diff --git a/.github/actions/flutter_build/action.yml b/.github/actions/flutter_build/action.yml new file mode 100644 index 0000000000000..81b28459493aa --- /dev/null +++ b/.github/actions/flutter_build/action.yml @@ -0,0 +1,97 @@ +name: Flutter Integration Test +description: Run integration tests for AppFlowy + +inputs: + os: + description: "The operating system to run the tests on" + required: true + flutter_version: + description: "The version of Flutter to use" + required: true + rust_toolchain: + description: "The version of Rust to use" + required: true + cargo_make_version: + description: "The version of cargo-make to use" + required: true + rust_target: + description: "The target to build for" + required: true + flutter_profile: + description: "The profile to build with" + required: true + +runs: + using: "composite" + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ inputs.rust_toolchain }} + target: ${{ inputs.rust_target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ inputs.flutter_version }} + cache: true + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ inputs.os }} + workspaces: | + frontend/rust-lib + cache-all-crates: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ inputs.cargo_make_version }}, duckscript_cli + + - name: Install prerequisites + working-directory: frontend + run: | + if [ "$RUNNER_OS" == "Linux" ]; then + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev + elif [ "$RUNNER_OS" == "Windows" ]; then + vcpkg integrate install + elif [ "$RUNNER_OS" == "macOS" ]; then + echo 'do nothing' + fi + cargo make appflowy-flutter-deps-tools + shell: bash + + - name: Build AppFlowy + working-directory: frontend + run: cargo make --profile ${{ inputs.flutter_profile }} appflowy-core-dev + shell: bash + + - name: Run code generation + working-directory: frontend + run: cargo make code_generation + shell: bash + + - name: Flutter Analyzer + working-directory: frontend/appflowy_flutter + run: flutter analyze . + shell: bash + + - name: Compress appflowy_flutter + run: tar -czf appflowy_flutter.tar.gz frontend/appflowy_flutter + shell: bash + + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.run_id }}-${{ matrix.os }} + path: appflowy_flutter.tar.gz \ No newline at end of file diff --git a/.github/actions/flutter_integration_test/action.yml b/.github/actions/flutter_integration_test/action.yml new file mode 100644 index 0000000000000..6df3ec005d7b4 --- /dev/null +++ b/.github/actions/flutter_integration_test/action.yml @@ -0,0 +1,78 @@ +name: Flutter Integration Test +description: Run integration tests for AppFlowy + +inputs: + test_path: + description: "The path to the integration test file" + required: true + flutter_version: + description: "The version of Flutter to use" + required: true + rust_toolchain: + description: "The version of Rust to use" + required: true + cargo_make_version: + description: "The version of cargo-make to use" + required: true + rust_target: + description: "The target to build for" + required: true + +runs: + using: "composite" + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ inputs.RUST_TOOLCHAIN }} + target: ${{ inputs.rust_target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ inputs.flutter_version }} + cache: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ inputs.cargo_make_version }} + + - name: Install prerequisites + working-directory: frontend + run: | + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager + shell: bash + + - name: Enable Flutter Desktop + run: | + flutter config --enable-linux-desktop + shell: bash + + - uses: actions/download-artifact@v4 + with: + name: ${{ github.run_id }}-ubuntu-latest + + - name: Uncompressed appflowy_flutter + run: tar -xf appflowy_flutter.tar.gz + shell: bash + + - name: Run Flutter integration tests + working-directory: frontend/appflowy_flutter + run: | + export DISPLAY=:99 + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + sudo apt-get install network-manager + flutter test ${{ inputs.test_path }} -d Linux --coverage + shell: bash \ No newline at end of file diff --git a/.github/workflows/android_ci.yaml b/.github/workflows/android_ci.yaml new file mode 100644 index 0000000000000..b65ceec60853f --- /dev/null +++ b/.github/workflows/android_ci.yaml @@ -0,0 +1,126 @@ +# name: Android CI + +# on: +# push: +# branches: +# - "main" +# paths: +# - ".github/workflows/mobile_ci.yaml" +# - "frontend/**" +# - "!frontend/appflowy_tauri/**" + +# pull_request: +# branches: +# - "main" +# paths: +# - ".github/workflows/mobile_ci.yaml" +# - "frontend/**" +# - "!frontend/appflowy_tauri/**" + +# env: +# CARGO_TERM_COLOR: always +# FLUTTER_VERSION: "3.19.0" +# RUST_TOOLCHAIN: "1.75" +# CARGO_MAKE_VERSION: "0.36.6" + +# concurrency: +# group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} +# cancel-in-progress: true + +# jobs: +# build: +# if: github.event.pull_request.draft != true +# strategy: +# fail-fast: true +# matrix: +# os: [macos-14] +# runs-on: ${{ matrix.os }} + +# steps: +# - name: Check storage space +# run: df -h + +# # the following step is required to avoid running out of space +# - name: Maximize build space +# if: matrix.os == 'ubuntu-latest' +# run: | +# sudo rm -rf /usr/share/dotnet +# sudo rm -rf /opt/ghc +# sudo rm -rf "/usr/local/share/boost" +# sudo rm -rf "$AGENT_TOOLSDIRECTORY" +# sudo docker image prune --all --force +# sudo rm -rf /opt/hostedtoolcache/codeQL +# sudo rm -rf ${GITHUB_WORKSPACE}/.git +# sudo rm -rf $ANDROID_HOME/ndk + +# - name: Check storage space +# run: df -h + +# - name: Checkout source code +# uses: actions/checkout@v4 + +# - uses: actions/setup-java@v4 +# with: +# distribution: temurin +# java-version: 11 + +# - name: Install Rust toolchain +# id: rust_toolchain +# uses: actions-rs/toolchain@v1 +# with: +# toolchain: ${{ env.RUST_TOOLCHAIN }} +# override: true +# profile: minimal + +# - name: Install flutter +# id: flutter +# uses: subosito/flutter-action@v2 +# with: +# channel: "stable" +# flutter-version: ${{ env.FLUTTER_VERSION }} + +# - uses: gradle/gradle-build-action@v3 +# with: +# gradle-version: 7.4.2 + +# - uses: davidB/rust-cargo-make@v1 +# with: +# version: "0.36.6" + +# - name: Install prerequisites +# working-directory: frontend +# run: | +# rustup target install aarch64-linux-android +# rustup target install x86_64-linux-android +# cargo install --force duckscript_cli +# cargo install cargo-ndk +# if [ "$RUNNER_OS" == "Linux" ]; then +# sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub +# sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list +# sudo apt-get update +# sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev +# sudo apt-get install keybinder-3.0 libnotify-dev +# sudo apt-get install gcc-multilib +# elif [ "$RUNNER_OS" == "Windows" ]; then +# vcpkg integrate install +# elif [ "$RUNNER_OS" == "macOS" ]; then +# echo 'do nothing' +# fi +# cargo make appflowy-flutter-deps-tools +# shell: bash + +# - name: Build AppFlowy +# working-directory: frontend +# run: | +# cargo make --profile development-android appflowy-android-dev-ci + + +# - name: Run integration tests +# # https://github.com/ReactiveCircus/android-emulator-runner +# uses: reactivecircus/android-emulator-runner@v2 +# with: +# api-level: 32 +# arch: arm64-v8a +# disk-size: 2048M +# working-directory: frontend/appflowy_flutter +# script: flutter test integration_test/runner.dart \ No newline at end of file diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 5c9b76ff9e43d..ffa1d309a5ebd 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -7,6 +7,7 @@ on: - "release/*" paths: - ".github/workflows/flutter_ci.yaml" + - ".github/actions/flutter_build/**" - "frontend/rust-lib/**" - "frontend/appflowy_flutter/**" - "frontend/resources/**" @@ -17,6 +18,7 @@ on: - "release/*" paths: - ".github/workflows/flutter_ci.yaml" + - ".github/actions/flutter_build/**" - "frontend/rust-lib/**" - "frontend/appflowy_flutter/**" - "frontend/resources/**" @@ -32,28 +34,21 @@ concurrency: cancel-in-progress: true jobs: - prepare: + prepare-linux: if: github.event.pull_request.draft != true strategy: - fail-fast: false + fail-fast: true matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 target: x86_64-unknown-linux-gnu - - os: macos-latest - flutter_profile: development-mac-x86_64 - target: x86_64-apple-darwin - - os: windows-latest - flutter_profile: development-windows-x86 - target: x86_64-pc-windows-msvc runs-on: ${{ matrix.os }} steps: # the following step is required to avoid running out of space - name: Maximize build space - if: matrix.os == 'ubuntu-latest' run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc @@ -63,72 +58,70 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 - - name: Install Rust toolchain - id: rust_toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - target: ${{ matrix.target }} - override: true - profile: minimal - - - name: Install flutter - id: flutter - uses: subosito/flutter-action@v2 + - name: Flutter build + uses: ./.github/actions/flutter_build with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} + os: ${{ matrix.os }} + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + flutter_profile: ${{ matrix.flutter_profile }} + + prepare-windows: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [windows-latest] + include: + - os: windows-latest + flutter_profile: development-windows-x86 + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.os }} - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: ${{ matrix.os }} - workspaces: | - frontend/rust-lib - cache-all-crates: true + steps: + - name: Checkout source code + uses: actions/checkout@v4 - - uses: taiki-e/install-action@v2 + - name: Flutter build + uses: ./.github/actions/flutter_build with: - tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}, duckscript_cli - - - name: Install prerequisites - working-directory: frontend - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev - elif [ "$RUNNER_OS" == "Windows" ]; then - vcpkg integrate install - elif [ "$RUNNER_OS" == "macOS" ]; then - echo 'do nothing' - fi - cargo make appflowy-flutter-deps-tools - shell: bash - - - name: Build AppFlowy - working-directory: frontend - run: cargo make --profile ${{ matrix.flutter_profile }} appflowy-core-dev - - - name: Run code generation - working-directory: frontend - run: cargo make code_generation - - - name: Flutter Analyzer - working-directory: frontend/appflowy_flutter - run: flutter analyze . + os: ${{ matrix.os }} + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + flutter_profile: ${{ matrix.flutter_profile }} + + prepare-macos: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [macos-latest] + include: + - os: macos-latest + flutter_profile: development-mac-x86_64 + target: x86_64-apple-darwin + runs-on: ${{ matrix.os }} - - name: Compress appflowy_flutter - run: | - tar -czf appflowy_flutter.tar.gz frontend/appflowy_flutter + steps: + - name: Checkout source code + uses: actions/checkout@v4 - - uses: actions/upload-artifact@v4 + - name: Flutter build + uses: ./.github/actions/flutter_build with: - name: ${{ github.run_id }}-${{ matrix.os }} - path: appflowy_flutter.tar.gz + os: ${{ matrix.os }} + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + flutter_profile: ${{ matrix.flutter_profile }} unit_test: - needs: [prepare] + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false @@ -159,6 +152,7 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true - uses: Swatinem/rust-cache@v2 with: @@ -218,7 +212,7 @@ jobs: shell: bash cloud_integration_test: - needs: [prepare] + needs: [prepare-linux] strategy: fail-fast: false matrix: @@ -262,6 +256,7 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true - uses: taiki-e/install-action@v2 with: @@ -304,8 +299,9 @@ jobs: flutter test integration_test/cloud/cloud_runner.dart -d Linux --coverage shell: bash - integration_test: - needs: [prepare] + # split the integration tests into different machines to minimize the time + integration_test_1: + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false @@ -313,156 +309,65 @@ jobs: os: [ubuntu-latest] include: - os: ubuntu-latest - flutter_profile: development-linux-x86_64 - target: x86_64-unknown-linux-gnu + target: 'x86_64-unknown-linux-gnu' runs-on: ${{ matrix.os }} - steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Install Rust toolchain - id: rust_toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - target: ${{ matrix.target }} - override: true - profile: minimal - - - name: Install flutter - id: flutter - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - - - uses: taiki-e/install-action@v2 + - name: Flutter Integration Test 1 + uses: ./.github/actions/flutter_integration_test with: - tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} - - - name: Install prerequisites - working-directory: frontend - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev - fi - shell: bash - - - name: Enable Flutter Desktop - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - flutter config --enable-linux-desktop - elif [ "$RUNNER_OS" == "macOS" ]; then - flutter config --enable-macos-desktop - elif [ "$RUNNER_OS" == "Windows" ]; then - git config --system core.longpaths true - flutter config --enable-windows-desktop - fi - shell: bash - - - uses: actions/download-artifact@v4 - with: - name: ${{ github.run_id }}-${{ matrix.os }} - - - name: Uncompressed appflowy_flutter - run: tar -xf appflowy_flutter.tar.gz - - - name: Run flutter pub get - working-directory: frontend - run: cargo make pub_get - - - name: Run Flutter integration tests - working-directory: frontend/appflowy_flutter - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - export DISPLAY=:99 - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & - sudo apt-get install network-manager - flutter test integration_test/runner.dart -d Linux --coverage - elif [ "$RUNNER_OS" == "macOS" ]; then - flutter test integration_test/runner.dart -d macOS --coverage - elif [ "$RUNNER_OS" == "Windows" ]; then - flutter test integration_test/runner.dart -d Windows --coverage - fi - shell: bash - build: - needs: [prepare] + test_path: integration_test/desktop_runner_1.dart + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + + integration_test_2: + needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] include: - os: ubuntu-latest - flutter_profile: development-linux-x86_64 - target: x86_64-unknown-linux-gnu - - os: macos-latest - flutter_profile: development-mac-x86_64 - target: x86_64-apple-darwin - - os: windows-latest - flutter_profile: development-windows-x86 - target: x86_64-pc-windows-msvc + target: 'x86_64-unknown-linux-gnu' runs-on: ${{ matrix.os }} - steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Install Rust toolchain - id: rust_toolchain - uses: actions-rs/toolchain@v1 + - name: Flutter Integration Test 2 + uses: ./.github/actions/flutter_integration_test with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - target: ${{ matrix.target }} - override: true - profile: minimal - - - name: Install flutter - id: flutter - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} - - - name: Install prerequisites - working-directory: frontend - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev - fi - shell: bash - - - name: Enable Flutter Desktop - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - flutter config --enable-linux-desktop - elif [ "$RUNNER_OS" == "macOS" ]; then - flutter config --enable-macos-desktop - elif [ "$RUNNER_OS" == "Windows" ]; then - git config --system core.longpaths true - flutter config --enable-windows-desktop - fi - shell: bash + test_path: integration_test/desktop_runner_2.dart + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + + integration_test_3: + needs: [prepare-linux] + if: github.event.pull_request.draft != true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + include: + - os: ubuntu-latest + target: 'x86_64-unknown-linux-gnu' + runs-on: ${{ matrix.os }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - name: Flutter Integration Test 3 + uses: ./.github/actions/flutter_integration_test with: - name: ${{ github.run_id }}-${{ matrix.os }} - - - name: Uncompressed appflowy_flutter - run: tar -xf appflowy_flutter.tar.gz - - - name: Build flutter product - working-directory: frontend - run: | - cargo make --profile ${{ matrix.flutter_profile }} appflowy-make-product-dev + test_path: integration_test/desktop_runner_3.dart + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} \ No newline at end of file diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml new file mode 100644 index 0000000000000..e679841a1b60c --- /dev/null +++ b/.github/workflows/ios_ci.yaml @@ -0,0 +1,91 @@ +name: iOS CI + +on: + push: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" + - "!frontend/appflowy_tauri/**" + + pull_request: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" + - "!frontend/appflowy_tauri/**" + +env: + FLUTTER_VERSION: "3.19.0" + RUST_TOOLCHAIN: "1.75" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [macos-14] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout source code + uses: actions/checkout@v2 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: aarch64-apple-ios-sim + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ matrix.os }} + workspaces: | + frontend/rust-lib + + - uses: davidB/rust-cargo-make@v1 + with: + version: "0.36.6" + + - name: Install prerequisites + working-directory: frontend + run: | + rustup target install aarch64-apple-ios-sim + cargo install --force duckscript_cli + cargo install cargo-lipo + cargo make appflowy-flutter-deps-tools + shell: bash + + - name: Build AppFlowy + working-directory: frontend + run: | + cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios + cargo make --profile development-ios-arm64-sim code_generation + + - uses: futureware-tech/simulator-action@v3 + id: simulator-action + with: + model: 'iPhone 15' + shutdown_after_job: false + + - name: Run integration tests + working-directory: frontend/appflowy_flutter + run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }} diff --git a/.github/workflows/mobile_ci.yaml b/.github/workflows/mobile_ci.yaml deleted file mode 100644 index 702b7a523a89e..0000000000000 --- a/.github/workflows/mobile_ci.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: Mobile-CI - -on: - push: - branches: - - "main" - paths: - - ".github/workflows/mobile_ci.yaml" - - "frontend/**" - - "!frontend/appflowy_tauri/**" - - pull_request: - branches: - - "main" - paths: - - ".github/workflows/mobile_ci.yaml" - - "frontend/**" - - "!frontend/appflowy_tauri/**" - -env: - FLUTTER_VERSION: "3.19.0" - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build: - if: github.event.pull_request.draft != true - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - include: - - os: ubuntu-latest - target: aarch64-linux-android - runs-on: ${{ matrix.os }} - - steps: - # the following step is required to avoid running out of space - - name: Maximize build space - if: matrix.os == 'ubuntu-latest' - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - sudo docker image prune --all --force - sudo rm -rf /opt/hostedtoolcache/codeQL - sudo rm -rf ${GITHUB_WORKSPACE}/.git - sudo rm -rf $ANDROID_HOME/ndk - - - name: Checkout source code - uses: actions/checkout@v4 - - - name: Install flutter - id: flutter - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true - - - uses: nttld/setup-ndk@v1 - id: setup-ndk - with: - ndk-version: "r24" - add-to-path: true - - - uses: gradle/gradle-build-action@v3 - with: - gradle-version: 7.6.3 - - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: ${{ matrix.os }} - workspaces: | - frontend/rust-lib - - - uses: davidB/rust-cargo-make@v1 - with: - version: "0.36.6" - - - name: Install prerequisites - working-directory: frontend - run: | - rustup target install aarch64-linux-android - rustup target install x86_64-linux-android - cargo install --force duckscript_cli - cargo install cargo-ndk - if [ "$RUNNER_OS" == "Linux" ]; then - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 libnotify-dev - sudo apt-get install gcc-multilib - elif [ "$RUNNER_OS" == "Windows" ]; then - vcpkg integrate install - elif [ "$RUNNER_OS" == "macOS" ]; then - echo 'do nothing' - fi - cargo make appflowy-flutter-deps-tools - shell: bash - - - name: Build AppFlowy - working-directory: frontend - env: - ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - run: | - cargo make --profile development-android appflowy-android-dev-ci diff --git a/.github/workflows/rust_ci.yaml b/.github/workflows/rust_ci.yaml index b4062980b5ea3..4b30b0043a488 100644 --- a/.github/workflows/rust_ci.yaml +++ b/.github/workflows/rust_ci.yaml @@ -25,6 +25,22 @@ jobs: test-on-ubuntu: runs-on: ubuntu-latest steps: +# - name: Maximize build space +# uses: easimon/maximize-build-space@master +# with: +# root-reserve-mb: 2048 +# swap-size-mb: 1024 +# remove-dotnet: 'true' +# +# # the following step is required to avoid running out of space +# - name: Maximize build space +# run: | +# sudo rm -rf /usr/share/dotnet +# sudo rm -rf /opt/ghc +# sudo rm -rf "/usr/local/share/boost" +# sudo rm -rf "$AGENT_TOOLSDIRECTORY" +# sudo docker image prune --all --force + - name: Checkout source code uses: actions/checkout@v4 @@ -75,7 +91,7 @@ jobs: RUST_LOG: info RUST_BACKTRACE: 1 af_cloud_test_base_url: http://localhost - af_cloud_test_ws_url: ws://localhost/ws + af_cloud_test_ws_url: ws://localhost/ws/v1 af_cloud_test_gotrue_url: http://localhost/gotrue run: cargo test --no-default-features --features="rev-sqlite,dart" -- --nocapture @@ -86,3 +102,8 @@ jobs: - name: clippy rust-lib run: cargo clippy --all-targets -- -D warnings working-directory: frontend/rust-lib + + - name: Clean up Docker images + run: | + docker image prune -af + docker volume prune -f diff --git a/.github/workflows/tauri_ci.yaml b/.github/workflows/tauri_ci.yaml index 7045e58aa1a8e..462bebb8ddd68 100644 --- a/.github/workflows/tauri_ci.yaml +++ b/.github/workflows/tauri_ci.yaml @@ -22,35 +22,36 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest] + platform: [ubuntu-20.04] runs-on: ${{ matrix.platform }} + + env: + CI: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Maximize build space (ubuntu only) + if: matrix.platform == 'ubuntu-20.04' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + - name: setup node uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - - name: Cache Rust Dependencies - uses: Swatinem/rust-cache@v2 + - name: setup pnpm + uses: pnpm/action-setup@v2 with: - key: rust-dependencies-${{ runner.os }} - workspaces: | - frontend/rust-lib - frontend/appflowy_tauri/src-tauri - - - name: Cache Node.js dependencies - uses: actions/cache@v2 - with: - path: ~/.npm - key: npm-${{ runner.os }} - - - name: Cache node_modules - uses: actions/cache@v2 - with: - path: frontend/appflowy_tauri/node_modules - key: node-modules-${{ runner.os }} + version: ${{ env.PNPM_VERSION }} - name: Install Rust toolchain id: rust_toolchain @@ -60,51 +61,54 @@ jobs: override: true profile: minimal + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./frontend/appflowy_tauri/src-tauri -> target" + + - name: Node_modules cache + uses: actions/cache@v2 + with: + path: frontend/appflowy_tauri/node_modules + key: node-modules-${{ runner.os }} + - name: install dependencies (windows only) if: matrix.platform == 'windows-latest' working-directory: frontend run: | - cargo install --force cargo-make cargo install --force duckscript_cli vcpkg integrate install - cargo make appflowy-tauri-deps-tools - npm install -g pnpm@${{ env.PNPM_VERSION }} - name: install dependencies (ubuntu only) - if: matrix.platform == 'ubuntu-latest' + if: matrix.platform == 'ubuntu-20.04' working-directory: frontend run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - cargo install --force cargo-make - cargo make appflowy-tauri-deps-tools - npm install -g pnpm@${{ env.PNPM_VERSION }} - - name: install dependencies (macOS only) - if: matrix.platform == 'macos-latest' + - name: install cargo-make working-directory: frontend run: | cargo install --force cargo-make cargo make appflowy-tauri-deps-tools - npm install -g pnpm@${{ env.PNPM_VERSION }} - - name: Build + - name: install frontend dependencies working-directory: frontend/appflowy_tauri run: | mkdir dist pnpm install cargo make --cwd .. tauri_build - pnpm test - pnpm test:errors - - name: Check for uncommitted changes + - name: frontend tests and linting + working-directory: frontend/appflowy_tauri run: | - diff_files=$(git status --porcelain) - if [ -n "$diff_files" ]; then - echo "There are uncommitted changes in the working tree. Please commit them before pushing." - exit 1 - fi + pnpm test + pnpm test:errors - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tauriScript: pnpm tauri + projectPath: frontend/appflowy_tauri + args: "--debug" \ No newline at end of file diff --git a/.github/workflows/tauri_release.yml b/.github/workflows/tauri_release.yml new file mode 100644 index 0000000000000..2e4be46dbea59 --- /dev/null +++ b/.github/workflows/tauri_release.yml @@ -0,0 +1,153 @@ +name: Publish Tauri Release + +on: + workflow_dispatch: + inputs: + branch: + description: 'The branch to release' + required: true + default: 'main' + version: + description: 'The version to release' + required: true + default: '0.0.0' +env: + NODE_VERSION: "18.16.0" + PNPM_VERSION: "8.5.0" + RUST_TOOLCHAIN: "1.75" + +jobs: + + publish-tauri: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + settings: + - platform: windows-latest + args: "--verbose" + target: "windows-x86_64" + - platform: macos-latest + args: "--target x86_64-apple-darwin" + target: "macos-x86_64" + - platform: ubuntu-20.04 + args: "--target x86_64-unknown-linux-gnu" + target: "linux-x86_64" + + runs-on: ${{ matrix.settings.platform }} + + env: + CI: true + PACKAGE_PREFIX: AppFlowy_Tauri-${{ github.event.inputs.version }}-${{ matrix.settings.target }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch }} + + - name: Maximize build space (ubuntu only) + if: matrix.settings.platform == 'ubuntu-20.04' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: setup pnpm + uses: pnpm/action-setup@v2 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + profile: minimal + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./frontend/appflowy_tauri/src-tauri -> target" + + - name: install dependencies (windows only) + if: matrix.settings.platform == 'windows-latest' + working-directory: frontend + run: | + cargo install --force duckscript_cli + vcpkg integrate install + + - name: install dependencies (ubuntu only) + if: matrix.settings.platform == 'ubuntu-20.04' + working-directory: frontend + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + + - name: install cargo-make + working-directory: frontend + run: | + cargo install --force cargo-make + cargo make appflowy-tauri-deps-tools + + - name: install frontend dependencies + working-directory: frontend/appflowy_tauri + run: | + mkdir dist + pnpm install + pnpm exec node scripts/update_version.cjs ${{ github.event.inputs.version }} + cargo make --cwd .. tauri_build + + - uses: tauri-apps/tauri-action@dev + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.MACOS_TEAM_ID }} + APPLE_ID: ${{ secrets.MACOS_NOTARY_USER }} + APPLE_TEAM_ID: ${{ secrets.MACOS_TEAM_ID }} + APPLE_PASSWORD: ${{ secrets.MACOS_NOTARY_PWD }} + CI: true + with: + args: ${{ matrix.settings.args }} + appVersion: ${{ github.event.inputs.version }} + tauriScript: pnpm tauri + projectPath: frontend/appflowy_tauri + + - name: Upload EXE package(windows only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'windows-latest' + with: + name: ${{ env.PACKAGE_PREFIX }}.exe + path: frontend/appflowy_tauri/src-tauri/target/release/bundle/nsis/AppFlowy_${{ github.event.inputs.version }}_x64-setup.exe + + - name: Upload DMG package(macos only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'macos-latest' + with: + name: ${{ env.PACKAGE_PREFIX }}.dmg + path: frontend/appflowy_tauri/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/AppFlowy_${{ github.event.inputs.version }}_x64.dmg + + - name: Upload Deb package(ubuntu only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'ubuntu-20.04' + with: + name: ${{ env.PACKAGE_PREFIX }}.deb + path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/app-flowy_${{ github.event.inputs.version }}_amd64.deb + + - name: Upload AppImage package(ubuntu only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'ubuntu-20.04' + with: + name: ${{ env.PACKAGE_PREFIX }}.AppImage + path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/app-flowy_${{ github.event.inputs.version }}_amd64.AppImage diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d52fe998e49..469186ea79ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,61 @@ # Release Notes +## Version 0.5.3 - 03/21/2024 +### New Features +- Added build support for 32-bit Android devices +- Introduced filters for KanBan boards for enhanced organization +- Introduced the new "Relations" column type in Grids +- Expanded language support with the addition of Greek +- Enhanced toolbar design for Mobile devices +- Introduced a command palette feature with initial support for page search +### Bug Fixes +- Rectified the issue of incomplete row data in Grids when adding new rows with active filters +- Enhanced the logic governing the filtering of number and select/multi-select fields for improved accuracy +- Implemented UI refinements on both Desktop and Mobile platforms, enriching the overall user experience of AppFlowy + +## Version 0.5.2 - 03/13/2024 +### Bug Fixes +- Import csv file. + +## Version 0.5.1 - 03/11/2024 +### New Features +- Introduced support for performing generic calculations on databases. +- Implemented functionality for easily duplicating calendar events. +- Added the ability to duplicate fields with cell data, facilitating smoother data management. +- Now supports customizing font styles and colors prior to typing. +- Enhanced the checklist user experience with the integration of keyboard shortcuts. +- Improved the dark mode experience on mobile devices. +### Bug Fixes +- Fixed an issue with some pages failing to sync properly. +- Fixed an issue where links without the http(s) scheme could not be opened, ensuring consistent link functionality. +- Fixed an issue that prevented numbers from being inserted before heading blocks. +- Fixed the inline page reference update mechanism to accurately reflect workspace changes. +- Fixed an issue that made it difficult to resize images in certain cases. +- Enhanced image loading reliability by clearing the image cache when images fail to load. +- Resolved a problem preventing the launching of URLs on some Linux distributions. + +## Version 0.5.0 - 02/26/2024 +### New Features +- Added support for scaling text on mobile platforms for better readability. +- Introduced a toggle for favorites directly from the documents' top bar. +- Optimized the image upload process and added error messaging for failed uploads. +- Implemented depth control for outline block components. +- New checklist task creation is now more intuitive, with prompts appearing on hover over list items in the row detail page. +- Enhanced sorting capabilities, allowing reordering and addition of multiple sorts. +- Expanded sorting and filtering options to include more field types like checklist, creation time, and modification time. +- Added support for field calculations within databases. +### Bug Fixes +- Fixed an issue where inserting an image from Unsplash in local mode was not possible. +- Fixed undo/redo functionality in lists. +- Fixed data loss issues when converting between block types. +- Fixed a bug where newly created rows were not being automatically sorted. +- Fixed issues related to deleting a sorting field or sort not removing existing sorts properly. +### Notes +- Windows 7, Windows 8, and iOS 11 are not yet supported due to the upgrade to Flutter 3.19.0. + ## Version 0.4.9 - 02/17/2024 ### Bug Fixes - Resolved the issue that caused users to be redirected to the Sign In page -- + ## Version 0.4.8 - 02/13/2024 ### Bug Fixes - Fixed a possible error when loading workspaces diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json index f759352c153fb..72d398e0fac56 100644 --- a/frontend/.vscode/launch.json +++ b/frontend/.vscode/launch.json @@ -12,7 +12,7 @@ "program": "./lib/main.dart", "type": "dart", "env": { - "RUST_LOG": "trace", + "RUST_LOG": "debug", "RUST_BACKTRACE": "1" }, // uncomment the following line to testing performance. @@ -115,9 +115,12 @@ }, { "name": "AF-desktop: Debug Rust", - "request": "attach", "type": "lldb", + "request": "attach", "pid": "${command:pickMyProcess}" + // To launch the application directly, use the following configuration: + // "request": "launch", + // "program": "[YOUR_APPLICATION_PATH]", }, { // https://tauri.app/v1/guides/debugging/vs-code diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json index 8bce9eb24b553..d940eef0a822d 100644 --- a/frontend/.vscode/tasks.json +++ b/frontend/.vscode/tasks.json @@ -257,7 +257,7 @@ "label": "AF: Tauri UI Dev", "type": "shell", "isBackground": true, - "command": "pnpm run tauri:dev", + "command": "pnpm sync:i18n && pnpm run dev", "options": { "cwd": "${workspaceFolder}/appflowy_tauri" } diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 0d79a16fa83ea..f4f95fe4eaa01 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.5.0" +APPFLOWY_VERSION = "0.5.3" FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" @@ -50,7 +50,7 @@ APP_ENVIRONMENT = "local" FLUTTER_FLOWY_SDK_PATH = "appflowy_flutter/packages/appflowy_backend" TAURI_BACKEND_SERVICE_PATH = "appflowy_tauri/src/services/backend" WEB_BACKEND_SERVICE_PATH = "appflowy_web/src/services/backend" -WEB_LIB_PATH= "appflowy_web/wasm-libs/af-wasm" +WEB_LIB_PATH = "appflowy_web/wasm-libs/af-wasm" # Test default config TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dylib" @@ -226,9 +226,8 @@ script = [''' echo FEATURES: ${FLUTTER_DESKTOP_FEATURES} echo PRODUCT_EXT: ${PRODUCT_EXT} echo APP_ENVIRONMENT: ${APP_ENVIRONMENT} - echo ${platforms} - echo ${BUILD_ARCHS} - echo ${BUILD_VERSION} + echo BUILD_ARCHS: ${BUILD_ARCHS} + echo BUILD_VERSION: ${BUILD_VERSION} '''] script_runner = "@shell" diff --git a/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt b/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt index 38b0aa5ca7917..455c5081b6b75 100644 --- a/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt +++ b/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt @@ -11,6 +11,12 @@ file(COPY DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/arm64-v8a ) +# armeabi-v7a +file(COPY + ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/armeabi-v7a/libc++_shared.so + DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/armeabi-v7a +) + # x86_64 file(COPY ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/x86_64/libc++_shared.so diff --git a/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h b/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h index 3fe1f39faaf71..77d9b96ec163d 100644 --- a/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h +++ b/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h @@ -13,6 +13,6 @@ int32_t set_stream_port(int64_t port); void link_me_please(void); -void backend_log(int64_t level, const char *data); +void rust_log(int64_t level, const char *data); void set_env(const char *data); diff --git a/frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md new file mode 100644 index 0000000000000..5998220774a1f --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md @@ -0,0 +1,11 @@ +# AppFlowy Test Markdown import with table + +# Table + +| S.No. | Column 2 | +| --- | --- | +| 1. | row 1 | +| 2. | row 2 | +| 3. | row 3 | +| 4. | row 4 | +| 5. | row 5 | \ No newline at end of file diff --git a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart index 4d72154053016..1e555b1667c0a 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: unused_import import 'dart:io'; +import 'dart:ui'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -14,11 +15,12 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; import 'package:integration_test/integration_test.dart'; -import '../util/dir.dart'; -import '../util/mock/mock_file_picker.dart'; -import '../util/util.dart'; +import 'package:path/path.dart' as p; + +import '../shared/dir.dart'; +import '../shared/mock/mock_file_picker.dart'; +import '../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart index 11a09b2b4787e..6abdb968a1884 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart @@ -11,10 +11,11 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_v import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; import 'package:integration_test/integration_test.dart'; -import '../util/mock/mock_file_picker.dart'; -import '../util/util.dart'; +import 'package:path/path.dart' as p; + +import '../shared/mock/mock_file_picker.dart'; +import '../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -47,13 +48,13 @@ void main() { tester.expectToSeeGoogleLoginButton(); }); - testWidgets('sign in as annoymous', (tester) async { + testWidgets('sign in as anonymous', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapSignInAsGuest(); - // should not see the sync setting page when sign in as annoymous + // should not see the sync setting page when sign in as anonymous await tester.openSettings(); await tester.openSettingsPage(SettingsPage.user); tester.expectToSeeGoogleLoginButton(); diff --git a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart index b0ad36755b767..c66cdd5cc156c 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/cloud_runner.dart @@ -1,8 +1,12 @@ -import 'empty_test.dart' as preset_af_cloud_env_test; +import 'anon_user_continue_test.dart' as anon_user_continue_test; import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test; +import 'empty_test.dart' as preset_af_cloud_env_test; // import 'document_sync_test.dart' as document_sync_test; import 'user_setting_sync_test.dart' as user_sync_test; -import 'anon_user_continue_test.dart' as anon_user_continue_test; +import 'workspace/change_name_and_icon_test.dart' + as change_workspace_name_and_icon_test; +import 'workspace/collaborative_workspace_test.dart' + as collaboration_workspace_test; Future main() async { preset_af_cloud_env_test.main(); @@ -14,4 +18,8 @@ Future main() async { user_sync_test.main(); anon_user_continue_test.main(); + + // workspace + collaboration_workspace_test.main(); + change_workspace_name_and_icon_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart b/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart index 5ad38e49e2761..e727e0bcecfbc 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/document_sync_test.dart @@ -16,9 +16,9 @@ import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:integration_test/integration_test.dart'; -import '../util/dir.dart'; -import '../util/mock/mock_file_picker.dart'; -import '../util/util.dart'; +import '../shared/dir.dart'; +import '../shared/mock/mock_file_picker.dart'; +import '../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/cloud/empty_test.dart b/frontend/appflowy_flutter/integration_test/cloud/empty_test.dart index 0ec336ae5120b..9f7d3ce9edaed 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/empty_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/empty_test.dart @@ -2,7 +2,7 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../shared/util.dart'; // This test is meaningless, just for preventing the CI from failing. void main() { diff --git a/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart index 2a3b0bd3f8b77..283e55ce4eacc 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/supabase_auth_test.dart @@ -6,7 +6,8 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_v import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; + +import '../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -35,11 +36,11 @@ void main() { tester.expectToSeeGoogleLoginButton(); }); - testWidgets('sign in as annoymous', (tester) async { + testWidgets('sign in as anonymous', (tester) async { await tester.initializeAppFlowy(cloudType: AuthenticatorType.supabase); await tester.tapSignInAsGuest(); - // should not see the sync setting page when sign in as annoymous + // should not see the sync setting page when sign in as anonymous await tester.openSettings(); await tester.openSettingsPage(SettingsPage.user); tester.expectToSeeGoogleLoginButton(); diff --git a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart index 8cb2386c66b79..17276698768a0 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart @@ -18,11 +18,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/dir.dart'; -import '../util/emoji.dart'; -import '../util/mock/mock_file_picker.dart'; -import '../util/util.dart'; +import '../shared/database_test_op.dart'; +import '../shared/dir.dart'; +import '../shared/emoji.dart'; +import '../shared/mock/mock_file_picker.dart'; +import '../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -40,19 +40,19 @@ void main() { await tester.openSettings(); await tester.openSettingsPage(SettingsPage.user); - final userAvatarFinder = find.descendant( - of: find.byType(SettingsUserView), - matching: find.byType(UserAvatar), - ); + // final userAvatarFinder = find.descendant( + // of: find.byType(SettingsUserView), + // matching: find.byType(UserAvatar), + // ); // Open icon picker dialog and select emoji - await tester.tap(userAvatarFinder); - await tester.pumpAndSettle(); - await tester.tapEmoji('😁'); - await tester.pumpAndSettle(); - final UserAvatar userAvatar = - tester.widget(userAvatarFinder) as UserAvatar; - expect(userAvatar.iconUrl, '😁'); + // await tester.tap(userAvatarFinder); + // await tester.pumpAndSettle(); + // await tester.tapEmoji('😁'); + // await tester.pumpAndSettle(); + // final UserAvatar userAvatar = + // tester.widget(userAvatarFinder) as UserAvatar; + // expect(userAvatar.iconUrl, '😁'); // enter user name final userNameFinder = find.descendant( @@ -81,12 +81,12 @@ void main() { await tester.openSettingsPage(SettingsPage.user); // verify icon - final userAvatarFinder = find.descendant( - of: find.byType(SettingsUserView), - matching: find.byType(UserAvatar), - ); - final UserAvatar userAvatar = tester.widget(userAvatarFinder) as UserAvatar; - expect(userAvatar.iconUrl, '😁'); + // final userAvatarFinder = find.descendant( + // of: find.byType(SettingsUserView), + // matching: find.byType(UserAvatar), + // ); + // final UserAvatar userAvatar = tester.widget(userAvatarFinder) as UserAvatar; + // expect(userAvatar.iconUrl, '😁'); // verify name final userNameFinder = find.descendant( diff --git a/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart new file mode 100644 index 0000000000000..5e0122c5ef23b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/cloud/workspace/change_name_and_icon_test.dart @@ -0,0 +1,81 @@ +// ignore_for_file: unused_import + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; +import '../../shared/workspace.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const icon = '😄'; + const name = 'AppFlowy'; + final email = '${uuid()}@appflowy.io'; + + testWidgets('change name and icon', (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, // use the same email to check the next test + ); + + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + var workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, ''); + + await tester.openWorkspaceMenu(); + await tester.changeWorkspaceIcon(icon); + await tester.changeWorkspaceName(name); + + workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, icon); + expect(find.findTextInFlowyText(name), findsOneWidget); + }); + + testWidgets('verify the result again after relaunching', (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, // use the same email to check the next test + ); + + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // check the result again + final workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, icon); + expect(workspaceIcon.workspace.name, name); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart new file mode 100644 index 0000000000000..31348b6485298 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart @@ -0,0 +1,115 @@ +// ignore_for_file: unused_import + +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/database_test_op.dart'; +import '../../shared/dir.dart'; +import '../../shared/emoji.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final email = '${uuid()}@appflowy.io'; + + group('collaborative workspace', () { + // combine the create and delete workspace test to reduce the time + testWidgets('create a new workspace, open it and then delete it', + (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + await tester.createCollaborativeWorkspace(name); + + // see the success message + var success = find.text(LocaleKeys.workspace_createSuccess.tr()); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + + // check the create result + await tester.openCollaborativeWorkspaceMenu(); + var items = find.byType(WorkspaceMenuItem); + expect(items, findsNWidgets(2)); + expect( + tester.widget(items.last).workspace.name, + name, + ); + + // open the newly created workspace + await tester.tapButton(items.last); + success = find.text(LocaleKeys.workspace_openSuccess.tr()); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + + await tester.closeCollaborativeWorkspaceMenu(); + + // delete the newly created workspace + await tester.openCollaborativeWorkspaceMenu(); + final secondWorkspace = find.byType(WorkspaceMenuItem).last; + await tester.hoverOnWidget( + secondWorkspace, + onHover: () async { + // click the more button + final moreButton = find.byType(WorkspaceMoreActionList); + expect(moreButton, findsOneWidget); + await tester.tapButton(moreButton); + // click the delete button + final deleteButton = find.text(LocaleKeys.button_delete.tr()); + expect(deleteButton, findsOneWidget); + await tester.tapButton(deleteButton); + // see the delete confirm dialog + final confirm = + find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr()); + expect(confirm, findsOneWidget); + await tester.tapButton(find.text(LocaleKeys.button_ok.tr())); + // delete success + success = find.text(LocaleKeys.workspace_createSuccess.tr()); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + }, + ); + + // check the result + await tester.openCollaborativeWorkspaceMenu(); + items = find.byType(WorkspaceMenuItem); + expect(items, findsOneWidget); + expect( + tester.widget(items.last).workspace.name != name, + true, + ); + await tester.closeCollaborativeWorkspaceMenu(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/board/board_add_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/board/board_add_row_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart index e6e098f0009af..4159f679ad87f 100644 --- a/frontend/appflowy_flutter/integration_test/board/board_add_row_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; const defaultFirstCardName = 'Card 1'; const defaultLastCardName = 'Card 3'; diff --git a/frontend/appflowy_flutter/integration_test/board/board_group_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/board/board_group_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart index 1ee1778e9c8e8..314da581d9243 100644 --- a/frontend/appflowy_flutter/integration_test/board/board_group_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart @@ -6,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:appflowy_board/appflowy_board.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/board/board_hide_groups_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/board/board_hide_groups_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart index 0271a2b5594cb..27c8efb511bae 100644 --- a/frontend/appflowy_flutter/integration_test/board/board_hide_groups_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart @@ -7,8 +7,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/board/board_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/board/board_row_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart index fae4e53482b45..3916730b2f00d 100644 --- a/frontend/appflowy_flutter/integration_test/board/board_row_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart @@ -7,8 +7,8 @@ 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'; +import '../../shared/util.dart'; +import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/board/board_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/board/board_test_runner.dart rename to frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart diff --git a/frontend/appflowy_flutter/integration_test/database/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart similarity index 84% rename from frontend/appflowy_flutter/integration_test/database/database_calendar_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart index f8829837126cb..d5916c627b4e8 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart @@ -1,10 +1,12 @@ +import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -80,7 +82,7 @@ void main() { // Hover over today's calendar cell await tester.hoverOnTodayCalendarCell( // Tap on create new event button - onHover: () async => tester.tapAddCalendarEventButton(), + onHover: tester.tapAddCalendarEventButton, ); // Make sure that the event editor popup is shown @@ -147,6 +149,49 @@ void main() { tester.assertNumberOfEventsOnSpecificDay(1, DateTime.now()); }); + testWidgets('create and duplicate calendar event', (tester) async { + const customTitle = "EventTitleCustom"; + + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // Create the calendar view + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Calendar, + ); + + // Scroll until today's date cell is visible + await tester.scrollToToday(); + + // Hover over today's calendar cell + await tester.hoverOnTodayCalendarCell( + // Tap on create new event button + onHover: () async => tester.tapAddCalendarEventButton(), + ); + + // Make sure that the event editor popup is shown + tester.assertEventEditorOpen(); + + tester.assertNumberOfEventsInCalendar(1); + + // Change the title of the event + await tester.editEventTitle(customTitle); + + // Duplicate event + final duplicateBtnFinder = find + .descendant( + of: find.byType(CalendarEventEditor), + matching: find.byType( + FlowyIconButton, + ), + ) + .first; + await tester.tap(duplicateBtnFinder); + await tester.pumpAndSettle(); + + tester.assertNumberOfEventsInCalendar(2, title: customTitle); + }); + testWidgets('rescheduling events', (tester) async { await tester.initializeAppFlowy(); await tester.tapGoButton(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_cell_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/database/database_cell_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart index d98e063bccd9e..1ce8a8a3fab30 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_cell_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart @@ -4,8 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:intl/intl.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_field_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart similarity index 96% rename from frontend/appflowy_flutter/integration_test/database/database_field_settings_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart index 1dc39838a5120..a7b92e7a0ec03 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_field_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart @@ -4,8 +4,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_field_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/database/database_field_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart index 5a41bbd9d5421..30f79e1ac8895 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_field_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart @@ -8,8 +8,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:intl/intl.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -346,11 +346,14 @@ void main() { await tester.tapNewPropertyButton(); await tester.renameField(FieldType.LastEditedTime.i18n); await tester.tapSwitchFieldTypeButton(); - await tester.selectFieldType(FieldType.LastEditedTime); - await tester.dismissFieldEditor(); + // get time just before modifying final modified = DateTime.now(); + // create a last modified field (cont'd) + await tester.selectFieldType(FieldType.LastEditedTime); + await tester.dismissFieldEditor(); + tester.assertCellContent( rowIndex: 0, fieldType: FieldType.CreatedTime, diff --git a/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/database/database_filter_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart index a7f5726842b17..b6db3e1a62f56 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_filter_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart @@ -4,7 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum. import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; +import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -103,8 +103,8 @@ void main() { // select the option 's4' await tester.tapOptionFilterWithName('s4'); - // The row with 's4' or 's5' should be shown. - await tester.assertNumberOfRowsInGridPage(2); + // The row with 's4' should be shown. + await tester.assertNumberOfRowsInGridPage(1); await tester.pumpAndSettle(); }); diff --git a/frontend/appflowy_flutter/integration_test/database/database_reminder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/database/database_reminder_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart index 33b6eb67c7d15..35ff31d30f864 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_reminder_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart @@ -5,8 +5,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_row_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/database/database_row_page_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart index 6bc5da5c5f203..23dabda97c681 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_row_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart @@ -7,9 +7,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/emoji.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/emoji.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_test.dart similarity index 84% rename from frontend/appflowy_flutter/integration_test/database/database_row_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_row_test.dart index 61d90e72a9524..1982ca6e225ac 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_row_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_test.dart @@ -2,8 +2,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -16,7 +16,7 @@ void main() { await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.tapCreateRowButtonInGrid(); - // The initial number of rows is 3 + // 3 initial rows + 1 created await tester.assertNumberOfRowsInGridPage(4); await tester.pumpAndSettle(); }); @@ -31,9 +31,8 @@ void main() { await tester.tapCreateRowButtonInRowMenuOfGrid(); - // The initial number of rows is 3 + // 3 initial rows + 1 created await tester.assertNumberOfRowsInGridPage(4); - await tester.assertRowCountInGridPage(4); await tester.pumpAndSettle(); }); @@ -48,9 +47,8 @@ void main() { await tester.tapRowMenuButtonInGrid(); await tester.tapDeleteOnRowMenu(); - // The initial number of rows is 3 + // 3 initial rows - 1 deleted await tester.assertNumberOfRowsInGridPage(2); - await tester.assertRowCountInGridPage(2); await tester.pumpAndSettle(); }); @@ -60,7 +58,6 @@ void main() { await tester.tapGoButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); - await tester.assertRowCountInGridPage(3); await tester.pumpAndSettle(); }); diff --git a/frontend/appflowy_flutter/integration_test/database/database_setting_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart similarity index 95% rename from frontend/appflowy_flutter/integration_test/database/database_setting_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart index caa5791e4c5ba..0830a960c75e3 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_setting_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart @@ -3,8 +3,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_share_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/database/database_share_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart index 51ede619303d9..bd3adff7cca68 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_share_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart @@ -2,7 +2,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum. import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; +import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_sort_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/database/database_sort_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart index 6b4d53d5bc783..e28072cebc090 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_sort_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart @@ -2,7 +2,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum. import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; +import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/database/database_view_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/database/database_view_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart index 1a2e1017635dc..e107f608f06a0 100644 --- a/frontend/appflowy_flutter/integration_test/database/database_view_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart @@ -3,8 +3,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_alignment_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/document/document_alignment_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart index 2bf621dda08ca..99cc3e7b3985d 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_alignment_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart @@ -5,8 +5,8 @@ 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'; +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_codeblock_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_codeblock_paste_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart index 3ebe0069611d8..cd6d960ee032a 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_codeblock_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart @@ -7,7 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart index aab5f41b5b813..6b4512c3483ae 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -8,7 +8,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart index d0939d955760e..65a5c50fe290c 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_create_and_delete_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart @@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_inline_page_reference_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_inline_page_reference_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart index 7104e7e7bc4f4..4c3937ce547c4 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_inline_page_reference_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart @@ -1,14 +1,13 @@ -import 'package:flutter/services.dart'; - import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.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'; +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_option_action_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_option_action_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart index da09508936b4a..6912ffff85e4c 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_option_action_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/document/document_test_runner.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart diff --git a/frontend/appflowy_flutter/integration_test/document/document_text_direction_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart similarity index 84% rename from frontend/appflowy_flutter/integration_test/document/document_text_direction_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart index 3ab2bd2f6e49f..d7e710c52fc5d 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_text_direction_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart @@ -1,16 +1,15 @@ -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('text direction', () { testWidgets( - '''no text direction items will be displayed in the default/LTR mode,and three text direction items will be displayed in the RTL mode.''', + '''no text direction items will be displayed in the default/LTR mode, and three text direction items will be displayed when toggle is enabled.''', (tester) async { // combine the two tests into one to avoid the time-consuming process of initializing the app await tester.initializeAppFlowy(); @@ -32,7 +31,7 @@ void main() { 'toolbar/text_direction_ltr', 'toolbar/text_direction_rtl', ]; - // no text direction items in default/LTR mode + // no text direction items by default var button = find.byWidgetPredicate( (widget) => widget is SVGIconItemWidget && @@ -41,7 +40,7 @@ void main() { expect(button, findsNothing); // switch to the RTL mode - await tester.switchLayoutDirectionMode(LayoutDirection.rtlLayout); + await tester.toggleEnableRTLToolbarItems(); await tester.editor.tapLineOfEditorAt(0); await tester.editor.updateSelection(selection); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart index 5c8f4cf184dd4..7d7d437418a95 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart @@ -7,8 +7,8 @@ import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/emoji.dart'; -import '../util/util.dart'; +import '../../shared/emoji.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart index 6f4fe79d4c312..593977b159812 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; -import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -12,7 +12,7 @@ import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart index f3dbf3948e26b..ca93ac1677e56 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart @@ -22,8 +22,8 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:run_with_network_images/run_with_network_images.dart'; -import '../util/mock/mock_file_picker.dart'; -import '../util/util.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart index c1a7fef0631ab..a1d1d0e3357ab 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test_1.png b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_1.png similarity index 100% rename from frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test_1.png rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_1.png diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test_2.png b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_2.png similarity index 100% rename from frontend/appflowy_flutter/integration_test/document/document_with_inline_math_equation_test_2.png rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_2.png diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart index 414ffa250d27d..0078c22b62ec3 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart @@ -5,7 +5,7 @@ import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_link_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document/document_with_link_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart index 400d1306669f9..06739775675d4 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_link_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart index f45eafbad6c02..2264067f1c21b 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart @@ -7,7 +7,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; const String heading1 = "Heading 1"; const String heading2 = "Heading 2"; diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart index e6ce6baff11f5..786b02ded0b90 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart @@ -7,7 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document/edit_document_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart index 84579b1ea7cf8..3d2d039e920dc 100644 --- a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart @@ -5,7 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.png b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.png similarity index 100% rename from frontend/appflowy_flutter/integration_test/document/edit_document_test.png rename to frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.png diff --git a/frontend/appflowy_flutter/integration_test/grid/grid_calculations_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/grid/grid_calculations_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart index c49d399c62b98..ad9cffd304201 100644 --- a/frontend/appflowy_flutter/integration_test/grid/grid_calculations_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart @@ -4,8 +4,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/database_test_op.dart'; -import '../util/util.dart'; +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart similarity index 96% rename from frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart index 85c3d68265625..2fc87088907b7 100644 --- a/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart @@ -1,5 +1,3 @@ -import 'package:flutter/services.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; @@ -11,14 +9,15 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/services.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/editor_test_operations.dart'; -import '../util/expectation.dart'; -import '../util/keyboard.dart'; +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/editor_test_operations.dart'; +import '../../shared/expectation.dart'; +import '../../shared/keyboard.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/settings/notifications_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/settings/notifications_settings_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart index ab7458b2501f3..039b250b66202 100644 --- a/frontend/appflowy_flutter/integration_test/settings/notifications_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/settings/settings_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/settings/settings_runner.dart rename to frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart diff --git a/frontend/appflowy_flutter/integration_test/settings/user_language_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/settings/user_language_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart index 32be781749ba3..9904d92e9e20f 100644 --- a/frontend/appflowy_flutter/integration_test/settings/user_language_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart @@ -1,10 +1,11 @@ import 'dart:ui'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; -import 'package:integration_test/integration_test.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../util/util.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/sidebar/rename_current_item_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart similarity index 93% rename from frontend/appflowy_flutter/integration_test/sidebar/rename_current_item_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart index 0740b76aa7bed..4ed430f77fa25 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/rename_current_item_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart @@ -6,9 +6,8 @@ import 'package:flutter/services.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/keyboard.dart'; +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart similarity index 70% rename from frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart index 76d183d62562f..9ff563604daef 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_expand_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart @@ -1,22 +1,22 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('sidebar expand test', () { bool isExpanded({required FolderCategoryType type}) { - if (type == FolderCategoryType.personal) { + if (type == FolderCategoryType.private) { return find .descendant( - of: find.byType(PersonalFolder), + of: find.byType(PrivateSectionFolder), matching: find.byType(ViewItem), ) .evaluate() @@ -30,19 +30,19 @@ void main() { await tester.tapGoButton(); // first time is expanded - expect(isExpanded(type: FolderCategoryType.personal), true); + expect(isExpanded(type: FolderCategoryType.private), true); // collapse the personal folder await tester.tapButton( - find.byTooltip(LocaleKeys.sideBar_clickToHidePersonal.tr()), + find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), ); - expect(isExpanded(type: FolderCategoryType.personal), false); + expect(isExpanded(type: FolderCategoryType.private), false); // expand the personal folder await tester.tapButton( - find.byTooltip(LocaleKeys.sideBar_clickToHidePersonal.tr()), + find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), ); - expect(isExpanded(type: FolderCategoryType.personal), true); + expect(isExpanded(type: FolderCategoryType.private), true); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart index 81bee64689d3a..9ccd06d5260b7 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_favorites_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart @@ -1,14 +1,14 @@ import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/hover.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'; +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/expectation.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart similarity index 93% rename from frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart index a2a641ec8c3e5..36690b1a091bf 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart @@ -2,9 +2,9 @@ import 'package:appflowy_backend/protobuf/flowy-folder/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'; +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/expectation.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart index 1da433559c107..eb6273602299c 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart @@ -12,7 +12,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import '../util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart similarity index 81% rename from frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart rename to frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart index bf199036a83e2..35bcf599ab735 100644 --- a/frontend/appflowy_flutter/integration_test/sidebar/sidebar_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart @@ -1,6 +1,5 @@ import 'package:integration_test/integration_test.dart'; -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; @@ -10,7 +9,7 @@ void startTesting() { // Sidebar integration tests sidebar_test.main(); - sidebar_expanded_test.main(); + // sidebar_expanded_test.main(); sidebar_favorite_test.main(); sidebar_icon_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/appearance_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/appearance_settings_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart index 4d01a07ab9027..60aed323d0461 100644 --- a/frontend/appflowy_flutter/integration_test/appearance_settings_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/board_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/board_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart index a5d329515dfe5..5e88b386979c3 100644 --- a/frontend/appflowy_flutter/integration_test/board_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart @@ -1,7 +1,8 @@ import 'package:appflowy_board/appflowy_board.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; + +import '../../shared/util.dart'; /// Integration tests for an empty board. The [TestWorkspaceService] will load /// a workspace from an empty board `assets/test/workspaces/board.zip` for all diff --git a/frontend/appflowy_flutter/integration_test/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart similarity index 94% rename from frontend/appflowy_flutter/integration_test/emoji_shortcut_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart index 4bc15dd2140ed..aaf6a69adb55c 100644 --- a/frontend/appflowy_flutter/integration_test/emoji_shortcut_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart'; @@ -6,8 +7,8 @@ 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'; +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -17,7 +18,7 @@ void main() { (tester) async { await tester.initializeAppFlowy(); await tester.tapGoButton(); - + final Finder editor = find.byType(AppFlowyEditor); await tester.tap(editor); await tester.pumpAndSettle(); diff --git a/frontend/appflowy_flutter/integration_test/empty_document_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/empty_document_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart index 273e7bb73a141..6712e6959dd37 100644 --- a/frontend/appflowy_flutter/integration_test/empty_document_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart @@ -3,8 +3,9 @@ import 'package:appflowy_editor/appflowy_editor.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'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; /// Integration tests for an empty document. The [TestWorkspaceService] will load a workspace from an empty document `assets/test/workspaces/empty_document.zip` for all tests. /// diff --git a/frontend/appflowy_flutter/integration_test/empty_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_test.dart similarity index 92% rename from frontend/appflowy_flutter/integration_test/empty_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_test.dart index 0b66c412134be..86d44add0e6ab 100644 --- a/frontend/appflowy_flutter/integration_test/empty_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; +import '../../shared/util.dart'; // This test is meaningless, just for preventing the CI from failing. void main() { diff --git a/frontend/appflowy_flutter/integration_test/hotkeys_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/hotkeys_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart index cf55e183cac61..cfe1e80ce84f3 100644 --- a/frontend/appflowy_flutter/integration_test/hotkeys_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; @@ -8,8 +9,8 @@ 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'; +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart new file mode 100644 index 0000000000000..8f356b84065c0 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('import files', () { + testWidgets('import multiple markdown files', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // expect to see a getting started page + tester.expectToSeePageName(gettingStarted); + + await tester.tapAddViewButton(); + await tester.tapImportButton(); + + final testFileNames = ['test1.md', 'test2.md']; + final paths = []; + for (final fileName in testFileNames) { + final str = await rootBundle.loadString( + 'assets/test/workspaces/markdowns/$fileName', + ); + final path = p.join(context.applicationDataDirectory, fileName); + paths.add(path); + File(path).writeAsStringSync(str); + } + // mock get files + mockPickFilePaths( + paths: testFileNames + .map((e) => p.join(context.applicationDataDirectory, e)) + .toList(), + ); + + await tester.tapTextAndMarkdownButton(); + + tester.expectToSeePageName('test1'); + tester.expectToSeePageName('test2'); + }); + + testWidgets('import markdown file with table', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // expect to see a getting started page + tester.expectToSeePageName(gettingStarted); + + await tester.tapAddViewButton(); + await tester.tapImportButton(); + + const testFileName = 'markdown_with_table.md'; + final paths = []; + final str = await rootBundle.loadString( + 'assets/test/workspaces/markdowns/$testFileName', + ); + final path = p.join(context.applicationDataDirectory, testFileName); + paths.add(path); + File(path).writeAsStringSync(str); + // mock get files + mockPickFilePaths( + paths: paths, + ); + + await tester.tapTextAndMarkdownButton(); + + tester.expectToSeePageName('markdown_with_table'); + + // expect to see all content of markdown file along with table + await tester.openPage('markdown_with_table'); + + final importedPageEditorState = tester.editor.getCurrentEditorState(); + expect(importedPageEditorState.getNodeAtPath([0])!.type, + HeadingBlockKeys.type,); + expect(importedPageEditorState.getNodeAtPath([2])!.type, + HeadingBlockKeys.type,); + expect(importedPageEditorState.getNodeAtPath([4])!.type, + TableBlockKeys.type,); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/language_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/language_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart index 94aeef0e9d450..c48fcd8028842 100644 --- a/frontend/appflowy_flutter/integration_test/language_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart @@ -2,7 +2,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/settings_langua import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/open_ai_smart_menu_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/open_ai_smart_menu_test.dart index 969f9ec60bcfe..f739820d04d7b 100644 --- a/frontend/appflowy_flutter/integration_test/open_ai_smart_menu_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/open_ai_smart_menu_test.dart @@ -7,8 +7,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/mock/mock_openai_repository.dart'; -import 'util/util.dart'; +import '../../shared/mock/mock_openai_repository.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/share_markdown_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/share_markdown_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart index 689ec4d0ed949..7ba09445a9963 100644 --- a/frontend/appflowy_flutter/integration_test/share_markdown_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart @@ -5,8 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; -import 'util/mock/mock_file_picker.dart'; -import 'util/util.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/switch_folder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart similarity index 97% rename from frontend/appflowy_flutter/integration_test/switch_folder_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart index 9cd9c9a374a46..e2a343d4f1dc3 100644 --- a/frontend/appflowy_flutter/integration_test/switch_folder_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart @@ -7,8 +7,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; -import 'util/mock/mock_file_picker.dart'; -import 'util/util.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/tabs_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart similarity index 93% rename from frontend/appflowy_flutter/integration_test/tabs_test.dart rename to frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart index 4c3d2fed16a54..87a28dc9236d0 100644 --- a/frontend/appflowy_flutter/integration_test/tabs_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart @@ -8,10 +8,10 @@ import 'package:flutter/services.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'; -import 'util/keyboard.dart'; +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/expectation.dart'; +import '../../shared/keyboard.dart'; const _documentName = 'First Doc'; const _documentTwoName = 'Second Doc'; diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart new file mode 100644 index 0000000000000..e972f49fbbc73 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart @@ -0,0 +1,21 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/document/document_test_runner.dart' as document_test_runner; +import 'desktop/uncategorized/empty_test.dart' as first_test; +import 'desktop/uncategorized/switch_folder_test.dart' as switch_folder_test; + +Future main() async { + await runIntegration1OnDesktop(); +} + +Future runIntegration1OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // This test must be run first, otherwise the CI will fail. + first_test.main(); + + switch_folder_test.main(); + document_test_runner.startTesting(); + + // DON'T add more tests here. This is the first test runner for desktop. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart new file mode 100644 index 0000000000000..9053da8d186c6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart @@ -0,0 +1,40 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/database/database_calendar_test.dart' as database_calendar_test; +import 'desktop/database/database_cell_test.dart' as database_cell_test; +import 'desktop/database/database_field_settings_test.dart' + as database_field_settings_test; +import 'desktop/database/database_field_test.dart' as database_field_test; +import 'desktop/database/database_filter_test.dart' as database_filter_test; +import 'desktop/database/database_row_page_test.dart' as database_row_page_test; +import 'desktop/database/database_row_test.dart' as database_row_test; +import 'desktop/database/database_setting_test.dart' as database_setting_test; +import 'desktop/database/database_share_test.dart' as database_share_test; +import 'desktop/database/database_sort_test.dart' as database_sort_test; +import 'desktop/database/database_view_test.dart' as database_view_test; +import 'desktop/uncategorized/empty_test.dart' as first_test; + +Future main() async { + await runIntegration2OnDesktop(); +} + +Future runIntegration2OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // This test must be run first, otherwise the CI will fail. + first_test.main(); + + database_cell_test.main(); + database_field_test.main(); + database_field_settings_test.main(); + database_share_test.main(); + database_row_page_test.main(); + database_row_test.main(); + database_setting_test.main(); + database_filter_test.main(); + database_sort_test.main(); + database_view_test.main(); + database_calendar_test.main(); + + // DON'T add more tests here. This is the second test runner for desktop. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart new file mode 100644 index 0000000000000..09a784d4fceff --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart @@ -0,0 +1,36 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/board/board_test_runner.dart' as board_test_runner; +import 'desktop/settings/settings_runner.dart' as settings_test_runner; +import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner; +import 'desktop/uncategorized/appearance_settings_test.dart' + as appearance_test_runner; +import 'desktop/uncategorized/emoji_shortcut_test.dart' as emoji_shortcut_test; +import 'desktop/uncategorized/empty_test.dart' as first_test; +import 'desktop/uncategorized/hotkeys_test.dart' as hotkeys_test; +import 'desktop/uncategorized/import_files_test.dart' as import_files_test; +import 'desktop/uncategorized/share_markdown_test.dart' as share_markdown_test; +import 'desktop/uncategorized/tabs_test.dart' as tabs_test; + +Future main() async { + await runIntegration3OnDesktop(); +} + +Future runIntegration3OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // This test must be run first, otherwise the CI will fail. + first_test.main(); + + hotkeys_test.main(); + emoji_shortcut_test.main(); + hotkeys_test.main(); + emoji_shortcut_test.main(); + appearance_test_runner.main(); + settings_test_runner.main(); + share_markdown_test.main(); + import_files_test.main(); + sidebar_test_runner.startTesting(); + board_test_runner.startTesting(); + tabs_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/import_files_test.dart b/frontend/appflowy_flutter/integration_test/import_files_test.dart deleted file mode 100644 index 8fe3113354106..0000000000000 --- a/frontend/appflowy_flutter/integration_test/import_files_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'util/mock/mock_file_picker.dart'; -import 'util/util.dart'; -import 'package:path/path.dart' as p; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('import files', () { - testWidgets('import multiple markdown files', (tester) async { - final context = await tester.initializeAppFlowy(); - await tester.tapGoButton(); - - // expect to see a getting started page - tester.expectToSeePageName(gettingStarted); - - await tester.tapAddViewButton(); - await tester.tapImportButton(); - - final testFileNames = ['test1.md', 'test2.md']; - final paths = []; - for (final fileName in testFileNames) { - final str = await rootBundle.loadString( - 'assets/test/workspaces/markdowns/$fileName', - ); - final path = p.join(context.applicationDataDirectory, fileName); - paths.add(path); - File(path).writeAsStringSync(str); - } - // mock get files - mockPickFilePaths( - paths: testFileNames - .map((e) => p.join(context.applicationDataDirectory, e)) - .toList(), - ); - - await tester.tapTextAndMarkdownButton(); - - tester.expectToSeePageName('test1'); - tester.expectToSeePageName('test2'); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart b/frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart new file mode 100644 index 0000000000000..f90b151372d30 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: unused_import + +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/home.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/dir.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('anonymous sign in on mobile', () { + testWidgets('anon user and then sign in', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.local, + ); + + // click the anonymousSignInButton + final anonymousSignInButton = find.byType(SignInAnonymousButton); + expect(anonymousSignInButton, findsOneWidget); + await tester.tapButton(anonymousSignInButton); + + // expect to see the home page + expect(find.byType(MobileHomeScreen), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile_runner.dart b/frontend/appflowy_flutter/integration_test/mobile_runner.dart new file mode 100644 index 0000000000000..ca1a7ae0d3ec4 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile_runner.dart @@ -0,0 +1,8 @@ +import 'package:integration_test/integration_test.dart'; + +import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test; + +Future runIntegrationOnMobile() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + anonymous_sign_in_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 51abc6de7be1c..cb7d2d6e331fb 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -1,30 +1,9 @@ -import 'package:integration_test/integration_test.dart'; +import 'dart:io'; -import 'appearance_settings_test.dart' as appearance_test_runner; -import 'board/board_test_runner.dart' as board_test_runner; -import 'database/database_calendar_test.dart' as database_calendar_test; -import 'database/database_cell_test.dart' as database_cell_test; -import 'database/database_field_settings_test.dart' - as database_field_settings_test; -import 'database/database_field_test.dart' as database_field_test; -import 'database/database_filter_test.dart' as database_filter_test; -import 'database/database_row_page_test.dart' as database_row_page_test; -import 'database/database_row_test.dart' as database_row_test; -import 'database/database_setting_test.dart' as database_setting_test; -import 'database/database_share_test.dart' as database_share_test; -import 'database/database_sort_test.dart' as database_sort_test; -import 'database/database_view_test.dart' as database_view_test; -import 'document/document_test_runner.dart' as document_test_runner; -import 'empty_test.dart' as first_test; -import 'hotkeys_test.dart' as hotkeys_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 'tabs_test.dart' as tabs_test; -import 'emoji_shortcut_test.dart' as emoji_shortcut_test; -// import 'auth/supabase_auth_test.dart' as supabase_auth_test_runner; +import 'desktop_runner_1.dart'; +import 'desktop_runner_2.dart'; +import 'desktop_runner_3.dart'; +import 'mobile_runner.dart'; /// The main task runner for all integration tests in AppFlowy. /// @@ -34,51 +13,13 @@ import 'emoji_shortcut_test.dart' as emoji_shortcut_test; /// Once removed, the integration_test.yaml must be updated to exclude this as /// as the test target. Future main() async { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - // This test must be run first, otherwise the CI will fail. - first_test.main(); - - switch_folder_test.main(); - share_markdown_test.main(); - import_files_test.main(); - - // Document integration tests - document_test_runner.startTesting(); - - // Sidebar integration tests - sidebar_test_runner.startTesting(); - - // Board integration test - board_test_runner.startTesting(); - - // Database integration tests - database_cell_test.main(); - database_field_test.main(); - database_field_settings_test.main(); - database_share_test.main(); - database_row_page_test.main(); - database_row_test.main(); - database_setting_test.main(); - database_filter_test.main(); - database_sort_test.main(); - database_view_test.main(); - database_calendar_test.main(); - - // Tabs - tabs_test.main(); - - // Others - hotkeys_test.main(); - emoji_shortcut_test.main(); - - // Appearance integration test - appearance_test_runner.main(); - - // User settings - settings_test_runner.main(); - - // board_test.main(); - // empty_document_test.main(); - // smart_menu_test.main(); + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { + await runIntegration1OnDesktop(); + await runIntegration2OnDesktop(); + await runIntegration3OnDesktop(); + } else if (Platform.isIOS || Platform.isAndroid) { + await runIntegrationOnMobile(); + } else { + throw Exception('Unsupported platform'); + } } diff --git a/frontend/appflowy_flutter/integration_test/util/auth_operation.dart b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/auth_operation.dart rename to frontend/appflowy_flutter/integration_test/shared/auth_operation.dart diff --git a/frontend/appflowy_flutter/integration_test/util/base.dart b/frontend/appflowy_flutter/integration_test/shared/base.dart similarity index 90% rename from frontend/appflowy_flutter/integration_test/util/base.dart rename to frontend/appflowy_flutter/integration_test/shared/base.dart index 11b0dcf1ba364..0feb050188c2b 100644 --- a/frontend/appflowy_flutter/integration_test/util/base.dart +++ b/frontend/appflowy_flutter/integration_test/shared/base.dart @@ -37,9 +37,10 @@ extension AppFlowyTestBase on WidgetTester { AuthenticatorType? cloudType, String? email, }) async { - // view.physicalSize = windowsSize; - await binding.setSurfaceSize(windowSize); - // addTearDown(() => binding.setSurfaceSize(null)); + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + // Set the window size + await binding.setSurfaceSize(windowSize); + } mockHotKeyManagerHandlers(); final applicationDataDirectory = dataDirectory ?? @@ -118,7 +119,7 @@ extension AppFlowyTestBase on WidgetTester { Future waitUntilSignInPageShow() async { if (isAuthEnabled) { final finder = find.byType(SignInAnonymousButton); - await pumpUntilFound(finder); + await pumpUntilFound(finder, timeout: const Duration(seconds: 30)); expect(finder, findsOneWidget); } else { final finder = find.byType(GoButton); @@ -134,8 +135,9 @@ extension AppFlowyTestBase on WidgetTester { Future pumpUntilFound( Finder finder, { Duration timeout = const Duration(seconds: 10), - Duration pumpInterval = - const Duration(milliseconds: 50), // Interval between pumps + Duration pumpInterval = const Duration( + milliseconds: 50, + ), // Interval between pumps }) async { bool timerDone = false; final timer = Timer(timeout, () => timerDone = true); @@ -148,6 +150,24 @@ extension AppFlowyTestBase on WidgetTester { timer.cancel(); } + Future pumpUntilNotFound( + Finder finder, { + Duration timeout = const Duration(seconds: 10), + Duration pumpInterval = const Duration( + milliseconds: 50, + ), // Interval between pumps + }) async { + bool timerDone = false; + final timer = Timer(timeout, () => timerDone = true); + while (!timerDone) { + await pump(pumpInterval); // Pump with an interval + if (!any(finder)) { + break; + } + } + timer.cancel(); + } + Future tapButton( Finder finder, { int? pointer, diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart similarity index 89% rename from frontend/appflowy_flutter/integration_test/util/common_operations.dart rename to frontend/appflowy_flutter/integration_test/shared/common_operations.dart index c1bd100dd0016..abfcb324f63c2 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -6,10 +6,13 @@ 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/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.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'; @@ -159,9 +162,7 @@ extension CommonOperations on WidgetTester { }) async { try { final gesture = await createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - await pump(); - await gesture.moveTo(offset ?? getCenter(finder)); + await gesture.addPointer(location: offset ?? getCenter(finder)); await pumpAndSettle(); await onHover?.call(); await gesture.removePointer(); @@ -306,7 +307,7 @@ extension CommonOperations on WidgetTester { KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); - final showRenameDialog = settingsOrFailure.fold(() => false, (r) => r); + final showRenameDialog = settingsOrFailure ?? false; if (showRenameDialog) { await tapOKButton(); } @@ -518,6 +519,51 @@ extension CommonOperations on WidgetTester { await pumpAndSettle(); } } + + Future openCollaborativeWorkspaceMenu() async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + + final workspace = find.byType(SidebarWorkspace); + expect(workspace, findsOneWidget); + // click it + await tapButton(workspace); + } + + Future closeCollaborativeWorkspaceMenu() async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + + await tapAt(Offset.zero); + await pumpAndSettle(); + } + + Future createCollaborativeWorkspace(String name) async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + await openCollaborativeWorkspaceMenu(); + // expect to see the workspace list, and there should be only one workspace + final workspacesMenu = find.byType(WorkspacesMenu); + expect(workspacesMenu, findsOneWidget); + + // click the create button + final createButton = find.byKey(createWorkspaceButtonKey); + expect(createButton, findsOneWidget); + await tapButton(createButton); + + // see the create workspace dialog + final createWorkspaceDialog = find.byType(CreateWorkspaceDialog); + expect(createWorkspaceDialog, findsOneWidget); + + // input the workspace name + await enterText(find.byType(TextField), name); + await pumpAndSettle(); + + await tapButtonWithName(LocaleKeys.button_ok.tr()); + } } extension ViewLayoutPBTest on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/integration_test/util/data.dart b/frontend/appflowy_flutter/integration_test/shared/data.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/data.dart rename to frontend/appflowy_flutter/integration_test/shared/data.dart diff --git a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/util/database_test_op.dart rename to frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index 12722ebe1b58a..50eb062c08645 100644 --- a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -1,26 +1,12 @@ import 'dart:io'; -import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; -import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/number.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/number.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/url.dart'; -import 'package:appflowy/util/field_type_extension.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; @@ -30,6 +16,9 @@ import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_e import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist/checklist.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart'; @@ -43,6 +32,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/header/deskt import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_editor.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/number.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/order_panel.dart'; @@ -52,14 +42,22 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; -import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; -import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/number.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/url.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/row_action.dart'; import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; @@ -70,6 +68,7 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; +import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart'; @@ -77,6 +76,7 @@ import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/remi 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/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:calendar_view/calendar_view.dart'; @@ -86,10 +86,9 @@ import 'package:flowy_infra_ui/style_widget/text_input.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; -import 'package:table_calendar/table_calendar.dart'; - // Non-exported member of the table_calendar library import 'package:table_calendar/src/widgets/cell_content.dart'; +import 'package:table_calendar/table_calendar.dart'; import 'base.dart'; import 'common_operations.dart'; @@ -974,11 +973,6 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButtonWithName(LocaleKeys.grid_row_delete.tr()); } - Future assertRowCountInGridPage(int num) async { - final text = find.text('${rowCountString()} $num', findRichText: true); - expect(text, findsOneWidget); - } - Future createField(FieldType fieldType, String name) async { await scrollToRight(find.byType(GridPage)); await tapNewPropertyButton(); @@ -1116,7 +1110,7 @@ extension AppFlowyDatabaseTest on WidgetTester { /// Must call [tapSortMenuInSettingBar] first. Future tapAllSortButton() async { - await tapButton(find.byType(DatabaseDeleteSortButton)); + await tapButton(find.byType(DeleteAllSortsButton)); } Future scrollOptionFilterListByOffset(Offset offset) async { diff --git a/frontend/appflowy_flutter/integration_test/util/dir.dart b/frontend/appflowy_flutter/integration_test/shared/dir.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/dir.dart rename to frontend/appflowy_flutter/integration_test/shared/dir.dart diff --git a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart rename to frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart index 64dbd1746e535..786b9e08ce817 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; @@ -14,8 +17,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embe 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'; @@ -132,8 +133,7 @@ class EditorOperations { of: find.byType(EmbedImageUrlWidget), matching: find.byType(TextField), ); - final textField = tester.widget(imageUrlTextField); - textField.controller?.text = imageUrl; + await tester.enterText(imageUrlTextField, imageUrl); await tester.pumpAndSettle(); await tester.tapButton( find.descendant( diff --git a/frontend/appflowy_flutter/integration_test/util/emoji.dart b/frontend/appflowy_flutter/integration_test/shared/emoji.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/emoji.dart rename to frontend/appflowy_flutter/integration_test/shared/emoji.dart diff --git a/frontend/appflowy_flutter/integration_test/util/expectation.dart b/frontend/appflowy_flutter/integration_test/shared/expectation.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/expectation.dart rename to frontend/appflowy_flutter/integration_test/shared/expectation.dart diff --git a/frontend/appflowy_flutter/integration_test/util/ime.dart b/frontend/appflowy_flutter/integration_test/shared/ime.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/ime.dart rename to frontend/appflowy_flutter/integration_test/shared/ime.dart diff --git a/frontend/appflowy_flutter/integration_test/util/keyboard.dart b/frontend/appflowy_flutter/integration_test/shared/keyboard.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/keyboard.dart rename to frontend/appflowy_flutter/integration_test/shared/keyboard.dart diff --git a/frontend/appflowy_flutter/integration_test/util/mock/mock_file_picker.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_file_picker.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/mock/mock_file_picker.dart rename to frontend/appflowy_flutter/integration_test/shared/mock/mock_file_picker.dart diff --git a/frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/mock/mock_openai_repository.dart rename to frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart diff --git a/frontend/appflowy_flutter/integration_test/util/mock/mock_url_launcher.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_url_launcher.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/mock/mock_url_launcher.dart rename to frontend/appflowy_flutter/integration_test/shared/mock/mock_url_launcher.dart diff --git a/frontend/appflowy_flutter/integration_test/util/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart similarity index 75% rename from frontend/appflowy_flutter/integration_test/util/settings.dart rename to frontend/appflowy_flutter/integration_test/shared/settings.dart index 23d9f73d44ef3..8f48aa36944f9 100644 --- a/frontend/appflowy_flutter/integration_test/util/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -1,12 +1,11 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'base.dart'; @@ -76,31 +75,15 @@ extension AppFlowySettings on WidgetTester { await pumpAndSettle(); } - // go to settings page and switch the layout direction - Future switchLayoutDirectionMode( - LayoutDirection layoutDirection, - ) async { + // go to settings page and toggle enable RTL toolbar items + Future toggleEnableRTLToolbarItems() async { await openSettings(); await openSettingsPage(SettingsPage.appearance); - final button = find.byKey(const ValueKey('layout_direction_option_button')); - expect(button, findsOneWidget); - await tapButton(button); - - switch (layoutDirection) { - case LayoutDirection.ltrLayout: - final ltrButton = find.text( - LocaleKeys.settings_appearance_layoutDirection_ltr.tr(), - ); - await tapButton(ltrButton); - break; - case LayoutDirection.rtlLayout: - final rtlButton = find.text( - LocaleKeys.settings_appearance_layoutDirection_rtl.tr(), - ); - await tapButton(rtlButton); - break; - } + final switchButton = + find.byKey(EnableRTLToolbarItemsSetting.enableRTLSwitchKey); + expect(switchButton, findsOneWidget); + await tapButton(switchButton); // tap anywhere to close the settings page await tapAt(Offset.zero); diff --git a/frontend/appflowy_flutter/integration_test/util/util.dart b/frontend/appflowy_flutter/integration_test/shared/util.dart similarity index 100% rename from frontend/appflowy_flutter/integration_test/util/util.dart rename to frontend/appflowy_flutter/integration_test/shared/util.dart diff --git a/frontend/appflowy_flutter/integration_test/shared/workspace.dart b/frontend/appflowy_flutter/integration_test/shared/workspace.dart new file mode 100644 index 0000000000000..5137944364957 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/workspace.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'base.dart'; + +extension AppFlowyWorkspace on WidgetTester { + /// Open workspace menu + Future openWorkspaceMenu() async { + final workspaceWrapper = find.byType(SidebarSwitchWorkspaceButton); + expect(workspaceWrapper, findsOneWidget); + await tapButton(workspaceWrapper); + final workspaceMenu = find.byType(WorkspacesMenu); + expect(workspaceMenu, findsOneWidget); + } + + /// Open a workspace + Future openWorkspace(String name) async { + final workspace = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.findTextInFlowyText(name), + ); + expect(workspace, findsOneWidget); + await tapButton(workspace); + } + + Future changeWorkspaceName(String name) async { + final moreButton = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceMoreActionList), + ); + expect(moreButton, findsOneWidget); + await tapButton(moreButton); + await tapButton(find.findTextInFlowyText(LocaleKeys.button_rename.tr())); + final input = find.byType(TextFormField); + expect(input, findsOneWidget); + await enterText(input, name); + await tapButton(find.text(LocaleKeys.button_ok.tr())); + } + + Future changeWorkspaceIcon(String icon) async { + final iconButton = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceIcon), + ); + expect(iconButton, findsOneWidget); + await tapButton(iconButton); + final iconPicker = find.byType(FlowyIconPicker); + expect(iconPicker, findsOneWidget); + await tapButton(find.findTextInFlowyText(icon)); + } +} diff --git a/frontend/appflowy_flutter/lib/core/config/kv.dart b/frontend/appflowy_flutter/lib/core/config/kv.dart index 971f9ab246145..b7c845b5470f0 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv.dart @@ -1,10 +1,9 @@ -import 'package:dartz/dartz.dart'; import 'package:shared_preferences/shared_preferences.dart'; abstract class KeyValueStorage { Future set(String key, String value); - Future> get(String key); - Future> getWithFormat( + Future get(String key); + Future getWithFormat( String key, T Function(String value) formatter, ); @@ -17,26 +16,26 @@ class DartKeyValue implements KeyValueStorage { SharedPreferences get sharedPreferences => _sharedPreferences!; @override - Future> get(String key) async { + Future get(String key) async { await _initSharedPreferencesIfNeeded(); final value = sharedPreferences.getString(key); if (value != null) { - return Some(value); + return value; } - return none(); + return null; } @override - Future> getWithFormat( + Future getWithFormat( String key, T Function(String value) formatter, ) async { final value = await get(key); - return value.fold( - () => none(), - (s) => Some(formatter(s)), - ); + if (value == null) { + return null; + } + return formatter(value); } @override diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 4c4e4264c5e3d..94103ffef820e 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -49,7 +49,7 @@ class KVKeys { static const String kCloudType = 'kCloudType'; static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL'; - static const String kSupabaseURL = 'kSupbaseURL'; + static const String kSupabaseURL = 'kSupabaseURL'; static const String kSupabaseAnonKey = 'kSupabaseAnonKey'; /// The key for saving the text scale factor. @@ -58,4 +58,10 @@ class KVKeys { /// The value range is from 0.8 to 1.0. If it's greater than 1.0, it will cause /// the text to be too large and not aligned with the icon static const String textScaleFactor = 'textScaleFactor'; + + /// The key for saving the feature flags + /// + /// The value is a json string with the following format: + /// {'feature_flag_1': true, 'feature_flag_2': false} + static const String featureFlag = 'featureFlag'; } diff --git a/frontend/appflowy_flutter/lib/core/helpers/helpers.dart b/frontend/appflowy_flutter/lib/core/helpers/helpers.dart index fd832306e8043..e325b1a7bb615 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/helpers.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/helpers.dart @@ -1,2 +1 @@ export 'target_platform.dart'; -export 'url_validator.dart'; diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart new file mode 100644 index 0000000000000..e8c9be51d5a7a --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:url_launcher/url_launcher.dart' as launcher; + +typedef OnFailureCallback = void Function(Uri uri); + +Future afLaunchUrl( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, + launcher.LaunchMode mode = launcher.LaunchMode.platformDefault, + String? webOnlyWindowName, + bool addingHttpSchemeWhenFailed = false, +}) async { + // try to launch the uri directly + bool result; + try { + result = await launcher.launchUrl( + uri, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + return false; + } + + // if the uri is not a valid url, try to launch it with http scheme + final url = uri.toString(); + if (addingHttpSchemeWhenFailed && + !result && + !isURL(url, {'require_protocol': true})) { + try { + final uriWithScheme = Uri.parse('http://$url'); + result = await launcher.launchUrl( + uriWithScheme, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + if (context != null && context.mounted) { + _errorHandler(uri, context: context, onFailure: onFailure, e: e); + } + } + } + + return result; +} + +Future afLaunchUrlString( + String url, { + bool addingHttpSchemeWhenFailed = false, +}) async { + final Uri uri; + try { + uri = Uri.parse(url); + } on FormatException catch (e) { + Log.error('Failed to parse url: $e'); + return false; + } + + // try to launch the uri directly + return afLaunchUrl( + uri, + addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed, + ); +} + +void _errorHandler( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, + PlatformException? e, +}) { + Log.error('Failed to open uri: $e'); + + if (onFailure != null) { + onFailure(uri); + } else { + showMessageToast( + LocaleKeys.failedToOpenUrl.tr(args: [e?.message ?? "PlatformException"]), + context: context, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_validator.dart b/frontend/appflowy_flutter/lib/core/helpers/url_validator.dart deleted file mode 100644 index bfead5619d84e..0000000000000 --- a/frontend/appflowy_flutter/lib/core/helpers/url_validator.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'url_validator.freezed.dart'; - -Either parseValidUrl(String url) { - try { - final uri = Uri.parse(url); - if (uri.scheme.isEmpty || uri.host.isEmpty) { - return left(const UriFailure.invalidSchemeHost()); - } - return right(uri); - } on FormatException { - return left(const UriFailure.invalidUriFormat()); - } -} - -@freezed -class UriFailure with _$UriFailure { - const factory UriFailure.invalidSchemeHost() = _InvalidSchemeHost; - const factory UriFailure.invalidUriFormat() = _InvalidUriFormat; -} diff --git a/frontend/appflowy_flutter/lib/core/notification/document_notification.dart b/frontend/appflowy_flutter/lib/core/notification/document_notification.dart index 584d54cbe2404..4dcaf3fa239a9 100644 --- a/frontend/appflowy_flutter/lib/core/notification/document_notification.dart +++ b/frontend/appflowy_flutter/lib/core/notification/document_notification.dart @@ -3,11 +3,11 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/notification_helper.dart'; import 'package:appflowy_backend/protobuf/flowy-document/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; typedef DocumentNotificationCallback = void Function( DocumentNotification, - Either, + FlowyResult, ); class DocumentNotificationParser diff --git a/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart b/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart index 66db7a966c4de..46cba8cbfe43f 100644 --- a/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart +++ b/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart @@ -1,17 +1,18 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; -import 'package:dartz/dartz.dart'; + import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'notification_helper.dart'; // Folder typedef FolderNotificationCallback = void Function( FolderNotification, - Either, + FlowyResult, ); class FolderNotificationParser @@ -27,7 +28,7 @@ class FolderNotificationParser typedef FolderNotificationHandler = Function( FolderNotification ty, - Either result, + FlowyResult result, ); class FolderNotificationListener { diff --git a/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart b/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart index 6a0f5d8bcda85..4d67f0bbb0d20 100644 --- a/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart +++ b/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart @@ -1,17 +1,18 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; + import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'notification_helper.dart'; // DatabasePB typedef DatabaseNotificationCallback = void Function( DatabaseNotification, - Either, + FlowyResult, ); class DatabaseNotificationParser @@ -27,7 +28,7 @@ class DatabaseNotificationParser typedef DatabaseNotificationHandler = Function( DatabaseNotification ty, - Either result, + FlowyResult result, ); class DatabaseNotificationListener { diff --git a/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart b/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart index 4bd988b015305..9aba14cd27d6f 100644 --- a/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart +++ b/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart @@ -1,8 +1,9 @@ import 'dart:typed_data'; + import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; -class NotificationParser { +class NotificationParser { NotificationParser({ this.id, required this.callback, @@ -11,7 +12,7 @@ class NotificationParser { }); String? id; - void Function(T, Either) callback; + void Function(T, FlowyResult) callback; E Function(Uint8List) errorParser; T? Function(int) tyParser; @@ -30,10 +31,10 @@ class NotificationParser { if (subject.hasError()) { final bytes = Uint8List.fromList(subject.error); final error = errorParser(bytes); - callback(ty, right(error)); + callback(ty, FlowyResult.failure(error)); } else { final bytes = Uint8List.fromList(subject.payload); - callback(ty, left(bytes)); + callback(ty, FlowyResult.success(bytes)); } } } diff --git a/frontend/appflowy_flutter/lib/core/notification/user_notification.dart b/frontend/appflowy_flutter/lib/core/notification/user_notification.dart index eca76f0eeb1ca..741f26967c336 100644 --- a/frontend/appflowy_flutter/lib/core/notification/user_notification.dart +++ b/frontend/appflowy_flutter/lib/core/notification/user_notification.dart @@ -1,17 +1,18 @@ import 'dart:async'; import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'notification_helper.dart'; // User typedef UserNotificationCallback = void Function( UserNotification, - Either, + FlowyResult, ); class UserNotificationParser @@ -27,7 +28,7 @@ class UserNotificationParser typedef UserNotificationHandler = Function( UserNotification ty, - Either result, + FlowyResult result, ); class UserNotificationListener { diff --git a/frontend/appflowy_flutter/lib/date/date_service.dart b/frontend/appflowy_flutter/lib/date/date_service.dart index bfd5a825ce8b2..bf49bce7a57ab 100644 --- a/frontend/appflowy_flutter/lib/date/date_service.dart +++ b/frontend/appflowy_flutter/lib/date/date_service.dart @@ -1,19 +1,25 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-date/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class DateService { - static Future> queryDate(String search) async { + static Future> queryDate( + String search, + ) async { final query = DateQueryPB.create()..query = search; - final result = (await DateEventQueryDate(query).send()).swap(); - return result.fold((l) => left(l), (r) { - final date = DateTime.tryParse(r.date); - if (date != null) { - return right(date); - } - - return left(FlowyError(msg: 'Could not parse Date (NLP) from String')); - }); + final result = await DateEventQueryDate(query).send(); + return result.fold( + (s) { + final date = DateTime.tryParse(s.date); + if (date != null) { + return FlowyResult.success(date); + } + return FlowyResult.failure( + FlowyError(msg: 'Could not parse Date (NLP) from String'), + ); + }, + (e) => FlowyResult.failure(e), + ); } } diff --git a/frontend/appflowy_flutter/lib/env/cloud_env.dart b/frontend/appflowy_flutter/lib/env/cloud_env.dart index 783b8177d10f1..27c4469f45f43 100644 --- a/frontend/appflowy_flutter/lib/env/cloud_env.dart +++ b/frontend/appflowy_flutter/lib/env/cloud_env.dart @@ -4,7 +4,6 @@ import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:dartz/dartz.dart'; /// Sets the cloud type for the application. /// @@ -52,7 +51,7 @@ const String kAppflowyCloudUrl = "https://beta.appflowy.cloud"; /// Future getAuthenticatorType() async { final value = await getIt().get(KVKeys.kCloudType); - if (value.isNone() && !integrationMode().isUnitTest) { + if (value == null && !integrationMode().isUnitTest) { // if the cloud type is not set, then set it to AppFlowy Cloud as default. await useAppFlowyBetaCloudWithURL( kAppflowyCloudUrl, @@ -61,7 +60,7 @@ Future getAuthenticatorType() async { return AuthenticatorType.appflowyCloud; } - switch (value.getOrElse(() => "0")) { + switch (value ?? "0") { case "0": return AuthenticatorType.local; case "1": @@ -177,16 +176,13 @@ AuthenticatorType currentCloudType() { return getIt().authenticatorType; } -Future _setAppFlowyCloudUrl(Option url) async { - await url.fold( - () => getIt().set(KVKeys.kAppflowyCloudBaseURL, ""), - (s) => getIt().set(KVKeys.kAppflowyCloudBaseURL, s), - ); +Future _setAppFlowyCloudUrl(String? url) async { + await getIt().set(KVKeys.kAppflowyCloudBaseURL, url ?? ''); } Future useSelfHostedAppFlowyCloudWithURL(String url) async { await _setAuthenticatorType(AuthenticatorType.appflowyCloudSelfHost); - await _setAppFlowyCloudUrl(Some(url)); + await _setAppFlowyCloudUrl(url); } Future useAppFlowyBetaCloudWithURL( @@ -194,7 +190,7 @@ Future useAppFlowyBetaCloudWithURL( AuthenticatorType authenticatorType, ) async { await _setAuthenticatorType(authenticatorType); - await _setAppFlowyCloudUrl(Some(url)); + await _setAppFlowyCloudUrl(url); } Future useLocalServer() async { @@ -206,7 +202,7 @@ Future useSupabaseCloud({ required String anonKey, }) async { await _setAuthenticatorType(AuthenticatorType.supabase); - await setSupbaseServer(Some(url), Some(anonKey)); + await setSupabaseServer(url, anonKey); } /// Use getIt() to get the shared environment. @@ -285,7 +281,7 @@ Future configurationFromUri( if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) { return AppFlowyCloudConfiguration( base_url: "$baseUrl:8000", - ws_base_url: "ws://${baseUri.host}:8000/ws", + ws_base_url: "ws://${baseUri.host}:8000/ws/v1", gotrue_url: "$baseUrl:9999", ); } else { @@ -314,10 +310,7 @@ Future getAppFlowyCloudConfig( Future getAppFlowyCloudUrl() async { final result = await getIt().get(KVKeys.kAppflowyCloudBaseURL); - return result.fold( - () => kAppflowyCloudUrl, - (url) => url, - ); + return result ?? kAppflowyCloudUrl; } Future _getAppFlowyCloudWSUrl(String baseURL) async { @@ -326,7 +319,7 @@ Future _getAppFlowyCloudWSUrl(String baseURL) async { // Construct the WebSocket URL directly from the parsed URI. final wsScheme = uri.isScheme('HTTPS') ? 'wss' : 'ws'; - final wsUrl = Uri(scheme: wsScheme, host: uri.host, path: '/ws'); + final wsUrl = Uri(scheme: wsScheme, host: uri.host, path: '/ws/v1'); return wsUrl.toString(); } catch (e) { @@ -339,27 +332,30 @@ Future _getAppFlowyCloudGotrueUrl(String baseURL) async { return "$baseURL/gotrue"; } -Future setSupbaseServer( - Option url, - Option anonKey, +Future setSupabaseServer( + String? url, + String? anonKey, ) async { assert( - (url.isSome() && anonKey.isSome()) || (url.isNone() && anonKey.isNone()), + (url != null && anonKey != null) || (url == null && anonKey == null), "Either both Supabase URL and anon key must be set, or both should be unset", ); - await url.fold( - () => getIt().remove(KVKeys.kSupabaseURL), - (s) => getIt().set(KVKeys.kSupabaseURL, s), - ); - await anonKey.fold( - () => getIt().remove(KVKeys.kSupabaseAnonKey), - (s) => getIt().set(KVKeys.kSupabaseAnonKey, s), - ); + if (url == null) { + await getIt().remove(KVKeys.kSupabaseURL); + } else { + await getIt().set(KVKeys.kSupabaseURL, url); + } + + if (anonKey == null) { + await getIt().remove(KVKeys.kSupabaseAnonKey); + } else { + await getIt().set(KVKeys.kSupabaseAnonKey, anonKey); + } } Future getSupabaseCloudConfig() async { - final url = await _getSupbaseUrl(); + final url = await _getSupabaseUrl(); final anonKey = await _getSupabaseAnonKey(); return SupabaseConfiguration( url: url, @@ -367,18 +363,12 @@ Future getSupabaseCloudConfig() async { ); } -Future _getSupbaseUrl() async { +Future _getSupabaseUrl() async { final result = await getIt().get(KVKeys.kSupabaseURL); - return result.fold( - () => "", - (url) => url, - ); + return result ?? ''; } Future _getSupabaseAnonKey() async { final result = await getIt().get(KVKeys.kSupabaseAnonKey); - return result.fold( - () => "", - (url) => url, - ); + return result ?? ''; } diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart index db4540f3c0458..3a2c9a83fa2cf 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -26,7 +26,7 @@ extension on ViewPB { String get routeName { switch (layout) { case ViewLayoutPB.Document: - return MobileEditorScreen.routeName; + return MobileDocumentScreen.routeName; case ViewLayoutPB.Grid: return MobileGridScreen.routeName; case ViewLayoutPB.Calendar: @@ -42,8 +42,8 @@ extension on ViewPB { switch (layout) { case ViewLayoutPB.Document: return { - MobileEditorScreen.viewId: id, - MobileEditorScreen.viewTitle: name, + MobileDocumentScreen.viewId: id, + MobileDocumentScreen.viewTitle: name, }; case ViewLayoutPB.Grid: return { diff --git a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart index e3d43c4a5107e..7edec07cc16ae 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart @@ -31,8 +31,8 @@ class UserProfileBloc extends Bloc { ); final userProfile = userOrFailure.fold( - (error) => null, (userProfilePB) => userProfilePB, + (error) => null, ); if (workspaceSetting == null || userProfile == null) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart index 2a35ab24cc385..2341513c25cd0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar_actions.dart @@ -77,7 +77,7 @@ class AppBarDoneButton extends StatelessWidget { Widget build(BuildContext context) { return AppBarButton( onTap: onTap, - padding: const EdgeInsets.fromLTRB(12, 12, 8, 12), + padding: const EdgeInsets.all(12), child: FlowyText( LocaleKeys.button_done.tr(), color: Theme.of(context).colorScheme.primary, @@ -93,7 +93,7 @@ class AppBarSaveButton extends StatelessWidget { super.key, required this.onTap, this.enable = true, - this.padding = const EdgeInsets.fromLTRB(12, 12, 8, 12), + this.padding = const EdgeInsets.all(12), }); final VoidCallback onTap; @@ -165,7 +165,7 @@ class AppBarMoreButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppBarButton( - padding: const EdgeInsets.fromLTRB(12, 12, 8, 12), + padding: const EdgeInsets.all(12), onTap: () => onTap(context), child: const FlowySvg(FlowySvgs.three_dots_s), ); 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 b8e5edd92ab5d..038da31ebf3f6 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 @@ -4,7 +4,9 @@ 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/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; -import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/plugins/document/presentation/document_sync_indicator.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; @@ -13,7 +15,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart' hide State; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -40,7 +42,7 @@ class MobileViewPage extends StatefulWidget { } class _MobileViewPageState extends State { - late final Future> future; + late final Future> future; @override void initState() { @@ -70,11 +72,16 @@ class _MobileViewPageState extends State { } else { body = state.data!.fold((view) { viewPB = view; - actions.add(_buildAppBarMoreButton(view)); - return view - .plugin(arguments: widget.arguments ?? const {}) - .widgetBuilder - .buildWidget(shrinkWrap: false); + actions.addAll([ + if (FeatureFlag.syncDocument.isOn) ...[ + DocumentSyncIndicator(view: view), + const HSpace(8.0), + ], + _buildAppBarMoreButton(view), + ]); + final plugin = view.plugin(arguments: widget.arguments ?? const {}) + ..init(); + return plugin.widgetBuilder.buildWidget(shrinkWrap: false); }, (error) { return FlowyMobileStateContainer.error( emoji: '😔', @@ -145,11 +152,13 @@ class _MobileViewPageState extends State { Widget _buildAppBarMoreButton(ViewPB view) { return AppBarMoreButton( onTap: (context) { + EditorNotification.exitEditing().post(); + showMobileBottomSheet( context, showDragHandle: true, showDivider: false, - backgroundColor: Theme.of(context).colorScheme.surface, + backgroundColor: Theme.of(context).colorScheme.background, builder: (_) => _buildViewPageBottomSheet(context), ); }, @@ -184,14 +193,12 @@ class _MobileViewPageState extends State { context.read().add(FavoriteEvent.toggle(view)); break; case MobileViewBottomSheetBodyAction.undo: - context.dispatchNotification( - const EditorNotification(type: EditorNotificationType.redo), - ); + EditorNotification.undo().post(); context.pop(); break; case MobileViewBottomSheetBodyAction.redo: + EditorNotification.redo().post(); context.pop(); - context.dispatchNotification(EditorNotification.redo()); break; case MobileViewBottomSheetBodyAction.helpCenter: // unimplemented diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart index 1d5fce146611a..d27085b3944df 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart @@ -20,6 +20,7 @@ class OptionColorList extends StatelessWidget { crossAxisCount: 6, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, children: SelectOptionColorPB.values.map( (colorPB) { final color = colorPB.toColor(context); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart new file mode 100644 index 0000000000000..c9d8a48e6b008 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart @@ -0,0 +1,82 @@ +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:flutter/material.dart'; + +class BottomSheetCloseButton extends StatelessWidget { + const BottomSheetCloseButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap ?? () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: 18, + height: 18, + child: FlowySvg( + FlowySvgs.m_bottom_sheet_close_m, + ), + ), + ), + ); + } +} + +class BottomSheetDoneButton extends StatelessWidget { + const BottomSheetDoneButton({ + super.key, + this.onDone, + }); + + final VoidCallback? onDone; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onDone ?? () => Navigator.pop(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12.0), + child: FlowyText( + LocaleKeys.button_done.tr(), + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ), + ); + } +} + +class BottomSheetBackButton extends StatelessWidget { + const BottomSheetBackButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap ?? () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: 18, + height: 18, + child: FlowySvg( + FlowySvgs.m_app_bar_back_s, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart index e09b13268cbe2..e1bc32a6f0fb0 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart @@ -1,6 +1,4 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -26,7 +24,7 @@ class BottomSheetHeader extends StatelessWidget { left: 0, child: Align( alignment: Alignment.centerLeft, - child: AppBarCloseButton( + child: BottomSheetCloseButton( onTap: onClose, ), ), @@ -41,19 +39,8 @@ class BottomSheetHeader extends StatelessWidget { if (onDone != null) Align( alignment: Alignment.centerRight, - child: FlowyButton( - useIntrinsicWidth: true, - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), - color: Color(0xFF00BCF0), - ), - text: FlowyText.medium( - LocaleKeys.button_done.tr(), - color: Colors.white, - fontSize: 16.0, - ), - onTap: onDone, + child: BottomSheetDoneButton( + onDone: onDone, ), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart index 0b0ce92b34677..d4f49cb9a97a5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart @@ -52,7 +52,7 @@ class _MobileBottomSheetRenameWidgetState height: 42.0, child: FlowyTextField( controller: controller, - textInputAction: TextInputAction.done, + keyboardType: TextInputType.text, onSubmitted: (text) => widget.onRename(text), ), ), 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 2b8209987c03b..624ae33b9fbc1 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,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/widget/separated_flex.dart'; import 'package:flutter/material.dart'; enum MobileViewItemBottomSheetBodyAction { @@ -26,12 +25,8 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { @override Widget build(BuildContext context) { - return SeparatedColumn( + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - separatorBuilder: () => const Divider( - height: 8.5, - thickness: 0.5, - ), children: [ MobileQuickActionButton( text: LocaleKeys.button_rename.tr(), @@ -40,6 +35,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { MobileViewItemBottomSheetBodyAction.rename, ), ), + _divider(), MobileQuickActionButton( text: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() @@ -54,6 +50,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { : MobileViewItemBottomSheetBodyAction.addToFavorites, ), ), + _divider(), MobileQuickActionButton( text: LocaleKeys.button_duplicate.tr(), icon: FlowySvgs.m_duplicate_s, @@ -61,6 +58,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { MobileViewItemBottomSheetBodyAction.duplicate, ), ), + _divider(), MobileQuickActionButton( text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, @@ -70,7 +68,13 @@ class MobileViewItemBottomSheetBody extends StatelessWidget { MobileViewItemBottomSheetBodyAction.delete, ), ), + _divider(), ], ); } + + Widget _divider() => const Divider( + height: 8.5, + thickness: 0.5, + ); } 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 99ea534b093a8..de9d51311c283 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 @@ -4,7 +4,6 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum MobileViewBottomSheetBodyAction { @@ -85,12 +84,8 @@ class MobileViewBottomSheetBody extends StatelessWidget { @override Widget build(BuildContext context) { final isFavorite = view.isFavorite; - return SeparatedColumn( + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - separatorBuilder: () => const Divider( - height: 8.5, - thickness: 0.5, - ), children: [ MobileQuickActionButton( text: LocaleKeys.button_rename.tr(), @@ -99,6 +94,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.rename, ), ), + _divider(), MobileQuickActionButton( text: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() @@ -113,6 +109,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { : MobileViewBottomSheetBodyAction.addToFavorites, ), ), + _divider(), MobileQuickActionButton( text: LocaleKeys.button_duplicate.tr(), icon: FlowySvgs.m_duplicate_s, @@ -120,6 +117,7 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.duplicate, ), ), + _divider(), MobileQuickActionButton( text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, @@ -129,7 +127,13 @@ class MobileViewBottomSheetBody extends StatelessWidget { MobileViewBottomSheetBodyAction.delete, ), ), + _divider(), ], ); } + + Widget _divider() => const Divider( + height: 8.5, + thickness: 0.5, + ); } 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 728486cdc7fef..f27c5b3b6f585 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 @@ -54,7 +54,7 @@ enum MobilePaneActionType { context, showDragHandle: true, showDivider: false, - backgroundColor: Theme.of(context).colorScheme.surface, + backgroundColor: Theme.of(context).colorScheme.background, useRootNavigator: true, builder: (context) { return MultiBlocProvider( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart index de9a87925fb7a..8a51a081767f8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart @@ -1,8 +1,27 @@ -import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder; import 'package:flutter/material.dart'; +extension BottomSheetPaddingExtension on BuildContext { + /// Calculates the total amount of space that should be added to the bottom of + /// a bottom sheet + double bottomSheetPadding({ + bool ignoreViewPadding = true, + }) { + final viewPadding = MediaQuery.viewPaddingOf(this); + final viewInsets = MediaQuery.viewInsetsOf(this); + double bottom = 0.0; + if (!ignoreViewPadding) { + bottom += viewPadding.bottom; + } + // for screens with 0 view padding, add some even more space + bottom += viewPadding.bottom == 0 ? 28.0 : 16.0; + bottom += viewInsets.bottom; + return bottom; + } +} + Future showMobileBottomSheet( BuildContext context, { required WidgetBuilder builder, @@ -46,7 +65,7 @@ Future showMobileBottomSheet( backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) - : const Color(0xFF626364); + : const Color(0xFF23262B); return showModalBottomSheet( context: context, @@ -108,9 +127,11 @@ Future showMobileBottomSheet( children: [ ...children, Expanded( - child: SingleChildScrollView( - controller: scrollController, - child: child, + child: Scrollbar( + child: SingleChildScrollView( + controller: scrollController, + child: child, + ), ), ), ], @@ -120,12 +141,11 @@ Future showMobileBottomSheet( } // ----- content area ----- - // make sure the keyboard won't cover the content + // add content padding and extra bottom padding children.add( Padding( - padding: padding.copyWith( - bottom: padding.bottom + MediaQuery.of(context).viewInsets.bottom, - ), + padding: + padding + EdgeInsets.only(bottom: context.bottomSheetPadding()), child: child, ), ); @@ -135,13 +155,6 @@ Future showMobileBottomSheet( return children.first; } - // add default padding - // for full screen bottom sheet, the padding should be 16.0 - // for non full screen bottom sheet, the padding should be 28.0 - children.add( - VSpace(MediaQuery.of(context).padding.bottom == 0 ? 28.0 : 16.0), - ); - return useSafeArea ? SafeArea( child: Column( @@ -182,12 +195,12 @@ class BottomSheetHeader extends StatelessWidget { if (showBackButton) const Align( alignment: Alignment.centerLeft, - child: AppBarBackButton(), + child: BottomSheetBackButton(), ), if (showCloseButton) const Align( alignment: Alignment.centerLeft, - child: AppBarCloseButton(), + child: BottomSheetCloseButton(), ), Align( child: FlowyText( @@ -199,8 +212,8 @@ class BottomSheetHeader extends StatelessWidget { if (showDoneButton) Align( alignment: Alignment.centerRight, - child: AppBarDoneButton( - onTap: () => Navigator.pop(context), + child: BottomSheetDoneButton( + onDone: () => Navigator.pop(context), ), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart index ed1130de1d99e..d9b9127468c60 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart @@ -39,7 +39,7 @@ Future showTransitionMobileBottomSheet( backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) - : const Color(0xFF626364); + : const Color(0xFF23262B); return Navigator.of( context, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart index 77d8c194cad85..5180febd8b574 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart @@ -1,6 +1,9 @@ +import 'package:flutter/material.dart'; + 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/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; @@ -8,7 +11,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/group.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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -107,46 +109,43 @@ class _GroupCardHeaderState extends State { splashRadius: 5, onPressed: () => showMobileBottomSheet( context, - title: LocaleKeys.board_column_groupActions.tr(), - showHeader: true, - showCloseButton: true, - builder: (_) { - return Row( - children: [ - Expanded( - child: BottomSheetActionWidget( - svg: FlowySvgs.edit_s, - text: LocaleKeys.board_column_renameColumn.tr(), - onTap: () { - context.read().add( - BoardEvent.startEditingHeader( - widget.groupData.id, - ), - ); - context.pop(); - }, - ), - ), - const HSpace(8), - Expanded( - child: BottomSheetActionWidget( - svg: FlowySvgs.hide_s, - text: LocaleKeys.board_column_hideColumn.tr(), - onTap: () { - context.read().add( - BoardEvent.toggleGroupVisibility( - widget.groupData.customData.group - as GroupPB, - false, - ), - ); - context.pop(); - }, - ), - ), - ], - ); - }, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) => SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.stretch, + separatorBuilder: () => const Divider( + height: 8.5, + thickness: 0.5, + ), + children: [ + MobileQuickActionButton( + text: LocaleKeys.board_column_renameColumn.tr(), + icon: FlowySvgs.edit_s, + onTap: () { + context.read().add( + BoardEvent.startEditingHeader( + widget.groupData.id, + ), + ); + context.pop(); + }, + ), + MobileQuickActionButton( + text: LocaleKeys.board_column_hideColumn.tr(), + icon: FlowySvgs.hide_s, + onTap: () { + context.read().add( + BoardEvent.toggleGroupVisibility( + widget.groupData.customData.group + as GroupPB, + false, + ), + ); + context.pop(); + }, + ), + ], + ), ), ), IconButton( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart index 078da42b9d86e..549a7f4a8a0e1 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart @@ -199,7 +199,7 @@ class MobileHiddenGroup extends StatelessWidget { children: [ Expanded( child: Text( - group.groupName, + context.read().generateGroupNameFromGroup(group), style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart index 2a1e8ca22b75b..0e462c641d846 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart @@ -150,6 +150,7 @@ class _MobileRowDetailPageState extends State { icon: FlowySvgs.m_delete_m, iconColor: Theme.of(context).colorScheme.error, ), + const Divider(height: 8.5, thickness: 0.5), ], ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart index c2892e97de6d5..e62ddeb872416 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart @@ -40,7 +40,7 @@ class MobileRowDetailCreateFieldButton extends StatelessWidget { LocaleKeys.grid_field_newProperty.tr(), fontSize: 15, ), - onPressed: () => showCreateFieldBottomSheet(context, viewId), + onPressed: () => mobileCreateFieldWorkflow(context, viewId), icon: const FlowySvg(FlowySvgs.add_m), ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart index 1858a04307991..fc869a54c7907 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart @@ -1,7 +1,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class OptionTextField extends StatelessWidget { @@ -10,31 +13,44 @@ class OptionTextField extends StatelessWidget { required this.controller, required this.type, required this.onTextChanged, + required this.onFieldTypeChanged, }); final TextEditingController controller; final FieldType type; final void Function(String value) onTextChanged; + final void Function(FieldType value) onFieldTypeChanged; @override Widget build(BuildContext context) { return FlowyOptionTile.textField( controller: controller, - autofocus: true, textFieldPadding: const EdgeInsets.symmetric(horizontal: 12.0), onTextChanged: onTextChanged, - leftIcon: Container( - height: 38, - width: 38, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: type.mobileIconBackgroundColor, - ), - child: Center( - child: FlowySvg( - type.svgData, - blendMode: null, - size: const Size.square(22), + leftIcon: GestureDetector( + onTap: () async { + final fieldType = await showFieldTypeGridBottomSheet( + context, + title: LocaleKeys.grid_field_editProperty.tr(), + ); + if (fieldType != null) { + onFieldTypeChanged(fieldType); + } + }, + child: Container( + height: 38, + width: 38, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).brightness == Brightness.light + ? type.mobileIconBackgroundColor + : type.mobileIconBackgroundColorDark, + ), + child: Center( + child: FlowySvg( + type.svgData, + size: const Size.square(22), + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart index eab036aabc22f..301c375c4bb5d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart @@ -55,7 +55,7 @@ class _MobileDateCellEditScreenState extends State { minChildSize: 0.4, snapSizes: const [0.4, 0.7, 1.0], builder: (_, controller) => Material( - color: Theme.of(context).colorScheme.secondaryContainer, + color: Colors.transparent, child: ListView( controller: controller, children: [ diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart index 958c282bbbb7c..7aa473a2ea9a5 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart @@ -1,9 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; -import 'package:appflowy/plugins/database/application/field/field_backend_service.dart'; +import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -61,7 +61,7 @@ class _MobileEditPropertyScreenState extends State { body: MobileFieldEditor( mode: FieldOptionMode.edit, isPrimary: widget.field.isPrimary, - defaultValues: _fieldOptionValues, + defaultValues: FieldOptionValues.fromField(field: widget.field.field), actions: [ widget.field.fieldSettings?.visibility.isVisibleState() ?? true ? FieldOptionAction.hide @@ -69,9 +69,25 @@ class _MobileEditPropertyScreenState extends State { FieldOptionAction.duplicate, FieldOptionAction.delete, ], - onOptionValuesChanged: (newFieldOptionValues) { + onOptionValuesChanged: (fieldOptionValues) async { + await fieldService.updateField(name: fieldOptionValues.name); + + await FieldBackendService.updateFieldType( + viewId: widget.viewId, + fieldId: widget.field.id, + fieldType: fieldOptionValues.type, + ); + + final data = fieldOptionValues.getTypeOptionData(); + if (data != null) { + await FieldBackendService.updateFieldTypeOption( + viewId: widget.viewId, + fieldId: widget.field.id, + typeOptionData: data, + ); + } setState(() { - _fieldOptionValues = newFieldOptionValues; + _fieldOptionValues = fieldOptionValues; }); }, onAction: (action) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart index fe5de486e9491..a4ef722ea7fdd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart @@ -29,48 +29,31 @@ const mobileSupportedFieldTypes = [ FieldType.Checklist, ]; -/// Shows the field type grid and upon selection, allow users to edit the -/// field's properties and saving it when the user clicks save. -void showCreateFieldBottomSheet( - BuildContext context, - String viewId, { - OrderObjectPositionPB? position, +Future showFieldTypeGridBottomSheet( + BuildContext context, { + required String title, }) { - showMobileBottomSheet( + return showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showCloseButton: true, elevation: 20, - title: LocaleKeys.grid_field_newProperty.tr(), - backgroundColor: Theme.of(context).colorScheme.surface, + title: title, + backgroundColor: Theme.of(context).colorScheme.background, enableDraggableScrollable: true, builder: (context) { final typeOptionMenuItemValue = mobileSupportedFieldTypes .map( (fieldType) => TypeOptionMenuItemValue( value: fieldType, - backgroundColor: fieldType.mobileIconBackgroundColor, + backgroundColor: Theme.of(context).brightness == Brightness.light + ? fieldType.mobileIconBackgroundColor + : fieldType.mobileIconBackgroundColorDark, text: fieldType.i18n, icon: fieldType.svgData, - onTap: (_, fieldType) async { - final optionValues = await context.push( - Uri( - path: MobileNewPropertyScreen.routeName, - queryParameters: { - MobileNewPropertyScreen.argViewId: viewId, - MobileNewPropertyScreen.argFieldTypeId: - fieldType.value.toString(), - }, - ).toString(), - ); - if (optionValues != null) { - await optionValues.create(viewId: viewId, position: position); - if (context.mounted) { - context.pop(); - } - } - }, + onTap: (context, fieldType) => + Navigator.of(context).pop(fieldType), ), ) .toList(); @@ -85,6 +68,34 @@ void showCreateFieldBottomSheet( ); } +/// Shows the field type grid and upon selection, allow users to edit the +/// field's properties and saving it when the user clicks save. +void mobileCreateFieldWorkflow( + BuildContext context, + String viewId, { + OrderObjectPositionPB? position, +}) async { + final fieldType = await showFieldTypeGridBottomSheet( + context, + title: LocaleKeys.grid_field_newProperty.tr(), + ); + if (fieldType == null || !context.mounted) { + return; + } + final optionValues = await context.push( + Uri( + path: MobileNewPropertyScreen.routeName, + queryParameters: { + MobileNewPropertyScreen.argViewId: viewId, + MobileNewPropertyScreen.argFieldTypeId: fieldType.value.toString(), + }, + ).toString(), + ); + if (optionValues != null) { + await optionValues.create(viewId: viewId, position: position); + } +} + /// Used to edit a field. Future showEditFieldScreen( BuildContext context, @@ -104,16 +115,17 @@ Future showEditFieldScreen( void showQuickEditField( BuildContext context, String viewId, + FieldController fieldController, FieldInfo fieldInfo, ) { showMobileBottomSheet( context, - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, showDragHandle: true, builder: (context) { return SingleChildScrollView( child: QuickEditField( viewId: viewId, + fieldController: fieldController, fieldInfo: fieldInfo, ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart index 2d48b3ca5878d..95c9fd0ee0562 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart @@ -5,16 +5,14 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; import 'package:appflowy/mobile/presentation/base/option_color_list.dart'; -import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; @@ -210,7 +208,9 @@ class _MobileFieldEditorState extends State { Widget build(BuildContext context) { final option = _buildOption(); return Container( - color: Theme.of(context).colorScheme.secondaryContainer, + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFFF7F8FB) + : const Color(0xFF23262B), height: MediaQuery.of(context).size.height, child: SingleChildScrollView( child: Column( @@ -223,6 +223,18 @@ class _MobileFieldEditorState extends State { isFieldNameChanged = true; _updateOptionValues(name: value); }, + onFieldTypeChanged: (type) { + setState( + () { + if (widget.mode == FieldOptionMode.add && + !isFieldNameChanged) { + controller.text = type.i18n; + _updateOptionValues(name: type.i18n); + } + _updateOptionValues(type: type); + }, + ); + }, ), const _Divider(), if (!widget.isPrimary) ...[ @@ -249,6 +261,7 @@ class _MobileFieldEditorState extends State { ], ..._buildOptionActions(), const _Divider(), + VSpace(MediaQuery.viewPaddingOf(context).bottom == 0 ? 28.0 : 16.0), ], ), ), @@ -341,7 +354,7 @@ class _MobileFieldEditorState extends State { } return [ - if (widget.actions.contains(FieldOptionAction.hide)) + if (widget.actions.contains(FieldOptionAction.hide) && !widget.isPrimary) FlowyOptionTile.text( text: LocaleKeys.grid_field_hide.tr(), leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), @@ -444,40 +457,14 @@ class _PropertyType extends StatelessWidget { ), ], ), - onTap: () { - showMobileBottomSheet( + onTap: () async { + final fieldType = await showFieldTypeGridBottomSheet( context, - showHeader: true, - showDragHandle: true, - showCloseButton: true, - elevation: 20, title: LocaleKeys.grid_field_editProperty.tr(), - backgroundColor: Theme.of(context).colorScheme.surface, - enableDraggableScrollable: true, - builder: (context) { - final typeOptionMenuItemValue = mobileSupportedFieldTypes - .map( - (fieldType) => TypeOptionMenuItemValue( - value: fieldType, - backgroundColor: fieldType.mobileIconBackgroundColor, - text: fieldType.i18n, - icon: fieldType.svgData, - onTap: (_, fieldType) { - onSelected(fieldType); - context.pop(); - }, - ), - ) - .toList(); - return Padding( - padding: EdgeInsets.all(16 * context.scale), - child: TypeOptionMenu( - values: typeOptionMenuItemValue, - scaleFactor: context.scale, - ), - ); - }, ); + if (fieldType != null) { + onSelected(fieldType); + } }, ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart index b70c508aab59a..a1676d65900aa 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart @@ -2,27 +2,29 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; -import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; -import 'package:appflowy/plugins/database/application/field/field_backend_service.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:protobuf/protobuf.dart' hide FieldInfo; class QuickEditField extends StatefulWidget { const QuickEditField({ super.key, required this.viewId, + required this.fieldController, required this.fieldInfo, }); final String viewId; + final FieldController fieldController; final FieldInfo fieldInfo; @override @@ -38,14 +40,10 @@ class _QuickEditFieldState extends State { ); late FieldVisibility fieldVisibility; - late FieldOptionValues _fieldOptionValues; @override void initState() { super.initState(); - - _fieldOptionValues = - FieldOptionValues.fromField(field: widget.fieldInfo.field); fieldVisibility = widget.fieldInfo.fieldSettings?.visibility ?? FieldVisibility.AlwaysShown; controller.text = widget.fieldInfo.field.name; @@ -59,140 +57,125 @@ class _QuickEditFieldState extends State { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const VSpace(16), - OptionTextField( - controller: controller, - type: _fieldOptionValues.type, - onTextChanged: (text) async { - await service.updateName(text); - }, - ), - const _Divider(), - FlowyOptionTile.text( - text: LocaleKeys.grid_field_editProperty.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_field_edit_s), - onTap: () async { - widget.fieldInfo.field.freeze(); - final field = widget.fieldInfo.field.rebuild((field) { - field.name = controller.text; - field.fieldType = _fieldOptionValues.type; - field.typeOptionData = - _fieldOptionValues.getTypeOptionData() ?? []; - }); - final fieldOptionValues = await showEditFieldScreen( - context, - widget.viewId, - widget.fieldInfo.copyWith(field: field), - ); - if (fieldOptionValues != null) { - if (fieldOptionValues.name != _fieldOptionValues.name) { - await service.updateName(fieldOptionValues.name); - } - - if (fieldOptionValues.type != _fieldOptionValues.type) { - await FieldBackendService.updateFieldType( - viewId: widget.viewId, - fieldId: widget.fieldInfo.id, - fieldType: fieldOptionValues.type, - ); - } - - final data = fieldOptionValues.getTypeOptionData(); - if (data != null) { - await FieldBackendService.updateFieldTypeOption( - viewId: widget.viewId, - fieldId: widget.fieldInfo.id, - typeOptionData: data, - ); - } - setState(() { - _fieldOptionValues = fieldOptionValues; - controller.text = fieldOptionValues.name; - }); - } else { - if (context.mounted) { - context.pop(); - } - } - }, - ), - if (!widget.fieldInfo.isPrimary) - FlowyOptionTile.text( - showTopBorder: false, - text: fieldVisibility.isVisibleState() - ? LocaleKeys.grid_field_hide.tr() - : LocaleKeys.grid_field_show.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), - onTap: () async { - context.pop(); - if (fieldVisibility.isVisibleState()) { - await service.hide(); - } else { - await service.hide(); - } - }, - ), - if (!widget.fieldInfo.isPrimary) - FlowyOptionTile.text( - showTopBorder: false, - text: LocaleKeys.grid_field_insertLeft.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_left_s), - onTap: () async { - context.pop(); - showCreateFieldBottomSheet( - context, - widget.viewId, - position: OrderObjectPositionPB( - position: OrderObjectPositionTypePB.Before, - objectId: widget.fieldInfo.id, + return BlocProvider( + create: (_) => FieldEditorBloc( + viewId: widget.viewId, + fieldController: widget.fieldController, + field: widget.fieldInfo.field, + )..add(const FieldEditorEvent.initial()), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.field.name != current.field.name, + listener: (context, state) => controller.text = state.field.name, + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(16), + OptionTextField( + controller: controller, + type: state.field.fieldType, + onTextChanged: (text) { + context + .read() + .add(FieldEditorEvent.renameField(text)); + }, + onFieldTypeChanged: (fieldType) { + context + .read() + .add(FieldEditorEvent.switchFieldType(fieldType)); + }, + ), + const _Divider(), + FlowyOptionTile.text( + text: LocaleKeys.grid_field_editProperty.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_edit_s), + onTap: () { + showEditFieldScreen( + context, + widget.viewId, + state.field, + ); + context.pop(); + }, + ), + if (!widget.fieldInfo.isPrimary) + FlowyOptionTile.text( + showTopBorder: false, + text: fieldVisibility.isVisibleState() + ? LocaleKeys.grid_field_hide.tr() + : LocaleKeys.grid_field_show.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), + onTap: () async { + context.pop(); + if (fieldVisibility.isVisibleState()) { + await service.hide(); + } else { + await service.hide(); + } + }, + ), + if (!widget.fieldInfo.isPrimary) + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.grid_field_insertLeft.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_left_s), + onTap: () { + context.pop(); + mobileCreateFieldWorkflow( + context, + widget.viewId, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.Before, + objectId: widget.fieldInfo.id, + ), + ); + }, ), - ); - }, - ), - FlowyOptionTile.text( - showTopBorder: false, - text: LocaleKeys.grid_field_insertRight.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_right_s), - onTap: () async { - context.pop(); - showCreateFieldBottomSheet( - context, - widget.viewId, - position: OrderObjectPositionPB( - position: OrderObjectPositionTypePB.After, - objectId: widget.fieldInfo.id, + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.grid_field_insertRight.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_right_s), + onTap: () { + context.pop(); + mobileCreateFieldWorkflow( + context, + widget.viewId, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.After, + objectId: widget.fieldInfo.id, + ), + ); + }, ), - ); - }, - ), - if (!widget.fieldInfo.isPrimary) ...[ - FlowyOptionTile.text( - showTopBorder: false, - text: LocaleKeys.button_duplicate.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), - onTap: () async { - context.pop(); - await service.duplicate(); - }, - ), - FlowyOptionTile.text( - showTopBorder: false, - text: LocaleKeys.button_delete.tr(), - textColor: Theme.of(context).colorScheme.error, - leftIcon: FlowySvg( - FlowySvgs.m_field_delete_s, - color: Theme.of(context).colorScheme.error, - ), - onTap: () async { - context.pop(); - await service.delete(); - }, - ), - ], - ], + if (!widget.fieldInfo.isPrimary) ...[ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_duplicate.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), + onTap: () { + context.pop(); + service.duplicate(); + }, + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.m_field_delete_s, + color: Theme.of(context).colorScheme.error, + ), + onTap: () { + context.pop(); + service.delete(); + }, + ), + ], + ], + ); + }, + ), ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart index 8cbc701277c1f..b5ec0f9d80a73 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart @@ -2,6 +2,7 @@ import 'dart:ui'; 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/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; @@ -61,6 +62,7 @@ class _MobileDatabaseFieldListBody extends StatelessWidget { if (state.fieldContexts.isEmpty) { return const SizedBox.shrink(); } + final fields = [...state.fieldContexts]; final firstField = fields.removeAt(0); final firstCell = DatabaseFieldListTile( @@ -124,10 +126,16 @@ class _MobileDatabaseFieldListBody extends StatelessWidget { children: [ _divider(), _NewDatabaseFieldTile(viewId: viewId), - const VSpace(24), + VSpace( + context.bottomSheetPadding( + ignoreViewPadding: false, + ), + ), ], ) - : const VSpace(24), + : VSpace( + context.bottomSheetPadding(ignoreViewPadding: false), + ), itemCount: cells.length, itemBuilder: (context, index) => cells[index], ); @@ -205,7 +213,7 @@ class _NewDatabaseFieldTile extends StatelessWidget { color: Theme.of(context).hintColor, ), textColor: Theme.of(context).hintColor, - onTap: () => showCreateFieldBottomSheet(context, viewId), + onTap: () => mobileCreateFieldWorkflow(context, viewId), ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart index d1e58838c472e..7270588c60527 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart @@ -121,8 +121,8 @@ class _Header extends StatelessWidget { if (state.newSortFieldId != null && state.newSortCondition != null) { context.read().add( SortEditorEvent.createSort( - state.newSortFieldId!, - state.newSortCondition!, + fieldId: state.newSortFieldId!, + condition: state.newSortCondition!, ), ); } @@ -262,11 +262,9 @@ class _SortItem extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Expanded( - child: FlowyText.medium( - LocaleKeys.grid_sort_by.tr(), - fontSize: 15, - ), + child: FlowyText.medium( + LocaleKeys.grid_sort_by.tr(), + fontSize: 15, ), ), const VSpace(10), @@ -407,6 +405,8 @@ class _SortDetailContent extends StatelessWidget { final SortInfo? sortInfo; + bool get isCreatingNewSort => sortInfo == null; + @override Widget build(BuildContext context) { return Column( @@ -417,7 +417,7 @@ class _SortDetailContent extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: DefaultTabController( length: 2, - initialIndex: sortInfo == null + initialIndex: isCreatingNewSort ? 0 : sortInfo!.sortPB.condition == SortConditionPB.Ascending ? 0 @@ -489,30 +489,40 @@ class _SortDetailContent extends StatelessWidget { child: BlocBuilder( builder: (context, state) { final fields = state.allFields - .where( - (field) => - field.canCreateSort || - sortInfo != null && sortInfo!.fieldId == field.id, - ) + .where((field) => field.canCreateSort || field.hasSort) .toList(); return ListView.builder( itemCount: fields.length, itemBuilder: (context, index) { final fieldInfo = fields[index]; - final isSelected = sortInfo == null + final isSelected = isCreatingNewSort ? context .watch() .state .newSortFieldId == fieldInfo.id : sortInfo!.fieldId == fieldInfo.id; + + final enabled = fieldInfo.canCreateSort || + isCreatingNewSort && !fieldInfo.hasSort || + !isCreatingNewSort && sortInfo!.fieldId == fieldInfo.id; + return FlowyOptionTile.checkbox( text: fieldInfo.field.name, isSelected: isSelected, + textColor: enabled ? null : Theme.of(context).disabledColor, showTopBorder: false, onTap: () { - if (!isSelected) { + if (isSelected) { + return; + } + if (enabled) { _changeFieldId(context, fieldInfo.id); + } else { + Fluttertoast.showToast( + msg: LocaleKeys.grid_sort_fieldInUse.tr(), + gravity: ToastGravity.BOTTOM, + ); } }, ); @@ -526,28 +536,26 @@ class _SortDetailContent extends StatelessWidget { } void _changeCondition(BuildContext context, SortConditionPB newCondition) { - if (sortInfo == null) { + if (isCreatingNewSort) { context.read().changeSortCondition(newCondition); } else { context.read().add( SortEditorEvent.editSort( - sortInfo!.sortId, - null, - newCondition, + sortId: sortInfo!.sortId, + condition: newCondition, ), ); } } void _changeFieldId(BuildContext context, String newFieldId) { - if (sortInfo == null) { + if (isCreatingNewSort) { context.read().changeFieldId(newFieldId); } else { context.read().add( SortEditorEvent.editSort( - sortInfo!.sortId, - newFieldId, - null, + sortId: sortInfo!.sortId, + fieldId: newFieldId, ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart index 4966d888bb87d..4fd639c6211d1 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart @@ -44,8 +44,10 @@ class MobileDatabaseViewList extends StatelessWidget { useFilledDoneButton: false, onDone: (context) => Navigator.pop(context), ), - SingleChildScrollView( - child: Column( + Expanded( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, children: [ ...views.mapIndexed( (index, view) => MobileDatabaseViewListButton( @@ -55,6 +57,9 @@ class MobileDatabaseViewList extends StatelessWidget { ), const VSpace(20), const MobileNewDatabaseViewButton(), + VSpace( + context.bottomSheetPadding(ignoreViewPadding: false), + ), ], ), ), @@ -178,6 +183,7 @@ class MobileDatabaseViewListButton extends StatelessWidget { showMobileBottomSheet( context, showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.background, builder: (_) { return BlocProvider( create: (_) => diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart index 6902903f3b3ad..eff4dc5e0ba58 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart @@ -30,10 +30,9 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - _actionButton(context, _Action.edit, () { + _actionButton(context, _Action.edit, () async { final bloc = context.read(); - context.pop(); - showTransitionMobileBottomSheet( + await showTransitionMobileBottomSheet( context, showHeader: true, showDoneButton: true, @@ -45,6 +44,9 @@ class MobileDatabaseViewQuickActions extends StatelessWidget { ), ), ); + if (context.mounted) { + context.pop(); + } }), _divider(), _actionButton( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart index 7b81a70d96972..a2771ece26e75 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart @@ -3,8 +3,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/application/database_view_service.dart'; -import 'package:appflowy/plugins/database/application/layout/layout_service.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/layout_service.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart index fe482f57c4df1..14c4e022aef45 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart @@ -2,8 +2,8 @@ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; -class MobileEditorScreen extends StatelessWidget { - const MobileEditorScreen({ +class MobileDocumentScreen extends StatelessWidget { + const MobileDocumentScreen({ super.key, required this.id, this.title, 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 5c19b3d6be736..d7d9b7993f8ba 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 @@ -3,7 +3,7 @@ import 'package:appflowy/mobile/application/mobile_router.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/workspace/application/menu/sidebar_root_views_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -27,10 +27,13 @@ class MobileFavoritePageFolder extends StatelessWidget { return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => MenuBloc( - user: userProfile, - workspaceId: workspaceSetting.workspaceId, - )..add(const MenuEvent.initial()), + create: (_) => SidebarRootViewsBloc() + ..add( + SidebarRootViewsEvent.initial( + userProfile, + workspaceSetting.workspaceId, + ), + ), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), @@ -38,11 +41,11 @@ class MobileFavoritePageFolder extends StatelessWidget { ], child: MultiBlocListener( listeners: [ - BlocListener( + BlocListener( listenWhen: (p, c) => - p.lastCreatedView?.id != c.lastCreatedView?.id, + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) => - context.pushView(state.lastCreatedView!), + context.pushView(state.lastCreatedRootView!), ), ], child: Builder( 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 1ed2a7ea209db..d6e9a18272b20 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 @@ -35,10 +35,12 @@ class MobileFavoriteScreen extends StatelessWidget { }, (error) => null, ); - final userProfile = - snapshots.data?[1].fold((error) => null, (userProfilePB) { - return userProfilePB as UserProfilePB?; - }); + final userProfile = snapshots.data?[1].fold( + (userProfilePB) { + return userProfilePB as UserProfilePB?; + }, + (error) => null, + ); // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. 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 da58fb8f5c0ad..d51b0958b75ad 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -1,14 +1,18 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; -import 'package:appflowy/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart'; +import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +// Contains Public And Private Sections class MobileFolders extends StatelessWidget { const MobileFolders({ super.key, @@ -26,39 +30,56 @@ class MobileFolders extends StatelessWidget { return MultiBlocProvider( providers: [ BlocProvider( - create: (_) => MenuBloc( - user: user, - workspaceId: workspaceSetting.workspaceId, - )..add(const MenuEvent.initial()), + create: (_) => SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + user, + workspaceSetting.workspaceId, + ), + ), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => - p.lastCreatedView?.id != c.lastCreatedView?.id, - listener: (context, state) => - context.pushView(state.lastCreatedView!), - ), - ], - child: Builder( - builder: (context) { - final menuState = context.watch().state; - return SlidableAutoCloseBehavior( - child: Column( - children: [ - MobilePersonalFolder( - views: menuState.views, - ), - const VSpace(8.0), - ], - ), - ); - }, - ), + child: BlocConsumer( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) { + final lastCreatedRootView = state.lastCreatedRootView; + if (lastCreatedRootView != null) { + context.pushView(lastCreatedRootView); + } + }, + builder: (context, state) { + final isCollaborativeWorkspace = + context.read().state.isCollabWorkspaceOn; + return SlidableAutoCloseBehavior( + child: Column( + children: [ + ...isCollaborativeWorkspace + ? [ + MobileSectionFolder( + title: LocaleKeys.sideBar_public.tr(), + views: state.section.publicViews, + ), + const VSpace(8.0), + MobileSectionFolder( + title: LocaleKeys.sideBar_private.tr(), + views: state.section.privateViews, + ), + ] + : [ + MobileSectionFolder( + title: LocaleKeys.sideBar_personal.tr(), + views: state.section.publicViews, + ), + ], + const VSpace(8.0), + ], + ), + ); + }, ), ); } 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 9c296774a0b24..b56b36a839f89 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 @@ -8,6 +8,7 @@ import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.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/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; @@ -15,6 +16,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -41,10 +43,12 @@ class MobileHomeScreen extends StatelessWidget { }, (error) => null, ); - final userProfile = - snapshots.data?[1].fold((error) => null, (userProfilePB) { - return userProfilePB as UserProfilePB?; - }); + final userProfile = snapshots.data?[1].fold( + (userProfilePB) { + return userProfilePB as UserProfilePB?; + }, + (error) => null, + ); // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. @@ -80,55 +84,68 @@ class MobileHomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - // Header - Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: Platform.isAndroid ? 8.0 : 0.0, - ), - child: MobileHomePageHeader( - userProfile: userProfile, - ), + return BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: userProfile) + ..add( + const UserWorkspaceEvent.initial(), ), - const Divider(), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + builder: (context, state) { + return Column( + children: [ + // Header + Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: Platform.isAndroid ? 8.0 : 0.0, + ), + child: MobileHomePageHeader( + userProfile: userProfile, + ), + ), + const Divider(), - // Folder - Expanded( - child: Scrollbar( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Recent files - const MobileRecentFolder(), + // Folder + Expanded( + child: Scrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Recent files + const MobileRecentFolder(), - // Folders - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: MobileFolders( - user: userProfile, - workspaceSetting: workspaceSetting, - showFavorite: false, + // Folders + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: MobileFolders( + user: userProfile, + workspaceSetting: workspaceSetting, + showFavorite: false, + ), + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: _TrashButton(), + ), + ], ), ), - const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 24), - child: _TrashButton(), - ), - ], + ), ), ), - ), - ), - ), - ], + ], + ); + }, + ), ); } } 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 157f878b75edf..e8aa0d2b266e1 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 @@ -5,8 +5,10 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -14,7 +16,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileHomePageHeader extends StatelessWidget { - const MobileHomePageHeader({super.key, required this.userProfile}); + const MobileHomePageHeader({ + super.key, + required this.userProfile, + }); final UserProfilePB userProfile; @@ -25,29 +30,17 @@ class MobileHomePageHeader extends StatelessWidget { ..add(const SettingsUserEvent.initial()), child: BlocBuilder( builder: (context, state) { - final userIcon = state.userProfile.iconUrl; + final isCollaborativeWorkspace = + context.read().state.isCollabWorkspaceOn; return ConstrainedBox( constraints: const BoxConstraints(minHeight: 52), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - _UserIcon(userIcon: userIcon), - const HSpace(12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const FlowyText.medium('AppFlowy', fontSize: 18), - const VSpace(4), - FlowyText.regular( - userProfile.email.isNotEmpty - ? state.userProfile.email - : state.userProfile.name, - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface, - overflow: TextOverflow.ellipsis, - ), - ], - ), + child: isCollaborativeWorkspace + ? _MobileWorkspace(userProfile: userProfile) + : _MobileUser(userProfile: userProfile), ), IconButton( onPressed: () => @@ -63,6 +56,83 @@ class MobileHomePageHeader extends StatelessWidget { } } +class _MobileUser extends StatelessWidget { + const _MobileUser({ + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final userIcon = userProfile.iconUrl; + return Row( + children: [ + _UserIcon(userIcon: userIcon), + const HSpace(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FlowyText.medium('AppFlowy', fontSize: 18), + const VSpace(4), + FlowyText.regular( + userProfile.email.isNotEmpty + ? userProfile.email + : userProfile.name, + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class _MobileWorkspace extends StatelessWidget { + const _MobileWorkspace({ + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + final workspaces = state.workspaces; + if (currentWorkspace == null || workspaces.isEmpty) { + return const SizedBox.shrink(); + } + return Row( + children: [ + const HSpace(2.0), + SizedBox.square( + dimension: 34.0, + child: WorkspaceIcon( + workspace: currentWorkspace, + iconSize: 26, + enableEdit: false, + ), + ), + const HSpace(8), + Expanded( + child: FlowyText.medium( + currentWorkspace.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + }, + ); + } +} + class _UserIcon extends StatelessWidget { const _UserIcon({ required this.userIcon, 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 c01257a4744e3..39c11298a406f 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 @@ -35,12 +35,15 @@ class _MobileHomeSettingPageState extends State { return const Center(child: CircularProgressIndicator.adaptive()); } - final userProfile = snapshot.data?.fold((error) { - errorMsg = error.msg; - return null; - }, (userProfile) { - return userProfile; - }); + final userProfile = snapshot.data?.fold( + (userProfile) { + return userProfile; + }, + (error) { + errorMsg = error.msg; + return null; + }, + ); return Scaffold( appBar: FlowyAppBar( 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/section_folder/mobile_home_section_folder.dart similarity index 85% rename from frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart rename to frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart index a5b04a7093f43..0042fe1cc5ffb 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart @@ -1,6 +1,6 @@ import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart'; -import 'package:appflowy/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart'; +import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; @@ -9,18 +9,20 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class MobilePersonalFolder extends StatelessWidget { - const MobilePersonalFolder({ +class MobileSectionFolder extends StatelessWidget { + const MobileSectionFolder({ super.key, + required this.title, required this.views, }); + final String title; final List views; @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => FolderBloc(type: FolderCategoryType.personal) + create: (context) => FolderBloc(type: FolderCategoryType.private) ..add( const FolderEvent.initial(), ), @@ -28,7 +30,8 @@ class MobilePersonalFolder extends StatelessWidget { builder: (context, state) { return Column( children: [ - MobilePersonalFolderHeader( + MobileSectionFolderHeader( + title: title, isExpanded: context.read().state.isExpanded, onPressed: () => context .read() @@ -45,9 +48,9 @@ class MobilePersonalFolder extends StatelessWidget { ...views.map( (view) => MobileViewItem( key: ValueKey( - '${FolderCategoryType.personal.name} ${view.id}', + '${FolderCategoryType.private.name} ${view.id}', ), - categoryType: FolderCategoryType.personal, + categoryType: FolderCategoryType.private, isFirstChild: view.id == views.first.id, view: view, level: 0, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart similarity index 74% rename from frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart rename to frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart index 91baaf4f68214..16383c8b4bce3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/personal_folder/mobile_home_personal_folder_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart @@ -1,30 +1,32 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_root_views_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class MobilePersonalFolderHeader extends StatefulWidget { - const MobilePersonalFolderHeader({ +class MobileSectionFolderHeader extends StatefulWidget { + const MobileSectionFolderHeader({ super.key, + required this.title, required this.onPressed, required this.onAdded, required this.isExpanded, }); + final String title; final VoidCallback onPressed; final VoidCallback onAdded; final bool isExpanded; @override - State createState() => - _MobilePersonalFolderHeaderState(); + State createState() => + _MobileSectionFolderHeaderState(); } -class _MobilePersonalFolderHeaderState - extends State { +class _MobileSectionFolderHeaderState extends State { double _turns = 0; @override @@ -35,7 +37,7 @@ class _MobilePersonalFolderHeaderState Expanded( child: FlowyButton( text: FlowyText.semibold( - LocaleKeys.sideBar_personal.tr(), + widget.title, fontSize: 20.0, ), margin: const EdgeInsets.symmetric(vertical: 8), @@ -67,10 +69,11 @@ class _MobilePersonalFolderHeaderState size: Size.square(iconSize), ), onPressed: () { - context.read().add( - MenuEvent.createApp( + context.read().add( + SidebarRootViewsEvent.createRootView( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), index: 0, + viewSection: ViewSectionPB.Private, ), ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart index 8156e8debec62..64e3e8824df0e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -4,7 +4,7 @@ import 'package:appflowy/mobile/presentation/notifications/widgets/mobile_notifi import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/notifications/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; @@ -80,12 +80,15 @@ class _NotificationScreenContent extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => MenuBloc( - workspaceId: workspaceSetting.workspaceId, - user: userProfile, - )..add(const MenuEvent.initial()), - child: BlocBuilder( - builder: (context, menuState) => + create: (_) => SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + userProfile, + workspaceSetting.workspaceId, + ), + ), + child: BlocBuilder( + builder: (context, sectionState) => BlocBuilder( builder: (context, filterState) => BlocBuilder( @@ -119,7 +122,7 @@ class _NotificationScreenContent extends StatelessWidget { NotificationsView( shownReminders: pastReminders, reminderBloc: reminderBloc, - views: menuState.views, + views: sectionState.section.publicViews, onAction: _onAction, onDelete: _onDelete, onReadChanged: _onReadChanged, @@ -131,7 +134,7 @@ class _NotificationScreenContent extends StatelessWidget { NotificationsView( shownReminders: upcomingReminders, reminderBloc: reminderBloc, - views: menuState.views, + views: sectionState.section.publicViews, isUpcoming: true, onAction: _onAction, ), 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 6f6c3adf490d8..c1ffc78e763bc 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 @@ -406,6 +406,7 @@ class _SingleMobileInnerViewItemState extends State { ViewEvent.createView( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layout, + section: widget.categoryType.toViewSectionPB, ), ); }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart index 2c5c3fad3eb75..66d25c58c6599 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart @@ -1,9 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/device_info_task.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 '../widgets/widgets.dart'; @@ -22,14 +23,14 @@ class AboutSettingGroup extends StatelessWidget { trailing: const Icon( Icons.chevron_right, ), - onTap: () => safeLaunchUrl('https://appflowy.io/privacy/mobile'), + onTap: () => afLaunchUrlString('https://appflowy.io/privacy/app'), ), MobileSettingItem( name: LocaleKeys.settings_mobile_termsAndConditions.tr(), trailing: const Icon( Icons.chevron_right, ), - onTap: () => safeLaunchUrl('https://appflowy.io/terms'), + onTap: () => afLaunchUrlString('https://appflowy.io/terms/app'), ), MobileSettingItem( name: LocaleKeys.settings_mobile_version.tr(), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart index 1d32071701674..e3526c3df03cd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart @@ -1,10 +1,11 @@ +import 'package:flutter/material.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/more/font_size_slider.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.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'; import '../setting.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart index a2a6ca04f56ee..f79bc3dd788a8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart @@ -1,10 +1,12 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/util/google_font_family_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -23,9 +25,7 @@ class FontPickerScreen extends StatelessWidget { } class LanguagePickerPage extends StatefulWidget { - const LanguagePickerPage({ - super.key, - }); + const LanguagePickerPage({super.key}); @override State createState() => _LanguagePickerPageState(); @@ -52,6 +52,7 @@ class _LanguagePickerPageState extends State { body: SafeArea( child: Scrollbar( child: ListView.builder( + itemCount: availableFonts.length + 1, // with search bar itemBuilder: (context, index) { if (index == 0) { // search bar @@ -65,7 +66,8 @@ class _LanguagePickerPageState extends State { setState(() { availableFonts = _availableFonts .where( - (element) => parseFontFamilyName(element) + (font) => font + .parseFontFamilyName() .toLowerCase() .contains(keyword.toLowerCase()), ) @@ -75,8 +77,9 @@ class _LanguagePickerPageState extends State { ), ); } + final fontFamilyName = availableFonts[index - 1]; - final displayName = parseFontFamilyName(fontFamilyName); + final displayName = fontFamilyName.parseFontFamilyName(); return FlowyOptionTile.checkbox( text: displayName, isSelected: selectedFontFamilyName == fontFamilyName, @@ -86,17 +89,9 @@ class _LanguagePickerPageState extends State { backgroundColor: Colors.transparent, ); }, - itemCount: availableFonts.length + 1, // with search bar ), ), ), ); } - - String parseFontFamilyName(String fontFamilyName) { - final camelCase = RegExp('(?<=[a-z])[A-Z]'); - return fontFamilyName - .replaceAll('_regular', '') - .replaceAllMapped(camelCase, (m) => ' ${m.group(0)}'); - } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart index ff33cc477dd55..23c31cc9169fe 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; 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 26a2e0ad8ca8b..cfdf3defb0c2a 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 @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; @@ -5,10 +7,10 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../widgets/widgets.dart'; + import 'personal_info.dart'; class PersonalInfoSettingGroup extends StatelessWidget { @@ -34,7 +36,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { settingItemList: [ MobileSettingItem( name: userName, - subtitle: isAuthEnabled + subtitle: isAuthEnabled && userProfile.email.isNotEmpty ? Text( userProfile.email, style: theme.textTheme.bodyMedium?.copyWith( 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 03dc888c4c663..5222a05b8ffb6 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 @@ -1,11 +1,15 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/share_log_files.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -28,7 +32,7 @@ class SupportSettingGroup extends StatelessWidget { trailing: const Icon( Icons.chevron_right, ), - onTap: () => safeLaunchUrl('https://discord.gg/JucBXeU2FE'), + onTap: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'), ), MobileSettingItem( name: LocaleKeys.workspace_errorActions_reportIssue.tr(), @@ -50,6 +54,36 @@ class SupportSettingGroup extends StatelessWidget { ); }, ), + MobileSettingItem( + name: LocaleKeys.settings_files_clearCache.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () async { + await showFlowyMobileConfirmDialog( + context, + title: FlowyText( + LocaleKeys.settings_files_areYouSureToClearCache.tr(), + maxLines: 2, + ), + content: FlowyText( + LocaleKeys.settings_files_clearCacheDesc.tr(), + fontSize: 12, + maxLines: 4, + ), + actionButtonTitle: LocaleKeys.button_yes.tr(), + onActionButtonPressed: () async { + await getIt().clearAllCache(); + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.settings_files_clearCacheSuccess.tr(), + ); + } + }, + ); + }, + ), ], ), ); @@ -73,7 +107,7 @@ class _ReportIssuesWidget extends StatelessWidget { text: LocaleKeys.workspace_errorActions_reportIssueOnGithub.tr(), onTap: () { final String os = Platform.operatingSystem; - safeLaunchUrl( + afLaunchUrlString( '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/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index b83081afece21..ea55a16173462 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -5,6 +5,7 @@ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -27,12 +28,9 @@ class UserSessionSettingGroup extends StatelessWidget { create: (context) => getIt(), child: BlocConsumer( listener: (context, state) { - state.successOrFail.fold( - () => null, - (result) => result.fold( - (l) {}, - (r) async => runAppFlowy(), - ), + state.successOrFail?.fold( + (result) => runAppFlowy(), + (e) => Log.error(e), ); }, builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart index cdb54ec122be4..6dc45c1c40ead 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart @@ -43,6 +43,7 @@ class MobileSettingItem extends StatelessWidget { trailing: trailing, onTap: onTap, visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0), ), ); } 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 index 9497d779ddf70..8aea36fbb9fc5 100644 --- 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 @@ -1,9 +1,10 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.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:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; enum _FlowyMobileStateContainerType { @@ -80,7 +81,7 @@ class FlowyMobileStateContainer extends StatelessWidget { onPressed: () { final String? version = snapshot.data?.version; final String os = Platform.operatingSystem; - safeLaunchUrl( + afLaunchUrlString( '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', ); }, @@ -90,7 +91,7 @@ class FlowyMobileStateContainer extends StatelessWidget { ), OutlinedButton( onPressed: () => - safeLaunchUrl('https://discord.gg/JucBXeU2FE'), + afLaunchUrlString('https://discord.gg/JucBXeU2FE'), child: Text( LocaleKeys.workspace_errorActions_reachOut.tr(), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart index 5ec4ce84d2e3c..ceca40d019d7d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart @@ -13,6 +13,7 @@ enum FlowyOptionTileType { class FlowyOptionTile extends StatelessWidget { const FlowyOptionTile._({ + super.key, required this.type, this.showTopBorder = true, this.showBottomBorder = true, @@ -88,9 +89,11 @@ class FlowyOptionTile extends StatelessWidget { } factory FlowyOptionTile.checkbox({ + Key? key, required String text, required bool isSelected, required VoidCallback? onTap, + Color? textColor, Widget? leftIcon, Widget? content, bool showTopBorder = true, @@ -99,9 +102,11 @@ class FlowyOptionTile extends StatelessWidget { Color? backgroundColor, }) { return FlowyOptionTile._( + key: key, type: FlowyOptionTileType.checkbox, isSelected: isSelected, text: text, + textColor: textColor, content: content, onTap: onTap, fontFamily: fontFamily, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart index 3fe2087e6b99f..707671d4d6933 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart @@ -11,8 +11,22 @@ extension CalcTypeLabel on CalculationType { LocaleKeys.grid_calculationTypeLabel_median.tr(), CalculationType.Min => LocaleKeys.grid_calculationTypeLabel_min.tr(), CalculationType.Sum => LocaleKeys.grid_calculationTypeLabel_sum.tr(), + CalculationType.Count => + LocaleKeys.grid_calculationTypeLabel_count.tr(), + CalculationType.CountEmpty => + LocaleKeys.grid_calculationTypeLabel_countEmpty.tr(), + CalculationType.CountNonEmpty => + LocaleKeys.grid_calculationTypeLabel_countNonEmpty.tr(), _ => throw UnimplementedError( 'Label for $this has not been implemented', ), }; + + String get shortLabel => switch (this) { + CalculationType.CountEmpty => + LocaleKeys.grid_calculationTypeLabel_countEmptyShort.tr(), + CalculationType.CountNonEmpty => + LocaleKeys.grid_calculationTypeLabel_countNonEmptyShort.tr(), + _ => label, + }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart index eb6939c646207..e074a9b283c70 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart @@ -4,11 +4,11 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef UpdateCalculationValue - = Either; + = FlowyResult; class CalculationsListener { CalculationsListener({required this.viewId}); @@ -31,15 +31,15 @@ class CalculationsListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateCalculation: _calculationNotifier?.value = result.fold( - (payload) => left( + (payload) => FlowyResult.success( CalculationChangesetNotificationPB.fromBuffer(payload), ), - (err) => right(err), + (err) => FlowyResult.failure(err), ); default: break; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart index 874c565c3e8c7..e3ef8d578ea89 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart @@ -1,7 +1,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class CalculationsBackendService { const CalculationsBackendService({required this.viewId}); @@ -9,7 +9,9 @@ class CalculationsBackendService { final String viewId; // Get Calculations (initial fetch) - Future> getCalculations() async { + + Future> + getCalculations() async { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllCalculations(payload).send(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart index f315e8f768ca2..7fe92cd137372 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; -import 'package:appflowy/plugins/database/application/cell/checklist_cell_service.dart'; +import 'package:appflowy/plugins/database/domain/checklist_cell_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; @@ -11,7 +11,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'checklist_cell_bloc.freezed.dart'; class ChecklistSelectOption { - ChecklistSelectOption(this.isSelected, this.data); + ChecklistSelectOption({required this.isSelected, required this.data}); final bool isSelected; final SelectOptionPB data; @@ -26,6 +26,7 @@ class ChecklistCellBloc extends Bloc { ), super(ChecklistCellState.initial(cellController)) { _dispatch(); + _startListening(); } final ChecklistCellController cellController; @@ -46,9 +47,6 @@ class ChecklistCellBloc extends Bloc { on( (event, emit) async { await event.when( - initial: () { - _startListening(); - }, didReceiveOptions: (data) { if (data == null) { emit( @@ -71,8 +69,8 @@ class ChecklistCellBloc extends Bloc { updateTaskName: (option, name) { _updateOption(option, name); }, - selectTask: (option) async { - await _checklistCellService.select(optionId: option.id); + selectTask: (id) async { + await _checklistCellService.select(optionId: id); }, createNewTask: (name) async { final result = await _checklistCellService.create(name: name); @@ -81,8 +79,8 @@ class ChecklistCellBloc extends Bloc { (err) => Log.error(err), ); }, - deleteTask: (option) async { - await _deleteOption([option]); + deleteTask: (id) async { + await _deleteOption([id]); }, ); }, @@ -102,21 +100,17 @@ class ChecklistCellBloc extends Bloc { void _updateOption(SelectOptionPB option, String name) async { final result = await _checklistCellService.updateName(option: option, name: name); - result.fold((l) => null, (err) => Log.error(err)); } - Future _deleteOption(List options) async { - final result = await _checklistCellService.delete( - optionIds: options.map((e) => e.id).toList(), - ); + Future _deleteOption(List options) async { + final result = await _checklistCellService.delete(optionIds: options); result.fold((l) => null, (err) => Log.error(err)); } } @freezed class ChecklistCellEvent with _$ChecklistCellEvent { - const factory ChecklistCellEvent.initial() = _InitialCell; const factory ChecklistCellEvent.didReceiveOptions( ChecklistCellDataPB? data, ) = _DidReceiveCellUpdate; @@ -124,12 +118,10 @@ class ChecklistCellEvent with _$ChecklistCellEvent { SelectOptionPB option, String name, ) = _UpdateTaskName; - const factory ChecklistCellEvent.selectTask(SelectOptionPB task) = - _SelectTask; + const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask; const factory ChecklistCellEvent.createNewTask(String description) = _CreateNewTask; - const factory ChecklistCellEvent.deleteTask(SelectOptionPB option) = - _DeleteTask; + const factory ChecklistCellEvent.deleteTask(String taskId) = _DeleteTask; } @freezed @@ -157,16 +149,14 @@ List _makeChecklistSelectOptions( if (data == null) { return []; } - - final List options = []; - final List allOptions = List.from(data.options); - final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList(); - - for (final option in allOptions) { - options.add( - ChecklistSelectOption(selectedOptionIds.contains(option.id), option), - ); - } - - return options; + return data.options + .map( + (option) => ChecklistSelectOption( + isSelected: data.selectedOptions.any( + (selected) => selected.id == option.id, + ), + data: option, + ), + ) + .toList(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart index 6b133a3a6115b..bb6c50c2e0602 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; -import 'package:appflowy/plugins/database/application/cell/date_cell_service.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/date_cell_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart new file mode 100644 index 0000000000000..39528a97c2a61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'relation_cell_bloc.freezed.dart'; + +class RelationCellBloc extends Bloc { + RelationCellBloc({required this.cellController}) + : super(RelationCellState.initial()) { + _dispatch(); + _startListening(); + _init(); + } + + final RelationCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didUpdateCell: (RelationCellDataPB? cellData) async { + if (cellData == null || + cellData.rowIds.isEmpty || + state.relatedDatabaseMeta == null) { + emit(state.copyWith(rows: const [])); + return; + } + final payload = RepeatedRowIdPB( + databaseId: state.relatedDatabaseMeta!.databaseId, + rowIds: cellData.rowIds, + ); + final result = + await DatabaseEventGetRelatedRowDatas(payload).send(); + final rows = result.fold( + (data) => data.rows, + (err) { + Log.error(err); + return const []; + }, + ); + emit(state.copyWith(rows: rows)); + }, + didUpdateRelationTypeOption: (typeOption) async { + if (typeOption.databaseId.isEmpty) { + return; + } + final meta = await _loadDatabaseMeta(typeOption.databaseId); + emit(state.copyWith(relatedDatabaseMeta: meta)); + _loadCellData(); + }, + selectDatabaseId: (databaseId) async { + await _updateTypeOption(databaseId); + }, + selectRow: (rowId) async { + await _handleSelectRow(rowId); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (data) { + if (!isClosed) { + add(RelationCellEvent.didUpdateCell(data)); + } + }, + onCellFieldChanged: (field) { + // hack: SingleFieldListener receives notification before + // FieldController's copy is updated. + Future.delayed(const Duration(milliseconds: 50), () { + if (!isClosed) { + final RelationTypeOptionPB typeOption = + cellController.getTypeOption(RelationTypeOptionDataParser()); + add(RelationCellEvent.didUpdateRelationTypeOption(typeOption)); + } + }); + }, + ); + } + + void _init() { + final typeOption = + cellController.getTypeOption(RelationTypeOptionDataParser()); + add(RelationCellEvent.didUpdateRelationTypeOption(typeOption)); + } + + void _loadCellData() { + final cellData = cellController.getCellData(); + if (!isClosed) { + add(RelationCellEvent.didUpdateCell(cellData)); + } + } + + Future _handleSelectRow(String rowId) async { + final payload = RelationCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + ); + if (state.rows.any((row) => row.rowId == rowId)) { + payload.removedRowIds.add(rowId); + } else { + payload.insertedRowIds.add(rowId); + } + final result = await DatabaseEventUpdateRelationCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _loadDatabaseMeta(String databaseId) async { + final getDatabaseResult = await DatabaseEventGetDatabases().send(); + final databaseMeta = getDatabaseResult.fold( + (s) => s.items.firstWhereOrNull( + (metaPB) => metaPB.databaseId == databaseId, + ), + (f) => null, + ); + if (databaseMeta != null) { + final result = + await ViewBackendService.getView(databaseMeta.inlineViewId); + return result.fold( + (s) => DatabaseMeta( + databaseId: databaseId, + inlineViewId: databaseMeta.inlineViewId, + databaseName: s.name, + ), + (f) => null, + ); + } + return null; + } + + Future _updateTypeOption(String databaseId) async { + final newDateTypeOption = RelationTypeOptionPB( + databaseId: databaseId, + ); + + final result = await FieldBackendService.updateFieldTypeOption( + viewId: cellController.viewId, + fieldId: cellController.fieldInfo.id, + typeOptionData: newDateTypeOption.writeToBuffer(), + ); + result.fold((s) => null, (err) => Log.error(err)); + } +} + +@freezed +class RelationCellEvent with _$RelationCellEvent { + const factory RelationCellEvent.didUpdateRelationTypeOption( + RelationTypeOptionPB typeOption, + ) = _DidUpdateRelationTypeOption; + const factory RelationCellEvent.didUpdateCell(RelationCellDataPB? data) = + _DidUpdateCell; + const factory RelationCellEvent.selectDatabaseId( + String databaseId, + ) = _SelectDatabaseId; + const factory RelationCellEvent.selectRow(String rowId) = _SelectRowId; +} + +@freezed +class RelationCellState with _$RelationCellState { + const factory RelationCellState({ + required DatabaseMeta? relatedDatabaseMeta, + required List rows, + }) = _RelationCellState; + + factory RelationCellState.initial() => const RelationCellState( + relatedDatabaseMeta: null, + rows: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart new file mode 100644 index 0000000000000..995e5c85d4525 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'relation_row_search_bloc.freezed.dart'; + +class RelationRowSearchBloc + extends Bloc { + RelationRowSearchBloc({ + required this.databaseId, + }) : super(RelationRowSearchState.initial()) { + _dispatch(); + _init(); + } + + final String databaseId; + final List allRows = []; + + void _dispatch() { + on( + (event, emit) { + event.when( + didUpdateRowList: (List rowList) { + allRows.clear(); + allRows.addAll(rowList); + emit(state.copyWith(filteredRows: allRows)); + }, + updateFilter: (String filter) => _updateFilter(filter, emit), + ); + }, + ); + } + + Future _init() async { + final payload = DatabaseIdPB(value: databaseId); + final result = await DatabaseEventGetRelatedDatabaseRows(payload).send(); + result.fold( + (data) => add(RelationRowSearchEvent.didUpdateRowList(data.rows)), + (err) => Log.error(err), + ); + } + + void _updateFilter(String filter, Emitter emit) { + final rows = [...allRows]; + if (filter.isNotEmpty) { + rows.retainWhere( + (row) => row.name.toLowerCase().contains(filter.toLowerCase()), + ); + } + emit(state.copyWith(filter: filter, filteredRows: rows)); + } +} + +@freezed +class RelationRowSearchEvent with _$RelationRowSearchEvent { + const factory RelationRowSearchEvent.didUpdateRowList( + List rowList, + ) = _DidUpdateRowList; + const factory RelationRowSearchEvent.updateFilter(String filter) = + _UpdateFilter; +} + +@freezed +class RelationRowSearchState with _$RelationRowSearchState { + const factory RelationRowSearchState({ + required String filter, + required List filteredRows, + }) = _RelationRowSearchState; + + factory RelationRowSearchState.initial() => const RelationRowSearchState( + filter: "", + filteredRows: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart index ab879b7d8bb5f..224d9167191b6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_editor_bloc.dart @@ -1,12 +1,10 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; - import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; -import 'package:appflowy/plugins/database/application/cell/select_option_cell_service.dart'; +import 'package:appflowy/plugins/database/domain/select_option_cell_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -52,7 +50,7 @@ class SelectOptionCellEditorBloc await _createOption(optionName); emit( state.copyWith( - filter: none(), + filter: null, ), ); }, @@ -164,7 +162,7 @@ class SelectOptionCellEditorBloc } // clear the filter - emit(state.copyWith(filter: none())); + emit(state.copyWith(filter: null)); } void _selectMultipleOptions(List optionNames) { @@ -186,12 +184,12 @@ class SelectOptionCellEditorBloc void _filterOption(String optionName, Emitter emit) { final _MakeOptionResult result = _makeOptions( - Some(optionName), + optionName, state.allOptions, ); emit( state.copyWith( - filter: Some(optionName), + filter: optionName, options: result.options, createOption: result.createOption, ), @@ -201,7 +199,7 @@ class SelectOptionCellEditorBloc Future _loadOptions() async { final result = await _selectOptionService.getCellData(); if (isClosed) { - Log.warn("Unexpected closing the bloc"); + Log.warn("Unexpecteded closing the bloc"); return; } @@ -220,28 +218,26 @@ class SelectOptionCellEditorBloc } _MakeOptionResult _makeOptions( - Option filter, + String? filter, List allOptions, ) { final List options = List.from(allOptions); - Option createOption = filter; - - filter.foldRight(null, (filter, previous) { - if (filter.isNotEmpty) { - options.retainWhere((option) { - final name = option.name.toLowerCase(); - final lFilter = filter.toLowerCase(); - - if (name == lFilter) { - createOption = none(); - } - - return name.contains(lFilter); - }); - } else { - createOption = none(); - } - }); + String? createOption = filter; + + if (filter != null && filter.isNotEmpty) { + options.retainWhere((option) { + final name = option.name.toLowerCase(); + final lFilter = filter.toLowerCase(); + + if (name == lFilter) { + createOption = null; + } + + return name.contains(lFilter); + }); + } else { + createOption = null; + } return _MakeOptionResult( options: options, @@ -295,8 +291,8 @@ class SelectOptionEditorState with _$SelectOptionEditorState { required List options, required List allOptions, required List selectedOptions, - required Option createOption, - required Option filter, + required String? createOption, + required String? filter, }) = _SelectOptionEditorState; factory SelectOptionEditorState.initial(SelectOptionCellController context) { @@ -305,8 +301,8 @@ class SelectOptionEditorState with _$SelectOptionEditorState { options: data?.options ?? [], allOptions: data?.options ?? [], selectedOptions: data?.selectOptions ?? [], - createOption: none(), - filter: none(), + createOption: null, + filter: null, ); } } @@ -318,5 +314,5 @@ class _MakeOptionResult { }); List options; - Option createOption; + String? createOption; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart index dbd2258cc143e..299e8ced61882 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart'; @@ -29,15 +30,17 @@ class URLCellBloc extends Bloc { void _dispatch() { on( (event, emit) async { - event.when( + await event.when( initial: () { _startListening(); }, - didReceiveCellUpdate: (cellData) { + didReceiveCellUpdate: (cellData) async { + final content = cellData?.content ?? ""; + final isValid = await isUrlValid(content); emit( state.copyWith( - content: cellData?.content ?? "", - url: cellData?.url ?? "", + content: content, + isValid: isValid, ), ); }, @@ -58,6 +61,35 @@ class URLCellBloc extends Bloc { }, ); } + + Future isUrlValid(String content) async { + if (content.isEmpty) { + return true; + } + + try { + // check protocol is provided + const linkPrefix = [ + 'http://', + 'https://', + 'file://', + 'ftp://', + 'ftps://', + 'mailto:', + ]; + final shouldAddScheme = + !linkPrefix.any((pattern) => content.startsWith(pattern)); + final url = shouldAddScheme ? 'http://$content' : content; + + // get hostname and check validity + final uri = Uri.parse(url); + final hostName = uri.host; + await InternetAddress.lookup(hostName); + } catch (_) { + return false; + } + return true; + } } @freezed @@ -72,14 +104,14 @@ class URLCellEvent with _$URLCellEvent { class URLCellState with _$URLCellState { const factory URLCellState({ required String content, - required String url, + required bool isValid, }) = _URLCellState; factory URLCellState.initial(URLCellController context) { final cellData = context.getCellData(); return URLCellState( content: cellData?.content ?? "", - url: cellData?.url ?? "", + isValid: true, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart index 0c4c5c0697ab9..171f86f11d1a4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart @@ -4,7 +4,7 @@ import 'cell_controller.dart'; /// CellMemCache is used to cache cell data of each block. /// We use CellContext to index the cell in the cache. -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid +/// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid /// for more information class CellMemCache { CellMemCache(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart index 65c17e763c85b..10c37b57ed090 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart @@ -2,22 +2,21 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_listener.dart'; +import 'package:appflowy/plugins/database/domain/cell_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_listener.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; -import 'package:appflowy/plugins/database/application/row/row_meta_listener.dart'; +import 'package:appflowy/plugins/database/domain/row_meta_listener.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'cell_cache.dart'; import 'cell_data_loader.dart'; import 'cell_data_persistence.dart'; -import 'cell_listener.dart'; part 'cell_controller.freezed.dart'; @@ -173,7 +172,7 @@ class CellController { Future saveCellData( D data, { bool debounce = false, - void Function(Option)? onFinish, + void Function(FlowyError?)? onFinish, }) async { _loadDataOperation?.cancel(); if (debounce) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart index 63e9324f4aad9..881e6e164bd32 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart @@ -14,6 +14,7 @@ typedef ChecklistCellController = CellController; typedef DateCellController = CellController; typedef TimestampCellController = CellController; typedef URLCellController = CellController; +typedef RelationCellController = CellController; CellController makeCellController( DatabaseController databaseController, @@ -118,6 +119,19 @@ CellController makeCellController( ), cellDataPersistence: TextCellDataPersistence(), ); + + case FieldType.Relation: + return RelationCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: RelationCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); } throw UnimplementedError; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart index e02d21ab85d6e..c5502bd8b1f0c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart @@ -1,10 +1,10 @@ import 'dart:convert'; +import 'package:appflowy/plugins/database/domain/cell_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'cell_controller.dart'; -import 'cell_service.dart'; abstract class IGridCellDataConfig { // The cell data will reload if it receives the field's change notification. @@ -133,3 +133,10 @@ class URLCellDataParser implements CellDataParser { return URLCellDataPB.fromBuffer(data); } } + +class RelationCellDataParser implements CellDataParser { + @override + RelationCellDataPB? parserData(List data) { + return data.isEmpty ? null : RelationCellDataPB.fromBuffer(data); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart index 1b0bc7a03afbf..f377515ac4191 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart @@ -1,13 +1,12 @@ +import 'package:appflowy/plugins/database/domain/cell_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; -import 'package:dartz/dartz.dart'; import 'cell_controller.dart'; -import 'cell_service.dart'; /// Save the cell data to disk /// You can extend this class to do custom operations. abstract class CellDataPersistence { - Future> save({ + Future save({ required String viewId, required CellContext cellContext, required D data, @@ -18,7 +17,7 @@ class TextCellDataPersistence implements CellDataPersistence { TextCellDataPersistence(); @override - Future> save({ + Future save({ required String viewId, required CellContext cellContext, required String data, @@ -30,8 +29,8 @@ class TextCellDataPersistence implements CellDataPersistence { ); return fut.then((result) { return result.fold( - (l) => none(), - (err) => Some(err), + (l) => null, + (err) => err, ); }); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart index 09ee5e153f8d7..877c470ff6c3e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart @@ -1,26 +1,21 @@ +import 'dart:async'; + import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/view/view_cache.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/group_listener.dart'; +import 'package:appflowy/plugins/database/domain/layout_service.dart'; +import 'package:appflowy/plugins/database/domain/layout_setting_listener.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/group.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group_changeset.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; import 'package:flutter/material.dart'; -import 'database_view_service.dart'; import 'defines.dart'; -import 'layout/layout_service.dart'; -import 'layout/layout_setting_listener.dart'; import 'row/row_cache.dart'; -import 'group/group_listener.dart'; typedef OnGroupConfigurationChanged = void Function(List); typedef OnGroupByField = void Function(List); @@ -136,7 +131,7 @@ class DatabaseController { } } - Future> open() async { + Future> open() async { return _databaseViewBackendSvc.openDatabase().then((result) { return result.fold( (DatabasePB database) async { @@ -157,21 +152,21 @@ class DatabaseController { return Future(() async { await _loadGroups(); await _loadLayoutSetting(); - return left(fields); + return FlowyResult.success(fields); }); }, (err) { Log.error(err); - return right(err); + return FlowyResult.failure(err); }, ); }, - (err) => right(err), + (err) => FlowyResult.failure(err), ); }); } - Future> moveGroupRow({ + Future> moveGroupRow({ required RowMetaPB fromRow, required String fromGroupId, required String toGroupId, @@ -185,7 +180,7 @@ class DatabaseController { ); } - Future> moveRow({ + Future> moveRow({ required String fromRowId, required String toRowId, }) { @@ -195,7 +190,7 @@ class DatabaseController { ); } - Future> moveGroup({ + Future> moveGroup({ required String fromGroupId, required String toGroupId, }) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart b/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart index d21f533f42146..88e9fc4f77d4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart @@ -5,7 +5,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filte import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'field/field_info.dart'; @@ -38,7 +38,7 @@ class LoadingState with _$LoadingState { const factory LoadingState.idle() = _Idle; const factory LoadingState.loading() = _Loading; const factory LoadingState.finish( - Either successOrFail, + FlowyResult successOrFail, ) = _Finish; const LoadingState._(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart index 1e6d953d1f0cb..a8983a98c5cf0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart index a95350516ebed..db1a56071e3d9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart @@ -1,32 +1,26 @@ import 'dart:collection'; -import 'package:appflowy/plugins/database/application/database_view_service.dart'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_listener.dart'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/field_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/setting/setting_listener.dart'; -import 'package:appflowy/plugins/database/application/sort/sort_listener.dart'; -import 'package:appflowy/plugins/database/application/sort/sort_service.dart'; +import 'package:appflowy/plugins/database/domain/sort_listener.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.dart'; import 'package:appflowy_backend/log.dart'; -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:appflowy_backend/protobuf/flowy-database2/filter_changeset.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter/foundation.dart'; import '../setting/setting_service.dart'; import 'field_info.dart'; -import 'field_listener.dart'; class _GridFieldNotifier extends ChangeNotifier { List _fieldInfos = []; @@ -168,75 +162,6 @@ class FieldController { /// Listen for filter changes in the backend. void _listenOnFilterChanges() { - void deleteFilterFromChangeset( - List filters, - FilterChangesetNotificationPB changeset, - ) { - final deleteFilterIds = changeset.deleteFilters.map((e) => e.id).toList(); - if (deleteFilterIds.isNotEmpty) { - filters.retainWhere( - (element) => !deleteFilterIds.contains(element.filter.id), - ); - } - } - - void insertFilterFromChangeset( - List filters, - FilterChangesetNotificationPB changeset, - ) { - for (final newFilter in changeset.insertFilters) { - final filterIndex = - filters.indexWhere((element) => element.filter.id == newFilter.id); - if (filterIndex == -1) { - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: newFilter.fieldId, - fieldType: newFilter.fieldType, - ); - if (fieldInfo != null) { - filters.add(FilterInfo(viewId, newFilter, fieldInfo)); - } - } - } - } - - void updateFilterFromChangeset( - List filters, - FilterChangesetNotificationPB changeset, - ) { - for (final updatedFilter in changeset.updateFilters) { - final filterIndex = filters.indexWhere( - (element) => element.filter.id == updatedFilter.filterId, - ); - // Remove the old filter - if (filterIndex != -1) { - filters.removeAt(filterIndex); - } - - // Insert the filter if there is a filter and its field info is - // not null - if (updatedFilter.hasFilter()) { - final fieldInfo = _findFieldInfo( - fieldInfos: fieldInfos, - fieldId: updatedFilter.filter.fieldId, - fieldType: updatedFilter.filter.fieldType, - ); - - if (fieldInfo != null) { - // Insert the filter with the position: filterIndex, otherwise, - // append it to the end of the list. - final filterInfo = - FilterInfo(viewId, updatedFilter.filter, fieldInfo); - if (filterIndex != -1) { - filters.insert(filterIndex, filterInfo); - } else { - filters.add(filterInfo); - } - } - } - } - } - _filtersListener.start( onFilterChanged: (result) { if (_isDisposed) { @@ -245,15 +170,19 @@ class FieldController { result.fold( (FilterChangesetNotificationPB changeset) { - final List filters = filterInfos; - // delete removed filters - deleteFilterFromChangeset(filters, changeset); - - // insert new filters - insertFilterFromChangeset(filters, changeset); - - // edit modified filters - updateFilterFromChangeset(filters, changeset); + final List filters = []; + for (final filter in changeset.filters.items) { + final fieldInfo = _findFieldInfo( + fieldInfos: fieldInfos, + fieldId: filter.data.fieldId, + fieldType: filter.data.fieldType, + ); + + if (fieldInfo != null) { + final filterInfo = FilterInfo(viewId, filter, fieldInfo); + filters.add(filterInfo); + } + } _filterNotifier?.filters = filters; _updateFieldInfos(); @@ -343,6 +272,7 @@ class FieldController { ...changeset.insertSorts.map((sort) => sort.sort.fieldId), ...changeset.updateSorts.map((sort) => sort.fieldId), ...changeset.deleteSorts.map((sort) => sort.fieldId), + ...?_sortNotifier?.sorts.map((sort) => sort.fieldId), ]); final newFieldInfos = [...fieldInfos]; @@ -373,8 +303,8 @@ class FieldController { insertSortFromChangeset(newSortInfos, changeset); updateSortFromChangeset(newSortInfos, changeset); - _sortNotifier?.sorts = newSortInfos; updateFieldInfos(newSortInfos, changeset); + _sortNotifier?.sorts = newSortInfos; }, (err) => Log.error(err), ); @@ -382,7 +312,7 @@ class FieldController { ); } - /// Listen for databse setting changes in the backend. + /// Listen for database setting changes in the backend. void _listenOnSettingChanges() { _settingListener.start( onSettingUpdated: (result) { @@ -581,7 +511,7 @@ class FieldController { } /// Load all of the fields. This is required when opening the database - Future> loadFields({ + Future> loadFields({ required List fieldIds, }) async { final result = await _databaseViewBackendSvc.getFields(fieldIds: fieldIds); @@ -589,7 +519,7 @@ class FieldController { () => result.fold( (newFields) async { if (_isDisposed) { - return left(unit); + return FlowyResult.success(null); } _fieldNotifier.fieldInfos = @@ -602,54 +532,54 @@ class FieldController { ]); _updateFieldInfos(); - return left(unit); + return FlowyResult.success(null); }, - (err) => right(err), + (err) => FlowyResult.failure(err), ), ); } /// Load all the filters from the backend. Required by `loadFields` - Future> _loadFilters() async { + Future> _loadFilters() async { return _filterBackendSvc.getAllFilters().then((result) { return result.fold( (filterPBs) { _filterNotifier?.filters = _filterInfoListFromPBs(filterPBs); - return left(unit); + return FlowyResult.success(null); }, - (err) => right(err), + (err) => FlowyResult.failure(err), ); }); } /// Load all the sorts from the backend. Required by `loadFields` - Future> _loadSorts() async { + Future> _loadSorts() async { return _sortBackendSvc.getAllSorts().then((result) { return result.fold( (sortPBs) { _sortNotifier?.sorts = _sortInfoListFromPBs(sortPBs); - return left(unit); + return FlowyResult.success(null); }, - (err) => right(err), + (err) => FlowyResult.failure(err), ); }); } /// Load all the field settings from the backend. Required by `loadFields` - Future> _loadAllFieldSettings() async { + Future> _loadAllFieldSettings() async { return _fieldSettingsBackendSvc.getAllFieldSettings().then((result) { return result.fold( (fieldSettingsList) { _fieldSettings.clear(); _fieldSettings.addAll(fieldSettingsList); - return left(unit); + return FlowyResult.success(null); }, - (err) => right(err), + (err) => FlowyResult.failure(err), ); }); } - Future> _loadSettings() async { + Future> _loadSettings() async { return SettingBackendService(viewId: viewId).getSetting().then( (result) => result.fold( (setting) { @@ -658,9 +588,9 @@ class FieldController { _groupConfigurationByFieldId[configuration.fieldId] = configuration; } - return left(unit); + return FlowyResult.success(null); }, - (err) => right(err), + (err) => FlowyResult.failure(err), ), ); } @@ -670,8 +600,8 @@ class FieldController { FilterInfo? getFilterInfo(FilterPB filterPB) { final fieldInfo = _findFieldInfo( fieldInfos: fieldInfos, - fieldId: filterPB.fieldId, - fieldType: filterPB.fieldType, + fieldId: filterPB.data.fieldId, + fieldType: filterPB.data.fieldType, ); return fieldInfo != null ? FilterInfo(viewId, filterPB, fieldInfo) : null; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart index 5c6c774108322..316bb50bdb4cf 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart @@ -1,17 +1,17 @@ import 'dart:typed_data'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/field_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'field_controller.dart'; import 'field_info.dart'; -import 'field_listener.dart'; -import 'field_service.dart'; part 'field_editor_bloc.freezed.dart'; @@ -111,7 +111,7 @@ class FieldEditorBloc extends Bloc { ); } - void _logIfError(Either result) { + void _logIfError(FlowyResult result) { result.fold( (l) => null, (err) => Log.error(err), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart index 64d5a398bee06..1612ab6a23265 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -47,7 +47,7 @@ class FieldInfo with _$FieldInfo { } bool get canCreateFilter { - if (hasFilter) { + if (isGroupField) { return false; } @@ -58,6 +58,7 @@ class FieldInfo with _$FieldInfo { case FieldType.RichText: case FieldType.SingleSelect: case FieldType.Checklist: + case FieldType.URL: return true; default: return false; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart index a43a3be1e6146..c4c31b880e6b5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart @@ -128,7 +128,7 @@ extension NumberFormatExtension on NumberFormatPB { } } - String iconSymbol() { + String iconSymbol([bool defaultPrefixInc = true]) { switch (this) { case NumberFormatPB.ArgentinePeso: return "\$"; @@ -169,7 +169,7 @@ extension NumberFormatExtension on NumberFormatPB { case NumberFormatPB.NorwegianKrone: return "kr"; case NumberFormatPB.Num: - return "#"; + return defaultPrefixInc ? "#" : ""; case NumberFormatPB.Percent: return "%"; case NumberFormatPB.PhilippinePeso: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart new file mode 100644 index 0000000000000..df8e0d46fb48a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart @@ -0,0 +1,63 @@ +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'relation_type_option_cubit.freezed.dart'; + +class RelationDatabaseListCubit extends Cubit { + RelationDatabaseListCubit() : super(RelationDatabaseListState.initial()) { + _loadDatabaseMetas(); + } + + void _loadDatabaseMetas() async { + final getDatabaseResult = await DatabaseEventGetDatabases().send(); + final metaPBs = getDatabaseResult.fold>( + (s) => s.items, + (f) => [], + ); + final futures = metaPBs.map((meta) { + return ViewBackendService.getView(meta.inlineViewId).then( + (result) => result.fold( + (s) => DatabaseMeta( + databaseId: meta.databaseId, + inlineViewId: meta.inlineViewId, + databaseName: s.name, + ), + (f) => null, + ), + ); + }); + final databaseMetas = await Future.wait(futures); + emit( + RelationDatabaseListState( + databaseMetas: databaseMetas.nonNulls.toList(), + ), + ); + } +} + +@freezed +class DatabaseMeta with _$DatabaseMeta { + factory DatabaseMeta({ + /// id of the database + required String databaseId, + + /// id of the inline view + required String inlineViewId, + + /// name of the database, currently identical to the name of the inline view + required String databaseName, + }) = _DatabaseMeta; +} + +@freezed +class RelationDatabaseListState with _$RelationDatabaseListState { + factory RelationDatabaseListState({ + required List databaseMetas, + }) = _RelationDatabaseListState; + + factory RelationDatabaseListState.initial() => + RelationDatabaseListState(databaseMetas: []); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart index d62cd8b8c519d..cd1db30fc6a7e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart @@ -1,5 +1,4 @@ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -28,10 +27,10 @@ class SelectOptionTypeOptionBloc emit(state.copyWith(options: options)); }, addingOption: () { - emit(state.copyWith(isEditingOption: true, newOptionName: none())); + emit(state.copyWith(isEditingOption: true, newOptionName: null)); }, endAddingOption: () { - emit(state.copyWith(isEditingOption: false, newOptionName: none())); + emit(state.copyWith(isEditingOption: false, newOptionName: null)); }, updateOption: (option) { final List options = @@ -69,13 +68,13 @@ class SelectOptionTypeOptionState with _$SelectOptionTypeOptionState { const factory SelectOptionTypeOptionState({ required List options, required bool isEditingOption, - required Option newOptionName, + required String? newOptionName, }) = _SelectOptionTypeOptionState; factory SelectOptionTypeOptionState.initial(List options) => SelectOptionTypeOptionState( options: options, isEditingOption: false, - newOptionName: none(), + newOptionName: null, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart index 19d23963afa90..8d46b994bb06b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart @@ -1,11 +1,10 @@ import 'dart:async'; +import 'package:appflowy/plugins/database/domain/type_option_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/builder.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; -import 'type_option_service.dart'; - abstract class ISelectOptionAction { ISelectOptionAction({ required this.onTypeOptionUpdated, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart index 8e91d9e15c023..b49b3a80df443 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart @@ -72,3 +72,11 @@ class ChecklistTypeOptionDataParser return ChecklistTypeOptionPB.fromBuffer(buffer); } } + +class RelationTypeOptionDataParser + extends TypeOptionParser { + @override + RelationTypeOptionPB fromBuffer(List buffer) { + return RelationTypeOptionPB.fromBuffer(buffer); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/filter/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/filter/filter_service.dart deleted file mode 100644 index 03056a980caf5..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/application/filter/filter_service.dart +++ /dev/null @@ -1,231 +0,0 @@ -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/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/number_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; -import 'package:fixnum/fixnum.dart' as $fixnum; - -class FilterBackendService { - const FilterBackendService({required this.viewId}); - - final String viewId; - - Future, FlowyError>> getAllFilters() { - final payload = DatabaseViewIdPB()..value = viewId; - - return DatabaseEventGetAllFilters(payload).send().then((result) { - return result.fold( - (repeated) => left(repeated.items), - (r) => right(r), - ); - }); - } - - Future> insertTextFilter({ - required String fieldId, - String? filterId, - required TextFilterConditionPB condition, - required String content, - }) { - final filter = TextFilterPB() - ..condition = condition - ..content = content; - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: FieldType.RichText, - data: filter.writeToBuffer(), - ); - } - - Future> insertCheckboxFilter({ - required String fieldId, - String? filterId, - required CheckboxFilterConditionPB condition, - }) { - final filter = CheckboxFilterPB()..condition = condition; - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: FieldType.Checkbox, - data: filter.writeToBuffer(), - ); - } - - Future> insertNumberFilter({ - required String fieldId, - String? filterId, - required NumberFilterConditionPB condition, - String content = "", - }) { - final filter = NumberFilterPB() - ..condition = condition - ..content = content; - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: FieldType.Number, - data: filter.writeToBuffer(), - ); - } - - Future> insertDateFilter({ - required String fieldId, - String? filterId, - required DateFilterConditionPB condition, - required FieldType fieldType, - int? start, - int? end, - int? timestamp, - }) { - assert( - [ - FieldType.DateTime, - FieldType.LastEditedTime, - FieldType.CreatedTime, - ].contains(fieldType), - ); - - final filter = DateFilterPB(); - if (timestamp != null) { - filter.timestamp = $fixnum.Int64(timestamp); - } else { - if (start != null && end != null) { - filter.start = $fixnum.Int64(start); - filter.end = $fixnum.Int64(end); - } else { - throw Exception( - "Start and end should not be null if the timestamp is null", - ); - } - } - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: fieldType, - data: filter.writeToBuffer(), - ); - } - - Future> insertURLFilter({ - required String fieldId, - String? filterId, - required TextFilterConditionPB condition, - String content = "", - }) { - final filter = TextFilterPB() - ..condition = condition - ..content = content; - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: FieldType.URL, - data: filter.writeToBuffer(), - ); - } - - Future> insertSelectOptionFilter({ - required String fieldId, - required FieldType fieldType, - required SelectOptionConditionPB condition, - String? filterId, - List optionIds = const [], - }) { - final filter = SelectOptionFilterPB() - ..condition = condition - ..optionIds.addAll(optionIds); - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: fieldType, - data: filter.writeToBuffer(), - ); - } - - Future> insertChecklistFilter({ - required String fieldId, - required ChecklistFilterConditionPB condition, - String? filterId, - List optionIds = const [], - }) { - final filter = ChecklistFilterPB()..condition = condition; - - return insertFilter( - fieldId: fieldId, - filterId: filterId, - fieldType: FieldType.Checklist, - data: filter.writeToBuffer(), - ); - } - - Future> insertFilter({ - required String fieldId, - String? filterId, - required FieldType fieldType, - required List data, - }) { - final insertFilterPayload = UpdateFilterPayloadPB.create() - ..fieldId = fieldId - ..fieldType = fieldType - ..viewId = viewId - ..data = data; - - if (filterId != null) { - insertFilterPayload.filterId = filterId; - } - - final payload = DatabaseSettingChangesetPB.create() - ..viewId = viewId - ..updateFilter = insertFilterPayload; - return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { - return result.fold( - (l) => left(l), - (err) { - Log.error(err); - return right(err); - }, - ); - }); - } - - Future> deleteFilter({ - required String fieldId, - required String filterId, - required FieldType fieldType, - }) { - final deleteFilterPayload = DeleteFilterPayloadPB.create() - ..fieldId = fieldId - ..filterId = filterId - ..viewId = viewId - ..fieldType = fieldType; - - final payload = DatabaseSettingChangesetPB.create() - ..viewId = viewId - ..deleteFilter = deleteFilterPayload; - - return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { - return result.fold( - (l) => left(l), - (err) { - Log.error(err); - return right(err); - }, - ); - }); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart index 1694b9e70e4fc..a5dd0d9ca1fbd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart @@ -1,9 +1,8 @@ +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import '../database_view_service.dart'; - part 'layout_bloc.freezed.dart'; class DatabaseLayoutBloc diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart index a78d70d307ea2..e946b7146de13 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart @@ -1,12 +1,12 @@ -import 'package:appflowy/plugins/database/application/field/field_listener.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.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 'row_meta_listener.dart'; +import '../../domain/row_meta_listener.dart'; part 'row_banner_bloc.freezed.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart index 9482c4add0aec..331dd159daab6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart @@ -1,13 +1,15 @@ import 'dart:collection'; +import 'package:flutter/foundation.dart'; + import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../cell/cell_cache.dart'; import '../cell/cell_controller.dart'; + import 'row_list.dart'; import 'row_service.dart'; @@ -25,7 +27,7 @@ abstract mixin class RowLifeCycle { void onRowDisposed(); } -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information. +/// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid for more information. class RowCache { RowCache({ diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart index 18ece0708d8ac..1b0d73de8f446 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart @@ -1,7 +1,7 @@ -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import '../field/field_info.dart'; @@ -12,7 +12,7 @@ class RowBackendService { final String viewId; - static Future> createRow({ + static Future> createRow({ required String viewId, String? groupId, void Function(RowDataBuilder builder)? withCells, @@ -28,22 +28,16 @@ class RowBackendService { ), ); - Map? cellDataByFieldId; - if (withCells != null) { final rowBuilder = RowDataBuilder(); withCells(rowBuilder); - cellDataByFieldId = rowBuilder.build(); - } - - if (cellDataByFieldId != null) { - payload.data = RowDataPB(cellDataByFieldId: cellDataByFieldId); + payload.data.addAll(rowBuilder.build()); } return DatabaseEventCreateRow(payload).send(); } - Future> createRowBefore(RowId rowId) { + Future> createRowBefore(RowId rowId) { return createRow( viewId: viewId, position: OrderObjectPositionTypePB.Before, @@ -51,7 +45,7 @@ class RowBackendService { ); } - Future> createRowAfter(RowId rowId) { + Future> createRowAfter(RowId rowId) { return createRow( viewId: viewId, position: OrderObjectPositionTypePB.After, @@ -59,7 +53,7 @@ class RowBackendService { ); } - static Future> getRow({ + static Future> getRow({ required String viewId, required String rowId, }) { @@ -70,7 +64,7 @@ class RowBackendService { return DatabaseEventGetRowMeta(payload).send(); } - Future> getRowMeta(RowId rowId) { + Future> getRowMeta(RowId rowId) { final payload = RowIdPB.create() ..viewId = viewId ..rowId = rowId; @@ -78,7 +72,7 @@ class RowBackendService { return DatabaseEventGetRowMeta(payload).send(); } - Future> updateMeta({ + Future> updateMeta({ required String rowId, String? iconURL, String? coverURL, @@ -102,7 +96,7 @@ class RowBackendService { return DatabaseEventUpdateRowMeta(payload).send(); } - static Future> deleteRow( + static Future> deleteRow( String viewId, RowId rowId, ) { @@ -113,7 +107,7 @@ class RowBackendService { return DatabaseEventDeleteRow(payload).send(); } - static Future> duplicateRow( + static Future> duplicateRow( String viewId, RowId rowId, ) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart index e1ae90c724c35..cef3ad6d7f4e1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart @@ -2,14 +2,13 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/group_service.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'; -import '../group/group_service.dart'; - part 'group_bloc.freezed.dart'; class DatabaseGroupBloc extends Bloc { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart index 9bcd66355f870..46414791f26c0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart @@ -2,14 +2,13 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy_backend/log.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/field_service.dart'; - part 'property_bloc.freezed.dart'; class DatabasePropertyBloc diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart index 2778e841ab09e..34dfba53d4864 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart @@ -1,13 +1,14 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; -typedef UpdateSettingNotifiedValue = Either; +typedef UpdateSettingNotifiedValue + = FlowyResult; class DatabaseSettingListener { DatabaseSettingListener({required this.viewId}); @@ -26,14 +27,17 @@ class DatabaseSettingListener { DatabaseNotificationListener(objectId: viewId, handler: _handler); } - void _handler(DatabaseNotification ty, Either result) { + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { switch (ty) { case DatabaseNotification.DidUpdateSettings: result.fold( - (payload) => _updateSettingNotifier?.value = left( + (payload) => _updateSettingNotifier?.value = FlowyResult.success( DatabaseViewSettingPB.fromBuffer(payload), ), - (error) => _updateSettingNotifier?.value = right(error), + (error) => _updateSettingNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart index d0a1c8a312a7c..d34fab1aea8de 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart @@ -1,15 +1,15 @@ -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/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class SettingBackendService { const SettingBackendService({required this.viewId}); final String viewId; - Future> getSetting() { + Future> getSetting() { final payload = DatabaseViewIdPB.create()..value = viewId; return DatabaseEventGetDatabaseSetting(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart index 4abc77aef20ec..1beaaedbfb263 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart @@ -1,12 +1,14 @@ import 'dart:io'; + import 'package:appflowy/workspace/application/settings/share/export_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + part 'share_bloc.freezed.dart'; class DatabaseShareBloc extends Bloc { @@ -35,9 +37,9 @@ class DatabaseShareBloc extends Bloc { result.fold( (l) { _saveCSVToPath(l.data, event.path); - return left(unit); + return FlowyResult.success(null); }, - (r) => right(r), + (r) => FlowyResult.failure(r), ), ), ); @@ -61,6 +63,6 @@ class DatabaseShareState with _$DatabaseShareState { const factory DatabaseShareState.initial() = _Initial; const factory DatabaseShareState.loading() = _Loading; const factory DatabaseShareState.finish( - Either successOrFail, + FlowyResult successOrFail, ) = _Finish; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart index 6d5f0b4984626..943f8ec20c5e4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; @@ -11,7 +12,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'database_controller.dart'; -import 'database_view_service.dart'; part 'tab_bar_bloc.freezed.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart index 2c1a92783ac56..77670fb0bbc92 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart @@ -1,10 +1,13 @@ import 'dart:async'; import 'dart:collection'; + import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; + import '../defines.dart'; import '../field/field_controller.dart'; import '../row/row_cache.dart'; + import 'view_listener.dart'; class DatabaseViewCallbacks { @@ -30,7 +33,7 @@ class DatabaseViewCallbacks { final OnRowsDeleted? onRowsDeleted; } -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information +/// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid for more information class DatabaseViewCache { DatabaseViewCache({ required this.viewId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart index c07ea4b4c1496..595c0139048b8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart @@ -1,19 +1,20 @@ import 'dart:async'; import 'dart:typed_data'; + import 'package:appflowy/core/notification/grid_notification.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/view_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; typedef RowsVisibilityNotifierValue - = Either; + = FlowyResult; -typedef NumberOfRowsNotifierValue = Either; -typedef ReorderAllRowsNotifierValue = Either, FlowyError>; -typedef SingleRowNotifierValue = Either; +typedef NumberOfRowsNotifierValue = FlowyResult; +typedef ReorderAllRowsNotifierValue = FlowyResult, FlowyError>; +typedef SingleRowNotifierValue = FlowyResult; class DatabaseViewListener { DatabaseViewListener({required this.viewId}); @@ -51,34 +52,38 @@ class DatabaseViewListener { _reorderSingleRow?.addPublishListener(onReorderSingleRow); } - void _handler(DatabaseNotification ty, Either result) { + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { switch (ty) { case DatabaseNotification.DidUpdateViewRowsVisibility: result.fold( (payload) => _rowsVisibility?.value = - left(RowsVisibilityChangePB.fromBuffer(payload)), - (error) => _rowsVisibility?.value = right(error), + FlowyResult.success(RowsVisibilityChangePB.fromBuffer(payload)), + (error) => _rowsVisibility?.value = FlowyResult.failure(error), ); break; case DatabaseNotification.DidUpdateViewRows: result.fold( - (payload) => - _rowsNotifier?.value = left(RowsChangePB.fromBuffer(payload)), - (error) => _rowsNotifier?.value = right(error), + (payload) => _rowsNotifier?.value = + FlowyResult.success(RowsChangePB.fromBuffer(payload)), + (error) => _rowsNotifier?.value = FlowyResult.failure(error), ); break; case DatabaseNotification.DidReorderRows: result.fold( - (payload) => _reorderAllRows?.value = - left(ReorderAllRowsPB.fromBuffer(payload).rowOrders), - (error) => _reorderAllRows?.value = right(error), + (payload) => _reorderAllRows?.value = FlowyResult.success( + ReorderAllRowsPB.fromBuffer(payload).rowOrders, + ), + (error) => _reorderAllRows?.value = FlowyResult.failure(error), ); break; case DatabaseNotification.DidReorderSingleRow: result.fold( (payload) => _reorderSingleRow?.value = - left(ReorderSingleRowPB.fromBuffer(payload)), - (error) => _reorderSingleRow?.value = right(error), + FlowyResult.success(ReorderSingleRowPB.fromBuffer(payload)), + (error) => _reorderSingleRow?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart index e0619f2171409..7b816bbafc4d4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart @@ -1,26 +1,26 @@ import 'dart:async'; import 'dart:collection'; -import 'package:flutter/foundation.dart'; - import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/group/group_service.dart'; +import 'package:appflowy/plugins/database/domain/group_service.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:intl/intl.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; import '../../application/database_controller.dart'; import '../../application/field/field_controller.dart'; import '../../application/row/row_cache.dart'; - import 'group_controller.dart'; part 'board_bloc.freezed.dart'; @@ -141,10 +141,10 @@ class BoardBloc extends Bloc { _groupItemStartEditing(group, row, true); }, didReceiveGridUpdate: (DatabasePB grid) { - emit(state.copyWith(grid: Some(grid))); + emit(state.copyWith(grid: grid)); }, didReceiveError: (FlowyError error) { - emit(state.copyWith(noneOrError: some(error))); + emit(state.copyWith(noneOrError: error)); }, didReceiveGroups: (List groups) { final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups); @@ -385,21 +385,28 @@ class BoardBloc extends Bloc { groupList.insert(insertGroups.index, group); add(BoardEvent.didReceiveGroups(groupList)); }, - onUpdateGroup: (updatedGroups) { + onUpdateGroup: (updatedGroups) async { if (isClosed) { return; } + // workaround: update group most of the time gets called before fields in + // field controller are updated. For single and multi-select group + // renames, this is required before generating the new group name. + await Future.delayed(const Duration(milliseconds: 50)); + 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; + if (index == -1) { + continue; + } + final columnController = boardController.getGroupController(group.groupId); if (columnController != null) { // remove the group or update its name - columnController.updateGroupName(group.groupName); + columnController.updateGroupName(generateGroupNameFromGroup(group)); if (!group.isVisible) { boardController.removeGroup(group.groupId); } @@ -450,11 +457,15 @@ class BoardBloc extends Bloc { (grid) { databaseController.setIsLoading(false); emit( - state.copyWith(loadingState: LoadingState.finish(left(unit))), + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.success(null)), + ), ); }, (err) => emit( - state.copyWith(loadingState: LoadingState.finish(right(err))), + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.failure(err)), + ), ), ); } @@ -489,7 +500,7 @@ class BoardBloc extends Bloc { AppFlowyGroupData _initializeGroupData(GroupPB group) { return AppFlowyGroupData( id: group.groupId, - name: group.groupName, + name: generateGroupNameFromGroup(group), items: _buildGroupItems(group), customData: GroupData( group: group, @@ -497,6 +508,72 @@ class BoardBloc extends Bloc { ), ); } + + String generateGroupNameFromGroup(GroupPB group) { + final field = fieldController.getField(group.fieldId); + if (field == null) { + return ""; + } + + // if the group is the default group, then + if (group.isDefault) { + return "No ${field.name}"; + } + + switch (field.fieldType) { + case FieldType.SingleSelect: + final options = + SingleSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) + .options; + final option = + options.firstWhereOrNull((option) => option.id == group.groupId); + return option == null ? "" : option.name; + case FieldType.MultiSelect: + final options = + MultiSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) + .options; + final option = + options.firstWhereOrNull((option) => option.id == group.groupId); + return option == null ? "" : option.name; + case FieldType.Checkbox: + return group.groupId; + case FieldType.URL: + return group.groupId; + case FieldType.DateTime: + // Assume DateCondition::Relative as there isn't an option for this + // right now. + final dateFormat = DateFormat("y/MM/dd"); + try { + final targetDateTime = dateFormat.parseLoose(group.groupId); + final targetDateTimeDay = DateTime( + targetDateTime.year, + targetDateTime.month, + targetDateTime.day, + ); + final now = DateTime.now(); + final nowDay = DateTime( + now.year, + now.month, + now.day, + ); + final diff = targetDateTimeDay.difference(nowDay).inDays; + return switch (diff) { + 0 => "Today", + -1 => "Yesterday", + 1 => "Tomorrow", + -7 => "Last 7 days", + 2 => "Next 7 days", + -30 => "Last 30 days", + 8 => "Next 30 days", + _ => DateFormat("MMM y").format(targetDateTimeDay) + }; + } on FormatException { + return ""; + } + default: + return ""; + } + } } @freezed @@ -543,12 +620,12 @@ class BoardEvent with _$BoardEvent { class BoardState with _$BoardState { const factory BoardState({ required String viewId, - required Option grid, + required DatabasePB? grid, required List groupIds, required bool isEditingHeader, required bool isEditingRow, required LoadingState loadingState, - required Option noneOrError, + required FlowyError? noneOrError, required BoardLayoutSettingPB? layoutSettings, String? editingHeaderId, BoardEditingRow? editingRow, @@ -557,12 +634,12 @@ class BoardState with _$BoardState { }) = _BoardState; factory BoardState.initial(String viewId) => BoardState( - grid: none(), + grid: null, viewId: viewId, groupIds: [], isEditingHeader: false, isEditingRow: false, - noneOrError: none(), + noneOrError: null, loadingState: const LoadingState.loading(), layoutSettings: null, hiddenGroups: [], diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart index 9f7511dbb1097..7e0700e2637b4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart @@ -1,12 +1,12 @@ -import 'package:appflowy/plugins/database/application/row/row_service.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; -import 'package:dartz/dartz.dart'; import 'package:protobuf/protobuf.dart'; typedef OnGroupError = void Function(FlowyError); @@ -31,23 +31,11 @@ class GroupController { final GroupControllerDelegate delegate; final void Function(GroupPB group) onGroupChanged; - RowMetaPB? rowAtIndex(int index) { - if (index < group.rows.length) { - return group.rows[index]; - } else { - return null; - } - } + RowMetaPB? rowAtIndex(int index) => group.rows.elementAtOrNull(index); - RowMetaPB? firstRow() { - if (group.rows.isEmpty) return null; - return group.rows.first; - } + RowMetaPB? firstRow() => group.rows.firstOrNull; - RowMetaPB? lastRow() { - if (group.rows.isEmpty) return null; - return group.rows.last; - } + RowMetaPB? lastRow() => group.rows.lastOrNull; void startListening() { _listener.start( @@ -112,7 +100,8 @@ class GroupController { } } -typedef UpdateGroupNotifiedValue = Either; +typedef UpdateGroupNotifiedValue + = FlowyResult; class SingleGroupListener { SingleGroupListener(this.group); @@ -134,14 +123,14 @@ class SingleGroupListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateGroupRow: result.fold( (payload) => _groupNotifier?.value = - left(GroupRowsNotificationPB.fromBuffer(payload)), - (error) => _groupNotifier?.value = right(error), + FlowyResult.success(GroupRowsNotificationPB.fromBuffer(payload)), + (error) => _groupNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/toolbar/board_setting_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/toolbar/board_setting_bloc.dart index 3c4c2abb3a511..ea2e1b93147ea 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/application/toolbar/board_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/toolbar/board_setting_bloc.dart @@ -1,4 +1,3 @@ -import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -11,7 +10,7 @@ class BoardSettingBloc extends Bloc { (event, emit) async { event.when( performAction: (action) { - emit(state.copyWith(selectedAction: Some(action))); + emit(state.copyWith(selectedAction: action)); }, ); }, @@ -30,11 +29,11 @@ class BoardSettingEvent with _$BoardSettingEvent { @freezed class BoardSettingState with _$BoardSettingState { const factory BoardSettingState({ - required Option selectedAction, + required BoardSettingAction? selectedAction, }) = _BoardSettingState; - factory BoardSettingState.initial() => BoardSettingState( - selectedAction: none(), + factory BoardSettingState.initial() => const BoardSettingState( + selectedAction: null, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 16b2d45f953fe..40f60a09f4c70 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -1,5 +1,7 @@ import 'dart:collection'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:flutter/material.dart' hide Card; import 'package:flutter/services.dart'; @@ -34,6 +36,8 @@ import 'toolbar/board_setting_bar.dart'; import 'widgets/board_hidden_groups.dart'; class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { + final _toggleExtension = ToggleExtensionNotifier(); + @override Widget content( BuildContext context, @@ -49,14 +53,27 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { BoardSettingBar( key: _makeValueKey(controller), databaseController: controller, + toggleExtension: _toggleExtension, ); @override Widget settingBarExtension( BuildContext context, DatabaseController controller, - ) => - const SizedBox.shrink(); + ) { + return DatabaseViewSettingExtension( + key: _makeValueKey(controller), + viewId: controller.viewId, + databaseController: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + void dispose() { + _toggleExtension.dispose(); + super.dispose(); + } ValueKey _makeValueKey(DatabaseController controller) => ValueKey(controller.viewId); diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart index 4a678a160edfe..aa2883ff732c7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart @@ -1,24 +1,53 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class BoardSettingBar extends StatelessWidget { const BoardSettingBar({ super.key, required this.databaseController, + required this.toggleExtension, }); final DatabaseController databaseController; + final ToggleExtensionNotifier toggleExtension; @override Widget build(BuildContext context) { - return SizedBox( - height: 20, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SettingButton(databaseController: databaseController), - ], + return BlocProvider( + create: (context) => DatabaseFilterMenuBloc( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + )..add(const DatabaseFilterMenuEvent.initial()), + child: BlocListener( + listenWhen: (p, c) => p.isVisible != c.isVisible, + listener: (context, state) => toggleExtension.toggle(), + child: ValueListenableBuilder( + valueListenable: databaseController.isLoading, + builder: (context, value, child) { + if (value) { + return const SizedBox.shrink(); + } + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const FilterButton(), + const HSpace(2), + SettingButton( + databaseController: databaseController, + ), + ], + ), + ); + }, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart index 82b11a0fd1125..0469378c8b421 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart @@ -269,7 +269,7 @@ class HiddenGroupButtonContent extends StatelessWidget { ), const HSpace(4), FlowyText.medium( - group.groupName, + bloc.generateGroupNameFromGroup(group), overflow: TextOverflow.ellipsis, ), const HSpace(6), @@ -369,7 +369,7 @@ class HiddenGroupPopupItemList extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: FlowyText.medium( - group.groupName, + context.read().generateGroupNameFromGroup(group), fontSize: 10, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart index 8826621778440..4a2674f1c54df 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart @@ -7,8 +7,8 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:calendar_view/calendar_view.dart'; -import 'package:dartz/dartz.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -44,15 +44,13 @@ class CalendarBloc extends Bloc { }, didReceiveCalendarSettings: (CalendarLayoutSettingPB settings) { // If the field id changed, reload all events - state.settings.fold(() => null, (oldSetting) { - if (oldSetting.fieldId != settings.fieldId) { - _loadAllEvents(); - } - }); - emit(state.copyWith(settings: Some(settings))); + if (state.settings?.fieldId != settings.fieldId) { + _loadAllEvents(); + } + emit(state.copyWith(settings: settings)); }, didReceiveDatabaseUpdate: (DatabasePB database) { - emit(state.copyWith(database: Some(database))); + emit(state.copyWith(database: database)); }, didLoadAllEvents: (events) { final calenderEvents = _calendarEventDataFromEventPBs(events); @@ -66,6 +64,20 @@ class CalendarBloc extends Bloc { createEvent: (DateTime date) async { await _createEvent(date); }, + duplicateEvent: (String viewId, String rowId) async { + final result = await RowBackendService.duplicateRow(viewId, rowId); + result.fold( + (_) => null, + (e) => Log.error('Failed to duplicate event: $e', e), + ); + }, + deleteEvent: (String viewId, String rowId) async { + final result = await RowBackendService.deleteRow(viewId, rowId); + result.fold( + (_) => null, + (e) => Log.error('Failed to delete event: $e', e), + ); + }, newEventPopupDisplayed: () { emit(state.copyWith(editingEvent: null)); }, @@ -132,45 +144,47 @@ class CalendarBloc extends Bloc { (database) { databaseController.setIsLoading(false); emit( - state.copyWith(loadingState: LoadingState.finish(left(unit))), + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.success(null)), + ), ); }, (err) => emit( - state.copyWith(loadingState: LoadingState.finish(right(err))), + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.failure(err)), + ), ), ); } Future _createEvent(DateTime date) async { - return state.settings.fold( - () { - Log.warn('Calendar settings not found'); - }, - (settings) async { - final dateField = _getCalendarFieldInfo(settings.fieldId); - if (dateField != null) { - final newRow = await RowBackendService.createRow( - viewId: viewId, - withCells: (builder) => builder.insertDate(dateField, date), - ).then( - (result) => result.fold( - (newRow) => newRow, - (err) { - Log.error(err); - return null; - }, - ), - ); - - if (newRow != null) { - final event = await _loadEvent(newRow.id); - if (event != null && !isClosed) { - add(CalendarEvent.didCreateEvent(event)); - } - } + final settings = state.settings; + if (settings == null) { + Log.warn('Calendar settings not found'); + return; + } + final dateField = _getCalendarFieldInfo(settings.fieldId); + if (dateField != null) { + final newRow = await RowBackendService.createRow( + viewId: viewId, + withCells: (builder) => builder.insertDate(dateField, date), + ).then( + (result) => result.fold( + (newRow) => newRow, + (err) { + Log.error(err); + return null; + }, + ), + ); + + if (newRow != null) { + final event = await _loadEvent(newRow.id); + if (event != null && !isClosed) { + add(CalendarEvent.didCreateEvent(event)); } - }, - ); + } + } } Future _moveEvent(CalendarDayEvent event, DateTime date) async { @@ -407,12 +421,18 @@ class CalendarEvent with _$CalendarEvent { const factory CalendarEvent.didReceiveDatabaseUpdate(DatabasePB database) = _ReceiveDatabaseUpdate; + + const factory CalendarEvent.duplicateEvent(String viewId, String rowId) = + _DuplicateEvent; + + const factory CalendarEvent.deleteEvent(String viewId, String rowId) = + _DeleteEvent; } @freezed class CalendarState with _$CalendarState { const factory CalendarState({ - required Option database, + required DatabasePB? database, // events by row id required Events allEvents, required Events initialEvents, @@ -420,19 +440,19 @@ class CalendarState with _$CalendarState { CalendarEventData? newEvent, CalendarEventData? updateEvent, required List deleteEventIds, - required Option settings, + required CalendarLayoutSettingPB? settings, required LoadingState loadingState, - required Option noneOrError, + required FlowyError? noneOrError, }) = _CalendarState; - factory CalendarState.initial() => CalendarState( - database: none(), + factory CalendarState.initial() => const CalendarState( + database: null, allEvents: [], initialEvents: [], deleteEventIds: [], - settings: none(), - noneOrError: none(), - loadingState: const LoadingState.loading(), + settings: null, + noneOrError: null, + loadingState: LoadingState.loading(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart index 583575688735f..8ea42d2d3beeb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart @@ -1,5 +1,5 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/application/layout/layout_setting_listener.dart'; +import 'package:appflowy/plugins/database/domain/layout_setting_listener.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:bloc/bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart index 9b3dff7d2b4b4..208906f00b348 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart @@ -5,7 +5,6 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -160,13 +159,13 @@ class UnscheduleEventsEvent with _$UnscheduleEventsEvent { @freezed class UnscheduleEventsState with _$UnscheduleEventsState { const factory UnscheduleEventsState({ - required Option database, + required DatabasePB? database, required List allEvents, required List unscheduleEvents, }) = _UnscheduleEventsState; - factory UnscheduleEventsState.initial() => UnscheduleEventsState( - database: none(), + factory UnscheduleEventsState.initial() => const UnscheduleEventsState( + database: null, allEvents: [], unscheduleEvents: [], ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart index 64ca08e00ae7f..9542cafe98d1f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; @@ -9,11 +11,11 @@ import 'package:appflowy_popover/appflowy_popover.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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../application/calendar_bloc.dart'; + import 'calendar_event_editor.dart'; class EventCard extends StatefulWidget { @@ -144,18 +146,18 @@ class _EventCardState extends State { asBarrier: true, margin: EdgeInsets.zero, offset: const Offset(10.0, 0), - popupBuilder: (BuildContext popoverContext) { - final settings = context.watch().state.settings.fold( - () => null, - (layoutSettings) => layoutSettings, - ); + popupBuilder: (_) { + final settings = context.watch().state.settings; if (settings == null) { return const SizedBox.shrink(); } - return CalendarEventEditor( - databaseController: widget.databaseController, - rowMeta: widget.event.event.rowMeta, - layoutSettings: settings, + return BlocProvider.value( + value: context.read(), + child: CalendarEventEditor( + databaseController: widget.databaseController, + rowMeta: widget.event.event.rowMeta, + layoutSettings: settings, + ), ); }, child: Container( diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart index cdef15c4d06fa..70cedae16bf3a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart @@ -1,25 +1,26 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; -import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_event_editor_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; -import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/util/field_type_extension.dart'; -import 'package:appflowy_backend/log.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:flutter/material.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalendarEventEditor extends StatelessWidget { @@ -86,17 +87,34 @@ class EventEditorControls extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + FlowyTooltip( + message: LocaleKeys.calendar_duplicateEvent.tr(), + child: FlowyIconButton( + width: 20, + icon: const FlowySvg( + FlowySvgs.m_duplicate_s, + size: Size.square(17), + ), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () => context.read().add( + CalendarEvent.duplicateEvent( + rowController.viewId, + rowController.rowId, + ), + ), + ), + ), + const HSpace(8.0), FlowyIconButton( width: 20, icon: const FlowySvg(FlowySvgs.delete_s), iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - onPressed: () async { - final result = await RowBackendService.deleteRow( - rowController.viewId, - rowController.rowId, - ); - result.fold((l) => null, (err) => Log.error(err)); - }, + onPressed: () => context.read().add( + CalendarEvent.deleteEvent( + rowController.viewId, + rowController.rowId, + ), + ), ), const HSpace(8.0), FlowyIconButton( @@ -107,12 +125,10 @@ class EventEditorControls extends StatelessWidget { PopoverContainer.of(context).close(); FlowyOverlay.show( context: context, - builder: (BuildContext context) { - return RowDetailPage( - databaseController: databaseController, - rowController: rowController, - ); - }, + builder: (_) => RowDetailPage( + databaseController: databaseController, + rowController: rowController, + ), ); }, ), @@ -268,7 +284,11 @@ class _TitleTextCellSkin extends IEditableTextCellSkin { textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), focusNode: focusNode, hintText: LocaleKeys.calendar_defaultNewCalendarTitle.tr(), - onChanged: (text) => bloc.add(TextCellEvent.updateText(text)), + onChanged: (text) { + if (textEditingController.value.composing.isCollapsed) { + bloc.add(TextCellEvent.updateText(text)); + } + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart index 9382ad3de5579..1d1a147026366 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - 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'; @@ -20,12 +18,12 @@ 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'; import 'package:go_router/go_router.dart'; import '../../application/row/row_controller.dart'; import '../../widgets/row/row_detail.dart'; - import 'calendar_day.dart'; import 'layout/sizes.dart'; import 'toolbar/calendar_setting_bar.dart'; @@ -167,8 +165,7 @@ class _CalendarPageState extends State { return _buildCalendar( context, _eventController, - state.settings - .foldLeft(0, (previous, a) => a.firstDayOfWeek), + state.settings?.firstDayOfWeek ?? 0, ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart index dfe0f74744a75..73b006691f23e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart @@ -7,16 +7,16 @@ class CalendarSize { static double get headerContainerPadding => 12 * scale; static EdgeInsets get contentInsets => EdgeInsets.fromLTRB( - GridSize.leadingHeaderPadding, + GridSize.horizontalHeaderPadding, CalendarSize.headerContainerPadding, - GridSize.leadingHeaderPadding, + GridSize.horizontalHeaderPadding, CalendarSize.headerContainerPadding, ); static EdgeInsets get contentInsetsMobile => EdgeInsets.fromLTRB( - GridSize.leadingHeaderPadding / 2, + GridSize.horizontalHeaderPadding / 2, 0, - GridSize.leadingHeaderPadding / 2, + GridSize.horizontalHeaderPadding / 2, 0, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart similarity index 72% rename from frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart index 111b611816ad8..0b58300b97795 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart @@ -4,12 +4,12 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; -import '../row/row_service.dart'; +import '../application/row/row_service.dart'; -typedef UpdateFieldNotifiedValue = Either; +typedef UpdateFieldNotifiedValue = FlowyResult; class CellListener { CellListener({required this.rowId, required this.fieldId}); @@ -29,12 +29,15 @@ class CellListener { ); } - void _handler(DatabaseNotification ty, Either result) { + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { switch (ty) { case DatabaseNotification.DidUpdateCell: result.fold( - (payload) => _updateCellNotifier?.value = left(unit), - (error) => _updateCellNotifier?.value = right(error), + (payload) => _updateCellNotifier?.value = FlowyResult.success(null), + (error) => _updateCellNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart similarity index 79% rename from frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart index 74437cb28001f..a4090d7c88c85 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart @@ -1,16 +1,16 @@ import 'dart:async'; -import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; -import 'cell_controller.dart'; +import '../application/cell/cell_controller.dart'; class CellBackendService { CellBackendService(); - static Future> updateCell({ + static Future> updateCell({ required String viewId, required CellContext cellContext, required String data, @@ -23,7 +23,7 @@ class CellBackendService { return DatabaseEventUpdateCell(payload).send(); } - static Future> getCell({ + static Future> getCell({ required String viewId, required CellContext cellContext, }) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/checklist_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart similarity index 87% rename from frontend/appflowy_flutter/lib/plugins/database/application/cell/checklist_cell_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart index 17a8fa0a55d14..5b89d056433e1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/checklist_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart @@ -2,7 +2,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:protobuf/protobuf.dart'; class ChecklistCellBackendService { @@ -16,7 +16,7 @@ class ChecklistCellBackendService { final String fieldId; final String rowId; - Future> create({ + Future> create({ required String name, }) { final payload = ChecklistCellDataChangesetPB.create() @@ -28,7 +28,7 @@ class ChecklistCellBackendService { return DatabaseEventUpdateChecklistCell(payload).send(); } - Future> delete({ + Future> delete({ required List optionIds, }) { final payload = ChecklistCellDataChangesetPB.create() @@ -40,7 +40,7 @@ class ChecklistCellBackendService { return DatabaseEventUpdateChecklistCell(payload).send(); } - Future> select({ + Future> select({ required String optionId, }) { final payload = ChecklistCellDataChangesetPB.create() @@ -52,7 +52,7 @@ class ChecklistCellBackendService { return DatabaseEventUpdateChecklistCell(payload).send(); } - Future> updateName({ + Future> updateName({ required SelectOptionPB option, required name, }) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/database_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/database_service.dart similarity index 59% rename from frontend/appflowy_flutter/lib/plugins/database/application/database_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/database_service.dart index ebfdbdfd2daa4..8144904cad1cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/database_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/database_service.dart @@ -1,13 +1,16 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class DatabaseBackendService { - static Future, FlowyError>> + static Future, FlowyError>> getAllDatabases() { return DatabaseEventGetDatabases().send().then((result) { - return result.fold((l) => left(l.items), (r) => right(r)); + return result.fold( + (l) => FlowyResult.success(l.items), + (r) => FlowyResult.failure(r), + ); }); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/database_view_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart similarity index 75% rename from frontend/appflowy_flutter/lib/plugins/database/application/database_view_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart index e4c36a3c5460e..de06c8d1d80eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/database_view_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart @@ -3,24 +3,24 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; -import 'layout/layout_service.dart'; +import 'layout_service.dart'; class DatabaseViewBackendService { DatabaseViewBackendService({required this.viewId}); final String viewId; - /// Returns the datbaase id associated with the view. - Future> getDatabaseId() async { + /// Returns the database id associated with the view. + Future> getDatabaseId() async { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventGetDatabaseId(payload) .send() - .then((value) => value.leftMap((l) => l.value)); + .then((value) => value.map((l) => l.value)); } - static Future> updateLayout({ + static Future> updateLayout({ required String viewId, required DatabaseLayoutPB layout, }) { @@ -31,12 +31,12 @@ class DatabaseViewBackendService { return FolderEventUpdateView(payload).send(); } - Future> openDatabase() async { + Future> openDatabase() async { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventGetDatabase(payload).send(); } - Future> moveGroupRow({ + Future> moveGroupRow({ required RowId fromRowId, required String fromGroupId, required String toGroupId, @@ -55,7 +55,7 @@ class DatabaseViewBackendService { return DatabaseEventMoveGroupRow(payload).send(); } - Future> moveRow({ + Future> moveRow({ required String fromRowId, required String toRowId, }) { @@ -67,7 +67,7 @@ class DatabaseViewBackendService { return DatabaseEventMoveRow(payload).send(); } - Future> moveGroup({ + Future> moveGroup({ required String fromGroupId, required String toGroupId, }) { @@ -79,7 +79,7 @@ class DatabaseViewBackendService { return DatabaseEventMoveGroup(payload).send(); } - Future, FlowyError>> getFields({ + Future, FlowyError>> getFields({ List? fieldIds, }) { final payload = GetFieldPayloadPB.create()..viewId = viewId; @@ -88,11 +88,14 @@ class DatabaseViewBackendService { payload.fieldIds = RepeatedFieldIdPB(items: fieldIds); } return DatabaseEventGetFields(payload).send().then((result) { - return result.fold((l) => left(l.items), (r) => right(r)); + return result.fold( + (l) => FlowyResult.success(l.items), + (r) => FlowyResult.failure(r), + ); }); } - Future> getLayoutSetting( + Future> getLayoutSetting( DatabaseLayoutPB layoutType, ) { final payload = DatabaseLayoutMetaPB.create() @@ -101,7 +104,7 @@ class DatabaseViewBackendService { return DatabaseEventGetLayoutSetting(payload).send(); } - Future> updateLayoutSetting({ + Future> updateLayoutSetting({ required DatabaseLayoutPB layoutType, BoardLayoutSettingPB? boardLayoutSetting, CalendarLayoutSettingPB? calendarLayoutSetting, @@ -121,12 +124,12 @@ class DatabaseViewBackendService { return DatabaseEventSetLayoutSetting(payload).send(); } - Future> closeView() { + Future> closeView() { final request = ViewIdPB(value: viewId); return FolderEventCloseView(request).send(); } - Future> loadGroups() { + Future> loadGroups() { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventGetGroups(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart similarity index 91% rename from frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart index 6bd12a66f7148..9a9f75e75fa99 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart @@ -2,7 +2,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; final class DateCellBackendService { @@ -17,7 +17,7 @@ final class DateCellBackendService { final CellIdPB cellId; - Future> update({ + Future> update({ required bool includeTime, required bool isRange, DateTime? date, @@ -52,7 +52,7 @@ final class DateCellBackendService { return DatabaseEventUpdateDateCell(payload).send(); } - Future> clear() { + Future> clear() { final payload = DateCellChangesetPB.create() ..cellId = cellId ..clearFlag = true; diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_backend_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart similarity index 91% rename from frontend/appflowy_flutter/lib/plugins/database/application/field/field_backend_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart index 386394d2f60f5..21933700ba4cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_backend_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart @@ -1,5 +1,5 @@ -import 'package:appflowy/plugins/database/application/field/field_service.dart'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; // This class is used for combining the diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart similarity index 83% rename from frontend/appflowy_flutter/lib/plugins/database/application/field/field_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart index 19c2f447e9df6..f620c92931b49 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart @@ -5,7 +5,7 @@ import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef UpdateFieldNotifiedValue = FieldPB; @@ -30,7 +30,7 @@ class SingleFieldListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateField: @@ -51,7 +51,7 @@ class SingleFieldListener { } typedef UpdateFieldsNotifiedValue - = Either; + = FlowyResult; class FieldsListener { FieldsListener({required this.viewId}); @@ -72,13 +72,16 @@ class FieldsListener { ); } - void _handler(DatabaseNotification ty, Either result) { + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { switch (ty) { case DatabaseNotification.DidUpdateFields: result.fold( (payload) => updateFieldsNotifier?.value = - left(DatabaseFieldChangesetPB.fromBuffer(payload)), - (error) => updateFieldsNotifier?.value = right(error), + FlowyResult.success(DatabaseFieldChangesetPB.fromBuffer(payload)), + (error) => updateFieldsNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart similarity index 77% rename from frontend/appflowy_flutter/lib/plugins/database/application/field/field_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart index a416ddc2d5df4..9bc873f7f1e78 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; /// FieldService provides many field-related interfaces event functions. Check out /// `rust-lib/flowy-database/event_map.rs` for a list of events and their @@ -16,7 +16,7 @@ class FieldBackendService { /// Create a field in a database view. The position will only be applicable /// in this view; for other views it will be appended to the end - static Future> createField({ + static Future> createField({ required String viewId, FieldType fieldType = FieldType.RichText, String? fieldName, @@ -35,7 +35,7 @@ class FieldBackendService { } /// Reorder a field within a database view - static Future> moveField({ + static Future> moveField({ required String viewId, required String fromFieldId, required String toFieldId, @@ -50,7 +50,7 @@ class FieldBackendService { } /// Delete a field - static Future> deleteField({ + static Future> deleteField({ required String viewId, required String fieldId, }) { @@ -62,21 +62,31 @@ class FieldBackendService { return DatabaseEventDeleteField(payload).send(); } - /// Duplicate a field - static Future> duplicateField({ + // Clear all data of all cells in a Field + static Future> clearField({ required String viewId, required String fieldId, }) { - final payload = DuplicateFieldPayloadPB( + final payload = ClearFieldPayloadPB( viewId: viewId, fieldId: fieldId, ); + return DatabaseEventClearField(payload).send(); + } + + /// Duplicate a field + static Future> duplicateField({ + required String viewId, + required String fieldId, + }) { + final payload = DuplicateFieldPayloadPB(viewId: viewId, fieldId: fieldId); + return DatabaseEventDuplicateField(payload).send(); } /// Update a field's properties - Future> updateField({ + Future> updateField({ String? name, bool? frozen, }) { @@ -96,7 +106,7 @@ class FieldBackendService { } /// Change a field's type - static Future> updateFieldType({ + static Future> updateFieldType({ required String viewId, required String fieldId, required FieldType fieldType, @@ -110,7 +120,7 @@ class FieldBackendService { } /// Update a field's type option data - static Future> updateFieldTypeOption({ + static Future> updateFieldTypeOption({ required String viewId, required String fieldId, required List typeOptionData, @@ -124,14 +134,14 @@ class FieldBackendService { } /// Returns the primary field of the view. - static Future> getPrimaryField({ + static Future> getPrimaryField({ required String viewId, }) { final payload = DatabaseViewIdPB.create()..value = viewId; return DatabaseEventGetPrimaryField(payload).send(); } - Future> createBefore({ + Future> createBefore({ FieldType fieldType = FieldType.RichText, String? fieldName, Uint8List? typeOptionData, @@ -148,7 +158,7 @@ class FieldBackendService { ); } - Future> createAfter({ + Future> createAfter({ FieldType fieldType = FieldType.RichText, String? fieldName, Uint8List? typeOptionData, @@ -165,7 +175,7 @@ class FieldBackendService { ); } - Future> updateType({ + Future> updateType({ required FieldType fieldType, }) => updateFieldType( @@ -174,9 +184,9 @@ class FieldBackendService { fieldType: fieldType, ); - Future> delete() => + Future> delete() => deleteField(viewId: viewId, fieldId: fieldId); - Future> duplicate() => + Future> duplicate() => duplicateField(viewId: viewId, fieldId: fieldId); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field_settings/field_settings_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart similarity index 78% rename from frontend/appflowy_flutter/lib/plugins/database/application/field_settings/field_settings_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart index 6262e16f3b674..13139390917d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field_settings/field_settings_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart @@ -3,10 +3,10 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; -typedef FieldSettingsValue = Either; +typedef FieldSettingsValue = FlowyResult; class FieldSettingsListener { FieldSettingsListener({required this.viewId}); @@ -29,14 +29,14 @@ class FieldSettingsListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFieldSettings: result.fold( (payload) => _fieldSettingsNotifier?.value = - left(FieldSettingsPB.fromBuffer(payload)), - (error) => _fieldSettingsNotifier?.value = right(error), + FlowyResult.success(FieldSettingsPB.fromBuffer(payload)), + (error) => _fieldSettingsNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field_settings/field_settings_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart similarity index 80% rename from frontend/appflowy_flutter/lib/plugins/database/application/field_settings/field_settings_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart index 47ac8e9ded5e1..916371f7528bc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field_settings/field_settings_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart @@ -1,14 +1,14 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class FieldSettingsBackendService { FieldSettingsBackendService({required this.viewId}); final String viewId; - Future> getFieldSettings( + Future> getFieldSettings( String fieldId, ) { final id = FieldIdPB(fieldId: fieldId); @@ -25,14 +25,14 @@ class FieldSettingsBackendService { fieldSetting.visibility = FieldVisibility.AlwaysShown; } - return left(fieldSetting); + return FlowyResult.success(fieldSetting); }, - (r) => right(r), + (r) => FlowyResult.failure(r), ); }); } - Future, FlowyError>> getAllFieldSettings() { + Future, FlowyError>> getAllFieldSettings() { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllFieldSettings(payload).send().then((result) { @@ -47,14 +47,14 @@ class FieldSettingsBackendService { fieldSettings.add(fieldSetting); } - return left(fieldSettings); + return FlowyResult.success(fieldSettings); }, - (r) => right(r), + (r) => FlowyResult.failure(r), ); }); } - Future> updateFieldSettings({ + Future> updateFieldSettings({ required String fieldId, FieldVisibility? fieldVisibility, double? width, diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/filter/filter_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart similarity index 67% rename from frontend/appflowy_flutter/lib/plugins/database/application/filter/filter_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart index ed6294f676e85..a9295e38dd87f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/filter/filter_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart @@ -1,15 +1,15 @@ 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/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/filter_changeset.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; typedef UpdateFilterNotifiedValue - = Either; + = FlowyResult; class FiltersListener { FiltersListener({required this.viewId}); @@ -32,14 +32,15 @@ class FiltersListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFilter: result.fold( - (payload) => _filterNotifier?.value = - left(FilterChangesetNotificationPB.fromBuffer(payload)), - (error) => _filterNotifier?.value = right(error), + (payload) => _filterNotifier?.value = FlowyResult.success( + FilterChangesetNotificationPB.fromBuffer(payload), + ), + (error) => _filterNotifier?.value = FlowyResult.failure(error), ); break; default: @@ -60,19 +61,11 @@ class FilterListener { final String viewId; final String filterId; - PublishNotifier? _onDeleteNotifier = PublishNotifier(); PublishNotifier? _onUpdateNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; - void start({ - void Function()? onDeleted, - void Function(FilterPB)? onUpdated, - }) { - _onDeleteNotifier?.addPublishListener((_) { - onDeleted?.call(); - }); - + void start({void Function(FilterPB)? onUpdated}) { _onUpdateNotifier?.addPublishListener((filter) { onUpdated?.call(filter); }); @@ -84,26 +77,18 @@ class FilterListener { } void handleChangeset(FilterChangesetNotificationPB changeset) { - // check the delete filter - final deletedIndex = changeset.deleteFilters.indexWhere( - (element) => element.id == filterId, - ); - if (deletedIndex != -1) { - _onDeleteNotifier?.value = changeset.deleteFilters[deletedIndex]; - } - - // check the updated filter - final updatedIndex = changeset.updateFilters.indexWhere( - (element) => element.filter.id == filterId, + final filters = changeset.filters.items; + final updatedIndex = filters.indexWhere( + (filter) => filter.id == filterId, ); if (updatedIndex != -1) { - _onUpdateNotifier?.value = changeset.updateFilters[updatedIndex].filter; + _onUpdateNotifier?.value = filters[updatedIndex]; } } void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFilter: @@ -121,9 +106,6 @@ class FilterListener { Future stop() async { await _listener?.stop(); - _onDeleteNotifier?.dispose(); - _onDeleteNotifier = null; - _onUpdateNotifier?.dispose(); _onUpdateNotifier = null; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart new file mode 100644 index 0000000000000..64854a8faff99 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -0,0 +1,281 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart' as $fixnum; + +class FilterBackendService { + const FilterBackendService({required this.viewId}); + + final String viewId; + + Future, FlowyError>> getAllFilters() { + final payload = DatabaseViewIdPB()..value = viewId; + + return DatabaseEventGetAllFilters(payload).send().then((result) { + return result.fold( + (repeated) => FlowyResult.success(repeated.items), + (r) => FlowyResult.failure(r), + ); + }); + } + + Future> insertTextFilter({ + required String fieldId, + String? filterId, + required TextFilterConditionPB condition, + required String content, + }) { + final filter = TextFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.RichText, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.RichText, + data: filter.writeToBuffer(), + ); + } + + Future> insertCheckboxFilter({ + required String fieldId, + String? filterId, + required CheckboxFilterConditionPB condition, + }) { + final filter = CheckboxFilterPB()..condition = condition; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Checkbox, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Checkbox, + data: filter.writeToBuffer(), + ); + } + + Future> insertNumberFilter({ + required String fieldId, + String? filterId, + required NumberFilterConditionPB condition, + String content = "", + }) { + final filter = NumberFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Number, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Number, + data: filter.writeToBuffer(), + ); + } + + Future> insertDateFilter({ + required String fieldId, + String? filterId, + required DateFilterConditionPB condition, + required FieldType fieldType, + int? start, + int? end, + int? timestamp, + }) { + assert( + fieldType == FieldType.DateTime || + fieldType == FieldType.LastEditedTime || + fieldType == FieldType.CreatedTime, + ); + + final filter = DateFilterPB(); + + if (timestamp != null) { + filter.timestamp = $fixnum.Int64(timestamp); + } + if (start != null) { + filter.start = $fixnum.Int64(start); + } + if (end != null) { + filter.end = $fixnum.Int64(end); + } + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.DateTime, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.DateTime, + data: filter.writeToBuffer(), + ); + } + + Future> insertURLFilter({ + required String fieldId, + String? filterId, + required TextFilterConditionPB condition, + String content = "", + }) { + final filter = TextFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.URL, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.URL, + data: filter.writeToBuffer(), + ); + } + + Future> insertSelectOptionFilter({ + required String fieldId, + required FieldType fieldType, + required SelectOptionFilterConditionPB condition, + String? filterId, + List optionIds = const [], + }) { + final filter = SelectOptionFilterPB() + ..condition = condition + ..optionIds.addAll(optionIds); + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: fieldType, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + data: filter.writeToBuffer(), + ); + } + + Future> insertChecklistFilter({ + required String fieldId, + required ChecklistFilterConditionPB condition, + String? filterId, + List optionIds = const [], + }) { + final filter = ChecklistFilterPB()..condition = condition; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Checklist, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Checklist, + data: filter.writeToBuffer(), + ); + } + + Future> insertFilter({ + required String fieldId, + required FieldType fieldType, + required List data, + }) async { + final filterData = FilterDataPB() + ..fieldId = fieldId + ..fieldType = fieldType + ..data = data; + + final insertFilterPayload = InsertFilterPB()..data = filterData; + + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..insertFilter = insertFilterPayload; + + final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + } + + Future> updateFilter({ + required String filterId, + required String fieldId, + required FieldType fieldType, + required List data, + }) async { + final filterData = FilterDataPB() + ..fieldId = fieldId + ..fieldType = fieldType + ..data = data; + + final updateFilterPayload = UpdateFilterDataPB() + ..filterId = filterId + ..data = filterData; + + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..updateFilterData = updateFilterPayload; + + final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + } + + Future> deleteFilter({ + required String fieldId, + required String filterId, + }) async { + final deleteFilterPayload = DeleteFilterPB() + ..fieldId = fieldId + ..filterId = filterId; + + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..deleteFilter = deleteFilterPayload; + + final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/group/group_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart similarity index 72% rename from frontend/appflowy_flutter/lib/plugins/database/application/group/group_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart index b22371737cffe..212bce80c3e3b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/group/group_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart @@ -1,17 +1,17 @@ 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/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group_changeset.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; typedef GroupConfigurationUpdateValue - = Either, FlowyError>; -typedef GroupUpdateValue = Either; -typedef GroupByNewFieldValue = Either, FlowyError>; + = FlowyResult, FlowyError>; +typedef GroupUpdateValue = FlowyResult; +typedef GroupByNewFieldValue = FlowyResult, FlowyError>; class DatabaseGroupListener { DatabaseGroupListener(this.viewId); @@ -37,21 +37,22 @@ class DatabaseGroupListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateNumOfGroups: result.fold( (payload) => _numOfGroupsNotifier?.value = - left(GroupChangesPB.fromBuffer(payload)), - (error) => _numOfGroupsNotifier?.value = right(error), + FlowyResult.success(GroupChangesPB.fromBuffer(payload)), + (error) => _numOfGroupsNotifier?.value = FlowyResult.failure(error), ); break; case DatabaseNotification.DidGroupByField: result.fold( - (payload) => _groupByFieldNotifier?.value = - left(GroupChangesPB.fromBuffer(payload).initialGroups), - (error) => _groupByFieldNotifier?.value = right(error), + (payload) => _groupByFieldNotifier?.value = FlowyResult.success( + GroupChangesPB.fromBuffer(payload).initialGroups, + ), + (error) => _groupByFieldNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/group/group_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart similarity index 82% rename from frontend/appflowy_flutter/lib/plugins/database/application/group/group_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart index c7fe72d95d410..69703d7748a8c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/group/group_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart @@ -1,14 +1,14 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class GroupBackendService { GroupBackendService(this.viewId); final String viewId; - Future> groupByField({ + Future> groupByField({ required String fieldId, }) { final payload = GroupByFieldPayloadPB.create() @@ -18,7 +18,7 @@ class GroupBackendService { return DatabaseEventSetGroupByField(payload).send(); } - Future> updateGroup({ + Future> updateGroup({ required String groupId, required String fieldId, String? name, @@ -38,7 +38,7 @@ class GroupBackendService { return DatabaseEventUpdateGroup(payload).send(); } - Future> createGroup({ + Future> createGroup({ required String name, String groupConfigId = "", }) { @@ -49,7 +49,7 @@ class GroupBackendService { return DatabaseEventCreateGroup(payload).send(); } - Future> deleteGroup({ + Future> deleteGroup({ required String groupId, }) { final payload = DeleteGroupPayloadPB.create() diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_service.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/layout_service.dart diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_setting_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart similarity index 79% rename from frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_setting_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart index 0d016e148fcac..fd213f74beb1f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_setting_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart @@ -1,12 +1,12 @@ 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'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; -typedef LayoutSettingsValue = Either; +typedef LayoutSettingsValue = FlowyResult; class DatabaseLayoutSettingListener { DatabaseLayoutSettingListener(this.viewId); @@ -30,14 +30,14 @@ class DatabaseLayoutSettingListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateLayoutSettings: result.fold( (payload) => _settingNotifier?.value = - left(DatabaseLayoutSettingPB.fromBuffer(payload)), - (error) => _settingNotifier?.value = right(error), + FlowyResult.success(DatabaseLayoutSettingPB.fromBuffer(payload)), + (error) => _settingNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart similarity index 94% rename from frontend/appflowy_flutter/lib/plugins/database/application/row/row_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart index 2e1b78d3b3eb2..3d033128a80e3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart @@ -2,9 +2,9 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; typedef DidFetchRowCallback = void Function(DidFetchRowPB); typedef RowMetaCallback = void Function(RowMetaPB); @@ -34,7 +34,7 @@ class RowListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateRowMeta: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_meta_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart similarity index 91% rename from frontend/appflowy_flutter/lib/plugins/database/application/row/row_meta_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart index 4836ec7a12c5f..1b7e66fd75fe0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_meta_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart @@ -2,9 +2,9 @@ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; typedef RowMetaCallback = void Function(RowMetaPB); @@ -26,7 +26,7 @@ class RowMetaListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateRowMeta: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/select_option_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart similarity index 78% rename from frontend/appflowy_flutter/lib/plugins/database/application/cell/select_option_cell_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart index 906496c18aa95..165671b211316 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/select_option_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart @@ -1,9 +1,9 @@ -import 'package:appflowy/plugins/database/application/field/type_option/type_option_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'type_option_service.dart'; class SelectOptionCellBackendService { SelectOptionCellBackendService({ @@ -16,7 +16,7 @@ class SelectOptionCellBackendService { final String fieldId; final String rowId; - Future> create({ + Future> create({ required String name, bool isSelected = true, }) { @@ -34,13 +34,13 @@ class SelectOptionCellBackendService { return DatabaseEventInsertOrUpdateSelectOption(payload).send(); }, - (r) => right(r), + (r) => FlowyResult.failure(r), ); }, ); } - Future> update({ + Future> update({ required SelectOptionPB option, }) { final payload = RepeatedSelectOptionPayload() @@ -52,7 +52,7 @@ class SelectOptionCellBackendService { return DatabaseEventInsertOrUpdateSelectOption(payload).send(); } - Future> delete({ + Future> delete({ required Iterable options, }) { final payload = RepeatedSelectOptionPayload() @@ -64,7 +64,7 @@ class SelectOptionCellBackendService { return DatabaseEventDeleteSelectOption(payload).send(); } - Future> getCellData() { + Future> getCellData() { final payload = CellIdPB() ..viewId = viewId ..fieldId = fieldId @@ -73,7 +73,7 @@ class SelectOptionCellBackendService { return DatabaseEventGetSelectOptionCellData(payload).send(); } - Future> select({ + Future> select({ required Iterable optionIds, }) { final payload = SelectOptionCellChangesetPB() @@ -83,7 +83,7 @@ class SelectOptionCellBackendService { return DatabaseEventUpdateSelectOptionCell(payload).send(); } - Future> unSelect({ + Future> unSelect({ required Iterable optionIds, }) { final payload = SelectOptionCellChangesetPB() diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart similarity index 74% rename from frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_listener.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart index 64960d087c155..83c9310331cb0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart @@ -1,13 +1,14 @@ 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/errors.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; -typedef SortNotifiedValue = Either; +typedef SortNotifiedValue + = FlowyResult; class SortsListener { SortsListener({required this.viewId}); @@ -29,14 +30,15 @@ class SortsListener { void _handler( DatabaseNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateSort: result.fold( - (payload) => _notifier?.value = - left(SortChangesetNotificationPB.fromBuffer(payload)), - (error) => _notifier?.value = right(error), + (payload) => _notifier?.value = FlowyResult.success( + SortChangesetNotificationPB.fromBuffer(payload), + ), + (error) => _notifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart similarity index 77% rename from frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart index 72634e67d2f9c..12255afb7f780 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/sort/sort_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart @@ -1,28 +1,28 @@ -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/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class SortBackendService { SortBackendService({required this.viewId}); final String viewId; - Future, FlowyError>> getAllSorts() { + Future, FlowyError>> getAllSorts() { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllSorts(payload).send().then((result) { return result.fold( - (repeated) => left(repeated.items), - (r) => right(r), + (repeated) => FlowyResult.success(repeated.items), + (r) => FlowyResult.failure(r), ); }); } - Future> updateSort({ + Future> updateSort({ required String sortId, required String fieldId, required SortConditionPB condition, @@ -38,16 +38,16 @@ class SortBackendService { ..updateSort = insertSortPayload; return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { return result.fold( - (l) => left(l), + (l) => FlowyResult.success(l), (err) { Log.error(err); - return right(err); + return FlowyResult.failure(err); }, ); }); } - Future> insertSort({ + Future> insertSort({ required String fieldId, required SortConditionPB condition, }) { @@ -61,16 +61,16 @@ class SortBackendService { ..updateSort = insertSortPayload; return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { return result.fold( - (l) => left(l), + (l) => FlowyResult.success(l), (err) { Log.error(err); - return right(err); + return FlowyResult.failure(err); }, ); }); } - Future> reorderSort({ + Future> reorderSort({ required String fromSortId, required String toSortId, }) { @@ -84,7 +84,7 @@ class SortBackendService { return DatabaseEventUpdateDatabaseSetting(payload).send(); } - Future> deleteSort({ + Future> deleteSort({ required String fieldId, required String sortId, }) { @@ -98,23 +98,23 @@ class SortBackendService { return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { return result.fold( - (l) => left(l), + (l) => FlowyResult.success(l), (err) { Log.error(err); - return right(err); + return FlowyResult.failure(err); }, ); }); } - Future> deleteAllSorts() { + Future> deleteAllSorts() { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventDeleteAllSorts(payload).send().then((result) { return result.fold( - (l) => left(l), + (l) => FlowyResult.success(l), (err) { Log.error(err); - return right(err); + return FlowyResult.failure(err); }, ); }); diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart similarity index 83% rename from frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_service.dart rename to frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart index 15c9683f05231..a222e69853473 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart @@ -1,7 +1,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class TypeOptionBackendService { TypeOptionBackendService({ @@ -12,7 +12,7 @@ class TypeOptionBackendService { final String viewId; final String fieldId; - Future> newOption({ + Future> newOption({ required String name, }) { final payload = CreateSelectOptionPayloadPB.create() diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart index 28d8b820e8caf..e41fa61b2fca6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart @@ -2,7 +2,6 @@ import 'package:appflowy/plugins/database/application/calculations/calculations_ import 'package:appflowy/plugins/database/application/calculations/calculations_service.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart'; import 'package:bloc/bloc.dart'; @@ -133,7 +132,7 @@ class CalculationsBloc extends Bloc { final calculationsOrFailure = await _calculationsService.getCalculations(); final RepeatedCalculationsPB? calculations = - calculationsOrFailure.getLeftOrNull(); + calculationsOrFailure.fold((s) => s, (e) => null); if (calculations != null) { final calculationMap = {}; for (final calculation in calculations.items) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart new file mode 100644 index 0000000000000..8fbb40ffde3ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart @@ -0,0 +1,40 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +extension AvailableCalculations on FieldType { + List calculationsForFieldType() { + final calculationTypes = [ + CalculationType.Count, + ]; + + // These FieldTypes cannot be empty, or might hold secondary + // data causing them to be seen as not empty when in fact they + // are empty. + if (![ + FieldType.URL, + FieldType.Checkbox, + FieldType.LastEditedTime, + FieldType.CreatedTime, + ].contains(this)) { + calculationTypes.addAll([ + CalculationType.CountEmpty, + CalculationType.CountNonEmpty, + ]); + } + + switch (this) { + case FieldType.Number: + calculationTypes.addAll([ + CalculationType.Sum, + CalculationType.Average, + CalculationType.Min, + CalculationType.Max, + CalculationType.Median, + ]); + break; + default: + break; + } + + return calculationTypes; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart index 7b87d725c1df8..17449bda4464d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checkbox_filter_editor_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; @@ -44,7 +44,6 @@ class CheckboxFilterEditorBloc _filterBackendSvc.deleteFilter( fieldId: filterInfo.fieldInfo.id, filterId: filterInfo.filter.id, - fieldType: filterInfo.fieldInfo.fieldType, ); }, didReceiveFilter: (FilterPB filter) { @@ -64,11 +63,10 @@ class CheckboxFilterEditorBloc void _startListening() { _listener.start( - onDeleted: () { - if (!isClosed) add(const CheckboxFilterEditorEvent.delete()); - }, onUpdated: (filter) { - if (!isClosed) add(CheckboxFilterEditorEvent.didReceiveFilter(filter)); + if (!isClosed) { + add(CheckboxFilterEditorEvent.didReceiveFilter(filter)); + } }, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart index c4b5cab844a02..1decdd82155e1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/checklist_filter_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; @@ -44,7 +44,6 @@ class ChecklistFilterEditorBloc _filterBackendSvc.deleteFilter( fieldId: filterInfo.fieldInfo.id, filterId: filterInfo.filter.id, - fieldType: filterInfo.fieldInfo.fieldType, ); }, didReceiveFilter: (FilterPB filter) { @@ -64,9 +63,6 @@ class ChecklistFilterEditorBloc void _startListening() { _listener.start( - onDeleted: () { - if (!isClosed) add(const ChecklistFilterEditorEvent.delete()); - }, onUpdated: (filter) { if (!isClosed) { add(ChecklistFilterEditorEvent.didReceiveFilter(filter)); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index 57d1d1deb5d77..d8ea5906a86fd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pbenum.dart'; @@ -11,7 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/number_filter.pb.dart' import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -91,7 +91,9 @@ class GridCreateFilterBloc fieldController.addListener(onReceiveFields: _onFieldFn); } - Future> _createDefaultFilter(FieldInfo field) async { + Future> _createDefaultFilter( + FieldInfo field, + ) async { final fieldId = field.id; switch (field.fieldType) { case FieldType.Checkbox: @@ -112,7 +114,7 @@ class GridCreateFilterBloc case FieldType.MultiSelect: return _filterBackendSvc.insertSelectOptionFilter( fieldId: fieldId, - condition: SelectOptionConditionPB.OptionIs, + condition: SelectOptionFilterConditionPB.OptionContains, fieldType: FieldType.MultiSelect, ); case FieldType.Checklist: @@ -128,23 +130,23 @@ class GridCreateFilterBloc case FieldType.RichText: return _filterBackendSvc.insertTextFilter( fieldId: fieldId, - condition: TextFilterConditionPB.Contains, + condition: TextFilterConditionPB.TextContains, content: '', ); case FieldType.SingleSelect: return _filterBackendSvc.insertSelectOptionFilter( fieldId: fieldId, - condition: SelectOptionConditionPB.OptionIs, + condition: SelectOptionFilterConditionPB.OptionIs, fieldType: FieldType.SingleSelect, ); case FieldType.URL: return _filterBackendSvc.insertURLFilter( fieldId: fieldId, - condition: TextFilterConditionPB.Contains, + condition: TextFilterConditionPB.TextContains, ); + default: + throw UnimplementedError(); } - - return left(unit); } @override diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_menu_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_menu_bloc.dart index 08e45305dea3a..cc26e42b83e8c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_menu_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_menu_bloc.dart @@ -8,11 +8,11 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'filter_menu_bloc.freezed.dart'; -class GridFilterMenuBloc - extends Bloc { - GridFilterMenuBloc({required this.viewId, required this.fieldController}) +class DatabaseFilterMenuBloc + extends Bloc { + DatabaseFilterMenuBloc({required this.viewId, required this.fieldController}) : super( - GridFilterMenuState.initial( + DatabaseFilterMenuState.initial( viewId, fieldController.filterInfos, fieldController.fieldInfos, @@ -27,7 +27,7 @@ class GridFilterMenuBloc void Function(List)? _onFieldFn; void _dispatch() { - on( + on( (event, emit) async { event.when( initial: () { @@ -55,11 +55,11 @@ class GridFilterMenuBloc void _startListening() { _onFilterFn = (filters) { - add(GridFilterMenuEvent.didReceiveFilters(filters)); + add(DatabaseFilterMenuEvent.didReceiveFilters(filters)); }; _onFieldFn = (fields) { - add(GridFilterMenuEvent.didReceiveFields(fields)); + add(DatabaseFilterMenuEvent.didReceiveFields(fields)); }; fieldController.addListener( @@ -87,32 +87,33 @@ class GridFilterMenuBloc } @freezed -class GridFilterMenuEvent with _$GridFilterMenuEvent { - const factory GridFilterMenuEvent.initial() = _Initial; - const factory GridFilterMenuEvent.didReceiveFilters( +class DatabaseFilterMenuEvent with _$DatabaseFilterMenuEvent { + const factory DatabaseFilterMenuEvent.initial() = _Initial; + const factory DatabaseFilterMenuEvent.didReceiveFilters( List filters, ) = _DidReceiveFilters; - const factory GridFilterMenuEvent.didReceiveFields(List fields) = - _DidReceiveFields; - const factory GridFilterMenuEvent.toggleMenu() = _SetMenuVisibility; + const factory DatabaseFilterMenuEvent.didReceiveFields( + List fields, + ) = _DidReceiveFields; + const factory DatabaseFilterMenuEvent.toggleMenu() = _SetMenuVisibility; } @freezed -class GridFilterMenuState with _$GridFilterMenuState { - const factory GridFilterMenuState({ +class DatabaseFilterMenuState with _$DatabaseFilterMenuState { + const factory DatabaseFilterMenuState({ required String viewId, required List filters, required List fields, required List creatableFields, required bool isVisible, - }) = _GridFilterMenuState; + }) = _DatabaseFilterMenuState; - factory GridFilterMenuState.initial( + factory DatabaseFilterMenuState.initial( String viewId, List filterInfos, List fields, ) => - GridFilterMenuState( + DatabaseFilterMenuState( viewId: viewId, filters: filterInfos, fields: fields, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart index 832bfa905ae91..d68dd17537b7d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/number_filter_editor_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -59,7 +59,6 @@ class NumberFilterEditorBloc _filterBackendSvc.deleteFilter( fieldId: filterInfo.fieldInfo.id, filterId: filterInfo.filter.id, - fieldType: filterInfo.fieldInfo.fieldType, ); }, ); @@ -69,11 +68,6 @@ class NumberFilterEditorBloc void _startListening() { _listener.start( - onDeleted: () { - if (!isClosed) { - add(const NumberFilterEditorEvent.delete()); - } - }, onUpdated: (filter) { if (!isClosed) { add(NumberFilterEditorEvent.didReceiveFilter(filter)); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart index 144e6f014caba..3f44cb6d369c3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbserver.dart'; @@ -38,7 +38,7 @@ class SelectOptionFilterEditorBloc _startListening(); _loadOptions(); }, - updateCondition: (SelectOptionConditionPB condition) { + updateCondition: (SelectOptionFilterConditionPB condition) { _filterBackendSvc.insertSelectOptionFilter( filterId: filterInfo.filter.id, fieldId: filterInfo.fieldInfo.id, @@ -60,7 +60,6 @@ class SelectOptionFilterEditorBloc _filterBackendSvc.deleteFilter( fieldId: filterInfo.fieldInfo.id, filterId: filterInfo.filter.id, - fieldType: filterInfo.fieldInfo.fieldType, ); }, didReceiveFilter: (FilterPB filter) { @@ -83,9 +82,6 @@ class SelectOptionFilterEditorBloc void _startListening() { _listener.start( - onDeleted: () { - if (!isClosed) add(const SelectOptionFilterEditorEvent.delete()); - }, onUpdated: (filter) { if (!isClosed) { add(SelectOptionFilterEditorEvent.didReceiveFilter(filter)); @@ -121,7 +117,7 @@ class SelectOptionFilterEditorEvent with _$SelectOptionFilterEditorEvent { FilterPB filter, ) = _DidReceiveFilter; const factory SelectOptionFilterEditorEvent.updateCondition( - SelectOptionConditionPB condition, + SelectOptionFilterConditionPB condition, ) = _UpdateCondition; const factory SelectOptionFilterEditorEvent.updateContent( List optionIds, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart index 6bcc5b880655d..278f2a424f1c0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart @@ -1,5 +1,5 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.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'; @@ -24,9 +24,12 @@ class SelectOptionFilterListBloc _startListening(); _loadOptions(); }, - selectOption: (option) { - final selectedOptionIds = Set.from(state.selectedOptionIds); - selectedOptionIds.add(option.id); + selectOption: (option, condition) { + final selectedOptionIds = delegate.selectOption( + state.selectedOptionIds, + option.id, + condition, + ); _updateSelectOptions( selectedOptionIds: selectedOptionIds, @@ -116,6 +119,7 @@ class SelectOptionFilterListEvent with _$SelectOptionFilterListEvent { const factory SelectOptionFilterListEvent.initial() = _Initial; const factory SelectOptionFilterListEvent.selectOption( SelectOptionPB option, + SelectOptionFilterConditionPB condition, ) = _SelectOption; const factory SelectOptionFilterListEvent.unselectOption( SelectOptionPB option, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/text_filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/text_filter_editor_bloc.dart index 33147c68b4fe1..e4fa67c4a8f11 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/text_filter_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/text_filter_editor_bloc.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:appflowy/plugins/database/application/filter/filter_listener.dart'; -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/util.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'; @@ -12,7 +11,7 @@ part 'text_filter_editor_bloc.freezed.dart'; class TextFilterEditorBloc extends Bloc { - TextFilterEditorBloc({required this.filterInfo}) + TextFilterEditorBloc({required this.filterInfo, required this.fieldType}) : _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId), _listener = FilterListener( viewId: filterInfo.viewId, @@ -23,6 +22,7 @@ class TextFilterEditorBloc } final FilterInfo filterInfo; + final FieldType fieldType; final FilterBackendService _filterBackendSvc; final FilterListener _listener; @@ -34,26 +34,39 @@ class TextFilterEditorBloc _startListening(); }, updateCondition: (TextFilterConditionPB condition) { - _filterBackendSvc.insertTextFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: condition, - content: state.filter.content, - ); + fieldType == FieldType.RichText + ? _filterBackendSvc.insertTextFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: condition, + content: state.filter.content, + ) + : _filterBackendSvc.insertURLFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: condition, + content: state.filter.content, + ); }, - updateContent: (content) { - _filterBackendSvc.insertTextFilter( - filterId: filterInfo.filter.id, - fieldId: filterInfo.fieldInfo.id, - condition: state.filter.condition, - content: content, - ); + updateContent: (String content) { + fieldType == FieldType.RichText + ? _filterBackendSvc.insertTextFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + content: content, + ) + : _filterBackendSvc.insertURLFilter( + filterId: filterInfo.filter.id, + fieldId: filterInfo.fieldInfo.id, + condition: state.filter.condition, + content: content, + ); }, delete: () { _filterBackendSvc.deleteFilter( fieldId: filterInfo.fieldInfo.id, filterId: filterInfo.filter.id, - fieldType: filterInfo.fieldInfo.fieldType, ); }, didReceiveFilter: (FilterPB filter) { @@ -73,11 +86,10 @@ class TextFilterEditorBloc void _startListening() { _listener.start( - onDeleted: () { - if (!isClosed) add(const TextFilterEditorEvent.delete()); - }, onUpdated: (filter) { - if (!isClosed) add(TextFilterEditorEvent.didReceiveFilter(filter)); + if (!isClosed) { + add(TextFilterEditorEvent.didReceiveFilter(filter)); + } }, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart index d7a5d901fae80..01178d9b3f025 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart @@ -9,7 +9,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_in import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -65,7 +65,7 @@ class GridBloc extends Bloc { databaseController.moveRow(fromRowId: fromRow, toRowId: toRow); }, didReceiveGridUpdate: (grid) { - emit(state.copyWith(grid: Some(grid))); + emit(state.copyWith(grid: grid)); }, didReceiveFieldUpdate: (fields) { emit( @@ -147,11 +147,15 @@ class GridBloc extends Bloc { (grid) { databaseController.setIsLoading(false); emit( - state.copyWith(loadingState: LoadingState.finish(left(unit))), + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.success(null)), + ), ); }, (err) => emit( - state.copyWith(loadingState: LoadingState.finish(right(err))), + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.failure(err)), + ), ), ); } @@ -186,7 +190,7 @@ class GridEvent with _$GridEvent { class GridState with _$GridState { const factory GridState({ required String viewId, - required Option grid, + required DatabasePB? grid, required List fields, required List rowInfos, required int rowCount, @@ -204,7 +208,7 @@ class GridState with _$GridState { rowInfos: [], rowCount: 0, createdRow: null, - grid: none(), + grid: null, viewId: viewId, reorderable: true, loadingState: const LoadingState.loading(), diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart index 59e5a61e7fc24..b9fa5b7e922c4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart @@ -7,7 +7,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entitie import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../application/field/field_service.dart'; +import '../../domain/field_service.dart'; part 'grid_header_bloc.freezed.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart index 25453098683b3..0d655a840bc9b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart @@ -1,8 +1,8 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; -import 'package:appflowy/plugins/database/application/field_settings/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/log.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart deleted file mode 100644 index cdeb37bd88989..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_create_bloc.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbserver.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../../../application/field/field_controller.dart'; -import '../../../application/sort/sort_service.dart'; - -import 'util.dart'; - -part 'sort_create_bloc.freezed.dart'; - -class CreateSortBloc extends Bloc { - CreateSortBloc({required this.viewId, required this.fieldController}) - : _sortBackendSvc = SortBackendService(viewId: viewId), - super(CreateSortState.initial(fieldController.fieldInfos)) { - _dispatch(); - } - - final String viewId; - final SortBackendService _sortBackendSvc; - final FieldController fieldController; - void Function(List)? _onFieldFn; - - void _dispatch() { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveFields: (List fields) { - emit( - state.copyWith( - allFields: fields, - creatableFields: _filterFields(fields, state.filterText), - ), - ); - }, - didReceiveFilterText: (String text) { - emit( - state.copyWith( - filterText: text, - creatableFields: _filterFields(state.allFields, text), - ), - ); - }, - createDefaultSort: (FieldInfo field) { - emit(state.copyWith(didCreateSort: true)); - _createDefaultSort(field); - }, - ); - }, - ); - } - - List _filterFields( - List fields, - String filterText, - ) { - final List allFields = List.from(fields); - final keyword = filterText.toLowerCase(); - allFields.retainWhere((field) { - if (!field.canCreateSort) { - return false; - } - - if (filterText.isNotEmpty) { - return field.name.toLowerCase().contains(keyword); - } - - return true; - }); - - return allFields; - } - - void _startListening() { - _onFieldFn = (fields) { - fields.retainWhere((field) => field.canCreateSort); - add(CreateSortEvent.didReceiveFields(fields)); - }; - fieldController.addListener(onReceiveFields: _onFieldFn); - } - - Future> _createDefaultSort(FieldInfo field) async { - final result = await _sortBackendSvc.insertSort( - fieldId: field.id, - condition: SortConditionPB.Ascending, - ); - - return result; - } - - @override - Future close() async { - if (_onFieldFn != null) { - fieldController.removeListener(onFieldsListener: _onFieldFn); - _onFieldFn = null; - } - return super.close(); - } -} - -@freezed -class CreateSortEvent with _$CreateSortEvent { - const factory CreateSortEvent.initial() = _Initial; - const factory CreateSortEvent.didReceiveFields(List fields) = - _DidReceiveFields; - - const factory CreateSortEvent.createDefaultSort(FieldInfo field) = - _CreateDefaultSort; - - const factory CreateSortEvent.didReceiveFilterText(String text) = - _DidReceiveFilterText; -} - -@freezed -class CreateSortState with _$CreateSortState { - const factory CreateSortState({ - required String filterText, - required List creatableFields, - required List allFields, - required bool didCreateSort, - }) = _CreateSortState; - - factory CreateSortState.initial(List fields) { - return CreateSortState( - filterText: "", - creatableFields: getCreatableSorts(fields), - allFields: fields, - didCreateSort: false, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart index 4cb258f7b5e86..5f2bd8cf89002 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart @@ -2,32 +2,29 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/sort/sort_service.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_info.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'util.dart'; - part 'sort_editor_bloc.freezed.dart'; class SortEditorBloc extends Bloc { SortEditorBloc({ required this.viewId, required this.fieldController, - required List sortInfos, }) : _sortBackendSvc = SortBackendService(viewId: viewId), super( SortEditorState.initial( - sortInfos, + fieldController.sortInfos, fieldController.fieldInfos, ), ) { _dispatch(); + _startListening(); } final String viewId; @@ -41,9 +38,6 @@ class SortEditorBloc extends Bloc { on( (event, emit) async { await event.when( - initial: () { - _startListening(); - }, didReceiveFields: (List fields) { emit( state.copyWith( @@ -52,10 +46,16 @@ class SortEditorBloc extends Bloc { ), ); }, - createSort: (String fieldId, SortConditionPB condition) async { + updateCreateSortFilter: (text) { + emit(state.copyWith(filter: text)); + }, + createSort: ( + String fieldId, + SortConditionPB? condition, + ) async { final result = await _sortBackendSvc.insertSort( fieldId: fieldId, - condition: condition, + condition: condition ?? SortConditionPB.Ascending, ); result.fold((l) => {}, (err) => Log.error(err)); }, @@ -142,24 +142,25 @@ class SortEditorBloc extends Bloc { @freezed class SortEditorEvent with _$SortEditorEvent { - const factory SortEditorEvent.initial() = _Initial; const factory SortEditorEvent.didReceiveFields(List fieldInfos) = _DidReceiveFields; const factory SortEditorEvent.didReceiveSorts(List sortInfos) = _DidReceiveSorts; - const factory SortEditorEvent.createSort( - String fieldId, - SortConditionPB condition, - ) = _CreateSort; - const factory SortEditorEvent.editSort( - String sortId, + const factory SortEditorEvent.updateCreateSortFilter(String text) = + _UpdateCreateSortFilter; + const factory SortEditorEvent.createSort({ + required String fieldId, + SortConditionPB? condition, + }) = _CreateSort; + const factory SortEditorEvent.editSort({ + required String sortId, String? fieldId, SortConditionPB? condition, - ) = _EditSort; - const factory SortEditorEvent.deleteSort(SortInfo sortInfo) = _DeleteSort; - const factory SortEditorEvent.deleteAllSorts() = _DeleteAllSorts; + }) = _EditSort; const factory SortEditorEvent.reorderSort(int oldIndex, int newIndex) = _ReorderSort; + const factory SortEditorEvent.deleteSort(SortInfo sortInfo) = _DeleteSort; + const factory SortEditorEvent.deleteAllSorts() = _DeleteAllSorts; } @freezed @@ -168,6 +169,7 @@ class SortEditorState with _$SortEditorState { required List sortInfos, required List creatableFields, required List allFields, + required String filter, }) = _SortEditorState; factory SortEditorState.initial( @@ -178,6 +180,13 @@ class SortEditorState with _$SortEditorState { creatableFields: getCreatableSorts(fields), allFields: fields, sortInfos: sortInfos, + filter: "", ); } } + +List getCreatableSorts(List fieldInfos) { + final List creatableFields = List.from(fieldInfos); + creatableFields.retainWhere((element) => element.canCreateSort); + return creatableFields; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_menu_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_menu_bloc.dart deleted file mode 100644 index b65f71ed1e8f7..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_menu_bloc.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../../../application/field/field_controller.dart'; -import '../../presentation/widgets/sort/sort_info.dart'; - -import 'util.dart'; - -part 'sort_menu_bloc.freezed.dart'; - -class SortMenuBloc extends Bloc { - SortMenuBloc({required this.viewId, required this.fieldController}) - : super( - SortMenuState.initial( - viewId, - fieldController.sortInfos, - fieldController.fieldInfos, - ), - ) { - _dispatch(); - } - - final String viewId; - final FieldController fieldController; - void Function(List)? _onSortChangeFn; - void Function(List)? _onFieldFn; - - void _dispatch() { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveSortInfos: (sortInfos) { - emit(state.copyWith(sortInfos: sortInfos)); - }, - toggleMenu: () { - final isVisible = !state.isVisible; - emit(state.copyWith(isVisible: isVisible)); - }, - didReceiveFields: (List fields) { - emit( - state.copyWith( - fields: fields, - creatableFields: getCreatableSorts(fields), - ), - ); - }, - ); - }, - ); - } - - void _startListening() { - _onSortChangeFn = (sortInfos) { - add(SortMenuEvent.didReceiveSortInfos(sortInfos)); - }; - - _onFieldFn = (fields) { - add(SortMenuEvent.didReceiveFields(fields)); - }; - - fieldController.addListener( - onSorts: (sortInfos) { - _onSortChangeFn?.call(sortInfos); - }, - onReceiveFields: (fields) { - _onFieldFn?.call(fields); - }, - ); - } - - @override - Future close() { - if (_onSortChangeFn != null) { - fieldController.removeListener(onSortsListener: _onSortChangeFn!); - _onSortChangeFn = null; - } - if (_onFieldFn != null) { - fieldController.removeListener(onFieldsListener: _onFieldFn!); - _onFieldFn = null; - } - return super.close(); - } -} - -@freezed -class SortMenuEvent with _$SortMenuEvent { - const factory SortMenuEvent.initial() = _Initial; - const factory SortMenuEvent.didReceiveSortInfos(List sortInfos) = - _DidReceiveSortInfos; - const factory SortMenuEvent.didReceiveFields(List fields) = - _DidReceiveFields; - const factory SortMenuEvent.toggleMenu() = _SetMenuVisibility; -} - -@freezed -class SortMenuState with _$SortMenuState { - const factory SortMenuState({ - required String viewId, - required List sortInfos, - required List fields, - required List creatableFields, - required bool isVisible, - }) = _SortMenuState; - - factory SortMenuState.initial( - String viewId, - List sortInfos, - List fields, - ) => - SortMenuState( - viewId: viewId, - sortInfos: sortInfos, - fields: fields, - creatableFields: getCreatableSorts(fields), - isVisible: false, - ); -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/util.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/util.dart deleted file mode 100644 index 5a147b8c67ac4..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/util.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:appflowy/plugins/database/application/field/field_info.dart'; - -List getCreatableSorts(List fieldInfos) { - final List creatableFields = List.from(fieldInfos); - creatableFields.retainWhere((element) => element.canCreateSort); - return creatableFields; -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index bca5f06dfb175..1c239397713ec 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -12,7 +12,6 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:collection/collection.dart'; 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/scrolling/styled_scrollview.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; @@ -20,7 +19,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import '../../application/database_controller.dart'; -import '../../application/row/row_cache.dart'; import '../../application/row/row_controller.dart'; import '../../tab_bar/tab_bar_view.dart'; import '../../widgets/row/row_detail.dart'; @@ -237,7 +235,6 @@ class _GridPageContentState extends State { viewId: widget.view.id, scrollController: _scrollController, ), - const _GridFooter(), ], ); } @@ -261,7 +258,7 @@ class _GridHeader extends StatelessWidget { } } -class _GridRows extends StatelessWidget { +class _GridRows extends StatefulWidget { const _GridRows({ required this.viewId, required this.scrollController, @@ -270,6 +267,30 @@ class _GridRows extends StatelessWidget { final String viewId; final GridScrollController scrollController; + @override + State<_GridRows> createState() => _GridRowsState(); +} + +class _GridRowsState extends State<_GridRows> { + bool showFloatingCalculations = false; + + @override + void initState() { + super.initState(); + _evaluateFloatingCalculations(); + } + + void _evaluateFloatingCalculations() { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + // maxScrollExtent is 0.0 if scrolling is not possible + showFloatingCalculations = widget + .scrollController.verticalController.position.maxScrollExtent > + 0; + }); + }); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -277,24 +298,18 @@ class _GridRows extends StatelessWidget { builder: (context, state) { return Flexible( child: _WrapScrollView( - scrollController: scrollController, + scrollController: widget.scrollController, contentWidth: GridLayout.headerWidth(state.fields), - child: BlocBuilder( - buildWhen: (previous, current) => current.reason.maybeWhen( - reorderRows: () => true, - reorderSingleRow: (reorderRow, rowInfo) => true, - delete: (item) => true, - insert: (item) => true, - orElse: () => true, - ), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.rowCount != current.rowCount, + listener: (context, state) => _evaluateFloatingCalculations(), builder: (context, state) { - final rowInfos = state.rowInfos; - final behavior = ScrollConfiguration.of(context).copyWith( - scrollbars: false, - ); return ScrollConfiguration( - behavior: behavior, - child: _renderList(context, state, rowInfos), + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + ), + child: _renderList(context, state), ); }, ), @@ -307,9 +322,8 @@ class _GridRows extends StatelessWidget { Widget _renderList( BuildContext context, GridState state, - List rowInfos, ) { - final children = rowInfos.mapIndexed((index, rowInfo) { + final children = state.rowInfos.mapIndexed((index, rowInfo) { return _renderRow( context, rowInfo.rowId, @@ -317,32 +331,56 @@ class _GridRows extends StatelessWidget { index: index, ); }).toList() - ..add(const GridRowBottomBar(key: Key('grid_footer'))) - ..add( + ..add(const GridRowBottomBar(key: Key('grid_footer'))); + + if (showFloatingCalculations) { + children.add( + const SizedBox( + key: Key('calculations_bottom_padding'), + height: 36, + ), + ); + } else { + children.add( GridCalculationsRow( key: const Key('grid_calculations'), - viewId: viewId, + viewId: widget.viewId, ), ); + } - return ReorderableListView.builder( - /// This is a workaround related to - /// https://github.com/flutter/flutter/issues/25652 - cacheExtent: 5000, - scrollController: scrollController.verticalController, - buildDefaultDragHandles: false, - proxyDecorator: (child, index, animation) => Material( - color: Colors.white.withOpacity(.1), - child: Opacity(opacity: .5, child: child), - ), - onReorder: (fromIndex, newIndex) { - final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; - if (fromIndex != toIndex) { - context.read().add(GridEvent.moveRow(fromIndex, toIndex)); - } - }, - itemCount: children.length, - itemBuilder: (context, index) => children[index], + children.add(const SizedBox(key: Key('footer_padding'), height: 10)); + + return Stack( + children: [ + Positioned.fill( + child: ReorderableListView.builder( + /// This is a workaround related to + /// https://github.com/flutter/flutter/issues/25652 + cacheExtent: 5000, + scrollController: widget.scrollController.verticalController, + physics: const ClampingScrollPhysics(), + buildDefaultDragHandles: false, + proxyDecorator: (child, index, animation) => Material( + color: Colors.white.withOpacity(.1), + child: Opacity(opacity: .5, child: child), + ), + onReorder: (fromIndex, newIndex) { + final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; + if (fromIndex != toIndex) { + context + .read() + .add(GridEvent.moveRow(fromIndex, toIndex)); + } + }, + itemCount: children.length, + itemBuilder: (context, index) => children[index], + ), + ), + if (showFloatingCalculations) ...[ + _PositionedCalculationsRow(viewId: widget.viewId), + ], + ], ); } @@ -380,21 +418,16 @@ class _GridRows extends StatelessWidget { openDetailPage: (context, cellBuilder) { FlowyOverlay.show( context: context, - builder: (BuildContext context) { - return RowDetailPage( - rowController: rowController, - databaseController: databaseController, - ); - }, + builder: (_) => RowDetailPage( + rowController: rowController, + databaseController: databaseController, + ), ); }, ); if (animation != null) { - return SizeTransition( - sizeFactor: animation, - child: child, - ); + return SizeTransition(sizeFactor: animation, child: child); } return child; @@ -415,12 +448,14 @@ class _WrapScrollView extends StatelessWidget { @override Widget build(BuildContext context) { return ScrollbarListStack( + includeInsets: false, axis: Axis.vertical, controller: scrollController.verticalController, barSize: GridSize.scrollBarSize, autoHideScrollbar: false, child: StyledSingleChildScrollView( autoHideScrollbar: false, + includeInsets: false, controller: scrollController.horizontalController, axis: Axis.horizontal, child: SizedBox( @@ -432,39 +467,48 @@ class _WrapScrollView extends StatelessWidget { } } -class _GridFooter extends StatelessWidget { - const _GridFooter(); +/// This Widget is used to show the Calculations Row at the bottom of the Grids ScrollView +/// when the ScrollView is scrollable. +/// +class _PositionedCalculationsRow extends StatefulWidget { + const _PositionedCalculationsRow({ + required this.viewId, + }); + + final String viewId; + + @override + State<_PositionedCalculationsRow> createState() => + _PositionedCalculationsRowState(); +} +class _PositionedCalculationsRowState + extends State<_PositionedCalculationsRow> { @override Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.rowCount, - builder: (context, rowCount) { - return Padding( - padding: GridSize.contentInsets, - child: RichText( - text: TextSpan( - text: rowCountString(), - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith(color: Theme.of(context).hintColor), - children: [ - TextSpan( - text: ' $rowCount', - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: AFThemeExtension.of(context).gridRowCountColor, - ), - ), - ], - ), + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding), + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), ), - ); - }, + ), + child: SizedBox( + height: 36, + width: double.infinity, + child: GridCalculationsRow( + key: const Key('floating_grid_calculations'), + viewId: widget.viewId, + includeDefaultInsets: false, + ), + ), + ), ); } } - -String rowCountString() { - return '${LocaleKeys.grid_row_count.tr()} :'; -} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart index 88541b343e776..853f83c64dc53 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart @@ -16,7 +16,7 @@ class GridLayout { .reduce((value, element) => value + element); return fieldsWidth + - GridSize.leadingHeaderPadding + + GridSize.horizontalHeaderPadding + GridSize.trailHeaderPadding; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart index e73ffe7e9a943..5e96e35f1f7bc 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart @@ -7,19 +7,14 @@ class GridSize { static double get scrollBarSize => 8 * scale; static double get headerHeight => 40 * scale; static double get footerHeight => 40 * scale; - static double get leadingHeaderPadding => - PlatformExtension.isDesktop ? 40 * scale : 20 * scale; + static double get horizontalHeaderPadding => + PlatformExtension.isDesktop ? 40 * scale : 16 * scale; static double get trailHeaderPadding => 140 * scale; - static double get headerContainerPadding => 0 * scale; static double get cellHPadding => 10 * scale; static double get cellVPadding => 10 * scale; static double get popoverItemHeight => 26 * scale; static double get typeOptionSeparatorHeight => 4 * scale; - static EdgeInsets get headerContentInsets => EdgeInsets.symmetric( - horizontal: GridSize.headerContainerPadding, - vertical: GridSize.headerContainerPadding, - ); static EdgeInsets get cellContentInsets => EdgeInsets.symmetric( horizontal: GridSize.cellHPadding, vertical: GridSize.cellVPadding, @@ -36,18 +31,13 @@ class GridSize { const EdgeInsets.symmetric(horizontal: 8, vertical: 2); static EdgeInsets get footerContentInsets => EdgeInsets.fromLTRB( - GridSize.leadingHeaderPadding, - GridSize.headerContainerPadding, - PlatformExtension.isMobile - ? GridSize.leadingHeaderPadding - : GridSize.headerContainerPadding, - PlatformExtension.isMobile ? 100 : GridSize.headerContainerPadding, + GridSize.horizontalHeaderPadding, + 0, + PlatformExtension.isMobile ? GridSize.horizontalHeaderPadding : 0, + PlatformExtension.isMobile ? 100 : 0, ); - static EdgeInsets get contentInsets => EdgeInsets.fromLTRB( - GridSize.leadingHeaderPadding, - GridSize.headerContainerPadding, - GridSize.leadingHeaderPadding, - GridSize.headerContainerPadding, + static EdgeInsets get contentInsets => EdgeInsets.symmetric( + horizontal: GridSize.horizontalHeaderPadding, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart index 23b51c437f37e..1172f81ac992e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart @@ -157,12 +157,14 @@ class _GridPageContentState extends State { final _scrollController = GridScrollController( scrollGroupController: LinkedScrollControllerGroup(), ); - late final ScrollController headerScrollController; + late final ScrollController contentScrollController; + late final ScrollController reorderableController; @override void initState() { super.initState(); - headerScrollController = _scrollController.linkHorizontalController(); + contentScrollController = _scrollController.linkHorizontalController(); + reorderableController = _scrollController.linkHorizontalController(); } @override @@ -196,7 +198,8 @@ class _GridPageContentState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _GridHeader( - headerScrollController: headerScrollController, + contentScrollController: contentScrollController, + reorderableController: reorderableController, ), _GridRows( viewId: widget.view.id, @@ -205,8 +208,8 @@ class _GridPageContentState extends State { ], ), Positioned( - bottom: 20, - right: 20, + bottom: 16, + right: 16, child: getGridFabs(context), ), ], @@ -216,9 +219,13 @@ class _GridPageContentState extends State { } class _GridHeader extends StatelessWidget { - const _GridHeader({required this.headerScrollController}); + const _GridHeader({ + required this.contentScrollController, + required this.reorderableController, + }); - final ScrollController headerScrollController; + final ScrollController contentScrollController; + final ScrollController reorderableController; @override Widget build(BuildContext context) { @@ -226,7 +233,8 @@ class _GridHeader extends StatelessWidget { builder: (context, state) { return MobileGridHeader( viewId: state.viewId, - anchorScrollController: headerScrollController, + contentScrollController: contentScrollController, + reorderableController: reorderableController, ); }, ); @@ -247,7 +255,7 @@ class _GridRows extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.fields != current.fields, builder: (context, state) { - final double contentWidth = _getContentWidth(state.fields); + final double contentWidth = getMobileGridContentWidth(state.fields); return Expanded( child: _WrapScrollView( scrollController: scrollController, @@ -277,14 +285,6 @@ class _GridRows extends StatelessWidget { ); } - double _getContentWidth(List fields) { - final visibleFields = fields.where( - (field) => - field.fieldSettings?.visibility != FieldVisibility.AlwaysHidden, - ); - return (visibleFields.length + 1) * 200 + GridSize.leadingHeaderPadding * 2; - } - Widget _renderList( BuildContext context, GridState state, @@ -438,3 +438,11 @@ class _AddRowButton extends StatelessWidget { ); } } + +double getMobileGridContentWidth(List fields) { + final visibleFields = fields.where( + (field) => field.fieldSettings?.visibility != FieldVisibility.AlwaysHidden, + ); + return (visibleFields.length + 1) * 200 + + GridSize.horizontalHeaderPadding * 2; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart index c2ff2952126ff..c8199ce135e9c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart @@ -1,17 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/calculations/calculations_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/calculations/field_type_calc_ext.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dart'; import 'package:appflowy_popover/appflowy_popover.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_bloc/flutter_bloc.dart'; class CalculateCell extends StatefulWidget { @@ -31,12 +35,45 @@ class CalculateCell extends StatefulWidget { } class _CalculateCellState extends State { + final _cellScrollController = ScrollController(); bool isSelected = false; + bool isScrollable = false; + + @override + void initState() { + super.initState(); + _checkScrollable(); + } + + @override + void didUpdateWidget(covariant CalculateCell oldWidget) { + _checkScrollable(); + super.didUpdateWidget(oldWidget); + } + + void _checkScrollable() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_cellScrollController.hasClients) { + setState( + () => + isScrollable = _cellScrollController.position.maxScrollExtent > 0, + ); + } + }); + } + + @override + void dispose() { + _cellScrollController.dispose(); + super.dispose(); + } void setIsSelected(bool selected) => setState(() => isSelected = selected); @override Widget build(BuildContext context) { + final prefix = _prefixFromFieldType(widget.fieldInfo.fieldType); + return SizedBox( height: 35, width: widget.width, @@ -63,75 +100,109 @@ class _CalculateCellState extends State { ), ), ), - ...CalculationType.values.map( - (type) => CalculationTypeItem( - type: type, - onTap: () { - if (type != widget.calculation?.calculationType) { - context.read().add( - CalculationsEvent.updateCalculationType( - widget.fieldInfo.id, - type, - calculationId: widget.calculation?.id, - ), - ); - } - }, - ), - ), + ...widget.fieldInfo.fieldType.calculationsForFieldType().map( + (type) => CalculationTypeItem( + type: type, + onTap: () { + if (type != widget.calculation?.calculationType) { + context.read().add( + CalculationsEvent.updateCalculationType( + widget.fieldInfo.id, + type, + calculationId: widget.calculation?.id, + ), + ); + } + }, + ), + ), ], ), ); }, - child: widget.fieldInfo.fieldType == FieldType.Number - ? widget.calculation != null - ? _showCalculateValue(context) - : CalculationSelector(isSelected: isSelected) - : const SizedBox.shrink(), + child: widget.calculation != null + ? _showCalculateValue(context, prefix) + : CalculationSelector(isSelected: isSelected), ), ); } - Widget _showCalculateValue(BuildContext context) { - return FlowyButton( - radius: BorderRadius.zero, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: FlowyText( - widget.calculation!.calculationType.label, - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, + Widget _showCalculateValue(BuildContext context, String? prefix) { + prefix = prefix != null ? '$prefix ' : ''; + final calculateValue = + '$prefix${_withoutTrailingZeros(widget.calculation!.value)}'; + + return FlowyTooltip( + message: !isScrollable ? "" : null, + richMessage: !isScrollable + ? null + : TextSpan( + children: [ + TextSpan( + text: widget.calculation!.calculationType.shortLabel + .toUpperCase(), + ), + const TextSpan(text: ' '), + TextSpan( + text: calculateValue, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ], ), - ), - if (widget.calculation!.value.isNotEmpty) ...[ - const HSpace(8), - Flexible( - child: FlowyText( - _withoutTrailingZeros(widget.calculation!.value), - color: AFThemeExtension.of(context).textColor, + child: FlowyButton( + radius: BorderRadius.zero, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: Row( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _cellScrollController, + key: ValueKey(widget.calculation!.id), + reverse: true, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyText( + widget.calculation!.calculationType.shortLabel + .toUpperCase(), + color: Theme.of(context).hintColor, + fontSize: 10, + ), + if (widget.calculation!.value.isNotEmpty) ...[ + const HSpace(8), + FlowyText( + calculateValue, + color: AFThemeExtension.of(context).textColor, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), ), ), ], - const HSpace(8), - FlowySvg( - FlowySvgs.arrow_down_s, - color: Theme.of(context).hintColor, - ), - ], + ), ), ); } String _withoutTrailingZeros(String value) { - final regex = RegExp(r'^(\d+(?:\.\d*?[1-9](?=0|\b))?)\.?0*$'); - if (regex.hasMatch(value)) { - final match = regex.firstMatch(value)!; + if (trailingZerosRegex.hasMatch(value)) { + final match = trailingZerosRegex.firstMatch(value)!; return match.group(1)!; } return value; } + + String? _prefixFromFieldType(FieldType fieldType) => switch (fieldType) { + FieldType.Number => + NumberTypeOptionPB.fromBuffer(widget.fieldInfo.field.typeOptionData) + .format + .iconSymbol(false), + _ => null, + }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart index 09ea5c76c595f..b95295818cd98 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart @@ -32,7 +32,7 @@ class _CalculationSelectorState extends State { onEnter: (_) => _setHovering(true), onExit: (_) => _setHovering(false), child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 100), opacity: widget.isSelected || _isHovering ? 1 : 0, child: FlowyButton( radius: BorderRadius.zero, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart index 0d44e94190525..9d9255e3a630a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart @@ -7,9 +7,14 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations import 'package:flutter_bloc/flutter_bloc.dart'; class GridCalculationsRow extends StatelessWidget { - const GridCalculationsRow({super.key, required this.viewId}); + const GridCalculationsRow({ + super.key, + required this.viewId, + this.includeDefaultInsets = true, + }); final String viewId; + final bool includeDefaultInsets; @override Widget build(BuildContext context) { @@ -23,7 +28,8 @@ class GridCalculationsRow extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return Padding( - padding: GridSize.contentInsets, + padding: + includeDefaultInsets ? GridSize.contentInsets : EdgeInsets.zero, child: Row( children: [ ...state.fields.map( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart index 74b1dfdced261..d33dba62938ab 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart @@ -1,15 +1,12 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/condition_button.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; - -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pb.dart'; -import 'package:flutter/material.dart'; - -import '../../condition_button.dart'; -import '../../filter_info.dart'; +import 'package:flutter/widgets.dart'; class SelectOptionFilterConditionList extends StatelessWidget { const SelectOptionFilterConditionList({ @@ -21,7 +18,7 @@ class SelectOptionFilterConditionList extends StatelessWidget { final FilterInfo filterInfo; final PopoverMutex popoverMutex; - final Function(SelectOptionConditionPB) onCondition; + final Function(SelectOptionFilterConditionPB) onCondition; @override Widget build(BuildContext context) { @@ -30,18 +27,17 @@ class SelectOptionFilterConditionList extends StatelessWidget { asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, - actions: SelectOptionConditionPB.values + actions: _conditionsForFieldType(filterInfo.fieldInfo.fieldType) .map( (action) => ConditionWrapper( action, selectOptionFilter.condition == action, - filterInfo.fieldInfo.fieldType, ), ) .toList(), buildChild: (controller) { return ConditionButton( - conditionName: filterName(selectOptionFilter), + conditionName: selectOptionFilter.condition.i18n, onTap: () => controller.show(), ); }, @@ -52,69 +48,62 @@ class SelectOptionFilterConditionList extends StatelessWidget { ); } - String filterName(SelectOptionFilterPB filter) { - if (filterInfo.fieldInfo.fieldType == FieldType.SingleSelect) { - return filter.condition.singleSelectFilterName; - } else { - return filter.condition.multiSelectFilterName; - } + List _conditionsForFieldType( + FieldType fieldType, + ) { + // SelectOptionFilterConditionPB.values is not in order + return switch (fieldType) { + FieldType.SingleSelect => [ + SelectOptionFilterConditionPB.OptionIs, + SelectOptionFilterConditionPB.OptionIsNot, + SelectOptionFilterConditionPB.OptionIsEmpty, + SelectOptionFilterConditionPB.OptionIsNotEmpty, + ], + FieldType.MultiSelect => [ + SelectOptionFilterConditionPB.OptionContains, + SelectOptionFilterConditionPB.OptionDoesNotContain, + SelectOptionFilterConditionPB.OptionIs, + SelectOptionFilterConditionPB.OptionIsNot, + SelectOptionFilterConditionPB.OptionIsEmpty, + SelectOptionFilterConditionPB.OptionIsNotEmpty, + ], + _ => [], + }; } } class ConditionWrapper extends ActionCell { - ConditionWrapper(this.inner, this.isSelected, this.fieldType); + ConditionWrapper(this.inner, this.isSelected); - final SelectOptionConditionPB inner; + final SelectOptionFilterConditionPB inner; final bool isSelected; - final FieldType fieldType; @override Widget? rightIcon(Color iconColor) { - if (isSelected) { - return const FlowySvg(FlowySvgs.check_s); - } else { - return null; - } + return isSelected ? const FlowySvg(FlowySvgs.check_s) : null; } @override - String get name { - if (fieldType == FieldType.SingleSelect) { - return inner.singleSelectFilterName; - } else { - return inner.multiSelectFilterName; - } - } + String get name => inner.i18n; } -extension SelectOptionConditionPBExtension on SelectOptionConditionPB { - String get singleSelectFilterName { - switch (this) { - case SelectOptionConditionPB.OptionIs: - return LocaleKeys.grid_singleSelectOptionFilter_is.tr(); - case SelectOptionConditionPB.OptionIsEmpty: - return LocaleKeys.grid_singleSelectOptionFilter_isEmpty.tr(); - case SelectOptionConditionPB.OptionIsNot: - return LocaleKeys.grid_singleSelectOptionFilter_isNot.tr(); - case SelectOptionConditionPB.OptionIsNotEmpty: - return LocaleKeys.grid_singleSelectOptionFilter_isNotEmpty.tr(); - default: - return ""; - } - } - - String get multiSelectFilterName { - switch (this) { - case SelectOptionConditionPB.OptionIs: - return LocaleKeys.grid_multiSelectOptionFilter_contains.tr(); - case SelectOptionConditionPB.OptionIsEmpty: - return LocaleKeys.grid_multiSelectOptionFilter_isEmpty.tr(); - case SelectOptionConditionPB.OptionIsNot: - return LocaleKeys.grid_multiSelectOptionFilter_doesNotContain.tr(); - case SelectOptionConditionPB.OptionIsNotEmpty: - return LocaleKeys.grid_multiSelectOptionFilter_isNotEmpty.tr(); - default: - return ""; - } +extension SelectOptionFilterConditionPBExtension + on SelectOptionFilterConditionPB { + String get i18n { + return switch (this) { + SelectOptionFilterConditionPB.OptionIs => + LocaleKeys.grid_selectOptionFilter_is.tr(), + SelectOptionFilterConditionPB.OptionIsNot => + LocaleKeys.grid_selectOptionFilter_isNot.tr(), + SelectOptionFilterConditionPB.OptionContains => + LocaleKeys.grid_selectOptionFilter_contains.tr(), + SelectOptionFilterConditionPB.OptionDoesNotContain => + LocaleKeys.grid_selectOptionFilter_doesNotContain.tr(), + SelectOptionFilterConditionPB.OptionIsEmpty => + LocaleKeys.grid_selectOptionFilter_isEmpty.tr(), + SelectOptionFilterConditionPB.OptionIsNotEmpty => + LocaleKeys.grid_selectOptionFilter_isNotEmpty.tr(), + _ => "", + }; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart index 5d2406094a040..67f10d4e3fa30 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/select_option_filter_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/filter/select_option_filter_list_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; @@ -90,9 +91,16 @@ class _SelectOptionFilterCellState extends State { .read() .add(SelectOptionFilterListEvent.unselectOption(widget.option)); } else { - context - .read() - .add(SelectOptionFilterListEvent.selectOption(widget.option)); + context.read().add( + SelectOptionFilterListEvent.selectOption( + widget.option, + context + .read() + .state + .filter + .condition, + ), + ); } }, children: [ diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart index 6c80c9f01adf8..24d1955a3faa8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart @@ -101,9 +101,10 @@ class _SelectOptionFilterEditorState extends State { SliverToBoxAdapter(child: _buildFilterPanel(context, state)), ]; - if (state.filter.condition != SelectOptionConditionPB.OptionIsEmpty && + if (state.filter.condition != + SelectOptionFilterConditionPB.OptionIsEmpty && state.filter.condition != - SelectOptionConditionPB.OptionIsNotEmpty) { + SelectOptionFilterConditionPB.OptionIsNotEmpty) { slivers.add(const SliverToBoxAdapter(child: VSpace(4))); slivers.add( SliverToBoxAdapter( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart index c36ac8c992b40..502faa9aa0bb5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option_loader.dart @@ -1,9 +1,15 @@ import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; abstract class SelectOptionFilterDelegate { List loadOptions(); + + Set selectOption( + Set currentOptionIds, + String optionId, + SelectOptionFilterConditionPB condition, + ); } class SingleSelectOptionFilterDelegateImpl @@ -17,6 +23,22 @@ class SingleSelectOptionFilterDelegateImpl final parser = SingleSelectTypeOptionDataParser(); return parser.fromBuffer(filterInfo.fieldInfo.field.typeOptionData).options; } + + @override + Set selectOption( + Set currentOptionIds, + String optionId, + SelectOptionFilterConditionPB condition, + ) { + final selectOptionIds = Set.from(currentOptionIds); + + if (condition == SelectOptionFilterConditionPB.OptionIsNot || + selectOptionIds.isEmpty) { + selectOptionIds.add(optionId); + } + + return selectOptionIds; + } } class MultiSelectOptionFilterDelegateImpl @@ -30,4 +52,12 @@ class MultiSelectOptionFilterDelegateImpl final parser = MultiSelectTypeOptionDataParser(); return parser.fromBuffer(filterInfo.fieldInfo.field.typeOptionData).options; } + + @override + Set selectOption( + Set currentOptionIds, + String optionId, + SelectOptionFilterConditionPB condition, + ) => + Set.from(currentOptionIds)..add(optionId); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart index 9c15af4f93fbc..66f17e09719d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart @@ -1,59 +1,44 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/text_filter_editor_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../application/filter/text_filter_editor_bloc.dart'; + import '../condition_button.dart'; import '../disclosure_button.dart'; import '../filter_info.dart'; import 'choicechip.dart'; -class TextFilterChoicechip extends StatefulWidget { +class TextFilterChoicechip extends StatelessWidget { const TextFilterChoicechip({required this.filterInfo, super.key}); final FilterInfo filterInfo; - @override - State createState() => _TextFilterChoicechipState(); -} - -class _TextFilterChoicechipState extends State { - late TextFilterEditorBloc bloc; - - @override - void initState() { - bloc = TextFilterEditorBloc(filterInfo: widget.filterInfo) - ..add(const TextFilterEditorEvent.initial()); - super.initState(); - } - - @override - void dispose() { - bloc.close(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, + return BlocProvider( + create: (_) => TextFilterEditorBloc( + filterInfo: filterInfo, + fieldType: FieldType.RichText, + )..add(const TextFilterEditorEvent.initial()), child: BlocBuilder( - builder: (blocContext, state) { + builder: (context, state) { return AppFlowyPopover( - controller: PopoverController(), constraints: BoxConstraints.loose(const Size(200, 76)), direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: (BuildContext context) { - return TextFilterEditor(bloc: bloc); + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: const TextFilterEditor(), + ); }, child: ChoiceChipButton( - filterInfo: widget.filterInfo, + filterInfo: filterInfo, filterDesc: _makeFilterDesc(state), ), ); @@ -78,9 +63,7 @@ class _TextFilterChoicechipState extends State { } class TextFilterEditor extends StatefulWidget { - const TextFilterEditor({required this.bloc, super.key}); - - final TextFilterEditorBloc bloc; + const TextFilterEditor({super.key}); @override State createState() => _TextFilterEditorState(); @@ -91,26 +74,23 @@ class _TextFilterEditorState extends State { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: widget.bloc, - child: BlocBuilder( - builder: (context, state) { - final List children = [ - _buildFilterPanel(context, state), - ]; + return BlocBuilder( + builder: (context, state) { + final List children = [ + _buildFilterPanel(context, state), + ]; - if (state.filter.condition != TextFilterConditionPB.TextIsEmpty && - state.filter.condition != TextFilterConditionPB.TextIsNotEmpty) { - children.add(const VSpace(4)); - children.add(_buildFilterTextField(context, state)); - } + if (state.filter.condition != TextFilterConditionPB.TextIsEmpty && + state.filter.condition != TextFilterConditionPB.TextIsNotEmpty) { + children.add(const VSpace(4)); + children.add(_buildFilterTextField(context, state)); + } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - child: IntrinsicHeight(child: Column(children: children)), - ); - }, - ), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, ); } @@ -236,17 +216,17 @@ class ConditionWrapper extends ActionCell { extension TextFilterConditionPBExtension on TextFilterConditionPB { String get filterName { switch (this) { - case TextFilterConditionPB.Contains: + case TextFilterConditionPB.TextContains: return LocaleKeys.grid_textFilter_contains.tr(); - case TextFilterConditionPB.DoesNotContain: + case TextFilterConditionPB.TextDoesNotContain: return LocaleKeys.grid_textFilter_doesNotContain.tr(); - case TextFilterConditionPB.EndsWith: + case TextFilterConditionPB.TextEndsWith: return LocaleKeys.grid_textFilter_endsWith.tr(); - case TextFilterConditionPB.Is: + case TextFilterConditionPB.TextIs: return LocaleKeys.grid_textFilter_is.tr(); - case TextFilterConditionPB.IsNot: + case TextFilterConditionPB.TextIsNot: return LocaleKeys.grid_textFilter_isNot.tr(); - case TextFilterConditionPB.StartsWith: + case TextFilterConditionPB.TextStartsWith: return LocaleKeys.grid_textFilter_startWith.tr(); case TextFilterConditionPB.TextIsEmpty: return LocaleKeys.grid_textFilter_isEmpty.tr(); @@ -259,13 +239,13 @@ extension TextFilterConditionPBExtension on TextFilterConditionPB { String get choicechipPrefix { switch (this) { - case TextFilterConditionPB.DoesNotContain: + case TextFilterConditionPB.TextDoesNotContain: return LocaleKeys.grid_textFilter_choicechipPrefix_isNot.tr(); - case TextFilterConditionPB.EndsWith: + case TextFilterConditionPB.TextEndsWith: return LocaleKeys.grid_textFilter_choicechipPrefix_endWith.tr(); - case TextFilterConditionPB.IsNot: + case TextFilterConditionPB.TextIsNot: return LocaleKeys.grid_textFilter_choicechipPrefix_isNot.tr(); - case TextFilterConditionPB.StartsWith: + case TextFilterConditionPB.TextStartsWith: return LocaleKeys.grid_textFilter_choicechipPrefix_startWith.tr(); case TextFilterConditionPB.TextIsEmpty: return LocaleKeys.grid_textFilter_choicechipPrefix_isEmpty.tr(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart index 440091f24deef..53d2b0ace8551 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart @@ -1,14 +1,58 @@ +import 'package:appflowy/plugins/database/grid/application/filter/text_filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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'; + import '../filter_info.dart'; import 'choicechip.dart'; -class URLFilterChoicechip extends StatelessWidget { - const URLFilterChoicechip({required this.filterInfo, super.key}); +class URLFilterChoiceChip extends StatelessWidget { + const URLFilterChoiceChip({required this.filterInfo, super.key}); final FilterInfo filterInfo; @override Widget build(BuildContext context) { - return ChoiceChipButton(filterInfo: filterInfo); + return BlocProvider( + create: (_) => TextFilterEditorBloc( + filterInfo: filterInfo, + fieldType: FieldType.URL, + ), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 76)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: const TextFilterEditor(), + ); + }, + child: ChoiceChipButton( + filterInfo: filterInfo, + filterDesc: _makeFilterDesc(state), + ), + ); + }, + ), + ); + } + + String _makeFilterDesc(TextFilterEditorState state) { + String filterDesc = state.filter.condition.choicechipPrefix; + if (state.filter.condition == TextFilterConditionPB.TextIsEmpty || + state.filter.condition == TextFilterConditionPB.TextIsNotEmpty) { + return filterDesc; + } + + if (state.filter.content.isNotEmpty) { + filterDesc += " ${state.filter.content}"; + } + + return filterDesc; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart index 97fc5907482d8..19c201d026aae 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_info.dart @@ -18,42 +18,46 @@ class FilterInfo { String get filterId => filter.id; - String get fieldId => filter.fieldId; + String get fieldId => filter.data.fieldId; DateFilterPB? dateFilter() { - return filter.fieldType == FieldType.DateTime - ? DateFilterPB.fromBuffer(filter.data) + final fieldType = filter.data.fieldType; + return fieldType == FieldType.DateTime || + fieldType == FieldType.CreatedTime || + fieldType == FieldType.LastEditedTime + ? DateFilterPB.fromBuffer(filter.data.data) : null; } TextFilterPB? textFilter() { - return filter.fieldType == FieldType.RichText - ? TextFilterPB.fromBuffer(filter.data) + return filter.data.fieldType == FieldType.RichText || + filter.data.fieldType == FieldType.URL + ? TextFilterPB.fromBuffer(filter.data.data) : null; } CheckboxFilterPB? checkboxFilter() { - return filter.fieldType == FieldType.Checkbox - ? CheckboxFilterPB.fromBuffer(filter.data) + return filter.data.fieldType == FieldType.Checkbox + ? CheckboxFilterPB.fromBuffer(filter.data.data) : null; } SelectOptionFilterPB? selectOptionFilter() { - return filter.fieldType == FieldType.SingleSelect || - filter.fieldType == FieldType.MultiSelect - ? SelectOptionFilterPB.fromBuffer(filter.data) + return filter.data.fieldType == FieldType.SingleSelect || + filter.data.fieldType == FieldType.MultiSelect + ? SelectOptionFilterPB.fromBuffer(filter.data.data) : null; } ChecklistFilterPB? checklistFilter() { - return filter.fieldType == FieldType.Checklist - ? ChecklistFilterPB.fromBuffer(filter.data) + return filter.data.fieldType == FieldType.Checklist + ? ChecklistFilterPB.fromBuffer(filter.data.data) : null; } NumberFilterPB? numberFilter() { - return filter.fieldType == FieldType.Number - ? NumberFilterPB.fromBuffer(filter.data) + return filter.data.fieldType == FieldType.Number + ? NumberFilterPB.fromBuffer(filter.data.data) : null; } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart index caca16a1ac971..80deb98695707 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart @@ -23,14 +23,14 @@ class FilterMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => GridFilterMenuBloc( + return BlocProvider( + create: (context) => DatabaseFilterMenuBloc( viewId: fieldController.viewId, fieldController: fieldController, )..add( - const GridFilterMenuEvent.initial(), + const DatabaseFilterMenuEvent.initial(), ), - child: BlocBuilder( + child: BlocBuilder( builder: (context, state) { final List children = []; children.addAll( @@ -115,7 +115,7 @@ class _AddFilterButtonState extends State { triggerActions: PopoverTriggerFlags.none, child: child, popupBuilder: (BuildContext context) { - final bloc = buildContext.read(); + final bloc = buildContext.read(); return GridCreateFilterList( viewId: widget.viewId, fieldController: bloc.fieldController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart index f661ea57de2e9..3ca86d3969bc6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart @@ -26,7 +26,7 @@ class FilterMenuItem extends StatelessWidget { FieldType.RichText => TextFilterChoicechip(filterInfo: filterInfo), FieldType.SingleSelect => SelectOptionFilterChoicechip(filterInfo: filterInfo), - FieldType.URL => URLFilterChoicechip(filterInfo: filterInfo), + FieldType.URL => URLFilterChoiceChip(filterInfo: filterInfo), FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo), _ => const SizedBox(), }; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_editor.dart index 6ababc0a1d63f..6a49c854d404b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_editor.dart @@ -1,11 +1,13 @@ import 'dart:typed_data'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/util/field_type_extension.dart'; @@ -14,7 +16,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -103,6 +104,8 @@ class _FieldEditorState extends State { VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.duplicate), VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.clearData), + VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.delete), ], ).padding(all: 8.0), @@ -194,6 +197,7 @@ enum FieldAction { insertRight, toggleVisibility, duplicate, + clearData, delete; Widget icon(FieldInfo fieldInfo, Color? color) { @@ -212,6 +216,8 @@ enum FieldAction { } case FieldAction.duplicate: svgData = FlowySvgs.copy_s; + case FieldAction.clearData: + svgData = FlowySvgs.reload_s; case FieldAction.delete: svgData = FlowySvgs.delete_s; } @@ -240,6 +246,8 @@ enum FieldAction { } case FieldAction.duplicate: return LocaleKeys.grid_field_duplicate.tr(); + case FieldAction.clearData: + return LocaleKeys.grid_field_clear.tr(); case FieldAction.delete: return LocaleKeys.grid_field_delete.tr(); } @@ -272,6 +280,22 @@ enum FieldAction { fieldId: fieldInfo.id, ); break; + case FieldAction.clearData: + NavigatorAlertDialog( + constraints: const BoxConstraints( + maxWidth: 250, + maxHeight: 260, + ), + title: LocaleKeys.grid_field_clearFieldPromptMessage.tr(), + confirm: () { + FieldBackendService.clearField( + viewId: viewId, + fieldId: fieldInfo.id, + ); + }, + ).show(context); + PopoverContainer.of(context).close(); + break; case FieldAction.delete: NavigatorAlertDialog( title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), @@ -369,9 +393,6 @@ class _FieldDetailsEditorState extends State { Widget _addDeleteFieldButton() { return BlocBuilder( builder: (context, state) { - if (state.field.isPrimary) { - return const SizedBox.shrink(); - } return Padding( padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), child: FieldActionCell( @@ -388,9 +409,6 @@ class _FieldDetailsEditorState extends State { Widget _addDuplicateFieldButton() { return BlocBuilder( builder: (context, state) { - if (state.field.isPrimary) { - return const SizedBox.shrink(); - } return Padding( padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), child: FieldActionCell( @@ -532,41 +550,52 @@ class _SwitchFieldButtonState extends State { @override Widget build(BuildContext context) { - return SizedBox( - height: GridSize.popoverItemHeight, - child: AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(460, 540)), - triggerActions: PopoverTriggerFlags.hover, - mutex: widget.popoverMutex, - controller: _popoverController, - offset: const Offset(8, 0), - margin: const EdgeInsets.all(8), - popupBuilder: (BuildContext popoverContext) { - return FieldTypeList( - onSelectField: (newFieldType) { - context - .read() - .add(FieldEditorEvent.switchFieldType(newFieldType)); + return BlocBuilder( + builder: (context, state) { + final bool isPrimary = state.field.isPrimary; + return SizedBox( + height: GridSize.popoverItemHeight, + child: AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(460, 540)), + triggerActions: isPrimary ? 0 : PopoverTriggerFlags.hover, + mutex: widget.popoverMutex, + controller: _popoverController, + offset: const Offset(8, 0), + margin: const EdgeInsets.all(8), + popupBuilder: (BuildContext popoverContext) { + return FieldTypeList( + onSelectField: (newFieldType) { + context + .read() + .add(FieldEditorEvent.switchFieldType(newFieldType)); + }, + ); }, - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: _buildMoreButton(context), - ), - ), - ); - } - - Widget _buildMoreButton(BuildContext context) { - final bloc = context.read(); - return FlowyButton( - onTap: () => _popoverController.show(), - text: FlowyText.medium( - bloc.state.field.fieldType.i18n, - ), - leftIcon: FlowySvg(bloc.state.field.fieldType.svgData), - rightIcon: const FlowySvg(FlowySvgs.more_s), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FlowyButton( + onTap: () { + if (!isPrimary) { + _popoverController.show(); + } + }, + text: FlowyText.medium( + state.field.fieldType.i18n, + color: isPrimary ? Theme.of(context).disabledColor : null, + ), + leftIcon: FlowySvg( + state.field.fieldType.svgData, + color: isPrimary ? Theme.of(context).disabledColor : null, + ), + rightIcon: FlowySvg( + FlowySvgs.more_s, + color: isPrimary ? Theme.of(context).disabledColor : null, + ), + ), + ), + ), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart index c57494615c322..4451a52b6fb7c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_list.dart @@ -9,6 +9,20 @@ import '../../layout/sizes.dart'; typedef SelectFieldCallback = void Function(FieldType); +const List _supportedFieldTypes = [ + FieldType.RichText, + FieldType.Number, + FieldType.SingleSelect, + FieldType.MultiSelect, + FieldType.DateTime, + FieldType.Checkbox, + FieldType.Checklist, + FieldType.URL, + FieldType.LastEditedTime, + FieldType.CreatedTime, + FieldType.Relation, +]; + class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { const FieldTypeList({required this.onSelectField, super.key}); @@ -16,7 +30,7 @@ class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { @override Widget build(BuildContext context) { - final cells = FieldType.values.map((fieldType) { + final cells = _supportedFieldTypes.map((fieldType) { return FieldTypeCell( fieldType: fieldType, onSelectField: (fieldType) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart index b9ba4f074966d..4668edc0e92af 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart'; import 'package:appflowy_backend/log.dart'; @@ -139,7 +139,7 @@ class _GridHeaderState extends State<_GridHeader> { } Widget _cellLeading() { - return SizedBox(width: GridSize.leadingHeaderPadding); + return SizedBox(width: GridSize.horizontalHeaderPadding); } } @@ -158,7 +158,6 @@ class _CellTrailing extends StatelessWidget { bottom: BorderSide(color: Theme.of(context).dividerColor), ), ), - padding: GridSize.headerContentInsets, child: CreateFieldButton( viewId: viewId, onFieldCreated: (fieldId) => context diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart index 0b320ef56b57c..d4c2289136227 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart @@ -38,7 +38,8 @@ class MobileFieldButton extends StatelessWidget { width: 200, decoration: _getDecoration(context), child: FlowyButton( - onTap: () => showQuickEditField(context, viewId, fieldInfo), + onTap: () => + showQuickEditField(context, viewId, fieldController, fieldInfo), radius: radius, margin: margin, leftIconSize: const Size.square(18), diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart index 462ba4d9441db..35c4127dfd028 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart @@ -13,17 +13,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; +import '../../mobile_grid_page.dart'; import 'mobile_field_button.dart'; +const double _kGridHeaderHeight = 50.0; + class MobileGridHeader extends StatefulWidget { const MobileGridHeader({ super.key, required this.viewId, - required this.anchorScrollController, + required this.contentScrollController, + required this.reorderableController, }); final String viewId; - final ScrollController anchorScrollController; + final ScrollController contentScrollController; + final ScrollController reorderableController; @override State createState() => _MobileGridHeaderState(); @@ -41,29 +46,45 @@ class _MobileGridHeaderState extends State { fieldController: fieldController, )..add(const GridHeaderEvent.initial()); }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: widget.anchorScrollController, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - HSpace(GridSize.leadingHeaderPadding), - Stack( - children: [ - Positioned(top: 0, left: 24, right: 24, child: _divider()), - Positioned(bottom: 0, left: 0, right: 0, child: _divider()), - SizedBox( - height: 50, - child: _GridHeader( - viewId: widget.viewId, - fieldController: fieldController, - ), + child: Stack( + children: [ + BlocBuilder( + builder: (context, state) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: widget.contentScrollController, + child: Stack( + children: [ + Positioned( + top: 0, + left: GridSize.horizontalHeaderPadding + 24, + right: GridSize.horizontalHeaderPadding + 24, + child: _divider(), + ), + Positioned( + bottom: 0, + left: GridSize.horizontalHeaderPadding, + right: GridSize.horizontalHeaderPadding, + child: _divider(), + ), + SizedBox( + height: _kGridHeaderHeight, + width: getMobileGridContentWidth(state.fields), + ), + ], ), - ], + ); + }, + ), + SizedBox( + height: _kGridHeaderHeight, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + scrollController: widget.reorderableController, ), - const HSpace(20), - ], - ), + ), + ], ), ); } @@ -81,10 +102,12 @@ class _GridHeader extends StatefulWidget { const _GridHeader({ required this.viewId, required this.fieldController, + required this.scrollController, }); final String viewId; final FieldController fieldController; + final ScrollController scrollController; @override State<_GridHeader> createState() => _GridHeaderState(); @@ -114,13 +137,16 @@ class _GridHeaderState extends State<_GridHeader> { .toList(); return ReorderableListView.builder( - scrollController: ScrollController(), + scrollController: widget.scrollController, shrinkWrap: true, scrollDirection: Axis.horizontal, proxyDecorator: (child, index, anim) => Material( color: Colors.transparent, child: child, ), + padding: EdgeInsets.symmetric( + horizontal: GridSize.horizontalHeaderPadding, + ), header: firstField != null ? MobileFieldButton.first( viewId: widget.viewId, @@ -182,7 +208,7 @@ class _CreateFieldButtonState extends State { color: Theme.of(context).hintColor, ), hoverColor: AFThemeExtension.of(context).greyHover, - onTap: () => showCreateFieldBottomSheet(context, widget.viewId), + onTap: () => mobileCreateFieldWorkflow(context, widget.viewId), leftIconSize: const Size.square(18), leftIcon: FlowySvg( FlowySvgs.add_s, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart index 7b3389c834f33..88d81ab5db5e6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/builder.dart @@ -9,6 +9,7 @@ import 'checklist.dart'; import 'date.dart'; import 'multi_select.dart'; import 'number.dart'; +import 'relation.dart'; import 'rich_text.dart'; import 'single_select.dart'; import 'timestamp.dart'; @@ -29,6 +30,7 @@ abstract class TypeOptionEditorFactory { FieldType.MultiSelect => const MultiSelectTypeOptionEditorFactory(), FieldType.Checkbox => const CheckboxTypeOptionEditorFactory(), FieldType.Checklist => const ChecklistTypeOptionEditorFactory(), + FieldType.Relation => const RelationTypeOptionEditorFactory(), _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/relation.dart new file mode 100644 index 0000000000000..9ca2729cb65b3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/relation.dart @@ -0,0 +1,160 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:protobuf/protobuf.dart'; + +import 'builder.dart'; + +class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory { + const RelationTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return BlocProvider( + create: (_) => RelationDatabaseListCubit(), + child: Builder( + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.only(left: 14, right: 8), + height: GridSize.popoverItemHeight, + alignment: Alignment.centerLeft, + child: FlowyText.regular( + LocaleKeys.grid_relation_relatedDatabasePlaceLabel.tr(), + color: Theme.of(context).hintColor, + fontSize: 11, + ), + ), + AppFlowyPopover( + mutex: popoverMutex, + triggerActions: + PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(6, 0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: BlocBuilder( + builder: (context, state) { + final databaseMeta = + state.databaseMetas.firstWhereOrNull( + (meta) => meta.databaseId == typeOption.databaseId, + ); + return FlowyText( + databaseMeta == null + ? LocaleKeys + .grid_relation_relatedDatabasePlaceholder + .tr() + : databaseMeta.databaseName, + color: databaseMeta == null + ? Theme.of(context).hintColor + : null, + overflow: TextOverflow.ellipsis, + ); + }, + ), + rightIcon: const FlowySvg(FlowySvgs.more_s), + ), + ), + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: _DatabaseList( + onSelectDatabase: (newDatabaseId) { + final newTypeOption = _updateTypeOption( + typeOption: typeOption, + databaseId: newDatabaseId, + ); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(context).close(); + }, + currentDatabaseId: typeOption.databaseId, + ), + ); + }, + ), + ], + ); + }, + ), + ); + } + + RelationTypeOptionPB _parseTypeOptionData(List data) { + return RelationTypeOptionDataParser().fromBuffer(data); + } + + RelationTypeOptionPB _updateTypeOption({ + required RelationTypeOptionPB typeOption, + required String databaseId, + }) { + typeOption.freeze(); + return typeOption.rebuild((typeOption) { + typeOption.databaseId = databaseId; + }); + } +} + +class _DatabaseList extends StatelessWidget { + const _DatabaseList({ + required this.onSelectDatabase, + required this.currentDatabaseId, + }); + + final String currentDatabaseId; + final void Function(String databaseId) onSelectDatabase; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final children = state.databaseMetas.map((meta) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + onTap: () => onSelectDatabase(meta.databaseId), + text: FlowyText.medium( + meta.databaseName, + overflow: TextOverflow.ellipsis, + ), + rightIcon: meta.databaseId == currentDatabaseId + ? const FlowySvg( + FlowySvgs.check_s, + ) + : null, + ), + ); + }).toList(); + + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/select/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/select/select_option.dart index 90e32c9225965..619260a631d01 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/select/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/type_option/select/select_option.dart @@ -1,17 +1,17 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/select_option_type_option_bloc.dart'; import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.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/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:easy_localization/easy_localization.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'select_option_editor.dart'; @@ -218,7 +218,7 @@ class _CreateOptionTextFieldState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final text = state.newOptionName.foldRight("", (a, previous) => a); + final text = state.newOptionName ?? ''; return Padding( padding: const EdgeInsets.symmetric(horizontal: 14.0), child: FlowyTextField( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart index f5ce1f9c402f7..92d936010652b 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart @@ -68,7 +68,7 @@ class _MobileGridRowState extends State { builder: (context, state) { return Row( children: [ - SizedBox(width: GridSize.leadingHeaderPadding), + SizedBox(width: GridSize.horizontalHeaderPadding), Expanded( child: RowContent( fieldController: widget.databaseController.fieldController, @@ -163,7 +163,6 @@ class RowContent extends StatelessWidget { Widget _finalCellDecoration(BuildContext context) { return Container( width: 200, - padding: GridSize.headerContentInsets, constraints: const BoxConstraints(minHeight: 46), decoration: BoxDecoration( border: Border( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart index 3869925b7e551..d2c56dbfd842b 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart @@ -115,7 +115,7 @@ class _RowLeadingState extends State<_RowLeading> { child: Consumer( builder: (context, state, _) { return SizedBox( - width: GridSize.leadingHeaderPadding, + width: GridSize.horizontalHeaderPadding, child: state.onEnter ? _activeWidget() : null, ); }, @@ -283,7 +283,6 @@ class RowContent extends StatelessWidget { cursor: SystemMouseCursors.basic, child: Container( width: GridSize.trailHeaderPadding, - padding: GridSize.headerContentInsets, constraints: const BoxConstraints(minHeight: 46), decoration: BoxDecoration( border: Border( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart index 7891247224512..69e46a04ff947 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart @@ -1,8 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/grid/application/sort/sort_create_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -15,97 +14,56 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class GridCreateSortList extends StatefulWidget { - const GridCreateSortList({ +class CreateDatabaseViewSortList extends StatelessWidget { + const CreateDatabaseViewSortList({ super.key, - required this.viewId, - required this.fieldController, - required this.onClosed, - this.onCreateSort, + required this.onTap, }); - final String viewId; - final FieldController fieldController; - final VoidCallback onClosed; - final VoidCallback? onCreateSort; - - @override - State createState() => _GridCreateSortListState(); -} - -class _GridCreateSortListState extends State { - late CreateSortBloc editBloc; - - @override - void initState() { - editBloc = CreateSortBloc( - viewId: widget.viewId, - fieldController: widget.fieldController, - )..add(const CreateSortEvent.initial()); - super.initState(); - } + final VoidCallback onTap; @override Widget build(BuildContext context) { - return BlocProvider.value( - value: editBloc, - child: BlocListener( - listener: (context, state) { - if (state.didCreateSort) { - widget.onClosed(); - } - }, - child: BlocBuilder( - builder: (context, state) { - final cells = state.creatableFields.map((fieldInfo) { - return SizedBox( - height: GridSize.popoverItemHeight, - child: GridSortPropertyCell( - fieldInfo: fieldInfo, - onTap: (fieldInfo) => createSort(fieldInfo), - ), - ); - }).toList(); - - final List slivers = [ - SliverPersistentHeader( - pinned: true, - delegate: _SortTextFieldDelegate(), - ), - SliverToBoxAdapter( - child: ListView.separated( - shrinkWrap: true, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - separatorBuilder: (BuildContext context, int index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - ), - ), - ]; - return CustomScrollView( + return BlocBuilder( + builder: (context, state) { + final filter = state.filter.toLowerCase(); + final cells = state.creatableFields + .where((field) => field.field.name.toLowerCase().contains(filter)) + .map((fieldInfo) { + return GridSortPropertyCell( + fieldInfo: fieldInfo, + onTap: () { + context + .read() + .add(SortEditorEvent.createSort(fieldId: fieldInfo.id)); + onTap.call(); + }, + ); + }).toList(); + + final List slivers = [ + SliverPersistentHeader( + pinned: true, + delegate: _SortTextFieldDelegate(), + ), + SliverToBoxAdapter( + child: ListView.separated( shrinkWrap: true, - slivers: slivers, - physics: StyledScrollPhysics(), - ); - }, - ), - ), + itemCount: cells.length, + itemBuilder: (_, index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + ), + ), + ]; + return CustomScrollView( + shrinkWrap: true, + slivers: slivers, + physics: StyledScrollPhysics(), + ); + }, ); } - - @override - void dispose() { - editBloc.close(); - super.dispose(); - } - - void createSort(FieldInfo field) { - editBloc.add(CreateSortEvent.createDefaultSort(field)); - widget.onCreateSort?.call(); - } } class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate { @@ -127,8 +85,8 @@ class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate { hintText: LocaleKeys.grid_settings_sortBy.tr(), onChanged: (text) { context - .read() - .add(CreateSortEvent.didReceiveFilterText(text)); + .read() + .add(SortEditorEvent.updateCreateSortFilter(text)); }, ), ); @@ -141,9 +99,7 @@ class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate { double get minExtent => fixHeight; @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { - return false; - } + bool shouldRebuild(covariant oldDelegate) => false; } class GridSortPropertyCell extends StatelessWidget { @@ -154,20 +110,23 @@ class GridSortPropertyCell extends StatelessWidget { }); final FieldInfo fieldInfo; - final Function(FieldInfo) onTap; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return FlowyButton( - hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: FlowyText.medium( - fieldInfo.name, - color: AFThemeExtension.of(context).textColor, - ), - onTap: () => onTap(fieldInfo), - leftIcon: FlowySvg( - fieldInfo.fieldType.svgData, - color: Theme.of(context).iconTheme.color, + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText.medium( + fieldInfo.name, + color: AFThemeExtension.of(context).textColor, + ), + onTap: onTap, + leftIcon: FlowySvg( + fieldInfo.fieldType.svgData, + color: Theme.of(context).iconTheme.color, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart index 847c6daca76b9..a00bc1002f2f3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart @@ -27,16 +27,15 @@ class SortChoiceButton extends StatelessWidget { decoration: BoxDecoration( color: Colors.transparent, border: Border.fromBorderSide( - BorderSide( - color: AFThemeExtension.of(context).toggleOffFill, - ), + BorderSide(color: Theme.of(context).dividerColor), ), - borderRadius: const BorderRadius.all(Radius.circular(14)), + borderRadius: BorderRadius.all(radius), ), useIntrinsicWidth: true, text: FlowyText( text, color: AFThemeExtension.of(context).textColor, + overflow: TextOverflow.ellipsis, ), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), radius: BorderRadius.all(radius), diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart index 2bd844a9586a7..671a5c2084d86 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart @@ -2,9 +2,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/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; -import 'package:appflowy/plugins/database/grid/application/sort/util.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -14,7 +12,6 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'dart:math' as math; import 'create_sort_list.dart'; import 'order_panel.dart'; @@ -22,16 +19,7 @@ import 'sort_choice_button.dart'; import 'sort_info.dart'; class SortEditor extends StatefulWidget { - const SortEditor({ - super.key, - required this.viewId, - required this.fieldController, - required this.sortInfos, - }); - - final String viewId; - final FieldController fieldController; - final List sortInfos; + const SortEditor({super.key}); @override State createState() => _SortEditorState(); @@ -42,69 +30,57 @@ class _SortEditorState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SortEditorBloc( - viewId: widget.viewId, - fieldController: widget.fieldController, - sortInfos: widget.sortInfos, - )..add(const SortEditorEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - final sortInfos = state.sortInfos; - - return ReorderableListView.builder( - onReorder: (oldIndex, newIndex) => context - .read() - .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), - itemCount: state.sortInfos.length, - itemBuilder: (context, index) => Padding( - key: ValueKey(sortInfos[index].sortId), - padding: const EdgeInsets.symmetric(vertical: 6), - child: DatabaseSortItem( - index: index, - sortInfo: sortInfos[index], - popoverMutex: popoverMutex, - ), - ), - proxyDecorator: (child, index, animation) => Material( - color: Colors.transparent, - child: Stack( - children: [ - BlocProvider.value( - value: context.read(), - child: child, - ), - MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grabbing, - child: const SizedBox.expand(), - ), - ], - ), - ), - shrinkWrap: true, - buildDefaultDragHandles: false, - footer: Row( + return BlocBuilder( + builder: (context, state) { + final sortInfos = state.sortInfos; + return ReorderableListView.builder( + onReorder: (oldIndex, newIndex) => context + .read() + .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), + itemCount: state.sortInfos.length, + itemBuilder: (context, index) => DatabaseSortItem( + key: ValueKey(sortInfos[index].sortId), + index: index, + sortInfo: sortInfos[index], + popoverMutex: popoverMutex, + ), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( children: [ - Flexible( - child: DatabaseAddSortButton( - viewId: widget.viewId, - fieldController: widget.fieldController, - popoverMutex: popoverMutex, - ), + BlocProvider.value( + value: context.read(), + child: child, ), - const HSpace(6), - Flexible( - child: DatabaseDeleteSortButton( - popoverMutex: popoverMutex, - ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), ), ], ), - ); - }, - ), + ), + shrinkWrap: true, + buildDefaultDragHandles: false, + footer: Row( + children: [ + Flexible( + child: DatabaseAddSortButton( + disable: state.creatableFields.isEmpty, + popoverMutex: popoverMutex, + ), + ), + const HSpace(6), + Flexible( + child: DeleteAllSortsButton( + popoverMutex: popoverMutex, + ), + ), + ], + ), + ); + }, ); } } @@ -123,80 +99,89 @@ class DatabaseSortItem extends StatelessWidget { @override Widget build(BuildContext context) { - final deleteButton = FlowyIconButton( - width: 26, - onPressed: () => context - .read() - .add(SortEditorEvent.deleteSort(sortInfo)), - iconPadding: const EdgeInsets.all(5), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - icon: - FlowySvg(FlowySvgs.close_s, color: Theme.of(context).iconTheme.color), - ); - - return Row( - children: [ - ReorderableDragStartListener( - index: index, - child: MouseRegion( - cursor: Platform.isWindows - ? SystemMouseCursors.click - : SystemMouseCursors.grab, + return Container( + padding: const EdgeInsets.symmetric(vertical: 6), + color: Theme.of(context).cardColor, + child: Row( + children: [ + ReorderableDragStartListener( + index: index, + child: MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: SizedBox( + width: 14 + 12, + height: 14, + child: FlowySvg( + FlowySvgs.drag_element_s, + size: const Size.square(14), + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ), + Flexible( + fit: FlexFit.tight, child: SizedBox( - width: 14, - height: 14, - child: FlowySvg( - FlowySvgs.drag_element_s, - color: Theme.of(context).iconTheme.color, + height: 26, + child: SortChoiceButton( + text: sortInfo.fieldInfo.name, + editable: false, ), ), ), - ), - const HSpace(6), - SizedBox( - height: 26, - child: SortChoiceButton( - text: sortInfo.fieldInfo.name, - editable: false, + const HSpace(6), + Flexible( + fit: FlexFit.tight, + child: SizedBox( + height: 26, + child: SortConditionButton( + sortInfo: sortInfo, + popoverMutex: popoverMutex, + ), + ), ), - ), - const HSpace(6), - SizedBox( - height: 26, - child: DatabaseSortItemOrderButton( - sortInfo: sortInfo, - popoverMutex: popoverMutex, + const HSpace(6), + FlowyIconButton( + width: 26, + onPressed: () { + context + .read() + .add(SortEditorEvent.deleteSort(sortInfo)); + PopoverContainer.of(context).close(); + }, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: FlowySvg( + FlowySvgs.trash_m, + color: Theme.of(context).iconTheme.color, + size: const Size.square(16), + ), ), - ), - const Spacer(), - const HSpace(6), - deleteButton, - ], + ], + ), ); } } extension SortConditionExtension on SortConditionPB { String get title { - switch (this) { - case SortConditionPB.Descending: - return LocaleKeys.grid_sort_descending.tr(); - default: - return LocaleKeys.grid_sort_ascending.tr(); - } + return switch (this) { + SortConditionPB.Ascending => LocaleKeys.grid_sort_ascending.tr(), + SortConditionPB.Descending => LocaleKeys.grid_sort_descending.tr(), + _ => throw UnimplementedError(), + }; } } class DatabaseAddSortButton extends StatefulWidget { const DatabaseAddSortButton({ super.key, - required this.viewId, - required this.fieldController, + required this.disable, required this.popoverMutex, }); - final String viewId; - final FieldController fieldController; + final bool disable; final PopoverMutex popoverMutex; @override @@ -213,32 +198,36 @@ class _DatabaseAddSortButtonState extends State { mutex: widget.popoverMutex, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(200, 300)), - offset: const Offset(0, 8), + offset: const Offset(-6, 8), triggerActions: PopoverTriggerFlags.none, asBarrier: true, + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: CreateDatabaseViewSortList( + onTap: () => _popoverController.close(), + ), + ); + }, + onClose: () => context + .read() + .add(const SortEditorEvent.updateCreateSortFilter("")), child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).greyHover, - disable: getCreatableSorts(widget.fieldController.fieldInfos).isEmpty, + disable: widget.disable, text: FlowyText.medium(LocaleKeys.grid_sort_addSort.tr()), onTap: () => _popoverController.show(), leftIcon: const FlowySvg(FlowySvgs.add_s), ), ), - popupBuilder: (BuildContext context) { - return GridCreateSortList( - viewId: widget.viewId, - fieldController: widget.fieldController, - onClosed: () => _popoverController.close(), - ); - }, ); } } -class DatabaseDeleteSortButton extends StatelessWidget { - const DatabaseDeleteSortButton({super.key, required this.popoverMutex}); +class DeleteAllSortsButton extends StatelessWidget { + const DeleteAllSortsButton({super.key, required this.popoverMutex}); final PopoverMutex popoverMutex; @@ -264,8 +253,8 @@ class DatabaseDeleteSortButton extends StatelessWidget { } } -class DatabaseSortItemOrderButton extends StatefulWidget { - const DatabaseSortItemOrderButton({ +class SortConditionButton extends StatefulWidget { + const SortConditionButton({ super.key, required this.popoverMutex, required this.sortInfo, @@ -275,21 +264,14 @@ class DatabaseSortItemOrderButton extends StatefulWidget { final SortInfo sortInfo; @override - State createState() => - _DatabaseSortItemOrderButtonState(); + State createState() => _SortConditionButtonState(); } -class _DatabaseSortItemOrderButtonState - extends State { +class _SortConditionButtonState extends State { final PopoverController popoverController = PopoverController(); @override Widget build(BuildContext context) { - final arrow = Transform.rotate( - angle: -math.pi / 2, - child: const FlowySvg(FlowySvgs.arrow_left_s), - ); - return AppFlowyPopover( controller: popoverController, mutex: widget.popoverMutex, @@ -301,9 +283,8 @@ class _DatabaseSortItemOrderButtonState onCondition: (condition) { context.read().add( SortEditorEvent.editSort( - widget.sortInfo.sortId, - null, - condition, + sortId: widget.sortInfo.sortId, + condition: condition, ), ); popoverController.close(); @@ -312,7 +293,10 @@ class _DatabaseSortItemOrderButtonState }, child: SortChoiceButton( text: widget.sortInfo.sortPB.condition.title, - rightIcon: arrow, + rightIcon: FlowySvg( + FlowySvgs.arrow_down_s, + color: Theme.of(context).iconTheme.color, + ), onTap: () => popoverController.show(), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart index 30fe44f10192f..43f583bd0722e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart @@ -1,6 +1,8 @@ +import 'dart:math' as math; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; -import 'package:appflowy/plugins/database/grid/application/sort/sort_menu_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -8,8 +10,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'dart:math' as math; - import 'sort_choice_button.dart'; import 'sort_editor.dart'; import 'sort_info.dart'; @@ -24,12 +24,12 @@ class SortMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SortMenuBloc( + return BlocProvider( + create: (context) => SortEditorBloc( viewId: fieldController.viewId, fieldController: fieldController, - )..add(const SortMenuEvent.initial()), - child: BlocBuilder( + ), + child: BlocBuilder( builder: (context, state) { if (state.sortInfos.isEmpty) { return const SizedBox.shrink(); @@ -42,10 +42,9 @@ class SortMenu extends StatelessWidget { offset: const Offset(0, 5), margin: const EdgeInsets.fromLTRB(6.0, 0.0, 6.0, 6.0), popupBuilder: (BuildContext popoverContext) { - return SortEditor( - viewId: state.viewId, - fieldController: context.read().fieldController, - sortInfos: state.sortInfos, + return BlocProvider.value( + value: context.read(), + child: const SortEditor(), ); }, child: SortChoiceChip(sortInfos: state.sortInfos), diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart index 1ecb82e66dd39..21c61713e48b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart @@ -23,7 +23,7 @@ class _FilterButtonState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { final textColor = state.filters.isEmpty ? AFThemeExtension.of(context).textColor @@ -41,11 +41,11 @@ class _FilterButtonState extends State { padding: GridSize.toolbarSettingButtonInsets, radius: Corners.s4Border, onPressed: () { - final bloc = context.read(); + final bloc = context.read(); if (bloc.state.filters.isEmpty) { _popoverController.show(); } else { - bloc.add(const GridFilterMenuEvent.toggleMenu()); + bloc.add(const DatabaseFilterMenuEvent.toggleMenu()); } }, ), @@ -63,14 +63,14 @@ class _FilterButtonState extends State { triggerActions: PopoverTriggerFlags.none, child: child, popupBuilder: (BuildContext context) { - final bloc = buildContext.read(); + final bloc = buildContext.read(); return GridCreateFilterList( viewId: bloc.viewId, fieldController: bloc.fieldController, onClosed: () => _popoverController.close(), onCreateFilter: () { if (!bloc.state.isVisible) { - bloc.add(const GridFilterMenuEvent.toggleMenu()); + bloc.add(const DatabaseFilterMenuEvent.toggleMenu()); } }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart index 347ada888393b..312bfd7511647 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart @@ -1,6 +1,6 @@ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; -import 'package:appflowy/plugins/database/grid/application/sort/sort_menu_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -24,30 +24,22 @@ class GridSettingBar extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider( - create: (context) => GridFilterMenuBloc( + BlocProvider( + create: (context) => DatabaseFilterMenuBloc( viewId: controller.viewId, fieldController: controller.fieldController, - )..add(const GridFilterMenuEvent.initial()), + )..add(const DatabaseFilterMenuEvent.initial()), ), - BlocProvider( - create: (context) => SortMenuBloc( + BlocProvider( + create: (context) => SortEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, - )..add(const SortMenuEvent.initial()), + ), ), ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - ), - BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - ), - ], + child: BlocListener( + listenWhen: (p, c) => p.isVisible != c.isVisible, + listener: (context, state) => toggleExtension.toggle(), child: ValueListenableBuilder( valueListenable: controller.isLoading, builder: (context, value, child) { @@ -61,7 +53,7 @@ class GridSettingBar extends StatelessWidget { children: [ const FilterButton(), const HSpace(2), - const SortButton(), + SortButton(toggleExtension: toggleExtension), const HSpace(2), SettingButton( databaseController: controller, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart index 585be2178750d..be4740cea01f5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart @@ -1,5 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/grid/application/sort/sort_menu_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; @@ -12,7 +13,9 @@ import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import '../sort/create_sort_list.dart'; class SortButton extends StatefulWidget { - const SortButton({super.key}); + const SortButton({super.key, required this.toggleExtension}); + + final ToggleExtensionNotifier toggleExtension; @override State createState() => _SortButtonState(); @@ -23,7 +26,7 @@ class _SortButtonState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { final textColor = state.sortInfos.isEmpty ? AFThemeExtension.of(context).textColor @@ -41,11 +44,10 @@ class _SortButtonState extends State { padding: GridSize.toolbarSettingButtonInsets, radius: Corners.s4Border, onPressed: () { - final bloc = context.read(); - if (bloc.state.sortInfos.isEmpty) { + if (state.sortInfos.isEmpty) { _popoverController.show(); } else { - bloc.add(const SortMenuEvent.toggleMenu()); + widget.toggleExtension.toggle(); } }, ), @@ -54,27 +56,30 @@ class _SortButtonState extends State { ); } - Widget wrapPopover(BuildContext buildContext, Widget child) { + Widget wrapPopover(BuildContext context, Widget child) { return AppFlowyPopover( controller: _popoverController, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(200, 300)), offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, - child: child, - popupBuilder: (BuildContext context) { - final bloc = buildContext.read(); - return GridCreateSortList( - viewId: bloc.viewId, - fieldController: bloc.fieldController, - onClosed: () => _popoverController.close(), - onCreateSort: () { - if (!bloc.state.isVisible) { - bloc.add(const SortMenuEvent.toggleMenu()); - } - }, + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: CreateDatabaseViewSortList( + onTap: () { + if (!widget.toggleExtension.isToggled) { + widget.toggleExtension.toggle(); + } + _popoverController.close(); + }, + ), ); }, + onClose: () => context + .read() + .add(const SortEditorEvent.updateCreateSortFilter("")), + child: child, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart index 5ddc111bcfecd..5b66c3a1496d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart @@ -57,7 +57,7 @@ class _DatabaseViewSettingContent extends StatelessWidget { builder: (context, state) { return Padding( padding: EdgeInsets.symmetric( - horizontal: GridSize.leadingHeaderPadding, + horizontal: GridSize.horizontalHeaderPadding, ), child: DecoratedBox( decoration: BoxDecoration( diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart index 08f77525d8e3f..7fec980112378 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart @@ -1,8 +1,7 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; @@ -12,6 +11,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'tab_bar_add_button.dart'; @@ -21,8 +21,11 @@ class TabBarHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( + return Container( height: 30, + padding: EdgeInsets.symmetric( + horizontal: GridSize.horizontalHeaderPadding, + ), child: Stack( children: [ Positioned( @@ -231,7 +234,7 @@ class TabBarItemButton extends StatelessWidget { NavigatorTextFieldDialog( title: LocaleKeys.menuAppHeader_renameDialog.tr(), value: view.name, - confirm: (newValue) { + onConfirm: (newValue, _) { context.read().add( DatabaseTabBarEvent.renameView(view.id, newValue), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart index 114352f6a9418..55394ec33c887 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart @@ -1,16 +1,17 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/view/database_view_list.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/setting/mobile_database_controls.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../grid/presentation/grid_page.dart'; @@ -26,11 +27,14 @@ class _MobileTabBarHeaderState extends State { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 14), + padding: EdgeInsets.only( + left: GridSize.horizontalHeaderPadding, + top: 14.0, + right: GridSize.horizontalHeaderPadding - 5.0, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - HSpace(GridSize.leadingHeaderPadding), const _DatabaseViewSelectorButton(), const Spacer(), BlocBuilder( @@ -83,7 +87,11 @@ class _DatabaseViewSelectorButton extends StatelessWidget { borderRadius: BorderRadius.all(Radius.circular(12)), ), ), - backgroundColor: const MaterialStatePropertyAll(Color(0x0F212729)), + backgroundColor: MaterialStatePropertyAll( + Theme.of(context).brightness == Brightness.light + ? const Color(0x0F212729) + : const Color(0x0FFFFFFF), + ), overlayColor: MaterialStatePropertyAll( Theme.of(context).colorScheme.secondary, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart index a047f64de36bc..57a747a631320 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/share_button.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.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-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -108,19 +111,9 @@ class _DatabaseTabBarViewState extends State { return const SizedBox.shrink(); } - if (PlatformExtension.isDesktop) { - return Padding( - padding: EdgeInsets.symmetric( - horizontal: GridSize.leadingHeaderPadding, - ), - child: const TabBarHeader(), - ); - } else { - return const Padding( - padding: EdgeInsets.only(right: 8), - child: MobileTabBarHeader(), - ); - } + return PlatformExtension.isDesktop + ? const TabBarHeader() + : const MobileTabBarHeader(); }, ); }, @@ -184,7 +177,9 @@ class DatabaseTabBarViewPlugin extends Plugin { @override final ViewPluginNotifier notifier; + final PluginType _pluginType; + late final ViewInfoBloc _viewInfoBloc; /// Used to open a Row on plugin load /// @@ -192,6 +187,7 @@ class DatabaseTabBarViewPlugin extends Plugin { @override PluginWidgetBuilder get widgetBuilder => DatabasePluginWidgetBuilder( + bloc: _viewInfoBloc, notifier: notifier, initialRowId: initialRowId, ); @@ -201,11 +197,28 @@ class DatabaseTabBarViewPlugin extends Plugin { @override PluginType get pluginType => _pluginType; + + @override + void init() { + _viewInfoBloc = ViewInfoBloc(view: notifier.view) + ..add(const ViewInfoEvent.started()); + } + + @override + void dispose() { + _viewInfoBloc.close(); + notifier.dispose(); + } } class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { - DatabasePluginWidgetBuilder({required this.notifier, this.initialRowId}); + DatabasePluginWidgetBuilder({ + required this.bloc, + required this.notifier, + this.initialRowId, + }); + final ViewInfoBloc bloc; final ViewPluginNotifier notifier; /// Used to open a Row on plugin load @@ -221,12 +234,12 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { @override Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { notifier.isDeleted.addListener(() { - notifier.isDeleted.value.fold(() => null, (deletedView) { - if (deletedView.hasIndex()) { - context?.onDeleted(notifier.view, deletedView.index); - } - }); + final deletedView = notifier.isDeleted.value; + if (deletedView != null && deletedView.hasIndex()) { + context?.onDeleted(notifier.view, deletedView.index); + } }); + return DatabaseTabBarView( key: ValueKey(notifier.view.id), view: notifier.view, @@ -240,9 +253,18 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { @override Widget? get rightBarItem { - return DatabaseShareButton( - key: ValueKey(notifier.view.id), - view: notifier.view, + final view = notifier.view; + return BlocProvider.value( + value: bloc, + child: Row( + children: [ + DatabaseShareButton(key: ValueKey(view.id), view: view), + const HSpace(4), + ViewFavoriteButton(view: view), + const HSpace(4), + MoreViewActions(view: view, isDocument: false), + ], + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart index ba2e8f0e00fcc..1db75c0a7301d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; -import 'package:appflowy/plugins/database/application/row/row_listener.dart'; +import 'package:appflowy/plugins/database/domain/row_listener.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart index 9e4c4b3fd5517..ae525672089f6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart @@ -1,5 +1,6 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flutter/widgets.dart'; @@ -84,6 +85,12 @@ class CardCellBuilder { databaseController: databaseController, cellContext: cellContext, ), + FieldType.Relation => RelationCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), _ => throw UnimplementedError, }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart index 63cd450cffe40..a45b621dded76 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart @@ -42,7 +42,7 @@ class _ChecklistCellState extends State { widget.databaseController, widget.cellContext, ).as(), - )..add(const ChecklistCellEvent.initial()); + ); }, child: BlocBuilder( builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart new file mode 100644 index 0000000000000..023048355ed8c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class RelationCardCellStyle extends CardCellStyle { + RelationCardCellStyle({ + required super.padding, + required this.textStyle, + required this.wrap, + }); + + final TextStyle textStyle; + final bool wrap; +} + +class RelationCardCell extends CardCell { + const RelationCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _RelationCellState(); +} + +class _RelationCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return RelationCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + builder: (context, state) { + if (state.rows.isEmpty) { + return const SizedBox.shrink(); + } + + final children = state.rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return Text( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + style: widget.style.textStyle.copyWith( + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + ), + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(); + + return Container( + alignment: AlignmentDirectional.topStart, + padding: widget.style.padding, + child: widget.style.wrap + ? Wrap(spacing: 4, runSpacing: 4, children: children) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart index 85101e919742c..2ea0f5ac08b6b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart @@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; +import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; @@ -73,5 +74,10 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { decoration: TextDecoration.underline, ), ), + FieldType.Relation: RelationCardCellStyle( + padding: padding, + wrap: true, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart index a7cef2fe6b83b..1b229f76f0b04 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart @@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; +import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; @@ -73,5 +74,10 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { decoration: TextDecoration.underline, ), ), + FieldType.Relation: RelationCardCellStyle( + padding: padding, + wrap: true, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart index d4c9bc57fcde3..7c5d3be1bee6f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart @@ -6,6 +6,7 @@ import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; +import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; @@ -72,5 +73,10 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { decoration: TextDecoration.underline, ), ), + FieldType.Relation: RelationCardCellStyle( + padding: padding, + textStyle: textStyle, + wrap: true, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart index 0db7329ffc8e4..285b0217eb791 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart @@ -1,11 +1,12 @@ -import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.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'; import '../editable_cell_skeleton/checklist.dart'; @@ -25,6 +26,7 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { constraints: BoxConstraints.loose(const Size(360, 400)), direction: PopoverDirection.bottomWithLeftAligned, triggerActions: PopoverTriggerFlags.none, + skipTraversal: true, popupBuilder: (BuildContext popoverContext) { WidgetsBinding.instance.addPostFrameCallback((_) { cellContainerNotifier.isFocus = true; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart new file mode 100644 index 0000000000000..a2e6c9fa8bf9e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/relation.dart'; + +class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), + margin: EdgeInsets.zero, + onClose: () => cellContainerNotifier.isFocus = false, + popupBuilder: (context) { + return BlocProvider.value( + value: bloc, + child: RelationCellEditor( + selectedRowIds: state.rows.map((row) => row.rowId).toList(), + ), + ); + }, + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: GridSize.cellContentInsets, + child: Wrap( + runSpacing: 4.0, + spacing: 4.0, + children: state.rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return FlowyText.medium( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart index 2ecd0359994e0..04861c71f8ed1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart @@ -1,16 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../editable_cell_skeleton/url.dart'; @@ -115,7 +115,7 @@ class _CopyURLAccessoryState extends State<_CopyURLAccessory> Widget build(BuildContext context) { if (widget.cellDataNotifier.value.isNotEmpty) { return FlowyTooltip( - message: LocaleKeys.tooltip_urlCopyAccessory.tr(), + message: LocaleKeys.grid_url_copy.tr(), preferBelow: false, child: _URLAccessoryIconContainer( child: FlowySvg( @@ -161,7 +161,7 @@ class _VisitURLAccessoryState extends State<_VisitURLAccessory> Widget build(BuildContext context) { if (widget.cellDataNotifier.value.isNotEmpty) { return FlowyTooltip( - message: LocaleKeys.tooltip_urlLaunchAccessory.tr(), + message: LocaleKeys.grid_url_launch.tr(), preferBelow: false, child: _URLAccessoryIconContainer( child: FlowySvg( @@ -176,21 +176,11 @@ class _VisitURLAccessoryState extends State<_VisitURLAccessory> } @override - bool enable() { - return widget.cellDataNotifier.value.isNotEmpty; - } + bool enable() => widget.cellDataNotifier.value.isNotEmpty; @override - void onTap() { - final content = widget.cellDataNotifier.value; - if (content.isEmpty) { - return; - } - final shouldAddScheme = - !['http', 'https'].any((pattern) => content.startsWith(pattern)); - final url = shouldAddScheme ? 'http://$content' : content; - canLaunchUrlString(url).then((value) => launchUrlString(url)); - } + void onTap() => + openUrlCellLink(widget.cellDataNotifier.value); } class _URLAccessoryIconContainer extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart index 3c4576e4c38c1..4a1f1f05bca0f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart @@ -64,14 +64,17 @@ class _ChecklistItemsState extends State { } final children = tasks .mapIndexed( - (index, task) => ChecklistItem( - task: task, - autofocus: widget.state.newTask && index == tasks.length - 1, - onSubmitted: () { - if (index == tasks.length - 1) { - widget.bloc.add(const ChecklistCellEvent.createNewTask("")); - } - }, + (index, task) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ChecklistItem( + key: ValueKey(task.data.id), + task: task, + autofocus: widget.state.newTask && index == tasks.length - 1, + onSubmitted: index == tasks.length - 1 + ? () => widget.bloc + .add(const ChecklistCellEvent.createNewTask("")) + : null, + ), ), ) .toList(); @@ -111,7 +114,7 @@ class _ChecklistItemsState extends State { ], ), ), - const VSpace(4), + const VSpace(2.0), ...children, ChecklistItemControl(cellNotifer: widget.cellContainerNotifier), ], @@ -136,7 +139,7 @@ class ChecklistItemControl extends StatelessWidget { .read() .add(const ChecklistCellEvent.createNewTask("")), child: Container( - margin: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), + margin: const EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 0), height: 12, child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart new file mode 100644 index 0000000000000..63b70c0f787b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart @@ -0,0 +1,71 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/relation.dart'; + +class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), + margin: EdgeInsets.zero, + onClose: () => cellContainerNotifier.isFocus = false, + popupBuilder: (context) { + return BlocProvider.value( + value: bloc, + child: RelationCellEditor( + selectedRowIds: state.rows.map((row) => row.rowId).toList(), + ), + ); + }, + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: state.rows.isEmpty + ? _buildPlaceholder(context) + : _buildRows(context, state.rows), + ), + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return FlowyText( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + ); + } + + Widget _buildRows(BuildContext context, List rows) { + return Wrap( + runSpacing: 4.0, + spacing: 4.0, + children: rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return FlowyText.medium( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart index 8348232f4ade6..c70dedd68e129 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -13,6 +13,7 @@ import 'editable_cell_skeleton/checkbox.dart'; import 'editable_cell_skeleton/checklist.dart'; import 'editable_cell_skeleton/date.dart'; import 'editable_cell_skeleton/number.dart'; +import 'editable_cell_skeleton/relation.dart'; import 'editable_cell_skeleton/select_option.dart'; import 'editable_cell_skeleton/text.dart'; import 'editable_cell_skeleton/timestamp.dart'; @@ -106,6 +107,12 @@ class EditableCellBuilder { skin: IEditableURLCellSkin.fromStyle(style), key: key, ), + FieldType.Relation => EditableRelationCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableRelationCellSkin.fromStyle(style), + key: key, + ), _ => throw UnimplementedError(), }; } @@ -186,6 +193,12 @@ class EditableCellBuilder { skin: skinMap.urlSkin!, key: key, ), + FieldType.Relation => EditableRelationCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.relationSkin!, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -340,6 +353,7 @@ class EditableCellSkinMap { this.numberSkin, this.textSkin, this.urlSkin, + this.relationSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -350,6 +364,7 @@ class EditableCellSkinMap { final IEditableNumberCellSkin? numberSkin; final IEditableTextCellSkin? textSkin; final IEditableURLCellSkin? urlSkin; + final IEditableRelationCellSkin? relationSkin; bool has(FieldType fieldType) { return switch (fieldType) { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart index 2f3407aea6714..dd7bc6c2c10ec 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart @@ -58,7 +58,7 @@ class GridChecklistCellState extends GridCellState { widget.databaseController, widget.cellContext, ).as(), - )..add(const ChecklistCellEvent.initial()); + ); @override void dispose() { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart new file mode 100644 index 0000000000000..4e39900abf9f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart @@ -0,0 +1,94 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_relation_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_relation_cell.dart'; +import '../mobile_grid/mobile_grid_relation_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_relation_cell.dart'; + +abstract class IEditableRelationCellSkin { + factory IEditableRelationCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridRelationCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailRelationCellSkin(), + EditableCellStyle.mobileGrid => MobileGridRelationCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailRelationCellSkin(), + }; + } + + const IEditableRelationCellSkin(); + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ); +} + +class EditableRelationCell extends EditableCellWidget { + EditableRelationCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableRelationCellSkin skin; + + @override + GridCellState createState() => _RelationCellState(); +} + +class _RelationCellState extends GridCellState { + final PopoverController _popover = PopoverController(); + late final cellBloc = RelationCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocBuilder( + builder: (context, state) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + state, + _popover, + ); + }, + ), + ); + } + + @override + void onRequestFocus() { + _popover.show(); + widget.cellContainerNotifier.isFocus = true; + } + + @override + String? onCopy() => ""; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart index 7deb6c85e4828..3628f6c511bfb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart @@ -1,5 +1,10 @@ import 'dart:async'; +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/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; @@ -7,8 +12,13 @@ import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.d import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:go_router/go_router.dart'; import '../desktop_grid/desktop_grid_url_cell.dart'; import '../desktop_row_detail/desktop_row_detail_url_cell.dart'; @@ -102,7 +112,8 @@ class _GridURLCellState extends GridEditableTextCell { child: BlocListener( listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { - _textEditingController.text = state.content; + _textEditingController.value = + _textEditingController.value.copyWith(text: state.content); widget._cellDataNotifier.value = state.content; }, child: widget.skin.build( @@ -121,8 +132,8 @@ class _GridURLCellState extends GridEditableTextCell { Future focusChanged() async { if (mounted && !cellBloc.isClosed && - cellBloc.state.content != _textEditingController.text.trim()) { - cellBloc.add(URLCellEvent.updateURL(_textEditingController.text.trim())); + cellBloc.state.content != _textEditingController.text) { + cellBloc.add(URLCellEvent.updateURL(_textEditingController.text)); } return super.focusChanged(); } @@ -130,3 +141,102 @@ class _GridURLCellState extends GridEditableTextCell { @override String? onCopy() => cellBloc.state.content; } + +class MobileURLEditor extends StatelessWidget { + const MobileURLEditor({ + super.key, + required this.textEditingController, + }); + + final TextEditingController textEditingController; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const VSpace(4.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyTextField( + controller: textEditingController, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + hintText: LocaleKeys.grid_url_textFieldHint.tr(), + textStyle: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.url, + hintTextConstraints: const BoxConstraints(maxHeight: 52), + error: context.watch().state.isValid + ? null + : const SizedBox.shrink(), + onChanged: (_) { + if (textEditingController.value.composing.isCollapsed) { + context + .read() + .add(URLCellEvent.updateURL(textEditingController.text)); + } + }, + onSubmitted: (text) => + context.read().add(URLCellEvent.updateURL(text)), + ), + ), + const VSpace(8.0), + MobileQuickActionButton( + enable: context.watch().state.content.isNotEmpty, + onTap: () { + openUrlCellLink(textEditingController.text); + context.pop(); + }, + icon: FlowySvgs.url_s, + text: LocaleKeys.grid_url_launch.tr(), + ), + const Divider(height: 8.5, thickness: 0.5), + MobileQuickActionButton( + enable: context.watch().state.content.isNotEmpty, + onTap: () { + Clipboard.setData( + ClipboardData(text: textEditingController.text), + ); + Fluttertoast.showToast( + msg: LocaleKeys.grid_url_copiedNotification.tr(), + gravity: ToastGravity.BOTTOM, + ); + context.pop(); + }, + icon: FlowySvgs.copy_s, + text: LocaleKeys.grid_url_copy.tr(), + ), + const Divider(height: 8.5, thickness: 0.5), + ], + ); + } +} + +void openUrlCellLink(String content) async { + String url = ""; + + try { + // check protocol is provided + const linkPrefix = [ + 'http://', + 'https://', + 'file://', + 'ftp://', + 'ftps://', + 'mailto:', + ]; + final shouldAddScheme = + !linkPrefix.any((pattern) => content.startsWith(pattern)); + url = shouldAddScheme ? 'http://$content' : content; + + // get hostname and check validity + final uri = Uri.parse(url); + final hostName = uri.host; + await InternetAddress.lookup(hostName); + } catch (_) { + url = "https://www.google.com/search?q=${Uri.encodeComponent(content)}"; + } finally { + await afLaunchUrlString(url); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart index 49fbf2639b3a7..aa55dd9e361dc 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart @@ -39,7 +39,6 @@ class MobileGridChecklistCellSkin extends IEditableChecklistCellSkin { ), onTap: () => showMobileBottomSheet( context, - backgroundColor: Theme.of(context).colorScheme.background, builder: (context) { return BlocProvider.value( value: bloc, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart index fba6937a58be9..7984322328a21 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart @@ -44,7 +44,6 @@ class MobileGridDateCellSkin extends IEditableDateCellSkin { onTap: () { showMobileBottomSheet( context, - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, builder: (context) { return MobileDateCellEditScreen( controller: bloc.cellController, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart new file mode 100644 index 0000000000000..0e411440efc92 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/relation.dart'; + +class MobileGridRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return FlowyButton( + radius: BorderRadius.zero, + hoverColor: Colors.transparent, + margin: EdgeInsets.zero, + text: Align( + alignment: AlignmentDirectional.centerStart, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: state.rows + .map( + (row) => FlowyText( + row.name, + fontSize: 15, + decoration: TextDecoration.underline, + ), + ) + .toList(), + ), + ), + ), + onTap: () { + showMobileBottomSheet( + context, + padding: EdgeInsets.zero, + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + builder: (context) { + return const FlowyText("Coming soon"); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart index 267ef65a3774b..4dffae30221c5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart @@ -1,13 +1,9 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../editable_cell_skeleton/url.dart'; @@ -24,53 +20,9 @@ class MobileGridURLCellSkin extends IEditableURLCellSkin { return BlocSelector( selector: (state) => state.content, builder: (context, content) { - if (content.isEmpty) { - return TextField( - focusNode: focusNode, - keyboardType: TextInputType.url, - decoration: const InputDecoration( - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - isCollapsed: true, - ), - onTapOutside: (event) => - FocusManager.instance.primaryFocus?.unfocus(), - onSubmitted: (value) => bloc.add(URLCellEvent.updateURL(value)), - ); - } - return GestureDetector( - onTap: () { - if (content.isEmpty) { - return; - } - final shouldAddScheme = !['http', 'https'] - .any((pattern) => content.startsWith(pattern)); - final url = shouldAddScheme ? 'http://$content' : content; - canLaunchUrlString(url).then((value) => launchUrlString(url)); - }, - onLongPress: () => showMobileBottomSheet( - context, - title: LocaleKeys.board_mobile_editURL.tr(), - showHeader: true, - showCloseButton: true, - builder: (_) { - final controller = TextEditingController(text: content); - return TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.url, - onEditingComplete: () { - bloc.add(URLCellEvent.updateURL(controller.text)); - context.pop(); - }, - ); - }, - ), + onTap: () => _showURLEditor(context, bloc, textEditingController), + behavior: HitTestBehavior.opaque, child: Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), @@ -91,6 +43,24 @@ class MobileGridURLCellSkin extends IEditableURLCellSkin { ); } + void _showURLEditor( + BuildContext context, + URLCellBloc bloc, + TextEditingController textEditingController, + ) { + showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.background, + builder: (context) => BlocProvider.value( + value: bloc, + child: MobileURLEditor( + textEditingController: textEditingController, + ), + ), + ); + } + @override List>> accessoryBuilder( GridCellAccessoryBuildContext context, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart new file mode 100644 index 0000000000000..eebb3e1c75f43 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart @@ -0,0 +1,56 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14)), + onTap: () => showMobileBottomSheet( + context, + padding: EdgeInsets.zero, + builder: (context) { + return const FlowyText("Coming soon"); + }, + ), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + child: Wrap( + runSpacing: 4.0, + spacing: 4.0, + children: state.rows + .map( + (row) => FlowyText( + row.name, + fontSize: 16, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart index e48a82c054449..f97eabe8306bd 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart @@ -1,13 +1,12 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../editable_cell_skeleton/url.dart'; @@ -26,17 +25,19 @@ class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { builder: (context, content) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(14)), - onTap: () { - if (content.isEmpty) { - _showURLEditor(context, bloc, content); - return; - } - final shouldAddScheme = !['http', 'https'] - .any((pattern) => content.startsWith(pattern)); - final url = shouldAddScheme ? 'http://$content' : content; - canLaunchUrlString(url).then((value) => launchUrlString(url)); - }, - onLongPress: () => _showURLEditor(context, bloc, content), + onTap: () => showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.background, + builder: (_) { + return BlocProvider.value( + value: bloc, + child: MobileURLEditor( + textEditingController: textEditingController, + ), + ); + }, + ), child: Container( constraints: const BoxConstraints( minHeight: 48, @@ -76,25 +77,4 @@ class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { URLCellDataNotifier cellDataNotifier, ) => const []; - - void _showURLEditor(BuildContext context, URLCellBloc bloc, String content) { - final controller = TextEditingController(text: content); - showMobileBottomSheet( - context, - title: LocaleKeys.board_mobile_editURL.tr(), - showHeader: true, - showCloseButton: true, - builder: (_) { - return TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.url, - onEditingComplete: () { - bloc.add(URLCellEvent.updateURL(controller.text)); - context.pop(); - }, - ); - }, - ).then((_) => controller.dispose()); - } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart index a2d0c4adb60a5..3ab883329a20c 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart @@ -1,4 +1,8 @@ import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -10,11 +14,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; + import 'checklist_progress_bar.dart'; class ChecklistCellEditor extends StatefulWidget { @@ -23,10 +26,10 @@ class ChecklistCellEditor extends StatefulWidget { final ChecklistCellController cellController; @override - State createState() => _GridChecklistCellState(); + State createState() => _ChecklistCellEditorState(); } -class _GridChecklistCellState extends State { +class _ChecklistCellEditorState extends State { /// Focus node for the new task text field late final FocusNode newTaskFocusNode; @@ -56,18 +59,14 @@ class _GridChecklistCellState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: state.tasks.isEmpty - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: ChecklistProgressBar( - tasks: state.tasks, - percent: state.percent, - ), - ), - ), + if (state.tasks.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + ), + ), ChecklistItemList( options: state.tasks, onUpdateTask: () => newTaskFocusNode.requestFocus(), @@ -92,7 +91,7 @@ class _GridChecklistCellState extends State { /// Displays the a list of all the exisiting tasks and an input field to create /// a new task if `isAddingNewTask` is true -class ChecklistItemList extends StatefulWidget { +class ChecklistItemList extends StatelessWidget { const ChecklistItemList({ super.key, required this.options, @@ -102,26 +101,19 @@ class ChecklistItemList extends StatefulWidget { final List options; final VoidCallback onUpdateTask; - @override - State createState() => _ChecklistItemListState(); -} - -class _ChecklistItemListState extends State { @override Widget build(BuildContext context) { - if (widget.options.isEmpty) { + if (options.isEmpty) { return const SizedBox.shrink(); } - final itemList = widget.options + final itemList = options .mapIndexed( (index, option) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: ChecklistItem( task: option, - onSubmitted: index == widget.options.length - 1 - ? widget.onUpdateTask - : null, + onSubmitted: index == options.length - 1 ? onUpdateTask : null, key: ValueKey(option.data.id), ), ), @@ -140,6 +132,14 @@ class _ChecklistItemListState extends State { } } +class _SelectTaskIntent extends Intent { + const _SelectTaskIntent(); +} + +class _EndEditingTaskIntent extends Intent { + const _EndEditingTaskIntent(); +} + /// Represents an existing task @visibleForTesting class ChecklistItem extends StatefulWidget { @@ -160,103 +160,140 @@ class ChecklistItem extends StatefulWidget { class _ChecklistItemState extends State { late final TextEditingController _textController; - late final FocusNode _focusNode; + final FocusNode _focusNode = FocusNode(skipTraversal: true); + final FocusNode _textFieldFocusNode = FocusNode(); + bool _isHovered = false; + bool _isFocused = false; Timer? _debounceOnChanged; @override void initState() { super.initState(); _textController = TextEditingController(text: widget.task.data.name); - _focusNode = FocusNode( - onKeyEvent: (node, event) { - if (event.logicalKey == LogicalKeyboardKey.escape) { - node.unfocus(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - ); - if (widget.autofocus) { - _focusNode.requestFocus(); - } } @override void dispose() { + _debounceOnChanged?.cancel(); _textController.dispose(); _focusNode.dispose(); - _debounceOnChanged?.cancel(); + _textFieldFocusNode.dispose(); super.dispose(); } @override void didUpdateWidget(ChecklistItem oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.task.data.name != oldWidget.task.data.name && - !_focusNode.hasFocus) { + if (widget.task.data.name != oldWidget.task.data.name) { + final selection = _textController.selection; _textController.text = widget.task.data.name; + _textController.selection = selection; } } @override Widget build(BuildContext context) { - final icon = FlowySvg( - widget.task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, - blendMode: BlendMode.dst, - ); - return MouseRegion( - onEnter: (event) => setState(() => _isHovered = true), - onExit: (event) => setState(() => _isHovered = false), + return FocusableActionDetector( + focusNode: _focusNode, + onShowHoverHighlight: (isHovered) { + setState(() => _isHovered = isHovered); + }, + onFocusChange: (isFocused) { + setState(() => _isFocused = isFocused); + }, + actions: { + _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( + onInvoke: (_SelectTaskIntent intent) => context + .read() + .add(ChecklistCellEvent.selectTask(widget.task.data.id)), + ), + _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( + onInvoke: (_EndEditingTaskIntent intent) => + _textFieldFocusNode.unfocus(), + ), + }, + shortcuts: { + SingleActivator( + LogicalKeyboardKey.enter, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): const _SelectTaskIntent(), + }, child: Container( constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), decoration: BoxDecoration( - color: _isHovered + color: _isHovered || _isFocused || _textFieldFocusNode.hasFocus ? AFThemeExtension.of(context).lightGreyHover : Colors.transparent, borderRadius: Corners.s6Border, ), child: Row( children: [ - FlowyIconButton( - width: 32, - icon: icon, - hoverColor: Colors.transparent, - onPressed: () => context.read().add( - ChecklistCellEvent.selectTask(widget.task.data), - ), + ExcludeFocus( + child: FlowyIconButton( + width: 32, + icon: FlowySvg( + widget.task.isSelected + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + ), + hoverColor: Colors.transparent, + onPressed: () => context.read().add( + ChecklistCellEvent.selectTask(widget.task.data.id), + ), + ), ), Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - style: Theme.of(context).textTheme.bodyMedium, - decoration: InputDecoration( - border: InputBorder.none, - isCollapsed: true, - contentPadding: EdgeInsets.only( - top: 8.0, - bottom: 8.0, - left: 2.0, - right: _isHovered ? 2.0 : 8.0, - ), - hintText: LocaleKeys.grid_checklist_taskHint.tr(), - ), - onChanged: _debounceOnChangedText, - onSubmitted: (description) { - _submitUpdateTaskDescription(description); - widget.onSubmitted?.call(); + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): + _EndEditingTaskIntent(), }, + child: Builder( + builder: (context) { + return TextField( + controller: _textController, + focusNode: _textFieldFocusNode, + autofocus: widget.autofocus, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.only( + top: 8.0, + bottom: 8.0, + left: 2.0, + right: _isHovered ? 2.0 : 8.0, + ), + hintText: LocaleKeys.grid_checklist_taskHint.tr(), + ), + textInputAction: widget.onSubmitted == null + ? TextInputAction.next + : null, + onChanged: (text) { + if (_textController.value.composing.isCollapsed) { + _debounceOnChangedText(text); + } + }, + onSubmitted: (description) { + _submitUpdateTaskDescription(description); + if (widget.onSubmitted != null) { + widget.onSubmitted?.call(); + } else { + Actions.invoke(context, const NextFocusIntent()); + } + }, + ); + }, + ), ), ), - if (_isHovered) - FlowyIconButton( - width: 32, - icon: const FlowySvg(FlowySvgs.delete_s), - hoverColor: Colors.transparent, - iconColorOnHover: Theme.of(context).colorScheme.error, + if (_isHovered || _isFocused || _textFieldFocusNode.hasFocus) + _DeleteTaskButton( onPressed: () => context.read().add( - ChecklistCellEvent.deleteTask(widget.task.data), + ChecklistCellEvent.deleteTask(widget.task.data.id), ), ), ], @@ -276,7 +313,7 @@ class _ChecklistItemState extends State { context.read().add( ChecklistCellEvent.updateTaskName( widget.task.data, - description.trim(), + description, ), ); } @@ -295,12 +332,11 @@ class NewTaskItem extends StatefulWidget { } class _NewTaskItemState extends State { - late final TextEditingController _textEditingController; + final _textEditingController = TextEditingController(); @override void initState() { super.initState(); - _textEditingController = TextEditingController(); if (widget.focusNode.canRequestFocus) { widget.focusNode.requestFocus(); } @@ -335,15 +371,13 @@ class _NewTaskItemState extends State { hintText: LocaleKeys.grid_checklist_addNew.tr(), ), onSubmitted: (taskDescription) { - if (taskDescription.trim().isNotEmpty) { - context.read().add( - ChecklistCellEvent.createNewTask( - taskDescription.trim(), - ), - ); + if (taskDescription.isNotEmpty) { + context + .read() + .add(ChecklistCellEvent.createNewTask(taskDescription)); + _textEditingController.clear(); } widget.focusNode.requestFocus(); - _textEditingController.clear(); }, onChanged: (value) => setState(() {}), ), @@ -359,19 +393,73 @@ class _NewTaskItemState extends State { : Theme.of(context).colorScheme.primaryContainer, fontColor: Theme.of(context).colorScheme.onPrimary, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - onPressed: () { - final text = _textEditingController.text.trim(); - if (text.isNotEmpty) { - context.read().add( - ChecklistCellEvent.createNewTask(text), - ); - } - widget.focusNode.requestFocus(); - _textEditingController.clear(); - }, + onPressed: _textEditingController.text.isEmpty + ? null + : () { + context.read().add( + ChecklistCellEvent.createNewTask( + _textEditingController.text, + ), + ); + widget.focusNode.requestFocus(); + _textEditingController.clear(); + }, ), ], ), ); } } + +class _DeleteTaskButton extends StatefulWidget { + const _DeleteTaskButton({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + State<_DeleteTaskButton> createState() => _DeleteTaskButtonState(); +} + +class _DeleteTaskButtonState extends State<_DeleteTaskButton> { + final _materialStatesController = MaterialStatesController(); + + @override + void dispose() { + _materialStatesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: widget.onPressed, + onHover: (_) => setState(() {}), + onFocusChange: (_) => setState(() {}), + style: ButtonStyle( + fixedSize: const MaterialStatePropertyAll(Size.square(32)), + minimumSize: const MaterialStatePropertyAll(Size.square(32)), + maximumSize: const MaterialStatePropertyAll(Size.square(32)), + overlayColor: MaterialStateProperty.resolveWith((state) { + if (state.contains(MaterialState.focused)) { + return AFThemeExtension.of(context).greyHover; + } + return Colors.transparent; + }), + shape: const MaterialStatePropertyAll( + RoundedRectangleBorder(borderRadius: Corners.s6Border), + ), + ), + statesController: _materialStatesController, + child: FlowySvg( + FlowySvgs.delete_s, + color: _materialStatesController.value + .contains(MaterialState.hovered) || + _materialStatesController.value.contains(MaterialState.focused) + ? Theme.of(context).colorScheme.error + : null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart index 944d438e69932..fb8b11474c221 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart @@ -159,7 +159,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { borderRadius: BorderRadius.circular(22), onTap: () => context .read() - .add(ChecklistCellEvent.selectTask(widget.task.data)), + .add(ChecklistCellEvent.selectTask(widget.task.data.id)), child: SizedBox.square( dimension: 44, child: Center( @@ -230,7 +230,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { void _showDeleteTaskBottomSheet() { showMobileBottomSheet( context, - padding: const EdgeInsets.only(top: 8, bottom: 32), + showDragHandle: true, builder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ @@ -239,7 +239,7 @@ class _ChecklistItemState extends State<_ChecklistItem> { child: InkWell( onTap: () { context.read().add( - ChecklistCellEvent.deleteTask(widget.task.data), + ChecklistCellEvent.deleteTask(widget.task.data.id), ); context.pop(); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart index 3b759295c74ee..f1c8b424f598e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart @@ -84,12 +84,11 @@ class _MobileSelectOptionEditorState extends State { const height = 44.0; return Stack( children: [ - Align( - alignment: Alignment.centerLeft, - child: showMoreOptions - ? AppBarBackButton(onTap: _popOrBack) - : AppBarCloseButton(onTap: _popOrBack), - ), + if (showMoreOptions) + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton(onTap: _popOrBack), + ), SizedBox( height: 44.0, child: Align( @@ -260,17 +259,15 @@ class _OptionList extends StatelessWidget { final List cells = []; // create an option cell - state.createOption.fold( - () => null, - (createOption) { - cells.add( - _CreateOptionCell( - optionName: createOption, - onTap: () => onCreateOption(createOption), - ), - ); - }, - ); + final createOption = state.createOption; + if (createOption != null) { + cells.add( + _CreateOptionCell( + optionName: createOption, + onTap: () => onCreateOption(createOption), + ), + ); + } cells.addAll( state.options.map( @@ -423,7 +420,6 @@ class _MoreOptionsState extends State<_MoreOptions> { @override Widget build(BuildContext context) { - final color = Theme.of(context).colorScheme.secondaryContainer; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -434,22 +430,18 @@ class _MoreOptionsState extends State<_MoreOptions> { const VSpace(16.0), Padding( padding: const EdgeInsets.only(left: 12.0), - child: ColoredBox( - color: color, - child: FlowyText( - LocaleKeys.grid_selectOption_colorPanelTitle.tr().toUpperCase(), - color: Theme.of(context).hintColor, - fontSize: 13, - ), + child: FlowyText( + LocaleKeys.grid_selectOption_colorPanelTitle.tr().toUpperCase(), + color: Theme.of(context).hintColor, + fontSize: 13, ), ), const VSpace(4.0), FlowyOptionDecorateBox( child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 6.0, - right: 6.0, + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 6.0, ), child: OptionColorList( selectedColor: option.color, @@ -482,7 +474,11 @@ class _MoreOptionsState extends State<_MoreOptions> { Widget _buildDeleteButton(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.button_delete.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_delete_s), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.m_delete_s, + color: Theme.of(context).colorScheme.error, + ), onTap: widget.onDelete, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart new file mode 100644 index 0000000000000..d3bf428ed8aec --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart @@ -0,0 +1,195 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.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'; + +import '../../application/cell/bloc/relation_cell_bloc.dart'; +import '../../application/cell/bloc/relation_row_search_bloc.dart'; + +class RelationCellEditor extends StatelessWidget { + const RelationCellEditor({ + super.key, + required this.selectedRowIds, + }); + + final List selectedRowIds; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, cellState) { + if (cellState.relatedDatabaseMeta == null) { + return const _RelationCellEditorDatabaseList(); + } + + return BlocProvider( + create: (context) => RelationRowSearchBloc( + databaseId: cellState.relatedDatabaseMeta!.databaseId, + ), + child: BlocBuilder( + builder: (context, state) { + final children = state.filteredRows + .map( + (row) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: FlowyButton( + text: FlowyText.medium( + row.name.trim().isEmpty + ? LocaleKeys.grid_title_placeholder.tr() + : row.name, + color: row.name.trim().isEmpty + ? Theme.of(context).hintColor + : null, + overflow: TextOverflow.ellipsis, + ), + rightIcon: cellState.rows + .map((e) => e.rowId) + .contains(row.rowId) + ? const FlowySvg( + FlowySvgs.check_s, + ) + : null, + onTap: () => context + .read() + .add(RelationCellEvent.selectRow(row.rowId)), + ), + ), + ) + .toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.grid_relation_inRelatedDatabase.tr(), + fontSize: 11, + color: Theme.of(context).hintColor, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: FlowyText.regular( + cellState.relatedDatabaseMeta!.databaseName, + fontSize: 11, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: FlowyTextField( + hintText: LocaleKeys + .grid_relation_rowSearchTextFieldPlaceholder + .tr(), + hintStyle: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).hintColor), + onChanged: (text) => context + .read() + .add(RelationRowSearchEvent.updateFilter(text)), + ), + ), + const VSpace(6.0), + const TypeOptionSeparator(spacing: 0.0), + if (state.filteredRows.isEmpty) + Padding( + padding: const EdgeInsets.all(6.0) + + GridSize.typeOptionContentInsets, + child: FlowyText.regular( + LocaleKeys.grid_relation_emptySearchResult.tr(), + color: Theme.of(context).hintColor, + ), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 6.0), + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + ), + ), + ], + ); + }, + ), + ); + }, + ); + } +} + +class _RelationCellEditorDatabaseList extends StatelessWidget { + const _RelationCellEditorDatabaseList(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => RelationDatabaseListCubit(), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 6, 0), + child: FlowyText( + LocaleKeys.grid_relation_noDatabaseSelected.tr(), + maxLines: null, + fontSize: 10, + color: Theme.of(context).hintColor, + ), + ), + Flexible( + child: ListView.separated( + padding: const EdgeInsets.all(6), + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: state.databaseMetas.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final databaseMeta = state.databaseMetas[index]; + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + onTap: () => context.read().add( + RelationCellEvent.selectDatabaseId( + databaseMeta.databaseId, + ), + ), + text: FlowyText.medium( + databaseMeta.databaseName, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_editor.dart index f10f83fcc0e40..a71e783dacc41 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_editor.dart @@ -12,11 +12,11 @@ import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../application/cell/bloc/select_option_editor_bloc.dart'; import '../../grid/presentation/layout/sizes.dart'; import '../../grid/presentation/widgets/common/type_option_separator.dart'; import '../../grid/presentation/widgets/header/type_option/select/select_option_editor.dart'; import 'extension.dart'; -import '../../application/cell/bloc/select_option_editor_bloc.dart'; import 'select_option_text_field.dart'; const double _editorPanelWidth = 300; @@ -95,12 +95,10 @@ class _OptionList extends StatelessWidget { ), ]; - state.createOption.fold( - () => null, - (createOption) { - cells.add(_CreateOptionCell(name: createOption)); - }, - ); + final createOption = state.createOption; + if (createOption != null) { + cells.add(_CreateOptionCell(name: createOption)); + } return ListView.separated( shrinkWrap: true, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart index 2879bc8887a1d..3f1d2a6ac1a41 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart @@ -75,6 +75,7 @@ class _SelectOptionTextFieldState extends State { @override void dispose() { widget.textController.removeListener(_onChanged); + focusNode.dispose(); super.dispose(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart index f3a023b3dafb6..97e13a626aab0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; class DatabaseViewWidget extends StatefulWidget { const DatabaseViewWidget({ @@ -28,11 +30,13 @@ class _DatabaseViewWidgetState extends State { /// The view will be updated by the [ViewListener]. late ViewPB view; + late Plugin viewPlugin; + @override void initState() { super.initState(); - view = widget.view; + viewPlugin = view.plugin()..init(); _listenOnViewUpdated(); } @@ -40,6 +44,7 @@ class _DatabaseViewWidgetState extends State { void dispose() { _layoutTypeChangeNotifier.dispose(); _listener.stop(); + viewPlugin.dispose(); super.dispose(); } @@ -47,12 +52,9 @@ class _DatabaseViewWidgetState extends State { Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: _layoutTypeChangeNotifier, - builder: (_, __, ___) { - return view - .plugin() - .widgetBuilder - .buildWidget(shrinkWrap: widget.shrinkWrap); - }, + builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget( + shrinkWrap: widget.shrinkWrap, + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart index 8cf9f868b75c2..9f620118e915e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -302,7 +302,11 @@ class _TitleSkin extends IEditableTextCellSkin { isDense: true, isCollapsed: true, ), - onChanged: (text) => bloc.add(TextCellEvent.updateText(text.trim())), + onChanged: (text) { + if (textEditingController.value.composing.isCollapsed) { + bloc.add(TextCellEvent.updateText(text)); + } + }, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart index 04211ffba205d..695ce432bcacf 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart @@ -1,13 +1,15 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart'; import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class RowDocument extends StatelessWidget { @@ -107,22 +109,25 @@ class _RowEditorState extends State { howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), ); } + 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), + child: BlocProvider( + create: (context) => ViewInfoBloc(view: widget.viewPB), + child: AppFlowyEditorPage( + shrinkWrap: true, + autoFocus: false, + editorState: editorState, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: const EdgeInsets.only(left: 16, right: 54), + ), + showParagraphPlaceholder: (editorState, node) => + editorState.document.isEmpty, + placeholderText: (node) => + LocaleKeys.cardDetails_notesPlaceholder.tr(), ), - showParagraphPlaceholder: (editorState, node) => - editorState.document.isEmpty, - placeholderText: (node) => - LocaleKeys.cardDetails_notesPlaceholder.tr(), ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart index a5b92ab9cbf6a..7a5f80217988a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart @@ -5,7 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_editor.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart index ee3aea478ef1d..d5f3ce293a1b1 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart @@ -6,7 +6,6 @@ import 'package:appflowy/mobile/presentation/database/view/database_sort_bottom_ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; -import 'package:appflowy/plugins/database/grid/application/sort/sort_menu_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -28,30 +27,22 @@ class MobileDatabaseControls extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider( - create: (context) => GridFilterMenuBloc( + BlocProvider( + create: (context) => DatabaseFilterMenuBloc( viewId: controller.viewId, fieldController: controller.fieldController, - )..add(const GridFilterMenuEvent.initial()), + )..add(const DatabaseFilterMenuEvent.initial()), ), - BlocProvider( - create: (context) => SortMenuBloc( + BlocProvider( + create: (context) => SortEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, - )..add(const SortMenuEvent.initial()), + ), ), ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - ), - BlocListener( - listenWhen: (p, c) => p.isVisible != c.isVisible, - listener: (context, state) => toggleExtension.toggle(), - ), - ], + child: BlocListener( + listenWhen: (p, c) => p.isVisible != c.isVisible, + listener: (context, state) => toggleExtension.toggle(), child: ValueListenableBuilder( valueListenable: controller.isLoading, builder: (context, isLoading, child) { @@ -64,7 +55,7 @@ class MobileDatabaseControls extends StatelessWidget { children: [ _DatabaseControlButton( icon: FlowySvgs.sort_ascending_s, - count: context.watch().state.sortInfos.length, + count: context.watch().state.sortInfos.length, onTap: () => _showEditSortPanelFromToolbar( context, controller, @@ -156,17 +147,13 @@ void _showEditSortPanelFromToolbar( ) { showMobileBottomSheet( context, - backgroundColor: Theme.of(context).colorScheme.surface, showDragHandle: true, showDivider: false, useSafeArea: false, + backgroundColor: Theme.of(context).colorScheme.background, builder: (_) { - return BlocProvider( - create: (_) => SortEditorBloc( - viewId: databaseController.viewId, - fieldController: databaseController.fieldController, - sortInfos: databaseController.fieldController.sortInfos, - )..add(const SortEditorEvent.initial()), + return BlocProvider.value( + value: context.read(), child: const MobileSortEditor(), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart index 76aae975804bd..d6c84e39fe481 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/share_bloc.dart'; import 'package:appflowy/startup/startup.dart'; @@ -11,7 +13,6 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DatabaseShareButton extends StatelessWidget { @@ -101,13 +102,14 @@ class DatabaseShareActionListState extends State { actions: ShareAction.values .map((action) => ShareActionWrapper(action)) .toList(), - buildChild: (controller) { - return RoundedTextButton( + buildChild: (controller) => Listener( + onPointerDown: (_) => controller.show(), + child: RoundedTextButton( title: LocaleKeys.shareAction_buttonText.tr(), textColor: Theme.of(context).colorScheme.onPrimary, - onPressed: () => controller.show(), - ); - }, + onPressed: () {}, + ), + ), onSelected: (action, controller) async { switch (action.inner) { case ShareAction.csv: diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart new file mode 100644 index 0000000000000..6c971ffdeb356 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/util/json_print.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:collection/collection.dart'; + +class CollabDocumentAdapter { + CollabDocumentAdapter(this.editorState, this.docId); + + final EditorState editorState; + final String docId; + + final _service = DocumentService(); + + /// Sync version 1 + /// + /// Force to reload the document + /// + /// Only use in development + Future syncV1() async { + final result = await _service.getDocument(viewId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return null; + } + return EditorState(document: document); + } + + /// Sync version 2 + /// + /// Translate the [docEvent] from yrs to [Operation]s and apply it to the [editorState] + /// + /// Not fully implemented yet + Future syncV2(DocEventPB docEvent) async { + prettyPrintJson(docEvent.toProto3Json()); + + final transaction = editorState.transaction; + + for (final event in docEvent.events) { + for (final blockEvent in event.event) { + switch (blockEvent.command) { + case DeltaTypePB.Inserted: + break; + case DeltaTypePB.Updated: + await _syncUpdated(blockEvent, transaction); + break; + case DeltaTypePB.Removed: + break; + default: + } + } + } + + await editorState.apply(transaction, isRemote: true); + } + + /// Sync version 3 + /// + /// Diff the local document with the remote document and apply the changes + Future syncV3() async { + final result = await _service.getDocument(viewId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return; + } + + final ops = diffNodes(editorState.document.root, document.root); + if (ops.isEmpty) { + return; + } + + final transaction = editorState.transaction; + for (final op in ops) { + transaction.add(op); + } + await editorState.apply(transaction, isRemote: true); + } + + Future _syncUpdated( + BlockEventPayloadPB payload, + Transaction transaction, + ) async { + assert(payload.command == DeltaTypePB.Updated); + + final path = payload.path; + final id = payload.id; + final value = jsonDecode(payload.value); + + final nodes = NodeIterator( + document: editorState.document, + startNode: editorState.document.root, + ).toList(); + + // 1. meta -> text_map = text delta change + if (path.isTextDeltaChangeset) { + // find the 'text' block and apply the delta + // ⚠️ not completed yet. + final target = nodes.singleWhereOrNull((n) => n.id == id); + if (target != null) { + try { + final delta = Delta.fromJson(jsonDecode(value)); + transaction.insertTextDelta(target, 0, delta); + } catch (e) { + Log.error('Failed to apply delta: $value, error: $e'); + } + } + } else if (path.isBlockChangeset) { + final target = nodes.singleWhereOrNull((n) => n.id == id); + if (target != null) { + try { + final delta = jsonDecode(value['data'])['delta']; + transaction.updateNode(target, { + 'delta': Delta.fromJson(delta).toJson(), + }); + } catch (e) { + Log.error('Failed to update $value, error: $e'); + } + } + } + } +} + +extension on List { + bool get isTextDeltaChangeset { + return length == 3 && this[0] == 'meta' && this[1] == 'text_map'; + } + + bool get isBlockChangeset { + return length == 2 && this[0] == 'blocks'; + } +} 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 99eaba7d9684e..560397a33de2c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -1,20 +1,21 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; - +import 'package:appflowy/plugins/document/application/collab_document_adapter.dart'; import 'package:appflowy/plugins/document/application/doc_service.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' show EditorState, @@ -23,7 +24,8 @@ import 'package:appflowy_editor/appflowy_editor.dart' Selection, Position, paragraphNode; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -48,6 +50,8 @@ class DocumentBloc extends Bloc { final DocumentService _documentService = DocumentService(); final TrashService _trashService = TrashService(); + late CollabDocumentAdapter _collabDocumentAdapter; + late final TransactionAdapter _transactionAdapter = TransactionAdapter( documentId: view.id, documentService: _documentService, @@ -55,6 +59,12 @@ class DocumentBloc extends Bloc { StreamSubscription? _subscription; + bool get isLocalMode { + final userProfilePB = state.userProfilePB; + final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; + return type == AuthenticatorPB.Local; + } + @override Future close() async { await _documentListener.stop(); @@ -73,30 +83,27 @@ class DocumentBloc extends Bloc { ) async { await event.when( initial: () async { - final editorState = await _fetchDocumentState(); + final result = await _fetchDocumentState(); _onViewChanged(); _onDocumentChanged(); - await editorState.fold( - (l) async => emit( - state.copyWith( - error: l, - editorState: null, + final newState = await result.fold( + (s) async { + final userProfilePB = + await getIt().getUser().toNullable(); + return state.copyWith( + error: null, + editorState: s, isLoading: false, - ), - ), - (r) async { - final result = await getIt().getUser(); - final userProfilePB = result.fold((l) => null, (r) => r); - emit( - state.copyWith( - error: null, - editorState: r, - isLoading: false, - userProfilePB: userProfilePB, - ), + userProfilePB: userProfilePB, ); }, + (f) async => state.copyWith( + error: f, + editorState: null, + isLoading: false, + ), ); + emit(newState); }, moveToTrash: () async { emit(state.copyWith(isDeleted: true)); @@ -114,8 +121,8 @@ class DocumentBloc extends Bloc { final isDeleted = result.fold((l) => false, (r) => true); emit(state.copyWith(isDeleted: isDeleted)); }, - syncStateChanged: (isSyncing) { - emit(state.copyWith(isSyncing: isSyncing)); + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); }, ); } @@ -124,13 +131,12 @@ class DocumentBloc extends Bloc { void _onViewChanged() { _viewListener.start( onViewMoveToTrash: (r) { - r.swap().map((r) => add(const DocumentEvent.moveToTrash())); + r.map((r) => add(const DocumentEvent.moveToTrash())); }, onViewDeleted: (r) { - r.swap().map((r) => add(const DocumentEvent.moveToTrash())); + r.map((r) => add(const DocumentEvent.moveToTrash())); }, - onViewRestored: (r) => - r.swap().map((r) => add(const DocumentEvent.restore())), + onViewRestored: (r) => r.map((r) => add(const DocumentEvent.restore())), ); } @@ -143,18 +149,18 @@ class DocumentBloc extends Bloc { _syncStateListener.start( didReceiveSyncState: (syncState) { if (!isClosed) { - add(DocumentEvent.syncStateChanged(syncState.isSyncing)); + add(DocumentEvent.syncStateChanged(syncState)); } }, ); } /// Fetch document - Future> _fetchDocumentState() async { + Future> _fetchDocumentState() async { final result = await _documentService.openDocument(viewId: view.id); return result.fold( - (l) => left(l), - (r) async => right(await _initAppFlowyEditorState(r)), + (s) async => FlowyResult.success(await _initAppFlowyEditorState(s)), + (e) => FlowyResult.failure(e), ); } @@ -167,6 +173,8 @@ class DocumentBloc extends Bloc { final editorState = EditorState(document: document); + _collabDocumentAdapter = CollabDocumentAdapter(editorState, view.id); + // subscribe to the document change from the editor _subscription = editorState.transactionStream.listen((event) async { final time = event.$1; @@ -234,22 +242,12 @@ class DocumentBloc extends Bloc { } } - void syncDocumentDataPB(DocEventPB docEvent) { - // prettyPrintJson(docEvent.toProto3Json()); - // todo: integrate the document change to the editor - // for (final event in docEvent.events) { - // for (final blockEvent in event.event) { - // switch (blockEvent.command) { - // case DeltaTypePB.Inserted: - // break; - // case DeltaTypePB.Updated: - // break; - // case DeltaTypePB.Removed: - // break; - // default: - // } - // } - // } + Future syncDocumentDataPB(DocEventPB docEvent) async { + if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { + return; + } + + await _collabDocumentAdapter.syncV3(); } } @@ -260,17 +258,18 @@ class DocumentEvent with _$DocumentEvent { const factory DocumentEvent.restore() = Restore; const factory DocumentEvent.restorePage() = RestorePage; const factory DocumentEvent.deletePermanently() = DeletePermanently; - const factory DocumentEvent.syncStateChanged(bool isSyncing) = - syncStateChanged; + const factory DocumentEvent.syncStateChanged( + final DocumentSyncStatePB syncState, + ) = syncStateChanged; } @freezed class DocumentState with _$DocumentState { const factory DocumentState({ - required bool isDeleted, - required bool forceClose, - required bool isLoading, - required bool isSyncing, + required final bool isDeleted, + required final bool forceClose, + required final bool isLoading, + required final DocumentSyncState syncState, bool? isDocumentEmpty, UserProfilePB? userProfilePB, EditorState? editorState, @@ -281,6 +280,6 @@ class DocumentState with _$DocumentState { isDeleted: false, forceClose: false, isLoading: true, - isSyncing: false, + syncState: DocumentSyncState.Syncing, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart index 9009ea52ffe08..110a5dc76699d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart @@ -2,56 +2,64 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class DocumentService { // unused now. - Future> createDocument({ + Future> createDocument({ required ViewPB view, }) async { final canOpen = await openDocument(viewId: view.id); - if (canOpen.isRight()) { - return const Right(unit); + if (canOpen.isSuccess) { + return FlowyResult.success(null); } final payload = CreateDocumentPayloadPB()..documentId = view.id; final result = await DocumentEventCreateDocument(payload).send(); - return result.swap(); + return result; } - Future> openDocument({ + Future> openDocument({ required String viewId, }) async { final payload = OpenDocumentPayloadPB()..documentId = viewId; final result = await DocumentEventOpenDocument(payload).send(); - return result.swap(); + return result; } - Future> getBlockFromDocument({ + Future> getDocument({ + required String viewId, + }) async { + final payload = OpenDocumentPayloadPB()..documentId = viewId; + final result = await DocumentEventGetDocumentData(payload).send(); + return result; + } + + Future> getBlockFromDocument({ required DocumentDataPB document, required String blockId, }) async { final block = document.blocks[blockId]; if (block != null) { - return right(block); + return FlowyResult.success(block); } - return left( + return FlowyResult.failure( FlowyError( msg: 'Block($blockId) not found in Document(${document.pageId})', ), ); } - Future> closeDocument({ + Future> closeDocument({ required ViewPB view, }) async { final payload = CloseDocumentPayloadPB()..documentId = view.id; final result = await DocumentEventCloseDocument(payload).send(); - return result.swap(); + return result; } - Future> applyAction({ + Future> applyAction({ required String documentId, required Iterable actions, }) async { @@ -60,7 +68,7 @@ class DocumentService { actions: actions, ); final result = await DocumentEventApplyAction(payload).send(); - return result.swap(); + return result; } /// Creates a new external text. @@ -68,7 +76,7 @@ class DocumentService { /// Normally, it's used to the block that needs sync long text. /// /// the delta parameter is the json representation of the delta. - Future> createExternalText({ + Future> createExternalText({ required String documentId, required String textId, String? delta, @@ -79,7 +87,7 @@ class DocumentService { delta: delta, ); final result = await DocumentEventCreateText(payload).send(); - return result.swap(); + return result; } /// Updates the external text. @@ -87,7 +95,7 @@ class DocumentService { /// this function is compatible with the [createExternalText] function. /// /// the delta parameter is the json representation of the delta too. - Future> updateExternalText({ + Future> updateExternalText({ required String documentId, required String textId, String? delta, @@ -98,11 +106,11 @@ class DocumentService { delta: delta, ); final result = await DocumentEventApplyTextDeltaEvent(payload).send(); - return result.swap(); + return result; } /// Upload a file to the cloud storage. - Future> uploadFile({ + Future> uploadFile({ required String localFilePath, bool isAsync = true, }) async { @@ -114,14 +122,14 @@ class DocumentService { isAsync: isAsync, ); final result = await DocumentEventUploadFile(payload).send(); - return result.swap(); + return result; }, (r) async { - return left(FlowyError(msg: 'Workspace not found')); + return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); }); } /// Download a file from the cloud storage. - Future> downloadFile({ + Future> downloadFile({ required String url, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); @@ -130,9 +138,9 @@ class DocumentService { url: url, ); final result = await DocumentEventDownloadFile(payload).send(); - return result.swap(); + return result; }, (r) async { - return left(FlowyError(msg: 'Workspace not found')); + return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); }); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart new file mode 100644 index 0000000000000..078727bc943e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'doc_sync_bloc.freezed.dart'; + +class DocumentSyncBloc extends Bloc { + DocumentSyncBloc({ + required this.view, + }) : _syncStateListener = DocumentSyncStateListener(id: view.id), + super(DocumentSyncBlocState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final userProfile = await getIt().getUser().then( + (result) => result.fold( + (l) => l, + (r) => null, + ), + ); + emit( + state.copyWith( + shouldShowIndicator: + userProfile?.authenticator != AuthenticatorPB.Local, + ), + ); + _syncStateListener.start( + didReceiveSyncState: (syncState) { + if (!isClosed) { + add(DocumentSyncEvent.syncStateChanged(syncState)); + } + }, + ); + + final isNetworkConnected = await _connectivity + .checkConnectivity() + .then((value) => value != ConnectivityResult.none); + emit(state.copyWith(isNetworkConnected: isNetworkConnected)); + + connectivityStream = + _connectivity.onConnectivityChanged.listen((result) { + if (!isClosed) {} + emit( + state.copyWith( + isNetworkConnected: result != ConnectivityResult.none, + ), + ); + }); + }, + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); + }, + ); + }, + ); + } + + final ViewPB view; + final DocumentSyncStateListener _syncStateListener; + final _connectivity = Connectivity(); + + StreamSubscription? connectivityStream; + + @override + Future close() async { + await connectivityStream?.cancel(); + await _syncStateListener.stop(); + return super.close(); + } +} + +@freezed +class DocumentSyncEvent with _$DocumentSyncEvent { + const factory DocumentSyncEvent.initial() = Initial; + const factory DocumentSyncEvent.syncStateChanged( + DocumentSyncStatePB syncState, + ) = syncStateChanged; +} + +@freezed +class DocumentSyncBlocState with _$DocumentSyncBlocState { + const factory DocumentSyncBlocState({ + required DocumentSyncState syncState, + @Default(true) bool isNetworkConnected, + @Default(false) bool shouldShowIndicator, + }) = _DocumentSyncState; + + factory DocumentSyncBlocState.initial() => const DocumentSyncBlocState( + syncState: DocumentSyncState.Syncing, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart similarity index 98% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart rename to frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart index f75fc3f7a794b..726d9597918d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart @@ -1,10 +1,11 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:bloc/bloc.dart'; -import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class DocumentAppearance { @@ -78,7 +79,8 @@ class DocumentAppearanceCubit extends Cubit { ? Color(int.parse(selectionColorString)) : null; - final textScaleFactor = prefs.getDouble(KVKeys.textScaleFactor) ?? 1.0; + final textScaleFactor = + double.parse(prefs.getString(KVKeys.textScaleFactor) ?? '1.0'); if (isClosed) { return; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart index 1c98b970f22c6..9762ae7020986 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart @@ -59,11 +59,11 @@ extension DocumentDataPBFromTo on DocumentDataPB { // generate the meta final childrenMap = {}; - blocks.forEach((key, value) { - final parentId = value.parentId; - if (parentId.isNotEmpty) { - childrenMap[parentId] ??= ChildrenPB.create(); - childrenMap[parentId]!.children.add(value.id); + blocks.values.where((e) => e.parentId.isNotEmpty).forEach((value) { + final childrenId = blocks[value.parentId]?.childrenId; + if (childrenId != null) { + childrenMap[childrenId] ??= ChildrenPB.create(); + childrenMap[childrenId]!.children.add(value.id); } }); final meta = MetaPB(childrenMap: childrenMap); @@ -101,10 +101,16 @@ extension DocumentDataPBFromTo on DocumentDataPB { children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull()); } - return block?.toNode( + final node = block?.toNode( children: children, meta: meta, ); + + for (final element in children) { + element.parent = node; + } + + return node; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/share_bloc.dart index 2a9f8810738b5..0760749c12449 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/share_bloc.dart @@ -4,7 +4,7 @@ import 'package:appflowy/workspace/application/export/document_exporter.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -28,8 +28,9 @@ class DocShareBloc extends Bloc { emit( DocShareState.finish( result.fold( - (error) => right(error), - (markdown) => left(_saveMarkdownToPath(markdown, event.path)), + (markdown) => + FlowyResult.success(_saveMarkdownToPath(markdown, event.path)), + (error) => FlowyResult.failure(error), ), ), ); @@ -55,6 +56,6 @@ class DocShareState with _$DocShareState { const factory DocShareState.initial() = _Initial; const factory DocShareState.loading() = _Loading; const factory DocShareState.finish( - Either successOrFail, + FlowyResult successOrFail, ) = _Finish; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index e6b0abebe2ca6..352258cc9e9e6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -1,23 +1,25 @@ library document_plugin; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/document_page.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; -import 'package:appflowy/plugins/document/presentation/more/more_button.dart'; +import 'package:appflowy/plugins/document/presentation/document_sync_indicator.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; -import 'package:appflowy/plugins/document/presentation/favorite/favorite_button.dart'; import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.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-folder/view.pb.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:flutter_bloc/flutter_bloc.dart'; class DocumentPluginBuilder extends PluginBuilder { @@ -53,6 +55,7 @@ class DocumentPlugin extends Plugin { } late PluginType _pluginType; + late final ViewInfoBloc _viewInfoBloc; @override final ViewPluginNotifier notifier; @@ -61,6 +64,7 @@ class DocumentPlugin extends Plugin { @override PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder( + bloc: _viewInfoBloc, notifier: notifier, initialSelection: initialSelection, ); @@ -70,15 +74,29 @@ class DocumentPlugin extends Plugin { @override PluginId get id => notifier.view.id; + + @override + void init() { + _viewInfoBloc = ViewInfoBloc(view: notifier.view) + ..add(const ViewInfoEvent.started()); + } + + @override + void dispose() { + _viewInfoBloc.close(); + notifier.dispose(); + } } class DocumentPluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { DocumentPluginWidgetBuilder({ + required this.bloc, required this.notifier, this.initialSelection, }); + final ViewInfoBloc bloc; final ViewPluginNotifier notifier; ViewPB get view => notifier.view; int? deletedViewIndex; @@ -90,22 +108,21 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder @override Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { notifier.isDeleted.addListener(() { - notifier.isDeleted.value.fold( - () => null, - (deletedView) { - if (deletedView.hasIndex()) { - deletedViewIndex = deletedView.index; - } - }, - ); + final deletedView = notifier.isDeleted.value; + if (deletedView != null && deletedView.hasIndex()) { + deletedViewIndex = deletedView.index; + } }); - return BlocBuilder( - builder: (_, state) => DocumentPage( - key: ValueKey(view.id), - view: view, - onDeleted: () => context?.onDeleted(view, deletedViewIndex), - initialSelection: initialSelection, + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (_, state) => DocumentPage( + key: ValueKey(view.id), + view: view, + onDeleted: () => context?.onDeleted(view, deletedViewIndex), + initialSelection: initialSelection, + ), ), ); } @@ -118,17 +135,33 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder @override Widget? get rightBarItem { - return Row( - children: [ - DocumentShareButton(key: ValueKey(view.id), view: view), - const HSpace(4), - DocumentFavoriteButton( - key: ValueKey('favorite_button_${view.id}'), - view: view, - ), - const HSpace(4), - const DocumentMoreButton(), - ], + return BlocProvider.value( + value: bloc, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DocumentShareButton( + key: ValueKey('share_button_${view.id}'), + view: view, + ), + ...FeatureFlag.syncDocument.isOn + ? [ + const HSpace(20), + DocumentSyncIndicator( + key: ValueKey('sync_state_${view.id}'), + view: view, + ), + const HSpace(12), + ] + : [const HSpace(8)], + ViewFavoriteButton( + key: ValueKey('favorite_button_${view.id}'), + view: view, + ), + const HSpace(4), + MoreViewActions(view: view), + ], + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 8cc60eaf7c675..98ca75ac45f48 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,8 +1,7 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; @@ -15,24 +14,9 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -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, @@ -50,12 +34,23 @@ class DocumentPage extends StatefulWidget { } class _DocumentPageState extends State { + EditorState? editorState; + @override void initState() { super.initState(); // The appflowy editor use Intl as localization, set the default language as fallback. Intl.defaultLocale = 'en_US'; + + EditorNotification.addListener(_onEditorNotification); + } + + @override + void dispose() { + EditorNotification.removeListener(_onEditorNotification); + + super.dispose(); } @override @@ -75,6 +70,7 @@ class _DocumentPageState extends State { } final editorState = state.editorState; + this.editorState = editorState; final error = state.error; if (error != null || editorState == null) { Log.error(error); @@ -149,20 +145,19 @@ class _DocumentPageState extends State { ); } - // Future _exportPage(DocumentDataPB data) async { - // final picker = getIt(); - // final dir = await picker.getDirectoryPath(); - // if (dir == null) { - // return; - // } - // final path = p.join(dir, '${documentBloc.view.name}.json'); - // const encoder = JsonEncoder.withIndent(' '); - // final json = encoder.convert(data.toProto3Json()); - // await File(path).writeAsString(json.base64.base64); - // if (mounted) { - // showSnackBarMessage(context, 'Export success to $path'); - // } - // } + void _onEditorNotification(EditorNotificationType type) { + final editorState = this.editorState; + if (editorState == null) { + return; + } + if (type == EditorNotificationType.undo) { + undoCommand.execute(editorState); + } else if (type == EditorNotificationType.redo) { + redoCommand.execute(editorState); + } else if (type == EditorNotificationType.exitEditing) { + editorState.selection = null; + } + } void _onNotificationAction( BuildContext context, @@ -180,20 +175,3 @@ class _DocumentPageState extends State { } } } - -class DocumentSyncIndicator extends StatelessWidget { - const DocumentSyncIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state.isSyncing) { - return const SizedBox(height: 1, child: LinearProgressIndicator()); - } else { - return const SizedBox(height: 1); - } - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart new file mode 100644 index 0000000000000..5092438217097 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_sync_indicator.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/doc_sync_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentSyncIndicator extends StatelessWidget { + const DocumentSyncIndicator({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + DocumentSyncBloc(view: view)..add(const DocumentSyncEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // don't show indicator if user is local + if (!state.shouldShowIndicator) { + return const SizedBox.shrink(); + } + final Color color; + final String hintText; + + if (!state.isNetworkConnected) { + color = Colors.grey; + hintText = LocaleKeys.newSettings_syncState_noNetworkConnected.tr(); + } else { + switch (state.syncState) { + case DocumentSyncState.SyncFinished: + color = Colors.green; + hintText = LocaleKeys.newSettings_syncState_synced.tr(); + break; + case DocumentSyncState.Syncing: + case DocumentSyncState.InitSyncBegin: + color = Colors.yellow; + hintText = LocaleKeys.newSettings_syncState_syncing.tr(); + break; + default: + return const SizedBox.shrink(); + } + } + + return FlowyTooltip( + message: hintText, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + width: 8, + height: 8, + ), + ); + }, + ), + ); + } +} 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 7ecc4088d3fbf..e8a5f101f99bf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -188,6 +188,7 @@ Map getEditorBuilderMap({ ), builder: (context, node, url, title, description, imageUrl) => CustomLinkPreviewWidget( + node: node, url: url, title: title, description: description, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart new file mode 100644 index 0000000000000..9ec6090b5bf88 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart @@ -0,0 +1,46 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +enum EditorNotificationType { + none, + undo, + redo, + exitEditing, +} + +class EditorNotification { + const EditorNotification({ + required this.type, + }); + + EditorNotification.undo() : type = EditorNotificationType.undo; + EditorNotification.redo() : type = EditorNotificationType.redo; + EditorNotification.exitEditing() : type = EditorNotificationType.exitEditing; + + static final PropertyValueNotifier _notifier = + PropertyValueNotifier( + EditorNotificationType.none, + ); + + final EditorNotificationType type; + + void post() { + _notifier.value = type; + } + + static void addListener(ValueChanged listener) { + _notifier.addListener(() { + listener(_notifier.value); + }); + } + + static void removeListener(ValueChanged listener) { + _notifier.removeListener(() { + listener(_notifier.value); + }); + } + + static void dispose() { + _notifier.dispose(); + } +} 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 fa48ecc2d4432..c40bae7266ae1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -14,6 +14,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; @@ -79,9 +80,9 @@ class _AppFlowyEditorPageState extends State { InlinePageReferenceService( currentViewId: documentBloc.view.id, limitResults: 5, - ).inlinePageReferenceDelegate, - DateReferenceService(context).dateReferenceDelegate, - ReminderReferenceService(context).reminderReferenceDelegate, + ), + DateReferenceService(context), + ReminderReferenceService(context), ], ); @@ -176,6 +177,8 @@ class _AppFlowyEditorPageState extends State { late final EditorScrollController editorScrollController; + late final ViewInfoBloc viewInfoBloc = context.read(); + Future showSlashMenu(editorState) async => customSlashCommand( slashMenuItems, shouldInsertSlash: false, @@ -186,8 +189,15 @@ class _AppFlowyEditorPageState extends State { void initState() { super.initState(); + viewInfoBloc.add( + ViewInfoEvent.registerEditorState( + editorState: widget.editorState, + ), + ); + _initEditorL10n(); _initializeShortcuts(); + appFlowyEditorAutoScrollEdgeOffset = 220; indentableBlockTypes.add(ToggleListBlockKeys.type); convertibleBlockTypes.add(ToggleListBlockKeys.type); slashMenuItems = _customSlashMenuItems(); @@ -224,6 +234,10 @@ class _AppFlowyEditorPageState extends State { @override void dispose() { + if (!viewInfoBloc.isClosed) { + viewInfoBloc.add(const ViewInfoEvent.unregisterEditorState()); + } + SystemChannels.textInput.invokeMethod('TextInput.hide'); if (widget.scrollController == null) { @@ -245,7 +259,9 @@ class _AppFlowyEditorPageState extends State { LayoutDirection.rtlLayout; final textDirection = isRTL ? TextDirection.rtl : TextDirection.ltr; - _setRTLToolbarItems(isRTL); + _setRTLToolbarItems( + context.read().state.enableRtlToolbarItems, + ); final editor = Directionality( textDirection: textDirection, @@ -281,19 +297,12 @@ class _AppFlowyEditorPageState extends State { if (PlatformExtension.isMobile) { return AppFlowyMobileToolbar( - toolbarHeight: 46.0, + toolbarHeight: 42.0, editorState: editorState, - toolbarItems: [ - undoToolbarItem, - redoToolbarItem, - addBlockToolbarItem, - todoListToolbarItem, - aaToolbarItem, - boldToolbarItem, - italicToolbarItem, - underlineToolbarItem, - colorToolbarItem, - ], + toolbarItemsBuilder: (selection) => buildMobileToolbarItems( + editorState, + selection, + ), child: Column( children: [ Expanded( @@ -301,19 +310,12 @@ class _AppFlowyEditorPageState extends State { editorState: editorState, editorScrollController: editorScrollController, toolbarBuilder: (context, anchor, closeToolbar) { - return AdaptiveTextSelectionToolbar.editable( - clipboardStatus: ClipboardStatus.pasteable, - onCopy: () { - customCopyCommand.execute(editorState); - closeToolbar(); - }, - onCut: () => customCutCommand.execute(editorState), - onPaste: () => customPasteCommand.execute(editorState), - onSelectAll: () => selectAllCommand.execute(editorState), - onLiveTextInput: null, - onLookUp: null, - onSearchWeb: null, - onShare: null, + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: buildMobileFloatingToolbarItems( + editorState, + anchor, + closeToolbar, + ), anchors: TextSelectionToolbarAnchors( primaryAnchor: anchor, ), @@ -398,12 +400,12 @@ class _AppFlowyEditorPageState extends State { ); } - void _setRTLToolbarItems(bool isRTL) { + void _setRTLToolbarItems(bool enableRtlToolbarItems) { final textDirectionItemIds = textDirectionItems.map((e) => e.id); // clear all the text direction items toolbarItems.removeWhere((item) => textDirectionItemIds.contains(item.id)); // only show the rtl item when the layout direction is ltr. - if (isRTL) { + if (enableRtlToolbarItems) { toolbarItems.addAll(textDirectionItems); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart index dd5dc93f5bc77..09906e1429d36 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart @@ -114,9 +114,12 @@ enum OptionAlignType { } enum OptionDepthType { - h1(1, "H1"), - h2(2, "H2"), - h3(3, "H3"); + h1(1, 'H1'), + h2(2, 'H2'), + h3(3, 'H3'), + h4(4, 'H4'), + h5(5, 'H5'), + h6(6, 'H6'); const OptionDepthType(this.level, this.description); @@ -357,9 +360,7 @@ class DepthOptionAction extends PopoverActionCell { (e) => HoverButton( onTap: () => onTap(e.inner), itemHeight: ActionListSizes.itemHeight, - leftIcon: null, name: e.name, - rightIcon: null, ), ) .toList(); 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 6535904a43f60..d7e819486715a 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 @@ -1,23 +1,22 @@ 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/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:dartz/dartz.dart' as dartz; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.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:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; - -import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; class BuiltInPageWidget extends StatefulWidget { const BuiltInPageWidget({ @@ -36,7 +35,8 @@ class BuiltInPageWidget extends StatefulWidget { } class _BuiltInPageWidgetState extends State { - late Future> future; + late Future> future; + final focusNode = FocusNode(); String get parentViewId => widget.node.attributes[DatabaseBlockKeys.parentID]; @@ -45,14 +45,10 @@ class _BuiltInPageWidgetState extends State { @override void initState() { super.initState(); - future = ViewBackendService() - .getChildView( - parentViewId: parentViewId, - childViewId: childViewId, - ) - .then( - (value) => value.swap(), - ); + future = ViewBackendService().getChildView( + parentViewId: parentViewId, + childViewId: childViewId, + ); } @override @@ -63,9 +59,9 @@ class _BuiltInPageWidgetState extends State { @override Widget build(BuildContext context) { - return FutureBuilder>( + return FutureBuilder>( builder: (context, snapshot) { - final page = snapshot.data?.toOption().toNullable(); + final page = snapshot.data?.toNullable(); if (snapshot.hasData && page != null) { return _build(context, page); } 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 721210d93d32c..9bcd0e18b83b5 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,5 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/database/application/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/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'; @@ -87,7 +87,7 @@ extension InsertDatabase on EditorState { // get the database id that the view is associated with final databaseId = await DatabaseViewBackendService(viewId: view.id) .getDatabaseId() - .then((value) => value.swap().toOption().toNullable()); + .then((value) => value.toNullable()); if (databaseId == null) { throw StateError( @@ -101,7 +101,7 @@ extension InsertDatabase on EditorState { name: "$prefix ${view.name}", layoutType: view.layout, databaseId: databaseId, - ).then((value) => value.swap().toOption().toNullable()); + ).then((value) => value.toNullable()); if (ref == null) { throw FlowyError( 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 bef7ca88d9bea..0dcb8dd3e6400 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 @@ -32,13 +32,13 @@ Future showLinkToPageMenu( customTitle: titleFromPageType(pageType), insertPage: pageType != ViewLayoutPB.Document, limitResults: 15, - ).inlinePageReferenceDelegate, + ), ], ); final List initialResults = []; for (final handler in service.handlers) { - final group = await handler(); + final group = await handler.search(null); if (group.results.isNotEmpty) { initialResults.add(group); 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 index f78b73bef7efd..42ee1a63d13d2 100644 --- 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 @@ -1,9 +1,10 @@ +import 'package:flutter/material.dart'; + 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 = '+'; @@ -89,7 +90,7 @@ Future inlinePageReferenceCommandHandler( InlinePageReferenceService( currentViewId: currentViewId, limitResults: 10, - ).inlinePageReferenceDelegate, + ), ], ); @@ -97,7 +98,7 @@ Future inlinePageReferenceCommandHandler( final List initialResults = []; for (final handler in service.handlers) { - final group = await handler(); + final group = await handler.search(null); if (group.results.isNotEmpty) { initialResults.add(group); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart new file mode 100644 index 0000000000000..120343a277da4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; + +class MenuBlockButton extends StatelessWidget { + const MenuBlockButton({ + super.key, + required this.tooltip, + required this.iconData, + this.onTap, + }); + + final VoidCallback? onTap; + final String tooltip; + final FlowySvgData iconData; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + onTap: onTap, + text: FlowyTooltip( + message: tooltip, + child: FlowySvg( + iconData, + size: const Size.square(16), + ), + ), + ); + } +} 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 1fc92c2d0fadf..34c6c8fe06eea 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 @@ -13,8 +13,8 @@ extension PasteNodes on EditorState { } final transaction = this.transaction; final insertedDelta = insertedNode.delta; - // if the node is empty, replace it with the inserted node. - if (delta.isEmpty) { + // if the node is empty and its type is paragprah, replace it with the inserted node. + if (delta.isEmpty && node.type == ParagraphBlockKeys.type) { transaction.insertNode( selection.end.path.next, insertedNode, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart index 5d0919b18cc26..602852e23834e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart @@ -1,12 +1,18 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/doc_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; extension PasteFromImage on EditorState { static final supportedImageFormats = [ @@ -20,11 +26,20 @@ extension PasteFromImage on EditorState { return false; } + final context = document.root.context; + + if (context == null) { + return false; + } + + final isLocalMode = context.read().isLocalMode; + final path = await getIt().getPath(); final imagePath = p.join( path, 'images', ); + try { // create the directory if not exists final directory = Directory(imagePath); @@ -33,14 +48,52 @@ extension PasteFromImage on EditorState { } final copyToPath = p.join( imagePath, - '${uuid()}.$format', + 'tmp_${uuid()}.$format', ); await File(copyToPath).writeAsBytes(imageBytes); - await insertImageNode(copyToPath); + final String? path; + + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_imageIsUploading.tr(), + ); + } + + if (isLocalMode) { + path = await saveImageToLocalStorage(copyToPath); + } else { + final result = await saveImageToCloudStorage(copyToPath); + + final errorMessage = result.$2; + + if (errorMessage != null && context.mounted) { + showSnackBarMessage( + context, + errorMessage, + ); + return false; + } + + path = result.$1; + } + + if (path != null) { + await insertImageNode(path); + } + + await File(copyToPath).delete(); return true; } catch (e) { Log.error('cannot copy image file', e); + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } } + return false; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart index 7f31309fc6831..114dde62a3759 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart @@ -1,10 +1,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -RegExp _hrefRegex = RegExp( - r'https?://(?:www\.)?[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(?:/[^\s]*)?', -); - extension PasteFromPlainText on EditorState { Future pastePlainText(String plainText) async { if (await pasteHtmlIfAvailable(plainText)) { @@ -23,7 +20,7 @@ extension PasteFromPlainText on EditorState { .map((e) { // parse the url content final Attributes attributes = {}; - if (_hrefRegex.hasMatch(e)) { + if (hrefRegex.hasMatch(e)) { attributes[AppFlowyRichTextKeys.href] = e; } return Delta()..insert(e, attributes: attributes); @@ -45,7 +42,7 @@ extension PasteFromPlainText on EditorState { if (selection == null || !selection.isSingle || selection.isCollapsed || - !_hrefRegex.hasMatch(plainText)) { + !hrefRegex.hasMatch(plainText)) { return false; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart index 989abcc3ec889..ff5df802d1b56 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart @@ -25,7 +25,7 @@ SelectionMenuItem inlineGridMenuItem(DocumentBloc documentBloc) => name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Grid, ); - value.swap().map((r) => editorState.insertInlinePage(parentViewId, r)); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, ); @@ -46,7 +46,7 @@ SelectionMenuItem inlineBoardMenuItem(DocumentBloc documentBloc) => name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Board, ); - value.swap().map((r) => editorState.insertInlinePage(parentViewId, r)); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, ); @@ -68,6 +68,6 @@ SelectionMenuItem inlineCalendarMenuItem(DocumentBloc documentBloc) => name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Calendar, ); - value.swap().map((r) => editorState.insertInlinePage(parentViewId, r)); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, ); 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 09b0ac3beccf2..560661d1577af 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 @@ -1,18 +1,18 @@ import 'dart:io'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class CoverImagePicker extends StatefulWidget { const CoverImagePicker({ @@ -37,12 +37,13 @@ class _CoverImagePickerState extends State { child: BlocListener( listener: (context, state) { if (state is NetworkImagePicked) { - state.successOrFail.isRight() - ? showSnapBar( - context, - LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), - ) - : null; + state.successOrFail.fold( + (s) {}, + (e) => showSnapBar( + context, + LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), + ), + ); } if (state is Done) { state.successOrFail.fold( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart index 72147e49c2d4c..316f2ddd8f7ad 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart @@ -4,7 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -36,11 +36,15 @@ class CoverImagePickerBloc emit(const CoverImagePickerState.loading()); final validateImage = await _validateURL(urlSubmit.path); if (validateImage) { - emit(CoverImagePickerState.networkImage(left(urlSubmit.path))); + emit( + CoverImagePickerState.networkImage( + FlowyResult.success(urlSubmit.path), + ), + ); } else { emit( CoverImagePickerState.networkImage( - right( + FlowyResult.failure( FlowyError( msg: LocaleKeys.document_plugins_cover_couldNotFetchImage .tr(), @@ -65,11 +69,11 @@ class CoverImagePickerBloc emit(const CoverImagePickerState.loading()); final saveImage = await _saveToGallery(saveToGallery.previousState); if (saveImage != null) { - emit(CoverImagePickerState.done(left(saveImage))); + emit(CoverImagePickerState.done(FlowyResult.success(saveImage))); } else { emit( CoverImagePickerState.done( - right( + FlowyResult.failure( FlowyError( msg: LocaleKeys.document_plugins_cover_imageSavingFailed .tr(), @@ -208,11 +212,11 @@ class CoverImagePickerState with _$CoverImagePickerState { const factory CoverImagePickerState.initial() = Initial; const factory CoverImagePickerState.loading() = Loading; const factory CoverImagePickerState.networkImage( - Either successOrFail, + FlowyResult successOrFail, ) = NetworkImagePicked; const factory CoverImagePickerState.fileImage(String path) = FileImagePicked; const factory CoverImagePickerState.done( - Either, FlowyError> successOrFail, + FlowyResult, FlowyError> successOrFail, ) = Done; } 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 784a7f246c184..9ca7f9ec8ce3e 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 @@ -7,13 +7,13 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.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/shared/appflowy_network_image.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -177,6 +177,7 @@ class _DocumentHeaderNodeWidgetState extends State { DocumentHeaderBlockKeys.coverDetails: coverDetails, DocumentHeaderBlockKeys.icon: widget.node.attributes[DocumentHeaderBlockKeys.icon], + CustomImageBlockKeys.imageType: '1', }; if (cover != null) { attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString(); @@ -629,9 +630,7 @@ class DocumentCoverState extends State { } bool _isLocalMode() { - final userProfilePB = context.read().state.userProfilePB; - final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; - return type == AuthenticatorPB.Local; + return context.read().isLocalMode; } } 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 34400597ac916..d34a4fc3a8fd0 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 @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; class EmbedImageUrlWidget extends StatefulWidget { const EmbedImageUrlWidget({ @@ -16,6 +18,7 @@ class EmbedImageUrlWidget extends StatefulWidget { } class _EmbedImageUrlWidgetState extends State { + bool isUrlValid = true; String inputText = ''; @override @@ -25,8 +28,15 @@ class _EmbedImageUrlWidgetState extends State { FlowyTextField( hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(), onChanged: (value) => inputText = value, - onEditingComplete: () => widget.onSubmit(inputText), + onEditingComplete: submit, ), + if (!isUrlValid) ...[ + const VSpace(8), + FlowyText( + LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), + color: Theme.of(context).colorScheme.error, + ), + ], const VSpace(8), SizedBox( width: 160, @@ -37,10 +47,20 @@ class _EmbedImageUrlWidgetState extends State { LocaleKeys.document_imageBlock_embedLink_label.tr(), textAlign: TextAlign.center, ), - onTap: () => widget.onSubmit(inputText), + onTap: submit, ), ), ], ); } + + void submit() { + if (checkUrlValidity(inputText)) { + return widget.onSubmit(inputText); + } + + setState(() => isUrlValid = false); + } + + bool checkUrlValidity(String url) => imgUrlRegex.hasMatch(url); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart index 96c6bec57a755..ca765bc0ed7d0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart @@ -1,13 +1,15 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.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/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.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:flowy_infra_ui/widget/ignore_parent_gesture.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -51,18 +53,23 @@ class _ImageMenuState extends State { const HSpace(4), // disable the copy link button if the image is hosted on appflowy cloud // because the url needs the verification token to be accessible - if (!(url?.isAppFlowyCloudUrl ?? false)) - _ImageCopyLinkButton( + if (!(url?.isAppFlowyCloudUrl ?? false)) ...[ + MenuBlockButton( + tooltip: LocaleKeys.editor_copyLink.tr(), + iconData: FlowySvgs.copy_s, onTap: copyImageLink, ), - const HSpace(4), + const HSpace(4), + ], _ImageAlignButton( node: widget.node, state: widget.state, ), const _Divider(), - _ImageDeleteButton( - onTap: () => deleteImage(), + MenuBlockButton( + tooltip: LocaleKeys.button_delete.tr(), + iconData: FlowySvgs.delete_s, + onTap: deleteImage, ), const HSpace(4), ], @@ -90,29 +97,6 @@ class _ImageMenuState extends State { } } -class _ImageCopyLinkButton extends StatelessWidget { - const _ImageCopyLinkButton({ - required this.onTap, - }); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return FlowyButton( - useIntrinsicWidth: true, - onTap: onTap, - text: FlowyTooltip( - message: LocaleKeys.editor_copyLink.tr(), - child: const FlowySvg( - FlowySvgs.copy_s, - size: Size.square(16), - ), - ), - ); - } -} - class _ImageAlignButton extends StatefulWidget { const _ImageAlignButton({ required this.node, @@ -134,7 +118,8 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { canTap: (details) => false, ); - String get align => widget.node.attributes['align'] ?? 'center'; + String get align => + widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey; final popoverController = PopoverController(); late final EditorState editorState; @@ -162,7 +147,10 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { margin: const EdgeInsets.all(0), direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 10), - child: buildAlignIcon(), + child: MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_align.tr(), + iconData: iconFor(align), + ), popupBuilder: (_) { preventMenuClose(); return _AlignButtons( @@ -201,28 +189,15 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { FlowySvgData iconFor(String alignment) { switch (alignment) { - case 'right': + case rightAlignmentKey: return FlowySvgs.align_right_s; - case 'center': + case centerAlignmentKey: return FlowySvgs.align_center_s; - case 'left': + case leftAlignmentKey: default: return FlowySvgs.align_left_s; } } - - Widget buildAlignIcon() { - return FlowyButton( - useIntrinsicWidth: true, - text: FlowyTooltip( - message: LocaleKeys.document_plugins_optionAction_align.tr(), - child: FlowySvg( - iconFor(align), - size: const Size.square(16), - ), - ), - ); - } } class _AlignButtons extends StatelessWidget { @@ -240,19 +215,22 @@ class _AlignButtons extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const HSpace(4), - _AlignButton( - icon: FlowySvgs.align_left_s, - onTap: () => onAlignChanged('left'), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_left, + iconData: FlowySvgs.align_left_s, + onTap: () => onAlignChanged(leftAlignmentKey), ), const _Divider(), - _AlignButton( - icon: FlowySvgs.align_center_s, - onTap: () => onAlignChanged('center'), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_center, + iconData: FlowySvgs.align_center_s, + onTap: () => onAlignChanged(centerAlignmentKey), ), const _Divider(), - _AlignButton( - icon: FlowySvgs.align_right_s, - onTap: () => onAlignChanged('right'), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_right, + iconData: FlowySvgs.align_right_s, + onTap: () => onAlignChanged(rightAlignmentKey), ), const HSpace(4), ], @@ -261,51 +239,6 @@ class _AlignButtons extends StatelessWidget { } } -class _AlignButton extends StatelessWidget { - const _AlignButton({ - required this.icon, - required this.onTap, - }); - - final FlowySvgData icon; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return FlowyButton( - useIntrinsicWidth: true, - onTap: onTap, - text: FlowySvg( - icon, - size: const Size.square(16), - ), - ); - } -} - -class _ImageDeleteButton extends StatelessWidget { - const _ImageDeleteButton({ - required this.onTap, - }); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return FlowyButton( - useIntrinsicWidth: true, - onTap: onTap, - text: FlowyTooltip( - message: LocaleKeys.button_delete.tr(), - child: const FlowySvg( - FlowySvgs.delete_s, - size: Size.square(16), - ), - ), - ); - } -} - class _Divider extends StatelessWidget { const _Divider(); 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 064580b75272d..f193f91617698 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 @@ -9,11 +9,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/cust import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/util/file_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -45,6 +43,7 @@ class ImagePlaceholderState extends State { late final editorState = context.read(); bool showLoading = false; + String? errorMessage; @override Widget build(BuildContext context) { @@ -67,19 +66,7 @@ class ImagePlaceholderState extends State { size: Size.square(24), ), const HSpace(10), - ...showLoading - ? [ - FlowyText( - LocaleKeys.document_imageBlock_imageIsUploading.tr(), - ), - const HSpace(8), - const CircularProgressIndicator.adaptive(), - ] - : [ - FlowyText( - LocaleKeys.document_plugins_image_addAnImage.tr(), - ), - ], + ..._buildTrailing(context), ], ), ), @@ -99,6 +86,13 @@ class ImagePlaceholderState extends State { popupBuilder: (context) { return UploadImageMenu( limitMaximumImageSize: !_isLocalMode(), + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + UploadImageType.openAI, + UploadImageType.stabilityAI, + ], onSelectedLocalImage: (path) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -136,6 +130,30 @@ class ImagePlaceholderState extends State { } } + List _buildTrailing(BuildContext context) { + if (errorMessage != null) { + return [ + FlowyText( + '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', + ), + ]; + } else if (showLoading) { + return [ + FlowyText( + LocaleKeys.document_imageBlock_imageIsUploading.tr(), + ), + const HSpace(8), + const CircularProgressIndicator.adaptive(), + ]; + } else { + return [ + FlowyText( + LocaleKeys.document_plugins_image_addAnImage.tr(), + ), + ]; + } + } + void showUploadImageMenu() { if (PlatformExtension.isDesktopOrWeb) { controller.show(); @@ -181,19 +199,9 @@ class ImagePlaceholderState extends State { } Future insertLocalImage(String? url) async { - if (url == null || url.isEmpty) { - controller.close(); - return; - } + controller.close(); - final size = url.fileSize; - if (size == null || size > 10 * 1024 * 1024) { - // show error - controller.close(); - showSnackBarMessage( - context, - LocaleKeys.document_imageBlock_uploadImageErrorImageSizeTooBig.tr(), - ); + if (url == null || url.isEmpty) { return; } @@ -205,15 +213,18 @@ class ImagePlaceholderState extends State { // if the user is using local authenticator, we need to save the image to local storage if (_isLocalMode()) { + // don't limit the image size for local mode. path = await saveImageToLocalStorage(url); } else { // else we should save the image to cloud storage setState(() { showLoading = true; + this.errorMessage = null; }); (path, errorMessage) = await saveImageToCloudStorage(url); setState(() { showLoading = false; + this.errorMessage = errorMessage; }); imageType = CustomImageType.internal; } @@ -225,6 +236,9 @@ class ImagePlaceholderState extends State { ? LocaleKeys.document_imageBlock_error_invalidImage.tr() : ': $errorMessage', ); + setState(() { + this.errorMessage = errorMessage; + }); return; } @@ -290,8 +304,6 @@ class ImagePlaceholderState extends State { } bool _isLocalMode() { - final userProfilePB = context.read().state.userProfilePB; - final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; - return type == AuthenticatorPB.Local; + return context.read().isLocalMode; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart index d6a31bb7eaab8..352a6c878ee25 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart @@ -1,10 +1,13 @@ import 'dart:io'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/file_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:path/path.dart' as p; @@ -37,19 +40,27 @@ Future saveImageToLocalStorage(String localImagePath) async { Future<(String? path, String? errorMessage)> saveImageToCloudStorage( String localImagePath, ) async { + final size = localImagePath.fileSize; + if (size == null || size > 10 * 1024 * 1024) { + // 10MB + return ( + null, + LocaleKeys.document_imageBlock_uploadImageErrorImageSizeTooBig.tr(), + ); + } final documentService = DocumentService(); final result = await documentService.uploadFile( localFilePath: localImagePath, isAsync: false, ); return result.fold( - (l) => (null, l.msg), - (r) async { + (s) async { await CustomImageCacheManager().putFile( - r.url, + s.url, File(localImagePath).readAsBytesSync(), ); - return (r.url, null); + return (s.url, null); }, + (e) => (null, e.msg), ); } 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 775b6f273dc12..9cd6320518071 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 @@ -4,7 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:dartz/dartz.dart' hide State; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -22,7 +22,7 @@ class OpenAIImageWidget extends StatefulWidget { } class _OpenAIImageWidgetState extends State { - Future>>? future; + Future, OpenAIError>>? future; String query = ''; @override @@ -63,19 +63,12 @@ class _OpenAIImageWidgetState extends State { return const CircularProgressIndicator.adaptive(); } return data.fold( - (l) => Center( - child: FlowyText( - l.message, - maxLines: 3, - textAlign: TextAlign.center, - ), - ), - (r) => GridView.count( + (s) => GridView.count( crossAxisCount: 3, mainAxisSpacing: 16.0, crossAxisSpacing: 10.0, childAspectRatio: 4 / 3, - children: r + children: s .map( (e) => GestureDetector( onTap: () => widget.onSelectNetworkImage(e), @@ -84,6 +77,13 @@ class _OpenAIImageWidgetState extends State { ) .toList(), ), + (e) => Center( + child: FlowyText( + e.message, + maxLines: 3, + textAlign: TextAlign.center, + ), + ), ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart index 5038e781a0309..055ae17605137 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart @@ -59,9 +59,7 @@ class _ResizableImageState extends State { imageWidth = widget.width; - if (widget.type == CustomImageType.internal) { - _userProfilePB = context.read().state.userProfilePB; - } + _userProfilePB = context.read().state.userProfilePB; } @override @@ -93,9 +91,9 @@ class _ResizableImageState extends State { return _buildLoading(context); } - _cacheImage ??= FlowyNetworkImage( + _cacheImage = FlowyNetworkImage( url: widget.src, - width: widget.width, + width: imageWidth - moveDistance, userProfilePB: _userProfilePB, errorWidgetBuilder: (context, url, error) => _buildError(context, error), 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 d1764a76aca01..0d5d986d10b2a 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 @@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:dartz/dartz.dart' hide State; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -27,7 +27,7 @@ class StabilityAIImageWidget extends StatefulWidget { } class _StabilityAIImageWidgetState extends State { - Future>>? future; + Future, StabilityAIRequestError>>? future; String query = ''; @override @@ -70,19 +70,12 @@ class _StabilityAIImageWidgetState extends State { return const CircularProgressIndicator.adaptive(); } return data.fold( - (l) => Center( - child: FlowyText( - l.message, - maxLines: 3, - textAlign: TextAlign.center, - ), - ), - (r) => GridView.count( + (s) => GridView.count( crossAxisCount: 3, mainAxisSpacing: 16.0, crossAxisSpacing: 10.0, childAspectRatio: 4 / 3, - children: r.map( + children: s.map( (e) { final base64Image = base64Decode(e); return GestureDetector( @@ -100,6 +93,13 @@ class _StabilityAIImageWidgetState extends State { }, ).toList(), ), + (e) => Center( + child: FlowyText( + e.message, + maxLines: 3, + textAlign: TextAlign.center, + ), + ), ); }, ), 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 fab96db524c57..4c9de6b07d404 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 @@ -76,12 +76,12 @@ class _UploadImageMenuState extends State { UserBackendService.getCurrentUserProfile().then( (value) { final supportOpenAI = value.fold( - (l) => false, - (r) => r.openaiKey.isNotEmpty, + (s) => s.openaiKey.isNotEmpty, + (e) => false, ); final supportStabilityAI = value.fold( - (l) => false, - (r) => r.stabilityAiKey.isNotEmpty, + (s) => s.stabilityAiKey.isNotEmpty, + (e) => false, ); if (supportOpenAI != this.supportOpenAI || supportStabilityAI != this.supportStabilityAI) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index b57226c442460..152e7ed20aa9e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -1,17 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +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/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/shared/appflowy_network_image.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:url_launcher/url_launcher_string.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; class CustomLinkPreviewWidget extends StatelessWidget { const CustomLinkPreviewWidget({ super.key, + required this.node, required this.url, this.title, this.description, this.imageUrl, }); + final Node node; final String? title; final String? description; final String? imageUrl; @@ -19,20 +31,29 @@ class CustomLinkPreviewWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: () => launchUrlString(url), - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.onSurface, - ), - borderRadius: BorderRadius.circular( - 6.0, - ), + final documentFontSize = context + .read() + .editorStyle + .textStyleConfiguration + .text + .fontSize ?? + 16.0; + final (fontSize, width) = PlatformExtension.isDesktopOrWeb + ? (documentFontSize, 180.0) + : (documentFontSize - 2, 120.0); + final Widget child = Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.onSurface, + ), + borderRadius: BorderRadius.circular( + 6.0, ), + ), + child: IntrinsicHeight( child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (imageUrl != null) ClipRRect( @@ -42,8 +63,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), child: FlowyNetworkImage( url: imageUrl!, - width: 180, - height: 120, + width: width, ), ), Expanded( @@ -55,12 +75,15 @@ class CustomLinkPreviewWidget extends StatelessWidget { children: [ if (title != null) Padding( - padding: const EdgeInsets.only(bottom: 4.0), + padding: const EdgeInsets.only( + bottom: 4.0, + right: 10.0, + ), child: FlowyText.medium( title!, maxLines: 2, overflow: TextOverflow.ellipsis, - fontSize: 16.0, + fontSize: fontSize, ), ), if (description != null) @@ -70,6 +93,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { description!, maxLines: 2, overflow: TextOverflow.ellipsis, + fontSize: fontSize - 4, ), ), FlowyText( @@ -77,6 +101,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, maxLines: 2, color: Theme.of(context).hintColor, + fontSize: fontSize - 4, ), ], ), @@ -86,5 +111,40 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), ), ); + + if (PlatformExtension.isDesktopOrWeb) { + return InkWell( + onTap: () => afLaunchUrlString(url), + child: child, + ); + } + + return MobileBlockActionButtons( + node: node, + editorState: context.read(), + extendActionWidgets: _buildExtendActionWidgets(context), + child: child, + ); + } + + // only used on mobile platform + List _buildExtendActionWidgets(BuildContext context) { + return [ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_aa_link_s, + size: Size.square(20), + ), + onTap: () { + context.pop(); + convertUrlPreviewNodeToLink( + context.read(), + node, + ); + }, + ), + ]; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart index 51454f5c1414b..6688cfe304ef3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart @@ -12,7 +12,7 @@ class LinkPreviewDataCache implements LinkPreviewDataCacheInterface { url, (value) => LinkPreviewData.fromJson(jsonDecode(value)), ); - return option.fold(() => null, (a) => a); + return option; } @override diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index 5de45bee31bef..a83fbed58946e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -1,5 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; @@ -43,11 +45,24 @@ class _LinkPreviewMenuState extends State { child: Row( children: [ const HSpace(4), - _CopyLinkButton( + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), + iconData: FlowySvgs.m_aa_link_s, + onTap: () => convertUrlPreviewNodeToLink( + context.read(), + widget.node, + ), + ), + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.editor_copyLink.tr(), + iconData: FlowySvgs.copy_s, onTap: copyImageLink, ), const _Divider(), - _DeleteButton( + MenuBlockButton( + tooltip: LocaleKeys.button_delete.tr(), + iconData: FlowySvgs.delete_s, onTap: deleteLinkPreviewNode, ), const HSpace(4), @@ -77,44 +92,6 @@ class _LinkPreviewMenuState extends State { } } -class _CopyLinkButton extends StatelessWidget { - const _CopyLinkButton({ - required this.onTap, - }); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: const FlowySvg( - FlowySvgs.copy_s, - size: Size.square(16), - ), - ); - } -} - -class _DeleteButton extends StatelessWidget { - const _DeleteButton({ - required this.onTap, - }); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: const FlowySvg( - FlowySvgs.delete_s, - size: Size.square(16), - ), - ); - } -} - class _Divider extends StatelessWidget { const _Divider(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart new file mode 100644 index 0000000000000..11dae6075daee --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +void convertUrlPreviewNodeToLink(EditorState editorState, Node node) { + assert(node.type == LinkPreviewBlockKeys.type); + final url = node.attributes[ImageBlockKeys.url]; + final transaction = editorState.transaction; + transaction + ..insertNode(node.path, paragraphNode(text: url)) + ..deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: url.length, + ), + ); + editorState.apply(transaction); +} 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 0fa144b02ed3c..5b9bfda88f54f 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 @@ -222,6 +222,7 @@ class MathEquationBlockComponentWidgetState actions: [ SecondaryTextButton( LocaleKeys.button_cancel.tr(), + mode: TextButtonMode.big, onPressed: () => dismiss(context), ), PrimaryTextButton( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart index 18afae962e808..00793adb4b213 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; enum MentionType { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart index e66bdb136355d..43633f9780a80 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -5,8 +5,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/document/application/doc_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index 8d6457c65084f..fc32143ff93ee 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -126,7 +126,7 @@ class _MentionPageBlockState extends State { Future fetchView(String pageId) async { final view = await ViewBackendService.getView(pageId).then( - (value) => value.swap().toOption().toNullable(), + (value) => value.toNullable(), ); if (view == null) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart index 0cdf04a7d005b..f2e3d4d4271b3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart @@ -141,7 +141,7 @@ class EditorMigration { } const backgroundColor = 'backgroundColor'; if (attributes.containsKey(backgroundColor)) { - attributes[AppFlowyRichTextKeys.highlightColor] = + attributes[AppFlowyRichTextKeys.backgroundColor] = attributes[backgroundColor]; attributes.remove(backgroundColor); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart new file mode 100644 index 0000000000000..5a8b99af5d0c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart @@ -0,0 +1,84 @@ +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'; + +List buildMobileFloatingToolbarItems( + EditorState editorState, + Offset offset, + Function closeToolbar, +) { + // copy, paste, select, select all, cut + final selection = editorState.selection; + if (selection == null) { + return []; + } + final toolbarItems = []; + + if (!selection.isCollapsed) { + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_copy.tr(), + onPressed: () { + copyCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + } + + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_paste.tr(), + onPressed: () { + pasteCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + + if (!selection.isCollapsed) { + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_cut.tr(), + onPressed: () { + cutCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + } + + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_select.tr(), + onPressed: () { + editorState.selectWord(offset); + closeToolbar(); + }, + ), + ); + + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_selectAll.tr(), + onPressed: () { + selectAllCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + + return toolbarItems; +} + +extension on EditorState { + void selectWord(Offset offset) { + final node = service.selectionService.getNodeInOffset(offset); + final selection = node?.selectable?.getWordBoundaryInOffset(offset); + if (selection == null) { + return; + } + updateSelectionWithReason(selection); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart index 699bce2f737d4..21f3b7ea68bd4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart @@ -11,6 +11,9 @@ Future showEditLinkBottomSheet( return showMobileBottomSheet( context, showHeader: false, + showCloseButton: false, + showDragHandle: true, + padding: const EdgeInsets.symmetric(horizontal: 16), builder: (context) { return MobileBottomSheetEditLinkWidget( text: text, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart new file mode 100644 index 0000000000000..b60eae3006895 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart @@ -0,0 +1,28 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension SelectionColor on EditorState { + String? getSelectionColor(String key) { + final selection = this.selection; + if (selection == null) { + return null; + } + String? color = toggledStyle[key]; + if (color == null) { + if (selection.isCollapsed && selection.startIndex != 0) { + color = getDeltaAttributeValueInSelection( + key, + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + ); + } else { + color = getDeltaAttributeValueInSelection( + key, + ); + } + } + return color; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart similarity index 92% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart index 22b10288714ac..dccff22664967 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart @@ -1,7 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; @@ -20,9 +20,9 @@ class AlignItems extends StatelessWidget { final EditorState editorState; final List<(String, FlowySvgData)> _alignMenuItems = [ - (_left, FlowySvgs.m_aa_align_left_s), - (_center, FlowySvgs.m_aa_align_center_s), - (_right, FlowySvgs.m_aa_align_right_s), + (_left, FlowySvgs.m_aa_align_left_m), + (_center, FlowySvgs.m_aa_align_center_m), + (_right, FlowySvgs.m_aa_align_right_m), ]; @override diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_bius_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart similarity index 85% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_bius_items.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart index 4a2c7220ce902..0de86ffd6c026 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_bius_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; @@ -14,10 +14,10 @@ class BIUSItems extends StatelessWidget { final EditorState editorState; final List<(FlowySvgData, String)> _bius = [ - (FlowySvgs.m_aa_bold_s, AppFlowyRichTextKeys.bold), - (FlowySvgs.m_aa_italic_s, AppFlowyRichTextKeys.italic), - (FlowySvgs.m_aa_underline_s, AppFlowyRichTextKeys.underline), - (FlowySvgs.m_aa_strike_s, AppFlowyRichTextKeys.strikethrough), + (FlowySvgs.m_toolbar_bold_m, AppFlowyRichTextKeys.bold), + (FlowySvgs.m_toolbar_italic_m, AppFlowyRichTextKeys.italic), + (FlowySvgs.m_toolbar_underline_m, AppFlowyRichTextKeys.underline), + (FlowySvgs.m_toolbar_strike_m, AppFlowyRichTextKeys.strikethrough), ]; @override @@ -71,7 +71,8 @@ class BIUSItems extends StatelessWidget { setState(() {}); }, icon: icon, - isSelected: editorState.isTextDecorationSelected(richTextKey), + isSelected: editorState.isTextDecorationSelected(richTextKey) && + editorState.toggledStyle[richTextKey] != false, iconPadding: const EdgeInsets.symmetric( vertical: 14.0, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart similarity index 84% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart index 3b11c078d1fbe..57670afadd768 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; @@ -23,9 +23,9 @@ class BlockItems extends StatelessWidget { final AppFlowyMobileToolbarWidgetService service; final List<(FlowySvgData, String)> _blockItems = [ - (FlowySvgs.m_aa_bulleted_list_s, BulletedListBlockKeys.type), - (FlowySvgs.m_aa_numbered_list_s, NumberedListBlockKeys.type), - (FlowySvgs.m_aa_quote_s, QuoteBlockKeys.type), + (FlowySvgs.m_toolbar_bulleted_list_m, BulletedListBlockKeys.type), + (FlowySvgs.m_toolbar_numbered_list_m, NumberedListBlockKeys.type), + (FlowySvgs.m_aa_quote_m, QuoteBlockKeys.type), ]; @override @@ -68,7 +68,7 @@ class BlockItems extends StatelessWidget { enableTopRightRadius: false, enableBottomRightRadius: false, onTap: () async { - await editorState.convertBlockType(blockType); + await _convert(blockType); }, backgroundColor: theme.toolbarMenuItemBackgroundColor, icon: icon, @@ -82,7 +82,7 @@ class BlockItems extends StatelessWidget { Widget _buildLinkItem(BuildContext context) { final theme = ToolbarColorExtension.of(context); final items = [ - (AppFlowyRichTextKeys.code, FlowySvgs.m_aa_code_s), + (AppFlowyRichTextKeys.code, FlowySvgs.m_aa_code_m), // (InlineMathEquationKeys.formula, FlowySvgs.m_aa_math_s), ]; return PopupMenu( @@ -119,7 +119,7 @@ class BlockItems extends StatelessWidget { showDownArrow: true, onTap: _onLinkItemTap, backgroundColor: theme.toolbarMenuItemBackgroundColor, - icon: FlowySvgs.m_aa_link_s, + icon: FlowySvgs.m_toolbar_link_m, isSelected: false, iconPadding: const EdgeInsets.symmetric( vertical: 14.0, @@ -196,4 +196,23 @@ class BlockItems extends StatelessWidget { ); editorState.service.keyboardService?.closeKeyboard(); } + + Future _convert(String blockType) async { + await editorState.convertBlockType( + blockType, + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + unawaited( + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_close_keyboard_or_menu_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart similarity index 56% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_close_keyboard_or_menu_button.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart index c62f110c509e6..4c91a00bc78dd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_close_keyboard_or_menu_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart @@ -5,27 +5,20 @@ import 'package:flutter/material.dart'; class CloseKeyboardOrMenuButton extends StatelessWidget { const CloseKeyboardOrMenuButton({ super.key, - required this.showingMenu, required this.onPressed, }); - final bool showingMenu; final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( width: 62, - height: 46, + height: 42, child: FlowyButton( - margin: showingMenu ? const EdgeInsets.only(right: 0.5) : null, - text: showingMenu - ? const FlowySvg( - FlowySvgs.m_toolbar_show_keyboard_s, - ) - : const FlowySvg( - FlowySvgs.m_toolbar_hide_keyboard_s, - ), + text: const FlowySvg( + FlowySvgs.m_toolbar_keyboard_m, + ), onTap: onPressed, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart similarity index 66% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_item.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart index e519d520dcbea..c5f4b77d62cf7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart @@ -1,8 +1,9 @@ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_list.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,11 @@ class ColorItem extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); + final String? selectedTextColor = + editorState.getSelectionColor(AppFlowyRichTextKeys.textColor); + final String? selectedBackgroundColor = + editorState.getSelectionColor(AppFlowyRichTextKeys.backgroundColor); + return MobileToolbarMenuItemWrapper( size: const Size(82, 52), onTap: () async { @@ -41,11 +47,13 @@ class ColorItem extends StatelessWidget { selection: editorState.selection!, ); }, - icon: FlowySvgs.m_aa_color_s, - backgroundColor: theme.toolbarMenuItemBackgroundColor, - isSelected: false, + icon: FlowySvgs.m_aa_font_color_m, + iconColor: selectedTextColor?.tryToColor(), + backgroundColor: selectedBackgroundColor?.tryToColor() ?? + theme.toolbarMenuItemBackgroundColor, + selectedBackgroundColor: selectedBackgroundColor?.tryToColor(), + isSelected: selectedBackgroundColor != null, showRightArrow: true, - enable: editorState.selection?.isCollapsed == false, iconPadding: const EdgeInsets.only( top: 14.0, bottom: 14.0, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_list.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart similarity index 77% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_list.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart index eb8b7ec0615a9..6b03ff6301c3e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart @@ -1,7 +1,8 @@ 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/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -9,6 +10,8 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +const _count = 6; + Future showTextColorAndBackgroundColorPicker( BuildContext context, { required EditorState editorState, @@ -26,7 +29,7 @@ Future showTextColorAndBackgroundColorPicker( backgroundColor: theme.toolbarMenuBackgroundColor, elevation: 20, title: LocaleKeys.grid_selectOption_colorPanelTitle.tr(), - padding: const EdgeInsets.fromLTRB(18, 4, 18, 8), + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), builder: (context) { return _TextColorAndBackgroundColor( editorState: editorState, @@ -64,15 +67,9 @@ class _TextColorAndBackgroundColorState @override Widget build(BuildContext context) { final String? selectedTextColor = - widget.editorState.getDeltaAttributeValueInSelection( - AppFlowyRichTextKeys.textColor, - widget.selection, - ); - final String? selectedBackgroundColor = - widget.editorState.getDeltaAttributeValueInSelection( - AppFlowyRichTextKeys.highlightColor, - widget.selection, - ); + widget.editorState.getSelectionColor(AppFlowyRichTextKeys.textColor); + final String? selectedBackgroundColor = widget.editorState + .getSelectionColor(AppFlowyRichTextKeys.backgroundColor); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -86,21 +83,30 @@ class _TextColorAndBackgroundColorState fontSize: 14.0, ), ), + const VSpace(6.0), _TextColors( selectedColor: selectedTextColor?.tryToColor(), onSelectedColor: (textColor) async { final hex = textColor.alpha == 0 ? null : textColor.toHex(); - await widget.editorState.formatDelta( - widget.selection, - { - AppFlowyRichTextKeys.textColor: hex, - }, - selectionExtraInfo: { - selectionExtraInfoDisableFloatingToolbar: true, - selectionExtraInfoDisableMobileToolbarKey: true, - selectionExtraInfoDoNotAttachTextService: true, - }, - ); + final selection = widget.selection; + if (selection.isCollapsed) { + widget.editorState.updateToggledStyle( + AppFlowyRichTextKeys.textColor, + hex ?? '', + ); + } else { + await widget.editorState.formatDelta( + widget.selection, + { + AppFlowyRichTextKeys.textColor: hex, + }, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + } setState(() {}); }, ), @@ -114,22 +120,31 @@ class _TextColorAndBackgroundColorState fontSize: 14.0, ), ), + const VSpace(6.0), _BackgroundColors( selectedColor: selectedBackgroundColor?.tryToColor(), onSelectedColor: (backgroundColor) async { final hex = backgroundColor.alpha == 0 ? null : backgroundColor.toHex(); - await widget.editorState.formatDelta( - widget.selection, - { - AppFlowyRichTextKeys.highlightColor: hex, - }, - selectionExtraInfo: { - selectionExtraInfoDisableFloatingToolbar: true, - selectionExtraInfoDisableMobileToolbarKey: true, - selectionExtraInfoDoNotAttachTextService: true, - }, - ); + final selection = widget.selection; + if (selection.isCollapsed) { + widget.editorState.updateToggledStyle( + AppFlowyRichTextKeys.backgroundColor, + hex ?? '', + ); + } else { + await widget.editorState.formatDelta( + widget.selection, + { + AppFlowyRichTextKeys.backgroundColor: hex, + }, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + } setState(() {}); }, ), @@ -171,7 +186,7 @@ class _BackgroundColors extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.count( - crossAxisCount: 6, + crossAxisCount: _count, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), children: colors.mapIndexed( @@ -205,9 +220,7 @@ class _BackgroundColorItem extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - margin: const EdgeInsets.all( - 6.0, - ), + margin: const EdgeInsets.all(6.0), decoration: BoxDecoration( color: color, borderRadius: Corners.s12Border, @@ -252,7 +265,7 @@ class _TextColors extends StatelessWidget { @override Widget build(BuildContext context) { return GridView.count( - crossAxisCount: 6, + crossAxisCount: _count, shrinkWrap: true, padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), @@ -286,9 +299,7 @@ class _TextColorItem extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - margin: const EdgeInsets.all( - 6.0, - ), + margin: const EdgeInsets.all(6.0), decoration: BoxDecoration( borderRadius: Corners.s12Border, border: Border.all( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_font_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart similarity index 59% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_font_item.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart index ec9216f807cdd..0265ea2c02479 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_font_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/util/google_font_family_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -22,15 +22,16 @@ class FontFamilyItem extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); - final fontFamily = editorState.getDeltaAttributeValueInSelection( - AppFlowyRichTextKeys.fontFamily, - ); + final fontFamily = _getCurrentSelectedFontFamilyName(); final systemFonFamily = context.read().state.fontFamily; return MobileToolbarMenuItemWrapper( size: const Size(144, 52), onTap: () async { final selection = editorState.selection; + if (selection == null) { + return; + } // disable the floating toolbar unawaited( editorState.updateSelectionWithReason( @@ -45,12 +46,17 @@ class FontFamilyItem extends StatelessWidget { final newFont = await context .read() .push(FontPickerScreen.routeName); - if (newFont != null && newFont != fontFamily) { - await editorState.formatDelta(selection, { - AppFlowyRichTextKeys.fontFamily: - GoogleFonts.getFont(newFont).fontFamily, - }); + + // if the selection is not collapsed, apply the font to the selection. + if (newFont != null && !selection.isCollapsed) { + if (newFont != fontFamily) { + await editorState.formatDelta(selection, { + AppFlowyRichTextKeys.fontFamily: + GoogleFonts.getFont(newFont).fontFamily, + }); + } } + // wait for the font picker screen to be dismissed. Future.delayed(const Duration(milliseconds: 250), () { // highlight the selected text again. @@ -61,13 +67,20 @@ class FontFamilyItem extends StatelessWidget { selectionExtraInfoDisableMobileToolbarKey: false, }, ); + // if the selection is collapsed, save the font for the next typing. + if (newFont != null && selection.isCollapsed) { + editorState.updateToggledStyle( + AppFlowyRichTextKeys.fontFamily, + GoogleFonts.getFont(newFont).fontFamily, + ); + } }); }, text: (fontFamily ?? systemFonFamily).parseFontFamilyName(), fontFamily: fontFamily ?? systemFonFamily, backgroundColor: theme.toolbarMenuItemBackgroundColor, isSelected: false, - enable: editorState.selection?.isCollapsed == false, + enable: true, showRightArrow: true, iconPadding: const EdgeInsets.only( top: 14.0, @@ -80,4 +93,28 @@ class FontFamilyItem extends StatelessWidget { ), ); } + + String? _getCurrentSelectedFontFamilyName() { + final toggleFontFamily = + editorState.toggledStyle[AppFlowyRichTextKeys.fontFamily]; + if (toggleFontFamily is String && toggleFontFamily.isNotEmpty) { + return toggleFontFamily; + } + final selection = editorState.selection; + if (selection != null && + selection.isCollapsed && + selection.startIndex != 0) { + return editorState.getDeltaAttributeValueInSelection( + AppFlowyRichTextKeys.fontFamily, + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + ); + } + return editorState.getDeltaAttributeValueInSelection( + AppFlowyRichTextKeys.fontFamily, + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_heading_and_text_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart similarity index 94% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_heading_and_text_items.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart index bce94a397caf8..b98a6fddffb3e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_heading_and_text_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart @@ -19,25 +19,25 @@ class HeadingsAndTextItems extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _HeadingOrTextItem( - icon: FlowySvgs.m_aa_h1_s, + icon: FlowySvgs.m_aa_h1_m, blockType: HeadingBlockKeys.type, editorState: editorState, level: 1, ), _HeadingOrTextItem( - icon: FlowySvgs.m_aa_h2_s, + icon: FlowySvgs.m_aa_h2_m, blockType: HeadingBlockKeys.type, editorState: editorState, level: 2, ), _HeadingOrTextItem( - icon: FlowySvgs.m_aa_h3_s, + icon: FlowySvgs.m_aa_h3_m, blockType: HeadingBlockKeys.type, editorState: editorState, level: 3, ), _HeadingOrTextItem( - icon: FlowySvgs.m_aa_text_s, + icon: FlowySvgs.m_aa_paragraph_m, blockType: ParagraphBlockKeys.type, editorState: editorState, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_indent_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart similarity index 92% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_indent_items.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart index 0188ceef2eab9..2ddcd4dacbfb7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_indent_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -22,7 +22,7 @@ class IndentAndOutdentItems extends StatelessWidget { children: [ MobileToolbarMenuItemWrapper( size: const Size(95, 52), - icon: FlowySvgs.m_aa_outdent_s, + icon: FlowySvgs.m_aa_outdent_m, enable: isOutdentable(editorState), isSelected: false, enableTopRightRadius: false, @@ -37,7 +37,7 @@ class IndentAndOutdentItems extends StatelessWidget { const ScaledVerticalDivider(), MobileToolbarMenuItemWrapper( size: const Size(95, 52), - icon: FlowySvgs.m_aa_indent_s, + icon: FlowySvgs.m_aa_indent_m, enable: isIndentable(editorState), isSelected: false, enableTopLeftRadius: false, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart similarity index 97% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart index 156f743b12374..7464514f9345e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart similarity index 98% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart index 6eb948fa1bdcb..d678d7c0bacb8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart similarity index 98% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart index 6bc3d401af82d..d35b8d56dffba 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; class ToolbarColorExtension extends ThemeExtension { factory ToolbarColorExtension.light() => const ToolbarColorExtension( - toolbarBackgroundColor: Color(0xFFF3F3F8), + toolbarBackgroundColor: Color(0xFFFFFFFF), toolbarItemIconColor: Color(0xFF1F2329), toolbarItemIconDisabledColor: Color(0xFF999BA0), toolbarItemIconSelectedColor: Color(0x1F232914), - toolbarItemSelectedBackgroundColor: Color(0x1F232914), + toolbarItemSelectedBackgroundColor: Color(0xFFF2F2F2), toolbarMenuBackgroundColor: Color(0xFFFFFFFF), toolbarMenuItemBackgroundColor: Color(0xFFF2F2F7), toolbarMenuItemSelectedBackgroundColor: Color(0xFF00BCF0), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart index 999e6607b90b3..7489911fb79b6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart @@ -1,23 +1,23 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_bius_items.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_item.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_font_item.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_heading_and_text_items.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_indent_items.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; final aaToolbarItem = AppFlowyMobileToolbarItem( - pilotAtExpandedSelection: true, itemBuilder: (context, editorState, service, onMenu, _) { return AppFlowyMobileToolbarIconItem( + editorState: editorState, isSelected: () => service.showMenuNotifier.value, keepSelectedStatus: true, - icon: FlowySvgs.m_toolbar_aa_s, + icon: FlowySvgs.m_toolbar_aa_m, onTap: () => onMenu?.call(), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart index 724211b6e7e68..0b1ae151d31fc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart @@ -7,7 +7,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -18,7 +18,8 @@ import 'package:go_router/go_router.dart'; final addBlockToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, __, onAction) { return AppFlowyMobileToolbarIconItem( - icon: FlowySvgs.m_toolbar_add_s, + editorState: editorState, + icon: FlowySvgs.m_toolbar_add_m, onTap: () { final selection = editorState.selection; service.closeKeyboard(); @@ -83,7 +84,7 @@ Future showAddBlockMenu( } class _AddBlockMenu extends StatelessWidget { - _AddBlockMenu({ + const _AddBlockMenu({ required this.selection, required this.editorState, }); @@ -91,151 +92,10 @@ class _AddBlockMenu extends StatelessWidget { final Selection selection; final EditorState editorState; - late final List> typeOptionMenuItemValue = [ - // heading 1 - 3 - TypeOptionMenuItemValue( - value: HeadingBlockKeys.type, - backgroundColor: const Color(0xFFBAC9FF), - text: LocaleKeys.editor_heading1.tr(), - icon: FlowySvgs.m_add_block_h1_s, - onTap: (_, __) => _insertBlock(headingNode(level: 1)), - ), - TypeOptionMenuItemValue( - value: HeadingBlockKeys.type, - backgroundColor: const Color(0xFFBAC9FF), - text: LocaleKeys.editor_heading2.tr(), - icon: FlowySvgs.m_add_block_h2_s, - onTap: (_, __) => _insertBlock(headingNode(level: 2)), - ), - TypeOptionMenuItemValue( - value: HeadingBlockKeys.type, - backgroundColor: const Color(0xFFBAC9FF), - text: LocaleKeys.editor_heading3.tr(), - icon: FlowySvgs.m_add_block_h3_s, - onTap: (_, __) => _insertBlock(headingNode(level: 3)), - ), - - // paragraph - TypeOptionMenuItemValue( - value: ParagraphBlockKeys.type, - backgroundColor: const Color(0xFFBAC9FF), - text: LocaleKeys.editor_text.tr(), - icon: FlowySvgs.m_add_block_paragraph_s, - onTap: (_, __) => _insertBlock(paragraphNode()), - ), - - // checkbox - TypeOptionMenuItemValue( - value: TodoListBlockKeys.type, - backgroundColor: const Color(0xFF98F4CD), - text: LocaleKeys.editor_checkbox.tr(), - icon: FlowySvgs.m_add_block_checkbox_s, - onTap: (_, __) => _insertBlock(todoListNode(checked: false)), - ), - - // quote - TypeOptionMenuItemValue( - value: QuoteBlockKeys.type, - backgroundColor: const Color(0xFFFDEDA7), - text: LocaleKeys.editor_quote.tr(), - icon: FlowySvgs.m_add_block_quote_s, - onTap: (_, __) => _insertBlock(quoteNode()), - ), - - // bulleted list, numbered list, toggle list - TypeOptionMenuItemValue( - value: BulletedListBlockKeys.type, - backgroundColor: const Color(0xFFFFB9EF), - text: LocaleKeys.editor_bulletedListShortForm.tr(), - icon: FlowySvgs.m_add_block_bulleted_list_s, - onTap: (_, __) => _insertBlock(bulletedListNode()), - ), - TypeOptionMenuItemValue( - value: NumberedListBlockKeys.type, - backgroundColor: const Color(0xFFFFB9EF), - text: LocaleKeys.editor_numberedListShortForm.tr(), - icon: FlowySvgs.m_add_block_numbered_list_s, - onTap: (_, __) => _insertBlock(numberedListNode()), - ), - TypeOptionMenuItemValue( - value: ToggleListBlockKeys.type, - backgroundColor: const Color(0xFFFFB9EF), - text: LocaleKeys.editor_toggleListShortForm.tr(), - icon: FlowySvgs.m_add_block_toggle_s, - onTap: (_, __) => _insertBlock(toggleListBlockNode()), - ), - - // image - TypeOptionMenuItemValue( - value: DividerBlockKeys.type, - backgroundColor: const Color(0xFF98F4CD), - text: LocaleKeys.editor_image.tr(), - icon: FlowySvgs.m_add_block_image_s, - onTap: (_, __) async { - AppGlobals.rootNavKey.currentContext?.pop(true); - Future.delayed(const Duration(milliseconds: 400), () async { - final imagePlaceholderKey = GlobalKey(); - await editorState.insertEmptyImageBlock(imagePlaceholderKey); - }); - }, - ), - - // date - TypeOptionMenuItemValue( - value: ParagraphBlockKeys.type, - backgroundColor: const Color(0xFF91EAF5), - text: LocaleKeys.editor_date.tr(), - icon: FlowySvgs.m_add_block_date_s, - onTap: (_, __) => _insertBlock(dateMentionNode()), - ), - - // divider - TypeOptionMenuItemValue( - value: DividerBlockKeys.type, - backgroundColor: const Color(0xFF98F4CD), - text: LocaleKeys.editor_divider.tr(), - icon: FlowySvgs.m_add_block_divider_s, - onTap: (_, __) { - AppGlobals.rootNavKey.currentContext?.pop(true); - Future.delayed(const Duration(milliseconds: 100), () { - editorState.insertDivider(selection); - }); - }, - ), - - // callout, code, math equation - TypeOptionMenuItemValue( - value: CalloutBlockKeys.type, - backgroundColor: const Color(0xFFCABDFF), - text: LocaleKeys.document_plugins_callout.tr(), - icon: FlowySvgs.m_add_block_callout_s, - onTap: (_, __) => _insertBlock(calloutNode()), - ), - TypeOptionMenuItemValue( - value: CodeBlockKeys.type, - backgroundColor: const Color(0xFFCABDFF), - text: LocaleKeys.editor_codeBlockShortForm.tr(), - icon: FlowySvgs.m_add_block_code_s, - onTap: (_, __) => _insertBlock(codeBlockNode()), - ), - TypeOptionMenuItemValue( - value: MathEquationBlockKeys.type, - backgroundColor: const Color(0xFFCABDFF), - text: LocaleKeys.editor_mathEquationShortForm.tr(), - icon: FlowySvgs.m_add_block_formula_s, - onTap: (_, __) { - AppGlobals.rootNavKey.currentContext?.pop(true); - Future.delayed(const Duration(milliseconds: 100), () { - editorState.insertMathEquation(selection); - }); - }, - ), - ]; - @override Widget build(BuildContext context) { return TypeOptionMenu( - values: typeOptionMenuItemValue, + values: buildTypeOptionMenuItemValues(context), scaleFactor: context.scale, ); } @@ -249,6 +109,188 @@ class _AddBlockMenu extends StatelessWidget { ); }); } + + List> buildTypeOptionMenuItemValues( + BuildContext context, + ) { + final colorMap = _colorMap(context); + return [ + // heading 1 - 3 + TypeOptionMenuItemValue( + value: HeadingBlockKeys.type, + backgroundColor: colorMap[HeadingBlockKeys.type]!, + text: LocaleKeys.editor_heading1.tr(), + icon: FlowySvgs.m_add_block_h1_s, + onTap: (_, __) => _insertBlock(headingNode(level: 1)), + ), + TypeOptionMenuItemValue( + value: HeadingBlockKeys.type, + backgroundColor: colorMap[HeadingBlockKeys.type]!, + text: LocaleKeys.editor_heading2.tr(), + icon: FlowySvgs.m_add_block_h2_s, + onTap: (_, __) => _insertBlock(headingNode(level: 2)), + ), + TypeOptionMenuItemValue( + value: HeadingBlockKeys.type, + backgroundColor: colorMap[HeadingBlockKeys.type]!, + text: LocaleKeys.editor_heading3.tr(), + icon: FlowySvgs.m_add_block_h3_s, + onTap: (_, __) => _insertBlock(headingNode(level: 3)), + ), + + // paragraph + TypeOptionMenuItemValue( + value: ParagraphBlockKeys.type, + backgroundColor: colorMap[ParagraphBlockKeys.type]!, + text: LocaleKeys.editor_text.tr(), + icon: FlowySvgs.m_add_block_paragraph_s, + onTap: (_, __) => _insertBlock(paragraphNode()), + ), + + // checkbox + TypeOptionMenuItemValue( + value: TodoListBlockKeys.type, + backgroundColor: colorMap[TodoListBlockKeys.type]!, + text: LocaleKeys.editor_checkbox.tr(), + icon: FlowySvgs.m_add_block_checkbox_s, + onTap: (_, __) => _insertBlock(todoListNode(checked: false)), + ), + + // quote + TypeOptionMenuItemValue( + value: QuoteBlockKeys.type, + backgroundColor: colorMap[QuoteBlockKeys.type]!, + text: LocaleKeys.editor_quote.tr(), + icon: FlowySvgs.m_add_block_quote_s, + onTap: (_, __) => _insertBlock(quoteNode()), + ), + + // bulleted list, numbered list, toggle list + TypeOptionMenuItemValue( + value: BulletedListBlockKeys.type, + backgroundColor: colorMap[BulletedListBlockKeys.type]!, + text: LocaleKeys.editor_bulletedListShortForm.tr(), + icon: FlowySvgs.m_add_block_bulleted_list_s, + onTap: (_, __) => _insertBlock(bulletedListNode()), + ), + TypeOptionMenuItemValue( + value: NumberedListBlockKeys.type, + backgroundColor: colorMap[NumberedListBlockKeys.type]!, + text: LocaleKeys.editor_numberedListShortForm.tr(), + icon: FlowySvgs.m_add_block_numbered_list_s, + onTap: (_, __) => _insertBlock(numberedListNode()), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.editor_toggleListShortForm.tr(), + icon: FlowySvgs.m_add_block_toggle_s, + onTap: (_, __) => _insertBlock(toggleListBlockNode()), + ), + + // image + TypeOptionMenuItemValue( + value: ImageBlockKeys.type, + backgroundColor: colorMap[ImageBlockKeys.type]!, + text: LocaleKeys.editor_image.tr(), + icon: FlowySvgs.m_add_block_image_s, + onTap: (_, __) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 400), () async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyImageBlock(imagePlaceholderKey); + }); + }, + ), + + // date + TypeOptionMenuItemValue( + value: ParagraphBlockKeys.type, + backgroundColor: colorMap['date']!, + text: LocaleKeys.editor_date.tr(), + icon: FlowySvgs.m_add_block_date_s, + onTap: (_, __) => _insertBlock(dateMentionNode()), + ), + + // divider + TypeOptionMenuItemValue( + value: DividerBlockKeys.type, + backgroundColor: colorMap[DividerBlockKeys.type]!, + text: LocaleKeys.editor_divider.tr(), + icon: FlowySvgs.m_add_block_divider_s, + onTap: (_, __) { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 100), () { + editorState.insertDivider(selection); + }); + }, + ), + + // callout, code, math equation + TypeOptionMenuItemValue( + value: CalloutBlockKeys.type, + backgroundColor: colorMap[CalloutBlockKeys.type]!, + text: LocaleKeys.document_plugins_callout.tr(), + icon: FlowySvgs.m_add_block_callout_s, + onTap: (_, __) => _insertBlock(calloutNode()), + ), + TypeOptionMenuItemValue( + value: CodeBlockKeys.type, + backgroundColor: colorMap[CodeBlockKeys.type]!, + text: LocaleKeys.editor_codeBlockShortForm.tr(), + icon: FlowySvgs.m_add_block_code_s, + onTap: (_, __) => _insertBlock(codeBlockNode()), + ), + TypeOptionMenuItemValue( + value: MathEquationBlockKeys.type, + backgroundColor: colorMap[MathEquationBlockKeys.type]!, + text: LocaleKeys.editor_mathEquationShortForm.tr(), + icon: FlowySvgs.m_add_block_formula_s, + onTap: (_, __) { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 100), () { + editorState.insertMathEquation(selection); + }); + }, + ), + ]; + } + + Map _colorMap(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + if (isDarkMode) { + return { + HeadingBlockKeys.type: const Color(0xFF5465A1), + ParagraphBlockKeys.type: const Color(0xFF5465A1), + TodoListBlockKeys.type: const Color(0xFF4BB299), + QuoteBlockKeys.type: const Color(0xFFBAAC74), + BulletedListBlockKeys.type: const Color(0xFFA35F94), + NumberedListBlockKeys.type: const Color(0xFFA35F94), + ToggleListBlockKeys.type: const Color(0xFFA35F94), + ImageBlockKeys.type: const Color(0xFFBAAC74), + 'date': const Color(0xFF40AAB8), + DividerBlockKeys.type: const Color(0xFF4BB299), + CalloutBlockKeys.type: const Color(0xFF66599B), + CodeBlockKeys.type: const Color(0xFF66599B), + MathEquationBlockKeys.type: const Color(0xFF66599B), + }; + } + return { + HeadingBlockKeys.type: const Color(0xFFBECCFF), + ParagraphBlockKeys.type: const Color(0xFFBECCFF), + TodoListBlockKeys.type: const Color(0xFF98F4CD), + QuoteBlockKeys.type: const Color(0xFFFDEDA7), + BulletedListBlockKeys.type: const Color(0xFFFFB9EF), + NumberedListBlockKeys.type: const Color(0xFFFFB9EF), + ToggleListBlockKeys.type: const Color(0xFFFFB9EF), + ImageBlockKeys.type: const Color(0xFFFDEDA7), + 'date': const Color(0xFF91EAF5), + DividerBlockKeys.type: const Color(0xFF98F4CD), + CalloutBlockKeys.type: const Color(0xFFCABDFF), + CodeBlockKeys.type: const Color(0xFFCABDFF), + MathEquationBlockKeys.type: const Color(0xFFCABDFF), + }; + } } extension on EditorState { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart index ba286b7b9f503..bf7d7d098f02d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_close_keyboard_or_menu_button.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -26,13 +26,15 @@ class AppFlowyMobileToolbar extends StatefulWidget { super.key, this.toolbarHeight = 50.0, required this.editorState, - required this.toolbarItems, + required this.toolbarItemsBuilder, required this.child, }); final EditorState editorState; final double toolbarHeight; - final List toolbarItems; + final List Function( + Selection? selection, + ) toolbarItemsBuilder; final Widget child; @override @@ -108,7 +110,7 @@ class _AppFlowyMobileToolbarState extends State { return RepaintBoundary( child: _MobileToolbar( editorState: widget.editorState, - toolbarItems: widget.toolbarItems, + toolbarItems: widget.toolbarItemsBuilder(selection), toolbarHeight: widget.toolbarHeight, ), ); @@ -234,14 +236,14 @@ class _MobileToolbarState extends State<_MobileToolbar> // - otherwise, add a spacer to push the toolbar up when the keyboard is shown return Column( children: [ - Divider( + const Divider( height: 0.5, - color: Colors.grey.withOpacity(0.5), + color: Color(0xFFEDEDED), ), _buildToolbar(context), - Divider( + const Divider( height: 0.5, - color: Colors.grey.withOpacity(0.5), + color: Color(0xFFEDEDED), ), _buildMenuOrSpacer(context), ], @@ -270,6 +272,11 @@ class _MobileToolbarState extends State<_MobileToolbar> widget.editorState.selection = null; } + // if the menu is shown and the height is not 0, we need to close the menu + if (showMenuNotifier.value && height != 0) { + closeItemMenu(); + } + if (canUpdateCachedKeyboardHeight) { cachedKeyboardHeight.value = height; } @@ -337,62 +344,29 @@ class _MobileToolbarState extends State<_MobileToolbar> }, ), ), - // close menu or close keyboard button - ClipRect( - clipper: const _MyClipper( - offset: -20, - ), - child: ValueListenableBuilder( - valueListenable: showMenuNotifier, - builder: (_, showingMenu, __) { - return ValueListenableBuilder( - valueListenable: toolbarOffset, - builder: (_, offset, __) { - final showShadow = offset > 0; - return DecoratedBox( - decoration: BoxDecoration( - color: theme.toolbarBackgroundColor, - boxShadow: showShadow - ? [ - BoxShadow( - color: theme.toolbarShadowColor, - blurRadius: 20, - offset: const Offset(-2, 0), - spreadRadius: -10, - ), - ] - : null, - ), - child: CloseKeyboardOrMenuButton( - showingMenu: showingMenu, - onPressed: () { - if (showingMenu) { - // close the menu and show the keyboard - closeItemMenu(); - _showKeyboard(); - } else { - closeKeyboardInitiative = true; - // close the keyboard and clear the selection - // if the selection is null, the keyboard and the toolbar will be hidden automatically - widget.editorState.selection = null; - - // sometimes, the keyboard is not closed after the selection is cleared - if (Platform.isAndroid) { - SystemChannels.textInput - .invokeMethod('TextInput.hide'); - } - } - }, - ), - ); - }, - ); - }, + const Padding( + padding: EdgeInsets.symmetric(vertical: 13.0), + child: VerticalDivider( + width: 1.0, + thickness: 1.0, + color: Color(0xFFD9D9D9), ), ), - const SizedBox( - width: 4.0, + // close menu or close keyboard button + CloseKeyboardOrMenuButton( + onPressed: () { + closeKeyboardInitiative = true; + // close the keyboard and clear the selection + // if the selection is null, the keyboard and the toolbar will be hidden automatically + widget.editorState.selection = null; + + // sometimes, the keyboard is not closed after the selection is cleared + if (Platform.isAndroid) { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + }, ), + const HSpace(4.0), ], ), ); @@ -484,7 +458,7 @@ class _ToolbarItemListViewState extends State<_ToolbarItemListView> { @override Widget build(BuildContext context) { final children = [ - const HSpace(16), + const HSpace(8), ...widget.toolbarItems .mapIndexed( (index, element) => element.itemBuilder.call( @@ -562,16 +536,16 @@ class _ToolbarItemListViewState extends State<_ToolbarItemListView> { } } -class _MyClipper extends CustomClipper { - const _MyClipper({ - this.offset = 0, - }); +// class _MyClipper extends CustomClipper { +// const _MyClipper({ +// this.offset = 0, +// }); - final double offset; +// final double offset; - @override - Rect getClip(Size size) => Rect.fromLTWH(offset, 0, 64.0, 46.0); +// @override +// Rect getClip(Size size) => Rect.fromLTWH(offset, 0, 64.0, 46.0); - @override - bool shouldReclip(CustomClipper oldClipper) => false; -} +// @override +// bool shouldReclip(CustomClipper oldClipper) => false; +// } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart index 423173fa6f2bb..d138e644cd9a5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart @@ -1,5 +1,7 @@ +import 'dart:async'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -42,7 +44,10 @@ class AppFlowyMobileToolbarIconItem extends StatefulWidget { this.keepSelectedStatus = false, this.iconBuilder, this.isSelected, + this.shouldListenToToggledStyle = false, + this.enable, required this.onTap, + required this.editorState, }); final FlowySvgData? icon; @@ -50,6 +55,9 @@ class AppFlowyMobileToolbarIconItem extends StatefulWidget { final VoidCallback onTap; final WidgetBuilder? iconBuilder; final bool Function()? isSelected; + final bool shouldListenToToggledStyle; + final EditorState editorState; + final bool Function()? enable; @override State createState() => @@ -59,12 +67,28 @@ class AppFlowyMobileToolbarIconItem extends StatefulWidget { class _AppFlowyMobileToolbarIconItemState extends State { bool isSelected = false; + StreamSubscription? _subscription; @override void initState() { super.initState(); isSelected = widget.isSelected?.call() ?? false; + if (widget.shouldListenToToggledStyle) { + widget.editorState.toggledStyleNotifier.addListener(_rebuild); + _subscription = widget.editorState.transactionStream.listen((_) { + _rebuild(); + }); + } + } + + @override + void dispose() { + if (widget.shouldListenToToggledStyle) { + widget.editorState.toggledStyleNotifier.removeListener(_rebuild); + _subscription?.cancel(); + } + super.dispose(); } @override @@ -79,36 +103,44 @@ class _AppFlowyMobileToolbarIconItemState @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); + final enable = widget.enable?.call() ?? true; return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 5), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { widget.onTap(); - if (widget.keepSelectedStatus && widget.isSelected == null) { - setState(() { - isSelected = !isSelected; - }); - } else { - setState(() { - isSelected = widget.isSelected?.call() ?? false; - }); - } + _rebuild(); }, - child: Container( - width: 48, - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: isSelected ? theme.toolbarItemSelectedBackgroundColor : null, - ), - child: widget.iconBuilder?.call(context) ?? - FlowySvg( + child: widget.iconBuilder?.call(context) ?? + Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: isSelected + ? theme.toolbarItemSelectedBackgroundColor + : null, + ), + child: FlowySvg( widget.icon!, - color: theme.toolbarItemIconColor, + color: enable + ? theme.toolbarItemIconColor + : theme.toolbarItemIconDisabledColor, ), - ), + ), ), ); } + + void _rebuild() { + if (!context.mounted) { + return; + } + setState(() { + isSelected = (widget.keepSelectedStatus && widget.isSelected == null) + ? !isSelected + : widget.isSelected?.call() ?? false; + }); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biuc_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biuc_toolbar_item.dart deleted file mode 100644 index 6072feed8886a..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biuc_toolbar_item.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_color_list.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -final boldToolbarItem = AppFlowyMobileToolbarItem( - itemBuilder: (context, editorState, _, __, onAction) { - return AppFlowyMobileToolbarIconItem( - isSelected: () => editorState.isTextDecorationSelected( - AppFlowyRichTextKeys.bold, - ), - icon: FlowySvgs.m_toolbar_bold_s, - onTap: () async => editorState.toggleAttribute( - AppFlowyRichTextKeys.bold, - selectionExtraInfo: { - selectionExtraInfoDisableFloatingToolbar: true, - }, - ), - ); - }, -); - -final italicToolbarItem = AppFlowyMobileToolbarItem( - itemBuilder: (context, editorState, _, __, onAction) { - return AppFlowyMobileToolbarIconItem( - // keepSelectedStatus: true, - isSelected: () => editorState.isTextDecorationSelected( - AppFlowyRichTextKeys.italic, - ), - icon: FlowySvgs.m_toolbar_italic_s, - onTap: () async => editorState.toggleAttribute( - AppFlowyRichTextKeys.italic, - selectionExtraInfo: { - selectionExtraInfoDisableFloatingToolbar: true, - }, - ), - ); - }, -); - -final underlineToolbarItem = AppFlowyMobileToolbarItem( - itemBuilder: (context, editorState, _, __, onAction) { - return AppFlowyMobileToolbarIconItem( - isSelected: () => editorState.isTextDecorationSelected( - AppFlowyRichTextKeys.underline, - ), - icon: FlowySvgs.m_toolbar_underline_s, - onTap: () async => editorState.toggleAttribute( - AppFlowyRichTextKeys.underline, - selectionExtraInfo: { - selectionExtraInfoDisableFloatingToolbar: true, - }, - ), - ); - }, -); - -final colorToolbarItem = AppFlowyMobileToolbarItem( - itemBuilder: (context, editorState, service, __, onAction) { - return AppFlowyMobileToolbarIconItem( - icon: FlowySvgs.m_toolbar_color_s, - onTap: () { - service.closeKeyboard(); - editorState.updateSelectionWithReason( - editorState.selection, - extraInfo: { - selectionExtraInfoDisableMobileToolbarKey: true, - selectionExtraInfoDisableFloatingToolbar: true, - selectionExtraInfoDoNotAttachTextService: true, - }, - ); - keepEditorFocusNotifier.increase(); - showTextColorAndBackgroundColorPicker( - context, - editorState: editorState, - selection: editorState.selection!, - ); - }, - ); - }, -); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biusc_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biusc_toolbar_item.dart new file mode 100644 index 0000000000000..f0bfda04a21a9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/biusc_toolbar_item.dart @@ -0,0 +1,152 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +final boldToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => + editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.bold, + ) && + editorState.toggledStyle[AppFlowyRichTextKeys.bold] != false, + icon: FlowySvgs.m_toolbar_bold_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.bold, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final italicToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.italic, + ), + icon: FlowySvgs.m_toolbar_italic_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.italic, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final underlineToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.underline, + ), + icon: FlowySvgs.m_toolbar_underline_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.underline, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final strikethroughToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.strikethrough, + ), + icon: FlowySvgs.m_toolbar_strike_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.strikethrough, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final colorToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, service, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + icon: FlowySvgs.m_aa_font_color_m, + iconBuilder: (context) { + String? getColor(String key) { + final selection = editorState.selection; + if (selection == null) { + return null; + } + String? color = editorState.toggledStyle[key]; + if (color == null) { + if (selection.isCollapsed && selection.startIndex != 0) { + color = editorState.getDeltaAttributeValueInSelection( + key, + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + ); + } else { + color = editorState.getDeltaAttributeValueInSelection( + key, + ); + } + } + return color; + } + + final textColor = getColor(AppFlowyRichTextKeys.textColor); + final backgroundColor = getColor(AppFlowyRichTextKeys.backgroundColor); + + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: backgroundColor?.tryToColor(), + ), + child: FlowySvg( + FlowySvgs.m_aa_font_color_m, + color: textColor?.tryToColor(), + ), + ); + }, + onTap: () { + service.closeKeyboard(); + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + keepEditorFocusNotifier.increase(); + showTextColorAndBackgroundColorPicker( + context, + editorState: editorState, + selection: editorState.selection!, + ); + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/checkbox_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/checkbox_toolbar_item.dart deleted file mode 100644 index ab8343082e258..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/checkbox_toolbar_item.dart +++ /dev/null @@ -1,22 +0,0 @@ -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'; - -final todoListToolbarItem = AppFlowyMobileToolbarItem( - itemBuilder: (context, editorState, _, __, onAction) { - final isSelected = editorState.isBlockTypeSelected(TodoListBlockKeys.type); - return AppFlowyMobileToolbarIconItem( - keepSelectedStatus: true, - isSelected: () => isSelected, - icon: FlowySvgs.m_toolbar_checkbox_s, - onTap: () async { - await editorState.convertBlockType( - TodoListBlockKeys.type, - extraAttributes: { - TodoListBlockKeys.checked: false, - }, - ); - }, - ); - }, -); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart new file mode 100644 index 0000000000000..290fa2d3e0db7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart @@ -0,0 +1,35 @@ +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'; + +final indentToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => false, + enable: () => isIndentable(editorState), + icon: FlowySvgs.m_aa_indent_m, + onTap: () async { + indentCommand.execute(editorState); + }, + ); + }, +); + +final outdentToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => false, + enable: () => isOutdentable(editorState), + icon: FlowySvgs.m_aa_outdent_m, + onTap: () async { + outdentCommand.execute(editorState); + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart new file mode 100644 index 0000000000000..240ea7072e7fe --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart @@ -0,0 +1,61 @@ +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'; + +final todoListToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => false, + icon: FlowySvgs.m_toolbar_checkbox_m, + onTap: () async { + await editorState.convertBlockType( + TodoListBlockKeys.type, + extraAttributes: { + TodoListBlockKeys.checked: false, + }, + ); + }, + ); + }, +); + +final numberedListToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + final isSelected = + editorState.isBlockTypeSelected(NumberedListBlockKeys.type); + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => isSelected, + icon: FlowySvgs.m_toolbar_numbered_list_m, + onTap: () async { + await editorState.convertBlockType( + NumberedListBlockKeys.type, + ); + }, + ); + }, +); + +final bulletedListToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + final isSelected = + editorState.isBlockTypeSelected(BulletedListBlockKeys.type); + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => isSelected, + icon: FlowySvgs.m_toolbar_bulleted_list_m, + onTap: () async { + await editorState.convertBlockType( + BulletedListBlockKeys.type, + ); + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart index fb07c0663ba47..1488847ea5cf9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart @@ -4,6 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da final moreToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( + editorState: editorState, icon: FlowySvgs.m_toolbar_more_s, onTap: () {}, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart new file mode 100644 index 0000000000000..adb1feeb357eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final _listBlockTypes = [ + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + TodoListBlockKeys.type, +]; + +final _defaultToolbarItems = [ + addBlockToolbarItem, + aaToolbarItem, + todoListToolbarItem, + bulletedListToolbarItem, + numberedListToolbarItem, + boldToolbarItem, + italicToolbarItem, + underlineToolbarItem, + strikethroughToolbarItem, + colorToolbarItem, + undoToolbarItem, + redoToolbarItem, +]; + +final _listToolbarItems = [ + addBlockToolbarItem, + aaToolbarItem, + outdentToolbarItem, + indentToolbarItem, + todoListToolbarItem, + bulletedListToolbarItem, + numberedListToolbarItem, + boldToolbarItem, + italicToolbarItem, + underlineToolbarItem, + strikethroughToolbarItem, + colorToolbarItem, + undoToolbarItem, + redoToolbarItem, +]; + +final _textToolbarItems = [ + aaToolbarItem, + boldToolbarItem, + italicToolbarItem, + underlineToolbarItem, + strikethroughToolbarItem, + colorToolbarItem, +]; + +/// Calculate the toolbar items based on the current selection. +/// +/// Default: +/// Add, Aa, Todo List, Image, Bulleted List, Numbered List, B, I, U, S, Color, Undo, Redo +/// +/// Selecting text: +/// Aa, B, I, U, S, Color +/// +/// Selecting a list: +/// Add, Aa, Indent, Outdent, Bulleted List, Numbered List, Todo List B, I, U, S +List buildMobileToolbarItems( + EditorState editorState, + Selection? selection, +) { + if (selection == null) { + return []; + } + + if (!selection.isCollapsed) { + return _textToolbarItems; + } + + final allSelectedAreListType = editorState + .getSelectedNodes(selection: selection) + .every((node) => _listBlockTypes.contains(node.type)); + if (allSelectedAreListType) { + return _listToolbarItems; + } + + return _defaultToolbarItems; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart index 5001b37c8137d..5578d8a33cf28 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart @@ -1,20 +1,28 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; final undoToolbarItem = AppFlowyMobileToolbarItem( - pilotAtCollapsedSelection: true, itemBuilder: (context, editorState, _, __, onAction) { final theme = ToolbarColorExtension.of(context); return AppFlowyMobileToolbarIconItem( + editorState: editorState, iconBuilder: (context) { final canUndo = editorState.undoManager.undoStack.isNonEmpty; - return FlowySvg( - FlowySvgs.m_toolbar_undo_s, - color: canUndo - ? theme.toolbarItemIconColor - : theme.toolbarItemIconDisabledColor, + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + ), + child: FlowySvg( + FlowySvgs.m_toolbar_undo_m, + color: canUndo + ? theme.toolbarItemIconColor + : theme.toolbarItemIconDisabledColor, + ), ); }, onTap: () => undoCommand.execute(editorState), @@ -26,13 +34,21 @@ final redoToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { final theme = ToolbarColorExtension.of(context); return AppFlowyMobileToolbarIconItem( + editorState: editorState, iconBuilder: (context) { final canRedo = editorState.undoManager.redoStack.isNonEmpty; - return FlowySvg( - FlowySvgs.m_toolbar_redo_s, - color: canRedo - ? theme.toolbarItemIconColor - : theme.toolbarItemIconDisabledColor, + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + ), + child: FlowySvg( + FlowySvgs.m_toolbar_redo_m, + color: canRedo + ? theme.toolbarItemIconColor + : theme.toolbarItemIconDisabledColor, + ), ); }, onTap: () => redoCommand.execute(editorState), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart index 8b723cc917fbb..07983ab078db1 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart @@ -1,5 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -11,6 +11,7 @@ class MobileToolbarMenuItemWrapper extends StatelessWidget { this.icon, this.text, this.backgroundColor, + this.selectedBackgroundColor, this.enable, this.fontFamily, required this.isSelected, @@ -23,6 +24,7 @@ class MobileToolbarMenuItemWrapper extends StatelessWidget { this.showRightArrow = false, this.textPadding = EdgeInsets.zero, required this.onTap, + this.iconColor, }); final Size size; @@ -40,18 +42,22 @@ class MobileToolbarMenuItemWrapper extends StatelessWidget { final bool showDownArrow; final bool showRightArrow; final Color? backgroundColor; + final Color? selectedBackgroundColor; final EdgeInsets textPadding; + final Color? iconColor; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); - Color? iconColor; - if (enable != null) { - iconColor = enable! ? null : theme.toolbarMenuIconDisabledColor; - } else { - iconColor = isSelected - ? theme.toolbarMenuIconSelectedColor - : theme.toolbarMenuIconColor; + Color? iconColor = this.iconColor; + if (iconColor == null) { + if (enable != null) { + iconColor = enable! ? null : theme.toolbarMenuIconDisabledColor; + } else { + iconColor = isSelected + ? theme.toolbarMenuIconSelectedColor + : theme.toolbarMenuIconColor; + } } final textColor = enable == false ? theme.toolbarMenuIconDisabledColor : null; @@ -90,7 +96,8 @@ class MobileToolbarMenuItemWrapper extends StatelessWidget { alignment: text != null ? Alignment.centerLeft : Alignment.center, decoration: BoxDecoration( color: isSelected - ? theme.toolbarMenuItemSelectedBackgroundColor + ? (selectedBackgroundColor ?? + theme.toolbarMenuItemSelectedBackgroundColor) : backgroundColor, borderRadius: BorderRadius.only( topLeft: enableTopLeftRadius ? radius : Radius.zero, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart index 45e8aa3eaf457..60306540a8749 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:http/http.dart' as http; import 'error.dart'; @@ -33,7 +33,7 @@ abstract class OpenAIRepository { /// [maxTokens] is the maximum number of tokens to generate /// [temperature] is the temperature of the model /// - Future> getCompletions({ + Future> getCompletions({ required String prompt, String? suffix, int maxTokens = 2048, @@ -58,7 +58,7 @@ abstract class OpenAIRepository { /// [instruction] is the instruction text /// [temperature] is the temperature of the model /// - Future> getEdits({ + Future> getEdits({ required String input, required String instruction, double temperature = 0.3, @@ -70,7 +70,7 @@ abstract class OpenAIRepository { /// [n] is the number of images to generate /// /// the result is a list of urls - Future>> generateImage({ + Future, OpenAIError>> generateImage({ required String prompt, int n = 1, }); @@ -91,7 +91,7 @@ class HttpOpenAIRepository implements OpenAIRepository { }; @override - Future> getCompletions({ + Future> getCompletions({ required String prompt, String? suffix, int maxTokens = 2048, @@ -113,7 +113,7 @@ class HttpOpenAIRepository implements OpenAIRepository { ); if (response.statusCode == 200) { - return Right( + return FlowyResult.success( TextCompletionResponse.fromJson( json.decode( utf8.decode(response.bodyBytes), @@ -121,7 +121,9 @@ class HttpOpenAIRepository implements OpenAIRepository { ), ); } else { - return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); + return FlowyResult.failure( + OpenAIError.fromJson(json.decode(response.body)['error']), + ); } } @@ -206,7 +208,7 @@ class HttpOpenAIRepository implements OpenAIRepository { } @override - Future> getEdits({ + Future> getEdits({ required String input, required String instruction, double temperature = 0.3, @@ -227,7 +229,7 @@ class HttpOpenAIRepository implements OpenAIRepository { ); if (response.statusCode == 200) { - return Right( + return FlowyResult.success( TextEditResponse.fromJson( json.decode( utf8.decode(response.bodyBytes), @@ -235,12 +237,14 @@ class HttpOpenAIRepository implements OpenAIRepository { ), ); } else { - return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); + return FlowyResult.failure( + OpenAIError.fromJson(json.decode(response.body)['error']), + ); } } @override - Future>> generateImage({ + Future, OpenAIError>> generateImage({ required String prompt, int n = 1, }) async { @@ -266,12 +270,14 @@ class HttpOpenAIRepository implements OpenAIRepository { .expand((e) => e) .map((e) => e.toString()) .toList(); - return Right(urls); + return FlowyResult.success(urls); } else { - return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); + return FlowyResult.failure( + OpenAIError.fromJson(json.decode(response.body)['error']), + ); } } catch (error) { - return Left(OpenAIError(message: error.toString())); + return FlowyResult.failure(OpenAIError(message: error.toString())); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart index 49b047c758cda..17e89b1bcac37 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart @@ -1,10 +1,8 @@ -import 'package:url_launcher/url_launcher.dart'; +import 'package:appflowy/core/helpers/url_launcher.dart'; + +const String learnMoreUrl = + 'https://docs.appflowy.io/docs/appflowy/product/appflowy-x-openai'; Future openLearnMorePage() async { - final uri = Uri.parse( - 'https://appflowy.gitbook.io/docs/essential-documentation/appflowy-x-openai', - ); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } + await afLaunchUrlString(learnMoreUrl); } 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 56eed31926b8b..7f8778b39e636 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 @@ -193,7 +193,7 @@ class _AutoCompletionBlockComponentState await _updateEditingText(); final userProfile = await UserBackendService.getCurrentUserProfile() - .then((value) => value.toOption().toNullable()); + .then((value) => value.toNullable()); if (userProfile == null) { await loading.stop(); if (mounted) { @@ -290,7 +290,7 @@ class _AutoCompletionBlockComponentState } // generate new response final userProfile = await UserBackendService.getCurrentUserProfile() - .then((value) => value.toOption().toNullable()); + .then((value) => value.toNullable()); if (userProfile == null) { await loading.stop(); if (mounted) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart index a25966c3423ab..a0f7002889c23 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart @@ -41,8 +41,8 @@ class _SmartEditActionListState extends State { UserBackendService.getCurrentUserProfile().then((value) { setState(() { isOpenAIEnabled = value.fold( - (l) => false, - (r) => r.openaiKey.isNotEmpty, + (s) => s.openaiKey.isNotEmpty, + (_) => false, ); }); }); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart index dd271326d6a4e..1c467ee90f78b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -32,6 +32,12 @@ Node outlineBlockNode() { ); } +enum _OutlineBlockStatus { + noHeadings, + noMatchHeadings, + success; +} + class OutlineBlockComponentBuilder extends BlockComponentBuilder { OutlineBlockComponentBuilder({ super.configuration, @@ -75,7 +81,7 @@ class _OutlineBlockWidgetState extends State BlockComponentTextDirectionMixin, BlockComponentBackgroundColorMixin { // Change the value if the heading block type supports heading levels greater than '3' - static const finalHeadingLevel = 3; + static const maxVisibleDepth = 6; @override BlockComponentConfiguration get configuration => widget.configuration; @@ -120,81 +126,97 @@ class _OutlineBlockWidgetState extends State final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); + final (status, headings) = getHeadingNodes(); - final children = getHeadingNodes() - .map( - (e) => Container( - padding: const EdgeInsets.only( - bottom: 4.0, - ), - width: double.infinity, - child: OutlineItemWidget( - node: e, - textDirection: textDirection, - ), - ), - ) - .toList(); + Widget child; - final child = children.isEmpty - ? Align( - alignment: Alignment.centerLeft, - child: Text( - LocaleKeys.document_plugins_outline_addHeadingToCreateOutline - .tr(), - style: configuration.placeholderTextStyle(node), - ), - ) - : Container( - padding: const EdgeInsets.symmetric( - vertical: 2.0, - horizontal: 5.0, - ), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: backgroundColor, - ), - child: Column( - key: ValueKey(children.hashCode), - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - textDirection: textDirection, - children: [ - Text( - LocaleKeys.document_outlineBlock_placeholder.tr(), - style: Theme.of(context).textTheme.titleLarge, + switch (status) { + case _OutlineBlockStatus.noHeadings: + child = Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.document_plugins_outline_addHeadingToCreateOutline.tr(), + style: configuration.placeholderTextStyle(node), + ), + ); + case _OutlineBlockStatus.noMatchHeadings: + child = Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.document_plugins_outline_noMatchHeadings.tr(), + style: configuration.placeholderTextStyle(node), + ), + ); + case _OutlineBlockStatus.success: + final children = headings + .map( + (e) => Container( + padding: const EdgeInsets.only( + bottom: 4.0, ), - const VSpace(8.0), - Padding( - padding: const EdgeInsets.only(left: 15.0), - child: Column( - children: children, - ), + width: double.infinity, + child: OutlineItemWidget( + node: e, + textDirection: textDirection, ), - ], - ), - ); + ), + ) + .toList(); + child = Padding( + padding: const EdgeInsets.only(left: 15.0), + child: Column( + children: children, + ), + ); + } return Container( constraints: const BoxConstraints( minHeight: 40.0, ), padding: padding, - child: child, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 2.0, + horizontal: 5.0, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: backgroundColor, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + children: [ + Text( + LocaleKeys.document_outlineBlock_placeholder.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const VSpace(8.0), + child, + ], + ), + ), ); } - Iterable getHeadingNodes() { + (_OutlineBlockStatus, Iterable) getHeadingNodes() { final children = editorState.document.root.children; final int level = - node.attributes[OutlineBlockKeys.depth] ?? finalHeadingLevel; - - return children.where( - (element) => - element.type == HeadingBlockKeys.type && - element.delta?.isNotEmpty == true && - element.attributes[HeadingBlockKeys.level] <= level, + node.attributes[OutlineBlockKeys.depth] ?? maxVisibleDepth; + var headings = children.where( + (e) => e.type == HeadingBlockKeys.type && e.delta?.isNotEmpty == true, ); + if (headings.isEmpty) { + return (_OutlineBlockStatus.noHeadings, []); + } + headings = + headings.where((e) => e.attributes[HeadingBlockKeys.level] <= level); + if (headings.isEmpty) { + return (_OutlineBlockStatus.noMatchHeadings, []); + } + return (_OutlineBlockStatus.success, headings); } } @@ -263,12 +285,8 @@ extension on Node { return 0.0; } final level = attributes[HeadingBlockKeys.level]; - if (level == 2) { - return 20; - } else if (level == 3) { - return 40; - } - return 0; + final indent = (level - 1) * 15.0 + 10.0; + return indent; } String get outlineItemText { 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 6cfd92f22139e..f5f1331c94f0c 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 @@ -29,13 +29,16 @@ export 'link_preview/link_preview_cache.dart'; export 'link_preview/link_preview_menu.dart'; export 'math_equation/math_equation_block_component.dart'; export 'math_equation/mobile_math_equation_toolbar_item.dart'; +export 'mobile_floating_toolbar/custom_mobile_floating_toolbar.dart'; export 'mobile_toolbar_v3/aa_toolbar_item.dart'; export 'mobile_toolbar_v3/add_block_toolbar_item.dart'; export 'mobile_toolbar_v3/appflowy_mobile_toolbar.dart'; export 'mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart'; -export 'mobile_toolbar_v3/biuc_toolbar_item.dart'; -export 'mobile_toolbar_v3/checkbox_toolbar_item.dart'; +export 'mobile_toolbar_v3/biusc_toolbar_item.dart'; +export 'mobile_toolbar_v3/indent_outdent_toolbar_item.dart'; +export 'mobile_toolbar_v3/list_toolbar_item.dart'; export 'mobile_toolbar_v3/more_toolbar_item.dart'; +export 'mobile_toolbar_v3/toolbar_item_builder.dart'; export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart'; export 'mobile_toolbar_v3/util.dart'; export 'openai/widgets/auto_completion_node_widget.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart index 7cae9c11b94ee..c3cbd11c97c6b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_error.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:http/http.dart' as http; enum StabilityAIRequestType { @@ -25,7 +25,7 @@ abstract class StabilityAIRepository { /// [n] is the number of images to generate /// /// the return value is a list of base64 encoded images - Future>> generateImage({ + Future, StabilityAIRequestError>> generateImage({ required String prompt, int n = 1, }); @@ -46,7 +46,7 @@ class HttpStabilityAIRepository implements StabilityAIRepository { }; @override - Future>> generateImage({ + Future, StabilityAIRequestError>> generateImage({ required String prompt, int n = 1, }) async { @@ -76,16 +76,16 @@ class HttpStabilityAIRepository implements StabilityAIRepository { (e) => e['base64'].toString(), ) .toList(); - return Right(base64Images); + return FlowyResult.success(base64Images); } else { - return Left( + return FlowyResult.failure( StabilityAIRequestError( data['message'].toString(), ), ); } } catch (error) { - return Left( + return FlowyResult.failure( StabilityAIRequestError( error.toString(), ), 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 6857e830d6040..a81677d97c77a 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 @@ -18,9 +18,11 @@ CharacterShortcutEvent formatGreaterToToggleList = CharacterShortcutEvent( editorState, (node) => node.type != ToggleListBlockKeys.type, (_, text, __) => text == _greater, - (_, node, delta) => toggleListBlockNode( - delta: delta.compose(Delta()..delete(_greater.length)), - ), + (_, node, delta) => [ + toggleListBlockNode( + delta: delta.compose(Delta()..delete(_greater.length)), + ), + ], ), ); 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 06ac4e222b9ef..ad851ed8397ea 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,9 +1,10 @@ import 'dart:math'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/util/google_font_family_extension.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; @@ -292,7 +293,10 @@ class EditorStyleCustomizer { ..onTap = () { final editorState = context.read(); if (editorState.selection == null) { - safeLaunchUrl(href); + afLaunchUrlString( + href, + addingHttpSchemeWhenFailed: true, + ); return; } 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 deleted file mode 100644 index 5351643d19d69..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/more_button.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; -import 'package:appflowy/plugins/document/presentation/more/font_size_slider.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/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 DocumentMoreButton extends StatelessWidget { - const DocumentMoreButton({super.key}); - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(200, 400)), - offset: const Offset(0, 30), - popupBuilder: (_) { - final actions = [ - AppFlowyPopover( - direction: PopoverDirection.leftWithCenterAligned, - constraints: const BoxConstraints(maxHeight: 40, maxWidth: 240), - offset: const Offset(-10, 0), - popupBuilder: (context) { - return BlocBuilder( - builder: (context, state) { - return FontSizeStepper( - minimumValue: 10, - maximumValue: 24, - value: state.fontSize, - divisions: 8, - onChanged: (newFontSize) { - context - .read() - .syncFontSize(newFontSize); - }, - ); - }, - ); - }, - child: FlowyButton( - text: FlowyText.regular( - LocaleKeys.moreAction_fontSize.tr(), - color: AFThemeExtension.of(context).textColor, - ), - leftIcon: Icon( - Icons.format_size_sharp, - color: Theme.of(context).iconTheme.color, - size: 18, - ), - leftIconSize: const Size(18, 18), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - ), - ), - ]; - - return ListView.separated( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: actions.length, - separatorBuilder: (_, __) => const VSpace(4), - physics: StyledScrollPhysics(), - itemBuilder: (_, index) => actions[index], - ); - }, - child: FlowyTooltip( - message: LocaleKeys.moreAction_moreOptions.tr(), - child: FlowyHover( - child: Padding( - padding: const EdgeInsets.all(6), - child: FlowySvg( - FlowySvgs.details_s, - size: const Size(18, 18), - color: Theme.of(context).iconTheme.color, - ), - ), - ), - ), - ); - } -} 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 c5a81ab2938bc..a5d1c40c40020 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 @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/share_bloc.dart'; import 'package:appflowy/startup/startup.dart'; @@ -12,7 +14,6 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DocumentShareButton extends StatelessWidget { @@ -108,13 +109,14 @@ class ShareActionListState extends State { actions: ShareAction.values .map((action) => ShareActionWrapper(action)) .toList(), - buildChild: (controller) { - return RoundedTextButton( + buildChild: (controller) => Listener( + onPointerDown: (_) => controller.show(), + child: RoundedTextButton( title: LocaleKeys.shareAction_buttonText.tr(), - onPressed: () => controller.show(), + onPressed: () {}, textColor: Theme.of(context).colorScheme.onPrimary, - ); - }, + ), + ), onSelected: (action, controller) async { switch (action.inner) { case ShareAction.markdown: 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 75ac0ead2893f..6f3bf087a8264 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 @@ -1,17 +1,19 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/date/date_service.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; final _keywords = [ LocaleKeys.inlineActions_date.tr().toLowerCase(), ]; -class DateReferenceService { +class DateReferenceService extends InlineActionsDelegate { DateReferenceService(this.context) { // Initialize locale _locale = context.locale.toLanguageTag(); @@ -27,7 +29,8 @@ class DateReferenceService { List options = []; - Future dateReferenceDelegate([ + @override + Future search([ String? search, ]) async { // Checks if Locale has changed since last @@ -92,8 +95,8 @@ class DateReferenceService { final result = await DateService.queryDate(search); result.fold( - (l) {}, (date) => options.insert(0, _itemFromDate(date)), + (_) {}, ); } 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 caba7d68886e3..b8d94cee3c11c 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 @@ -9,16 +9,23 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/me import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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/service_handler.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -class InlinePageReferenceService { +class InlinePageReferenceService extends InlineActionsDelegate { InlinePageReferenceService({ required this.currentViewId, this.viewLayout, @@ -51,12 +58,67 @@ class InlinePageReferenceService { List _items = []; List _filtered = []; + UserProfilePB? _user; + String? _workspaceId; + WorkspaceListener? _listener; + Future init() async { _items = await _generatePageItems(currentViewId, viewLayout); _filtered = limitResults > 0 ? _items.take(limitResults).toList() : _items; + + await _initWorkspaceListener(); + _initCompleter.complete(); } + Future _initWorkspaceListener() async { + final snapshot = await Future.wait([ + FolderEventGetCurrentWorkspaceSetting().send(), + getIt().getUser(), + ]); + + final (workspaceSettings, userProfile) = (snapshot.first, snapshot.last); + _workspaceId = workspaceSettings.fold( + (s) => (s as WorkspaceSettingPB).workspaceId, + (e) => null, + ); + + _user = userProfile.fold((s) => s as UserProfilePB, (e) => null); + + if (_user != null && _workspaceId != null) { + _listener = WorkspaceListener( + user: _user!, + workspaceId: _workspaceId!, + ); + _listener!.start( + appsChanged: (_) async { + _items = await _generatePageItems(currentViewId, viewLayout); + _filtered = + limitResults > 0 ? _items.take(limitResults).toList() : _items; + }, + ); + } + } + + @override + Future search([ + String? search, + ]) async { + _filtered = await _filterItems(search); + + return InlineActionsResult( + title: customTitle?.isNotEmpty == true + ? customTitle! + : LocaleKeys.inlineActions_pageReference.tr(), + results: _filtered, + ); + } + + @override + Future dispose() async { + await _listener?.stop(); + } + Future> _filterItems(String? search) async { await _initCompleter.future; @@ -76,19 +138,6 @@ class InlinePageReferenceService { : items.toList(); } - Future inlinePageReferenceDelegate([ - String? search, - ]) async { - _filtered = await _filterItems(search); - - return InlineActionsResult( - title: customTitle?.isNotEmpty == true - ? customTitle! - : LocaleKeys.inlineActions_pageReference.tr(), - results: _filtered, - ); - } - Future> _generatePageItems( String currentViewId, ViewLayoutPB? viewLayout, 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 bf06b8e0966b8..1fdda2a40e710 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 @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; @@ -21,7 +22,7 @@ final _keywords = [ LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), ]; -class ReminderReferenceService { +class ReminderReferenceService extends InlineActionsDelegate { ReminderReferenceService(this.context) { // Initialize locale _locale = context.locale.toLanguageTag(); @@ -37,7 +38,8 @@ class ReminderReferenceService { List options = []; - Future reminderReferenceDelegate([ + @override + Future search([ String? search, ]) async { // Checks if Locale has changed since last @@ -111,13 +113,13 @@ class ReminderReferenceService { final result = await DateService.queryDate(search); result.fold( - (l) {}, (date) { // Only insert dates in the future if (DateTime.now().isBefore(date)) { options.insert(0, _itemFromDate(date)); } }, + (_) {}, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart index c1004d63f25a8..845eaf8c69ab0 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart @@ -41,7 +41,7 @@ Future inlineActionsCommandHandler( final List initialResults = []; for (final handler in service.handlers) { - final group = await handler(); + final group = await handler.search(null); if (group.results.isNotEmpty) { initialResults.add(group); diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart index d3a5623d2f866..3bdd5bf61a4d9 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart @@ -1,9 +1,6 @@ -import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:flutter/material.dart'; -typedef InlineActionsDelegate = Future Function([ - String? search, -]); +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; abstract class _InlineActionsProvider { void dispose(); @@ -26,7 +23,10 @@ class InlineActionsService extends _InlineActionsProvider { /// we set the [BuildContext] to null. /// @override - void dispose() { + Future dispose() async { + for (final handler in handlers) { + await handler.dispose(); + } context = null; } } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart new file mode 100644 index 0000000000000..de537fb964a92 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart @@ -0,0 +1,7 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; + +abstract class InlineActionsDelegate { + Future search(String? search); + + Future dispose() async {} +} 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 6f8babf22f867..a19dffbd02834 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,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; @@ -7,8 +10,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; /// All heights are in physical pixels const double _groupTextHeight = 14; // 12 height + 2 bottom spacing @@ -91,7 +92,7 @@ class _InlineActionsHandlerState extends State { Future _doSearch() async { final List newResults = []; for (final handler in widget.service.handlers) { - final group = await handler.call(_search); + final group = await handler.search(_search); if (group.results.isNotEmpty) { newResults.add(group); @@ -208,6 +209,10 @@ class _InlineActionsHandlerState extends State { results[groupIndex].results[handlerIndex]; KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + const moveKeys = [ LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart index c2ce55cf105ba..64ce2940c5b81 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart @@ -3,7 +3,7 @@ import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -26,13 +26,15 @@ class TrashBloc extends Bloc { initial: (e) async { _listener.start(trashUpdated: _listenTrashUpdated); final result = await _service.readTrash(); + emit( result.fold( (object) => state.copyWith( objects: object.items, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), ), - (error) => state.copyWith(successOrFailure: right(error)), + (error) => + state.copyWith(successOrFailure: FlowyResult.failure(error)), ), ); }, @@ -60,18 +62,20 @@ class TrashBloc extends Bloc { } Future _handleResult( - Either result, + FlowyResult result, Emitter emit, ) async { emit( result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), + (l) => state.copyWith(successOrFailure: FlowyResult.success(null)), + (error) => state.copyWith(successOrFailure: FlowyResult.failure(error)), ), ); } - void _listenTrashUpdated(Either, FlowyError> trashOrFailed) { + void _listenTrashUpdated( + FlowyResult, FlowyError> trashOrFailed, + ) { trashOrFailed.fold( (trash) { add(TrashEvent.didReceiveTrash(trash)); @@ -103,11 +107,11 @@ class TrashEvent with _$TrashEvent { class TrashState with _$TrashState { const factory TrashState({ required List objects, - required Either successOrFailure, + required FlowyResult successOrFailure, }) = _TrashState; factory TrashState.init() => TrashState( objects: [], - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart index eddf355166303..1d1a8f1ed8b66 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart @@ -1,15 +1,16 @@ import 'dart:async'; import 'dart:typed_data'; + import 'package:appflowy/core/notification/folder_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; typedef TrashUpdatedCallback = void Function( - Either, FlowyError> trashOrFailed, + FlowyResult, FlowyError> trashOrFailed, ); class TrashListener { @@ -29,7 +30,7 @@ class TrashListener { void _observableCallback( FolderNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateTrash: @@ -37,9 +38,9 @@ class TrashListener { result.fold( (payload) { final repeatedTrash = RepeatedTrashPB.fromBuffer(payload); - _trashUpdated!(left(repeatedTrash.items)); + _trashUpdated!(FlowyResult.success(repeatedTrash.items)); }, - (error) => _trashUpdated!(right(error)), + (error) => _trashUpdated!(FlowyResult.failure(error)), ); } break; diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart index 96ae6be339d76..b6d319009b906 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart @@ -1,21 +1,22 @@ import 'dart:async'; -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-folder/trash.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class TrashService { - Future> readTrash() { + Future> readTrash() { return FolderEventListTrashItems().send(); } - Future> putback(String trashId) { + Future> putback(String trashId) { final id = TrashIdPB.create()..id = trashId; return FolderEventRestoreTrashItem(id).send(); } - Future> deleteViews(List trash) { + Future> deleteViews(List trash) { final items = trash.map((trash) { return TrashIdPB.create()..id = trash; }); @@ -24,11 +25,11 @@ class TrashService { return FolderEventPermanentlyDeleteTrashItem(ids).send(); } - Future> restoreAll() { + Future> restoreAll() { return FolderEventRecoverAllTrashItems().send(); } - Future> deleteAll() { + Future> deleteAll() { return FolderEventPermanentlyDeleteAllTrashItem().send(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/util.dart b/frontend/appflowy_flutter/lib/plugins/util.dart index 7c7c3c317f9a5..d7ec567abe097 100644 --- a/frontend/appflowy_flutter/lib/plugins/util.dart +++ b/frontend/appflowy_flutter/lib/plugins/util.dart @@ -1,18 +1,17 @@ import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; -class ViewPluginNotifier extends PluginNotifier> { +class ViewPluginNotifier extends PluginNotifier { ViewPluginNotifier({ required this.view, }) : _viewListener = ViewListener(viewId: view.id) { _viewListener?.start( onViewUpdated: (updatedView) => view = updatedView, onViewMoveToTrash: (result) => result.fold( - (deletedView) => isDeleted.value = some(deletedView), + (deletedView) => isDeleted.value = deletedView, (err) => Log.error(err), ), ); @@ -22,7 +21,7 @@ class ViewPluginNotifier extends PluginNotifier> { final ViewListener? _viewListener; @override - final ValueNotifier> isDeleted = ValueNotifier(none()); + final ValueNotifier isDeleted = ValueNotifier(null); @override void dispose() { diff --git a/frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart b/frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart new file mode 100644 index 0000000000000..32b993938a9cd --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart @@ -0,0 +1,25 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension AFRolePBExtension on AFRolePB { + bool get isOwner => this == AFRolePB.Owner; + + bool get canInvite => isOwner; + + bool get canDelete => isOwner; + + bool get canUpdate => isOwner; + + String get description { + switch (this) { + case AFRolePB.Owner: + return LocaleKeys.settings_appearance_members_owner.tr(); + case AFRolePB.Member: + return LocaleKeys.settings_appearance_members_member.tr(); + case AFRolePB.Guest: + return LocaleKeys.settings_appearance_members_guest.tr(); + } + throw UnimplementedError('Unknown role: $this'); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart b/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart new file mode 100644 index 0000000000000..4036d37b77edf --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:path_provider/path_provider.dart'; + +class FlowyCacheManager { + final _caches = []; + + // if you add a new cache, you should register it here. + void registerCache(ICache cache) { + _caches.add(cache); + } + + void unregisterAllCache(ICache cache) { + _caches.clear(); + } + + Future clearAllCache() async { + try { + for (final cache in _caches) { + await cache.clearAll(); + } + + Log.info('Cache cleared'); + } catch (e) { + Log.error(e); + } + } + + Future getCacheSize() async { + try { + int tmpDirSize = 0; + for (final cache in _caches) { + tmpDirSize += await cache.cacheSize(); + } + Log.info('Cache size: $tmpDirSize'); + return tmpDirSize; + } catch (e) { + Log.error(e); + return 0; + } + } +} + +abstract class ICache { + Future cacheSize(); + Future clearAll(); +} + +class TemporaryDirectoryCache implements ICache { + @override + Future cacheSize() async { + final tmpDir = await getTemporaryDirectory(); + final tmpDirStat = await tmpDir.stat(); + return tmpDirStat.size; + } + + @override + Future clearAll() async { + final tmpDir = await getTemporaryDirectory(); + await tmpDir.delete(recursive: true); + } +} + +class FeatureFlagCache implements ICache { + @override + Future cacheSize() async { + return 0; + } + + @override + Future clearAll() async { + await FeatureFlag.clear(); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart index 72c6be583e380..d37b2e0838a00 100644 --- a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -39,8 +39,10 @@ class FlowyNetworkImage extends StatelessWidget { assert(userProfilePB != null && userProfilePB!.token.isNotEmpty); } + final manager = CustomImageCacheManager(); + return CachedNetworkImage( - cacheManager: CustomImageCacheManager(), + cacheManager: manager, httpHeaders: _header(), imageUrl: url, fit: fit, @@ -50,6 +52,12 @@ class FlowyNetworkImage extends StatelessWidget { errorWidget: (context, url, error) => errorWidgetBuilder?.call(context, url, error) ?? const SizedBox.shrink(), + errorListener: (value) { + // try to clear the image cache. + manager.removeFile(url); + + Log.error(value.toString()); + }, ); } diff --git a/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart b/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart index 5e5e677193c86..b0624301c1fbb 100644 --- a/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart +++ b/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart @@ -1,6 +1,9 @@ +import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -class CustomImageCacheManager extends CacheManager with ImageCacheManager { +class CustomImageCacheManager extends CacheManager + with ImageCacheManager + implements ICache { CustomImageCacheManager._() : super(Config(key)); factory CustomImageCacheManager() => _instance; @@ -8,4 +11,16 @@ class CustomImageCacheManager extends CacheManager with ImageCacheManager { static final CustomImageCacheManager _instance = CustomImageCacheManager._(); static const key = 'appflowy_image_cache'; + + @override + Future cacheSize() async { + // https://github.com/Baseflow/flutter_cache_manager/issues/239#issuecomment-719475429 + // this package does not provide a way to get the cache size + return 0; + } + + @override + Future clearAll() async { + await emptyCache(); + } } diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart new file mode 100644 index 0000000000000..feb08611909b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:collection/collection.dart'; + +typedef FeatureFlagMap = Map; + +/// The [FeatureFlag] is used to control the front-end features of the app. +/// +/// For example, if your feature is still under development, +/// you can set the value to `false` to hide the feature. +enum FeatureFlag { + // used to control the visibility of the collaborative workspace feature + // if it's on, you can see the workspace list and the workspace settings + // in the top-left corner of the app + collaborativeWorkspace, + + // used to control the visibility of the members settings + // if it's on, you can see the members settings in the settings page + membersSettings, + + // used to control the sync feature of the document + // if it's on, the document will be synced the events from server in real-time + syncDocument, + + // used for ignore the conflicted feature flag + unknown; + + static Future initialize() async { + final values = await getIt().getWithFormat( + KVKeys.featureFlag, + (value) => Map.from(jsonDecode(value)).map( + (key, value) { + final k = FeatureFlag.values.firstWhereOrNull( + (e) => e.name == key, + ) ?? + FeatureFlag.unknown; + return MapEntry(k, value as bool); + }, + ), + ) ?? + {}; + + _values = { + ...{for (final flag in FeatureFlag.values) flag: false}, + ...values, + }; + } + + static UnmodifiableMapView get data => + UnmodifiableMapView(_values); + + Future turnOn() async { + await update(true); + } + + Future turnOff() async { + await update(false); + } + + Future update(bool value) async { + _values[this] = value; + + await getIt().set( + KVKeys.featureFlag, + jsonEncode( + _values.map((key, value) => MapEntry(key.name, value)), + ), + ); + } + + static Future clear() async { + _values = {}; + await getIt().remove(KVKeys.featureFlag); + } + + bool get isOn { + if (_values.containsKey(this)) { + return _values[this]!; + } + + switch (this) { + case FeatureFlag.collaborativeWorkspace: + return true; + case FeatureFlag.membersSettings: + return true; + case FeatureFlag.syncDocument: + return false; + case FeatureFlag.unknown: + return false; + } + } + + String get description { + switch (this) { + case FeatureFlag.collaborativeWorkspace: + return 'if it\'s on, you can see the workspace list and the workspace settings in the top-left corner of the app'; + case FeatureFlag.membersSettings: + return 'if it\'s on, you can see the members settings in the settings page'; + case FeatureFlag.syncDocument: + return 'if it\'s on, the document will be synced the events from server in real-time'; + case FeatureFlag.unknown: + return ''; + } + } + + String get key => 'appflowy_feature_flag_${toString()}'; +} + +FeatureFlagMap _values = {}; diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart new file mode 100644 index 0000000000000..fce937908af56 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -0,0 +1,23 @@ +const _trailingZerosPattern = r'^(\d+(?:\.\d*?[1-9](?=0|\b))?)\.?0*$'; +final trailingZerosRegex = RegExp(_trailingZerosPattern); + +const _hrefPattern = + r'https?://(?:www\.)?[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(?:/[^\s]*)?'; +final hrefRegex = RegExp(_hrefPattern); + +/// This pattern allows for both HTTP and HTTPS Scheme +/// It allows for query parameters +/// It only allows the following image extensions: .png, .jpg, .gif, .webm +/// +const _imgUrlPattern = + r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?'; +final imgUrlRegex = RegExp(_imgUrlPattern); + +const _appflowyCloudUrlPattern = r'^(https:\/\/)(.*)(\.appflowy\.cloud\/)(.*)'; +final appflowyCloudUrlRegex = RegExp(_appflowyCloudUrlPattern); + +const _camelCasePattern = '(?<=[a-z])[A-Z]'; +final camelCaseRegex = RegExp(_camelCasePattern); + +const _macOSVolumesPattern = '^/Volumes/[^/]+'; +final macOSVolumesRegex = RegExp(_macOSVolumesPattern); diff --git a/frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart new file mode 100644 index 0000000000000..530e9d4558515 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart @@ -0,0 +1,19 @@ +/// RegExp to match Twelve Hour formats +/// Source: https://stackoverflow.com/a/33906224 +/// +/// Matches eg: "05:05 PM", "5:50 Pm", "10:59 am", etc. +/// +const _twelveHourTimePattern = + r'\b((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))'; +final twelveHourTimeRegex = RegExp(_twelveHourTimePattern); +bool isTwelveHourTime(String? time) => twelveHourTimeRegex.hasMatch(time ?? ''); + +/// RegExp to match Twenty Four Hour formats +/// Source: https://stackoverflow.com/a/7536768 +/// +/// Matches eg: "0:01", "04:59", "16:30", etc. +/// +const _twentyFourHourtimePattern = r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'; +final tewentyFourHourTimeRegex = RegExp(_twentyFourHourtimePattern); +bool isTwentyFourHourTime(String? time) => + tewentyFourHourTimeRegex.hasMatch(time ?? ''); diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 5abfc34b790cb..c2759ab2c84a3 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -6,6 +6,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/stability_ai/stability_ai_client.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart'; @@ -90,15 +92,15 @@ void _resolveCommonService( () async { final result = await UserBackendService.getCurrentUserProfile(); return result.fold( - (l) { - throw Exception('Failed to get user profile: ${l.msg}'); - }, - (r) { + (s) { return HttpOpenAIRepository( client: http.Client(), - apiKey: r.openaiKey, + apiKey: s.openaiKey, ); }, + (e) { + throw Exception('Failed to get user profile: ${e.msg}'); + }, ); }, ); @@ -107,15 +109,15 @@ void _resolveCommonService( () async { final result = await UserBackendService.getCurrentUserProfile(); return result.fold( - (l) { - throw Exception('Failed to get user profile: ${l.msg}'); - }, - (r) { + (s) { return HttpStabilityAIRepository( client: http.Client(), - apiKey: r.stabilityAiKey, + apiKey: s.stabilityAiKey, ); }, + (e) { + throw Exception('Failed to get user profile: ${e.msg}'); + }, ); }, ); @@ -128,6 +130,13 @@ void _resolveCommonService( getIt.registerFactory( () => PlatformExtension.isMobile ? MobileAppearance() : DesktopAppearance(), ); + + getIt.registerFactory( + () => FlowyCacheManager() + ..registerCache(TemporaryDirectoryCache()) + ..registerCache(CustomImageCacheManager()) + ..registerCache(FeatureFlagCache()), + ); } void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { diff --git a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart index 197cd98c07517..c5b88b361d2c5 100644 --- a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart +++ b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart @@ -1,11 +1,12 @@ library flowy_plugin; +import 'package:flutter/widgets.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/widgets.dart'; export "./src/sandbox.dart"; @@ -29,6 +30,8 @@ abstract class Plugin { PluginType get pluginType; + void init() {} + void dispose() { notifier?.dispose(); } diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 4b5c97346812e..38a4911da8e8b 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/startup/tasks/feature_flag_task.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:flutter/foundation.dart'; @@ -113,6 +114,7 @@ class FlowyRunner { // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored. MemoryLeakDetectorTask(), const DebugTask(), + const FeatureFlagTask(), // localization const InitLocalizationTask(), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index b1ecef58c0ba6..4b5978297666f 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:appflowy/mobile/application/mobile_router.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; @@ -61,11 +61,13 @@ class InitAppWidgetTask extends LaunchTask { Locale('am', 'ET'), Locale('ar', 'SA'), Locale('ca', 'ES'), + Locale('cs', 'CZ'), Locale('ckb', 'KU'), Locale('de', 'DE'), Locale('en'), Locale('es', 'VE'), Locale('eu', 'ES'), + Locale('el', 'GR'), Locale('fr', 'FR'), Locale('fr', 'CA'), Locale('hu', 'HU'), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart index 9683039465a7c..d8723ac232814 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart @@ -32,7 +32,7 @@ class WindowSizeManager { final defaultWindowSize = jsonEncode({height: 600.0, width: 800.0}); final windowSize = await getIt().get(KVKeys.windowSize); final size = json.decode( - windowSize.getOrElse(() => defaultWindowSize), + windowSize ?? defaultWindowSize, ); return Size(size[width]!, size[height]!); } @@ -49,12 +49,10 @@ class WindowSizeManager { Future getPosition() async { final position = await getIt().get(KVKeys.windowPosition); - return position.fold( - () => null, - (r) { - final offset = json.decode(r); - return Offset(offset[dx], offset[dy]); - }, - ); + if (position == null) { + return null; + } + final offset = json.decode(position); + return Offset(offset[dx], offset[dy]); } } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 4a52c109f7c8b..75c43f64fd5d0 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -16,7 +16,7 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; import 'package:url_protocol/url_protocol.dart'; @@ -29,7 +29,7 @@ class AppFlowyCloudDeepLink { await _handleUri(uri); }, onError: (Object err, StackTrace stackTrace) { - Log.error('on deeplink stream error: ${err.toString()}', stackTrace); + Log.error('on DeepLink stream error: ${err.toString()}', stackTrace); _deeplinkSubscription?.cancel(); _deeplinkSubscription = null; }, @@ -46,7 +46,7 @@ class AppFlowyCloudDeepLink { final _appLinks = AppLinks(); ValueNotifier? _stateNotifier = ValueNotifier(null); - Completer>? _completer; + Completer>? _completer; // The AppLinks is a singleton, so we need to cancel the previous subscription // before creating a new one. @@ -58,8 +58,8 @@ class AppFlowyCloudDeepLink { _stateNotifier = null; } - void resigerCompleter( - Completer> completer, + void registerCompleter( + Completer> completer, ) { _completer = completer; } @@ -86,11 +86,13 @@ class AppFlowyCloudDeepLink { _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.none); if (uri == null) { - Log.error('onDeepLinkError: Unexpect empty deep link callback'); - _completer?.complete(left(AuthError.emptyDeeplink)); + Log.error('onDeepLinkError: Unexpected empty deep link callback'); + _completer?.complete(FlowyResult.failure(AuthError.emptyDeepLink)); _completer = null; + return; } - return _isAuthCallbackDeeplink(uri!).fold( + + return _isAuthCallbackDeepLink(uri).fold( (_) async { final deviceId = await getDeviceId(); final payload = OauthSignInPB( @@ -101,9 +103,7 @@ class AppFlowyCloudDeepLink { }, ); _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.loading); - final result = await UserEventOauthSignIn(payload) - .send() - .then((value) => value.swap()); + final result = await UserEventOauthSignIn(payload).send(); _stateNotifier?.value = DeepLinkResult( state: DeepLinkState.finish, @@ -112,6 +112,9 @@ class AppFlowyCloudDeepLink { // If there is no completer, runAppFlowy() will be called. if (_completer == null) { await result.fold( + (_) async { + await runAppFlowy(); + }, (err) { Log.error(err); final context = AppGlobals.rootNavKey.currentState?.context; @@ -122,9 +125,6 @@ class AppFlowyCloudDeepLink { ); } }, - (_) async { - await runAppFlowy(); - }, ); } else { _completer?.complete(result); @@ -132,7 +132,7 @@ class AppFlowyCloudDeepLink { } }, (err) { - Log.error('onDeepLinkError: Unexpect deep link: $err'); + Log.error('onDeepLinkError: Unexpected deep link: $err'); if (_completer == null) { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { @@ -142,19 +142,19 @@ class AppFlowyCloudDeepLink { ); } } else { - _completer?.complete(left(err)); + _completer?.complete(FlowyResult.failure(err)); _completer = null; } }, ); } - Either<(), FlowyError> _isAuthCallbackDeeplink(Uri uri) { + FlowyResult _isAuthCallbackDeepLink(Uri uri) { if (uri.fragment.contains('access_token')) { - return left(()); + return FlowyResult.success(null); } - return right( + return FlowyResult.failure( FlowyError.create() ..code = ErrorCode.MissingAuthField ..msg = uri.path, @@ -197,7 +197,7 @@ class DeepLinkResult { DeepLinkResult({required this.state, this.result}); final DeepLinkState state; - final Either? result; + final FlowyResult? result; } enum DeepLinkState { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart new file mode 100644 index 0000000000000..fd2439c89224e --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart @@ -0,0 +1,21 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:flutter/foundation.dart'; + +import '../startup.dart'; + +class FeatureFlagTask extends LaunchTask { + const FeatureFlagTask(); + + @override + Future initialize(LaunchContext context) async { + // the hotkey manager is not supported on mobile + if (!kDebugMode) { + return; + } + + await FeatureFlag.initialize(); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 3ba8f4bf70342..e9d7e13d2e523 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -463,14 +463,14 @@ GoRoute _signInScreenRoute() { GoRoute _mobileEditorScreenRoute() { return GoRoute( - path: MobileEditorScreen.routeName, + path: MobileDocumentScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { - final id = state.uri.queryParameters[MobileEditorScreen.viewId]!; - final title = state.uri.queryParameters[MobileEditorScreen.viewTitle]; + final id = state.uri.queryParameters[MobileDocumentScreen.viewId]!; + final title = state.uri.queryParameters[MobileDocumentScreen.viewTitle]; return MaterialExtendedPage( - child: MobileEditorScreen(id: id, title: title), + child: MobileDocumentScreen(id: id, title: title), ); }, ); @@ -580,8 +580,8 @@ GoRoute _rootRoute(Widget child) { // Every time before navigating to splash screen, we check if user is already logged in in desktop. It is used to skip showing splash screen when user just changes apperance settings like theme mode. final userResponse = await getIt().getUser(); final routeName = userResponse.fold( - (error) => null, (user) => DesktopHomeScreen.routeName, + (error) => null, ); if (routeName != null && !PlatformExtension.isMobile) return routeName; 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 737de56eb7958..010ffac63e202 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 @@ -1,14 +1,15 @@ import 'dart:async'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; -import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:url_launcher/url_launcher.dart'; import 'auth_error.dart'; @@ -21,7 +22,7 @@ class AppFlowyCloudAuthService implements AuthService { ); @override - Future> signUp({ + Future> signUp({ required String name, required String email, required String password, @@ -31,7 +32,7 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -40,7 +41,7 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> signUpWithOAuth({ + Future> signUpWithOAuth({ required String platform, Map params = const {}, }) async { @@ -55,28 +56,30 @@ class AppFlowyCloudAuthService implements AuthService { (data) async { // Open the webview with oauth url final uri = Uri.parse(data.oauthUrl); - final isSuccess = await launchUrl( + final isSuccess = await afLaunchUrl( uri, mode: LaunchMode.externalApplication, webOnlyWindowName: '_self', ); - final completer = Completer>(); + final completer = Completer>(); if (isSuccess) { // The [AppFlowyCloudDeepLink] must be registered before using the // [AppFlowyCloudAuthService]. if (getIt.isRegistered()) { - getIt().resigerCompleter(completer); + getIt().registerCompleter(completer); } else { throw Exception('AppFlowyCloudDeepLink is not registered'); } } else { - completer.complete(left(AuthError.signInWithOauthError)); + completer.complete( + FlowyResult.failure(AuthError.unableToGetDeepLink), + ); } return completer.future; }, - (r) => left(r), + (r) => FlowyResult.failure(r), ); } @@ -86,14 +89,14 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> signUpAsGuest({ + Future> signUpAsGuest({ Map params = const {}, }) async { return _backendAuthService.signUpAsGuest(); } @override - Future> signInWithMagicLink({ + Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { @@ -101,7 +104,7 @@ class AppFlowyCloudAuthService implements AuthService { } @override - Future> getUser() async { + Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart index df6dc89eb1f59..deba0f37003fa 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart @@ -1,14 +1,14 @@ import 'dart:async'; -import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/uuid.dart'; /// Only used for testing. @@ -22,7 +22,7 @@ class AppFlowyCloudMockAuthService implements AuthService { BackendAuthService(AuthenticatorPB.Supabase); @override - Future> signUp({ + Future> signUp({ required String name, required String email, required String password, @@ -32,7 +32,7 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -41,7 +41,7 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> signUpWithOAuth({ + Future> signUpWithOAuth({ required String platform, Map params = const {}, }) async { @@ -65,12 +65,12 @@ class AppFlowyCloudMockAuthService implements AuthService { Log.info("UserEventOauthSignIn with payload: $payload"); return UserEventOauthSignIn(payload).send().then((value) { value.fold((l) => null, (err) => Log.error(err)); - return value.swap(); + return value; }); }, (r) { Log.error(r); - return left(r); + return FlowyResult.failure(r); }, ); } @@ -81,14 +81,14 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> signUpAsGuest({ + Future> signUpAsGuest({ Map params = const {}, }) async { return _appFlowyAuthService.signUpAsGuest(); } @override - Future> signInWithMagicLink({ + Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { @@ -96,7 +96,7 @@ class AppFlowyCloudMockAuthService implements AuthService { } @override - Future> getUser() async { + Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart index 1415672d78879..06ddd9023841c 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart @@ -22,11 +22,15 @@ class AuthError { ..msg = 'sign in with oauth error -10003' ..code = ErrorCode.UserUnauthorized; - static final emptyDeeplink = FlowyError() - ..msg = 'Unexpected empty deeplink' - ..code = ErrorCode.UnexpectedEmpty; + static final emptyDeepLink = FlowyError() + ..msg = 'Unexpected empty DeepLink' + ..code = ErrorCode.UnexpectedCalendarFieldType; - static final deeplinkError = FlowyError() - ..msg = 'Deeplink error' + static final deepLinkError = FlowyError() + ..msg = 'DeepLink error' + ..code = ErrorCode.Internal; + + static final unableToGetDeepLink = FlowyError() + ..msg = 'Unable to get the deep link' ..code = ErrorCode.Internal; } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart index 82fecd6715647..201b682484aa3 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart @@ -1,6 +1,6 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class AuthServiceMapKeys { const AuthServiceMapKeys._(); @@ -25,7 +25,7 @@ abstract class AuthService { /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signInWithEmailPassword({ + Future> signInWithEmailPassword({ required String email, required String password, Map params, @@ -39,7 +39,7 @@ abstract class AuthService { /// - `params`: Additional parameters for registration (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signUp({ + Future> signUp({ required String name, required String email, required String password, @@ -52,7 +52,7 @@ abstract class AuthService { /// - `params`: Additional parameters for OAuth registration (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signUpWithOAuth({ + Future> signUpWithOAuth({ required String platform, Map params, }); @@ -62,7 +62,7 @@ abstract class AuthService { /// - `params`: Additional parameters for guest registration (optional). /// /// Returns a default [UserProfilePB]. - Future> signUpAsGuest({ + Future> signUpAsGuest({ Map params, }); @@ -72,7 +72,7 @@ abstract class AuthService { /// - `params`: Additional parameters for authentication with magic link (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. - Future> signInWithMagicLink({ + Future> signInWithMagicLink({ required String email, Map params, }); @@ -83,5 +83,5 @@ abstract class AuthService { /// Retrieves the currently authenticated user's profile. /// /// Returns [UserProfilePB] if the user has signed in, otherwise returns [FlowyError]. - Future> getUser(); + Future> getUser(); } diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart index 2c60e3e674b0b..c130a98d4575f 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -1,14 +1,14 @@ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/user_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; -import 'package:dartz/dartz.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; import '../../../generated/locale_keys.g.dart'; import 'device_id.dart'; @@ -19,7 +19,7 @@ class BackendAuthService implements AuthService { final AuthenticatorPB authType; @override - Future> signInWithEmailPassword({ + Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -30,11 +30,11 @@ class BackendAuthService implements AuthService { ..authType = authType ..deviceId = await getDeviceId(); final response = UserEventSignInWithEmailPassword(request).send(); - return response.then((value) => value.swap()); + return response.then((value) => value); } @override - Future> signUp({ + Future> signUp({ required String name, required String email, required String password, @@ -47,7 +47,7 @@ class BackendAuthService implements AuthService { ..authType = authType ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( - (value) => value.swap(), + (value) => value, ); return response; } @@ -61,7 +61,7 @@ class BackendAuthService implements AuthService { } @override - Future> signUpAsGuest({ + Future> signUpAsGuest({ Map params = const {}, }) async { const password = "Guest!@123456"; @@ -76,18 +76,18 @@ class BackendAuthService implements AuthService { ..authType = AuthenticatorPB.Local ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( - (value) => value.swap(), + (value) => value, ); return response; } @override - Future> signUpWithOAuth({ + Future> signUpWithOAuth({ required String platform, AuthenticatorPB authType = AuthenticatorPB.Local, Map params = const {}, }) async { - return left( + return FlowyResult.failure( FlowyError.create() ..code = ErrorCode.Internal ..msg = "Unsupported sign up action", @@ -95,16 +95,16 @@ class BackendAuthService implements AuthService { } @override - Future> getUser() async { + Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } @override - Future> signInWithMagicLink({ + Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { - return left( + return FlowyResult.failure( FlowyError.create() ..code = ErrorCode.Internal ..msg = "Unsupported sign up action", diff --git a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart index a8758d2c9f87b..ba8161d5a068d 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart @@ -12,26 +12,26 @@ Future getDeviceId() async { return "test_device_id"; } - String deviceId = ""; + String? deviceId; try { if (Platform.isAndroid) { final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; deviceId = androidInfo.device; } else if (Platform.isIOS) { final IosDeviceInfo iosInfo = await deviceInfo.iosInfo; - deviceId = iosInfo.identifierForVendor ?? ""; + deviceId = iosInfo.identifierForVendor; } else if (Platform.isMacOS) { final MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo; - deviceId = macInfo.systemGUID ?? ""; + deviceId = macInfo.systemGUID; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; deviceId = windowsInfo.computerName; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; - deviceId = linuxInfo.machineId ?? ""; + deviceId = linuxInfo.machineId; } } on PlatformException { Log.error('Failed to get platform version'); } - return deviceId; + return deviceId ?? ''; } 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 38b3980711e63..0dc48d7ef7bfa 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 @@ -9,7 +9,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -26,7 +26,7 @@ class SupabaseAuthService implements AuthService { ); @override - Future> signUp({ + Future> signUp({ required String name, required String email, required String password, @@ -39,7 +39,7 @@ class SupabaseAuthService implements AuthService { ); final uuid = response.user?.id; if (uuid == null) { - return left(AuthError.supabaseSignUpError); + return FlowyResult.failure(AuthError.supabaseSignUpError); } // assign the uuid to our backend service. // and will transfer this logic to backend later. @@ -54,7 +54,7 @@ class SupabaseAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -66,7 +66,7 @@ class SupabaseAuthService implements AuthService { ); final uuid = response.user?.id; if (uuid == null) { - return Left(AuthError.supabaseSignInError); + return FlowyResult.failure(AuthError.supabaseSignInError); } return _backendAuthService.signInWithEmailPassword( email: email, @@ -77,12 +77,12 @@ class SupabaseAuthService implements AuthService { ); } on AuthException catch (e) { Log.error(e); - return Left(AuthError.supabaseSignInError); + return FlowyResult.failure(AuthError.supabaseSignInError); } } @override - Future> signUpWithOAuth({ + Future> signUpWithOAuth({ required String platform, Map params = const {}, }) async { @@ -110,7 +110,9 @@ class SupabaseAuthService implements AuthService { redirectTo: supabaseLoginCallback, ); if (!response) { - completer.complete(left(AuthError.supabaseSignInWithOauthError)); + completer.complete( + FlowyResult.failure(AuthError.supabaseSignInWithOauthError), + ); } return completer.future; } @@ -122,7 +124,7 @@ class SupabaseAuthService implements AuthService { } @override - Future> signUpAsGuest({ + Future> signUpAsGuest({ Map params = const {}, }) async { // supabase don't support guest login. @@ -131,7 +133,7 @@ class SupabaseAuthService implements AuthService { } @override - Future> signInWithMagicLink({ + Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { @@ -155,19 +157,19 @@ class SupabaseAuthService implements AuthService { } @override - Future> getUser() async { + Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } - Future> getSupabaseUser() async { + Future> getSupabaseUser() async { final user = _auth.currentUser; if (user == null) { - return left(AuthError.supabaseGetUserError); + return FlowyResult.failure(AuthError.supabaseGetUserError); } - return Right(user); + return FlowyResult.success(user); } - Future> _setupAuth({ + Future> _setupAuth({ required Map map, }) async { final payload = OauthSignInPB( @@ -175,7 +177,7 @@ class SupabaseAuthService implements AuthService { map: map, ); - return UserEventOauthSignIn(payload).send().then((value) => value.swap()); + return UserEventOauthSignIn(payload).send().then((value) => value); } } @@ -208,15 +210,15 @@ extension on String { /// a `FlowyError` or a `UserProfilePB`. /// /// Returns: -/// A completer of type `Either`. This completer completes +/// A completer of type `FlowyResult`. This completer completes /// with the response from the [onSuccess] callback when a user signs in. -Completer> supabaseLoginCompleter({ - required Future> Function( +Completer> supabaseLoginCompleter({ + required Future> Function( String userId, String userEmail, ) onSuccess, }) { - final completer = Completer>(); + final completer = Completer>(); late final StreamSubscription subscription; final auth = Supabase.instance.client.auth; 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 829c825e4c882..bd2620caaa504 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 @@ -1,13 +1,13 @@ import 'dart:async'; -import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'auth_error.dart'; @@ -24,7 +24,7 @@ class SupabaseMockAuthService implements AuthService { BackendAuthService(AuthenticatorPB.Supabase); @override - Future> signUp({ + Future> signUp({ required String name, required String email, required String password, @@ -34,7 +34,7 @@ class SupabaseMockAuthService implements AuthService { } @override - Future> signInWithEmailPassword({ + Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, @@ -43,7 +43,7 @@ class SupabaseMockAuthService implements AuthService { } @override - Future> signUpWithOAuth({ + Future> signUpWithOAuth({ required String platform, Map params = const {}, }) async { @@ -58,7 +58,7 @@ class SupabaseMockAuthService implements AuthService { ); } catch (e) { Log.error(e); - return Left(AuthError.supabaseSignUpError); + return FlowyResult.failure(AuthError.supabaseSignUpError); } } // Check if the user is already logged in. @@ -76,10 +76,10 @@ class SupabaseMockAuthService implements AuthService { ); // Send the sign-in event and handle the response. - return UserEventOauthSignIn(payload).send().then((value) => value.swap()); + return UserEventOauthSignIn(payload).send().then((value) => value); } on AuthException catch (e) { Log.error(e); - return Left(AuthError.supabaseSignInError); + return FlowyResult.failure(AuthError.supabaseSignInError); } } @@ -90,7 +90,7 @@ class SupabaseMockAuthService implements AuthService { } @override - Future> signUpAsGuest({ + Future> signUpAsGuest({ Map params = const {}, }) async { // supabase don't support guest login. @@ -99,7 +99,7 @@ class SupabaseMockAuthService implements AuthService { } @override - Future> signInWithMagicLink({ + Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { @@ -107,7 +107,7 @@ class SupabaseMockAuthService implements AuthService { } @override - Future> getUser() async { + Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } } diff --git a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart index 023b8f6056f59..19b8101ae8a9e 100644 --- a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart @@ -3,7 +3,7 @@ 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-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -39,7 +39,7 @@ class EncryptSecretBloc extends Bloc { emit( state.copyWith( loadingState: const LoadingState.loading(), - successOrFail: none(), + successOrFail: null, ), ); }, @@ -47,26 +47,26 @@ class EncryptSecretBloc extends Bloc { await getIt().signOut(); emit( state.copyWith( - successOrFail: none(), + successOrFail: null, isSignOut: true, ), ); }, - didFinishCheck: (Either result) { + didFinishCheck: (result) { result.fold( (unit) { emit( state.copyWith( loadingState: const LoadingState.loading(), - successOrFail: Some(result), + successOrFail: result, ), ); }, (err) { emit( state.copyWith( - loadingState: LoadingState.finish(right(err)), - successOrFail: Some(result), + loadingState: LoadingState.finish(FlowyResult.failure(err)), + successOrFail: result, ), ); }, @@ -94,7 +94,7 @@ class EncryptSecretEvent with _$EncryptSecretEvent { const factory EncryptSecretEvent.setEncryptSecret(String secret) = _SetEncryptSecret; const factory EncryptSecretEvent.didFinishCheck( - Either result, + FlowyResult result, ) = _DidFinishCheck; const factory EncryptSecretEvent.cancelInputSecret() = _CancelInputSecret; } @@ -102,13 +102,13 @@ class EncryptSecretEvent with _$EncryptSecretEvent { @freezed class EncryptSecretState with _$EncryptSecretState { const factory EncryptSecretState({ - required Option> successOrFail, + required FlowyResult? successOrFail, required bool isSignOut, LoadingState? loadingState, }) = _EncryptSecretState; - factory EncryptSecretState.initial() => EncryptSecretState( - successOrFail: none(), + factory EncryptSecretState.initial() => const EncryptSecretState( + successOrFail: null, isSignOut: false, ); } 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 abb6903de7623..a7fb47b40504e 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -59,8 +59,8 @@ class ReminderBloc extends Bloc { final remindersOrFailure = await _reminderService.fetchReminders(); remindersOrFailure.fold( - (error) => Log.error(error), (reminders) => emit(state.copyWith(reminders: reminders)), + (error) => Log.error(error), ); }, remove: (reminderId) async { @@ -68,12 +68,12 @@ class ReminderBloc extends Bloc { await _reminderService.removeReminder(reminderId: reminderId); unitOrFailure.fold( - (error) => Log.error(error), (_) { final reminders = [...state.reminders]; reminders.removeWhere((e) => e.id == reminderId); emit(state.copyWith(reminders: reminders)); }, + (error) => Log.error(error), ); }, add: (reminder) async { @@ -81,11 +81,11 @@ class ReminderBloc extends Bloc { await _reminderService.addReminder(reminder: reminder); return unitOrFailure.fold( - (error) => Log.error(error), (_) { final reminders = [...state.reminders, reminder]; emit(state.copyWith(reminders: reminders)); }, + (error) => Log.error(error), ); }, addById: (reminderId, objectId, scheduledAt, meta) async => add( @@ -115,7 +115,6 @@ class ReminderBloc extends Bloc { ); failureOrUnit.fold( - (error) => Log.error(error), (_) { final index = state.reminders.indexWhere((r) => r.id == reminder.id); @@ -123,6 +122,7 @@ class ReminderBloc extends Bloc { reminders.replaceRange(index, index + 1, [newReminder]); emit(state.copyWith(reminders: reminders)); }, + (error) => Log.error(error), ); }, pressReminder: (reminderId, path, view) { diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart index 4b9aaf1fb3c13..eed44f9cedafc 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart @@ -1,19 +1,23 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; /// Interface for a Reminder Service that handles /// communication to the backend /// abstract class IReminderService { - Future>> fetchReminders(); + Future, FlowyError>> fetchReminders(); - Future> removeReminder({required String reminderId}); + Future> removeReminder({ + required String reminderId, + }); - Future> addReminder({required ReminderPB reminder}); + Future> addReminder({ + required ReminderPB reminder, + }); - Future> updateReminder({ + Future> updateReminder({ required ReminderPB reminder, }); } @@ -22,37 +26,40 @@ class ReminderService implements IReminderService { const ReminderService(); @override - Future> addReminder({ + Future> addReminder({ required ReminderPB reminder, }) async { final unitOrFailure = await UserEventCreateReminder(reminder).send(); - return unitOrFailure.swap(); + return unitOrFailure; } @override - Future> updateReminder({ + Future> updateReminder({ required ReminderPB reminder, }) async { final unitOrFailure = await UserEventUpdateReminder(reminder).send(); - return unitOrFailure.swap(); + return unitOrFailure; } @override - Future>> fetchReminders() async { + Future, FlowyError>> fetchReminders() async { final resultOrFailure = await UserEventGetAllReminders().send(); - return resultOrFailure.swap().fold((l) => left(l), (r) => right(r.items)); + return resultOrFailure.fold( + (s) => FlowyResult.success(s.items), + (e) => FlowyResult.failure(e), + ); } @override - Future> removeReminder({ + Future> removeReminder({ required String reminderId, }) async { final request = ReminderIdentifierPB(id: reminderId); final unitOrFailure = await UserEventRemoveReminder(request).send(); - return unitOrFailure.swap(); + return unitOrFailure; } } diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 665407bccaa9a..0cc39df691a9d 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -6,7 +6,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -58,8 +58,8 @@ class SignInBloc extends Bloc { emit( state.copyWith( email: value.email, - emailError: none(), - successOrFail: none(), + emailError: null, + successOrFail: null, ), ); }, @@ -67,8 +67,8 @@ class SignInBloc extends Bloc { emit( state.copyWith( password: value.password, - passwordError: none(), - successOrFail: none(), + passwordError: null, + successOrFail: null, ), ); }, @@ -85,20 +85,20 @@ class SignInBloc extends Bloc { emit( state.copyWith( isSubmitting: true, - emailError: none(), - passwordError: none(), - successOrFail: none(), + emailError: null, + passwordError: null, + successOrFail: null, ), ); case DeepLinkState.finish: if (value.result.result != null) { emit( value.result.result!.fold( - (error) => stateFromCode(error), (userProfile) => state.copyWith( isSubmitting: false, - successOrFail: some(left(userProfile)), + successOrFail: FlowyResult.success(userProfile), ), + (error) => stateFromCode(error), ), ); } @@ -108,9 +108,9 @@ class SignInBloc extends Bloc { emit( state.copyWith( isSubmitting: false, - emailError: none(), - passwordError: none(), - successOrFail: none(), + emailError: null, + passwordError: null, + successOrFail: null, ), ); }, @@ -129,11 +129,11 @@ class SignInBloc extends Bloc { ); emit( result.fold( - (error) => stateFromCode(error), (userProfile) => state.copyWith( isSubmitting: false, - successOrFail: some(left(userProfile)), + successOrFail: FlowyResult.success(userProfile), ), + (error) => stateFromCode(error), ), ); } @@ -146,9 +146,9 @@ class SignInBloc extends Bloc { emit( state.copyWith( isSubmitting: true, - emailError: none(), - passwordError: none(), - successOrFail: none(), + emailError: null, + passwordError: null, + successOrFail: null, ), ); @@ -157,11 +157,11 @@ class SignInBloc extends Bloc { ); emit( result.fold( - (error) => stateFromCode(error), (userProfile) => state.copyWith( isSubmitting: false, - successOrFail: some(left(userProfile)), + successOrFail: FlowyResult.success(userProfile), ), + (error) => stateFromCode(error), ), ); } @@ -174,22 +174,23 @@ class SignInBloc extends Bloc { emit( state.copyWith( isSubmitting: true, - emailError: none(), - passwordError: none(), - successOrFail: none(), + emailError: null, + passwordError: null, + successOrFail: null, ), ); final result = await authService.signInWithMagicLink( email: email, ); + emit( result.fold( - (error) => stateFromCode(error), (userProfile) => state.copyWith( isSubmitting: false, - successOrFail: some(left(userProfile)), + successOrFail: FlowyResult.success(userProfile), ), + (error) => stateFromCode(error), ), ); } @@ -201,20 +202,20 @@ class SignInBloc extends Bloc { emit( state.copyWith( isSubmitting: true, - emailError: none(), - passwordError: none(), - successOrFail: none(), + emailError: null, + passwordError: null, + successOrFail: null, ), ); final result = await authService.signUpAsGuest(); emit( result.fold( - (error) => stateFromCode(error), (userProfile) => state.copyWith( isSubmitting: false, - successOrFail: some(left(userProfile)), + successOrFail: FlowyResult.success(userProfile), ), + (error) => stateFromCode(error), ), ); } @@ -224,19 +225,19 @@ class SignInBloc extends Bloc { case ErrorCode.EmailFormatInvalid: return state.copyWith( isSubmitting: false, - emailError: some(error.msg), - passwordError: none(), + emailError: error.msg, + passwordError: null, ); case ErrorCode.PasswordFormatInvalid: return state.copyWith( isSubmitting: false, - passwordError: some(error.msg), - emailError: none(), + passwordError: error.msg, + emailError: null, ); default: return state.copyWith( isSubmitting: false, - successOrFail: some(right(error)), + successOrFail: FlowyResult.failure(error), ); } } @@ -264,15 +265,15 @@ class SignInState with _$SignInState { String? email, String? password, required bool isSubmitting, - required Option passwordError, - required Option emailError, - required Option> successOrFail, + required String? passwordError, + required String? emailError, + required FlowyResult? successOrFail, }) = _SignInState; - factory SignInState.initial() => SignInState( + factory SignInState.initial() => const SignInState( isSubmitting: false, - passwordError: none(), - emailError: none(), - successOrFail: none(), + passwordError: null, + emailError: null, + successOrFail: null, ); } diff --git a/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart index a7cd6e29c5b12..1935d00c6d8e2 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart @@ -1,10 +1,10 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -29,8 +29,8 @@ class SignUpBloc extends Bloc { emit( state.copyWith( email: value.email, - emailError: none(), - successOrFail: none(), + emailError: null, + successOrFail: null, ), ); }, @@ -38,8 +38,8 @@ class SignUpBloc extends Bloc { emit( state.copyWith( password: value.password, - passwordError: none(), - successOrFail: none(), + passwordError: null, + successOrFail: null, ), ); }, @@ -47,8 +47,8 @@ class SignUpBloc extends Bloc { emit( state.copyWith( repeatedPassword: value.password, - repeatPasswordError: none(), - successOrFail: none(), + repeatPasswordError: null, + successOrFail: null, ), ); }, @@ -61,7 +61,7 @@ class SignUpBloc extends Bloc { emit( state.copyWith( isSubmitting: true, - successOrFail: none(), + successOrFail: null, ), ); @@ -71,7 +71,7 @@ class SignUpBloc extends Bloc { emit( state.copyWith( isSubmitting: false, - passwordError: some(LocaleKeys.signUp_emptyPasswordError.tr()), + passwordError: LocaleKeys.signUp_emptyPasswordError.tr(), ), ); return; @@ -81,8 +81,7 @@ class SignUpBloc extends Bloc { emit( state.copyWith( isSubmitting: false, - repeatPasswordError: - some(LocaleKeys.signUp_repeatPasswordEmptyError.tr()), + repeatPasswordError: LocaleKeys.signUp_repeatPasswordEmptyError.tr(), ), ); return; @@ -92,8 +91,7 @@ class SignUpBloc extends Bloc { emit( state.copyWith( isSubmitting: false, - repeatPasswordError: - some(LocaleKeys.signUp_unmatchedPasswordError.tr()), + repeatPasswordError: LocaleKeys.signUp_unmatchedPasswordError.tr(), ), ); return; @@ -101,8 +99,8 @@ class SignUpBloc extends Bloc { emit( state.copyWith( - passwordError: none(), - repeatPasswordError: none(), + passwordError: null, + repeatPasswordError: null, ), ); @@ -113,14 +111,14 @@ class SignUpBloc extends Bloc { ); emit( result.fold( - (error) => stateFromCode(error), (profile) => state.copyWith( isSubmitting: false, - successOrFail: some(left(profile)), - emailError: none(), - passwordError: none(), - repeatPasswordError: none(), + successOrFail: FlowyResult.success(profile), + emailError: null, + passwordError: null, + repeatPasswordError: null, ), + (error) => stateFromCode(error), ), ); } @@ -130,21 +128,21 @@ class SignUpBloc extends Bloc { case ErrorCode.EmailFormatInvalid: return state.copyWith( isSubmitting: false, - emailError: some(error.msg), - passwordError: none(), - successOrFail: none(), + emailError: error.msg, + passwordError: null, + successOrFail: null, ); case ErrorCode.PasswordFormatInvalid: return state.copyWith( isSubmitting: false, - passwordError: some(error.msg), - emailError: none(), - successOrFail: none(), + passwordError: error.msg, + emailError: null, + successOrFail: null, ); default: return state.copyWith( isSubmitting: false, - successOrFail: some(right(error)), + successOrFail: FlowyResult.failure(error), ); } } @@ -167,17 +165,17 @@ class SignUpState with _$SignUpState { String? password, String? repeatedPassword, required bool isSubmitting, - required Option passwordError, - required Option repeatPasswordError, - required Option emailError, - required Option> successOrFail, + required String? passwordError, + required String? repeatPasswordError, + required String? emailError, + required FlowyResult? successOrFail, }) = _SignUpState; - factory SignUpState.initial() => SignUpState( + factory SignUpState.initial() => const SignUpState( isSubmitting: false, - passwordError: none(), - repeatPasswordError: none(), - emailError: none(), - successOrFail: none(), + passwordError: null, + repeatPasswordError: null, + emailError: null, + successOrFail: null, ); } diff --git a/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart b/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart index f603e1cf53a82..584c730fba238 100644 --- a/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart @@ -13,8 +13,8 @@ class SplashBloc extends Bloc { getUser: (val) async { final response = await getIt().getUser(); final authState = response.fold( - (error) => AuthState.unauthenticated(error), (user) => AuthState.authenticated(user), + (error) => AuthState.unauthenticated(error), ); emit(state.copyWith(auth: authState)); }, diff --git a/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart b/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart index e47334676ab08..ba3dcb6cb7320 100644 --- a/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart +++ b/frontend/appflowy_flutter/lib/user/application/supabase_realtime.dart @@ -11,7 +11,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; /// A service to manage realtime interactions with Supabase. /// -/// `SupbaseRealtimeService` handles subscribing to table changes in Supabase +/// `SupabaseRealtimeService` handles subscribing to table changes in Supabase /// based on the authentication state of a user. The service is initialized with /// a reference to a Supabase instance and sets up the necessary subscriptions /// accordingly. @@ -54,25 +54,39 @@ class SupabaseRealtimeService { Future _subscribeTablesChanges() async { final result = await UserBackendService.getCurrentUserProfile(); - result.fold((l) => null, (userProfile) { - Log.info("Start listening supabase table changes"); + result.fold( + (userProfile) { + Log.info("Start listening supabase table changes"); - // https://supabase.com/docs/guides/realtime/postgres-changes + // https://supabase.com/docs/guides/realtime/postgres-changes + + const ops = RealtimeChannelConfig(ack: true); + channel?.unsubscribe(); + channel = supabase.client.channel("table-db-changes", opts: ops); + for (final name in [ + "document", + "folder", + "database", + "database_row", + "w_database", + ]) { + channel?.onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: 'public', + table: 'af_collab_update_$name', + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'uid', + value: userProfile.id, + ), + callback: _onPostgresChangesCallback, + ); + } - const ops = RealtimeChannelConfig(ack: true); - channel?.unsubscribe(); - channel = supabase.client.channel("table-db-changes", opts: ops); - for (final name in [ - "document", - "folder", - "database", - "database_row", - "w_database", - ]) { channel?.onPostgresChanges( - event: PostgresChangeEvent.insert, + event: PostgresChangeEvent.update, schema: 'public', - table: 'af_collab_update_$name', + table: 'af_user', filter: PostgresChangeFilter( type: PostgresChangeFilterType.eq, column: 'uid', @@ -80,28 +94,17 @@ class SupabaseRealtimeService { ), callback: _onPostgresChangesCallback, ); - } - channel?.onPostgresChanges( - event: PostgresChangeEvent.update, - schema: 'public', - table: 'af_user', - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'uid', - value: userProfile.id, - ), - callback: _onPostgresChangesCallback, - ); - - channel?.subscribe( - (status, [err]) { - Log.info( - "subscribe channel statue: $status, err: $err", - ); - }, - ); - }); + channel?.subscribe( + (status, [err]) { + Log.info( + "subscribe channel statue: $status, err: $err", + ); + }, + ); + }, + (_) => null, + ); } Future dispose() async { diff --git a/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart index 776d537f36af7..612c1eacd5e49 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart @@ -9,7 +9,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart' as user; import 'package:appflowy_backend/rust_stream.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class UserAuthStateListener { void Function(String)? _onInvalidAuth; @@ -41,7 +41,7 @@ class UserAuthStateListener { void _userNotificationCallback( user.UserNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case user.UserNotification.UserAuthStateChanged: diff --git a/frontend/appflowy_flutter/lib/user/application/user_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_listener.dart index b23689f562962..2fbacf3b6b30b 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_listener.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_listener.dart @@ -1,20 +1,21 @@ import 'dart:async'; +import 'dart:typed_data'; + import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy/core/notification/user_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'dart:typed_data'; -import 'package:flowy_infra/notifier.dart'; -import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart' as user; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; -typedef UserProfileNotifyValue = Either; -typedef AuthNotifyValue = Either; +typedef UserProfileNotifyValue = FlowyResult; +typedef AuthNotifyValue = FlowyResult; class UserListener { UserListener({ @@ -52,14 +53,14 @@ class UserListener { void _userNotificationCallback( user.UserNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case user.UserNotification.DidUpdateUserProfile: result.fold( - (payload) => - _profileNotifier?.value = left(UserProfilePB.fromBuffer(payload)), - (error) => _profileNotifier?.value = right(error), + (payload) => _profileNotifier?.value = + FlowyResult.success(UserProfilePB.fromBuffer(payload)), + (error) => _profileNotifier?.value = FlowyResult.failure(error), ); break; default: @@ -68,7 +69,8 @@ class UserListener { } } -typedef WorkspaceSettingNotifyValue = Either; +typedef WorkspaceSettingNotifyValue + = FlowyResult; class UserWorkspaceListener { UserWorkspaceListener(); @@ -95,14 +97,15 @@ class UserWorkspaceListener { void _handleObservableType( FolderNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateWorkspaceSetting: result.fold( (payload) => _settingChangedNotifier?.value = - left(WorkspaceSettingPB.fromBuffer(payload)), - (error) => _settingChangedNotifier?.value = right(error), + FlowyResult.success(WorkspaceSettingPB.fromBuffer(payload)), + (error) => + _settingChangedNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index ac76bf523430f..5c07e11af6652 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -4,7 +4,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; class UserBackendService { @@ -14,13 +14,13 @@ class UserBackendService { final Int64 userId; - static Future> + static Future> getCurrentUserProfile() async { final result = await UserEventGetUserProfile().send(); - return result.swap(); + return result; } - Future> updateUserProfile({ + Future> updateUserProfile({ String? name, String? password, String? email, @@ -57,65 +57,137 @@ class UserBackendService { return UserEventUpdateUserProfile(payload).send(); } - Future> deleteWorkspace({ + Future> deleteWorkspace({ required String workspaceId, }) { throw UnimplementedError(); } - static Future> signOut() { + static Future> signOut() { return UserEventSignOut().send(); } - Future> initUser() async { + Future> initUser() async { return UserEventInitUser().send(); } - static Future> getAnonUser() async { + static Future> getAnonUser() async { return UserEventGetAnonUser().send(); } - static Future> openAnonUser() async { + static Future> openAnonUser() async { return UserEventOpenAnonUser().send(); } - Future, FlowyError>> getWorkspaces() { - // final request = WorkspaceIdPB.create(); - // return FolderEventReadAllWorkspaces(request).send().then((result) { - // return result.fold( - // (workspaces) => left(workspaces.items), - // (error) => right(error), - // ); - // }); - return Future.value(left([])); + Future, FlowyError>> getWorkspaces() { + return UserEventGetAllWorkspace().send().then((value) { + return value.fold( + (workspaces) => FlowyResult.success(workspaces.items), + (error) => FlowyResult.failure(error), + ); + }); } - Future> openWorkspace(String workspaceId) { + Future> openWorkspace(String workspaceId) { final payload = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventOpenWorkspace(payload).send(); } - Future> getCurrentWorkspace() { + Future> getCurrentWorkspace() { return FolderEventReadCurrentWorkspace().send().then((result) { return result.fold( - (workspace) => left(workspace), - (error) => right(error), + (workspace) => FlowyResult.success(workspace), + (error) => FlowyResult.failure(error), ); }); } - Future> createWorkspace( + Future> createWorkspace( String name, String desc, ) { final request = CreateWorkspacePayloadPB.create() ..name = name ..desc = desc; - return FolderEventCreateWorkspace(request).send().then((result) { + return FolderEventCreateFolderWorkspace(request).send().then((result) { return result.fold( - (workspace) => left(workspace), - (error) => right(error), + (workspace) => FlowyResult.success(workspace), + (error) => FlowyResult.failure(error), ); }); } + + Future> createUserWorkspace( + String name, + ) { + final request = CreateWorkspacePB.create()..name = name; + return UserEventCreateWorkspace(request).send(); + } + + Future> deleteWorkspaceById( + String workspaceId, + ) { + final request = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventDeleteWorkspace(request).send(); + } + + Future> renameWorkspace( + String workspaceId, + String name, + ) { + final request = RenameWorkspacePB() + ..workspaceId = workspaceId + ..newName = name; + return UserEventRenameWorkspace(request).send(); + } + + Future> updateWorkspaceIcon( + String workspaceId, + String icon, + ) { + final request = ChangeWorkspaceIconPB() + ..workspaceId = workspaceId + ..newIcon = icon; + return UserEventChangeWorkspaceIcon(request).send(); + } + + Future> + getWorkspaceMembers( + String workspaceId, + ) async { + final data = QueryWorkspacePB()..workspaceId = workspaceId; + return UserEventGetWorkspaceMember(data).send(); + } + + Future> addWorkspaceMember( + String workspaceId, + String email, + ) async { + final data = AddWorkspaceMemberPB() + ..workspaceId = workspaceId + ..email = email; + return UserEventAddWorkspaceMember(data).send(); + } + + Future> removeWorkspaceMember( + String workspaceId, + String email, + ) async { + final data = RemoveWorkspaceMemberPB() + ..workspaceId = workspaceId + ..email = email; + return UserEventRemoveWorkspaceMember(data).send(); + } + + Future> updateWorkspaceMember( + String workspaceId, + String email, + AFRolePB role, + ) async { + final data = UpdateWorkspaceMemberPB() + ..workspaceId = workspaceId + ..email = email + ..role = role; + return UserEventUpdateWorkspaceMember(data).send(); + } } diff --git a/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart b/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart index ebf15da954a9e..f9718a9134d7c 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart @@ -1,9 +1,9 @@ -import 'package:dartz/dartz.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class UserSettingsBackendService { Future getAppearanceSetting() async { @@ -16,11 +16,11 @@ class UserSettingsBackendService { ); } - Future> getUserSetting() { + Future> getUserSetting() { return UserEventGetUserSetting().send(); } - Future> setAppearanceSetting( + Future> setAppearanceSetting( AppearanceSettingsPB setting, ) { return UserEventSetAppearanceSetting(setting).send(); @@ -36,16 +36,16 @@ class UserSettingsBackendService { ); } - Future> setDateTimeSettings( + Future> setDateTimeSettings( DateTimeSettingsPB settings, ) async { - return (await UserEventSetDateTimeSettings(settings).send()).swap(); + return UserEventSetDateTimeSettings(settings).send(); } - Future> setNotificationSettings( + Future> setNotificationSettings( NotificationSettingsPB settings, ) async { - return (await UserEventSetNotificationSettings(settings).send()).swap(); + return UserEventSetNotificationSettings(settings).send(); } Future getNotificationSettings() async { diff --git a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart index c9b9471ad03d0..ce51fdd10b15f 100644 --- a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart @@ -4,7 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -70,7 +70,7 @@ class WorkspaceErrorEvent with _$WorkspaceErrorEvent { const factory WorkspaceErrorEvent.logout() = _DidLogout; const factory WorkspaceErrorEvent.resetWorkspace() = _ResetWorkspace; const factory WorkspaceErrorEvent.didResetWorkspace( - Either result, + FlowyResult result, ) = _DidResetWorkspace; } diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart index 93564bad6fc15..9abd417df3a91 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart @@ -1,12 +1,12 @@ import 'package:appflowy/user/presentation/helpers/helpers.dart'; import 'package:appflowy/user/presentation/presentation.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; void handleUserProfileResult( - Either userProfileResult, + FlowyResult userProfileResult, BuildContext context, AuthRouter authRouter, ) { diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart index 24fefbfc341b1..1edd20b671f31 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart @@ -55,17 +55,12 @@ class _EncryptSecretScreenState extends State { listenWhen: (previous, current) => previous.successOrFail != current.successOrFail, listener: (context, state) async { - state.successOrFail.fold( - () {}, - (result) { - result.fold( - (unit) async { - await runAppFlowy(); - }, - (error) { - handleOpenWorkspaceError(context, error); - }, - ); + await state.successOrFail?.fold( + (unit) async { + await runAppFlowy(); + }, + (error) { + handleOpenWorkspaceError(context, error); }, ); }, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index 1ebf14dd3de6a..a9daf2b961db2 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -23,14 +23,14 @@ class SignInScreen extends StatelessWidget { create: (context) => getIt(), child: BlocConsumer( listener: (context, state) { - state.successOrFail.fold( - () => null, - (userProfileResult) => handleUserProfileResult( - userProfileResult, + final successOrFail = state.successOrFail; + if (successOrFail != null) { + handleUserProfileResult( + successOrFail, context, getIt(), - ), - ); + ); + } }, builder: (context, state) { final isLoading = context.read().state.isSubmitting; 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 049279ca4a87e..8aea8dde5579b 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 @@ -1,21 +1,21 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_up_bloc.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' - show UserProfilePB; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; class SignUpScreen extends StatelessWidget { const SignUpScreen({ @@ -32,10 +32,10 @@ class SignUpScreen extends StatelessWidget { create: (context) => getIt(), child: BlocListener( listener: (context, state) { - state.successOrFail.fold( - () => {}, - (result) => _handleSuccessOrFail(context, result), - ); + final successOrFail = state.successOrFail; + if (successOrFail != null) { + _handleSuccessOrFail(context, successOrFail); + } }, child: const Scaffold(body: SignUpForm()), ), @@ -44,7 +44,7 @@ class SignUpScreen extends StatelessWidget { void _handleSuccessOrFail( BuildContext context, - Either result, + FlowyResult result, ) { result.fold( (user) => router.pushWorkspaceStartScreen(context, user), @@ -154,11 +154,7 @@ class PasswordTextField extends StatelessWidget { normalBorderColor: Theme.of(context).colorScheme.outline, errorBorderColor: Theme.of(context).colorScheme.error, cursorColor: Theme.of(context).colorScheme.primary, - errorText: context - .read() - .state - .passwordError - .fold(() => "", (error) => error), + errorText: context.read().state.passwordError ?? '', onChanged: (value) => context .read() .add(SignUpEvent.passwordChanged(value)), @@ -187,11 +183,7 @@ class RepeatPasswordTextField extends StatelessWidget { normalBorderColor: Theme.of(context).colorScheme.outline, errorBorderColor: Theme.of(context).colorScheme.error, cursorColor: Theme.of(context).colorScheme.primary, - errorText: context - .read() - .state - .repeatPasswordError - .fold(() => "", (error) => error), + errorText: context.read().state.repeatPasswordError ?? '', onChanged: (value) => context .read() .add(SignUpEvent.repeatPasswordChanged(value)), @@ -218,11 +210,7 @@ class EmailTextField extends StatelessWidget { normalBorderColor: Theme.of(context).colorScheme.outline, errorBorderColor: Theme.of(context).colorScheme.error, cursorColor: Theme.of(context).colorScheme.primary, - errorText: context - .read() - .state - .emailError - .fold(() => "", (error) => error), + errorText: context.read().state.emailError ?? '', onChanged: (value) => context.read().add(SignUpEvent.emailChanged(value)), ); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart index af5e66496fed6..b125abf93ce0b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart @@ -1,23 +1,24 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.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/language.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:url_launcher/url_launcher.dart'; class SkipLogInScreen extends StatefulWidget { const SkipLogInScreen({super.key}); @@ -87,12 +88,12 @@ class _SkipLogInScreenState extends State { Future _autoRegister(BuildContext context) async { final result = await getIt().signUpAsGuest(); result.fold( - (error) { - Log.error(error); - }, (user) { getIt().goHomeScreen(context, user); }, + (error) { + Log.error(error); + }, ); } @@ -158,9 +159,8 @@ class SubscribeButtons extends StatelessWidget { fontColor: Theme.of(context).colorScheme.primary, hoverColor: Colors.transparent, fillColor: Colors.transparent, - onPressed: () => _launchURL( - 'https://github.com/AppFlowy-IO/appflowy', - ), + onPressed: () => + afLaunchUrlString('https://github.com/AppFlowy-IO/appflowy'), ), ], ), @@ -179,22 +179,14 @@ class SubscribeButtons extends StatelessWidget { fontColor: Theme.of(context).colorScheme.primary, hoverColor: Colors.transparent, fillColor: Colors.transparent, - onPressed: () => _launchURL('https://www.appflowy.io/blog'), + onPressed: () => + afLaunchUrlString('https://www.appflowy.io/blog'), ), ], ), ], ); } - - Future _launchURL(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - throw 'Could not launch $url'; - } - } } class LanguageSelectorOnWelcomePage extends StatelessWidget { 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 51f566dc79e67..fe02f193cc1b0 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -105,7 +105,7 @@ class SplashScreen extends StatelessWidget { Future _registerIfNeeded() async { final result = await UserEventGetUserProfile().send(); - if (!result.isLeft()) { + if (result.isFailure) { await getIt().signUpAsGuest(); } } diff --git a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart index f6d42101b6c45..c77650443ea27 100644 --- a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart +++ b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart @@ -1,11 +1,10 @@ -import 'dart:ui'; +import 'package:flutter/material.dart'; class ColorGenerator { - Color generateColorFromString(String string) { - final hash = string.hashCode; - final int r = (hash & 0xFF0000) >> 16; - final int g = (hash & 0x00FF00) >> 8; - final int b = hash & 0x0000FF; - return Color.fromRGBO(r, g, b, 0.5); + static Color generateColorFromString(String string) { + final int hash = + string.codeUnits.fold(0, (int acc, int unit) => acc + unit); + final double hue = (hash % 360).toDouble(); + return HSLColor.fromAHSL(1.0, hue, 0.5, 0.8).toColor(); } } diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 4b88244c792a5..517520408ab51 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -20,6 +20,7 @@ extension FieldTypeExtension on FieldType { FieldType.LastEditedTime => LocaleKeys.grid_field_updatedAtFieldName.tr(), FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(), + FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(), _ => throw UnimplementedError(), }; @@ -34,20 +35,38 @@ extension FieldTypeExtension on FieldType { FieldType.Checklist => FlowySvgs.checklist_s, FieldType.LastEditedTime => FlowySvgs.last_modified_s, FieldType.CreatedTime => FlowySvgs.created_at_s, + FieldType.Relation => FlowySvgs.relation_s, _ => throw UnimplementedError(), }; Color get mobileIconBackgroundColor => switch (this) { FieldType.RichText => const Color(0xFFBECCFF), FieldType.Number => const Color(0xFFCABDFF), - FieldType.DateTime => const Color(0xFFFDEDA7), + FieldType.URL => const Color(0xFFFFB9EF), FieldType.SingleSelect => const Color(0xFFBECCFF), FieldType.MultiSelect => const Color(0xFFBECCFF), - FieldType.URL => const Color(0xFFFFB9EF), - FieldType.Checkbox => const Color(0xFF98F4CD), - FieldType.Checklist => const Color(0xFF98F4CD), + FieldType.DateTime => const Color(0xFFFDEDA7), FieldType.LastEditedTime => const Color(0xFFFDEDA7), FieldType.CreatedTime => const Color(0xFFFDEDA7), + FieldType.Checkbox => const Color(0xFF98F4CD), + FieldType.Checklist => const Color(0xFF98F4CD), + FieldType.Relation => const Color(0xFFFDEDA7), + _ => throw UnimplementedError(), + }; + + // TODO(RS): inner icon color isn't always white + Color get mobileIconBackgroundColorDark => switch (this) { + FieldType.RichText => const Color(0xFF6859A7), + FieldType.Number => const Color(0xFF6859A7), + FieldType.URL => const Color(0xFFA75C96), + FieldType.SingleSelect => const Color(0xFF5366AB), + FieldType.MultiSelect => const Color(0xFF5366AB), + FieldType.DateTime => const Color(0xFFB0A26D), + FieldType.LastEditedTime => const Color(0xFFB0A26D), + FieldType.CreatedTime => const Color(0xFFB0A26D), + FieldType.Checkbox => const Color(0xFF42AD93), + FieldType.Checklist => const Color(0xFF42AD93), + FieldType.Relation => const Color(0xFFFDEDA7), _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/util/google_font_family_extension.dart b/frontend/appflowy_flutter/lib/util/google_font_family_extension.dart index d20a42fc87b9e..30a3229085d5a 100644 --- a/frontend/appflowy_flutter/lib/util/google_font_family_extension.dart +++ b/frontend/appflowy_flutter/lib/util/google_font_family_extension.dart @@ -1,7 +1,6 @@ +import 'package:appflowy/shared/patterns/common_patterns.dart'; + extension GoogleFontsParser on String { - String parseFontFamilyName() { - final camelCase = RegExp('(?<=[a-z])[A-Z]'); - return replaceAll('_regular', '') - .replaceAllMapped(camelCase, (m) => ' ${m.group(0)}'); - } + String parseFontFamilyName() => replaceAll('_regular', '') + .replaceAllMapped(camelCaseRegex, (m) => ' ${m.group(0)}'); } diff --git a/frontend/appflowy_flutter/lib/util/json_print.dart b/frontend/appflowy_flutter/lib/util/json_print.dart index b73a740249cfb..35824b82121a6 100644 --- a/frontend/appflowy_flutter/lib/util/json_print.dart +++ b/frontend/appflowy_flutter/lib/util/json_print.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; const JsonEncoder _encoder = JsonEncoder.withIndent(' '); void prettyPrintJson(Object? object) { Log.trace(_encoder.convert(object)); + debugPrint(_encoder.convert(object)); } diff --git a/frontend/appflowy_flutter/lib/util/string_extension.dart b/frontend/appflowy_flutter/lib/util/string_extension.dart index 0730a9f76d3c9..3db25f788578d 100644 --- a/frontend/appflowy_flutter/lib/util/string_extension.dart +++ b/frontend/appflowy_flutter/lib/util/string_extension.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; + extension StringExtension on String { static const _specialCharacters = r'\/:*?"<>| '; @@ -31,8 +33,6 @@ extension StringExtension on String { return null; } - /// Returns if the string is a appflowy cloud url. - bool get isAppFlowyCloudUrl { - return RegExp(r'^(https:\/\/)(.*)(\.appflowy\.cloud\/)(.*)').hasMatch(this); - } + /// Returns true if the string is a appflowy cloud url. + bool get isAppFlowyCloudUrl => appflowyCloudUrlRegex.hasMatch(this); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart index fd35c01831c3c..61ff86edcd29c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart @@ -6,7 +6,7 @@ import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class DocumentListener { DocumentListener({ @@ -36,13 +36,11 @@ class DocumentListener { void _callback( DocumentNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DocumentNotification.DidReceiveUpdate: - result - .swap() - .map((r) => didReceiveUpdate?.call(DocEventPB.fromBuffer(r))); + result.map((r) => didReceiveUpdate?.call(DocEventPB.fromBuffer(r))); break; default: break; diff --git a/frontend/appflowy_flutter/lib/workspace/application/doc/sync_state_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/doc/sync_state_listener.dart index 37122fa31fec3..6cd57ba0e6d7d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/doc/sync_state_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/doc/sync_state_listener.dart @@ -6,7 +6,7 @@ import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class DocumentSyncStateListener { DocumentSyncStateListener({ @@ -34,11 +34,11 @@ class DocumentSyncStateListener { void _callback( DocumentNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case DocumentNotification.DidUpdateDocumentSyncState: - result.swap().map( + result.map( (r) { final value = DocumentSyncStatePB.fromBuffer(r); didReceiveSyncState?.call(value); diff --git a/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart index fc948f7591f22..52d36033fe38c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart @@ -1,5 +1,4 @@ import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -10,10 +9,10 @@ class EditPanelBloc extends Bloc { on((event, emit) async { await event.map( startEdit: (e) async { - emit(state.copyWith(isEditing: true, editContext: some(e.context))); + emit(state.copyWith(isEditing: true, editContext: e.context)); }, endEdit: (value) async { - emit(state.copyWith(isEditing: false, editContext: none())); + emit(state.copyWith(isEditing: false, editContext: null)); }, ); }); @@ -31,11 +30,11 @@ class EditPanelEvent with _$EditPanelEvent { class EditPanelState with _$EditPanelState { const factory EditPanelState({ required bool isEditing, - required Option editContext, + required EditPanelContext? editContext, }) = _EditPanelState; - factory EditPanelState.initial() => EditPanelState( + factory EditPanelState.initial() => const EditPanelState( isEditing: false, - editContext: none(), + editContext: null, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart index bb8ce9a953c01..2c89de53815bc 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart @@ -7,7 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/do import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; const List _customParsers = [ @@ -30,30 +30,35 @@ class DocumentExporter { final ViewPB view; - Future> export(DocumentExportType type) async { + Future> export( + DocumentExportType type, + ) async { final documentService = DocumentService(); final result = await documentService.openDocument(viewId: view.id); - return result.fold((error) => left(error), (r) { - final document = r.toDocument(); - if (document == null) { - return left( - FlowyError( - msg: LocaleKeys.settings_files_exportFileFail.tr(), - ), - ); - } - switch (type) { - case DocumentExportType.json: - return right(jsonEncode(document)); - case DocumentExportType.markdown: - final markdown = documentToMarkdown( - document, - customParsers: _customParsers, + return result.fold( + (r) { + final document = r.toDocument(); + if (document == null) { + return FlowyResult.failure( + FlowyError( + msg: LocaleKeys.settings_files_exportFileFail.tr(), + ), ); - return right(markdown); - case DocumentExportType.text: - throw UnimplementedError(); - } - }); + } + switch (type) { + case DocumentExportType.json: + return FlowyResult.success(jsonEncode(document)); + case DocumentExportType.markdown: + final markdown = documentToMarkdown( + document, + customParsers: _customParsers, + ); + return FlowyResult.success(markdown); + case DocumentExportType.text: + throw UnimplementedError(); + } + }, + (error) => FlowyResult.failure(error), + ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart index 204d18e025ec6..1f88340cc1ded 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart @@ -2,7 +2,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -69,14 +69,14 @@ class FavoriteBloc extends Bloc { } void _onFavoritesUpdated( - Either favoriteOrFailed, + FlowyResult favoriteOrFailed, bool didFavorite, ) { favoriteOrFailed.fold( - (error) => Log.error(error), (favorite) => didFavorite ? add(FavoriteEvent.didFavorite(favorite)) : add(FavoriteEvent.didUnfavorite(favorite)), + (error) => Log.error(error), ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart index 533f866764584..6606ab26ebe3d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart @@ -6,11 +6,11 @@ import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; typedef FavoriteUpdated = void Function( - Either result, + FlowyResult result, bool isFavorite, ); @@ -35,7 +35,7 @@ class FavoriteListener { void _observableCallback( FolderNotification ty, - Either result, + FlowyResult result, ) { if (_favoriteUpdated == null) { return; @@ -46,12 +46,12 @@ class FavoriteListener { (payload) { final view = RepeatedViewPB.fromBuffer(payload); _favoriteUpdated!( - right(view), + FlowyResult.success(view), isFavorite, ); }, (error) => _favoriteUpdated!( - left(error), + FlowyResult.failure(error), isFavorite, ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart index d88a617bce692..d9343e2ee386d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart @@ -1,14 +1,14 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class FavoriteService { - Future> readFavorites() { + Future> readFavorites() { return FolderEventReadFavorites().send(); } - Future> toggleFavorite( + Future> toggleFavorite( String viewId, bool favoriteStatus, ) async { diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_service.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_service.dart index f8fcfbbb760e1..8fd945aa587a7 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_service.dart @@ -1,12 +1,12 @@ import 'dart:async'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.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-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class HomeService { - Future> readApp({required String appId}) { + Future> readApp({required String appId}) { final payload = ViewIdPB.create()..value = appId; return FolderEventGetView(payload).send(); diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart index 90531f1b9f3ec..171bf634a70b7 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart @@ -3,7 +3,6 @@ import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' show WorkspaceSettingPB; -import 'package:dartz/dartz.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -43,10 +42,10 @@ class HomeSettingBloc extends Bloc { await event.map( initial: (_Initial value) {}, setEditPanel: (e) async { - emit(state.copyWith(panelContext: some(e.editContext))); + emit(state.copyWith(panelContext: e.editContext)); }, dismissEditPanel: (value) async { - emit(state.copyWith(panelContext: none())); + emit(state.copyWith(panelContext: null)); }, didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { emit(state.copyWith(workspaceSetting: value.setting)); @@ -139,7 +138,7 @@ class HomeSettingEvent with _$HomeSettingEvent { @freezed class HomeSettingState with _$HomeSettingState { const factory HomeSettingState({ - required Option panelContext, + required EditPanelContext? panelContext, required WorkspaceSettingPB workspaceSetting, required bool unauthorized, required bool isMenuCollapsed, @@ -156,7 +155,7 @@ class HomeSettingState with _$HomeSettingState { double screenWidthPx, ) { return HomeSettingState( - panelContext: none(), + panelContext: null, workspaceSetting: workspaceSetting, unauthorized: false, isMenuCollapsed: appearanceSettingsState.isMenuCollapsed, diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart deleted file mode 100644 index 3816dfb87d90c..0000000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_bloc.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy/workspace/application/workspace/workspace_listener.dart'; -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-folder/view.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'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'menu_bloc.freezed.dart'; - -class MenuBloc extends Bloc { - MenuBloc({required this.user, required this.workspaceId}) - : _workspaceService = WorkspaceService(workspaceId: workspaceId), - _listener = WorkspaceListener( - user: user, - workspaceId: workspaceId, - ), - super(MenuState.initial()) { - _dispatch(); - } - - final WorkspaceService _workspaceService; - final WorkspaceListener _listener; - final UserProfilePB user; - final String workspaceId; - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } - - void _dispatch() { - on( - (event, emit) async { - await event.map( - initial: (e) async { - _listener.start(appsChanged: _handleAppsOrFail); - await _fetchApps(emit); - }, - createApp: (_CreateApp event) async { - final result = await _workspaceService.createApp( - name: event.name, - desc: event.desc, - index: event.index, - ); - result.fold( - (app) => emit(state.copyWith(lastCreatedView: app)), - (error) { - Log.error(error); - emit(state.copyWith(successOrFailure: right(error))); - }, - ); - }, - didReceiveApps: (e) async { - emit( - e.appsOrFail.fold( - (views) => - state.copyWith(views: views, successOrFailure: left(unit)), - (err) => state.copyWith(successOrFailure: right(err)), - ), - ); - }, - moveApp: (_MoveApp value) { - if (state.views.length > value.fromIndex) { - final view = state.views[value.fromIndex]; - _workspaceService.moveApp( - appId: view.id, - fromIndex: value.fromIndex, - toIndex: value.toIndex, - ); - final apps = List.from(state.views); - - apps.insert(value.toIndex, apps.removeAt(value.fromIndex)); - emit(state.copyWith(views: apps)); - } - }, - ); - }, - ); - } - - // ignore: unused_element - Future _fetchApps(Emitter emit) async { - final viewsOrError = await _workspaceService.getViews(); - emit( - viewsOrError.fold( - (views) => state.copyWith(views: views), - (error) { - Log.error(error); - return state.copyWith(successOrFailure: right(error)); - }, - ), - ); - } - - void _handleAppsOrFail(Either, FlowyError> appsOrFail) { - appsOrFail.fold( - (apps) => add(MenuEvent.didReceiveApps(left(apps))), - (error) => add(MenuEvent.didReceiveApps(right(error))), - ); - } -} - -@freezed -class MenuEvent with _$MenuEvent { - const factory MenuEvent.initial() = _Initial; - const factory MenuEvent.createApp(String name, {String? desc, int? index}) = - _CreateApp; - const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp; - const factory MenuEvent.didReceiveApps( - Either, FlowyError> appsOrFail, - ) = _ReceiveApps; -} - -@freezed -class MenuState with _$MenuState { - const factory MenuState({ - required List views, - required Either successOrFailure, - ViewPB? lastCreatedView, - }) = _MenuState; - - factory MenuState.initial() => MenuState( - views: [], - successOrFailure: left(unit), - ); -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart index 3b41726b090ff..58071fb68e922 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart @@ -4,7 +4,7 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -63,7 +63,9 @@ class MenuUserBloc extends Bloc { result.fold((l) => null, (error) => Log.error(error)); } - void _profileUpdated(Either userProfileOrFailed) { + void _profileUpdated( + FlowyResult userProfileOrFailed, + ) { if (isClosed) { return; } @@ -90,13 +92,13 @@ class MenuUserEvent with _$MenuUserEvent { class MenuUserState with _$MenuUserState { const factory MenuUserState({ required UserProfilePB userProfile, - required Option> workspaces, - required Either successOrFailure, + required List? workspaces, + required FlowyResult successOrFailure, }) = _MenuUserState; factory MenuUserState.initial(UserProfilePB userProfile) => MenuUserState( userProfile: userProfile, - workspaces: none(), - successOrFailure: left(unit), + workspaces: null, + successOrFailure: FlowyResult.success(null), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart index 0bf94ea60b1b1..7d24a56b0c978 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart @@ -1,2 +1,2 @@ -export 'menu_bloc.dart'; export 'menu_user_bloc.dart'; +export 'sidebar_root_views_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart new file mode 100644 index 0000000000000..8aa73d5b221ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_root_views_bloc.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/workspace/workspace_listener.dart'; +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-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sidebar_root_views_bloc.freezed.dart'; + +class SidebarRootViewsBloc + extends Bloc { + SidebarRootViewsBloc() : super(SidebarRootViewState.initial()) { + _dispatch(); + } + + late WorkspaceService _workspaceService; + WorkspaceListener? _listener; + + @override + Future close() async { + await _listener?.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspaceId) async { + _initial(userProfile, workspaceId); + await _fetchRootViews(emit); + }, + reset: (userProfile, workspaceId) async { + await _listener?.stop(); + _initial(userProfile, workspaceId); + await _fetchRootViews(emit); + }, + createRootView: (name, desc, index, section) async { + final result = await _workspaceService.createView( + name: name, + desc: desc, + index: index, + viewSection: section, + ); + result.fold( + (view) => emit(state.copyWith(lastCreatedRootView: view)), + (error) { + Log.error(error); + emit( + state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ); + }, + ); + }, + didReceiveViews: (viewsOrFailure) async { + // emit( + // viewsOrFailure.fold( + // (views) => state.copyWith( + // views: views, + // successOrFailure: FlowyResult.success(null), + // ), + // (err) => + // state.copyWith(successOrFailure: FlowyResult.failure(err)), + // ), + // ); + }, + moveRootView: (int fromIndex, int toIndex) { + // if (state.views.length > fromIndex) { + // final view = state.views[fromIndex]; + + // _workspaceService.moveApp( + // appId: view.id, + // fromIndex: fromIndex, + // toIndex: toIndex, + // ); + + // final views = List.from(state.views); + // views.insert(toIndex, views.removeAt(fromIndex)); + // emit(state.copyWith(views: views)); + // } + }, + ); + }, + ); + } + + Future _fetchRootViews( + Emitter emit, + ) async { + try { + final publicViews = await _workspaceService.getPublicViews().getOrThrow(); + final privateViews = + await _workspaceService.getPrivateViews().getOrThrow(); + emit( + state.copyWith( + publicViews: publicViews, + privateViews: privateViews, + ), + ); + } catch (e) { + Log.error(e); + // TODO: handle error + // emit( + // state.copyWith( + // successOrFailure: FlowyResult.failure(e), + // ), + // ); + } + } + + void _handleAppsOrFail(FlowyResult, FlowyError> viewsOrFail) { + viewsOrFail.fold( + (views) => add( + SidebarRootViewsEvent.didReceiveViews(FlowyResult.success(views)), + ), + (error) => add( + SidebarRootViewsEvent.didReceiveViews(FlowyResult.failure(error)), + ), + ); + } + + void _initial(UserProfilePB userProfile, String workspaceId) { + _workspaceService = WorkspaceService(workspaceId: workspaceId); + _listener = WorkspaceListener( + user: userProfile, + workspaceId: workspaceId, + )..start(appsChanged: _handleAppsOrFail); + } +} + +@freezed +class SidebarRootViewsEvent with _$SidebarRootViewsEvent { + const factory SidebarRootViewsEvent.initial( + UserProfilePB userProfile, + String workspaceId, + ) = _Initial; + const factory SidebarRootViewsEvent.reset( + UserProfilePB userProfile, + String workspaceId, + ) = _Reset; + const factory SidebarRootViewsEvent.createRootView( + String name, { + String? desc, + int? index, + required ViewSectionPB viewSection, + }) = _createRootView; + const factory SidebarRootViewsEvent.moveRootView( + int fromIndex, + int toIndex, + ) = _MoveRootView; + const factory SidebarRootViewsEvent.didReceiveViews( + FlowyResult, FlowyError> appsOrFail, + ) = _ReceiveApps; +} + +@freezed +class SidebarRootViewState with _$SidebarRootViewState { + const factory SidebarRootViewState({ + @Default([]) List privateViews, + @Default([]) List publicViews, + required FlowyResult successOrFailure, + @Default(null) ViewPB? lastCreatedRootView, + }) = _SidebarRootViewState; + + factory SidebarRootViewState.initial() => SidebarRootViewState( + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart new file mode 100644 index 0000000000000..8f3e4d1f59b23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart @@ -0,0 +1,261 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart'; +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-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sidebar_sections_bloc.freezed.dart'; + +class SidebarSection { + const SidebarSection({ + required this.publicViews, + required this.privateViews, + }); + + const SidebarSection.empty() + : publicViews = const [], + privateViews = const []; + + final List publicViews; + final List privateViews; + + List get views => publicViews + privateViews; + + SidebarSection copyWith({ + List? publicViews, + List? privateViews, + }) { + return SidebarSection( + publicViews: publicViews ?? this.publicViews, + privateViews: privateViews ?? this.privateViews, + ); + } +} + +/// The [SidebarSectionsBloc] is responsible for +/// managing the root views in different sections of the workspace. +class SidebarSectionsBloc + extends Bloc { + SidebarSectionsBloc() : super(SidebarSectionsState.initial()) { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspaceId) async { + _initial(userProfile, workspaceId); + final sectionViews = await _getSectionViews(); + if (sectionViews != null) { + emit( + state.copyWith( + section: sectionViews, + ), + ); + } + }, + reset: (userProfile, workspaceId) async { + _reset(userProfile, workspaceId); + final sectionViews = await _getSectionViews(); + if (sectionViews != null) { + emit( + state.copyWith( + section: sectionViews, + ), + ); + } + }, + createRootViewInSection: (name, section, desc, index) async { + final result = await _workspaceService.createView( + name: name, + viewSection: section, + desc: desc, + index: index, + ); + result.fold( + (view) => emit( + state.copyWith( + lastCreatedRootView: view, + createRootViewResult: FlowyResult.success(null), + ), + ), + (error) { + Log.error('Failed to create root view: $error'); + emit( + state.copyWith( + createRootViewResult: FlowyResult.failure(error), + ), + ); + }, + ); + }, + receiveSectionViewsUpdate: (sectionViews) async { + final section = sectionViews.section; + switch (section) { + case ViewSectionPB.Public: + emit( + state.copyWith( + section: state.section.copyWith( + publicViews: sectionViews.views, + ), + ), + ); + case ViewSectionPB.Private: + emit( + state.copyWith( + section: state.section.copyWith( + privateViews: sectionViews.views, + ), + ), + ); + break; + default: + break; + } + }, + moveRootView: (fromIndex, toIndex, fromSection, toSection) async { + final views = fromSection == ViewSectionPB.Public + ? List.from(state.section.publicViews) + : List.from(state.section.privateViews); + if (fromIndex < 0 || fromIndex >= views.length) { + Log.error( + 'Invalid fromIndex: $fromIndex, maxIndex: ${views.length - 1}', + ); + return; + } + final view = views[fromIndex]; + final result = await _workspaceService.moveView( + viewId: view.id, + fromIndex: fromIndex, + toIndex: toIndex, + ); + result.fold( + (value) { + views.insert(toIndex, views.removeAt(fromIndex)); + var newState = state; + if (fromSection == ViewSectionPB.Public) { + newState = newState.copyWith( + section: newState.section.copyWith(publicViews: views), + ); + } else if (fromSection == ViewSectionPB.Private) { + newState = newState.copyWith( + section: newState.section.copyWith(privateViews: views), + ); + } + emit(newState); + }, + (error) { + Log.error('Failed to move root view: $error'); + }, + ); + }, + ); + }, + ); + } + + late WorkspaceService _workspaceService; + WorkspaceSectionsListener? _listener; + + @override + Future close() async { + await _listener?.stop(); + _listener = null; + return super.close(); + } + + ViewSectionPB? getViewSection(ViewPB view) { + final publicViews = state.section.publicViews.map((e) => e.id); + final privateViews = state.section.privateViews.map((e) => e.id); + if (publicViews.contains(view.id)) { + return ViewSectionPB.Public; + } else if (privateViews.contains(view.id)) { + return ViewSectionPB.Private; + } else { + return null; + } + } + + Future _getSectionViews() async { + try { + final publicViews = await _workspaceService.getPublicViews().getOrThrow(); + final privateViews = + await _workspaceService.getPrivateViews().getOrThrow(); + return SidebarSection( + publicViews: publicViews, + privateViews: privateViews, + ); + } catch (e) { + Log.error('Failed to get section views: $e'); + return null; + } + } + + void _initial(UserProfilePB userProfile, String workspaceId) { + _workspaceService = WorkspaceService(workspaceId: workspaceId); + + _listener = WorkspaceSectionsListener( + user: userProfile, + workspaceId: workspaceId, + )..start( + sectionChanged: (result) { + if (!isClosed) { + result.fold( + (s) => add(SidebarSectionsEvent.receiveSectionViewsUpdate(s)), + (f) => Log.error('Failed to receive section views: $f'), + ); + } + }, + ); + } + + void _reset(UserProfilePB userProfile, String workspaceId) { + _listener?.stop(); + _listener = null; + + _initial(userProfile, workspaceId); + } +} + +@freezed +class SidebarSectionsEvent with _$SidebarSectionsEvent { + const factory SidebarSectionsEvent.initial( + UserProfilePB userProfile, + String workspaceId, + ) = _Initial; + const factory SidebarSectionsEvent.reset( + UserProfilePB userProfile, + String workspaceId, + ) = _Reset; + const factory SidebarSectionsEvent.createRootViewInSection({ + required String name, + required ViewSectionPB viewSection, + String? desc, + int? index, + }) = _CreateRootViewInSection; + const factory SidebarSectionsEvent.moveRootView({ + required int fromIndex, + required int toIndex, + required ViewSectionPB fromSection, + required ViewSectionPB toSection, + }) = _MoveRootView; + const factory SidebarSectionsEvent.receiveSectionViewsUpdate( + SectionViewsPB sectionViews, + ) = _ReceiveSectionViewsUpdate; +} + +@freezed +class SidebarSectionsState with _$SidebarSectionsState { + const factory SidebarSectionsState({ + required SidebarSection section, + @Default(null) ViewPB? lastCreatedRootView, + FlowyResult? createRootViewResult, + }) = _SidebarSectionsState; + + factory SidebarSectionsState.initial() => const SidebarSectionsState( + section: SidebarSection.empty(), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart index 763fcc966760b..fc70ef0602ea6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart @@ -6,11 +6,11 @@ import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; typedef RecentViewsUpdated = void Function( - Either result, + FlowyResult result, ); class RecentViewsListener { @@ -34,7 +34,7 @@ class RecentViewsListener { void _observableCallback( FolderNotification ty, - Either result, + FlowyResult result, ) { if (_recentViewsUpdated == null) { return; @@ -44,11 +44,11 @@ class RecentViewsListener { (payload) { final view = RepeatedViewIdPB.fromBuffer(payload); _recentViewsUpdated?.call( - right(view), + FlowyResult.success(view), ); }, (error) => _recentViewsUpdated?.call( - left(error), + FlowyResult.failure(error), ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_service.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_service.dart index 0aea84857276c..9050b378ee870 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_service.dart @@ -1,10 +1,10 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class RecentService { - Future> updateRecentViews( + Future> updateRecentViews( List viewIds, bool addInRecent, ) async { @@ -13,7 +13,7 @@ class RecentService { ).send(); } - Future> readRecentViews() { + Future> readRecentViews() { return FolderEventReadRecentViews().send(); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart index 32496b1c45f56..a454952016db0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart @@ -3,7 +3,7 @@ import 'package:appflowy/workspace/application/recent/recent_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -54,7 +54,7 @@ class RecentViewsBloc extends Bloc { } void _onRecentViewsUpdated( - Either result, + FlowyResult result, ) { add(const RecentViewsEvent.fetchRecentViews()); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart index 756bb80097b73..d55aae4f4e42d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart @@ -42,6 +42,7 @@ class AppearanceSettingsCubit extends Cubit { appearanceSettings.monospaceFont, appearanceSettings.layoutDirection, appearanceSettings.textDirection, + appearanceSettings.enableRtlToolbarItems, appearanceSettings.locale, appearanceSettings.isMenuCollapsed, appearanceSettings.menuOffset, @@ -83,13 +84,11 @@ class AppearanceSettingsCubit extends Cubit { Future readTextScaleFactor() async { final textScaleFactor = await getIt().getWithFormat( - KVKeys.textScaleFactor, - (value) => double.parse(value), - ); - textScaleFactor.fold( - () => emit(state.copyWith(textScaleFactor: 1.0)), - (value) => emit(state.copyWith(textScaleFactor: value.clamp(0.7, 1.0))), - ); + KVKeys.textScaleFactor, + (value) => double.parse(value), + ) ?? + 1.0; + emit(state.copyWith(textScaleFactor: textScaleFactor.clamp(0.7, 1.0))); } /// Update selected theme in the user's settings and emit an updated state @@ -136,6 +135,12 @@ class AppearanceSettingsCubit extends Cubit { emit(state.copyWith(textDirection: textDirection)); } + void setEnableRTLToolbarItems(bool value) { + _appearanceSettings.enableRtlToolbarItems = value; + _saveAppearanceSettings(); + emit(state.copyWith(enableRtlToolbarItems: value)); + } + /// Update selected font in the user's settings and emit an updated state /// with the font name. void setFontFamily(String fontFamilyName) { @@ -267,8 +272,8 @@ class AppearanceSettingsCubit extends Cubit { final result = await UserSettingsBackendService() .setDateTimeSettings(_dateTimeSettings); result.fold( - (error) => Log.error(error), (_) => null, + (error) => Log.error(error), ); } @@ -367,6 +372,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState { required String monospaceFont, required LayoutDirection layoutDirection, required AppFlowyTextDirection? textDirection, + required bool enableRtlToolbarItems, required Locale locale, required bool isMenuCollapsed, required double menuOffset, @@ -385,6 +391,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState { String monospaceFont, LayoutDirectionPB layoutDirectionPB, TextDirectionPB? textDirectionPB, + bool enableRtlToolbarItems, LocaleSettingsPB localePB, bool isMenuCollapsed, double menuOffset, @@ -401,6 +408,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState { monospaceFont: monospaceFont, layoutDirection: LayoutDirection.fromLayoutDirectionPB(layoutDirectionPB), textDirection: AppFlowyTextDirection.fromTextDirectionPB(textDirectionPB), + enableRtlToolbarItems: enableRtlToolbarItems, themeMode: _themeModeFromPB(themeModePB), locale: Locale(localePB.languageCode, localePB.countryCode), isMenuCollapsed: isMenuCollapsed, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index 8900ae2256667..2cbbf5476e8dc 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -1,5 +1,5 @@ // ThemeData in mobile -import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; @@ -74,10 +74,9 @@ class MobileAppearance extends BaseAppearance { outline: _hintColorInDarkMode, outlineVariant: Colors.black, //Snack bar - surface: const Color(0xff2F3030), + surface: const Color(0xFF171A1F), onSurface: const Color(0xffC5C6C7), // text/body color ); - final hintColor = brightness == Brightness.light ? const Color(0x991F2329) : _hintColorInDarkMode; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart index b94b52fa48b3e..fdb53f4e43ed0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart @@ -3,7 +3,7 @@ import 'package:appflowy/workspace/application/settings/cloud_setting_listener.d import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -86,16 +86,18 @@ class AppFlowyCloudSettingState with _$AppFlowyCloudSettingState { ); } -Either validateUrl(String url) { +FlowyResult validateUrl(String url) { try { // Use Uri.parse to validate the url. final uri = Uri.parse(url); if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { - return right(()); + return FlowyResult.success(null); } else { - return left(LocaleKeys.settings_menu_invalidCloudURLScheme.tr()); + return FlowyResult.failure( + LocaleKeys.settings_menu_invalidCloudURLScheme.tr(), + ); } } catch (e) { - return left(e.toString()); + return FlowyResult.failure(e.toString()); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart index 72b91f011ce9c..998e6d632fe06 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart @@ -2,7 +2,7 @@ import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -19,7 +19,7 @@ class AppFlowyCloudURLsBloc emit( state.copyWith( updatedServerUrl: url, - urlError: none(), + urlError: null, showRestartHint: url.isNotEmpty, ), ); @@ -29,9 +29,8 @@ class AppFlowyCloudURLsBloc emit( state.copyWith( updatedServerUrl: "", - urlError: Some( - LocaleKeys.settings_menu_appFlowyCloudUrlCanNotBeEmpty.tr(), - ), + urlError: + LocaleKeys.settings_menu_appFlowyCloudUrlCanNotBeEmpty.tr(), restartApp: false, ), ); @@ -41,14 +40,14 @@ class AppFlowyCloudURLsBloc await useSelfHostedAppFlowyCloudWithURL(url); add(const AppFlowyCloudURLsEvent.didSaveConfig()); }, - (err) => emit(state.copyWith(urlError: Some(err))), + (err) => emit(state.copyWith(urlError: err)), ); } }, didSaveConfig: () { emit( state.copyWith( - urlError: none(), + urlError: null, restartApp: true, ), ); @@ -72,14 +71,14 @@ class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { const factory AppFlowyCloudURLsState({ required AppFlowyCloudConfiguration config, required String updatedServerUrl, - required Option urlError, + required String? urlError, required bool restartApp, required bool showRestartHint, }) = _AppFlowyCloudURLsState; factory AppFlowyCloudURLsState.initial() => AppFlowyCloudURLsState( config: getIt().appflowyCloudConfig, - urlError: none(), + urlError: null, updatedServerUrl: getIt().appflowyCloudConfig.base_url, showRestartHint: getIt() @@ -90,17 +89,19 @@ class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { ); } -Either validateUrl(String url) { +FlowyResult validateUrl(String url) { try { // Use Uri.parse to validate the url. final uri = Uri.parse(removeTrailingSlash(url)); if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { - return left(uri.toString()); + return FlowyResult.success(uri.toString()); } else { - return right(LocaleKeys.settings_menu_invalidCloudURLScheme.tr()); + return FlowyResult.failure( + LocaleKeys.settings_menu_invalidCloudURLScheme.tr(), + ); } } catch (e) { - return right(e.toString()); + return FlowyResult.failure(e.toString()); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart index 1680678107483..b0c8cb09485ef 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart @@ -1,10 +1,12 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; + import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../../../startup/tasks/prelude.dart'; @@ -26,7 +28,7 @@ class ApplicationDataStorage { if (Platform.isMacOS) { // remove the prefix `/Volumes/*` - path = path.replaceFirst(RegExp('^/Volumes/[^/]+'), ''); + path = path.replaceFirst(macOSVolumesRegex, ''); } else if (Platform.isWindows) { path = path.replaceAll('/', '\\'); } @@ -64,14 +66,14 @@ class ApplicationDataStorage { } final response = await getIt().get(KVKeys.pathLocation); - String path = await response.fold( - () async { - // return the default path if the path is not set - final directory = await appFlowyApplicationDataDirectory(); - return directory.path; - }, - (path) => path, - ); + + String path; + if (response == null) { + final directory = await appFlowyApplicationDataDirectory(); + path = directory.path; + } else { + path = response; + } _cachePath = path; // if the path is not exists means the path is invalid, so we should clear the kv store diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart index f23d5aacf0199..b6007847b3071 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart @@ -5,7 +5,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import '../../../core/notification/user_notification.dart'; @@ -14,10 +14,10 @@ class UserCloudConfigListener { UserNotificationParser? _userParser; StreamSubscription? _subscription; - void Function(Either)? _onSettingChanged; + void Function(FlowyResult)? _onSettingChanged; void start({ - void Function(Either)? onSettingChanged, + void Function(FlowyResult)? onSettingChanged, }) { _onSettingChanged = onSettingChanged; _userParser = UserNotificationParser( @@ -37,14 +37,14 @@ class UserCloudConfigListener { void _userNotificationCallback( UserNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case UserNotification.DidUpdateCloudConfig: result.fold( - (payload) => - _onSettingChanged?.call(left(CloudSettingPB.fromBuffer(payload))), - (error) => _onSettingChanged?.call(right(error)), + (payload) => _onSettingChanged + ?.call(FlowyResult.success(CloudSettingPB.fromBuffer(payload))), + (error) => _onSettingChanged?.call(FlowyResult.failure(error)), ); break; default: diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart index 143048c030397..7ff1ed5fedd54 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart @@ -21,9 +21,6 @@ class CreateFileSettingsCubit extends Cubit { KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); - settingsOrFailure.fold( - () => emit(false), - (settings) => emit(settings), - ); + emit(settingsOrFailure ?? false); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart deleted file mode 100644 index 57443b23f82ea..0000000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// RegExp to match Twelve Hour formats -/// Source: https://stackoverflow.com/a/33906224 -/// -/// Matches eg: "05:05 PM", "5:50 Pm", "10:59 am", etc. -/// -final _twelveHourTimePattern = - RegExp(r'\b((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))'); -bool isTwelveHourTime(String? time) => - _twelveHourTimePattern.hasMatch(time ?? ''); - -/// RegExp to match Twenty Four Hour formats -/// Source: https://stackoverflow.com/a/7536768 -/// -/// Matches eg: "0:01", "04:59", "16:30", etc. -/// -final _twentyFourHourtimePattern = RegExp(r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'); -bool isTwentyFourHourTime(String? time) => - _twentyFourHourtimePattern.hasMatch(time ?? ''); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart index 06bb7bd61e086..aae0a6360983f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart @@ -47,8 +47,8 @@ class NotificationSettingsCubit extends Cubit { final result = await UserSettingsBackendService() .setNotificationSettings(_notificationSettings); result.fold( - (error) => Log.error(error), (r) => null, + (error) => Log.error(error), ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart index 2ed81a194ef3f..517b943b3596d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart @@ -3,7 +3,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/import_data.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -36,8 +36,9 @@ class SettingFileImportBloc (l) { emit( state.copyWith( - successOrFail: some(left(unit)), - loadingState: LoadingState.finish(left(unit)), + successOrFail: FlowyResult.success(null), + loadingState: + LoadingState.finish(FlowyResult.success(null)), ), ); }, @@ -45,8 +46,8 @@ class SettingFileImportBloc Log.error(err); emit( state.copyWith( - successOrFail: some(right(err)), - loadingState: LoadingState.finish(right(err)), + successOrFail: FlowyResult.failure(err), + loadingState: LoadingState.finish(FlowyResult.failure(err)), ), ); }, @@ -63,7 +64,7 @@ class SettingFileImportEvent with _$SettingFileImportEvent { const factory SettingFileImportEvent.importAppFlowyDataFolder(String path) = _ImportAppFlowyDataFolder; const factory SettingFileImportEvent.finishImport( - Either result, + FlowyResult result, ) = _ImportResult; } @@ -71,11 +72,11 @@ class SettingFileImportEvent with _$SettingFileImportEvent { class SettingFileImportState with _$SettingFileImportState { const factory SettingFileImportState({ required LoadingState loadingState, - required Option> successOrFail, + required FlowyResult? successOrFail, }) = _SettingFileImportState; - factory SettingFileImportState.initial() => SettingFileImportState( - loadingState: const LoadingState.idle(), - successOrFail: none(), + factory SettingFileImportState.initial() => const SettingFileImportState( + loadingState: LoadingState.idle(), + successOrFail: null, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 47eb6705dddf0..2d73d6ebe7ec2 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -2,7 +2,7 @@ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -16,6 +16,8 @@ enum SettingsPage { notifications, cloud, shortcuts, + member, + featureFlags, } class SettingsDialogBloc @@ -53,7 +55,9 @@ class SettingsDialogBloc ); } - void _profileUpdated(Either userProfileOrFailed) { + void _profileUpdated( + FlowyResult userProfileOrFailed, + ) { userProfileOrFailed.fold( (newUserProfile) => add(SettingsDialogEvent.didReceiveUserProfile(newUserProfile)), @@ -76,14 +80,14 @@ class SettingsDialogEvent with _$SettingsDialogEvent { class SettingsDialogState with _$SettingsDialogState { const factory SettingsDialogState({ required UserProfilePB userProfile, - required Either successOrFailure, + required FlowyResult successOrFailure, required SettingsPage page, }) = _SettingsDialogState; factory SettingsDialogState.initial(UserProfilePB userProfile) => SettingsDialogState( userProfile: userProfile, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), page: SettingsPage.appearance, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart index 1d0ad4068b66d..7cf81b3bfb99c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart @@ -2,10 +2,11 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/share_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class BackendExportService { - static Future> exportDatabaseAsCSV( + static Future> + exportDatabaseAsCSV( String viewId, ) async { final payload = DatabaseViewIdPB.create()..value = viewId; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart index 9628ea104d95e..34ea16e52ffcb 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart @@ -2,10 +2,10 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/import.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class ImportBackendService { - static Future> importData( + static Future> importData( List data, String name, String parentViewId, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart index 7465f1c437dc8..9308a06a985ee 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart @@ -5,7 +5,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -56,7 +56,7 @@ class SupabaseCloudSettingBloc emit( state.copyWith( setting: setting, - loadingState: LoadingState.finish(left(unit)), + loadingState: LoadingState.finish(FlowyResult.success(null)), ), ); }, @@ -96,7 +96,7 @@ class SupabaseCloudSettingState with _$SupabaseCloudSettingState { factory SupabaseCloudSettingState.initial(CloudSettingPB setting) => SupabaseCloudSettingState( - loadingState: LoadingState.finish(left(unit)), + loadingState: LoadingState.finish(FlowyResult.success(null)), setting: setting, config: getIt().supabaseConfig, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart index b87e0189ab9db..fdd4cbef21f54 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart @@ -4,7 +4,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -23,7 +22,7 @@ class SupabaseCloudURLsBloc state.copyWith( updatedUrl: url, showRestartHint: url.isNotEmpty && state.upatedAnonKey.isNotEmpty, - urlError: none(), + urlError: null, ), ); }, @@ -33,7 +32,7 @@ class SupabaseCloudURLsBloc upatedAnonKey: anonKey, showRestartHint: anonKey.isNotEmpty && state.updatedUrl.isNotEmpty, - anonKeyError: none(), + anonKeyError: null, ), ); }, @@ -41,10 +40,9 @@ class SupabaseCloudURLsBloc if (state.updatedUrl.isEmpty) { emit( state.copyWith( - urlError: Some( - LocaleKeys.settings_menu_cloudSupabaseUrlCanNotBeEmpty.tr(), - ), - anonKeyError: none(), + urlError: + LocaleKeys.settings_menu_cloudSupabaseUrlCanNotBeEmpty.tr(), + anonKeyError: null, restartApp: false, ), ); @@ -54,11 +52,10 @@ class SupabaseCloudURLsBloc if (state.upatedAnonKey.isEmpty) { emit( state.copyWith( - urlError: none(), - anonKeyError: Some( - LocaleKeys.settings_menu_cloudSupabaseAnonKeyCanNotBeEmpty - .tr(), - ), + urlError: null, + anonKeyError: LocaleKeys + .settings_menu_cloudSupabaseAnonKeyCanNotBeEmpty + .tr(), restartApp: false, ), ); @@ -66,7 +63,6 @@ class SupabaseCloudURLsBloc } validateUrl(state.updatedUrl).fold( - (error) => emit(state.copyWith(urlError: Some(error))), (_) async { await useSupabaseCloud( url: state.updatedUrl, @@ -75,13 +71,14 @@ class SupabaseCloudURLsBloc add(const SupabaseCloudURLsEvent.didSaveConfig()); }, + (error) => emit(state.copyWith(urlError: error)), ); }, didSaveConfig: () { emit( state.copyWith( - urlError: none(), - anonKeyError: none(), + urlError: null, + anonKeyError: null, restartApp: true, ), ); @@ -110,8 +107,8 @@ class SupabaseCloudURLsState with _$SupabaseCloudURLsState { required SupabaseConfiguration config, required String updatedUrl, required String upatedAnonKey, - required Option urlError, - required Option anonKeyError, + required String? urlError, + required String? anonKeyError, required bool restartApp, required bool showRestartHint, }) = _SupabaseCloudURLsState; @@ -121,8 +118,8 @@ class SupabaseCloudURLsState with _$SupabaseCloudURLsState { return SupabaseCloudURLsState( updatedUrl: config.url, upatedAnonKey: config.anon_key, - urlError: none(), - anonKeyError: none(), + urlError: null, + anonKeyError: null, restartApp: false, showRestartHint: config.url.isNotEmpty && config.anon_key.isNotEmpty, config: config, diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart index 19927043a4185..e82b54241ae7f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart @@ -3,13 +3,27 @@ import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; + part 'folder_bloc.freezed.dart'; enum FolderCategoryType { favorite, - personal, + private, + public; + + ViewSectionPB get toViewSectionPB { + switch (this) { + case FolderCategoryType.private: + return ViewSectionPB.Private; + case FolderCategoryType.public: + return ViewSectionPB.Public; + case FolderCategoryType.favorite: + throw UnimplementedError(); + } + } } class FolderBloc extends Bloc { @@ -34,10 +48,10 @@ class FolderBloc extends Bloc { Future _setFolderExpandStatus(bool isExpanded) async { final result = await getIt().get(KVKeys.expandedViews); - final map = result.fold( - () => {}, - (r) => jsonDecode(r), - ); + var map = {}; + if (result != null) { + map = jsonDecode(result); + } if (isExpanded) { // set expand status to true if it's not expanded map[state.type.name] = true; @@ -50,10 +64,11 @@ class FolderBloc extends Bloc { Future _getFolderExpandStatus() async { return getIt().get(KVKeys.expandedViews).then((result) { - return result.fold(() => true, (r) { - final map = jsonDecode(r); - return map[state.type.name] ?? true; - }); + if (result == null) { + return true; + } + final map = jsonDecode(result); + return map[state.type.name] ?? true; }); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart index f698497db9285..b9bbb0ff083c4 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart @@ -1 +1,2 @@ export 'settings_user_bloc.dart'; +export 'user_workspace_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart index fa0c84237f8f7..0f45b019fd774 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -3,7 +3,7 @@ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -101,15 +101,17 @@ class SettingsUserViewBloc extends Bloc { } result.fold( - (err) => Log.error(err), (userProfile) => add( SettingsUserEvent.didReceiveUserProfile(userProfile), ), + (err) => Log.error(err), ); }); } - void _profileUpdated(Either userProfileOrFailed) { + void _profileUpdated( + FlowyResult userProfileOrFailed, + ) { userProfileOrFailed.fold( (newUserProfile) { add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)); @@ -141,12 +143,12 @@ class SettingsUserEvent with _$SettingsUserEvent { class SettingsUserState with _$SettingsUserState { const factory SettingsUserState({ required UserProfilePB userProfile, - required Either successOrFailure, + required FlowyResult successOrFailure, }) = _SettingsUserState; factory SettingsUserState.initial(UserProfilePB userProfile) => SettingsUserState( userProfile: userProfile, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart new file mode 100644 index 0000000000000..96558d5c04767 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -0,0 +1,301 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.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:protobuf/protobuf.dart'; + +part 'user_workspace_bloc.freezed.dart'; + +class UserWorkspaceBloc extends Bloc { + UserWorkspaceBloc({ + required this.userProfile, + }) : _userService = UserBackendService(userId: userProfile.id), + super(UserWorkspaceState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final result = await _fetchWorkspaces(); + final isCollabWorkspaceOn = + userProfile.authenticator != AuthenticatorPB.Local && + FeatureFlag.collaborativeWorkspace.isOn; + emit( + state.copyWith( + currentWorkspace: result?.$1, + workspaces: result?.$2 ?? [], + isCollabWorkspaceOn: isCollabWorkspaceOn, + actionResult: null, + ), + ); + }, + fetchWorkspaces: () async { + final result = await _fetchWorkspaces(); + if (result != null) { + emit( + state.copyWith( + currentWorkspace: result.$1, + workspaces: result.$2, + ), + ); + } else { + emit( + state.copyWith( + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.none, + result: FlowyResult.failure( + FlowyError( + code: ErrorCode.Internal, + msg: LocaleKeys.workspace_fetchWorkspacesFailed.tr(), + ), + ), + ), + ), + ); + } + }, + createWorkspace: (name) async { + final result = await _userService.createUserWorkspace(name); + final workspaces = result.fold( + (s) => [...state.workspaces, s], + (e) => state.workspaces, + ); + emit( + state.copyWith( + workspaces: workspaces, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.create, + result: result, + ), + ), + ); + // open the created workspace by default + result.onSuccess((s) { + add(OpenWorkspace(s.workspaceId)); + }); + }, + deleteWorkspace: (workspaceId) async { + if (state.workspaces.length <= 1) { + // do not allow to delete the last workspace, otherwise the user + // cannot do create workspace again + final result = FlowyResult.failure( + FlowyError( + code: ErrorCode.Internal, + msg: LocaleKeys.workspace_cannotDeleteTheOnlyWorkspace.tr(), + ), + ); + return emit( + state.copyWith( + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.delete, + result: result, + ), + ), + ); + } + + final result = await _userService.deleteWorkspaceById(workspaceId); + final workspaces = result.fold( + // remove the deleted workspace from the list instead of fetching + // the workspaces again + (s) => state.workspaces + .where((e) => e.workspaceId != workspaceId) + .toList(), + (e) => state.workspaces, + ); + result.onSuccess((_) { + // if the current workspace is deleted, open the first workspace + if (state.currentWorkspace?.workspaceId == workspaceId) { + add(OpenWorkspace(workspaces.first.workspaceId)); + } + }); + emit( + state.copyWith( + workspaces: workspaces, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.delete, + result: result, + ), + ), + ); + }, + openWorkspace: (workspaceId) async { + final result = await _userService.openWorkspace(workspaceId); + final currentWorkspace = result.fold( + (s) => state.workspaces.firstWhereOrNull( + (e) => e.workspaceId == workspaceId, + ), + (e) => state.currentWorkspace, + ); + emit( + state.copyWith( + currentWorkspace: currentWorkspace, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.open, + result: result, + ), + ), + ); + }, + renameWorkspace: (workspaceId, name) async { + final result = + await _userService.renameWorkspace(workspaceId, name); + final workspaces = result.fold( + (s) => state.workspaces.map( + (e) { + if (e.workspaceId == workspaceId) { + e.freeze(); + return e.rebuild((p0) { + p0.name = name; + }); + } + return e; + }, + ).toList(), + (f) => state.workspaces, + ); + final currentWorkspace = workspaces.firstWhere( + (e) => e.workspaceId == state.currentWorkspace?.workspaceId, + ); + emit( + state.copyWith( + workspaces: workspaces, + currentWorkspace: currentWorkspace, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.rename, + result: result, + ), + ), + ); + }, + updateWorkspaceIcon: (workspaceId, icon) async { + final result = await _userService.updateWorkspaceIcon( + workspaceId, + icon, + ); + final workspaces = result.fold( + (s) => state.workspaces.map( + (e) { + if (e.workspaceId == workspaceId) { + e.freeze(); + return e.rebuild((p0) { + p0.icon = icon; + }); + } + return e; + }, + ).toList(), + (f) => state.workspaces, + ); + final currentWorkspace = workspaces.firstWhere( + (e) => e.workspaceId == state.currentWorkspace?.workspaceId, + ); + emit( + state.copyWith( + workspaces: workspaces, + currentWorkspace: currentWorkspace, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.updateIcon, + result: result, + ), + ), + ); + }, + ); + }, + ); + } + + final UserProfilePB userProfile; + final UserBackendService _userService; + + Future<(UserWorkspacePB currentWorkspace, List workspaces)?> + _fetchWorkspaces() async { + try { + final currentWorkspace = + await _userService.getCurrentWorkspace().getOrThrow(); + final workspaces = await _userService.getWorkspaces().getOrThrow(); + final currentWorkspaceInList = + workspaces.firstWhere((e) => e.workspaceId == currentWorkspace.id); + return (currentWorkspaceInList, workspaces); + } catch (e) { + Log.error('fetch workspace error: $e'); + return null; + } + } +} + +@freezed +class UserWorkspaceEvent with _$UserWorkspaceEvent { + const factory UserWorkspaceEvent.initial() = Initial; + const factory UserWorkspaceEvent.fetchWorkspaces() = FetchWorkspaces; + const factory UserWorkspaceEvent.createWorkspace(String name) = + CreateWorkspace; + const factory UserWorkspaceEvent.deleteWorkspace(String workspaceId) = + DeleteWorkspace; + const factory UserWorkspaceEvent.openWorkspace(String workspaceId) = + OpenWorkspace; + const factory UserWorkspaceEvent.renameWorkspace( + String workspaceId, + String name, + ) = _RenameWorkspace; + const factory UserWorkspaceEvent.updateWorkspaceIcon( + String workspaceId, + String icon, + ) = _UpdateWorkspaceIcon; +} + +enum UserWorkspaceActionType { + none, + create, + delete, + open, + rename, + updateIcon, + fetchWorkspaces; +} + +class UserWorkspaceActionResult { + const UserWorkspaceActionResult({ + required this.actionType, + required this.result, + }); + + final UserWorkspaceActionType actionType; + final FlowyResult result; +} + +@freezed +class UserWorkspaceState with _$UserWorkspaceState { + const UserWorkspaceState._(); + + const factory UserWorkspaceState({ + @Default(null) UserWorkspacePB? currentWorkspace, + @Default([]) List workspaces, + @Default(null) UserWorkspaceActionResult? actionResult, + @Default(false) bool isCollabWorkspaceOn, + }) = _UserWorkspaceState; + + factory UserWorkspaceState.initial() => const UserWorkspaceState(); + + @override + int get hashCode => runtimeType.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UserWorkspaceState && + other.currentWorkspace == currentWorkspace && + other.workspaces == workspaces && + identical(other.actionResult, actionResult); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index b7ef0e3f664e7..8cfa6a20145a8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -9,8 +9,8 @@ import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; @@ -45,7 +45,7 @@ class ViewBloc extends Bloc { initial: (e) async { listener.start( onViewUpdated: (result) { - add(ViewEvent.viewDidUpdate(left(result))); + add(ViewEvent.viewDidUpdate(FlowyResult.success(result))); }, onViewChildViewsUpdated: (result) async { final view = await _updateChildViews(result); @@ -56,13 +56,20 @@ class ViewBloc extends Bloc { ); favoriteListener.start( favoritesUpdated: (result, isFavorite) { - result.fold((error) {}, (result) { - final current = result.items - .firstWhereOrNull((v) => v.id == state.view.id); - if (current != null) { - add(ViewEvent.viewDidUpdate(left(current))); - } - }); + result.fold( + (result) { + final current = result.items + .firstWhereOrNull((v) => v.id == state.view.id); + if (current != null) { + add( + ViewEvent.viewDidUpdate( + FlowyResult.success(current), + ), + ); + } + }, + (error) {}, + ); }, ); final isExpanded = await _getViewIsExpanded(view); @@ -95,12 +102,12 @@ class ViewBloc extends Bloc { emit( state.copyWith( view: view_ ?? view, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), ), ); }, (error) => emit( - state.copyWith(successOrFailure: right(error)), + state.copyWith(successOrFailure: FlowyResult.failure(error)), ), ); }, @@ -118,11 +125,13 @@ class ViewBloc extends Bloc { (b) => b.name = e.newName, ); return state.copyWith( - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), view: newView, ); }, - (error) => state.copyWith(successOrFailure: right(error)), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), ), ); }, @@ -130,8 +139,11 @@ class ViewBloc extends Bloc { final result = await ViewBackendService.delete(viewId: view.id); emit( result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), + (l) => + state.copyWith(successOrFailure: FlowyResult.success(null)), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), ), ); await RecentService().updateRecentViews([view.id], false); @@ -140,8 +152,11 @@ class ViewBloc extends Bloc { final result = await ViewBackendService.duplicate(view: view); emit( result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), + (l) => + state.copyWith(successOrFailure: FlowyResult.success(null)), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), ), ); }, @@ -150,11 +165,16 @@ class ViewBloc extends Bloc { viewId: value.from.id, newParentId: value.newParentId, prevViewId: value.prevId, + fromSection: value.fromSection, + toSection: value.toSection, ); emit( result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), + (l) => + state.copyWith(successOrFailure: FlowyResult.success(null)), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), ), ); }, @@ -166,15 +186,17 @@ class ViewBloc extends Bloc { layoutType: e.layoutType, ext: {}, openAfterCreate: e.openAfterCreated, + section: e.section, ); - emit( result.fold( (view) => state.copyWith( lastCreatedView: view, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), + ), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), ), - (error) => state.copyWith(successOrFailure: right(error)), ), ); }, @@ -225,7 +247,7 @@ class ViewBloc extends Bloc { }, (error) => emit( state.copyWith( - successOrFailure: right(error), + successOrFailure: FlowyResult.failure(error), isExpanded: true, isLoading: false, ), @@ -235,10 +257,12 @@ class ViewBloc extends Bloc { Future _setViewIsExpanded(ViewPB view, bool isExpanded) async { final result = await getIt().get(KVKeys.expandedViews); - final map = result.fold( - () => {}, - (r) => jsonDecode(r), - ); + final Map map; + if (result != null) { + map = jsonDecode(result); + } else { + map = {}; + } if (isExpanded) { map[view.id] = true; } else { @@ -249,10 +273,11 @@ class ViewBloc extends Bloc { Future _getViewIsExpanded(ViewPB view) { return getIt().get(KVKeys.expandedViews).then((result) { - return result.fold(() => false, (r) { - final map = jsonDecode(r); - return map[view.id] ?? false; - }); + if (result == null) { + return false; + } + final map = jsonDecode(result); + return map[view.id] ?? false; }); } @@ -330,15 +355,19 @@ class ViewEvent with _$ViewEvent { ViewPB from, String newParentId, String? prevId, + ViewSectionPB? fromSection, + ViewSectionPB? toSection, ) = Move; const factory ViewEvent.createView( String name, ViewLayoutPB layoutType, { /// open the view after created @Default(true) bool openAfterCreated, + required ViewSectionPB section, }) = CreateView; - const factory ViewEvent.viewDidUpdate(Either result) = - ViewDidUpdate; + const factory ViewEvent.viewDidUpdate( + FlowyResult result, + ) = ViewDidUpdate; const factory ViewEvent.viewUpdateChildView(ViewPB result) = ViewUpdateChildView; } @@ -349,7 +378,7 @@ class ViewState with _$ViewState { required ViewPB view, required bool isEditing, required bool isExpanded, - required Either successOrFailure, + required FlowyResult successOrFailure, @Default(true) bool isLoading, @Default(null) ViewPB? lastCreatedView, }) = _ViewState; @@ -358,6 +387,6 @@ class ViewState with _$ViewState { view: view, isExpanded: false, isEditing: false, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(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 3e111d72cf4fb..476f80b484d43 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; @@ -12,7 +10,8 @@ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:dartz/dartz.dart' hide id; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/material.dart'; enum FlowyPlugin { editor, @@ -93,13 +92,13 @@ extension ViewExtension on ViewPB { final ancestors = []; if (includeSelf) { final self = await ViewBackendService.getView(id); - ancestors.add(self.getLeftOrNull() ?? this); + ancestors.add(self.fold((s) => s, (e) => this)); } - Either parent = + FlowyResult parent = await ViewBackendService.getView(parentViewId); - while (parent.isLeft()) { + while (parent.isSuccess) { // parent is not null - final view = parent.getLeftOrNull(); + final view = parent.fold((s) => s, (e) => null); if (view == null || (!includeRoot && view.parentViewId.isEmpty)) { break; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart index d082cd84f62d1..95505b8216541 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart @@ -1,22 +1,23 @@ import 'dart:async'; import 'dart:typed_data'; + import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; // Delete the view from trash, which means the view was deleted permanently -typedef DeleteViewNotifyValue = Either; +typedef DeleteViewNotifyValue = FlowyResult; // The view get updated typedef UpdateViewNotifiedValue = ViewPB; // Restore the view from trash -typedef RestoreViewNotifiedValue = Either; +typedef RestoreViewNotifiedValue = FlowyResult; // Move the view to trash -typedef MoveToTrashNotifiedValue = Either; +typedef MoveToTrashNotifiedValue = FlowyResult; class ViewListener { ViewListener({required this.viewId}); @@ -63,7 +64,7 @@ class ViewListener { void _handleObservableType( FolderNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateView: @@ -86,22 +87,23 @@ class ViewListener { break; case FolderNotification.DidDeleteView: result.fold( - (payload) => _deletedNotifier?.call(left(ViewPB.fromBuffer(payload))), - (error) => _deletedNotifier?.call(right(error)), + (payload) => _deletedNotifier + ?.call(FlowyResult.success(ViewPB.fromBuffer(payload))), + (error) => _deletedNotifier?.call(FlowyResult.failure(error)), ); break; case FolderNotification.DidRestoreView: result.fold( - (payload) => - _restoredNotifier?.call(left(ViewPB.fromBuffer(payload))), - (error) => _restoredNotifier?.call(right(error)), + (payload) => _restoredNotifier + ?.call(FlowyResult.success(ViewPB.fromBuffer(payload))), + (error) => _restoredNotifier?.call(FlowyResult.failure(error)), ); break; case FolderNotification.DidMoveViewToTrash: result.fold( (payload) => _moveToTrashNotifier - ?.call(left(DeletedViewPB.fromBuffer(payload))), - (error) => _moveToTrashNotifier?.call(right(error)), + ?.call(FlowyResult.success(DeletedViewPB.fromBuffer(payload))), + (error) => _moveToTrashNotifier?.call(FlowyResult.failure(error)), ); break; default: 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 8c31a69a76da1..a8ffc0516ea7d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -3,10 +3,10 @@ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class ViewBackendService { - static Future> createView({ + static Future> createView({ /// The [layoutType] is the type of the view. required ViewLayoutPB layoutType, @@ -37,6 +37,7 @@ class ViewBackendService { /// The [index] is the index of the view in the parent view. /// If the index is null, the view will be added to the end of the list. int? index, + ViewSectionPB? section, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = parentViewId @@ -58,13 +59,17 @@ class ViewBackendService { payload.index = index; } + if (section != null) { + payload.section = section; + } + return FolderEventCreateView(payload).send(); } /// The orphan view is meant to be a view that is not attached to any parent view. By default, this /// view will not be shown in the view list unless it is attached to a parent view that is shown in /// the view list. - static Future> createOrphanView({ + static Future> createOrphanView({ required String viewId, required ViewLayoutPB layoutType, required String name, @@ -84,7 +89,7 @@ class ViewBackendService { return FolderEventCreateOrphanView(payload).send(); } - static Future> createDatabaseLinkedView({ + static Future> createDatabaseLinkedView({ required String parentViewId, required String databaseId, required ViewLayoutPB layoutType, @@ -101,39 +106,47 @@ class ViewBackendService { } /// Returns a list of views that are the children of the given [viewId]. - static Future, FlowyError>> getChildViews({ + static Future, FlowyError>> getChildViews({ required String viewId, }) { final payload = ViewIdPB.create()..value = viewId; return FolderEventGetView(payload).send().then((result) { return result.fold( - (view) => left(view.childViews), - (error) => right(error), + (view) => FlowyResult.success(view.childViews), + (error) => FlowyResult.failure(error), ); }); } - static Future> delete({required String viewId}) { + static Future> delete({ + required String viewId, + }) { final request = RepeatedViewIdPB.create()..items.add(viewId); return FolderEventDeleteView(request).send(); } - static Future> deleteView({required String viewId}) { + static Future> deleteView({ + required String viewId, + }) { final request = RepeatedViewIdPB.create()..items.add(viewId); return FolderEventDeleteView(request).send(); } - static Future> duplicate({required ViewPB view}) { + static Future> duplicate({ + required ViewPB view, + }) { return FolderEventDuplicateView(view).send(); } - static Future> favorite({required String viewId}) { + static Future> favorite({ + required String viewId, + }) { final request = RepeatedViewIdPB.create()..items.add(viewId); return FolderEventToggleFavorite(request).send(); } - static Future> updateView({ + static Future> updateView({ required String viewId, String? name, bool? isFavorite, @@ -151,7 +164,7 @@ class ViewBackendService { return FolderEventUpdateView(payload).send(); } - static Future> updateViewIcon({ + static Future> updateViewIcon({ required String viewId, required String viewIcon, }) { @@ -166,7 +179,7 @@ class ViewBackendService { } // deprecated - static Future> moveView({ + static Future> moveView({ required String viewId, required int fromIndex, required int toIndex, @@ -183,15 +196,19 @@ class ViewBackendService { /// /// supports nested view /// if the [prevViewId] is null, the view will be moved to the beginning of the list - static Future> moveViewV2({ + static Future> moveViewV2({ required String viewId, required String newParentId, required String? prevViewId, + ViewSectionPB? fromSection, + ViewSectionPB? toSection, }) { final payload = MoveNestedViewPayloadPB( viewId: viewId, newParentId: newParentId, prevViewId: prevViewId, + fromSection: fromSection, + toSection: toSection, ); return FolderEventMoveNestedView(payload).send(); @@ -214,7 +231,7 @@ class ViewBackendService { Future> fetchViews() async { final result = []; return FolderEventReadCurrentWorkspace().send().then((value) async { - final workspace = value.getLeftOrNull(); + final workspace = value.toNullable(); if (workspace != null) { final views = workspace.views; for (final view in views) { @@ -230,7 +247,7 @@ class ViewBackendService { Future> getAllViews(ViewPB view) async { final result = []; final childViews = await getChildViews(viewId: view.id).then( - (value) => value.getLeftOrNull>()?.toList(), + (value) => value.toNullable(), ); if (childViews != null && childViews.isNotEmpty) { result.addAll(childViews); @@ -242,35 +259,25 @@ class ViewBackendService { return result; } - static Future> getView( + static Future> getView( String viewID, ) async { final payload = ViewIdPB.create()..value = viewID; return FolderEventGetView(payload).send(); } - Future> getChildView({ + Future> getChildView({ required String parentViewId, required String childViewId, }) async { final payload = ViewIdPB.create()..value = parentViewId; return FolderEventGetView(payload).send().then((result) { return result.fold( - (app) => left( + (app) => FlowyResult.success( app.childViews.firstWhere((e) => e.id == childViewId), ), - (error) => right(error), + (error) => FlowyResult.failure(error), ); }); } } - -extension AppFlowy on Either { - T? getLeftOrNull() { - if (isLeft()) { - final result = fold((l) => l, (r) => null); - return result; - } - return null; - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart new file mode 100644 index 0000000000000..b24c4f6a9a441 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'view_info_bloc.freezed.dart'; + +class ViewInfoBloc extends Bloc { + ViewInfoBloc({required this.view}) : super(ViewInfoState.initial()) { + on((event, emit) { + event.when( + started: () { + emit(state.copyWith(createdAt: view.createTime.toDateTime())); + }, + unregisterEditorState: () { + _clearWordCountService(); + emit(state.copyWith(documentCounters: null)); + }, + registerEditorState: (editorState) { + _clearWordCountService(); + _wordCountService = WordCountService(editorState: editorState) + ..addListener(_onWordCountChanged) + ..register(); + + emit( + state.copyWith( + documentCounters: _wordCountService!.documentCounters, + ), + ); + }, + wordCountChanged: () { + emit( + state.copyWith( + documentCounters: _wordCountService?.documentCounters, + ), + ); + }, + ); + }); + } + + final ViewPB view; + + WordCountService? _wordCountService; + + @override + Future close() async { + _clearWordCountService(); + await super.close(); + } + + void _onWordCountChanged() => add(const ViewInfoEvent.wordCountChanged()); + + void _clearWordCountService() { + _wordCountService + ?..removeListener(_onWordCountChanged) + ..dispose(); + _wordCountService = null; + } +} + +@freezed +class ViewInfoEvent with _$ViewInfoEvent { + const factory ViewInfoEvent.started() = _Started; + + const factory ViewInfoEvent.unregisterEditorState() = _UnregisterEditorState; + + const factory ViewInfoEvent.registerEditorState({ + required EditorState editorState, + }) = _RegisterEditorState; + + const factory ViewInfoEvent.wordCountChanged() = _WordCountChanged; +} + +@freezed +class ViewInfoState with _$ViewInfoState { + const factory ViewInfoState({ + required Counters? documentCounters, + required DateTime? createdAt, + }) = _ViewInfoState; + + factory ViewInfoState.initial() => const ViewInfoState( + documentCounters: null, + createdAt: null, + ); +} 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 f7a7a2a24f4a4..d8d5db45b47b0 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -2,7 +2,7 @@ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -25,14 +25,16 @@ class WorkspaceBloc extends Bloc { createWorkspace: (e) async { await _createWorkspace(e.name, e.desc, emit); }, - workspacesReveived: (e) async { + workspacesReceived: (e) async { emit( e.workspacesOrFail.fold( (workspaces) => state.copyWith( workspaces: workspaces, - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), + ), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), ), - (error) => state.copyWith(successOrFailure: right(error)), ), ); }, @@ -46,12 +48,12 @@ class WorkspaceBloc extends Bloc { emit( workspacesOrFailed.fold( (workspaces) => state.copyWith( - workspaces: workspaces, - successOrFailure: left(unit), + workspaces: [], + successOrFailure: FlowyResult.success(null), ), (error) { Log.error(error); - return state.copyWith(successOrFailure: right(error)); + return state.copyWith(successOrFailure: FlowyResult.failure(error)); }, ), ); @@ -66,11 +68,11 @@ class WorkspaceBloc extends Bloc { emit( result.fold( (workspace) { - return state.copyWith(successOrFailure: left(unit)); + return state.copyWith(successOrFailure: FlowyResult.success(null)); }, (error) { Log.error(error); - return state.copyWith(successOrFailure: right(error)); + return state.copyWith(successOrFailure: FlowyResult.failure(error)); }, ), ); @@ -82,8 +84,8 @@ class WorkspaceEvent with _$WorkspaceEvent { const factory WorkspaceEvent.initial() = Initial; const factory WorkspaceEvent.createWorkspace(String name, String desc) = CreateWorkspace; - const factory WorkspaceEvent.workspacesReveived( - Either, FlowyError> workspacesOrFail, + const factory WorkspaceEvent.workspacesReceived( + FlowyResult, FlowyError> workspacesOrFail, ) = WorkspacesReceived; } @@ -92,12 +94,12 @@ class WorkspaceState with _$WorkspaceState { const factory WorkspaceState({ required bool isLoading, required List workspaces, - required Either successOrFailure, + required FlowyResult successOrFailure, }) = _WorkspaceState; factory WorkspaceState.initial() => WorkspaceState( isLoading: false, workspaces: List.empty(), - successOrFailure: left(unit), + successOrFailure: FlowyResult.success(null), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart index 3168e3ff3af01..fb3beb4dd569a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart @@ -1,32 +1,38 @@ import 'dart:async'; import 'dart:typed_data'; + import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; -typedef AppListNotifyValue = Either, FlowyError>; -typedef WorkspaceNotifyValue = Either; +typedef RootViewsNotifyValue = FlowyResult, FlowyError>; +typedef WorkspaceNotifyValue = FlowyResult; +/// The [WorkspaceListener] listens to the changes including the below: +/// +/// - The root views of the workspace. (Not including the views are inside the root views) +/// - The workspace itself. class WorkspaceListener { WorkspaceListener({required this.user, required this.workspaceId}); final UserProfilePB user; final String workspaceId; - PublishNotifier? _appsChangedNotifier = PublishNotifier(); + PublishNotifier? _appsChangedNotifier = + PublishNotifier(); PublishNotifier? _workspaceUpdatedNotifier = PublishNotifier(); FolderNotificationListener? _listener; void start({ - void Function(AppListNotifyValue)? appsChanged, + void Function(RootViewsNotifyValue)? appsChanged, void Function(WorkspaceNotifyValue)? onWorkspaceUpdated, }) { if (appsChanged != null) { @@ -45,21 +51,22 @@ class WorkspaceListener { void _handleObservableType( FolderNotification ty, - Either result, + FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateWorkspace: result.fold( (payload) => _workspaceUpdatedNotifier?.value = - left(WorkspacePB.fromBuffer(payload)), - (error) => _workspaceUpdatedNotifier?.value = right(error), + FlowyResult.success(WorkspacePB.fromBuffer(payload)), + (error) => + _workspaceUpdatedNotifier?.value = FlowyResult.failure(error), ); break; case FolderNotification.DidUpdateWorkspaceViews: result.fold( (payload) => _appsChangedNotifier?.value = - left(RepeatedViewPB.fromBuffer(payload).items), - (error) => _appsChangedNotifier?.value = right(error), + FlowyResult.success(RepeatedViewPB.fromBuffer(payload).items), + (error) => _appsChangedNotifier?.value = FlowyResult.failure(error), ); break; default: diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart new file mode 100644 index 0000000000000..73c2a9045f466 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef SectionNotifyValue = FlowyResult; + +/// The [WorkspaceSectionsListener] listens to the changes including the below: +/// +/// - The root views inside different section of the workspace. (Not including the views are inside the root views) +/// depends on the section type(s). +class WorkspaceSectionsListener { + WorkspaceSectionsListener({ + required this.user, + required this.workspaceId, + }); + + final UserProfilePB user; + final String workspaceId; + + final _sectionNotifier = PublishNotifier(); + late final FolderNotificationListener _listener; + + void start({ + void Function(SectionNotifyValue)? sectionChanged, + }) { + if (sectionChanged != null) { + _sectionNotifier.addPublishListener(sectionChanged); + } + + _listener = FolderNotificationListener( + objectId: workspaceId, + handler: _handleObservableType, + ); + } + + void _handleObservableType( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidUpdateSectionViews: + final FlowyResult value = result.fold( + (s) => FlowyResult.success( + SectionViewsPB.fromBuffer(s), + ), + (f) => FlowyResult.failure(f), + ); + _sectionNotifier.value = value; + break; + default: + break; + } + } + + Future stop() async { + _sectionNotifier.dispose(); + + await _listener.stop(); + } +} 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 da6f366ca1fea..6e42b744f65c8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -1,26 +1,27 @@ import 'dart:async'; -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-folder/view.pb.dart' - show CreateViewPayloadPB, MoveViewPayloadPB, ViewLayoutPB, ViewPB; -import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; class WorkspaceService { WorkspaceService({required this.workspaceId}); final String workspaceId; - Future> createApp({ + Future> createView({ required String name, + required ViewSectionPB viewSection, String? desc, int? index, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = workspaceId ..name = name - ..layout = ViewLayoutPB.Document; + // only allow document layout for the top-level views + ..layout = ViewLayoutPB.Document + ..section = viewSection; if (desc != null) { payload.desc = desc; @@ -33,27 +34,37 @@ class WorkspaceService { return FolderEventCreateView(payload).send(); } - Future> getWorkspace() { + Future> getWorkspace() { return FolderEventReadCurrentWorkspace().send(); } - Future, FlowyError>> getViews() { - final payload = WorkspaceIdPB.create()..value = workspaceId; + Future, FlowyError>> getPublicViews() { + final payload = GetWorkspaceViewPB.create()..value = workspaceId; return FolderEventReadWorkspaceViews(payload).send().then((result) { return result.fold( - (views) => left(views.items), - (error) => right(error), + (views) => FlowyResult.success(views.items), + (error) => FlowyResult.failure(error), ); }); } - Future> moveApp({ - required String appId, + Future, FlowyError>> getPrivateViews() { + final payload = GetWorkspaceViewPB.create()..value = workspaceId; + return FolderEventReadPrivateViews(payload).send().then((result) { + return result.fold( + (views) => FlowyResult.success(views.items), + (error) => FlowyResult.failure(error), + ); + }); + } + + Future> moveView({ + required String viewId, required int fromIndex, required int toIndex, }) { final payload = MoveViewPayloadPB.create() - ..viewId = appId + ..viewId = viewId ..from = fromIndex ..to = toIndex; 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 3335b67c8ec5a..1add004e82320 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 @@ -4,6 +4,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/memory_leak_detector.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/home/home_bloc.dart'; import 'package:appflowy/workspace/application/home/home_service.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; @@ -13,7 +14,6 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; -import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/edit_panel/panel_animation.dart'; import 'package:appflowy/workspace/presentation/widgets/float_bubble/question_bubble.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; @@ -54,8 +54,8 @@ class DesktopHomeScreen extends StatelessWidget { (error) => null, ); final userProfile = snapshots.data?[1].fold( - (error) => null, (userProfilePB) => userProfilePB as UserProfilePB, + (error) => null, ); // In the unlikely case either of the above is null, eg. @@ -182,7 +182,7 @@ class DesktopHomeScreen extends StatelessWidget { required WorkspaceSettingPB workspaceSetting, }) { final homeMenu = HomeSideBar( - user: userProfile, + userProfile: userProfile, workspaceSetting: workspaceSetting, ); return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); @@ -197,15 +197,16 @@ class DesktopHomeScreen extends StatelessWidget { buildWhen: (previous, current) => previous.panelContext != current.panelContext, builder: (context, state) { - return state.panelContext.fold( - () => const SizedBox(), - (panelContext) => FocusTraversalGroup( - child: RepaintBoundary( - child: EditPanel( - panelContext: panelContext, - onEndEdit: () => - homeBloc.add(const HomeSettingEvent.dismissEditPanel()), - ), + final panelContext = state.panelContext; + if (panelContext == null) { + return const SizedBox.shrink(); + } + return FocusTraversalGroup( + child: RepaintBoundary( + child: EditPanel( + panelContext: panelContext, + onEndEdit: () => + homeBloc.add(const HomeSettingEvent.dismissEditPanel()), ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart index 60f3330408be1..68cac12dc5ec0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart @@ -1,11 +1,12 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.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/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; class WorkspaceFailedScreen extends StatefulWidget { @@ -51,7 +52,7 @@ class _WorkspaceFailedScreenState extends State { title: LocaleKeys.workspace_errorActions_reportIssue.tr(), height: 40, - onPressed: () => safeLaunchUrl( + onPressed: () => afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Workspace%20failed%20to%20load&version=$version&os=$os', ), ), @@ -62,7 +63,7 @@ class _WorkspaceFailedScreenState extends State { title: LocaleKeys.workspace_errorActions_reachOut.tr(), height: 40, onPressed: () => - safeLaunchUrl('https://discord.gg/JucBXeU2FE'), + afLaunchUrlString('https://discord.gg/JucBXeU2FE'), ), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart index b466ca9d2eeac..1da2ca32fab2d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart @@ -12,7 +12,7 @@ import 'home_sizes.dart'; class HomeLayout { HomeLayout(BuildContext context) { final homeSetting = context.read().state; - showEditPanel = homeSetting.panelContext.isSome(); + showEditPanel = homeSetting.panelContext != null; menuWidth = Sizes.sideBarWidth; menuWidth += homeSetting.resizeOffset; 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 a941c9a284cd3..0574bb8ae4c45 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; @@ -10,7 +12,6 @@ import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; @@ -175,6 +176,7 @@ class PageNotifier extends ChangeNotifier { /// No need compare the old plugin with the new plugin. Just set it. set plugin(Plugin newPlugin) { _plugin.dispose(); + newPlugin.init(); /// Set the plugin view as the latest view. FolderEventSetLatestView(ViewIdPB(value: newPlugin.id)).send(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart index e49c2786b3fa5..356e18bded4b2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart @@ -1,13 +1,12 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_setting.dart'; +import 'package:flutter/material.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:provider/provider.dart'; 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 similarity index 100% rename from frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_favorite_folder.dart diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart new file mode 100644 index 0000000000000..00f88e153dbce --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart @@ -0,0 +1,63 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class FolderHeader extends StatefulWidget { + const FolderHeader({ + super.key, + required this.title, + required this.expandButtonTooltip, + required this.addButtonTooltip, + required this.onPressed, + required this.onAdded, + }); + + final String title; + final String expandButtonTooltip; + final String addButtonTooltip; + final VoidCallback onPressed; + final VoidCallback onAdded; + + @override + State createState() => _FolderHeaderState(); +} + +class _FolderHeaderState extends State { + bool onHover = false; + + @override + Widget build(BuildContext context) { + const iconSize = 26.0; + const textPadding = 4.0; + return MouseRegion( + onEnter: (event) => setState(() => onHover = true), + onExit: (event) => setState(() => onHover = false), + child: Row( + children: [ + FlowyTextButton( + widget.title, + tooltip: widget.expandButtonTooltip, + constraints: const BoxConstraints( + minHeight: iconSize + textPadding * 2, + ), + padding: const EdgeInsets.all(textPadding), + fillColor: Colors.transparent, + onPressed: widget.onPressed, + ), + if (onHover) ...[ + const Spacer(), + FlowyIconButton( + tooltipText: widget.addButtonTooltip, + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + iconPadding: const EdgeInsets.all(2), + height: iconSize, + width: iconSize, + icon: const FlowySvg(FlowySvgs.add_s), + onPressed: widget.onAdded, + ), + ], + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart new file mode 100644 index 0000000000000..2cf57a6d08303 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart @@ -0,0 +1,116 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SectionFolder extends StatelessWidget { + const SectionFolder({ + super.key, + required this.title, + required this.categoryType, + required this.views, + this.isHoverEnabled = true, + }); + + final String title; + final FolderCategoryType categoryType; + final List views; + final bool isHoverEnabled; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => FolderBloc(type: categoryType) + ..add( + const FolderEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + FolderHeader( + title: title, + expandButtonTooltip: expandButtonTooltip, + addButtonTooltip: addButtonTooltip, + onPressed: () => context + .read() + .add(const FolderEvent.expandOrUnExpand()), + onAdded: () { + createViewAndShowRenameDialogIfNeeded( + context, + LocaleKeys.newPageText.tr(), + (viewName, _) { + if (viewName.isNotEmpty) { + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: viewName, + index: 0, + viewSection: categoryType.toViewSectionPB, + ), + ); + + context.read().add( + const FolderEvent.expandOrUnExpand( + isExpanded: true, + ), + ); + } + }, + ); + }, + ), + if (state.isExpanded) + ...views.map( + (view) => ViewItem( + key: ValueKey( + '${categoryType.name} ${view.id}', + ), + categoryType: categoryType, + isFirstChild: view.id == views.first.id, + view: view, + level: 0, + leftPadding: 16, + isFeedback: false, + onSelected: (view) { + if (HardwareKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + + context.read().openPlugin(view); + }, + onTertiarySelected: (view) => + context.read().openTab(view), + isHoverEnabled: isHoverEnabled, + ), + ), + ], + ); + }, + ), + ); + } + + String get expandButtonTooltip { + return switch (categoryType) { + FolderCategoryType.public => LocaleKeys.sideBar_clickToHidePublic.tr(), + FolderCategoryType.private => LocaleKeys.sideBar_clickToHidePrivate.tr(), + _ => '', + }; + } + + String get addButtonTooltip { + return switch (categoryType) { + FolderCategoryType.public => LocaleKeys.sideBar_addAPageToPublic.tr(), + FolderCategoryType.private => LocaleKeys.sideBar_addAPageToPrivate.tr(), + _ => '', + }; + } +} 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 deleted file mode 100644 index e6352f5c38e41..0000000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; -import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; -import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart'; -import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class PersonalFolder extends StatelessWidget { - const PersonalFolder({ - super.key, - required this.views, - }); - - final List views; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => FolderBloc(type: FolderCategoryType.personal) - ..add( - const FolderEvent.initial(), - ), - child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - PersonalFolderHeader( - onPressed: () => context - .read() - .add(const FolderEvent.expandOrUnExpand()), - onAdded: () => context - .read() - .add(const FolderEvent.expandOrUnExpand(isExpanded: true)), - ), - if (state.isExpanded) - ...views.map( - (view) => ViewItem( - key: ValueKey( - '${FolderCategoryType.personal.name} ${view.id}', - ), - categoryType: FolderCategoryType.personal, - isFirstChild: view.id == views.first.id, - view: view, - level: 0, - leftPadding: 16, - isFeedback: false, - onSelected: (view) { - if (HardwareKeyboard.instance.isControlPressed) { - context.read().openTab(view); - } - - context.read().openPlugin(view); - }, - onTertiarySelected: (view) => - context.read().openTab(view), - ), - ), - ], - ); - }, - ), - ); - } -} - -class PersonalFolderHeader extends StatefulWidget { - const PersonalFolderHeader({ - super.key, - required this.onPressed, - required this.onAdded, - }); - - final VoidCallback onPressed; - final VoidCallback onAdded; - - @override - State createState() => _PersonalFolderHeaderState(); -} - -class _PersonalFolderHeaderState extends State { - bool onHover = false; - - @override - Widget build(BuildContext context) { - const iconSize = 26.0; - const textPadding = 4.0; - return MouseRegion( - onEnter: (event) => setState(() => onHover = true), - onExit: (event) => setState(() => onHover = false), - child: Row( - children: [ - FlowyTextButton( - LocaleKeys.sideBar_personal.tr(), - tooltip: LocaleKeys.sideBar_clickToHidePersonal.tr(), - constraints: const BoxConstraints( - minHeight: iconSize + textPadding * 2, - ), - padding: const EdgeInsets.all(textPadding), - fillColor: Colors.transparent, - onPressed: widget.onPressed, - ), - if (onHover) ...[ - const Spacer(), - FlowyIconButton( - tooltipText: LocaleKeys.sideBar_addAPage.tr(), - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - iconPadding: const EdgeInsets.all(2), - height: iconSize, - width: iconSize, - icon: const FlowySvg(FlowySvgs.add_s), - onPressed: () { - createViewAndShowRenameDialogIfNeeded( - context, - LocaleKeys.newPageText.tr(), - (viewName) { - if (viewName.isNotEmpty) { - context.read().add( - MenuEvent.createApp( - viewName, - index: 0, - ), - ); - - widget.onAdded(); - } - }, - ); - }, - ), - ], - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart index 3ec06bbd5c252..bf18df1a98c2c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart @@ -15,21 +15,21 @@ import 'package:flutter/material.dart'; Future createViewAndShowRenameDialogIfNeeded( BuildContext context, String dialogTitle, - void Function(String viewName) createView, + void Function(String viewName, BuildContext context) createView, ) async { final value = await getIt().getWithFormat( KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); - final showRenameDialog = value.fold(() => false, (r) => r); + final showRenameDialog = value ?? false; if (context.mounted && showRenameDialog) { await NavigatorTextFieldDialog( title: dialogTitle, value: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), autoSelectAllText: true, - confirm: createView, + onConfirm: createView, ).show(context); - } else { - createView(LocaleKeys.menuAppHeader_defaultNewPageName.tr()); + } else if (context.mounted) { + createView(LocaleKeys.menuAppHeader_defaultNewPageName.tr(), context); } } 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 af6f475698da3..a35248629bf69 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 @@ -1,23 +1,24 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.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/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_trash.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Home Sidebar is the left side bar of the home page. @@ -30,63 +31,161 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class HomeSideBar extends StatelessWidget { const HomeSideBar({ super.key, - required this.user, + required this.userProfile, required this.workspaceSetting, }); - final UserProfilePB user; + final UserProfilePB userProfile; final WorkspaceSettingPB workspaceSetting; @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => getIt(), - ), - BlocProvider( - create: (_) => MenuBloc( - user: user, - workspaceId: workspaceSetting.workspaceId, - )..add(const MenuEvent.initial()), + // Workspace Bloc: control the current workspace + // | + // +-- Workspace Menu + // | | + // | +-- Workspace List: control to switch workspace + // | | + // | +-- Workspace Settings + // | | + // | +-- Notification Center + // | + // +-- Favorite Section + // | + // +-- Public Or Private Section: control the sections of the workspace + // | + // +-- Trash Section + return BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: userProfile) + ..add( + const UserWorkspaceEvent.initial(), ), - ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => - p.lastCreatedView?.id != c.lastCreatedView?.id, - listener: (context, state) => context.read().add( - TabsEvent.openPlugin(plugin: state.lastCreatedView!.plugin()), + child: BlocBuilder( + // Rebuild the whole sidebar when the current workspace changes + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + builder: (context, state) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => getIt(), + ), + BlocProvider( + create: (_) => SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + userProfile, + state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId, + ), + ), + ), + ], + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) => context.read().add( + TabsEvent.openPlugin( + plugin: state.lastCreatedRootView!.plugin(), + ), + ), ), - ), - BlocListener( - listenWhen: (_, curr) => curr.action != null, - listener: _onNotificationAction, - ), - ], - child: Builder( - builder: (context) { - final menuState = context.watch().state; - final favoriteState = context.watch().state; - - return _buildSidebar( - context, - menuState.views, - favoriteState.views, - ); - }, - ), + BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: _onNotificationAction, + ), + BlocListener( + listener: (context, state) { + context.read().add( + SidebarSectionsEvent.initial( + userProfile, + state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId, + ), + ); + }, + ), + ], + child: _Sidebar(userProfile: userProfile), + ), + ); + }, ), ); } - Widget _buildSidebar( + void _onNotificationAction( BuildContext context, - List views, - List favoriteViews, + NotificationActionState state, ) { + final action = state.action; + if (action != null) { + if (action.type == ActionType.openView) { + final view = context + .read() + .state + .section + .publicViews + .findView(action.objectId); + + if (view != null) { + final Map arguments = {}; + + final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; + if (nodePath != null) { + arguments[PluginArgumentKeys.selection] = Selection.collapsed( + Position(path: [nodePath]), + ); + } + + final rowId = action.arguments?[ActionArgumentKeys.rowId]; + if (rowId != null) { + arguments[PluginArgumentKeys.rowId] = rowId; + } + + context.read().openPlugin(view, arguments: arguments); + } + } + } + } +} + +class _Sidebar extends StatefulWidget { + const _Sidebar({ + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State<_Sidebar> createState() => _SidebarState(); +} + +class _SidebarState extends State<_Sidebar> { + final _scrollController = ScrollController(); + Timer? _scrollDebounce; + bool isScrolling = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScrollChanged); + } + + @override + void dispose() { + _scrollDebounce?.cancel(); + _scrollController.removeListener(_onScrollChanged); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 12); return DecoratedBox( decoration: BoxDecoration( @@ -103,20 +202,29 @@ class HomeSideBar extends StatelessWidget { padding: menuHorizontalInset, child: SidebarTopMenu(), ), - // user, setting + // user or workspace, setting Padding( padding: menuHorizontalInset, - child: SidebarUser(user: user, views: views), + child: context.read().state.isCollabWorkspaceOn + ? SidebarWorkspace( + userProfile: widget.userProfile, + ) + : SidebarUser( + userProfile: widget.userProfile, + ), ), + const VSpace(20), // scrollable document list Expanded( child: Padding( padding: menuHorizontalInset, child: SingleChildScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics(), child: SidebarFolder( - views: views, - favoriteViews: favoriteViews, + userProfile: widget.userProfile, + isHoverEnabled: !isScrolling, ), ), ), @@ -135,34 +243,17 @@ class HomeSideBar extends StatelessWidget { ); } - void _onNotificationAction( - BuildContext context, - NotificationActionState state, - ) { - final action = state.action; - if (action != null) { - if (action.type == ActionType.openView) { - final view = - context.read().state.views.findView(action.objectId); - - if (view != null) { - final Map arguments = {}; - - final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; - if (nodePath != null) { - arguments[PluginArgumentKeys.selection] = Selection.collapsed( - Position(path: [nodePath]), - ); - } + void _onScrollChanged() { + setState(() => isScrolling = true); - final rowId = action.arguments?[ActionArgumentKeys.rowId]; - if (rowId != null) { - arguments[PluginArgumentKeys.rowId] = rowId; - } + _scrollDebounce?.cancel(); + _scrollDebounce = + Timer(const Duration(milliseconds: 300), _setScrollStopped); + } - context.read().openPlugin(view, arguments: arguments); - } - } + void _setScrollStopped() { + if (mounted) { + setState(() => isScrolling = false); } } } 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 01ec648e74009..e61c167a12df6 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 @@ -1,47 +1,117 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/favorite_folder.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/personal_folder.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_favorite_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarFolder extends StatelessWidget { const SidebarFolder({ super.key, - required this.views, - required this.favoriteViews, + this.isHoverEnabled = true, + required this.userProfile, }); - final List views; - final List favoriteViews; + final bool isHoverEnabled; + final UserProfilePB userProfile; @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) { return Column( children: [ // favorite - if (favoriteViews.isNotEmpty) ...[ - FavoriteFolder( - // remove the duplicate views - views: favoriteViews, - ), - const VSpace(10), - ], - // personal - PersonalFolder(views: views), + BlocBuilder( + builder: (context, state) { + if (state.views.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: FavoriteFolder( + // remove the duplicate views + views: state.views, + ), + ); + }, + ), + // public or private + BlocBuilder( + builder: (context, state) { + // only show public and private section if the workspace is collaborative and not local + final isCollaborativeWorkspace = + context.read().state.isCollabWorkspaceOn; + + return Column( + children: + // only show public and private section if the workspace is collaborative + isCollaborativeWorkspace + ? [ + // public + const VSpace(10), + PublicSectionFolder( + views: state.section.publicViews, + ), + + // private + const VSpace(10), + PrivateSectionFolder( + views: state.section.privateViews, + ), + ] + : [ + // personal + const VSpace(10), + PersonalSectionFolder( + views: state.section.publicViews, + ), + ], + ); + }, + ), ], ); }, ); } } + +class PrivateSectionFolder extends SectionFolder { + PrivateSectionFolder({ + super.key, + required super.views, + }) : super( + title: LocaleKeys.sideBar_private.tr(), + categoryType: FolderCategoryType.private, + ); +} + +class PublicSectionFolder extends SectionFolder { + PublicSectionFolder({ + super.key, + required super.views, + }) : super( + title: LocaleKeys.sideBar_public.tr(), + categoryType: FolderCategoryType.public, + ); +} + +class PersonalSectionFolder extends SectionFolder { + PersonalSectionFolder({ + super.key, + required super.views, + }) : super( + title: LocaleKeys.sideBar_personal.tr(), + categoryType: FolderCategoryType.public, + ); +} 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 46cd3dfbb182f..eac80118b4562 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 @@ -1,7 +1,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/extension.dart'; @@ -23,9 +25,19 @@ class SidebarNewPageButton extends StatelessWidget { onPressed: () async => createViewAndShowRenameDialogIfNeeded( context, LocaleKeys.newPageText.tr(), - (viewName) { + (viewName, _) { if (viewName.isNotEmpty) { - context.read().add(MenuEvent.createApp(viewName)); + // if the workspace is collaborative, create the view in the private section by default. + final section = + context.read().state.isCollabWorkspaceOn + ? ViewSectionPB.Private + : ViewSectionPB.Public; + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: viewName, + viewSection: section, + ), + ); } }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart new file mode 100644 index 0000000000000..24d4dd312fb3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; + +final GlobalKey _settingsDialogKey = GlobalKey(); + +Future openSettingsHotKey(BuildContext context) async { + final userProfileOrFailure = await getIt().getUser(); + + return userProfileOrFailure.fold( + (userProfile) => HotKeyItem( + hotKey: HotKey( + KeyCode.comma, + scope: HotKeyScope.inapp, + modifiers: [ + PlatformExtension.isMacOS ? KeyModifier.meta : KeyModifier.control, + ], + ), + keyDownHandler: (_) { + if (_settingsDialogKey.currentContext == null) { + showSettingsDialog(context, userProfile); + } else { + Navigator.of(context, rootNavigator: true) + .popUntil((route) => route.isFirst); + } + }, + ), + (e) { + Log.error('Failed to get user $e'); + return null; + }, + ); +} + +class UserSettingButton extends StatelessWidget { + const UserSettingButton({required this.userProfile, super.key}); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_open.tr(), + child: IconButton( + onPressed: () => showSettingsDialog(context, userProfile), + icon: SizedBox.square( + dimension: 20, + child: FlowySvg( + FlowySvgs.settings_m, + color: Theme.of(context).colorScheme.tertiary, + ), + ), + ), + ); + } +} + +void showSettingsDialog( + BuildContext context, + UserProfilePB userProfile, +) { + showDialog( + context: context, + builder: (dialogContext) { + return BlocProvider.value( + key: _settingsDialogKey, + value: BlocProvider.of(dialogContext), + child: SettingsDialog( + userProfile, + didLogout: () async { + // Pop the dialog using the dialog context + Navigator.of(dialogContext).pop(); + await runAppFlowy(); + }, + dismissDialog: () { + if (Navigator.of(dialogContext).canPop()) { + Navigator.of(dialogContext).pop(); + } else { + Log.warn("Can't pop dialog context"); + } + }, + restartApp: () async { + // Pop the dialog using the dialog context + Navigator.of(dialogContext).pop(); + await runAppFlowy(); + }, + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart index 1c7452584abbe..e4d5f2fa3e69f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart @@ -4,7 +4,7 @@ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; @@ -24,7 +24,7 @@ class SidebarTopMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return SizedBox( height: HomeSizes.topBarHeight, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart index 0532d8cfabb87..288bd76a74d0e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart @@ -1,71 +1,29 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; -import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:easy_localization/easy_localization.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; - -final GlobalKey _settingsDialogKey = GlobalKey(); - -Future openSettingsHotKey(BuildContext context) async { - final userProfileOrFailure = await getIt().getUser(); - - return userProfileOrFailure.fold( - (e) { - Log.error('Failed to get user $e'); - return null; - }, - (userProfile) => HotKeyItem( - hotKey: HotKey( - KeyCode.comma, - scope: HotKeyScope.inapp, - modifiers: [ - PlatformExtension.isMacOS ? KeyModifier.meta : KeyModifier.control, - ], - ), - keyDownHandler: (_) { - if (_settingsDialogKey.currentContext == null) { - _showSettingsDialog(context, userProfile); - } else { - Navigator.of(context, rootNavigator: true) - .popUntil((route) => route.isFirst); - } - }, - ), - ); -} +// keep this widget in case we need to roll back (lucas.xu) class SidebarUser extends StatelessWidget { const SidebarUser({ super.key, - required this.user, - required this.views, + required this.userProfile, }); - final UserProfilePB user; - final List views; + final UserProfilePB userProfile; @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => MenuUserBloc(user) + create: (context) => MenuUserBloc(userProfile) ..add( const MenuUserEvent.initial(), ), @@ -76,13 +34,13 @@ class SidebarUser extends StatelessWidget { iconUrl: state.userProfile.iconUrl, name: state.userProfile.name, ), - const HSpace(4), + const HSpace(8), Expanded( child: _buildUserName(context, state), ), UserSettingButton(userProfile: state.userProfile), const HSpace(4), - NotificationButton(views: views), + const NotificationButton(), ], ), ), @@ -107,61 +65,3 @@ class SidebarUser extends StatelessWidget { return name; } } - -class UserSettingButton extends StatelessWidget { - const UserSettingButton({required this.userProfile, super.key}); - - final UserProfilePB userProfile; - - @override - Widget build(BuildContext context) { - return FlowyTooltip( - message: LocaleKeys.settings_menu_open.tr(), - child: IconButton( - onPressed: () => _showSettingsDialog(context, userProfile), - icon: SizedBox.square( - dimension: 20, - child: FlowySvg( - FlowySvgs.settings_m, - color: Theme.of(context).colorScheme.tertiary, - ), - ), - ), - ); - } -} - -void _showSettingsDialog( - BuildContext context, - UserProfilePB userProfile, -) { - showDialog( - context: context, - builder: (dialogContext) { - return BlocProvider.value( - key: _settingsDialogKey, - value: BlocProvider.of(dialogContext), - child: SettingsDialog( - userProfile, - didLogout: () async { - // Pop the dialog using the dialog context - Navigator.of(dialogContext).pop(); - await runAppFlowy(); - }, - dismissDialog: () { - if (Navigator.of(dialogContext).canPop()) { - Navigator.of(dialogContext).pop(); - } else { - Log.warn("Can't pop dialog context"); - } - }, - restartApp: () async { - // Pop the dialog using the dialog context - Navigator.of(dialogContext).pop(); - await runAppFlowy(); - }, - ), - ); - }, - ); -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart new file mode 100644 index 0000000000000..f75dcb91db3ae --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart @@ -0,0 +1,197 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_setting.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarWorkspace extends StatelessWidget { + const SidebarWorkspace({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: _showResultDialog, + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + if (currentWorkspace == null) { + return const SizedBox.shrink(); + } + return Row( + children: [ + Expanded( + child: SidebarSwitchWorkspaceButton( + userProfile: userProfile, + currentWorkspace: currentWorkspace, + ), + ), + UserSettingButton(userProfile: userProfile), + const HSpace(4), + const NotificationButton(), + ], + ); + }, + ); + } + + void _showResultDialog(BuildContext context, UserWorkspaceState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + + result.onFailure((f) { + Log.error( + '[Workspace] Failed to perform ${actionType.toString()} action: $f', + ); + }); + + // show a confirmation dialog if the action is create and the result is LimitExceeded failure + if (actionType == UserWorkspaceActionType.create && + result.isFailure && + result.getFailure().code == ErrorCode.WorkspaceLimitExceeded) { + showDialog( + context: context, + builder: (context) => NavigatorOkCancelDialog( + message: LocaleKeys.workspace_createLimitExceeded.tr(), + ), + ); + return; + } + + final String? message; + switch (actionType) { + case UserWorkspaceActionType.create: + message = result.fold( + (s) => LocaleKeys.workspace_createSuccess.tr(), + (e) => '${LocaleKeys.workspace_createFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.delete: + message = result.fold( + (s) => LocaleKeys.workspace_deleteSuccess.tr(), + (e) => '${LocaleKeys.workspace_deleteFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.open: + message = result.fold( + (s) => LocaleKeys.workspace_openSuccess.tr(), + (e) => '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.updateIcon: + message = result.fold( + (s) => LocaleKeys.workspace_updateIconSuccess.tr(), + (e) => '${LocaleKeys.workspace_updateIconFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.rename: + message = result.fold( + (s) => LocaleKeys.workspace_renameSuccess.tr(), + (e) => '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.none: + case UserWorkspaceActionType.fetchWorkspaces: + message = null; + break; + } + + if (message != null) { + showSnackBarMessage(context, message); + } + } +} + +class SidebarSwitchWorkspaceButton extends StatefulWidget { + const SidebarSwitchWorkspaceButton({ + super.key, + required this.userProfile, + required this.currentWorkspace, + }); + + final UserWorkspacePB currentWorkspace; + final UserProfilePB userProfile; + + @override + State createState() => + _SidebarSwitchWorkspaceButtonState(); +} + +class _SidebarSwitchWorkspaceButtonState + extends State { + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints(maxWidth: 260, maxHeight: 600), + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + final workspaces = state.workspaces; + if (currentWorkspace == null || workspaces.isEmpty) { + return const SizedBox.shrink(); + } + return WorkspacesMenu( + userProfile: widget.userProfile, + currentWorkspace: currentWorkspace, + workspaces: workspaces, + ); + }, + ), + ); + }, + child: FlowyButton( + onTap: () => controller.show(), + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(vertical: 8), + text: Row( + children: [ + const HSpace(2.0), + SizedBox.square( + dimension: 28.0, + child: WorkspaceIcon( + workspace: widget.currentWorkspace, + iconSize: 18, + enableEdit: false, + ), + ), + const HSpace(4), + Expanded( + child: FlowyText.medium( + widget.currentWorkspace.name, + overflow: TextOverflow.ellipsis, + ), + ), + const FlowySvg(FlowySvgs.drop_menu_show_m), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart new file mode 100644 index 0000000000000..7fa07bcfe5597 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart @@ -0,0 +1,108 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum WorkspaceMoreAction { + rename, + delete, +} + +class WorkspaceMoreActionList extends StatelessWidget { + const WorkspaceMoreActionList({ + super.key, + required this.workspace, + }); + + final UserWorkspacePB workspace; + + @override + Widget build(BuildContext context) { + return PopoverActionList<_WorkspaceMoreActionWrapper>( + direction: PopoverDirection.bottomWithCenterAligned, + actions: WorkspaceMoreAction.values + .map((e) => _WorkspaceMoreActionWrapper(e, workspace)) + .toList(), + buildChild: (controller) { + return FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.three_dots_vertical_s, + ), + onTap: () { + controller.show(); + }, + ); + }, + onSelected: (action, controller) {}, + ); + } +} + +class _WorkspaceMoreActionWrapper extends CustomActionCell { + _WorkspaceMoreActionWrapper(this.inner, this.workspace); + + final WorkspaceMoreAction inner; + final UserWorkspacePB workspace; + + @override + Widget buildWithContext(BuildContext context) { + return FlowyButton( + text: FlowyText( + name, + color: inner == WorkspaceMoreAction.delete + ? Theme.of(context).colorScheme.error + : null, + ), + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + onTap: () async { + PopoverContainer.of(context).closeAll(); + + final workspaceBloc = context.read(); + switch (inner) { + case WorkspaceMoreAction.delete: + await NavigatorAlertDialog( + title: LocaleKeys.workspace_deleteWorkspaceHintText.tr(), + confirm: () { + workspaceBloc.add( + UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId), + ); + }, + ).show(context); + case WorkspaceMoreAction.rename: + await NavigatorTextFieldDialog( + title: LocaleKeys.workspace_create.tr(), + value: workspace.name, + hintText: '', + autoSelectAllText: true, + onConfirm: (name, context) async { + workspaceBloc.add( + UserWorkspaceEvent.renameWorkspace( + workspace.workspaceId, + name, + ), + ); + }, + ).show(context); + } + }, + ); + } + + String get name { + switch (inner) { + case WorkspaceMoreAction.delete: + return LocaleKeys.button_delete.tr(); + case WorkspaceMoreAction.rename: + return LocaleKeys.button_rename.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart new file mode 100644 index 0000000000000..ebe53420a5d13 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.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'; + +class WorkspaceIcon extends StatefulWidget { + const WorkspaceIcon({ + super.key, + required this.enableEdit, + required this.iconSize, + required this.workspace, + }); + + final UserWorkspacePB workspace; + final double iconSize; + final bool enableEdit; + + @override + State createState() => _WorkspaceIconState(); +} + +class _WorkspaceIconState extends State { + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + final child = widget.workspace.icon.isNotEmpty + ? FlowyText( + widget.workspace.icon, + textAlign: TextAlign.center, + fontSize: widget.iconSize, + ) + : Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorGenerator.generateColorFromString( + widget.workspace.name, + ), + borderRadius: BorderRadius.circular(4), + ), + margin: const EdgeInsets.all(2), + child: FlowyText( + widget.workspace.name.isEmpty + ? '' + : widget.workspace.name.substring(0, 1), + fontSize: 16, + color: Colors.black, + ), + ); + return AppFlowyPopover( + offset: const Offset(0, 8), + controller: controller, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(360, 380)), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (BuildContext popoverContext) { + return FlowyIconPicker( + onSelected: (result) { + context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + widget.workspace.workspaceId, + result.emoji, + ), + ); + controller.close(); + }, + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart new file mode 100644 index 0000000000000..9387aea155bab --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -0,0 +1,232 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +@visibleForTesting +const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); + +class WorkspacesMenu extends StatelessWidget { + const WorkspacesMenu({ + super.key, + required this.userProfile, + required this.currentWorkspace, + required this.workspaces, + }); + + final UserProfilePB userProfile; + final UserWorkspacePB currentWorkspace; + final List workspaces; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // user email + Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + Expanded( + child: FlowyText.medium( + _getUserInfo(), + fontSize: 12.0, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + const HSpace(4.0), + FlowyButton( + key: createWorkspaceButtonKey, + useIntrinsicWidth: true, + text: const FlowySvg(FlowySvgs.add_m), + onTap: () { + _showCreateWorkspaceDialog(context); + PopoverContainer.of(context).closeAll(); + }, + ), + ], + ), + ), + for (final workspace in workspaces) ...[ + WorkspaceMenuItem( + workspace: workspace, + userProfile: userProfile, + isSelected: workspace.workspaceId == currentWorkspace.workspaceId, + ), + const VSpace(4.0), + ], + ], + ); + } + + String _getUserInfo() { + if (userProfile.email.isNotEmpty) { + return userProfile.email; + } + + if (userProfile.name.isNotEmpty) { + return userProfile.name; + } + + return LocaleKeys.defaultUsername.tr(); + } + + Future _showCreateWorkspaceDialog(BuildContext context) async { + if (context.mounted) { + final workspaceBloc = context.read(); + await CreateWorkspaceDialog( + onConfirm: (name) { + workspaceBloc.add(UserWorkspaceEvent.createWorkspace(name)); + }, + ).show(context); + } + } +} + +class WorkspaceMenuItem extends StatelessWidget { + const WorkspaceMenuItem({ + super.key, + required this.workspace, + required this.userProfile, + required this.isSelected, + }); + + final UserProfilePB userProfile; + final UserWorkspacePB workspace; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + WorkspaceMemberBloc(userProfile: userProfile, workspace: workspace) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final members = state.members; + // settings right icon inside the flowy button will + // cause the popover dismiss intermediately when click the right icon. + // so using the stack to put the right icon on the flowy button. + return SizedBox( + height: 52, + child: Stack( + alignment: Alignment.center, + children: [ + FlowyButton( + onTap: () { + if (!isSelected) { + context.read().add( + UserWorkspaceEvent.openWorkspace( + workspace.workspaceId, + ), + ); + PopoverContainer.of(context).closeAll(); + } + }, + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + iconPadding: 10.0, + leftIconSize: const Size.square(32), + leftIcon: const SizedBox.square( + dimension: 32, + ), + rightIcon: const HSpace(42.0), + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + // mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText.medium( + workspace.name, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + FlowyText( + state.isLoading + ? '' + : LocaleKeys + .settings_appearance_members_membersCount + .plural( + members.length, + ), + fontSize: 10.0, + color: Theme.of(context).hintColor, + ), + ], + ), + ), + Positioned( + left: 8, + child: SizedBox.square( + dimension: 32, + child: WorkspaceIcon( + workspace: workspace, + iconSize: 26, + enableEdit: true, + ), + ), + ), + Positioned( + right: 12.0, + child: Align( + child: _buildRightIcon(context), + ), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildRightIcon(BuildContext context) { + // only the owner can update or delete workspace. + // only show the more action button when the workspace is selected. + if (!isSelected || context.read().state.isLoading) { + return const SizedBox.shrink(); + } + + return Row( + children: [ + if (context.read().state.myRole.isOwner) + WorkspaceMoreActionList(workspace: workspace), + const FlowySvg( + FlowySvgs.blue_check_s, + ), + ], + ); + } +} + +class CreateWorkspaceDialog extends StatelessWidget { + const CreateWorkspaceDialog({ + super.key, + required this.onConfirm, + }); + + final void Function(String name) onConfirm; + + @override + Widget build(BuildContext context) { + return NavigatorTextFieldDialog( + title: LocaleKeys.workspace_create.tr(), + value: '', + hintText: '', + autoSelectAllText: true, + onConfirm: (name, _) => onConfirm(name), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart index 2fcf4ce098789..910286f4b7380 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart'; @@ -188,6 +189,9 @@ class _DraggableViewItemState extends State { return; } + final fromSection = getViewSection(from); + final toSection = getViewSection(to); + switch (position) { case DraggableHoverPosition.top: context.read().add( @@ -195,6 +199,8 @@ class _DraggableViewItemState extends State { from, to.parentViewId, null, + fromSection, + toSection, ), ); break; @@ -204,6 +210,8 @@ class _DraggableViewItemState extends State { from, to.parentViewId, to.id, + fromSection, + toSection, ), ); break; @@ -213,6 +221,8 @@ class _DraggableViewItemState extends State { from, to.id, to.childViews.lastOrNull?.id, + fromSection, + toSection, ), ); break; @@ -251,6 +261,10 @@ class _DraggableViewItemState extends State { return true; } + + ViewSectionPB? getViewSection(ViewPB view) { + return context.read().getViewSection(view); + } } extension on ViewPB { 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 5ce8d542888fe..19876b8eab972 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,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; @@ -25,6 +23,7 @@ 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'; typedef ViewItemOnSelected = void Function(ViewPB); @@ -43,6 +42,7 @@ class ViewItem extends StatelessWidget { this.isDraggable = true, required this.isFeedback, this.height = 28.0, + this.isHoverEnabled = true, }); final ViewPB view; @@ -76,6 +76,8 @@ class ViewItem extends StatelessWidget { final double height; + final bool isHoverEnabled; + @override Widget build(BuildContext context) { return BlocProvider( @@ -102,6 +104,7 @@ class ViewItem extends StatelessWidget { isDraggable: isDraggable, isFeedback: isFeedback, height: height, + isHoverEnabled: isHoverEnabled, ); }, ), @@ -128,6 +131,7 @@ class InnerViewItem extends StatelessWidget { this.isFirstChild = false, required this.isFeedback, required this.height, + this.isHoverEnabled = true, }); final ViewPB view; @@ -149,6 +153,8 @@ class InnerViewItem extends StatelessWidget { final ViewItemOnSelected? onTertiarySelected; final double height; + final bool isHoverEnabled; + @override Widget build(BuildContext context) { Widget child = SingleInnerViewItem( @@ -265,6 +271,7 @@ class SingleInnerViewItem extends StatefulWidget { this.onTertiarySelected, required this.isFeedback, required this.height, + this.isHoverEnabled = true, }); final ViewPB view; @@ -283,6 +290,8 @@ class SingleInnerViewItem extends StatefulWidget { final FolderCategoryType categoryType; final double height; + final bool isHoverEnabled; + @override State createState() => _SingleInnerViewItemState(); } @@ -293,13 +302,16 @@ class _SingleInnerViewItemState extends State { @override Widget build(BuildContext context) { - if (widget.isFeedback) { - return _buildViewItem(false); - } - final isSelected = getIt().latestOpenView?.id == widget.view.id; + if (widget.isFeedback || !widget.isHoverEnabled) { + return _buildViewItem( + false, + !widget.isHoverEnabled ? isSelected : false, + ); + } + return FlowyHover( style: HoverStyle( hoverColor: Theme.of(context).colorScheme.secondary, @@ -456,13 +468,14 @@ class _SingleInnerViewItemState extends State { createViewAndShowRenameDialogIfNeeded( context, _convertLayoutToHintText(pluginBuilder.layoutType!), - (viewName) { + (viewName, _) { if (viewName.isNotEmpty) { viewBloc.add( ViewEvent.createView( viewName, pluginBuilder.layoutType!, openAfterCreated: openAfterCreated, + section: widget.categoryType.toViewSectionPB, ), ); } @@ -499,7 +512,7 @@ class _SingleInnerViewItemState extends State { autoSelectAllText: true, value: widget.view.name, maxLength: 256, - confirm: (newValue) { + onConfirm: (newValue, _) { context.read().add(ViewEvent.rename(newValue)); }, ).show(context); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart index 57c3590a25465..abccd04056bf0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart @@ -69,6 +69,7 @@ void showSnackBarMessage( content: FlowyText( message, color: Colors.white, + maxLines: 2, ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart index 45ac056a4ffaa..a7925dc3f7984 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart @@ -2,8 +2,8 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/notifications/notification_dialog.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -13,12 +13,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class NotificationButton extends StatelessWidget { - const NotificationButton({super.key, required this.views}); - - final List views; + const NotificationButton({ + super.key, + }); @override Widget build(BuildContext context) { + final views = context.watch().state.section.views; final mutex = PopoverMutex(); return BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart index 0f02100801d72..facd3a2f0be45 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; @@ -12,7 +10,8 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/material.dart'; /// Displays a Lsit of Notifications, currently used primarily to /// display Reminders. @@ -103,12 +102,12 @@ class NotificationsView extends StatelessWidget { } Future _getNodeFromDocument( - Future> documentFuture, + Future> documentFuture, String blockId, ) async { final document = (await documentFuture).fold( - (l) => null, (document) => document, + (_) => null, ); if (document == null) { 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 c57378ce718c6..195037dd4c08c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -1,18 +1,21 @@ -import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; -import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + import 'widgets/setting_cloud.dart'; const _dialogHorizontalPadding = EdgeInsets.symmetric(horizontal: 12); @@ -47,6 +50,7 @@ class SettingsDialog extends StatelessWidget { color: Theme.of(context).colorScheme.tertiary, ), ), + width: MediaQuery.of(context).size.width * 0.7, child: ScaffoldMessenger( child: Scaffold( backgroundColor: Colors.transparent, @@ -110,6 +114,10 @@ class SettingsDialog extends StatelessWidget { ); case SettingsPage.shortcuts: return const SettingsCustomizeShortcutsWrapper(); + case SettingsPage.member: + return WorkspaceMembersPage(userProfile: user); + case SettingsPage.featureFlags: + return const FeatureFlagsPage(); default: return const SizedBox.shrink(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart new file mode 100644 index 0000000000000..772857433ea03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart @@ -0,0 +1,72 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class FeatureFlagsPage extends StatelessWidget { + const FeatureFlagsPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: SeparatedColumn( + children: [ + ...FeatureFlag.data.entries + .where((e) => e.key != FeatureFlag.unknown) + .map( + (e) => _FeatureFlagItem(featureFlag: e.key), + ), + FlowyTextButton( + 'Restart the app to apply changes', + fontSize: 16.0, + fontColor: Colors.red, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + onPressed: () async { + await runAppFlowy(); + }, + ), + ], + ), + ); + } +} + +class _FeatureFlagItem extends StatefulWidget { + const _FeatureFlagItem({ + required this.featureFlag, + }); + + final FeatureFlag featureFlag; + + @override + State<_FeatureFlagItem> createState() => _FeatureFlagItemState(); +} + +class _FeatureFlagItemState extends State<_FeatureFlagItem> { + @override + Widget build(BuildContext context) { + return ListTile( + title: FlowyText( + widget.featureFlag.name, + fontSize: 16.0, + ), + subtitle: FlowyText.small( + widget.featureFlag.description, + maxLines: 3, + ), + trailing: Switch( + value: widget.featureFlag.isOn, + onChanged: (value) { + setState(() { + widget.featureFlag.update(value); + }); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_file_import_appflowy_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart similarity index 86% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_file_import_appflowy_data_view.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart index a54e461db0d6b..20498752b9abd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_file_import_appflowy_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart @@ -1,16 +1,16 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/setting_file_importer_bloc.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_backend/log.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:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; -import 'package:url_launcher/url_launcher.dart'; class ImportAppFlowyData extends StatefulWidget { const ImportAppFlowyData({super.key}); @@ -33,17 +33,12 @@ class _ImportAppFlowyDataState extends State { create: (context) => SettingFileImportBloc(), child: BlocListener( listener: (context, state) { - state.successOrFail.fold( - () {}, - (either) { - either.fold( - (unit) { - _showToast(LocaleKeys.settings_menu_importSuccess.tr()); - }, - (err) { - _showToast(LocaleKeys.settings_menu_importFailed.tr()); - }, - ); + state.successOrFail?.fold( + (_) { + _showToast(LocaleKeys.settings_menu_importSuccess.tr()); + }, + (_) { + _showToast(LocaleKeys.settings_menu_importFailed.tr()); }, ); }, @@ -97,22 +92,14 @@ class AppFlowyDataImportTip extends StatelessWidget { color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), - recognizer: TapGestureRecognizer()..onTap = () => _launchURL(), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString(url), ), ], ), ), ); } - - Future _launchURL() async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - Log.error("Could not launch $url"); - } - } } class ImportAppFlowyDataButton extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart similarity index 95% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart index d97b8a5631f9f..1c6441e90bcf0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_export_file_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart @@ -1,11 +1,11 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart'; -import 'package:flutter/material.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; -import '../../../../generated/locale_keys.g.dart'; +import '../../../../../generated/locale_keys.g.dart'; class SettingsExportFileWidget extends StatefulWidget { const SettingsExportFileWidget({ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart new file mode 100644 index 0000000000000..011b7ece9f887 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SettingsFileCacheWidget extends StatelessWidget { + const SettingsFileCacheWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: FlowyText.medium( + LocaleKeys.settings_files_clearCache.tr(), + fontSize: 13, + overflow: TextOverflow.ellipsis, + ), + ), + const VSpace(8), + Opacity( + opacity: 0.6, + child: FlowyText( + LocaleKeys.settings_files_clearCacheDesc.tr(), + fontSize: 10, + maxLines: 3, + ), + ), + ], + ), + ), + const _ClearCacheButton(), + ], + ); + } +} + +class _ClearCacheButton extends StatelessWidget { + const _ClearCacheButton(); + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + tooltipText: LocaleKeys.settings_files_clearCache.tr(), + icon: FlowySvg( + FlowySvgs.delete_s, + size: const Size.square(18), + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + NavigatorAlertDialog( + title: LocaleKeys.settings_files_areYouSureToClearCache.tr(), + confirm: () async { + await getIt().clearAllCache(); + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.settings_files_clearCacheSuccess.tr(), + ); + } + }, + ).show(context); + }, + ); + } +} 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/files/settings_file_customize_location_view.dart similarity index 96% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_customize_location_view.dart index 0116690dbbd1e..5fdc8ff7cc99e 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/files/settings_file_customize_location_view.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -12,11 +13,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:styled_widget/styled_widget.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../../../../generated/locale_keys.g.dart'; -import '../../../../startup/startup.dart'; -import '../../../../startup/tasks/prelude.dart'; +import '../../../../../generated/locale_keys.g.dart'; +import '../../../../../startup/startup.dart'; +import '../../../../../startup/tasks/prelude.dart'; class SettingsFileLocationCustomizer extends StatefulWidget { const SettingsFileLocationCustomizer({ @@ -234,9 +234,7 @@ class _OpenStorageButton extends StatelessWidget { ), onPressed: () async { final uri = Directory(usingPath).uri; - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } + await afLaunchUrl(uri, context: context); }, ); } @@ -263,7 +261,7 @@ class _RecoverDefaultStorageButtonState tooltipText: LocaleKeys.settings_files_recoverLocationTooltips.tr(), icon: const FlowySvg( FlowySvgs.restore_s, - size: Size.square(24), + size: Size.square(20), ), onPressed: () async { // reset to the default directory and reload app 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/files/settings_file_exporter_widget.dart similarity index 94% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart index 6055f52182498..ea1cd98933b8c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart @@ -10,7 +10,7 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:dartz/dartz.dart' as dartz; +import 'package:appflowy_result/appflowy_result.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' hide WidgetBuilder; @@ -18,7 +18,7 @@ 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'; +import '../../../../../generated/locale_keys.g.dart'; class FileExporterWidget extends StatefulWidget { const FileExporterWidget({super.key}); @@ -34,12 +34,12 @@ class _FileExporterWidgetState extends State { @override Widget build(BuildContext context) { - return FutureBuilder>( + return FutureBuilder>( future: FolderEventReadCurrentWorkspace().send(), builder: (context, snapshot) { if (snapshot.hasData && snapshot.connectionState == ConnectionState.done) { - final workspace = snapshot.data?.getLeftOrNull(); + final workspace = snapshot.data?.fold((s) => s, (e) => null); if (workspace != null) { final views = workspace.views; cubit ??= SettingsFileExporterCubit(views: views); @@ -224,17 +224,6 @@ class _ExpandedListState extends State<_ExpandedList> { } } -extension AppFlowy on dartz.Either { - T? getLeftOrNull() { - if (isLeft()) { - final result = fold((l) => l, (r) => null); - return result; - } - - return null; - } -} - class _AppFlowyFileExporter { static Future<(bool result, List failedNames)> exportToPath( String path, @@ -251,9 +240,12 @@ class _AppFlowyFileExporter { final result = await documentExporter.export( DocumentExportType.json, ); - result.fold((l) => Log.error(l), (json) { - content = json; - }); + result.fold( + (json) { + content = json; + }, + (e) => Log.error(e), + ); fileExtension = 'afdocument'; break; default: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart new file mode 100644 index 0000000000000..6b148b8f2b046 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart @@ -0,0 +1,240 @@ +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'workspace_member_bloc.freezed.dart'; + +// 1. get the workspace members +// 2. display the content based on the user role +// Owner: +// - invite member button +// - delete member button +// - member list +// Member: +// Guest: +// - member list +class WorkspaceMemberBloc + extends Bloc { + WorkspaceMemberBloc({ + required this.userProfile, + this.workspace, + }) : _userBackendService = UserBackendService(userId: userProfile.id), + super(WorkspaceMemberState.initial()) { + on((event, emit) async { + await event.when( + initial: () async { + await _setCurrentWorkspaceId(); + + final result = await _userBackendService.getWorkspaceMembers( + _workspaceId, + ); + final members = result.fold>( + (s) => s.items, + (e) => [], + ); + final myRole = _getMyRole(members); + emit( + state.copyWith( + members: members, + myRole: myRole, + isLoading: false, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.get, + result: result, + ), + ), + ); + }, + getWorkspaceMembers: () async { + final result = await _userBackendService.getWorkspaceMembers( + _workspaceId, + ); + final members = result.fold>( + (s) => s.items, + (e) => [], + ); + final myRole = _getMyRole(members); + emit( + state.copyWith( + members: members, + myRole: myRole, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.get, + result: result, + ), + ), + ); + }, + addWorkspaceMember: (email) async { + final result = await _userBackendService.addWorkspaceMember( + _workspaceId, + email, + ); + emit( + state.copyWith( + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.add, + result: result, + ), + ), + ); + // the addWorkspaceMember doesn't return the updated members, + // so we need to get the members again + result.onSuccess((s) { + add(const WorkspaceMemberEvent.getWorkspaceMembers()); + }); + }, + removeWorkspaceMember: (email) async { + final result = await _userBackendService.removeWorkspaceMember( + _workspaceId, + email, + ); + final members = result.fold( + (s) => state.members.where((e) => e.email != email).toList(), + (e) => state.members, + ); + emit( + state.copyWith( + members: members, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.remove, + result: result, + ), + ), + ); + }, + updateWorkspaceMember: (email, role) async { + final result = await _userBackendService.updateWorkspaceMember( + _workspaceId, + email, + role, + ); + final members = result.fold( + (s) => state.members.map((e) { + if (e.email == email) { + e.freeze(); + return e.rebuild((p0) { + p0.role = role; + }); + } + return e; + }).toList(), + (e) => state.members, + ); + emit( + state.copyWith( + members: members, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.updateRole, + result: result, + ), + ), + ); + }, + ); + }); + } + + final UserProfilePB userProfile; + + // if the workspace is null, use the current workspace + final UserWorkspacePB? workspace; + + late final String _workspaceId; + final UserBackendService _userBackendService; + + AFRolePB _getMyRole(List members) { + final role = members + .firstWhereOrNull( + (e) => e.email == userProfile.email, + ) + ?.role; + if (role == null) { + Log.error('Failed to get my role'); + return AFRolePB.Guest; + } + return role; + } + + Future _setCurrentWorkspaceId() async { + if (workspace != null) { + _workspaceId = workspace!.workspaceId; + } else { + final currentWorkspace = await FolderEventReadCurrentWorkspace().send(); + currentWorkspace.fold((s) { + _workspaceId = s.id; + }, (e) { + assert(false, 'Failed to read current workspace: $e'); + Log.error('Failed to read current workspace: $e'); + _workspaceId = ''; + }); + } + } +} + +@freezed +class WorkspaceMemberEvent with _$WorkspaceMemberEvent { + const factory WorkspaceMemberEvent.initial() = Initial; + const factory WorkspaceMemberEvent.getWorkspaceMembers() = + GetWorkspaceMembers; + const factory WorkspaceMemberEvent.addWorkspaceMember(String email) = + AddWorkspaceMember; + const factory WorkspaceMemberEvent.removeWorkspaceMember(String email) = + RemoveWorkspaceMember; + const factory WorkspaceMemberEvent.updateWorkspaceMember( + String email, + AFRolePB role, + ) = UpdateWorkspaceMember; +} + +enum WorkspaceMemberActionType { + none, + get, + add, + remove, + updateRole, +} + +class WorkspaceMemberActionResult { + const WorkspaceMemberActionResult({ + required this.actionType, + required this.result, + }); + + final WorkspaceMemberActionType actionType; + final FlowyResult result; +} + +@freezed +class WorkspaceMemberState with _$WorkspaceMemberState { + const WorkspaceMemberState._(); + + const factory WorkspaceMemberState({ + @Default([]) List members, + @Default(AFRolePB.Guest) AFRolePB myRole, + @Default(null) WorkspaceMemberActionResult? actionResult, + @Default(true) bool isLoading, + }) = _WorkspaceMemberState; + + factory WorkspaceMemberState.initial() => const WorkspaceMemberState(); + + @override + int get hashCode => runtimeType.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is WorkspaceMemberState && + other.members == members && + other.myRole == myRole && + identical(other.actionResult, actionResult); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart new file mode 100644 index 0000000000000..9779ed763253a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart @@ -0,0 +1,504 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.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:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +class WorkspaceMembersPage extends StatelessWidget { + const WorkspaceMembersPage({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocConsumer( + listener: _showResultDialog, + builder: (context, state) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // title + FlowyText.semibold( + LocaleKeys.settings_appearance_members_title.tr(), + fontSize: 20, + ), + if (state.myRole.canInvite) const _InviteMember(), + if (state.members.isNotEmpty) + _MemberList( + members: state.members, + userProfile: userProfile, + myRole: state.myRole, + ), + const VSpace(48.0), + ], + ), + ); + }, + ), + ); + } + + void _showResultDialog(BuildContext context, WorkspaceMemberState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + + // only show the result dialog when the action is WorkspaceMemberActionType.add + if (actionType == WorkspaceMemberActionType.add) { + result.fold( + (s) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), + ); + }, + (f) { + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() + : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); + showDialog( + context: context, + builder: (context) => NavigatorOkCancelDialog(message: message), + ); + }, + ); + } + + result.onFailure((f) { + Log.error( + '[Member] Failed to perform ${actionType.toString()} action: $f', + ); + }); + } +} + +class _InviteMember extends StatefulWidget { + const _InviteMember(); + + @override + State<_InviteMember> createState() => _InviteMemberState(); +} + +class _InviteMemberState extends State<_InviteMember> { + final _emailController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(12.0), + FlowyText.semibold( + LocaleKeys.settings_appearance_members_inviteMembers.tr(), + fontSize: 16.0, + ), + const VSpace(8.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor( + height: 48.0, + ), + child: FlowyTextField( + controller: _emailController, + onEditingComplete: _inviteMember, + ), + ), + ), + const HSpace(10.0), + SizedBox( + height: 48.0, + child: IntrinsicWidth( + child: RoundedTextButton( + title: LocaleKeys.settings_appearance_members_sendInvite.tr(), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + onPressed: _inviteMember, + ), + ), + ), + ], + ), + const VSpace(16.0), + /* Enable this when the feature is ready + PrimaryButton( + backgroundColor: const Color(0xFFE0E0E0), + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 24, + top: 8, + bottom: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.invite_member_link_m, + color: Colors.black, + ), + const HSpace(8.0), + FlowyText( + LocaleKeys.settings_appearance_members_copyInviteLink.tr(), + color: Colors.black, + ), + ], + ), + ), + onPressed: () { + showSnackBarMessage(context, 'not implemented'); + }, + ), + const VSpace(16.0), + */ + const Divider( + height: 1.0, + thickness: 1.0, + ), + ], + ); + } + + void _inviteMember() { + final email = _emailController.text; + if (!isEmail(email)) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_emailInvalidError.tr(), + ); + return; + } + context + .read() + .add(WorkspaceMemberEvent.addWorkspaceMember(email)); + } +} + +class _MemberList extends StatelessWidget { + const _MemberList({ + required this.members, + required this.myRole, + required this.userProfile, + }); + + final List members; + final AFRolePB myRole; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const VSpace(16.0), + SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const Divider(), + children: [ + const _MemberListHeader(), + ...members.map( + (member) => _MemberItem( + member: member, + myRole: myRole, + userProfile: userProfile, + ), + ), + ], + ), + ], + ); + } +} + +class _MemberListHeader extends StatelessWidget { + const _MemberListHeader(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + LocaleKeys.settings_appearance_members_label.tr(), + fontSize: 16.0, + ), + const VSpace(16.0), + Row( + children: [ + Expanded( + child: FlowyText.semibold( + LocaleKeys.settings_appearance_members_user.tr(), + fontSize: 14.0, + ), + ), + Expanded( + child: FlowyText.semibold( + LocaleKeys.settings_appearance_members_role.tr(), + fontSize: 14.0, + ), + ), + const HSpace(28.0), + ], + ), + ], + ); + } +} + +class _MemberItem extends StatelessWidget { + const _MemberItem({ + required this.member, + required this.myRole, + required this.userProfile, + }); + + final WorkspaceMemberPB member; + final AFRolePB myRole; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final textColor = member.role.isOwner ? Theme.of(context).hintColor : null; + return Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 14.0, + ), + ), + Expanded( + child: member.role.isOwner || !myRole.canUpdate + ? FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 14.0, + ) + : _MemberRoleActionList( + member: member, + ), + ), + myRole.canDelete && + member.email != userProfile.email // can't delete self + ? _MemberMoreActionList(member: member) + : const HSpace(28.0), + ], + ); + } +} + +enum _MemberMoreAction { + delete, +} + +class _MemberMoreActionList extends StatelessWidget { + const _MemberMoreActionList({ + required this.member, + }); + + final WorkspaceMemberPB member; + + @override + Widget build(BuildContext context) { + return PopoverActionList<_MemberMoreActionWrapper>( + asBarrier: true, + direction: PopoverDirection.bottomWithCenterAligned, + actions: _MemberMoreAction.values + .map((e) => _MemberMoreActionWrapper(e, member)) + .toList(), + buildChild: (controller) { + return FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.three_dots_vertical_s, + ), + onTap: () { + controller.show(); + }, + ); + }, + onSelected: (action, controller) { + switch (action.inner) { + case _MemberMoreAction.delete: + showDialog( + context: context, + builder: (_) => NavigatorOkCancelDialog( + title: LocaleKeys.settings_appearance_members_removeMember.tr(), + message: LocaleKeys + .settings_appearance_members_areYouSureToRemoveMember + .tr(), + onOkPressed: () => context.read().add( + WorkspaceMemberEvent.removeWorkspaceMember( + action.member.email, + ), + ), + okTitle: LocaleKeys.button_yes.tr(), + ), + ); + break; + } + controller.close(); + }, + ); + } +} + +class _MemberMoreActionWrapper extends ActionCell { + _MemberMoreActionWrapper(this.inner, this.member); + + final _MemberMoreAction inner; + final WorkspaceMemberPB member; + + @override + String get name { + switch (inner) { + case _MemberMoreAction.delete: + return LocaleKeys.settings_appearance_members_removeFromWorkspace.tr(); + } + } +} + +class _MemberRoleActionList extends StatelessWidget { + const _MemberRoleActionList({ + required this.member, + }); + + final WorkspaceMemberPB member; + + @override + Widget build(BuildContext context) { + return PopoverActionList<_MemberRoleActionWrapper>( + asBarrier: true, + direction: PopoverDirection.bottomWithLeftAligned, + actions: [AFRolePB.Member] + .map((e) => _MemberRoleActionWrapper(e, member)) + .toList(), + offset: const Offset(0, 10), + buildChild: (controller) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => controller.show(), + child: Row( + children: [ + FlowyText.medium( + member.role.description, + fontSize: 14.0, + ), + const HSpace(8.0), + const FlowySvg( + FlowySvgs.drop_menu_show_s, + ), + ], + ), + ), + ); + }, + onSelected: (action, controller) async { + switch (action.inner) { + case AFRolePB.Member: + case AFRolePB.Guest: + context.read().add( + WorkspaceMemberEvent.updateWorkspaceMember( + action.member.email, + action.inner, + ), + ); + break; + case AFRolePB.Owner: + break; + } + controller.close(); + }, + ); + } +} + +class _MemberRoleActionWrapper extends ActionCell { + _MemberRoleActionWrapper(this.inner, this.member); + + final AFRolePB inner; + final WorkspaceMemberPB member; + + @override + Widget? rightIcon(Color iconColor) { + return SizedBox( + width: 58.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyTooltip( + message: tooltip, + child: const FlowySvg( + FlowySvgs.information_s, + // color: iconColor, + ), + ), + const Spacer(), + if (member.role == inner) + const FlowySvg( + FlowySvgs.checkmark_tiny_s, + ), + ], + ), + ); + } + + @override + String get name { + switch (inner) { + case AFRolePB.Guest: + return LocaleKeys.settings_appearance_members_guest.tr(); + case AFRolePB.Member: + return LocaleKeys.settings_appearance_members_member.tr(); + case AFRolePB.Owner: + return LocaleKeys.settings_appearance_members_owner.tr(); + } + throw UnimplementedError('Unknown role: $inner'); + } + + String get tooltip { + switch (inner) { + case AFRolePB.Guest: + return LocaleKeys.settings_appearance_members_guestHintText.tr(); + case AFRolePB.Member: + return LocaleKeys.settings_appearance_members_memberHintText.tr(); + case AFRolePB.Owner: + return ''; + } + throw UnimplementedError('Unknown role: $inner'); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index f84fe9f374bf4..6805633f74e0f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -1,3 +1,7 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -6,18 +10,14 @@ import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; -import 'package:dartz/dartz.dart' show Either; +import 'package:appflowy_result/appflowy_result.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:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:url_launcher/url_launcher.dart'; class AppFlowyCloudViewSetting extends StatelessWidget { const AppFlowyCloudViewSetting({ @@ -33,7 +33,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder>( + return FutureBuilder>( future: UserEventGetCloudConfig().send(), builder: (context, snapshot) { if (snapshot.data != null && @@ -94,7 +94,7 @@ class CustomAppFlowyCloudView extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder>( + return FutureBuilder>( future: UserEventGetCloudConfig().send(), builder: (context, snapshot) { if (snapshot.data != null && @@ -227,7 +227,8 @@ class AppFlowySelfhostTip extends StatelessWidget { color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), - recognizer: TapGestureRecognizer()..onTap = () => _launchURL(), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString(url), ), TextSpan( text: LocaleKeys.settings_menu_selfHostEnd.tr(), @@ -238,15 +239,6 @@ class AppFlowySelfhostTip extends StatelessWidget { ), ); } - - Future _launchURL() async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - Log.error("Could not launch $url"); - } - } } @visibleForTesting @@ -303,11 +295,7 @@ class CloudURLInputState extends State { borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), ), hintText: widget.hint, - errorText: context - .read() - .state - .urlError - .fold(() => null, (error) => error), + errorText: context.read().state.urlError, ), onChanged: widget.onChanged, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart index 78f21c4485920..3e3e376df4f8f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart @@ -217,7 +217,7 @@ class CloudTypeItem extends StatelessWidget { confirm: () async { onSelected(cloudType); }, - hideCancleButton: true, + hideCancelButton: true, ).show(context); } PopoverContainer.of(context).close(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart index 43e5472ef8b7a..c014cdf516ea4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart @@ -1,3 +1,8 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/supabase_cloud_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/supabase_cloud_urls_bloc.dart'; @@ -5,20 +10,15 @@ import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; -import 'package:dartz/dartz.dart' show Either; +import 'package:appflowy_result/appflowy_result.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:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:url_launcher/url_launcher.dart'; class SettingSupabaseCloudView extends StatelessWidget { const SettingSupabaseCloudView({required this.restartAppFlowy, super.key}); @@ -27,7 +27,7 @@ class SettingSupabaseCloudView extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder>( + return FutureBuilder>( future: UserEventGetCloudConfig().send(), builder: (context, snapshot) { if (snapshot.data != null && @@ -102,7 +102,7 @@ class SupabaseCloudURLs extends StatelessWidget { .read() .add(SupabaseCloudURLsEvent.updateUrl(text)); }, - error: state.urlError.fold(() => null, (a) => a), + error: state.urlError, ), SupabaseInput( title: LocaleKeys.settings_menu_cloudSupabaseAnonKey.tr(), @@ -113,7 +113,7 @@ class SupabaseCloudURLs extends StatelessWidget { .read() .add(SupabaseCloudURLsEvent.updateAnonKey(text)); }, - error: state.anonKeyError.fold(() => null, (a) => a), + error: state.anonKeyError, ), const VSpace(20), RestartButton( @@ -330,7 +330,8 @@ class SupabaseSelfhostTip extends StatelessWidget { color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), - recognizer: TapGestureRecognizer()..onTap = () => _launchURL(), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString(url), ), TextSpan( text: LocaleKeys.settings_menu_selfHostEnd.tr(), @@ -341,13 +342,4 @@ class SupabaseSelfhostTip extends StatelessWidget { ), ); } - - Future _launchURL() async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - Log.error("Could not launch $url"); - } - } } 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 73bf46f285663..e9ccc32b0e054 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 @@ -6,7 +6,7 @@ import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:dartz/dartz.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; @@ -24,10 +24,10 @@ class SettingThirdPartyLogin extends StatelessWidget { create: (context) => getIt(), child: BlocConsumer( listener: (context, state) { - state.successOrFail.fold( - () => null, - (result) => _handleSuccessOrFail(result, context), - ); + final successOrFail = state.successOrFail; + if (successOrFail != null) { + _handleSuccessOrFail(successOrFail, context); + } }, builder: (_, state) { final indicator = state.isSubmitting @@ -68,7 +68,7 @@ class SettingThirdPartyLogin extends StatelessWidget { } Future _handleSuccessOrFail( - Either result, + FlowyResult result, BuildContext context, ) async { result.fold( 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 d65feb24eda0d..1f21cfa4bb317 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 @@ -1,6 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -25,7 +25,6 @@ class LayoutDirectionSetting extends StatelessWidget { hint: LocaleKeys.settings_appearance_layoutDirection_hint.tr(), trailing: [ FlowySettingValueDropDown( - key: const ValueKey('layout_direction_option_button'), currentValue: _layoutDirectionLabelText(currentLayoutDirection), popupBuilder: (context) => Column( mainAxisSize: MainAxisSize.min, @@ -141,3 +140,34 @@ class TextDirectionSetting extends StatelessWidget { } } } + +class EnableRTLToolbarItemsSetting extends StatelessWidget { + const EnableRTLToolbarItemsSetting({ + super.key, + }); + + static const enableRTLSwitchKey = ValueKey('enable_rtl_toolbar_items_switch'); + + @override + Widget build(BuildContext context) { + return FlowySettingListTile( + label: LocaleKeys.settings_appearance_enableRTLToolbarItems.tr(), + trailing: [ + Switch( + key: enableRTLSwitchKey, + value: context + .read() + .state + .enableRtlToolbarItems, + splashRadius: 0, + activeColor: Theme.of(context).colorScheme.primary, + onChanged: (value) { + context + .read() + .setEnableRTLToolbarItems(value); + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart index 11276a95f405f..c0eb42bf4a5dd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart @@ -1,12 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.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 DocumentCursorColorSetting extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart index 2a10c70521390..89e3e18a636e1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart @@ -1,12 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.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 DocumentSelectionColorSetting extends StatelessWidget { 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 ce0c9ac07959d..7a25e2f032f2e 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 @@ -1,6 +1,8 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/util/google_font_family_extension.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; @@ -8,7 +10,6 @@ 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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -94,7 +95,7 @@ class _FontFamilyDropDownState extends State { return FlowySettingValueDropDown( popoverKey: ThemeFontFamilySetting.popoverKey, popoverController: widget.popoverController, - currentValue: parseFontFamilyName(widget.currentFontFamily), + currentValue: widget.currentFontFamily.parseFontFamilyName(), onClose: () { query.value = ''; widget.onClose?.call(); @@ -161,18 +162,11 @@ class _FontFamilyDropDownState extends State { ); } - String parseFontFamilyName(String fontFamilyName) { - final camelCase = RegExp('(?<=[a-z])[A-Z]'); - return fontFamilyName - .replaceAll('_regular', '') - .replaceAllMapped(camelCase, (m) => ' ${m.group(0)}'); - } - Widget _fontFamilyItemButton( BuildContext context, TextStyle style, ) { - final buttonFontFamily = parseFontFamilyName(style.fontFamily!); + final buttonFontFamily = style.fontFamily!.parseFontFamilyName(); return Tooltip( message: buttonFontFamily, @@ -183,21 +177,19 @@ class _FontFamilyDropDownState extends State { child: FlowyButton( onHover: (_) => FocusScope.of(context).unfocus(), text: FlowyText.medium( - parseFontFamilyName(style.fontFamily!), + buttonFontFamily, fontFamily: style.fontFamily!, ), rightIcon: - buttonFontFamily == parseFontFamilyName(widget.currentFontFamily) - ? const FlowySvg( - FlowySvgs.check_s, - ) + buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() + ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () { if (widget.onFontFamilyChanged != null) { widget.onFontFamilyChanged!(style.fontFamily!); } else { final fontFamily = style.fontFamily!.parseFontFamilyName(); - if (parseFontFamilyName(widget.currentFontFamily) != + if (widget.currentFontFamily.parseFontFamilyName() != buttonFontFamily) { context .read() diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart index ccb6281210940..73ed265c1debd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart @@ -53,6 +53,7 @@ class SettingsAppearanceView extends StatelessWidget { TextDirectionSetting( currentTextDirection: state.textDirection, ), + const EnableRTLToolbarItemsSetting(), const Divider(), DateFormatSetting( currentFormat: state.dateFormat, 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 31aa7171bd9fa..1def9a7b2b0ac 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 @@ -1,6 +1,8 @@ -import 'package:appflowy/workspace/presentation/settings/widgets/setting_file_import_appflowy_data_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_export_file_widget.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_customize_location_view.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -19,15 +21,15 @@ class _SettingsFileSystemViewState extends State { // disable export data for v0.2.0 in release mode. if (kDebugMode) const SettingsExportFileWidget(), const ImportAppFlowyData(), + // clear the cache + const SettingsFileCacheWidget(), ]; @override Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - itemBuilder: (context, index) => _items[index], - separatorBuilder: (context, index) => const Divider(), - itemCount: _items.length, + return SeparatedColumn( + separatorBuilder: () => const Divider(), + children: _items, ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index a5f3e889cce19..bf3c2c54514d8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -1,7 +1,9 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SettingsMenu extends StatelessWidget { @@ -16,64 +18,79 @@ class SettingsMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - SettingsMenuElement( - page: SettingsPage.appearance, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_appearance.tr(), - icon: Icons.brightness_4, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.language, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_language.tr(), - icon: Icons.translate, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.files, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_files.tr(), - icon: Icons.file_present_outlined, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.user, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_user.tr(), - icon: Icons.account_box_outlined, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.notifications, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_notifications.tr(), - icon: Icons.notifications_outlined, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.cloud, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_cloudSettings.tr(), - icon: Icons.sync, - changeSelectedPage: changeSelectedPage, - ), - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.shortcuts, - selectedPage: currentPage, - label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), - icon: Icons.cut, - changeSelectedPage: changeSelectedPage, - ), - ], + return SingleChildScrollView( + child: SeparatedColumn( + separatorBuilder: () => const SizedBox(height: 10), + children: [ + SettingsMenuElement( + page: SettingsPage.appearance, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_appearance.tr(), + icon: Icons.brightness_4, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.language, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_language.tr(), + icon: Icons.translate, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.files, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_files.tr(), + icon: Icons.file_present_outlined, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.user, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_user.tr(), + icon: Icons.account_box_outlined, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.notifications, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_notifications.tr(), + icon: Icons.notifications_outlined, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.cloud, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_cloudSettings.tr(), + icon: Icons.sync, + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.shortcuts, + selectedPage: currentPage, + label: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), + icon: Icons.cut, + changeSelectedPage: changeSelectedPage, + ), + if (FeatureFlag.membersSettings.isOn) + SettingsMenuElement( + page: SettingsPage.member, + selectedPage: currentPage, + label: LocaleKeys.settings_appearance_members_label.tr(), + icon: Icons.people, + changeSelectedPage: changeSelectedPage, + ), + // enable in v0.5.3 temporarily + // if (kDebugMode) + SettingsMenuElement( + // no need to translate this page + page: SettingsPage.featureFlags, + selectedPage: currentPage, + label: 'Feature Flags', + icon: Icons.flag, + changeSelectedPage: changeSelectedPage, + ), + ], + ), ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart index e386aa3ebc215..628232bd71f51 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart @@ -1,3 +1,6 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -5,14 +8,12 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; class ThemeUploadLearnMoreButton extends StatelessWidget { const ThemeUploadLearnMoreButton({super.key}); static const learnMoreURL = - 'https://appflowy.gitbook.io/docs/essential-documentation/themes'; + 'https://docs.appflowy.io/docs/appflowy/product/themes'; @override Widget build(BuildContext context) { @@ -30,27 +31,29 @@ class ThemeUploadLearnMoreButton extends StatelessWidget { ), onPressed: () async { final uri = Uri.parse(learnMoreURL); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - if (context.mounted) { - await Dialogs.show( - context, - child: FlowyDialog( - child: FlowyErrorPage.message( - LocaleKeys - .settings_appearance_themeUpload_urlUploadFailure - .tr() - .replaceAll( - '{}', - uri.toString(), - ), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + await afLaunchUrl( + uri, + context: context, + onFailure: (_) async { + if (context.mounted) { + await Dialogs.show( + context, + child: FlowyDialog( + child: FlowyErrorPage.message( + LocaleKeys + .settings_appearance_themeUpload_urlUploadFailure + .tr() + .replaceAll( + '{}', + uri.toString(), + ), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), ), - ), - ); - } - } + ); + } + }, + ); }, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 1b431b58d65fe..513f72b4ed89d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -1,15 +1,17 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; -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:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; class NavigatorTextFieldDialog extends StatefulWidget { const NavigatorTextFieldDialog({ @@ -17,17 +19,19 @@ class NavigatorTextFieldDialog extends StatefulWidget { required this.title, this.autoSelectAllText = false, required this.value, - required this.confirm, - this.cancel, + required this.onConfirm, + this.onCancel, this.maxLength, + this.hintText, }); final String value; final String title; - final void Function()? cancel; - final void Function(String) confirm; + final VoidCallback? onCancel; + final void Function(String, BuildContext) onConfirm; final bool autoSelectAllText; final int? maxLength; + final String? hintText; @override State createState() => @@ -69,7 +73,8 @@ class _NavigatorTextFieldDialogState extends State { ), VSpace(Insets.m), FlowyFormTextInput( - hintText: LocaleKeys.dialogCreatePageNameHint.tr(), + hintText: + widget.hintText ?? LocaleKeys.dialogCreatePageNameHint.tr(), controller: controller, textStyle: Theme.of(context) .textTheme @@ -82,20 +87,18 @@ class _NavigatorTextFieldDialogState extends State { newValue = text; }, onEditingComplete: () { - widget.confirm(newValue); + widget.onConfirm(newValue, context); AppGlobals.nav.pop(); }, ), VSpace(Insets.xl), OkCancelButton( onOkPressed: () { - widget.confirm(newValue); + widget.onConfirm(newValue, context); Navigator.of(context).pop(); }, onCancelPressed: () { - if (widget.cancel != null) { - widget.cancel!(); - } + widget.onCancel?.call(); Navigator.of(context).pop(); }, ), @@ -111,13 +114,15 @@ class NavigatorAlertDialog extends StatefulWidget { required this.title, this.cancel, this.confirm, - this.hideCancleButton = false, + this.hideCancelButton = false, + this.constraints, }); final String title; final void Function()? cancel; final void Function()? confirm; - final bool hideCancleButton; + final bool hideCancelButton; + final BoxConstraints? constraints; @override State createState() => _CreateFlowyAlertDialog(); @@ -138,10 +143,11 @@ class _CreateFlowyAlertDialog extends State { children: [ ...[ ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 400, - maxHeight: 260, - ), + constraints: widget.constraints ?? + const BoxConstraints( + maxWidth: 400, + maxHeight: 260, + ), child: FlowyText.medium( widget.title, fontSize: FontSizes.s16, @@ -158,7 +164,7 @@ class _CreateFlowyAlertDialog extends State { widget.confirm?.call(); Navigator.of(context).pop(); }, - onCancelPressed: widget.hideCancleButton + onCancelPressed: widget.hideCancelButton ? null : () { widget.cancel?.call(); @@ -180,7 +186,7 @@ class NavigatorOkCancelDialog extends StatelessWidget { this.okTitle, this.cancelTitle, this.title, - required this.message, + this.message, this.maxWidth, }); @@ -189,13 +195,14 @@ class NavigatorOkCancelDialog extends StatelessWidget { final String? okTitle; final String? cancelTitle; final String? title; - final String message; + final String? message; final double? maxWidth; @override Widget build(BuildContext context) { return StyledDialog( maxWidth: maxWidth ?? 500, + padding: EdgeInsets.symmetric(horizontal: Insets.xl, vertical: Insets.l), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -203,6 +210,7 @@ class NavigatorOkCancelDialog extends StatelessWidget { FlowyText.medium( title!.toUpperCase(), fontSize: FontSizes.s16, + maxLines: 3, ), VSpace(Insets.sm * 1.5), Container( @@ -211,7 +219,11 @@ class NavigatorOkCancelDialog extends StatelessWidget { ), VSpace(Insets.m * 1.5), ], - FlowyText.medium(message), + if (message != null) + FlowyText.medium( + message!, + maxLines: 3, + ), SizedBox(height: Insets.l), OkCancelButton( onOkPressed: () { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/favorite/favorite_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart similarity index 50% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/favorite/favorite_button.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart index b82b68dee4061..4c52e6c27851f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/favorite/favorite_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart @@ -1,15 +1,17 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class DocumentFavoriteButton extends StatelessWidget { - const DocumentFavoriteButton({ +class ViewFavoriteButton extends StatelessWidget { + const ViewFavoriteButton({ super.key, required this.view, }); @@ -21,34 +23,27 @@ class DocumentFavoriteButton extends StatelessWidget { return BlocBuilder( builder: (context, state) { final isFavorite = state.views.any((v) => v.id == view.id); - return _buildFavoriteButton(context, isFavorite); - }, - ); - } - - Widget _buildFavoriteButton(BuildContext context, bool isFavorite) { - return FlowyTooltip( - message: isFavorite - ? LocaleKeys.button_removeFromFavorites.tr() - : LocaleKeys.button_addToFavorites.tr(), - child: FlowyHover( - child: GestureDetector( - onTap: () => + return Listener( + onPointerDown: (_) => context.read().add(FavoriteEvent.toggle(view)), - child: _buildFavoriteIcon(context, isFavorite), - ), - ), - ); - } - - Widget _buildFavoriteIcon(BuildContext context, bool isFavorite) { - return Padding( - padding: const EdgeInsets.all(6), - child: FlowySvg( - isFavorite ? FlowySvgs.favorite_s : FlowySvgs.unfavorite_s, - size: const Size(18, 18), - color: Theme.of(context).iconTheme.color, - ), + child: FlowyTooltip( + message: isFavorite + ? LocaleKeys.button_removeFromFavorites.tr() + : LocaleKeys.button_addToFavorites.tr(), + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.all(6), + child: FlowySvg( + isFavorite ? FlowySvgs.favorite_s : FlowySvgs.unfavorite_s, + size: const Size(18, 18), + color: AFThemeExtension.of(context).warning, + ), + ), + ), + ), + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart index 0190e74a470c1..e72dfa098cc25 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart @@ -1,3 +1,7 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/rust_sdk.dart'; @@ -10,11 +14,8 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/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:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:styled_widget/styled_widget.dart'; -import 'package:url_launcher/url_launcher.dart'; class QuestionBubble extends StatelessWidget { const QuestionBubble({super.key}); @@ -86,26 +87,26 @@ class _BubbleActionListState extends State { if (action is BubbleActionWrapper) { switch (action.inner) { case BubbleAction.whatsNews: - _launchURL("https://www.appflowy.io/what-is-new"); + afLaunchUrlString("https://www.appflowy.io/what-is-new"); break; case BubbleAction.help: - _launchURL("https://discord.gg/9Q2xaN37tV"); + afLaunchUrlString("https://discord.gg/9Q2xaN37tV"); break; case BubbleAction.debug: _DebugToast().show(); break; case BubbleAction.shortcuts: - _launchURL( - "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts", + afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/shortcuts", ); break; case BubbleAction.markdown: - _launchURL( - "https://appflowy.gitbook.io/docs/essential-documentation/markdown", + afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/markdown", ); break; case BubbleAction.github: - _launchURL( + afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose', ); break; @@ -116,15 +117,6 @@ class _BubbleActionListState extends State { }, ); } - - void _launchURL(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - throw 'Could not launch $url'; - } - } } class _DebugToast { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart new file mode 100644 index 0000000000000..c8634b3df5ef7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/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'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MoreViewActions extends StatefulWidget { + const MoreViewActions({ + super.key, + required this.view, + this.isDocument = true, + }); + + /// The view to show the actions for. + final ViewPB view; + + /// If false the view is a Database, otherwise it is a Document. + final bool isDocument; + + @override + State createState() => _MoreViewActionsState(); +} + +class _MoreViewActionsState extends State { + late final List viewActions; + final popoverMutex = PopoverMutex(); + + @override + void initState() { + super.initState(); + viewActions = ViewActionType.values + .map( + (type) => ViewAction( + type: type, + view: widget.view, + mutex: popoverMutex, + ), + ) + .toList(); + } + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appearanceSettings = context.watch().state; + final dateFormat = appearanceSettings.dateFormat; + final timeFormat = appearanceSettings.timeFormat; + + return BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + mutex: popoverMutex, + constraints: BoxConstraints.loose(const Size(215, 400)), + offset: const Offset(0, 30), + popupBuilder: (_) { + final actions = [ + if (widget.isDocument) ...[ + const FontSizeAction(), + const Divider(height: 4), + ], + ...viewActions, + if (state.documentCounters != null || + state.createdAt != null) ...[ + const Divider(height: 4), + ViewMetaInfo( + dateFormat: dateFormat, + timeFormat: timeFormat, + documentCounters: state.documentCounters, + createdAt: state.createdAt, + ), + ], + ]; + + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: actions.length, + separatorBuilder: (_, __) => const VSpace(4), + physics: StyledScrollPhysics(), + itemBuilder: (_, index) => actions[index], + ); + }, + child: FlowyTooltip( + message: LocaleKeys.moreAction_moreOptions.tr(), + child: FlowyHover( + style: HoverStyle( + foregroundColorOnHover: Theme.of(context).colorScheme.onPrimary, + ), + builder: (context, isHovering) => Padding( + padding: const EdgeInsets.all(6), + child: FlowySvg( + FlowySvgs.details_s, + size: const Size(18, 18), + color: isHovering + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).iconTheme.color, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart new file mode 100644 index 0000000000000..0ce56272b1a71 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; + +enum ViewActionType { + delete, + duplicate; + + String get label => switch (this) { + ViewActionType.delete => LocaleKeys.moreAction_deleteView.tr(), + ViewActionType.duplicate => LocaleKeys.moreAction_duplicateView.tr(), + }; + + FlowySvgData get icon => switch (this) { + ViewActionType.delete => FlowySvgs.delete_s, + ViewActionType.duplicate => FlowySvgs.m_duplicate_s, + }; + + ViewEvent get actionEvent => switch (this) { + ViewActionType.delete => const ViewEvent.delete(), + ViewActionType.duplicate => const ViewEvent.duplicate(), + }; +} + +class ViewAction extends StatelessWidget { + const ViewAction({ + super.key, + required this.type, + required this.view, + this.mutex, + }); + + final ViewActionType type; + final ViewPB view; + final PopoverMutex? mutex; + + @override + Widget build(BuildContext context) { + return FlowyButton( + onTap: () { + getIt(param1: view).add(type.actionEvent); + mutex?.close(); + }, + text: FlowyText.regular( + type.label, + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: FlowySvg( + type.icon, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart new file mode 100644 index 0000000000000..c56f93ee9023f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.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/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FontSizeAction extends StatelessWidget { + const FontSizeAction({super.key}); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.leftWithCenterAligned, + constraints: const BoxConstraints(maxHeight: 40, maxWidth: 240), + offset: const Offset(-10, 0), + popupBuilder: (context) { + return BlocBuilder( + builder: (_, state) => FontSizeStepper( + minimumValue: 10, + maximumValue: 24, + value: state.fontSize, + divisions: 8, + onChanged: (newFontSize) => context + .read() + .syncFontSize(newFontSize), + ), + ); + }, + child: FlowyButton( + text: FlowyText.regular( + LocaleKeys.moreAction_fontSize.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: Icon( + Icons.format_size_sharp, + color: Theme.of(context).iconTheme.color, + size: 18, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/font_size_slider.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart similarity index 95% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/more/font_size_slider.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart index 7a512b3b6b3fe..67261598ff173 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/font_size_slider.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; class FontSizeStepper extends StatefulWidget { const FontSizeStepper({ @@ -53,10 +54,7 @@ class _FontSizeStepperState extends State { max: widget.maximumValue, divisions: widget.divisions, onChanged: (value) { - setState(() { - _value = value; - }); - + setState(() => _value = value); widget.onChanged(value); }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart new file mode 100644 index 0000000000000..a5a72964afe1d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.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/widget/spacing.dart'; + +class ViewMetaInfo extends StatelessWidget { + const ViewMetaInfo({ + super.key, + required this.dateFormat, + required this.timeFormat, + this.documentCounters, + this.createdAt, + }); + + final UserDateFormatPB dateFormat; + final UserTimeFormatPB timeFormat; + final Counters? documentCounters; + final DateTime? createdAt; + + @override + Widget build(BuildContext context) { + final numberFormat = NumberFormat(); + + // If more info is added to this Widget, use a separated ListView + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (documentCounters != null) ...[ + FlowyText.regular( + LocaleKeys.moreAction_wordCount.tr( + args: [ + numberFormat.format(documentCounters!.wordCount).toString(), + ], + ), + fontSize: 11, + color: Theme.of(context).hintColor, + ), + const VSpace(2), + FlowyText.regular( + LocaleKeys.moreAction_charCount.tr( + args: [ + numberFormat.format(documentCounters!.charCount).toString(), + ], + ), + fontSize: 11, + color: Theme.of(context).hintColor, + ), + ], + if (createdAt != null) ...[ + if (documentCounters != null) const VSpace(2), + FlowyText.regular( + LocaleKeys.moreAction_createdAt.tr( + args: [dateFormat.formatDate(createdAt!, true, timeFormat)], + ), + fontSize: 11, + maxLines: 2, + color: Theme.of(context).hintColor, + ), + ], + ], + ), + ); + } +} 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 83a42249d5be9..bb285a79170f7 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 @@ -219,9 +219,9 @@ class HoverButton extends StatelessWidget { super.key, required this.onTap, required this.itemHeight, - required this.leftIcon, + this.leftIcon, required this.name, - required this.rightIcon, + this.rightIcon, }); final VoidCallback onTap; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart index 766f012af88e4..bb2277bbf1731 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart @@ -29,7 +29,7 @@ class UserAvatar extends StatelessWidget { if (iconUrl.isEmpty) { final String nameOrDefault = _userName(name); - final Color color = ColorGenerator().generateColorFromString(name); + final Color color = ColorGenerator.generateColorFromString(name); const initialsCount = 2; // Taking the first letters of the name components and limiting to 2 elements @@ -50,7 +50,7 @@ class UserAvatar extends StatelessWidget { ), child: FlowyText.semibold( nameInitials, - color: Colors.white, + color: Colors.black, fontSize: isLarge ? nameInitials.length == initialsCount ? 20 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 index 3f2c6cc1680ab..aca18da74e061 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -216,12 +216,13 @@ class _ViewTitleState extends State<_ViewTitle> { ); if (widget.behavior == _ViewTitleBehavior.uneditable) { - return FlowyButton( - useIntrinsicWidth: true, - onTap: () { - context.read().openPlugin(widget.view); - }, - text: child, + return Listener( + onPointerDown: (_) => context.read().openPlugin(widget.view), + child: FlowyButton( + useIntrinsicWidth: true, + onTap: () {}, + text: child, + ), ); } diff --git a/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h b/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h index 710af508fd51e..9d9128671c1bf 100644 --- a/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h +++ b/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h @@ -13,6 +13,6 @@ int32_t set_stream_port(int64_t port); void link_me_please(void); -void backend_log(int64_t level, const char *data); +void rust_log(int64_t level, const char *data); void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h index 3fe1f39faaf71..77d9b96ec163d 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h +++ b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h @@ -13,6 +13,6 @@ int32_t set_stream_port(int64_t port); void link_me_please(void); -void backend_log(int64_t level, const char *data); +void rust_log(int64_t level, const char *data); void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index 816ff76d02469..d32663f470ba5 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -1,37 +1,37 @@ +import 'dart:async'; +import 'dart:convert' show utf8; import 'dart:ffi'; -import 'package:dartz/dartz.dart'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/ffi.dart' as ffi; import 'package:appflowy_backend/log.dart'; // ignore: unnecessary_import import 'package:appflowy_backend/protobuf/dart-ffi/ffi_response.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:isolates/isolates.dart'; -import 'package:isolates/ports.dart'; -import 'package:ffi/ffi.dart'; -import 'package:flutter/services.dart'; -import 'dart:async'; -import 'dart:typed_data'; -import 'package:appflowy_backend/ffi.dart' as ffi; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; - +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/services.dart'; +import 'package:isolates/isolates.dart'; +import 'package:isolates/ports.dart'; import 'package:protobuf/protobuf.dart'; -import 'dart:convert' show utf8; + import '../protobuf/flowy-config/entities.pb.dart'; import '../protobuf/flowy-config/event_map.pb.dart'; -import 'error.dart'; - import '../protobuf/flowy-date/entities.pb.dart'; import '../protobuf/flowy-date/event_map.pb.dart'; +import 'error.dart'; -part 'dart_event/flowy-folder/dart_event.dart'; -part 'dart_event/flowy-user/dart_event.dart'; -part 'dart_event/flowy-database2/dart_event.dart'; -part 'dart_event/flowy-document/dart_event.dart'; part 'dart_event/flowy-config/dart_event.dart'; +part 'dart_event/flowy-database2/dart_event.dart'; part 'dart_event/flowy-date/dart_event.dart'; +part 'dart_event/flowy-document/dart_event.dart'; +part 'dart_event/flowy-folder/dart_event.dart'; +part 'dart_event/flowy-user/dart_event.dart'; enum FFIException { RequestIsEmpty, @@ -43,7 +43,8 @@ class DispatchException implements Exception { } class Dispatch { - static Future> asyncRequest(FFIRequest request) { + static Future> asyncRequest( + FFIRequest request) { // FFIRequest => Rust SDK final bytesFuture = _sendToRust(request); @@ -57,43 +58,43 @@ class Dispatch { } } -Future> _extractPayload( - Future> responseFuture) { +Future> _extractPayload( + Future> responseFuture) { return responseFuture.then((result) { return result.fold( (response) { switch (response.code) { case FFIStatusCode.Ok: - return left(Uint8List.fromList(response.payload)); + return FlowySuccess(Uint8List.fromList(response.payload)); case FFIStatusCode.Err: - return right(Uint8List.fromList(response.payload)); + return FlowyFailure(Uint8List.fromList(response.payload)); case FFIStatusCode.Internal: final error = utf8.decode(response.payload); Log.error("Dispatch internal error: $error"); - return right(emptyBytes()); + return FlowyFailure(emptyBytes()); default: Log.error("Impossible to here"); - return right(emptyBytes()); + return FlowyFailure(emptyBytes()); } }, (error) { Log.error("Response should not be empty $error"); - return right(emptyBytes()); + return FlowyFailure(emptyBytes()); }, ); }); } -Future> _extractResponse( +Future> _extractResponse( Completer bytesFuture) { return bytesFuture.future.then((bytes) { try { final response = FFIResponse.fromBuffer(bytes); - return left(response); + return FlowySuccess(response); } catch (e, s) { final error = StackTraceError(e, s); Log.error('Deserialize response failed. ${error.toString()}'); - return right(error.asFlowyError()); + return FlowyFailure(error.asFlowyError()); } }); } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart index 51b6f9851f172..cb57d438dcb63 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart @@ -137,20 +137,20 @@ typedef _store_dart_post_cobject_Dart = void Function( Pointer)>> ptr, ); -void log( +void rust_log( int level, Pointer data, ) { - _invoke_log(level, data); + _invoke_rust_log(level, data); } -final _invoke_log_Dart _invoke_log = _dart_ffi_lib - .lookupFunction<_invoke_log_C, _invoke_log_Dart>('backend_log'); -typedef _invoke_log_C = Void Function( +final _invoke_rust_log_Dart _invoke_rust_log = _dart_ffi_lib + .lookupFunction<_invoke_rust_log_C, _invoke_rust_log_Dart>('rust_log'); +typedef _invoke_rust_log_C = Void Function( Int64 level, Pointer data, ); -typedef _invoke_log_Dart = void Function( +typedef _invoke_rust_log_Dart = void Function( int level, Pointer, ); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart index 9dba77754a2ae..2ba3be1a2acc2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -9,6 +9,7 @@ import 'ffi.dart'; class Log { static final shared = Log(); + // ignore: unused_field late Logger _logger; Log() { @@ -26,63 +27,23 @@ class Log { } static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - if (isReleaseVersion()) { - log(0, toNativeUtf8(msg)); - } else { - Log.shared._logger.i( - msg, - error: error, - stackTrace: stackTrace, - ); - } + rust_log(0, toNativeUtf8(msg)); } static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - if (isReleaseVersion()) { - log(1, toNativeUtf8(msg)); - } else { - Log.shared._logger.d( - msg, - error: error, - stackTrace: stackTrace, - ); - } + rust_log(1, toNativeUtf8(msg)); } static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - if (isReleaseVersion()) { - log(3, toNativeUtf8(msg)); - } else { - Log.shared._logger.w( - msg, - error: error, - stackTrace: stackTrace, - ); - } + rust_log(3, toNativeUtf8(msg)); } static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - if (isReleaseVersion()) { - log(2, toNativeUtf8(msg)); - } else { - Log.shared._logger.t( - msg, - error: error, - stackTrace: stackTrace, - ); - } + rust_log(2, toNativeUtf8(msg)); } static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { - if (isReleaseVersion()) { - log(4, toNativeUtf8(msg)); - } else { - Log.shared._logger.e( - msg, - error: error, - stackTrace: stackTrace, - ); - } + rust_log(4, toNativeUtf8(msg)); } } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h b/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h index 9a1769c338e90..c14543eec1ccf 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h +++ b/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h @@ -14,6 +14,6 @@ int32_t set_stream_port(int64_t port); void link_me_please(void); -void backend_log(int64_t level, const char *data); +void rust_log(int64_t level, const char *data); void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h b/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h index 3fe1f39faaf71..77d9b96ec163d 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h +++ b/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h @@ -13,6 +13,6 @@ int32_t set_stream_port(int64_t port); void link_me_please(void); -void backend_log(int64_t level, const char *data); +void rust_log(int64_t level, const char *data); void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml index 318cd173ccfad..c014da75dfdcd 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml @@ -14,11 +14,12 @@ dependencies: ffi: ^2.0.2 isolates: ^3.0.3+8 protobuf: ^3.1.0 - dartz: ^0.10.1 freezed_annotation: logger: ^2.0.0 plugin_platform_interface: ^2.1.3 json_annotation: ^4.7.0 + appflowy_result: + path: ../appflowy_result dev_dependencies: flutter_test: 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 8a18b447a7e67..17eefc44f0adc 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -1,7 +1,8 @@ -import 'package:appflowy_popover/src/layout.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:appflowy_popover/src/layout.dart'; + import 'mask.dart'; import 'mutex.dart'; @@ -90,6 +91,8 @@ class Popover extends StatefulWidget { /// the conflict won't be resolve by using Listener, we want these two gestures exclusive. final PopoverClickHandler clickHandler; + final bool skipTraversal; + /// The content area of the popover. final Widget child; @@ -110,6 +113,7 @@ class Popover extends StatefulWidget { this.canClose, this.asBarrier = false, this.clickHandler = PopoverClickHandler.listener, + this.skipTraversal = false, }); @override @@ -158,18 +162,20 @@ class PopoverState extends State { popupBuilder: widget.popupBuilder, onClose: () => close(), onCloseAll: () => _removeRootOverlay(), + skipTraversal: widget.skipTraversal, ), ); - return FocusScope( - onKey: (node, event) { - if (event.logicalKey == LogicalKeyboardKey.escape) { - _removeRootOverlay(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + _removeRootOverlay(), }, - child: Stack(children: children), + child: FocusScope( + child: Stack( + children: children, + ), + ), ); }); _rootEntry.addEntry(context, this, newEntry, widget.asBarrier); @@ -262,6 +268,7 @@ class PopoverContainer extends StatefulWidget { final EdgeInsets windowPadding; final void Function() onClose; final void Function() onCloseAll; + final bool skipTraversal; const PopoverContainer({ super.key, @@ -272,6 +279,7 @@ class PopoverContainer extends StatefulWidget { required this.windowPadding, required this.onClose, required this.onCloseAll, + required this.skipTraversal, }); @override @@ -292,6 +300,7 @@ class PopoverContainerState extends State { Widget build(BuildContext context) { return Focus( autofocus: true, + skipTraversal: widget.skipTraversal, child: CustomSingleChildLayout( delegate: PopoverLayoutDelegate( direction: widget.direction, diff --git a/frontend/appflowy_flutter/packages/appflowy_result/.gitignore b/frontend/appflowy_flutter/packages/appflowy_result/.gitignore new file mode 100644 index 0000000000000..ac5aa9893e489 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/frontend/appflowy_flutter/packages/appflowy_result/.metadata b/frontend/appflowy_flutter/packages/appflowy_result/.metadata new file mode 100644 index 0000000000000..ea18fe993df39 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4" + channel: "[user-branch]" + +project_type: package diff --git a/frontend/appflowy_flutter/packages/appflowy_result/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_result/CHANGELOG.md new file mode 100644 index 0000000000000..41cc7d8192ecf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/frontend/appflowy_flutter/packages/appflowy_result/LICENSE b/frontend/appflowy_flutter/packages/appflowy_result/LICENSE new file mode 100644 index 0000000000000..ba75c69f7f217 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/appflowy_flutter/packages/appflowy_result/README.md b/frontend/appflowy_flutter/packages/appflowy_result/README.md new file mode 100644 index 0000000000000..02fe8ecabcbf3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/frontend/appflowy_flutter/packages/appflowy_result/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_result/analysis_options.yaml new file mode 100644 index 0000000000000..a5744c1cfbe77 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart new file mode 100644 index 0000000000000..97b81cfe1a251 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart @@ -0,0 +1,4 @@ +library appflowy_result; + +export 'src/async_result.dart'; +export 'src/result.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart new file mode 100644 index 0000000000000..94cd9a68a61b0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart @@ -0,0 +1,37 @@ +import 'package:appflowy_result/appflowy_result.dart'; + +typedef FlowyAsyncResult = Future>; + +extension FlowyAsyncResultExtension + on FlowyAsyncResult { + Future getOrElse(S Function(F f) onFailure) { + return then((result) => result.getOrElse(onFailure)); + } + + Future toNullable() { + return then((result) => result.toNullable()); + } + + Future getOrThrow() { + return then((result) => result.getOrThrow()); + } + + Future fold( + W Function(S s) onSuccess, + W Function(F f) onFailure, + ) { + return then((result) => result.fold(onSuccess, onFailure)); + } + + Future isError() { + return then((result) => result.isFailure); + } + + Future isSuccess() { + return then((result) => result.isSuccess); + } + + FlowyAsyncResult onFailure(void Function(F failure) onFailure) { + return then((result) => result..onFailure(onFailure)); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart new file mode 100644 index 0000000000000..eca6726b9ec3b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart @@ -0,0 +1,163 @@ +abstract class FlowyResult { + const FlowyResult(); + + factory FlowyResult.success(S s) => FlowySuccess(s); + + factory FlowyResult.failure(F f) => FlowyFailure(f); + + T fold(T Function(S s) onSuccess, T Function(F f) onFailure); + + FlowyResult map(T Function(S success) fn); + FlowyResult mapError(T Function(F failure) fn); + + bool get isSuccess; + bool get isFailure; + + S? toNullable(); + + void onSuccess(void Function(S s) onSuccess); + void onFailure(void Function(F f) onFailure); + + S getOrElse(S Function(F failure) onFailure); + S getOrThrow(); + + F getFailure(); +} + +class FlowySuccess implements FlowyResult { + final S _value; + + FlowySuccess(this._value); + + S get value => _value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FlowySuccess && + runtimeType == other.runtimeType && + _value == other._value; + + @override + int get hashCode => _value.hashCode; + + @override + String toString() => 'Success(value: $_value)'; + + @override + T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => + onSuccess(_value); + + @override + map(T Function(S success) fn) { + return FlowySuccess(fn(_value)); + } + + @override + FlowyResult mapError(T Function(F error) fn) { + return FlowySuccess(_value); + } + + @override + bool get isSuccess => true; + + @override + bool get isFailure => false; + + @override + S? toNullable() { + return _value; + } + + @override + void onSuccess(void Function(S success) onSuccess) { + onSuccess(_value); + } + + @override + void onFailure(void Function(F failure) onFailure) {} + + @override + S getOrElse(S Function(F failure) onFailure) { + return _value; + } + + @override + S getOrThrow() { + return _value; + } + + @override + F getFailure() { + throw UnimplementedError(); + } +} + +class FlowyFailure implements FlowyResult { + final F _value; + + FlowyFailure(this._value); + + F get error => _value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FlowyFailure && + runtimeType == other.runtimeType && + _value == other._value; + + @override + int get hashCode => _value.hashCode; + + @override + String toString() => 'Failure(error: $_value)'; + + @override + T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => + onFailure(_value); + + @override + map(T Function(S success) fn) { + return FlowyFailure(_value); + } + + @override + FlowyResult mapError(T Function(F error) fn) { + return FlowyFailure(fn(_value)); + } + + @override + bool get isSuccess => false; + + @override + bool get isFailure => true; + + @override + S? toNullable() { + return null; + } + + @override + void onSuccess(void Function(S success) onSuccess) {} + + @override + void onFailure(void Function(F failure) onFailure) { + onFailure(_value); + } + + @override + S getOrElse(S Function(F failure) onFailure) { + return onFailure(_value); + } + + @override + S getOrThrow() { + throw _value; + } + + @override + F getFailure() { + return _value; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml new file mode 100644 index 0000000000000..241f437d9bd3f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml @@ -0,0 +1,54 @@ +name: appflowy_result +description: "A new Flutter package project." +version: 0.0.1 +homepage: + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index 2a80f770ba792..234de2d736f5d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -22,6 +22,8 @@ String languageFromLocale(Locale locale) { return "العربية"; case "ca": return "Català"; + case "cs": + return "Čeština"; case "ckb": switch (locale.countryCode) { case "KU": @@ -35,6 +37,8 @@ String languageFromLocale(Locale locale) { return "Español"; case "eu": return "Euskera"; + case "el": + return "Ελληνικά"; case "fr": switch (locale.countryCode) { case "CA": diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index aa94f0cc02087..0a3ff0a119769 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -26,6 +26,10 @@ class AppFlowyPopover extends StatelessWidget { /// the conflict won't be resolve by using Listener, we want these two gestures exclusive. final PopoverClickHandler clickHandler; + /// If true the popover will not participate in focus traversal. + /// + final bool skipTraversal; + const AppFlowyPopover({ super.key, required this.child, @@ -43,6 +47,7 @@ class AppFlowyPopover extends StatelessWidget { this.windowPadding = const EdgeInsets.all(8.0), this.decoration, this.clickHandler = PopoverClickHandler.listener, + this.skipTraversal = false, }); @override @@ -58,6 +63,7 @@ class AppFlowyPopover extends StatelessWidget { windowPadding: windowPadding, offset: offset, clickHandler: clickHandler, + skipTraversal: skipTraversal, popupBuilder: (context) { return _PopoverContainer( constraints: constraints, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart index cd3705122040b..7ad09eb4f755c 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; import 'dart:math'; +import 'package:flutter/material.dart'; + const _overlayContainerPadding = EdgeInsets.symmetric(vertical: 12); const overlayContainerMaxWidth = 760.0; const overlayContainerMinWidth = 320.0; @@ -14,6 +15,7 @@ class FlowyDialog extends StatelessWidget { this.constraints, this.padding = _overlayContainerPadding, this.backgroundColor, + this.width, }); final Widget? title; @@ -22,11 +24,12 @@ class FlowyDialog extends StatelessWidget { final BoxConstraints? constraints; final EdgeInsets padding; final Color? backgroundColor; + final double? width; @override Widget build(BuildContext context) { final windowSize = MediaQuery.of(context).size; - final size = windowSize * 0.7; + final size = windowSize * 0.6; return SimpleDialog( contentPadding: EdgeInsets.zero, backgroundColor: backgroundColor ?? Theme.of(context).cardColor, @@ -38,8 +41,11 @@ class FlowyDialog extends StatelessWidget { type: MaterialType.transparency, child: Container( height: size.height, - width: max(min(size.width, overlayContainerMaxWidth), - overlayContainerMinWidth), + width: width ?? + max( + min(size.width, overlayContainerMaxWidth), + overlayContainerMinWidth, + ), constraints: constraints, child: child, ), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart index 0345102d8c917..97d368eab6a00 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart @@ -292,7 +292,7 @@ class FlowyOverlayState extends State { RenderObject renderObject = anchorContext.findRenderObject()!; assert( renderObject is RenderBox, - 'Unexpected non-RenderBox render object caught.', + 'Unexpecteded non-RenderBox render object caught.', ); final renderBox = renderObject as RenderBox; targetAnchorPosition = renderBox.localToGlobal(Offset.zero); 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 e2cd3bc48e83f..dced70a6bea0d 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,12 +1,13 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; class FlowyButton extends StatelessWidget { final Widget text; @@ -88,7 +89,7 @@ class FlowyButton extends StatelessWidget { } Widget _render(BuildContext context) { - List children = List.empty(growable: true); + final List children = []; if (leftIcon != null) { children.add( @@ -213,6 +214,7 @@ class FlowyTextButton extends StatelessWidget { ); child = RawMaterialButton( + focusNode: FocusNode(skipTraversal: onPressed == null), hoverElevation: 0, highlightElevation: 0, shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), @@ -237,6 +239,10 @@ class FlowyTextButton extends StatelessWidget { ); } + if (onPressed == null) { + child = ExcludeFocus(child: child); + } + return child; } } 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 010c7cd6f901c..116158b10bec1 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 @@ -38,7 +38,7 @@ class FlowyIconButton extends StatelessWidget { this.preferBelow = true, this.isSelected, required this.icon, - }) : assert((richTooltipText != null && tooltipText == null) || + }) : assert((richTooltipText != null && tooltipText == null) || (richTooltipText == null && tooltipText != null) || (richTooltipText == null && tooltipText == null)); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart index b3326d0e60e89..da226741529d4 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart @@ -1,13 +1,28 @@ -import 'dart:math'; import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + import 'package:async/async.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/widget/mouse_hover_builder.dart'; -import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; class StyledScrollbar extends StatefulWidget { + const StyledScrollbar({ + super.key, + this.size, + required this.axis, + required this.controller, + this.onDrag, + this.contentSize, + this.showTrack = false, + this.autoHideScrollbar = true, + this.handleColor, + this.trackColor, + }); + final double? size; final Axis axis; final ScrollController controller; @@ -22,18 +37,6 @@ class StyledScrollbar extends StatefulWidget { // https://stackoverflow.com/questions/60855712/flutter-how-to-force-scrollcontroller-to-recalculate-position-maxextents final double? contentSize; - const StyledScrollbar( - {super.key, - this.size, - required this.axis, - required this.controller, - this.onDrag, - this.contentSize, - this.showTrack = false, - this.autoHideScrollbar = true, - this.handleColor, - this.trackColor}); - @override ScrollbarState createState() => ScrollbarState(); } @@ -46,25 +49,29 @@ class ScrollbarState extends State { @override void initState() { super.initState(); - widget.controller.addListener(() => setState(() {})); - - widget.controller.position.isScrollingNotifier.addListener( - _hideScrollbarInTime, - ); + widget.controller.addListener(_onScrollChanged); + widget.controller.position.isScrollingNotifier + .addListener(_hideScrollbarInTime); } @override - void didUpdateWidget(StyledScrollbar oldWidget) { - if (oldWidget.contentSize != widget.contentSize) setState(() {}); - super.didUpdateWidget(oldWidget); + void dispose() { + if (widget.controller.hasClients) { + widget.controller.removeListener(_onScrollChanged); + widget.controller.position.isScrollingNotifier + .removeListener(_hideScrollbarInTime); + } + super.dispose(); } + void _onScrollChanged() => setState(() {}); + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (_, BoxConstraints constraints) { double maxExtent; - final contentSize = widget.contentSize; + final double? contentSize = widget.contentSize; switch (widget.axis) { case Axis.vertical: @@ -109,11 +116,9 @@ class ScrollbarState extends State { } // Hide the handle if content is < the viewExtent - var showHandle = contentExtent > _viewExtent && contentExtent > 0; - - if (hideHandler) { - showHandle = false; - } + var showHandle = hideHandler + ? false + : contentExtent > _viewExtent && contentExtent > 0; // Handle color var handleColor = widget.handleColor ?? @@ -184,7 +189,7 @@ class ScrollbarState extends State { if (!widget.controller.position.isScrollingNotifier.value) { _hideScrollbarOperation = CancelableOperation.fromFuture( - Future.delayed(const Duration(seconds: 2), () {}), + Future.delayed(const Duration(seconds: 2)), ).then((_) { hideHandler = true; if (mounted) { @@ -216,17 +221,6 @@ class ScrollbarState extends State { } class ScrollbarListStack extends StatelessWidget { - final double barSize; - final Axis axis; - final Widget child; - final ScrollController controller; - final double? contentSize; - final EdgeInsets? scrollbarPadding; - final Color? handleColor; - final Color? trackColor; - final bool showTrack; - final bool autoHideScrollbar; - const ScrollbarListStack({ super.key, required this.barSize, @@ -239,20 +233,37 @@ class ScrollbarListStack extends StatelessWidget { this.autoHideScrollbar = true, this.trackColor, this.showTrack = false, + this.includeInsets = true, }); + final double barSize; + final Axis axis; + final Widget child; + final ScrollController controller; + final double? contentSize; + final EdgeInsets? scrollbarPadding; + final Color? handleColor; + final Color? trackColor; + final bool showTrack; + final bool autoHideScrollbar; + final bool includeInsets; + @override Widget build(BuildContext context) { return Stack( children: [ - /// LIST - /// Wrap with a bit of padding on the right - child.padding( - right: axis == Axis.vertical ? barSize + Insets.m : 0, - bottom: axis == Axis.horizontal ? barSize + Insets.m : 0, + /// Wrap with a bit of padding on the right or bottom to make room for the scrollbar + Padding( + padding: !includeInsets + ? EdgeInsets.zero + : EdgeInsets.only( + right: axis == Axis.vertical ? barSize + Insets.m : 0, + bottom: axis == Axis.horizontal ? barSize + Insets.m : 0, + ), + child: child, ), - /// SCROLLBAR + /// Display the scrollbar Padding( padding: scrollbarPadding ?? EdgeInsets.zero, child: StyledScrollbar( @@ -266,7 +277,7 @@ class ScrollbarListStack extends StatelessWidget { showTrack: showTrack, ), ) - // The animate will be used by the children that using styled_widget. + // The animate will be used by the children that are using styled_widget. .animate(const Duration(milliseconds: 250), Curves.easeOut), ], ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart index 664dbc7ed1f3d..b1b7c8afc7001 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart @@ -4,17 +4,6 @@ import 'styled_list.dart'; import 'styled_scroll_bar.dart'; class StyledSingleChildScrollView extends StatefulWidget { - final double? contentSize; - final Axis axis; - final Color? trackColor; - final Color? handleColor; - final ScrollController? controller; - final EdgeInsets? scrollbarPadding; - final double barSize; - final bool autoHideScrollbar; - - final Widget? child; - const StyledSingleChildScrollView({ super.key, required this.child, @@ -26,8 +15,20 @@ class StyledSingleChildScrollView extends StatefulWidget { this.scrollbarPadding, this.barSize = 8, this.autoHideScrollbar = true, + this.includeInsets = true, }); + final Widget? child; + final double? contentSize; + final Axis axis; + final Color? trackColor; + final Color? handleColor; + final ScrollController? controller; + final EdgeInsets? scrollbarPadding; + final double barSize; + final bool autoHideScrollbar; + final bool includeInsets; + @override State createState() => StyledSingleChildScrollViewState(); @@ -35,13 +36,8 @@ class StyledSingleChildScrollView extends StatefulWidget { class StyledSingleChildScrollViewState extends State { - late ScrollController scrollController; - - @override - void initState() { - scrollController = widget.controller ?? ScrollController(); - super.initState(); - } + late final ScrollController scrollController = + widget.controller ?? ScrollController(); @override void dispose() { @@ -51,14 +47,6 @@ class StyledSingleChildScrollViewState super.dispose(); } - @override - void didUpdateWidget(StyledSingleChildScrollView oldWidget) { - if (oldWidget.child != widget.child) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } - @override Widget build(BuildContext context) { return ScrollbarListStack( @@ -70,6 +58,7 @@ class StyledSingleChildScrollViewState barSize: widget.barSize, trackColor: widget.trackColor, handleColor: widget.handleColor, + includeInsets: widget.includeInsets, child: SingleChildScrollView( scrollDirection: widget.axis, physics: StyledScrollPhysics(), @@ -81,13 +70,6 @@ class StyledSingleChildScrollViewState } class StyledCustomScrollView extends StatefulWidget { - final Axis axis; - final Color? trackColor; - final Color? handleColor; - final ScrollController? verticalController; - final List slivers; - final double barSize; - const StyledCustomScrollView({ super.key, this.axis = Axis.vertical, @@ -98,32 +80,20 @@ class StyledCustomScrollView extends StatefulWidget { this.barSize = 8, }); + final Axis axis; + final Color? trackColor; + final Color? handleColor; + final ScrollController? verticalController; + final List slivers; + final double barSize; + @override StyledCustomScrollViewState createState() => StyledCustomScrollViewState(); } class StyledCustomScrollViewState extends State { - late ScrollController controller; - - @override - void initState() { - controller = widget.verticalController ?? ScrollController(); - - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - void didUpdateWidget(StyledCustomScrollView oldWidget) { - if (oldWidget.slivers != widget.slivers) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } + late final ScrollController controller = + widget.verticalController ?? ScrollController(); @override Widget build(BuildContext context) { 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 6593ae65678ce..e9aab3bfb6b3e 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 @@ -21,6 +21,7 @@ class FlowyTextField extends StatefulWidget { final bool submitOnLeave; final Duration? debounceDuration; final String? errorText; + final Widget? error; final int? maxLines; final bool showCounter; final Widget? prefixIcon; @@ -52,6 +53,7 @@ class FlowyTextField extends StatefulWidget { this.submitOnLeave = false, this.debounceDuration, this.errorText, + this.error, this.maxLines = 1, this.showCounter = true, this.prefixIcon, @@ -63,7 +65,7 @@ class FlowyTextField extends StatefulWidget { this.decoration, this.textAlignVertical, this.textInputAction, - this.keyboardType, + this.keyboardType = TextInputType.multiline, this.inputFormatters, }); @@ -148,7 +150,6 @@ class FlowyTextFieldState extends State { _onChanged(text); } }, - textInputAction: widget.textInputAction, onSubmitted: (text) => _onSubmitted(text), onEditingComplete: widget.onEditingComplete, minLines: 1, @@ -157,7 +158,7 @@ class FlowyTextFieldState extends State { maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, style: widget.textStyle ?? Theme.of(context).textTheme.bodySmall, textAlignVertical: widget.textAlignVertical ?? TextAlignVertical.center, - keyboardType: widget.keyboardType ?? TextInputType.multiline, + keyboardType: widget.keyboardType, inputFormatters: widget.inputFormatters, decoration: widget.decoration ?? InputDecoration( @@ -180,6 +181,7 @@ class FlowyTextFieldState extends State { isDense: false, hintText: widget.hintText, errorText: widget.errorText, + error: widget.error, errorStyle: Theme.of(context) .textTheme .bodySmall! diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart index a2fe2f091e2a1..62074190095c7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart @@ -1,5 +1,6 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; + import 'base_styled_button.dart'; import 'secondary_button.dart'; @@ -25,15 +26,18 @@ class PrimaryTextButton extends StatelessWidget { } class PrimaryButton extends StatelessWidget { + const PrimaryButton({ + super.key, + required this.child, + this.onPressed, + this.mode = TextButtonMode.big, + this.backgroundColor, + }); + final Widget child; final VoidCallback? onPressed; final TextButtonMode mode; - - const PrimaryButton( - {super.key, - required this.child, - this.onPressed, - this.mode = TextButtonMode.big}); + final Color? backgroundColor; @override Widget build(BuildContext context) { @@ -41,7 +45,7 @@ class PrimaryButton extends StatelessWidget { minWidth: mode.size.width, minHeight: mode.size.height, contentPadding: EdgeInsets.zero, - bgColor: Theme.of(context).colorScheme.primary, + bgColor: backgroundColor ?? Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primaryContainer, borderRadius: mode.borderRadius, onPressed: onPressed, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart index d4e9ed48c1f9f..11b71b7d283cb 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart @@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget { final Color? hoverColor; final Color? textColor; final double? fontSize; + final EdgeInsets padding; const RoundedTextButton({ super.key, @@ -26,6 +27,7 @@ class RoundedTextButton extends StatelessWidget { this.hoverColor, this.textColor, this.fontSize, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), }); @override @@ -48,6 +50,7 @@ class RoundedTextButton extends StatelessWidget { fillColor: fillColor ?? Theme.of(context).colorScheme.primary, hoverColor: hoverColor ?? Theme.of(context).colorScheme.primaryContainer, + padding: padding, ), ), ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart index 9b2cac25e4b24..c59c15e73ab94 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; typedef SeparatorBuilder = Widget Function(); +Widget _defaultColumnSeparatorBuilder() => const Divider(); +Widget _defaultRowSeparatorBuilder() => const VerticalDivider(); + class SeparatedColumn extends Column { SeparatedColumn({ super.key, @@ -11,7 +14,7 @@ class SeparatedColumn extends Column { super.textBaseline, super.textDirection, super.verticalDirection, - required SeparatorBuilder separatorBuilder, + SeparatorBuilder separatorBuilder = _defaultColumnSeparatorBuilder, required List children, }) : super(children: _insertSeparators(children, separatorBuilder)); } @@ -25,7 +28,7 @@ class SeparatedRow extends Row { super.textBaseline, super.textDirection, super.verticalDirection, - required SeparatorBuilder separatorBuilder, + SeparatorBuilder separatorBuilder = _defaultRowSeparatorBuilder, required List children, }) : super(children: _insertSeparators(children, separatorBuilder)); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml index 816ac4d160842..3d73a51d7b438 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: sdk: flutter # Thirdparty packages - dartz: provider: ^6.0.5 styled_widget: ^0.4.1 equatable: ^2.0.5 diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 44516648fda3a..af24f4f87af04 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,17 +53,17 @@ packages: dependency: "direct main" description: path: "." - ref: "1715ed4" - resolved-ref: "1715ed45490e0a432fa1bbfcb2f1693471632ff7" + ref: a571f2b + resolved-ref: a571f2bc9df764d90569951f40364c8c59787f30 url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git - version: "2.3.2" + version: "2.3.3" appflowy_editor_plugins: dependency: "direct main" description: path: "." - ref: "0223cca" - resolved-ref: "0223ccabe74b86092d3f3849b69026c89df3b236" + ref: "8f238f2" + resolved-ref: "8f238f214de72e629fe2d90317518c5a0510cdc5" url: "https://github.com/LucasXu0/appflowy_editor_plugins" source: git version: "0.0.1" @@ -74,6 +74,13 @@ packages: relative: true source: path version: "0.0.1" + appflowy_result: + dependency: "direct main" + description: + path: "packages/appflowy_result" + relative: true + source: path + version: "0.0.1" archive: dependency: "direct main" description: @@ -339,14 +346,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" - dartz: - dependency: "direct main" - description: - name: dartz - sha256: e6acf34ad2e31b1eb00948692468c30ab48ac8250e0f0df661e29f12dd252168 - url: "https://pub.dev" - source: hosted - version: "0.10.1" dbus: dependency: transitive description: @@ -1102,10 +1101,10 @@ packages: dependency: transitive description: name: markdown - sha256: "1b134d9f8ff2da15cb298efe6cd8b7d2a78958c1b00384ebcbdf13fe340a6c90" + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 url: "https://pub.dev" source: hosted - version: "7.2.1" + version: "7.2.2" matcher: dependency: transitive description: @@ -1606,11 +1605,11 @@ packages: dependency: "direct main" description: path: sheet - ref: "8ee20bd" - resolved-ref: "8ee20bd36acaeb36996a09ba9d0f9e7059bb49df" - url: "https://github.com/richardshiue/modal_bottom_sheet" + ref: e44458d + resolved-ref: e44458d2359565324e117bb3d41da04f5e60362e + url: "https://github.com/jamesblasco/modal_bottom_sheet" source: git - version: "1.0.0-pre" + version: "1.0.0" shelf: dependency: transitive description: @@ -2187,5 +2186,5 @@ packages: source: hosted version: "2.0.0" sdks: - dart: ">=3.3.0-279.1.beta <4.0.0" + dart: ">=3.3.0 <4.0.0" flutter: ">=3.19.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 00068fc836003..2ecc7b3bd0cda 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ 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.5.0 +version: 0.5.3 environment: flutter: ">=3.19.0" @@ -44,11 +44,13 @@ dependencies: git: url: https://github.com/AppFlowy-IO/appflowy-board.git ref: 15a3a50 + appflowy_result: + path: packages/appflowy_result appflowy_editor: appflowy_editor_plugins: git: url: https://github.com/LucasXu0/appflowy_editor_plugins - ref: "0223cca" + ref: "8f238f2" appflowy_popover: path: packages/appflowy_popover @@ -61,7 +63,6 @@ dependencies: get_it: ^7.6.0 flutter_bloc: ^8.1.3 flutter_math_fork: ^0.7.2 - dartz: ^0.10.1 provider: ^6.0.5 path_provider: ^2.0.15 sized_context: ^1.0.0+4 @@ -166,12 +167,12 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "1715ed4" + ref: "a571f2b" sheet: git: - url: https://github.com/richardshiue/modal_bottom_sheet - ref: 8ee20bd + url: https://github.com/jamesblasco/modal_bottom_sheet + ref: e44458d path: sheet uuid: ^4.1.0 diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart index 6c59b29137acf..b8f6140c29c1d 100644 --- a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart @@ -1,7 +1,8 @@ +import 'package:flutter/widgets.dart'; + import 'package:appflowy/core/config/kv_keys.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; 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 dd07369d9d129..efe89e5bd5256 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 @@ -49,12 +49,6 @@ void main() { boardBloc.groupControllers.values.length == 1, "Expected 1, but receive ${boardBloc.groupControllers.values.length}", ); - final expectedGroupName = "No ${multiSelectField.name}"; - assert( - boardBloc.groupControllers.values.first.group.groupName == - expectedGroupName, - "Expected $expectedGroupName, but receive ${boardBloc.groupControllers.values.first.group.groupName}", - ); }); test('group by multi select with no options test', () async { @@ -105,11 +99,5 @@ void main() { boardBloc.groupControllers.values.length == 3, "Expected 3, but receive ${boardBloc.groupControllers.values.length}", ); - - final groups = - boardBloc.groupControllers.values.map((e) => e.group).toList(); - 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/grid_test/cell/select_option_cell_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart index 415df571dfc63..e12fe5e23e6b1 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart @@ -1,8 +1,8 @@ import 'package:appflowy/plugins/database/application/cell/bloc/select_option_editor_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; -import 'package:dartz/dartz.dart'; import 'package:flutter_test/flutter_test.dart'; + import '../util.dart'; void main() { @@ -179,7 +179,7 @@ void main() { assert(bloc.state.selectedOptions.length == 1); expect(bloc.state.selectedOptions[0].name, "A"); - expect(bloc.state.filter, const Some("x")); + expect(bloc.state.filter, "x"); }); test('filter options', () async { @@ -231,8 +231,8 @@ void main() { 3, reason: "Options: ${bloc.state.options}", ); - expect(bloc.state.createOption, const Some("a")); - expect(bloc.state.filter, const Some("a")); + expect(bloc.state.createOption, "a"); + expect(bloc.state.filter, "a"); }); }); } diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/create_filter_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/create_filter_test.dart index 8826db712b9ee..d02a319ab1904 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/create_filter_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/create_filter_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; @@ -42,7 +42,6 @@ void main() { await service.deleteFilter( fieldId: textField.id, filterId: filterInfo.filter.id, - fieldType: textField.fieldType, ); await gridResponseFuture(); diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_menu_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_menu_test.dart index 696f61f7c91f9..a5aa0c9f58dbf 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_menu_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_menu_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_menu_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,10 +13,10 @@ void main() { test('test filter menu after create a text filter)', () async { final context = await gridTest.createTestGrid(); - final menuBloc = GridFilterMenuBloc( + final menuBloc = DatabaseFilterMenuBloc( viewId: context.gridView.id, fieldController: context.fieldController, - )..add(const GridFilterMenuEvent.initial()); + )..add(const DatabaseFilterMenuEvent.initial()); await gridResponseFuture(); assert(menuBloc.state.creatableFields.length == 3); @@ -28,15 +28,15 @@ void main() { content: "", ); await gridResponseFuture(); - assert(menuBloc.state.creatableFields.length == 2); + assert(menuBloc.state.creatableFields.length == 3); }); test('test filter menu after update existing text filter)', () async { final context = await gridTest.createTestGrid(); - final menuBloc = GridFilterMenuBloc( + final menuBloc = DatabaseFilterMenuBloc( viewId: context.gridView.id, fieldController: context.fieldController, - )..add(const GridFilterMenuEvent.initial()); + )..add(const DatabaseFilterMenuEvent.initial()); await gridResponseFuture(); final service = FilterBackendService(viewId: context.gridView.id); @@ -55,13 +55,13 @@ void main() { await service.insertTextFilter( fieldId: textField.id, filterId: textFilter.filter.id, - condition: TextFilterConditionPB.Is, + condition: TextFilterConditionPB.TextIs, content: "ABC", ); await gridResponseFuture(); assert( menuBloc.state.filters.first.textFilter()!.condition == - TextFilterConditionPB.Is, + TextFilterConditionPB.TextIs, ); assert(menuBloc.state.filters.first.textFilter()!.content == "ABC"); }); diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart index 006b1cccf298f..675ee8fc5729a 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_checkbox_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart index e2243f0339847..0af6b180920bb 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_rows_by_text_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/plugins/database/application/filter/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -37,7 +37,6 @@ void main() { await service.deleteFilter( fieldId: textField.id, filterId: textFilter.filter.id, - fieldType: textField.fieldType, ); await gridResponseFuture(); assert(context.rowInfos.length == 3); @@ -65,7 +64,6 @@ void main() { await service.deleteFilter( fieldId: textField.id, filterId: textFilter.filter.id, - fieldType: textField.fieldType, ); await gridResponseFuture(); assert(context.rowInfos.length == 3); @@ -107,7 +105,6 @@ void main() { await service.deleteFilter( fieldId: textField.id, filterId: textFilter.filter.id, - fieldType: textField.fieldType, ); await gridResponseFuture(); assert(context.rowInfos.length == 3); @@ -121,7 +118,7 @@ void main() { // create a new filter await service.insertTextFilter( fieldId: textField.id, - condition: TextFilterConditionPB.Is, + condition: TextFilterConditionPB.TextIs, content: "A", ); await gridResponseFuture(); @@ -135,7 +132,7 @@ void main() { await service.insertTextFilter( fieldId: textField.id, filterId: textFilter.filter.id, - condition: TextFilterConditionPB.Is, + condition: TextFilterConditionPB.TextIs, content: "B", ); await gridResponseFuture(); @@ -145,7 +142,7 @@ void main() { await service.insertTextFilter( fieldId: textField.id, filterId: textFilter.filter.id, - condition: TextFilterConditionPB.Is, + condition: TextFilterConditionPB.TextIs, content: "b", ); await gridResponseFuture(); @@ -155,7 +152,7 @@ void main() { await service.insertTextFilter( fieldId: textField.id, filterId: textFilter.filter.id, - condition: TextFilterConditionPB.Is, + condition: TextFilterConditionPB.TextIs, content: "C", ); await gridResponseFuture(); @@ -165,7 +162,6 @@ void main() { await service.deleteFilter( fieldId: textField.id, filterId: textFilter.filter.id, - fieldType: textField.fieldType, ); await gridResponseFuture(); assert(context.rowInfos.length == 3); diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart index dba8f077097a5..e9ae25b96fb3d 100644 --- a/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart @@ -1,7 +1,8 @@ -import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + import 'util.dart'; void main() { @@ -17,7 +18,8 @@ void main() { context = await gridTest.createTestGrid(); }); - // The initial number of rows is 3 for each grid. + // The initial number of rows is 3 for each grid + // We create one row so we expect 4 rows blocTest( "create a row", build: () => GridBloc( 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 f6cb9308e9695..cd67f1b889c07 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,7 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; -import 'package:appflowy/plugins/database/application/field/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; 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 ec7a357ee6e1d..1027d2a71933e 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 @@ -38,8 +38,13 @@ void main() { final appBloc = ViewBloc(view: app)..add(const ViewEvent.initial()); assert(appBloc.state.lastCreatedView == null); - appBloc - .add(const ViewEvent.createView("New document", ViewLayoutPB.Document)); + appBloc.add( + const ViewEvent.createView( + "New document", + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); await blocResponseFuture(); assert(appBloc.state.lastCreatedView != null); 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 deleted file mode 100644 index cae6493ed41a0..0000000000000 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/menu_bloc_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../util.dart'; - -void main() { - late AppFlowyUnitTest testContext; - setUpAll(() async { - testContext = await AppFlowyUnitTest.ensureInitialized(); - }); - - test('assert initial apps is the build-in app', () async { - final menuBloc = MenuBloc( - user: testContext.userProfile, - workspaceId: testContext.currentWorkspace.id, - )..add(const MenuEvent.initial()); - await blocResponseFuture(); - - assert(menuBloc.state.views.length == 1); - }); - - test('reorder apps', () async { - final menuBloc = MenuBloc( - user: testContext.userProfile, - workspaceId: testContext.currentWorkspace.id, - )..add(const MenuEvent.initial()); - await blocResponseFuture(); - menuBloc.add(const MenuEvent.createApp("App 1")); - await blocResponseFuture(); - menuBloc.add(const MenuEvent.createApp("App 2")); - await blocResponseFuture(); - menuBloc.add(const MenuEvent.createApp("App 3")); - await blocResponseFuture(); - - assert(menuBloc.state.views[1].name == 'App 1'); - assert(menuBloc.state.views[2].name == 'App 2'); - assert(menuBloc.state.views[3].name == 'App 3'); - }); -} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart new file mode 100644 index 0000000000000..75ade70a87bfc --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util.dart'; + +void main() { + late AppFlowyUnitTest testContext; + setUpAll(() async { + testContext = await AppFlowyUnitTest.ensureInitialized(); + }); + + test('assert initial apps is the build-in app', () async { + final menuBloc = SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + testContext.userProfile, + testContext.currentWorkspace.id, + ), + ); + + await blocResponseFuture(); + + assert(menuBloc.state.section.publicViews.length == 1); + assert(menuBloc.state.section.privateViews.isEmpty); + }); + + test('create views', () async { + final menuBloc = SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + testContext.userProfile, + testContext.currentWorkspace.id, + ), + ); + await blocResponseFuture(); + + final names = ['View 1', 'View 2', 'View 3']; + for (final name in names) { + menuBloc.add( + SidebarSectionsEvent.createRootViewInSection( + name: name, + index: 0, + viewSection: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + } + + final reversedNames = names.reversed.toList(); + for (var i = 0; i < names.length; i++) { + assert( + menuBloc.state.section.publicViews[i].name == reversedNames[i], + ); + } + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart index 0bd464e1b02b7..189a32cbac2a2 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart @@ -22,6 +22,7 @@ class TrashTestContext { const ViewEvent.createView( "Document 1", ViewLayoutPB.Document, + section: ViewSectionPB.Public, ), ); await blocResponseFuture(); @@ -30,6 +31,7 @@ class TrashTestContext { const ViewEvent.createView( "Document 2", ViewLayoutPB.Document, + section: ViewSectionPB.Public, ), ); await blocResponseFuture(); @@ -38,6 +40,7 @@ class TrashTestContext { const ViewEvent.createView( "Document 3", ViewLayoutPB.Document, + section: ViewSectionPB.Public, ), ); await blocResponseFuture(); diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart index f70a8e5ec1bab..868a003d5b98b 100644 --- a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart @@ -36,7 +36,11 @@ void main() { final viewBloc = await createTestViewBloc(); // create a nested view viewBloc.add( - const ViewEvent.createView(name, ViewLayoutPB.Document), + const ViewEvent.createView( + name, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), ); await blocResponseFuture(); expect(viewBloc.state.view.childViews.length, 1); @@ -52,7 +56,11 @@ void main() { test('delete view test', () async { final viewBloc = await createTestViewBloc(); viewBloc.add( - const ViewEvent.createView(name, ViewLayoutPB.Document), + const ViewEvent.createView( + name, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), ); await blocResponseFuture(); expect(viewBloc.state.view.childViews.length, 1); @@ -69,7 +77,11 @@ void main() { test('create nested view test', () async { final viewBloc = await createTestViewBloc(); viewBloc.add( - const ViewEvent.createView('Document 1', ViewLayoutPB.Document), + const ViewEvent.createView( + 'Document 1', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), ); await blocResponseFuture(); final document1Bloc = ViewBloc(view: viewBloc.state.view.childViews.first) @@ -79,7 +91,11 @@ void main() { await blocResponseFuture(); const name = 'Document 1 - 1'; document1Bloc.add( - const ViewEvent.createView('Document 1 - 1', ViewLayoutPB.Document), + const ViewEvent.createView( + 'Document 1 - 1', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), ); await blocResponseFuture(); expect(document1Bloc.state.view.childViews.length, 1); @@ -91,7 +107,11 @@ void main() { final names = ['1', '2', '3']; for (final name in names) { viewBloc.add( - ViewEvent.createView(name, ViewLayoutPB.Document), + ViewEvent.createView( + name, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), ); await blocResponseFuture(); } @@ -106,7 +126,13 @@ void main() { final viewBloc = await createTestViewBloc(); expect(viewBloc.state.lastCreatedView, isNull); - viewBloc.add(const ViewEvent.createView('1', ViewLayoutPB.Document)); + viewBloc.add( + const ViewEvent.createView( + '1', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); await blocResponseFuture(); expect( viewBloc.state.lastCreatedView!.id, @@ -117,7 +143,13 @@ void main() { '1', ); - viewBloc.add(const ViewEvent.createView('2', ViewLayoutPB.Document)); + viewBloc.add( + const ViewEvent.createView( + '2', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); await blocResponseFuture(); expect( viewBloc.state.lastCreatedView!.name, @@ -128,13 +160,25 @@ void main() { test('open latest document test', () async { const name1 = 'document'; final viewBloc = await createTestViewBloc(); - viewBloc.add(const ViewEvent.createView(name1, ViewLayoutPB.Document)); + viewBloc.add( + const ViewEvent.createView( + name1, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); await blocResponseFuture(); final document = viewBloc.state.lastCreatedView!; assert(document.name == name1); const gird = 'grid'; - viewBloc.add(const ViewEvent.createView(gird, ViewLayoutPB.Document)); + viewBloc.add( + const ViewEvent.createView( + gird, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); await blocResponseFuture(); assert(viewBloc.state.lastCreatedView!.name == gird); @@ -170,7 +214,11 @@ void main() { for (var i = 0; i < layouts.length; i++) { final layout = layouts[i]; viewBloc.add( - ViewEvent.createView('Test $layout', layout), + ViewEvent.createView( + 'Test $layout', + layout, + section: ViewSectionPB.Public, + ), ); await blocResponseFuture(); expect(viewBloc.state.view.childViews.length, i + 1); diff --git a/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart index f15bd16b9359d..0f189cbee5200 100644 --- a/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart @@ -1,7 +1,8 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:mocktail/mocktail.dart'; diff --git a/frontend/appflowy_flutter/test/util.dart b/frontend/appflowy_flutter/test/util.dart index eb42a075968b8..65303cb7898e4 100644 --- a/frontend/appflowy_flutter/test/util.dart +++ b/frontend/appflowy_flutter/test/util.dart @@ -47,13 +47,13 @@ class AppFlowyUnitTest { email: userEmail, ); result.fold( - (error) { - assert(false, 'Error: $error'); - }, (user) { userProfile = user; userService = UserBackendService(userId: userProfile.id); }, + (error) { + assert(false, 'Error: $error'); + }, ); } @@ -74,7 +74,10 @@ class AppFlowyUnitTest { } Future createWorkspace() async { - final result = await workspaceService.createApp(name: "Test App"); + final result = await workspaceService.createView( + name: "Test App", + viewSection: ViewSectionPB.Public, + ); return result.fold( (app) => app, (error) => throw Exception(error), @@ -82,7 +85,7 @@ class AppFlowyUnitTest { } Future> loadApps() async { - final result = await workspaceService.getViews(); + final result = await workspaceService.getPublicViews(); return result.fold( (apps) => apps, diff --git a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart index 7c89fd3d7d4e0..12b2a8e151b8a 100644 --- a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart +++ b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart @@ -1,9 +1,10 @@ -import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; diff --git a/frontend/appflowy_tauri/.gitignore b/frontend/appflowy_tauri/.gitignore index 6a6338d33e5a4..32a3d59bc297a 100644 --- a/frontend/appflowy_tauri/.gitignore +++ b/frontend/appflowy_tauri/.gitignore @@ -28,4 +28,6 @@ dist-ssr **/src/appflowy_app/i18n/translations/ coverage -**/AppFlowy-Collab \ No newline at end of file +**/AppFlowy-Collab + +.env \ No newline at end of file diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index 87249b6f2c058..30c79787715ab 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -52,6 +52,7 @@ "react-beautiful-dnd": "^13.1.1", "react-big-calendar": "^1.8.5", "react-color": "^2.19.3", + "react-custom-scrollbars": "^4.2.1", "react-datepicker": "^4.23.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", @@ -73,6 +74,7 @@ "slate-history": "^0.100.0", "slate-react": "^0.101.3", "ts-results": "^3.3.0", + "unsplash-js": "^7.0.19", "utf8": "^3.0.0", "valtio": "^1.12.1", "yjs": "^13.5.51" @@ -91,6 +93,7 @@ "@types/react": "^18.0.15", "@types/react-beautiful-dnd": "^13.1.3", "@types/react-color": "^3.0.6", + "@types/react-custom-scrollbars": "^4.0.13", "@types/react-datepicker": "^4.19.3", "@types/react-dom": "^18.0.6", "@types/react-katex": "^3.0.0", diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index b2b708cb99d2e..d670b8b312483 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -103,6 +103,9 @@ dependencies: react-color: specifier: ^2.19.3 version: 2.19.3(react@18.2.0) + react-custom-scrollbars: + specifier: ^4.2.1 + version: 4.2.1(react-dom@18.2.0)(react@18.2.0) react-datepicker: specifier: ^4.23.0 version: 4.23.0(react-dom@18.2.0)(react@18.2.0) @@ -166,6 +169,9 @@ dependencies: ts-results: specifier: ^3.3.0 version: 3.3.0 + unsplash-js: + specifier: ^7.0.19 + version: 7.0.19 utf8: specifier: ^3.0.0 version: 3.0.0 @@ -216,6 +222,9 @@ devDependencies: '@types/react-color': specifier: ^3.0.6 version: 3.0.6 + '@types/react-custom-scrollbars': + specifier: ^4.0.13 + version: 4.0.13 '@types/react-datepicker': specifier: ^4.19.3 version: 4.19.3(react-dom@18.2.0)(react@18.2.0) @@ -2343,6 +2352,12 @@ packages: '@types/reactcss': 1.2.6 dev: true + /@types/react-custom-scrollbars@4.0.13: + resolution: {integrity: sha512-t+15reWgAE1jXlrhaZoxjuH/SQf+EG0rzAzSCzTIkSiP5CDT7KhoExNPwIa6uUxtPkjc3gdW/ry7GetLEwCfGA==} + dependencies: + '@types/react': 18.2.6 + dev: true + /@types/react-datepicker@4.19.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-85F9eKWu9fGiD9r4KVVMPYAdkJJswR3Wci9PvqplmB6T+D+VbUqPeKtifg96NZ4nEhufjehW+SX4JLrEWVplWw==} dependencies: @@ -2652,6 +2667,10 @@ packages: hasBin: true dev: true + /add-px-to-style@1.0.0: + resolution: {integrity: sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew==} + dev: false + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3340,6 +3359,14 @@ packages: esutils: 2.0.3 dev: true + /dom-css@2.1.0: + resolution: {integrity: sha512-w9kU7FAbaSh3QKijL6n59ofAhkkmMJ31GclJIz/vyQdjogfyxcB6Zf8CZyibOERI5o0Hxz30VmJS7+7r5fEj2Q==} + dependencies: + add-px-to-style: 1.0.0 + prefix-style: 2.0.1 + to-camel-case: 1.0.0 + dev: false + /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: @@ -5423,6 +5450,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + /performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + dev: false + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -5515,6 +5546,10 @@ packages: source-map-js: 1.0.2 dev: true + /prefix-style@2.0.1: + resolution: {integrity: sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ==} + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5683,6 +5718,12 @@ packages: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} dev: false + /raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + dependencies: + performance-now: 2.1.0 + dev: false + /react-beautiful-dnd@13.1.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} peerDependencies: @@ -5743,6 +5784,19 @@ packages: tinycolor2: 1.6.0 dev: false + /react-custom-scrollbars@4.2.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-VtJTUvZ7kPh/auZWIbBRceGPkE30XBYe+HktFxuMWBR2eVQQ+Ur6yFJMoaYcNpyGq22uYJ9Wx4UAEcC0K+LNPQ==} + peerDependencies: + react: ^0.14.0 || ^15.0.0 || ^16.0.0 + react-dom: ^0.14.0 || ^15.0.0 || ^16.0.0 + dependencies: + dom-css: 2.1.0 + prop-types: 15.8.1 + raf: 3.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-datepicker@4.23.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-w+msqlOZ14v6H1UknTKtZw/dw9naFMgAOspf59eY130gWpvy5dvKj/bgsFICDdvxB7PtKWxDcbGlAqCloY1d2A==} peerDependencies: @@ -6624,16 +6678,32 @@ packages: /tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + /to-camel-case@1.0.0: + resolution: {integrity: sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q==} + dependencies: + to-space-case: 1.0.0 + dev: false + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} + /to-no-case@1.0.2: + resolution: {integrity: sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==} + dev: false + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 + /to-space-case@1.0.0: + resolution: {integrity: sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==} + dependencies: + to-no-case: 1.0.2 + dev: false + /tough-cookie@4.1.3: resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} engines: {node: '>=6'} @@ -6853,6 +6923,11 @@ packages: engines: {node: '>= 10.0.0'} dev: true + /unsplash-js@7.0.19: + resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==} + engines: {node: '>=10'} + dev: false + /update-browserslist-db@1.0.11(browserslist@4.21.5): resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt b/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt new file mode 100644 index 0000000000000..246c977c9f2ba --- /dev/null +++ b/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf new file mode 100644 index 0000000000000..71c0f995ee643 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf new file mode 100644 index 0000000000000..7aeb58bd1b943 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf new file mode 100644 index 0000000000000..00559eeb290fb Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf new file mode 100644 index 0000000000000..e61e8e88bdc98 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf new file mode 100644 index 0000000000000..df7093608a7eb Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf new file mode 100644 index 0000000000000..14d2b375dc0c2 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf new file mode 100644 index 0000000000000..e76ec69a650f1 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf new file mode 100644 index 0000000000000..89513d94693ae Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf new file mode 100644 index 0000000000000..12b7b3c40b5c8 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf new file mode 100644 index 0000000000000..bc36bcc2427a8 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf new file mode 100644 index 0000000000000..9e70be6a9ef05 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf new file mode 100644 index 0000000000000..6bcdcc27f22e0 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf new file mode 100644 index 0000000000000..be67410fd0a5a Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf new file mode 100644 index 0000000000000..9f0c71b70a496 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf new file mode 100644 index 0000000000000..74c726e32781b Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf new file mode 100644 index 0000000000000..3e6c942233c69 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf new file mode 100644 index 0000000000000..03e736613a750 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf new file mode 100644 index 0000000000000..e26db5dd3dbab Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/LICENSE.txt b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/LICENSE.txt new file mode 100644 index 0000000000000..75b52484ea471 --- /dev/null +++ b/frontend/appflowy_tauri/public/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_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf new file mode 100644 index 0000000000000..61e5303325a1b Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000..6df2b25360309 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/launch_splash.jpg b/frontend/appflowy_tauri/public/launch_splash.jpg similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/assets/launch_splash.jpg rename to frontend/appflowy_tauri/public/launch_splash.jpg diff --git a/frontend/appflowy_tauri/scripts/update_version.cjs b/frontend/appflowy_tauri/scripts/update_version.cjs new file mode 100644 index 0000000000000..498b8c3e4f413 --- /dev/null +++ b/frontend/appflowy_tauri/scripts/update_version.cjs @@ -0,0 +1,31 @@ +const fs = require('fs'); +const path = require('path'); + +if (process.argv.length < 3) { + console.error('Usage: node update-tauri-version.js '); + process.exit(1); +} + +const newVersion = process.argv[2]; + +const tauriConfigPath = path.join(__dirname, '../src-tauri', 'tauri.conf.json'); + +fs.readFile(tauriConfigPath, 'utf8', (err, data) => { + if (err) { + console.error('Error reading tauri.conf.json:', err); + return; + } + + const config = JSON.parse(data); + + config.package.version = newVersion; + + fs.writeFile(tauriConfigPath, JSON.stringify(config, null, 2), 'utf8', (err) => { + if (err) { + console.error('Error writing tauri.conf.json:', err); + return; + } + + console.log(`Tauri version updated to ${newVersion} successfully.`); + }); +}); diff --git a/frontend/appflowy_tauri/src-tauri/.gitignore b/frontend/appflowy_tauri/src-tauri/.gitignore index f4dfb82b2cf3d..61e1bdd46ac23 100644 --- a/frontend/appflowy_tauri/src-tauri/.gitignore +++ b/frontend/appflowy_tauri/src-tauri/.gitignore @@ -1,4 +1,4 @@ # Generated by Cargo # will have compiled files and executables /target/ - +.env diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 12b1f0adaf76d..0d8588cbec25e 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -162,7 +162,7 @@ checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", @@ -182,6 +182,7 @@ name = "appflowy_tauri" version = "0.0.0" dependencies = [ "bytes", + "dotenv", "flowy-config", "flowy-core", "flowy-date", @@ -194,6 +195,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-utils", "tracing", "uuid", @@ -714,7 +716,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "again", "anyhow", @@ -724,6 +726,7 @@ dependencies = [ "brotli", "bytes", "chrono", + "client-websocket", "collab", "collab-entity", "database-entity", @@ -741,6 +744,7 @@ dependencies = [ "realtime-protocol", "reqwest", "scraper 0.17.1", + "semver", "serde", "serde_json", "serde_repr", @@ -753,11 +757,28 @@ dependencies = [ "url", "uuid", "wasm-bindgen-futures", - "websocket", "workspace-template", "yrs", ] +[[package]] +name = "client-websocket" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" +dependencies = [ + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "percent-encoding", + "thiserror", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "cmd_lib" version = "1.3.0" @@ -817,12 +838,13 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-trait", "bincode", "bytes", + "chrono", "js-sys", "parking_lot 0.12.1", "serde", @@ -832,6 +854,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "unicode-segmentation", "web-sys", "yrs", ] @@ -839,7 +862,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-trait", @@ -853,6 +876,7 @@ dependencies = [ "lru", "nanoid", "parking_lot 0.12.1", + "rayon", "serde", "serde_json", "serde_repr", @@ -868,7 +892,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "collab", @@ -887,7 +911,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "bytes", @@ -902,7 +926,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "chrono", @@ -928,6 +952,7 @@ dependencies = [ "collab", "collab-entity", "collab-plugins", + "futures", "lib-infra", "parking_lot 0.12.1", "serde", @@ -939,7 +964,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-stream", @@ -978,7 +1003,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "collab", @@ -1312,7 +1337,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -1436,6 +1461,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1446,6 +1480,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1463,6 +1509,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "dtoa" version = "1.0.6" @@ -1743,6 +1795,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "sysinfo", "tokio", "tokio-stream", "tracing", @@ -1909,6 +1962,7 @@ dependencies = [ "client-api", "collab-database", "collab-document", + "collab-folder", "collab-plugins", "fancy-regex 0.11.0", "flowy-codegen", @@ -2017,6 +2071,7 @@ dependencies = [ "mime_guess", "parking_lot 0.12.1", "postgrest", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -2584,7 +2639,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "futures-util", @@ -2601,7 +2656,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -3056,7 +3111,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "reqwest", @@ -3082,6 +3137,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + [[package]] name = "ipnet" version = "2.8.0" @@ -3713,6 +3781,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3805,6 +3882,39 @@ dependencies = [ "objc_exception", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c71324e4180d0899963fc83d9d241ac39e699609fc1025a850aadac8257459" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + [[package]] name = "objc_exception" version = "0.1.2" @@ -3908,6 +4018,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "0.9.2" @@ -4757,9 +4873,9 @@ checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" [[package]] name = "rayon" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd" dependencies = [ "either", "rayon-core", @@ -4767,24 +4883,23 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ - "crossbeam-channel", "crossbeam-deque", "crossbeam-utils", - "num_cpus", ] [[package]] name = "realtime-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", "bytes", + "client-websocket", "collab", "collab-entity", "database-entity", @@ -4794,16 +4909,16 @@ dependencies = [ "realtime-protocol", "serde", "serde_json", + "serde_repr", "thiserror", "tokio-tungstenite", - "websocket", "yrs", ] [[package]] name = "realtime-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", @@ -4946,6 +5061,30 @@ dependencies = [ "winreg 0.50.0", ] +[[package]] +name = "rfd" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea" +dependencies = [ + "block", + "dispatch", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "lazy_static", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.37.0", +] + [[package]] name = "ring" version = "0.16.20" @@ -5272,9 +5411,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.17" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" dependencies = [ "serde", ] @@ -5451,7 +5590,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -5717,6 +5856,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.30.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb4f3438c8f6389c864e61221cbc97e9bca98b4daf39a5beb7bea660f528bb2" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows 0.52.0", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -5871,6 +6025,7 @@ dependencies = [ "rand 0.8.5", "raw-window-handle", "regex", + "rfd", "semver", "serde", "serde_json", @@ -5951,6 +6106,22 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4536f5f6602e8fdfaa7b3b185076c2a0704f8eb7015f4e58461eb483ec3ed1f8" +dependencies = [ + "dirs", + "interprocess", + "log", + "objc2", + "once_cell", + "tauri-utils", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "tauri-runtime" version = "0.14.1" @@ -6177,11 +6348,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + [[package]] name = "tokio" -version = "1.35.1" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", "bytes", @@ -6928,23 +7105,6 @@ version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" -[[package]] -name = "websocket" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" -dependencies = [ - "futures-channel", - "futures-util", - "http", - "httparse", - "js-sys", - "thiserror", - "tokio", - "tokio-tungstenite", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "webview2-com" version = "0.19.1" @@ -7025,6 +7185,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +dependencies = [ + "windows_aarch64_msvc 0.37.0", + "windows_i686_gnu 0.37.0", + "windows_i686_msvc 0.37.0", + "windows_x86_64_gnu 0.37.0", + "windows_x86_64_msvc 0.37.0", +] + [[package]] name = "windows" version = "0.39.0" @@ -7048,6 +7221,16 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.0", +] + [[package]] name = "windows-bindgen" version = "0.39.0" @@ -7058,6 +7241,15 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + [[package]] name = "windows-implement" version = "0.39.0" @@ -7161,6 +7353,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +[[package]] +name = "windows_aarch64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" + [[package]] name = "windows_aarch64_msvc" version = "0.39.0" @@ -7185,6 +7383,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +[[package]] +name = "windows_i686_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" + [[package]] name = "windows_i686_gnu" version = "0.39.0" @@ -7209,6 +7413,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +[[package]] +name = "windows_i686_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" + [[package]] name = "windows_i686_msvc" version = "0.39.0" @@ -7233,6 +7443,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +[[package]] +name = "windows_x86_64_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" + [[package]] name = "windows_x86_64_gnu" version = "0.39.0" @@ -7275,6 +7491,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +[[package]] +name = "windows_x86_64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" + [[package]] name = "windows_x86_64_msvc" version = "0.39.0" @@ -7331,7 +7553,7 @@ dependencies = [ [[package]] name = "workspace-template" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "async-trait", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 91ad303b121cd..f393cc77c0afe 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ lru = "0.12.0" serde_json.workspace = true serde.workspace = true tauri = { version = "1.5", features = [ + "dialog-all", "clipboard-all", "fs-all", "shell-open", @@ -66,7 +67,10 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ "tauri_ts", ] } + uuid = "1.5.0" +tauri-plugin-deep-link = "0.1.2" +dotenv = "0.15.0" [features] # by default Tauri runs in production mode @@ -82,7 +86,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 = "29a0851f485957cc6410ccf9d261c781c1d2f757" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ab9496c248b7c733d1aa160062abeb66c4e41325" } # Please use the following script to update collab. # Working directory: frontend # @@ -92,10 +96,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "29a # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } diff --git a/frontend/appflowy_tauri/src-tauri/Info.plist b/frontend/appflowy_tauri/src-tauri/Info.plist new file mode 100644 index 0000000000000..25b430c049f36 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/Info.plist @@ -0,0 +1,19 @@ + + + + + + CFBundleURLTypes + + + CFBundleURLName + + appflowy-flutter + CFBundleURLSchemes + + appflowy-flutter + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src-tauri/env.development b/frontend/appflowy_tauri/src-tauri/env.development new file mode 100644 index 0000000000000..188835e3d05be --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/env.development @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://test.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://test.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://test.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_tauri/src-tauri/env.production b/frontend/appflowy_tauri/src-tauri/env.production new file mode 100644 index 0000000000000..b03c328b84eac --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/env.production @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://beta.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://beta.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://beta.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_tauri/src-tauri/src/init.rs b/frontend/appflowy_tauri/src-tauri/src/init.rs index 7f7c2726d3e35..40c0e5d47b050 100644 --- a/frontend/appflowy_tauri/src-tauri/src/init.rs +++ b/frontend/appflowy_tauri/src-tauri/src/init.rs @@ -3,10 +3,33 @@ use flowy_core::{AppFlowyCore, DEFAULT_NAME}; use lib_dispatch::runtime::AFPluginRuntime; use std::sync::Arc; +use dotenv::dotenv; + +pub fn read_env() { + dotenv().ok(); + + let env = if cfg!(debug_assertions) { + include_str!("../env.development") + } else { + include_str!("../env.production") + }; + + for line in env.lines() { + if let Some((key, value)) = line.split_once('=') { + // Check if the environment variable is not already set in the system + let current_value = std::env::var(key).unwrap_or_default(); + if current_value.is_empty() { + std::env::set_var(key, value); + } + } + } +} + pub fn init_flowy_core() -> AppFlowyCore { let config_json = include_str!("../tauri.conf.json"); let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); + let app_version = config.package.version.clone().map(|v| v.to_string()).unwrap_or_else(|| "0.0.0".to_string()); let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap(); if cfg!(debug_assertions) { data_path.push("data_dev"); @@ -18,10 +41,11 @@ pub fn init_flowy_core() -> AppFlowyCore { let application_path = data_path.to_str().unwrap().to_string(); let device_id = uuid::Uuid::new_v4().to_string(); + read_env(); std::env::set_var("RUST_LOG", "trace"); - // TODO(nathan): pass the real version here + let config = AppFlowyCoreConfig::new( - "1.0.0".to_string(), + app_version, custom_application_path, application_path, device_id, diff --git a/frontend/appflowy_tauri/src-tauri/src/main.rs b/frontend/appflowy_tauri/src-tauri/src/main.rs index edc59ed2409c8..6a69de07fd322 100644 --- a/frontend/appflowy_tauri/src-tauri/src/main.rs +++ b/frontend/appflowy_tauri/src-tauri/src/main.rs @@ -3,6 +3,10 @@ windows_subsystem = "windows" )] +#[allow(dead_code)] +pub const DEEP_LINK_SCHEME: &str = "appflowy-flutter"; +pub const OPEN_DEEP_LINK: &str = "open_deep_link"; + mod init; mod notification; mod request; @@ -12,8 +16,11 @@ use init::*; use notification::*; use request::*; use tauri::Manager; +extern crate dotenv; fn main() { + tauri_plugin_deep_link::prepare(DEEP_LINK_SCHEME); + let flowy_core = init_flowy_core(); tauri::Builder::default() .invoke_handler(tauri::generate_handler![invoke_request]) @@ -26,16 +33,37 @@ fn main() { unregister_all_notification_sender(); register_notification_sender(TSNotificationSender::new(app_handler.clone())); // tauri::async_runtime::spawn(async move {}); + window.listen_global(AF_EVENT, move |event| { on_event(app_handler.clone(), event); }); }) - .setup(|app| { - #[cfg(debug_assertions)] - { - let window = app.get_window("main").unwrap(); - window.open_devtools(); - } + .setup(|_app| { + let splashscreen_window = _app.get_window("splashscreen").unwrap(); + let window = _app.get_window("main").unwrap(); + let handle = _app.handle(); + + // we perform the initialization code on a new task so the app doesn't freeze + tauri::async_runtime::spawn(async move { + // initialize your app here instead of sleeping :) + std::thread::sleep(std::time::Duration::from_secs(2)); + + // After it's done, close the splashscreen and display the main window + splashscreen_window.close().unwrap(); + window.show().unwrap(); + // If you need macOS support this must be called in .setup() ! + // Otherwise this could be called right after prepare() but then you don't have access to tauri APIs + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + tauri_plugin_deep_link::register( + DEEP_LINK_SCHEME, + move |request| { + dbg!(&request); + handle.emit_all(OPEN_DEEP_LINK, request).unwrap(); + }, + ) + .unwrap(/* If listening to the scheme is optional for your app, you don't want to unwrap here. */); + }); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json index c0da4386af11d..11dd7c206c463 100644 --- a/frontend/appflowy_tauri/src-tauri/tauri.conf.json +++ b/frontend/appflowy_tauri/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "AppFlowy", - "version": "0.0.0" + "version": "0.0.1" }, "tauri": { "allowlist": { @@ -20,8 +20,7 @@ "fs": { "all": true, "scope": [ - "$APPLOCALDATA/**", - "$APPLOCALDATA/images/*" + "$APPLOCALDATA/**" ], "readFile": true, "writeFile": true, @@ -37,6 +36,14 @@ "all": true, "writeText": true, "readText": true + }, + "dialog": { + "all": true, + "ask": true, + "confirm": true, + "message": true, + "open": true, + "save": true } }, "bundle": { @@ -83,11 +90,24 @@ { "fileDropEnabled": false, "fullscreen": false, - "height": 1200, + "height": 800, "resizable": true, "title": "AppFlowy", - "width": 1200 + "width": 1200, + "minWidth": 800, + "minHeight": 600, + "visible": false, + "label": "main" + }, + { + "height": 300, + "width": 549, + "decorations": false, + "url": "launch_splash.jpg", + "label": "splashscreen", + "center": true, + "visible": true } ] } -} +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts index 8d4088ca25ec8..9c46b8ab38343 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts @@ -1,6 +1,6 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useEffect, useMemo } from 'react'; -import { currentUserActions } from '$app_reducers/current-user/slice'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; import { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice'; import { createTheme } from '@mui/material/styles'; import { getDesignTokens } from '$app/utils/mui'; @@ -10,15 +10,21 @@ import { UserService } from '$app/application/user/user.service'; export function useUserSetting() { const dispatch = useAppDispatch(); const { i18n } = useTranslation(); - const { - themeMode = ThemeMode.System, - isDark = false, - theme: themeType = ThemeType.Default, - } = useAppSelector((state) => { - return state.currentUser.userSetting || {}; + const loginState = useAppSelector((state) => state.currentUser.loginState); + + const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => { + return { + themeMode: state.currentUser.userSetting.themeMode, + theme: state.currentUser.userSetting.theme, + }; }); + const isDark = + themeMode === ThemeMode.Dark || + (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); + useEffect(() => { + if (loginState !== LoginState.Success && loginState !== undefined) return; void (async () => { const settings = await UserService.getAppearanceSetting(); @@ -26,7 +32,7 @@ export function useUserSetting() { dispatch(currentUserActions.setUserSetting(settings)); await i18n.changeLanguage(settings.language); })(); - }, [dispatch, i18n]); + }, [dispatch, i18n, loginState]); useEffect(() => { const html = document.documentElement; diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx index c418791461282..76bdb167b0523 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx @@ -7,6 +7,8 @@ import { ThemeProvider } from '@mui/material'; import { useUserSetting } from '$app/AppMain.hooks'; import TrashPage from '$app/views/TrashPage'; import DocumentPage from '$app/views/DocumentPage'; +import { Toaster } from 'react-hot-toast'; +import AppFlowyDevTool from '$app/components/_shared/devtool/AppFlowyDevTool'; function AppMain() { const { muiTheme } = useUserSetting(); @@ -20,6 +22,8 @@ function AppMain() { } /> + + {process.env.NODE_ENV === 'development' && } ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts index edb51fc97ee20..950f5becb3bfc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts @@ -132,5 +132,9 @@ export async function updateDateCell( const result = await DatabaseEventUpdateDateCell(payload); - return result.unwrap(); + if (!result.ok) { + return Promise.reject(typeof result.val.msg === 'string' ? result.val.msg : 'Unknown error'); + } + + return result.val; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts index 559ed32b7ac6a..74ebfb1df06d3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts @@ -26,6 +26,8 @@ export async function getDatabase(viewId: string) { const result = await DatabaseEventGetDatabase(payload); + if (!result.ok) return Promise.reject('Failed to get database'); + return result .map((value) => { return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts index 5d9c9f9be0c46..72526b577f46c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts @@ -3,6 +3,7 @@ import { ChecklistFilterConditionPB, FieldType, NumberFilterConditionPB, + SelectOptionFilterConditionPB, TextFilterConditionPB, } from '@/services/backend'; import { UndeterminedFilter } from '$app/application/database'; @@ -12,7 +13,7 @@ export function getDefaultFilter(fieldType: FieldType): UndeterminedFilter['data case FieldType.RichText: case FieldType.URL: return { - condition: TextFilterConditionPB.Contains, + condition: TextFilterConditionPB.TextContains, content: '', }; case FieldType.Number: @@ -27,6 +28,14 @@ export function getDefaultFilter(fieldType: FieldType): UndeterminedFilter['data return { condition: ChecklistFilterConditionPB.IsIncomplete, }; + case FieldType.SingleSelect: + return { + condition: SelectOptionFilterConditionPB.OptionIs, + }; + case FieldType.MultiSelect: + return { + condition: SelectOptionFilterConditionPB.OptionContains, + }; default: return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts index 9a0cd46b2cf78..323f8dac82297 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts @@ -1,34 +1,8 @@ import { Database, pbToFilter } from '$app/application/database'; import { FilterChangesetNotificationPB } from '@/services/backend'; -const deleteFiltersFromChange = (database: Database, changeset: FilterChangesetNotificationPB) => { - const deleteIds = changeset.delete_filters.map((pb) => pb.id); - - if (deleteIds.length) { - database.filters = database.filters.filter((item) => !deleteIds.includes(item.id)); - } -}; - -const insertFiltersFromChange = (database: Database, changeset: FilterChangesetNotificationPB) => { - changeset.insert_filters.forEach((pb) => { - database.filters.push(pbToFilter(pb)); - }); -}; - -const updateFiltersFromChange = (database: Database, changeset: FilterChangesetNotificationPB) => { - changeset.update_filters.forEach((pb) => { - const found = database.filters.find((item) => item.id === pb.filter_id); - - if (found) { - const newFilter = pbToFilter(pb.filter); - - Object.assign(found, newFilter); - } - }); -}; - export const didUpdateFilter = (database: Database, changeset: FilterChangesetNotificationPB) => { - deleteFiltersFromChange(database, changeset); - insertFiltersFromChange(database, changeset); - updateFiltersFromChange(database, changeset); + const filters = changeset.filters.items.map((pb) => pbToFilter(pb)); + + database.filters = filters; }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts index 97fcb6e50539e..6283763d28112 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts @@ -21,22 +21,20 @@ export async function insertFilter({ fieldId, fieldType, data, - filterId, }: { viewId: string; fieldId: string; fieldType: FieldType; data?: UndeterminedFilter['data']; - filterId?: string; }): Promise { const payload = DatabaseSettingChangesetPB.fromObject({ view_id: viewId, - update_filter: { - view_id: viewId, - field_id: fieldId, - field_type: fieldType, - filter_id: filterId, - data: data ? filterDataToPB(data, fieldType)?.serialize() : undefined, + insert_filter: { + data: { + field_id: fieldId, + field_type: fieldType, + data: data ? filterDataToPB(data, fieldType)?.serialize() : undefined, + }, }, }); @@ -52,12 +50,13 @@ export async function insertFilter({ export async function updateFilter(viewId: string, filter: UndeterminedFilter): Promise { const payload = DatabaseSettingChangesetPB.fromObject({ view_id: viewId, - update_filter: { - view_id: viewId, + update_filter_data: { filter_id: filter.id, - field_id: filter.fieldId, - field_type: filter.fieldType, - data: filterDataToPB(filter.data, filter.fieldType)?.serialize(), + data: { + field_id: filter.fieldId, + field_type: filter.fieldType, + data: filterDataToPB(filter.data, filter.fieldType)?.serialize(), + }, }, }); @@ -74,10 +73,8 @@ export async function deleteFilter(viewId: string, filter: Omit) const payload = DatabaseSettingChangesetPB.fromObject({ view_id: viewId, delete_filter: { - view_id: viewId, filter_id: filter.id, field_id: filter.fieldId, - field_type: filter.fieldType, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts index 9e6f9f87ced0d..f9f80985e57be 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts @@ -5,7 +5,7 @@ import { FilterPB, NumberFilterConditionPB, NumberFilterPB, - SelectOptionConditionPB, + SelectOptionFilterConditionPB, SelectOptionFilterPB, TextFilterConditionPB, TextFilterPB, @@ -66,7 +66,7 @@ export interface ChecklistFilterData { } export interface SelectFilterData { - condition?: SelectOptionConditionPB; + condition?: SelectOptionFilterConditionPB; optionIds?: string[]; } @@ -195,8 +195,8 @@ export function bytesToFilterData(bytes: Uint8Array, fieldType: FieldType) { export function pbToFilter(pb: FilterPB): Filter { return { id: pb.id, - fieldId: pb.field_id, - fieldType: pb.field_type, - data: bytesToFilterData(pb.data, pb.field_type), + fieldId: pb.data.field_id, + fieldType: pb.data.field_type, + data: bytesToFilterData(pb.data.data, pb.data.field_type), }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts index ad0c8af5428dd..b75ecc0bd477a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts @@ -8,7 +8,6 @@ export interface GroupSetting { export interface Group { id: string; - name: string; isDefault: boolean; isVisible: boolean; fieldId: string; @@ -18,7 +17,6 @@ export interface Group { export function pbToGroup(pb: GroupPB): Group { return { id: pb.group_id, - name: pb.group_name, isDefault: pb.is_default, isVisible: pb.is_visible, fieldId: pb.field_id, diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts index acee8d141b1a0..e8a638403e379 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts @@ -2,6 +2,7 @@ import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChang import { Database } from '../database'; import { pbToRowMeta, RowMeta } from './row_types'; import { didDeleteCells } from '$app/application/database/cell/cell_listeners'; +import { getDatabase } from '$app/application/database/database/database_service'; const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { changeset.deleted_rows.forEach((rowId) => { @@ -15,12 +16,6 @@ const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => }); }; -const insertRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { - changeset.inserted_rows.forEach(({ index, row_meta: rowMetaPB }) => { - database.rowMetas.splice(index, 0, pbToRowMeta(rowMetaPB)); - }); -}; - const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => { const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); @@ -31,9 +26,15 @@ const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => }); }; -export const didUpdateViewRows = (database: Database, changeset: RowsChangePB) => { +export const didUpdateViewRows = async (viewId: string, database: Database, changeset: RowsChangePB) => { + if (changeset.inserted_rows.length > 0) { + const { rowMetas } = await getDatabase(viewId); + + database.rowMetas = rowMetas; + return; + } + deleteRowsFromChangeset(database, changeset); - insertRowsFromChangeset(database, changeset); updateRowsFromChangeset(database, changeset); }; @@ -56,18 +57,39 @@ export const didReorderSingleRow = (database: Database, changeset: ReorderSingle } }; -export const didUpdateViewRowsVisibility = (database: Database, changeset: RowsVisibilityChangePB) => { +export const didUpdateViewRowsVisibility = async ( + viewId: string, + database: Database, + changeset: RowsVisibilityChangePB +) => { const { invisible_rows, visible_rows } = changeset; - database.rowMetas.forEach((rowMeta) => { - if (invisible_rows.includes(rowMeta.id)) { + let reFetchRows = false; + + for (const rowId of invisible_rows) { + const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); + + if (rowMeta) { rowMeta.isHidden = true; } + } - const found = visible_rows.find((visibleRow) => visibleRow.row_meta.id === rowMeta.id); + for (const insertedRow of visible_rows) { + const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === insertedRow.row_meta.id); - if (found) { + if (rowMeta) { rowMeta.isHidden = false; + } else { + reFetchRows = true; + break; } - }); + } + + if (reFetchRows) { + const { rowMetas } = await getDatabase(viewId); + + database.rowMetas = rowMetas; + + await didUpdateViewRowsVisibility(viewId, database, changeset); + } }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts index 0e06199b89755..029da3b0c9646 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts @@ -30,7 +30,7 @@ export async function createRow(viewId: string, params?: { object_id: params?.rowId, }, group_id: params?.groupId, - data: params?.data ? { cell_data_by_field_id: params.data } : undefined, + data: params?.data, }); const result = await DatabaseEventCreateRow(payload); diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts index 3b8506360409b..0db128ec7ac51 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts @@ -262,9 +262,11 @@ function flattenBlockJson(block: BlockJSON) { slateNode.children = block.children.map((child) => traverse(child)); if (textNode) { - if (!LIST_TYPES.includes(block.type as EditorNodeType) && slateNode.type !== EditorNodeType.Page) { + const texts = CustomEditor.getNodeTextContent(textNode); + + if (texts && !LIST_TYPES.includes(block.type as EditorNodeType) && slateNode.type !== EditorNodeType.Page) { slateNode.children.unshift(textNode); - } else { + } else if (texts) { slateNode.children.unshift({ type: EditorNodeType.Paragraph, children: [textNode], diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts index 98b493581def0..e6eb1d6923864 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -2,7 +2,8 @@ import { Op } from 'quill-delta'; import { HTMLAttributes } from 'react'; import { Element } from 'slate'; import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend'; -import { YXmlText } from 'yjs/dist/src/types/YXmlText'; +import { PageCover } from '$app_reducers/pages/slice'; +import * as Y from 'yjs'; export interface EditorNode { id: string; @@ -73,6 +74,9 @@ export interface QuoteNode extends Element { export interface NumberedListNode extends Element { type: EditorNodeType.NumberedListBlock; blockId: string; + data: { + number?: number; + } & BlockData; } export interface BulletedListNode extends Element { @@ -109,6 +113,23 @@ export interface MathEquationNode extends Element { } & BlockData; } +export enum ImageType { + Local = 0, + Internal = 1, + External = 2, +} + +export interface ImageNode extends Element { + type: EditorNodeType.ImageBlock; + blockId: string; + data: { + url?: string; + width?: number; + image_type?: ImageType; + height?: number; + } & BlockData; +} + export interface FormulaNode extends Element { type: EditorInlineNodeType.Formula; data: string; @@ -146,11 +167,20 @@ export interface MentionPage { } export interface EditorProps { - id: string; - sharedType?: YXmlText; title?: string; + cover?: PageCover; onTitleChange?: (title: string) => void; + onCoverChange?: (cover?: PageCover) => void; showTitle?: boolean; + id: string; + disableFocus?: boolean; +} + +export interface LocalEditorProps { + disableFocus?: boolean; + sharedType: Y.XmlText; + id: string; + caretColor?: string; } export enum EditorNodeType { @@ -204,7 +234,9 @@ export enum MentionType { export interface Mention { // inline page ref id - page?: string; + page_id?: string; // reminder date ref id date?: string; + + type: MentionType; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts index 25aa0033a4280..7d988b9866d57 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts @@ -19,6 +19,7 @@ import { FolderEventMoveNestedView, FolderEventUpdateView, FolderEventUpdateViewIcon, + FolderEventSetLatestView, } from '@/services/backend/events/flowy-folder'; export async function getPage(id: string) { @@ -149,3 +150,17 @@ export const updatePageIcon = async (viewId: string, icon?: PageIcon) => { return Promise.reject(result.err); }; + +export async function setLatestOpenedPage(id: string) { + const payload = new ViewIdPB({ + value: id, + }); + + const res = await FolderEventSetLatestView(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts index 0a1ac683af7e5..fe066b73770de 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts @@ -1,5 +1,12 @@ -import { CreateViewPayloadPB, UserWorkspaceIdPB, WorkspaceIdPB } from '@/services/backend'; -import { UserEventOpenWorkspace } from '@/services/backend/events/flowy-user'; +import { parserViewPBToPage } from '$app_reducers/pages/slice'; +import { + ChangeWorkspaceIconPB, + CreateViewPayloadPB, + GetWorkspaceViewPB, + RenameWorkspacePB, + UserWorkspaceIdPB, + WorkspaceIdPB, +} from '@/services/backend'; import { FolderEventCreateView, FolderEventDeleteWorkspace, @@ -7,7 +14,12 @@ import { FolderEventReadCurrentWorkspace, FolderEventReadWorkspaceViews, } from '@/services/backend/events/flowy-folder'; -import { parserViewPBToPage } from '$app_reducers/pages/slice'; +import { + UserEventChangeWorkspaceIcon, + UserEventGetAllWorkspace, + UserEventOpenWorkspace, + UserEventRenameWorkspace, +} from '@/services/backend/events/flowy-user'; export async function openWorkspace(id: string) { const payload = new UserWorkspaceIdPB({ @@ -38,7 +50,7 @@ export async function deleteWorkspace(id: string) { } export async function getWorkspaceChildViews(id: string) { - const payload = new WorkspaceIdPB({ + const payload = new GetWorkspaceViewPB({ value: id, }); @@ -52,17 +64,13 @@ export async function getWorkspaceChildViews(id: string) { } export async function getWorkspaces() { - const result = await FolderEventReadCurrentWorkspace(); + const result = await UserEventGetAllWorkspace(); if (result.ok) { - const item = result.val; - - return [ - { - id: item.id, - name: item.name, - }, - ]; + return result.val.items.map((workspace) => ({ + id: workspace.workspace_id, + name: workspace.name, + })); } return []; @@ -82,12 +90,7 @@ export async function getCurrentWorkspace() { const result = await FolderEventReadCurrentWorkspace(); if (result.ok) { - const workspace = result.val; - - return { - id: workspace.id, - name: workspace.name, - }; + return result.val.id; } return null; @@ -101,9 +104,37 @@ export async function createCurrentWorkspaceChildView( const result = await FolderEventCreateView(payload); if (result.ok) { - const view = result.val; + return result.val; + } + + return Promise.reject(result.err); +} - return view; +export async function renameWorkspace(id: string, name: string) { + const payload = new RenameWorkspacePB({ + workspace_id: id, + new_name: name, + }); + + const result = await UserEventRenameWorkspace(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +} + +export async function changeWorkspaceIcon(id: string, icon: string) { + const payload = new ChangeWorkspaceIconPB({ + workspace_id: id, + new_icon: icon, + }); + + const result = await UserEventChangeWorkspaceIcon(payload); + + if (result.ok) { + return result.val; } return Promise.reject(result.err); diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts index 726bfabaec15f..c63a5d9823b18 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts @@ -22,7 +22,9 @@ import { ViewPB, RepeatedTrashPB, ChildViewUpdatePB, + WorkspacePB, } from '@/services/backend'; +import { AsyncQueue } from '$app/utils/async_queue'; const Notification = { [DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB, @@ -39,11 +41,12 @@ const Notification = { [DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB, [DatabaseNotification.DidUpdateFilter]: FilterChangesetNotificationPB, [DocumentNotification.DidReceiveUpdate]: DocEventPB, - [UserNotification.DidUpdateUserProfile]: UserProfilePB, + [FolderNotification.DidUpdateWorkspace]: WorkspacePB, [FolderNotification.DidUpdateWorkspaceViews]: RepeatedViewPB, [FolderNotification.DidUpdateView]: ViewPB, [FolderNotification.DidUpdateChildViews]: ChildViewUpdatePB, [FolderNotification.DidUpdateTrash]: RepeatedTrashPB, + [UserNotification.DidUpdateUserProfile]: UserProfilePB, }; type NotificationMap = typeof Notification; @@ -56,7 +59,9 @@ type NullableInstanceType any) | null> any ? InstanceType : void; -export type NotificationHandler = (result: NullableInstanceType) => void; +export type NotificationHandler = ( + result: NullableInstanceType +) => void | Promise; /** * Subscribes to a set of notifications. @@ -103,10 +108,9 @@ export function subscribeNotifications( callbacks: { [K in NotificationEnum]?: NotificationHandler; }, - options?: { id?: string } + options?: { id?: string | number } ): Promise<() => void> { - return listen>('af-notification', (event) => { - const subject = SubscribeObject.fromObject(event.payload); + const handler = async (subject: SubscribeObject) => { const { id, ty } = subject; if (options?.id !== undefined && id !== options.id) { @@ -127,8 +131,20 @@ export function subscribeNotifications( } else { const { payload } = subject; - pb ? callback(pb.deserialize(payload)) : callback(); + if (pb) { + await callback(pb.deserialize(payload)); + } else { + await callback(); + } } + }; + + const queue = new AsyncQueue(handler); + + return listen>('af-notification', (event) => { + const subject = SubscribeObject.fromObject(event.payload); + + queue.enqueue(subject); }); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts index 82c0a6779b9a8..ec258abc87e99 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts @@ -1,33 +1,63 @@ -import { SignInPayloadPB, SignUpPayloadPB } from '@/services/backend'; import { - UserEventSignInWithEmailPassword, + SignUpPayloadPB, + OauthProviderPB, + ProviderTypePB, + OauthSignInPB, + AuthenticatorPB, + SignInPayloadPB, +} from '@/services/backend'; +import { UserEventSignOut, UserEventSignUp, + UserEventGetOauthURLWithProvider, + UserEventOauthSignIn, + UserEventSignInWithEmailPassword, } from '@/services/backend/events/flowy-user'; -import { nanoid } from '@reduxjs/toolkit'; import { Log } from '$app/utils/log'; export const AuthService = { - signIn: async (params: { email: string; password: string }) => { - const payload = SignInPayloadPB.fromObject({ email: params.email, password: params.password }); + getOAuthURL: async (provider: ProviderTypePB) => { + const providerDataRes = await UserEventGetOauthURLWithProvider( + OauthProviderPB.fromObject({ + provider, + }) + ); - const res = await UserEventSignInWithEmailPassword(payload); + if (!providerDataRes.ok) { + Log.error(providerDataRes.val.msg); + throw new Error(providerDataRes.val.msg); + } + + const providerData = providerDataRes.val; + + return providerData.oauth_url; + }, + + signInWithOAuth: async ({ uri, deviceId }: { uri: string; deviceId: string }) => { + const payload = OauthSignInPB.fromObject({ + authenticator: AuthenticatorPB.AppFlowyCloud, + map: { + sign_in_url: uri, + device_id: deviceId, + }, + }); + + const res = await UserEventOauthSignIn(payload); - if (res.ok) { - return res.val; + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); } - Log.error(res.val.msg); - throw new Error(res.val.msg); + return res.val; }, - signUp: async (params: { name: string; email: string; password: string }) => { - const deviceId = nanoid(8); + signUp: async (params: { deviceId: string; name: string; email: string; password: string }) => { const payload = SignUpPayloadPB.fromObject({ name: params.name, email: params.email, password: params.password, - device_id: deviceId, + device_id: params.deviceId, }); const res = await UserEventSignUp(payload); @@ -43,4 +73,20 @@ export const AuthService = { signOut: () => { return UserEventSignOut(); }, + + signIn: async (email: string, password: string) => { + const payload = SignInPayloadPB.fromObject({ + email, + password, + }); + + const res = await UserEventSignInWithEmailPassword(payload); + + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); + } + + return res.val; + }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts index f91c39cb71e24..ec64fb810c603 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts @@ -1,9 +1,10 @@ import { Theme, ThemeMode, UserSetting } from '$app_reducers/current-user/slice'; -import { AppearanceSettingsPB } from '@/services/backend'; +import { AppearanceSettingsPB, UpdateUserProfilePayloadPB } from '@/services/backend'; import { UserEventGetAppearanceSetting, UserEventGetUserProfile, UserEventSetAppearanceSetting, + UserEventUpdateUserProfile, } from '@/services/backend/events/flowy-user'; export const UserService = { @@ -52,4 +53,16 @@ export const UserService = { return; }, + + updateUserProfile: async (params: ReturnType) => { + const payload = UpdateUserProfilePayloadPB.fromObject(params); + + const res = await UserEventUpdateUserProfile(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); + }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg new file mode 100644 index 0000000000000..80d8c4132e98d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg new file mode 100644 index 0000000000000..f82a41d226a0e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg new file mode 100644 index 0000000000000..0739605066ab0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg b/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg new file mode 100644 index 0000000000000..aeaa6a0f29b2d Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg new file mode 100644 index 0000000000000..37ca4d58379f1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg new file mode 100644 index 0000000000000..f5cd761ba77c8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg new file mode 100644 index 0000000000000..b1ac8d66fb664 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg index 4e4a8c039a370..05caec861a5dc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg new file mode 100644 index 0000000000000..fddfca7575e30 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg new file mode 100644 index 0000000000000..c6fa56067bcaf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png new file mode 100644 index 0000000000000..15a2db5eb8d0b Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png new file mode 100644 index 0000000000000..f71e68c6ed819 Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png new file mode 100644 index 0000000000000..597883b7a3e24 Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png new file mode 100644 index 0000000000000..60032628a8a9e Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png new file mode 100644 index 0000000000000..09b2d9c4755f0 Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg new file mode 100644 index 0000000000000..2076ea3e2c347 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx new file mode 100644 index 0000000000000..1248882238fcf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx @@ -0,0 +1,33 @@ +import { stringToColor, stringToShortName } from '$app/utils/avatar'; +import { Avatar } from '@mui/material'; +import { useAppSelector } from '$app/stores/store'; + +export const ProfileAvatar = ({ + onClick, + className, + width, + height, +}: { + onClick?: (e: React.MouseEvent) => void; + width?: number; + height?: number; + className?: string; +}) => { + const { displayName = 'Me', iconUrl } = useAppSelector((state) => state.currentUser); + + return ( + + {iconUrl ? iconUrl : stringToShortName(displayName)} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx new file mode 100644 index 0000000000000..079342b528ec8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx @@ -0,0 +1,34 @@ +import { Avatar } from '@mui/material'; +import { stringToColor, stringToShortName } from '$app/utils/avatar'; + +export const WorkplaceAvatar = ({ + workplaceName, + icon, + onClick, + width, + height, + className, +}: { + workplaceName: string; + width: number; + height: number; + className?: string; + icon?: string; + onClick?: (e: React.MouseEvent) => void; +}) => { + return ( + + {icon ? icon : stringToShortName(workplaceName)} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts new file mode 100644 index 0000000000000..772056737ac7c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts @@ -0,0 +1,2 @@ +export * from './WorkplaceAvatar'; +export * from './ProfileAvatar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx index b46ce37345ba9..058335d30c0c0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx @@ -1,24 +1,28 @@ import React, { useCallback } from 'react'; import DialogContent from '@mui/material/DialogContent'; -import { Button, DialogActions, Divider } from '@mui/material'; +import { Button, DialogProps } from '@mui/material'; import Dialog from '@mui/material/Dialog'; import { useTranslation } from 'react-i18next'; import { Log } from '$app/utils/log'; -interface Props { +interface Props extends DialogProps { open: boolean; title: string; - subtitle: string; - onOk: () => Promise; + subtitle?: string; + onOk?: () => Promise; onClose: () => void; + onCancel?: () => void; + okText?: string; + cancelText?: string; + container?: HTMLElement | null; } -function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { +function DeleteConfirmDialog({ open, title, onOk, onCancel, onClose, okText, cancelText, container, ...props }: Props) { const { t } = useTranslation(); const onDone = useCallback(async () => { try { - await onOk(); + await onOk?.(); onClose(); } catch (e) { Log.error(e); @@ -27,6 +31,7 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { return ( { if (e.key === 'Escape') { @@ -44,20 +49,27 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose} + {...props} > - -
{title}
- {subtitle &&
{subtitle}
} + + {title} +
+ + +
- - - - -
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx index 4c99d37e87e73..cb8b7a80ed552 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx @@ -67,7 +67,7 @@ function RenameDialog({ - + + + + ); +} + +export default ManualSignInDialog; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx index 0d7c1858a3766..b8dcb3f6c73a9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx @@ -7,9 +7,10 @@ import EmojiPickerCategories from './EmojiPickerCategories'; interface Props { onEmojiSelect: (emoji: string) => void; onEscape?: () => void; + defaultEmoji?: string; } -function EmojiPicker({ onEscape, ...props }: Props) { +function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) { const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props); return ( @@ -21,7 +22,12 @@ function EmojiPicker({ onEscape, ...props }: Props) { searchValue={searchValue} onSearchChange={setSearchValue} /> - + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx index d410b6109eee1..eefea8db11725 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { EMOJI_SIZE, EmojiCategory, @@ -14,10 +14,12 @@ function EmojiPickerCategories({ emojiCategories, onEmojiSelect, onEscape, + defaultEmoji, }: { emojiCategories: EmojiCategory[]; onEmojiSelect: (emoji: string) => void; onEscape?: () => void; + defaultEmoji?: string; }) { const scrollRef = React.useRef(null); const { t } = useTranslation(); @@ -28,6 +30,8 @@ function EmojiPickerCategories({ const rows = useMemo(() => { return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT); }, [emojiCategories]); + const mouseY = useRef(null); + const mouseX = useRef(null); const ref = React.useRef(null); @@ -75,6 +79,8 @@ function EmojiPickerCategories({ {item.emojis?.map((emoji, columnIndex) => { const isSelected = selectCell.row === index && selectCell.column === columnIndex; + const isDefaultEmoji = defaultEmoji === emoji.native; + return (
{ onEmojiSelect(emoji.native); }} - className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-active ${ - isSelected ? 'bg-fill-list-hover' : '' - }`} + onMouseMove={(e) => { + mouseY.current = e.clientY; + mouseX.current = e.clientX; + }} + onMouseEnter={(e) => { + if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) { + setSelectCell({ + row: index, + column: columnIndex, + }); + } + + mouseX.current = e.clientX; + mouseY.current = e.clientY; + }} + className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${ + isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent' + } ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`} > {emoji.native}
@@ -98,7 +119,7 @@ function EmojiPickerCategories({ ); }, - [getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row] + [defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row] ); const getNewColumnIndex = useCallback( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx new file mode 100644 index 0000000000000..34a99007ade9b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useState } from 'react'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; + +const urlPattern = /^(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm|.webp|.svg)(\?[^\s[",><]*)?$/; + +export function EmbedLink({ + onDone, + onEscape, + defaultLink, +}: { + defaultLink?: string; + onDone?: (value: string) => void; + onEscape?: () => void; +}) { + const { t } = useTranslation(); + + const [value, setValue] = useState(defaultLink ?? ''); + const [error, setError] = useState(false); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + + setValue(value); + setError(!urlPattern.test(value)); + }, + [setValue, setError] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !error && value) { + e.preventDefault(); + e.stopPropagation(); + onDone?.(value); + } + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onEscape?.(); + } + }, + [error, onDone, onEscape, value] + ); + + return ( +
+ + +
+ ); +} + +export default EmbedLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx new file mode 100644 index 0000000000000..d94e5f2889f42 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx @@ -0,0 +1,62 @@ +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { CircularProgress } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ErrorOutline } from '@mui/icons-material'; + +export const LocalImage = forwardRef< + HTMLImageElement, + { + renderErrorNode?: () => React.ReactElement | null; + } & React.ImgHTMLAttributes +>((localImageProps, ref) => { + const { src, renderErrorNode, ...props } = localImageProps; + const imageRef = useRef(null); + const { t } = useTranslation(); + const [imageURL, setImageURL] = useState(''); + const [loading, setLoading] = useState(true); + const [isError, setIsError] = useState(false); + const loadLocalImage = useCallback(async () => { + if (!src) return; + setLoading(true); + setIsError(false); + const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs'); + + try { + const svg = src.endsWith('.svg'); + + const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData }); + const blob = new Blob([buffer], { type: svg ? 'image/svg+xml' : 'image' }); + + setImageURL(URL.createObjectURL(blob)); + } catch (e) { + setIsError(true); + } + + setLoading(false); + }, [src]); + + useEffect(() => { + void loadLocalImage(); + }, [loadLocalImage]); + + if (loading) { + return ( +
+ + {t('editor.loading')}... +
+ ); + } + + if (isError) { + if (renderErrorNode) return renderErrorNode(); + return ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ); + } + + return {'local; +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx new file mode 100644 index 0000000000000..01da8323b9909 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createApi } from 'unsplash-js'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import debounce from 'lodash-es/debounce'; +import { CircularProgress } from '@mui/material'; +import { open } from '@tauri-apps/api/shell'; + +const unsplash = createApi({ + accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids', +}); + +const SEARCH_DEBOUNCE_TIME = 500; + +export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [photos, setPhotos] = useState< + { + thumb: string; + regular: string; + alt: string | null; + id: string; + user: { + name: string; + link: string; + }; + }[] + >([]); + const [searchValue, setSearchValue] = useState(''); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + setSearchValue(value); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onEscape?.(); + } + }, + [onEscape] + ); + + const debounceSearchPhotos = useMemo(() => { + return debounce(async (searchValue: string) => { + const request = searchValue + ? unsplash.search.getPhotos({ query: searchValue ?? undefined, perPage: 32 }) + : unsplash.photos.list({ perPage: 32 }); + + setError(''); + setLoading(true); + await request.then((result) => { + if (result.errors) { + setError(result.errors[0]); + } else { + setPhotos( + result.response.results.map((photo) => ({ + id: photo.id, + thumb: photo.urls.thumb, + regular: photo.urls.regular, + alt: photo.alt_description, + user: { + name: photo.user.name, + link: photo.user.links.html, + }, + })) + ); + } + + setLoading(false); + }); + }, SEARCH_DEBOUNCE_TIME); + }, []); + + useEffect(() => { + void debounceSearchPhotos(searchValue); + return () => { + debounceSearchPhotos.cancel(); + }; + }, [debounceSearchPhotos, searchValue]); + + return ( +
+ + + {loading ? ( +
+ +
{t('editor.loading')}
+
+ ) : error ? ( + + {error} + + ) : ( +
+ {photos.length > 0 ? ( + <> +
+ {photos.map((photo) => ( +
+ { + onDone?.(photo.regular); + }} + src={photo.thumb} + alt={photo.alt ?? ''} + className={'h-20 w-32 rounded object-cover hover:opacity-80'} + /> +
+ by{' '} + { + void open(photo.user.link); + }} + className={'underline hover:text-function-info'} + > + {photo.user.name} + +
+
+ ))} +
+ + {t('findAndReplace.searchMore')} + + + ) : ( + + {t('findAndReplace.noResult')} + + )} +
+ )} +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx new file mode 100644 index 0000000000000..a6b66a4c1fbdb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx @@ -0,0 +1,95 @@ +import React, { useCallback } from 'react'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import CloudUploadIcon from '@mui/icons-material/CloudUploadOutlined'; +import { notify } from '$app/components/_shared/notify'; +import { isTauri } from '$app/utils/env'; +import { getFileName, IMAGE_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE } from '$app/utils/upload_image'; + +export function UploadImage({ onDone }: { onDone?: (url: string) => void }) { + const { t } = useTranslation(); + + const checkTauriFile = useCallback( + async (url: string) => { + const { readBinaryFile } = await import('@tauri-apps/api/fs'); + + const buffer = await readBinaryFile(url); + const blob = new Blob([buffer]); + + if (blob.size > MAX_IMAGE_SIZE) { + notify.error(t('document.imageBlock.error.invalidImageSize')); + return false; + } + + return true; + }, + [t] + ); + + const uploadTauriLocalImage = useCallback( + async (url: string) => { + const { copyFile, BaseDirectory, exists, createDir } = await import('@tauri-apps/api/fs'); + + const checked = await checkTauriFile(url); + + if (!checked) return; + + try { + const existDir = await exists(IMAGE_DIR, { dir: BaseDirectory.AppLocalData }); + + if (!existDir) { + await createDir(IMAGE_DIR, { dir: BaseDirectory.AppLocalData }); + } + + const filename = getFileName(url); + + await copyFile(url, `${IMAGE_DIR}/${filename}`, { dir: BaseDirectory.AppLocalData }); + const newUrl = `${IMAGE_DIR}/${filename}`; + + onDone?.(newUrl); + } catch (e) { + notify.error(t('document.plugins.image.imageUploadFailed')); + } + }, + [checkTauriFile, onDone, t] + ); + + const handleClickUpload = useCallback(async () => { + if (!isTauri()) return; + const { open } = await import('@tauri-apps/api/dialog'); + + const url = await open({ + multiple: false, + directory: false, + filters: [ + { + name: 'Image', + extensions: ALLOWED_IMAGE_EXTENSIONS, + }, + ], + }); + + if (!url || typeof url !== 'string') return; + + await uploadTauriLocalImage(url); + }, [uploadTauriLocalImage]); + + return ( +
+ +
+ ); +} + +export default UploadImage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx new file mode 100644 index 0000000000000..fb65c709cecbf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx @@ -0,0 +1,128 @@ +import React, { SyntheticEvent, useCallback, useState } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs'; +import SwipeableViews from 'react-swipeable-views'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; + +export enum TAB_KEY { + Colors = 'colors', + UPLOAD = 'upload', + EMBED_LINK = 'embed_link', + UNSPLASH = 'unsplash', +} + +export type TabOption = { + key: TAB_KEY; + label: string; + Component: React.ComponentType<{ + onDone?: (value: string) => void; + onEscape?: () => void; + }>; + onDone?: (value: string) => void; +}; + +export function UploadTabs({ + tabOptions, + popoverProps, + containerStyle, + extra, +}: { + containerStyle?: React.CSSProperties; + tabOptions: TabOption[]; + popoverProps?: PopoverProps; + extra?: React.ReactNode; +}) { + const [tabValue, setTabValue] = useState(() => { + return tabOptions[0].key; + }); + + const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => { + setTabValue(newValue as TAB_KEY); + }, []); + + const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + popoverProps?.onClose?.({}, 'escapeKeyDown'); + } + + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + setTabValue((prev) => { + const currentIndex = tabOptions.findIndex((tab) => tab.key === prev); + let nextIndex = currentIndex + 1; + + if (e.shiftKey) { + nextIndex = currentIndex - 1; + } + + return tabOptions[nextIndex % tabOptions.length]?.key ?? tabOptions[0].key; + }); + } + }, + [popoverProps, tabOptions] + ); + + return ( + +
+
+ + {tabOptions.map((tab) => { + const { key, label } = tab; + + return ; + })} + + {extra} +
+ +
+ + {tabOptions.map((tab, index) => { + const { key, Component, onDone } = tab; + + return ( + + popoverProps?.onClose?.({}, 'escapeKeyDown')} /> + + ); + })} + +
+
+
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts new file mode 100644 index 0000000000000..28673cae5f6da --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts @@ -0,0 +1,5 @@ +export * from './Unsplash'; +export * from './UploadImage'; +export * from './EmbedLink'; +export * from './UploadTabs'; +export * from './LocalImage'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx index b7c6db9db0c6b..7db90c4e8f6ee 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx @@ -48,6 +48,9 @@ export interface KeyboardNavigationProps { defaultFocusedKey?: T; onFocus?: () => void; onBlur?: () => void; + itemClassName?: string; + itemStyle?: React.CSSProperties; + renderNoResult?: () => React.ReactNode; } function KeyboardNavigation({ @@ -65,6 +68,9 @@ function KeyboardNavigation({ disableSelect = false, onBlur, onFocus, + itemClassName, + itemStyle, + renderNoResult, }: KeyboardNavigationProps) { const { t } = useTranslation(); const ref = useRef(null); @@ -197,7 +203,7 @@ function KeyboardNavigation({ const renderOption = useCallback( (option: KeyboardNavigationOption, index: number) => { - const hasChildren = option.children && option.children.length > 0; + const hasChildren = option.children; const isFocused = focusedKey === option.key; @@ -216,6 +222,7 @@ function KeyboardNavigation({ mouseY.current = e.clientY; }} onMouseEnter={(e) => { + onFocus?.(); if (mouseY.current === null || mouseY.current !== e.clientY) { setFocusedKey(option.key); } @@ -229,9 +236,10 @@ function KeyboardNavigation({ } }} selected={isFocused} + style={itemStyle} className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${ !isFocused ? 'hover:bg-transparent' : '' - }`} + } ${itemClassName ?? ''}`} > {option.content} @@ -243,7 +251,7 @@ function KeyboardNavigation({ ); }, - [focusedKey, onConfirm] + [itemClassName, focusedKey, onConfirm, onFocus, itemStyle] ); useEffect(() => { @@ -281,16 +289,24 @@ function KeyboardNavigation({ onBlur={(e) => { e.stopPropagation(); + const target = e.relatedTarget as HTMLElement; + + if (target?.closest('.keyboard-navigation')) { + return; + } + onBlur?.(); }} autoFocus={!disableFocus} - className={'flex w-full flex-col gap-1 outline-none'} + className={'keyboard-navigation flex w-full flex-col gap-1 outline-none'} ref={ref} > {options.length > 0 ? ( options.map(renderOption) + ) : renderNoResult ? ( + renderNoResult() ) : ( - + {t('findAndReplace.noResult')} )} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts index 44e2df4099776..0fc1b5e61ef4d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts @@ -30,7 +30,9 @@ function getOffsetLeft( height: number; width: number; }, - horizontal: number | 'center' | 'left' | 'right' + paperWidth: number, + horizontal: number | 'center' | 'left' | 'right', + transformHorizontal: number | 'center' | 'left' | 'right' ) { let offset = 0; @@ -42,6 +44,12 @@ function getOffsetLeft( offset = rect.width; } + if (transformHorizontal === 'center') { + offset -= paperWidth / 2; + } else if (transformHorizontal === 'right') { + offset -= paperWidth; + } + return offset; } @@ -50,7 +58,9 @@ function getOffsetTop( height: number; width: number; }, - vertical: number | 'center' | 'bottom' | 'top' + papertHeight: number, + vertical: number | 'center' | 'bottom' | 'top', + transformVertical: number | 'center' | 'bottom' | 'top' ) { let offset = 0; @@ -62,6 +72,12 @@ function getOffsetTop( offset = rect.height; } + if (transformVertical === 'center') { + offset -= papertHeight / 2; + } else if (transformVertical === 'bottom') { + offset -= papertHeight; + } + return offset; } @@ -84,7 +100,9 @@ const usePopoverAutoPosition = ({ initialPaperHeight, marginThreshold = 16, open, -}: UsePopoverAutoPositionProps): PopoverPosition => { +}: UsePopoverAutoPositionProps): PopoverPosition & { + calculateAnchorSize: () => void; +} => { const [position, setPosition] = useState({ anchorOrigin: initialAnchorOrigin, transformOrigin: initialTransformOrigin, @@ -94,24 +112,21 @@ const usePopoverAutoPosition = ({ isEntered: false, }); - const getAnchorOffset = useCallback(() => { - if (anchorPosition) { - return { - ...anchorPosition, - width: 0, - }; - } + const calculateAnchorSize = useCallback(() => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - return anchorEl ? anchorEl.getBoundingClientRect() : undefined; - }, [anchorEl, anchorPosition]); + const getAnchorOffset = () => { + if (anchorPosition) { + return { + ...anchorPosition, + width: 0, + }; + } - useEffect(() => { - if (!open) { - return; - } + return anchorEl ? anchorEl.getBoundingClientRect() : undefined; + }; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; const anchorRect = getAnchorOffset(); if (!anchorRect) return; @@ -123,8 +138,12 @@ const usePopoverAutoPosition = ({ }; // calculate new paper width - const newLeft = anchorRect.left + getOffsetLeft(anchorRect, initialAnchorOrigin.horizontal); - const newTop = anchorRect.top + getOffsetTop(anchorRect, initialAnchorOrigin.vertical); + const newLeft = + anchorRect.left + + getOffsetLeft(anchorRect, newPaperWidth, initialAnchorOrigin.horizontal, initialTransformOrigin.horizontal); + const newTop = + anchorRect.top + + getOffsetTop(anchorRect, newPaperHeight, initialAnchorOrigin.vertical, initialTransformOrigin.vertical); let isExceedViewportRight = false; let isExceedViewportBottom = false; @@ -183,24 +202,36 @@ const usePopoverAutoPosition = ({ newPosition.anchorPosition.top += anchorRect.height; } - if (newPosition.anchorOrigin.vertical === 'top' && newPosition.transformOrigin.vertical === 'bottom') { + if ( + isExceedViewportTop && + isExceedViewportBottom && + newPosition.anchorOrigin.vertical === 'top' && + newPosition.transformOrigin.vertical === 'bottom' + ) { newPosition.paperHeight = newPaperHeight - anchorRect.height; } // Set new position and set isEntered to true setPosition({ ...newPosition, isEntered: true }); }, [ - anchorPosition, - open, initialAnchorOrigin, initialTransformOrigin, initialPaperWidth, initialPaperHeight, marginThreshold, - getAnchorOffset, + anchorEl, + anchorPosition, ]); - return position; + useEffect(() => { + if (!open) return; + calculateAnchorSize(); + }, [open, calculateAnchorSize]); + + return { + ...position, + calculateAnchorSize, + }; }; export default usePopoverAutoPosition; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx new file mode 100644 index 0000000000000..0527b6cc26431 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx @@ -0,0 +1,55 @@ +import { Scrollbars } from 'react-custom-scrollbars'; +import React from 'react'; + +export interface AFScrollerProps { + children: React.ReactNode; + overflowXHidden?: boolean; + overflowYHidden?: boolean; + className?: string; + style?: React.CSSProperties; +} +export const AFScroller = ({ style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps) => { + return ( +
} + renderThumbVertical={(props) =>
} + {...(overflowXHidden && { + renderTrackHorizontal: (props) => ( +
+ ), + })} + {...(overflowYHidden && { + renderTrackVertical: (props) => ( +
+ ), + })} + style={style} + renderView={(props) => ( +
+ )} + > + {children} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts new file mode 100644 index 0000000000000..7a740a5bb0fe1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts @@ -0,0 +1 @@ +export * from './AFScroller'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AddSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AddSvg.tsx deleted file mode 100644 index 495a0151c1194..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AddSvg.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogo.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogo.tsx deleted file mode 100644 index 0625424c76c62..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogo.tsx +++ /dev/null @@ -1,42 +0,0 @@ -export const AppflowyLogo = () => { - return ( - - - - - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogoDark.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogoDark.tsx deleted file mode 100644 index f43ce1f495587..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogoDark.tsx +++ /dev/null @@ -1,77 +0,0 @@ -export const AppflowyLogoDark = () => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogoLight.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogoLight.tsx deleted file mode 100644 index 1c9b3dcbb2da2..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/AppflowyLogoLight.tsx +++ /dev/null @@ -1,53 +0,0 @@ -export const AppflowyLogoLight = () => ( - - - - - - - - - - - - - - - - - - - -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ArrowLeftSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ArrowLeftSvg.tsx deleted file mode 100644 index 9c4d68be7575d..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ArrowLeftSvg.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const ArrowLeftSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ArrowRightSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ArrowRightSvg.tsx deleted file mode 100644 index 8b9501c508291..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ArrowRightSvg.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const ArrowRightSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/BoardSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/BoardSvg.tsx deleted file mode 100644 index 11c29fae58241..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/BoardSvg.tsx +++ /dev/null @@ -1,20 +0,0 @@ -export const BoardSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CheckboxSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CheckboxSvg.tsx deleted file mode 100644 index 862badd2a26d9..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CheckboxSvg.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const CheckboxSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ChecklistTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ChecklistTypeSvg.tsx deleted file mode 100644 index ea4f168737c34..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ChecklistTypeSvg.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const ChecklistTypeSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CheckmarkSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CheckmarkSvg.tsx deleted file mode 100644 index 7ab64e7e28005..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CheckmarkSvg.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const CheckmarkSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ClockSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ClockSvg.tsx deleted file mode 100644 index b66f7bfe18bf6..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ClockSvg.tsx +++ /dev/null @@ -1,15 +0,0 @@ -export const ClockSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CloseSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CloseSvg.tsx deleted file mode 100644 index 50e76a68c58f7..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CloseSvg.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const CloseSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CopySvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CopySvg.tsx deleted file mode 100644 index 9d4eb5bfca09a..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/CopySvg.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const CopySvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DateTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DateTypeSvg.tsx deleted file mode 100644 index 7bc133b2afd0a..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DateTypeSvg.tsx +++ /dev/null @@ -1,15 +0,0 @@ -export const DateTypeSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/Details2Svg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/Details2Svg.tsx deleted file mode 100644 index 47e8cd9a007f9..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/Details2Svg.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export const Details2Svg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DocumentSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DocumentSvg.tsx deleted file mode 100644 index 52843553d4b9e..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DocumentSvg.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const DocumentSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DragElementSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DragElementSvg.tsx deleted file mode 100644 index 9d58ccde0fe1a..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DragElementSvg.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const DragElementSvg = () => { - return ( - - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DragSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DragSvg.tsx deleted file mode 100644 index cce8d09199779..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DragSvg.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const DragSvg = () => { - return ( - - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DropDownShowSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DropDownShowSvg.tsx deleted file mode 100644 index b3956a77d1fc4..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/DropDownShowSvg.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export const DropDownShowSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EarthSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EarthSvg.tsx deleted file mode 100644 index f2911a940c59c..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EarthSvg.tsx +++ /dev/null @@ -1,21 +0,0 @@ -export const EarthSvg = () => { - return ( - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditSvg.tsx deleted file mode 100644 index 7174c1c373bcb..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditSvg.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const EditSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditorCheckSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditorCheckSvg.tsx deleted file mode 100644 index c784aa0be6522..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditorCheckSvg.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const EditorCheckSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditorUncheckSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditorUncheckSvg.tsx deleted file mode 100644 index 3f62b51eacbd8..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EditorUncheckSvg.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const EditorUncheckSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EyeClosedSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EyeClosedSvg.tsx deleted file mode 100644 index d4d50668d31aa..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EyeClosedSvg.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const EyeClosedSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EyeOpenSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EyeOpenSvg.tsx deleted file mode 100644 index c775d233cc9ca..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/EyeOpenSvg.tsx +++ /dev/null @@ -1,20 +0,0 @@ -export const EyeOpenSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/FilterSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/FilterSvg.tsx deleted file mode 100644 index d46600610aa3d..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/FilterSvg.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const FilterSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/FullView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/FullView.tsx deleted file mode 100644 index aa79420d5ac79..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/FullView.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export const FullView = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GridSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GridSvg.tsx deleted file mode 100644 index 5fbf0d86d7d77..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GridSvg.tsx +++ /dev/null @@ -1,30 +0,0 @@ -export const GridSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GroupByFieldSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GroupByFieldSvg.tsx deleted file mode 100644 index 960e1bad2ac97..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GroupByFieldSvg.tsx +++ /dev/null @@ -1,26 +0,0 @@ -export const GroupByFieldSvg = () => { - return ( - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GroupBySvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GroupBySvg.tsx deleted file mode 100644 index 7ac7e37303ad9..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/GroupBySvg.tsx +++ /dev/null @@ -1,31 +0,0 @@ -export const GroupBySvg = () => { - return ( - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/HideMenuSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/HideMenuSvg.tsx deleted file mode 100644 index af69b2ab5c9ca..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/HideMenuSvg.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export const HideMenuSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ImageSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ImageSvg.tsx deleted file mode 100644 index 488c17065678d..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ImageSvg.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export const ImageSvg = () => { - return ( - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/InformationSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/InformationSvg.tsx deleted file mode 100644 index 8217fe0f829da..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/InformationSvg.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export const InformationSvg = () => { - return ( - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/LogoutSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/LogoutSvg.tsx deleted file mode 100644 index 86e69c08c1647..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/LogoutSvg.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export const LogoutSvg = () => { - return ( - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/MoreSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/MoreSvg.tsx deleted file mode 100644 index 20dd851302461..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/MoreSvg.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export const MoreSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/MultiSelectTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/MultiSelectTypeSvg.tsx deleted file mode 100644 index ec9c56d868787..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/MultiSelectTypeSvg.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const MultiSelectTypeSvg = () => { - return ( - - - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/NumberTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/NumberTypeSvg.tsx deleted file mode 100644 index b41a8704e4305..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/NumberTypeSvg.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export const NumberTypeSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/PropertiesSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/PropertiesSvg.tsx deleted file mode 100644 index cef2527c72957..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/PropertiesSvg.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const PropertiesSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SearchSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SearchSvg.tsx deleted file mode 100644 index 28717c95a0e93..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SearchSvg.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const SearchSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SettingsSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SettingsSvg.tsx deleted file mode 100644 index 84f449bc27aae..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SettingsSvg.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const SettingsSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ShareSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ShareSvg.tsx deleted file mode 100644 index 217a995f62250..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ShareSvg.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const ShareSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ShowMenuSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ShowMenuSvg.tsx deleted file mode 100644 index 736e9a8b5052a..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/ShowMenuSvg.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export const ShowMenuSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SingleSelectTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SingleSelectTypeSvg.tsx deleted file mode 100644 index 82b847681da7c..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SingleSelectTypeSvg.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const SingleSelectTypeSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SkipLeftSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SkipLeftSvg.tsx deleted file mode 100644 index 4e84c77e06bd3..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SkipLeftSvg.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export const SkipLeftSvg = () => { - return ( - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SkipRightSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SkipRightSvg.tsx deleted file mode 100644 index 6bcf2ebe7e6f2..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SkipRightSvg.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export const SkipRightSvg = () => { - return ( - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortAscSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortAscSvg.tsx deleted file mode 100644 index 7304ef7cbb512..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortAscSvg.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export const SortAscSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortDescSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortDescSvg.tsx deleted file mode 100644 index c0d310b6de5ed..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortDescSvg.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export const SortDescSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortSvg.tsx deleted file mode 100644 index 7fb7d9564f7ad..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/SortSvg.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export const SortSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/TextTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/TextTypeSvg.tsx deleted file mode 100644 index 5ace944b18cf0..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/TextTypeSvg.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export const TextTypeSvg = () => { - return ( - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/TrashSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/TrashSvg.tsx deleted file mode 100644 index cb445e4704cbb..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/TrashSvg.tsx +++ /dev/null @@ -1,34 +0,0 @@ -export const TrashSvg = () => { - return ( - - - - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/UrlTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/UrlTypeSvg.tsx deleted file mode 100644 index 0c7a268ec3810..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/svg/UrlTypeSvg.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const UrlTypeSvg = () => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx index 99f444ac2601c..95e44ae9c2258 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx @@ -1,33 +1,52 @@ import ViewIconGroup from '$app/components/_shared/view_title/ViewIconGroup'; -import { PageIcon } from '$app_reducers/pages/slice'; +import { PageCover, PageIcon } from '$app_reducers/pages/slice'; import ViewIcon from '$app/components/_shared/view_title/ViewIcon'; +import { ViewCover } from '$app/components/_shared/view_title/cover'; function ViewBanner({ icon, hover, onUpdateIcon, + showCover, + cover, + onUpdateCover, }: { icon?: PageIcon; hover: boolean; onUpdateIcon: (icon: string) => void; + showCover: boolean; + cover?: PageCover; + onUpdateCover?: (cover?: PageCover) => void; }) { return ( - <> -
- -
-
- +
+ {showCover && cover && } + +
+
+ +
+
+ +
- +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx index 706827ba9c4ef..009548df5389a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx @@ -22,6 +22,9 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon const onEmojiSelect = useCallback( (emoji: string) => { onUpdateIcon(emoji); + if (!emoji) { + setAnchorPosition(undefined); + } }, [onUpdateIcon] ); @@ -29,7 +32,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon if (!icon) return null; return ( <> -
+
{icon.value}
@@ -44,6 +47,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon onClose={() => setAnchorPosition(undefined)} > { setAnchorPosition(undefined); }} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx index 68377951e847d..54256f8eb1fde 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx @@ -1,44 +1,55 @@ import { useTranslation } from 'react-i18next'; -import { PageIcon } from '$app_reducers/pages/slice'; +import { CoverType, PageCover, PageIcon } from '$app_reducers/pages/slice'; import React, { useCallback } from 'react'; import { randomEmoji } from '$app/utils/emoji'; import { EmojiEmotionsOutlined } from '@mui/icons-material'; import Button from '@mui/material/Button'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; +import { ImageType } from '$app/application/document/document.types'; interface Props { icon?: PageIcon; - // onUpdateCover: (coverType: CoverType, cover: string) => void; onUpdateIcon: (icon: string) => void; + showCover: boolean; + cover?: PageCover; + onUpdateCover?: (cover: PageCover) => void; } -function ViewIconGroup({ icon, onUpdateIcon }: Props) { + +const defaultCover = { + cover_selection_type: CoverType.Asset, + cover_selection: 'app_flowy_abstract_cover_2.jpeg', + image_type: ImageType.Internal, +}; + +function ViewIconGroup({ icon, onUpdateIcon, showCover, cover, onUpdateCover }: Props) { const { t } = useTranslation(); const showAddIcon = !icon?.value; + const showAddCover = !cover && showCover; + const onAddIcon = useCallback(() => { const emoji = randomEmoji(); onUpdateIcon(emoji); }, [onUpdateIcon]); - // const onAddCover = useCallback(() => { - // const color = randomColor(); - // - // onUpdateCover(CoverType.Color, color); - // }, []); + const onAddCover = useCallback(() => { + onUpdateCover?.(defaultCover); + }, [onUpdateCover]); return ( -
+
{showAddIcon && ( - )} - {/*{showAddCover && (*/} - {/* */} - {/*)}*/} + {showAddCover && ( + + )}
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx index 26f83ac921014..8d81b6d4b7e6a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import ViewBanner from '$app/components/_shared/view_title/ViewBanner'; -import { Page, PageIcon } from '$app_reducers/pages/slice'; +import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice'; import { ViewIconTypePB } from '@/services/backend'; import ViewTitleInput from '$app/components/_shared/view_title/ViewTitleInput'; @@ -9,9 +9,20 @@ interface Props { showTitle?: boolean; onTitleChange?: (title: string) => void; onUpdateIcon?: (icon: PageIcon) => void; + forceHover?: boolean; + showCover?: boolean; + onUpdateCover?: (cover?: PageCover) => void; } -function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpdateIconProp }: Props) { +function ViewTitle({ + view, + forceHover = false, + onTitleChange, + showTitle = true, + onUpdateIcon: onUpdateIconProp, + showCover = false, + onUpdateCover, +}: Props) { const [hover, setHover] = useState(false); const [icon, setIcon] = useState(view.icon); @@ -38,7 +49,14 @@ function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpda onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} > - + {showTitle && (
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx index ff3923109eb0a..2c69bb4d760f3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx @@ -23,7 +23,7 @@ function ViewTitleInput({ value, onChange }: { value: string; onChange?: (value: autoFocus value={value} onInput={onTitleChange} - className={`min-h-[40px] resize-none text-4xl font-bold leading-[50px] caret-text-title`} + className={`min-h-[40px] resize-none text-5xl font-bold leading-[50px] caret-text-title`} /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx new file mode 100644 index 0000000000000..78b8bbcc4634c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { colorMap } from '$app/utils/color'; + +const colors = Object.entries(colorMap); + +function Colors({ onDone }: { onDone?: (value: string) => void }) { + return ( +
+ {colors.map(([name, value]) => ( +
onDone?.(name)} + /> + ))} +
+ ); +} + +export default Colors; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx new file mode 100644 index 0000000000000..bd8c178380342 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { CoverType, PageCover } from '$app_reducers/pages/slice'; +import { PopoverOrigin } from '@mui/material/Popover'; +import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY, UploadImage } from '$app/components/_shared/image_upload'; +import { useTranslation } from 'react-i18next'; +import Colors from '$app/components/_shared/view_title/cover/Colors'; +import { ImageType } from '$app/application/document/document.types'; +import Button from '@mui/material/Button'; + +const initialOrigin: { + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; +} = { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, +}; + +function CoverPopover({ + anchorEl, + open, + onClose, + onUpdateCover, + onRemoveCover, +}: { + anchorEl: HTMLElement | null; + open: boolean; + onClose: () => void; + onUpdateCover?: (cover?: PageCover) => void; + onRemoveCover?: () => void; +}) { + const { t } = useTranslation(); + const tabOptions: TabOption[] = useMemo(() => { + return [ + { + label: t('document.plugins.cover.colors'), + key: TAB_KEY.Colors, + Component: Colors, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Color, + cover_selection: value, + image_type: ImageType.Internal, + }); + }, + }, + { + label: t('button.upload'), + key: TAB_KEY.UPLOAD, + Component: UploadImage, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Image, + cover_selection: value, + image_type: ImageType.Local, + }); + onClose(); + }, + }, + { + label: t('document.imageBlock.embedLink.label'), + key: TAB_KEY.EMBED_LINK, + Component: EmbedLink, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Image, + cover_selection: value, + image_type: ImageType.External, + }); + onClose(); + }, + }, + { + key: TAB_KEY.UNSPLASH, + label: t('document.imageBlock.unsplash.label'), + Component: Unsplash, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Image, + cover_selection: value, + image_type: ImageType.External, + }); + }, + }, + ]; + }, [onClose, onUpdateCover, t]); + + return ( + + {t('button.remove')} + + } + /> + ); +} + +export default CoverPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx new file mode 100644 index 0000000000000..f207e078861c8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { CoverType, PageCover } from '$app_reducers/pages/slice'; +import { renderColor } from '$app/utils/color'; +import ViewCoverActions from '$app/components/_shared/view_title/cover/ViewCoverActions'; +import CoverPopover from '$app/components/_shared/view_title/cover/CoverPopover'; +import DefaultImage from '$app/assets/images/default_cover.jpg'; +import { ImageType } from '$app/application/document/document.types'; +import { LocalImage } from '$app/components/_shared/image_upload'; + +export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdateCover?: (cover?: PageCover) => void }) { + const { + cover_selection_type: type, + cover_selection: value = '', + image_type: source, + } = useMemo(() => cover || {}, [cover]); + const [showAction, setShowAction] = useState(false); + const actionRef = useRef(null); + const [showPopover, setShowPopover] = useState(false); + + const renderCoverColor = useCallback((color: string) => { + return ( +
+ ); + }, []); + + const renderCoverImage = useCallback((url: string) => { + return {''}; + }, []); + + const handleRemoveCover = useCallback(() => { + onUpdateCover?.(null); + }, [onUpdateCover]); + + const handleClickChange = useCallback(() => { + setShowPopover(true); + }, []); + + return ( +
{ + setShowAction(true); + }} + onMouseLeave={() => { + setShowAction(false); + }} + className={'relative flex h-[255px] w-full'} + > + {source === ImageType.Local ? ( + + ) : ( + <> + {type === CoverType.Asset ? renderCoverImage(DefaultImage) : null} + {type === CoverType.Color ? renderCoverColor(value) : null} + {type === CoverType.Image ? renderCoverImage(value) : null} + + )} + + + {showPopover && ( + setShowPopover(false)} + anchorEl={actionRef.current} + onUpdateCover={onUpdateCover} + onRemoveCover={handleRemoveCover} + /> + )} +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx new file mode 100644 index 0000000000000..fbf8063f44dec --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from 'react'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; + +function ViewCoverActions( + { show, onRemove, onClickChange }: { show: boolean; onRemove: () => void; onClickChange: () => void }, + ref: React.ForwardedRef +) { + const { t } = useTranslation(); + + return ( +
+
+
+ +
+
+
+ ); +} + +export default forwardRef(ViewCoverActions); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts new file mode 100644 index 0000000000000..8df50bb41e5f5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts @@ -0,0 +1 @@ +export * from './ViewCover'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx new file mode 100644 index 0000000000000..481b80a532961 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx @@ -0,0 +1,51 @@ +import Button from '@mui/material/Button'; +import GoogleIcon from '$app/assets/settings/google.png'; +import GithubIcon from '$app/assets/settings/github.png'; +import DiscordIcon from '$app/assets/settings/discord.png'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import { ProviderTypePB } from '@/services/backend'; + +export const LoginButtonGroup = () => { + const { t } = useTranslation(); + + const { signIn } = useAuth(); + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx index 341eff871ecfe..523f0b518897e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx @@ -2,37 +2,104 @@ import { Outlet } from 'react-router-dom'; import { useAuth } from './auth.hooks'; import Layout from '$app/components/layout/Layout'; import { useCallback, useEffect, useState } from 'react'; -import { GetStarted } from '$app/components/auth/get_started/GetStarted'; -import { AppflowyLogo } from '../_shared/svg/AppflowyLogo'; +import { Welcome } from '$app/components/auth/Welcome'; +import { isTauri } from '$app/utils/env'; +import { notify } from '$app/components/_shared/notify'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; +import { CircularProgress, Portal } from '@mui/material'; +import { ReactComponent as Logo } from '$app/assets/logo.svg'; +import { useAppDispatch } from '$app/stores/store'; export const ProtectedRoutes = () => { - const { currentUser, checkUser } = useAuth(); - const [isLoading, setIsLoading] = useState(true); + const { currentUser, checkUser, subscribeToUser, signInWithOAuth } = useAuth(); + const dispatch = useAppDispatch(); + + const isLoading = currentUser?.loginState === LoginState.Loading; + + const [checked, setChecked] = useState(false); const checkUserStatus = useCallback(async () => { await checkUser(); - setIsLoading(false); + setChecked(true); }, [checkUser]); useEffect(() => { void checkUserStatus(); }, [checkUserStatus]); - if (isLoading) { - // It's better to make a fading effect to disappear the loading page - return ; - } else { - return ; - } + useEffect(() => { + if (currentUser.isAuthenticated) { + return subscribeToUser(); + } + }, [currentUser.isAuthenticated, subscribeToUser]); + + const onDeepLink = useCallback(async () => { + if (!isTauri()) return; + const { event } = await import('@tauri-apps/api'); + + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + return await event.listen('open_deep_link', async (e) => { + const payload = e.payload as string; + + const [, hash] = payload.split('//#'); + const obj = parseHash(hash); + + if (!obj.access_token) { + notify.error('Failed to sign in, the access token is missing'); + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return; + } + + try { + await signInWithOAuth(payload); + } catch (e) { + notify.error('Failed to sign in, please try again'); + } + }); + }, [dispatch, signInWithOAuth]); + + useEffect(() => { + void onDeepLink(); + }, [onDeepLink]); + + return ( +
+ {checked ? ( + + ) : ( +
+ +
+ )} + + {isLoading && } +
+ ); }; const StartLoading = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + const preventDefault = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + dispatch(currentUserActions.resetLoginState()); + } + }; + + document.addEventListener('keydown', preventDefault, true); + + return () => { + document.removeEventListener('keydown', preventDefault, true); + }; + }, [dispatch]); return ( -
-
- + +
+
-
+ ); }; @@ -44,6 +111,17 @@ const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => { ); } else { - return ; + return ; } }; + +function parseHash(hash: string) { + const hashParams = new URLSearchParams(hash); + const hashObject: Record = {}; + + for (const [key, value] of hashParams) { + hashObject[key] = value; + } + + return hashObject; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx new file mode 100644 index 0000000000000..eadcf08c21833 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx @@ -0,0 +1,55 @@ +import { ReactComponent as AppflowyLogo } from '$app/assets/logo.svg'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { LoginButtonGroup } from '$app/components/auth/LoginButtonGroup'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import { Log } from '$app/utils/log'; + +export const Welcome = () => { + const { signInAsAnonymous } = useAuth(); + const { t } = useTranslation(); + + return ( + <> +
e.preventDefault()} method='POST'> +
+
+ +
+ +
+ + {t('welcomeTo')} {t('appName')} + +
+ +
+ +
+
+ {t('signIn.or')} +
+
+
+ +
+
+
+ + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts index b9342fb2105ef..89b7388e64389 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts @@ -1,84 +1,186 @@ -import { currentUserActions } from '$app_reducers/current-user/slice'; -import { UserProfilePB } from '@/services/backend/events/flowy-user'; +import { currentUserActions, LoginState, parseWorkspaceSettingPBToSetting } from '$app_reducers/current-user/slice'; +import { AuthenticatorPB, ProviderTypePB, UserNotification, UserProfilePB } from '@/services/backend/events/flowy-user'; import { UserService } from '$app/application/user/user.service'; import { AuthService } from '$app/application/user/auth.service'; -import { useAppSelector, useAppDispatch } from '$app/stores/store'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { getCurrentWorkspaceSetting } from '$app/application/folder/workspace.service'; import { useCallback } from 'react'; +import { subscribeNotifications } from '$app/application/notification'; +import { nanoid } from 'nanoid'; +import { open } from '@tauri-apps/api/shell'; export const useAuth = () => { const dispatch = useAppDispatch(); const currentUser = useAppSelector((state) => state.currentUser); - const checkUser = useCallback(async () => { - const userProfile = await UserService.getUserProfile(); - - if (!userProfile) return; - const workspaceSetting = await getCurrentWorkspaceSetting(); - - dispatch( - currentUserActions.checkUser({ - id: userProfile.id, - token: userProfile.token, - email: userProfile.email, - displayName: userProfile.name, - isAuthenticated: true, - workspaceSetting: workspaceSetting, - }) - ); - - return userProfile; - }, [dispatch]); - - const register = useCallback( - async (email: string, password: string, name: string): Promise => { - const userProfile = await AuthService.signUp({ email, password, name }); - - // Get the workspace setting after user registered. The workspace setting - // contains the latest visiting page and the current workspace data. - const workspaceSetting = await getCurrentWorkspaceSetting(); - - if (workspaceSetting) { + // Subscribe to user update events + const subscribeToUser = useCallback(() => { + const unsubscribePromise = subscribeNotifications({ + [UserNotification.DidUpdateUserProfile]: async (changeset) => { dispatch( currentUserActions.updateUser({ - id: userProfile.id, - token: userProfile.token, - email: userProfile.email, - displayName: userProfile.name, - isAuthenticated: true, - workspaceSetting: workspaceSetting, + email: changeset.email, + displayName: changeset.name, + iconUrl: changeset.icon_url, }) ); - } + }, + }); - return userProfile; - }, - [dispatch] - ); + return () => { + void unsubscribePromise.then((fn) => fn()); + }; + }, [dispatch]); + + const setUser = useCallback( + async (userProfile?: Partial) => { + if (!userProfile) return; - const login = useCallback( - async (email: string, password: string): Promise => { - const user = await AuthService.signIn({ email, password }); - const { id, token, name } = user; + const workspaceSetting = await getCurrentWorkspaceSetting(); + + const isLocal = userProfile.authenticator === AuthenticatorPB.Local; dispatch( currentUserActions.updateUser({ - id: id, - token: token, - email, - displayName: name, + id: userProfile.id, + token: userProfile.token, + email: userProfile.email, + displayName: userProfile.name, + iconUrl: userProfile.icon_url, isAuthenticated: true, + workspaceSetting: workspaceSetting ? parseWorkspaceSettingPBToSetting(workspaceSetting) : undefined, + isLocal, }) ); - return user; }, [dispatch] ); + // Check if the user is authenticated + const checkUser = useCallback(async () => { + const userProfile = await UserService.getUserProfile(); + + await setUser(userProfile); + + return userProfile; + }, [setUser]); + + const register = useCallback( + async (email: string, password: string, name: string): Promise => { + const deviceId = currentUser?.deviceId ?? nanoid(8); + const userProfile = await AuthService.signUp({ deviceId, email, password, name }); + + await setUser(userProfile); + + return userProfile; + }, + [setUser, currentUser?.deviceId] + ); + const logout = useCallback(async () => { await AuthService.signOut(); dispatch(currentUserActions.logout()); }, [dispatch]); - return { currentUser, checkUser, register, login, logout }; + const signInAsAnonymous = useCallback(async () => { + const fakeEmail = nanoid(8) + '@appflowy.io'; + const fakePassword = 'AppFlowy123@'; + const fakeName = 'Me'; + + await register(fakeEmail, fakePassword, fakeName); + }, [register]); + + const signIn = useCallback( + async (provider: ProviderTypePB) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + try { + const url = await AuthService.getOAuthURL(provider); + + await open(url); + } catch { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + } + }, + [dispatch] + ); + + const signInWithOAuth = useCallback( + async (uri: string) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + try { + const deviceId = currentUser?.deviceId ?? nanoid(8); + + await AuthService.signInWithOAuth({ uri, deviceId }); + const userProfile = await UserService.getUserProfile(); + + await setUser(userProfile); + + return userProfile; + } catch (e) { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return Promise.reject(e); + } + }, + [dispatch, currentUser?.deviceId, setUser] + ); + + // Only for development purposes + const signInWithEmailPassword = useCallback( + async (email: string, password: string, domain?: string) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + + try { + const response = await fetch( + `https://${domain ? domain : 'test.appflowy.cloud'}/gotrue/token?grant_type=password`, + { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + email, + password, + }), + } + ); + + const data = await response.json(); + + let uri = `appflowy-flutter://#`; + const params: string[] = []; + + Object.keys(data).forEach((key) => { + if (typeof data[key] === 'object') { + return; + } + + params.push(`${key}=${data[key]}`); + }); + uri += params.join('&'); + + return signInWithOAuth(uri); + } catch (e) { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return Promise.reject(e); + } + }, + [dispatch, signInWithOAuth] + ); + + return { + currentUser, + checkUser, + register, + logout, + subscribeToUser, + signInAsAnonymous, + signIn, + signInWithOAuth, + signInWithEmailPassword, + }; }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/get_started/GetStarted.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/get_started/GetStarted.tsx deleted file mode 100644 index de387a1c052a3..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/get_started/GetStarted.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from 'i18next'; -import { AppflowyLogo } from '../../_shared/svg/AppflowyLogo'; -import Button from '@mui/material/Button'; -import { useLogin } from '$app/components/auth/get_started/useLogin'; - -export const GetStarted = () => { - const { onAutoSignInClick } = useLogin(); - - return ( - <> -
e.preventDefault()} method='POST'> -
-
- -
- -
- - {t('signIn.loginTitle').replace('@:appName', 'AppFlowy')} - -
- -
- -
-
-
- - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/get_started/useLogin.ts b/frontend/appflowy_tauri/src/appflowy_app/components/auth/get_started/useLogin.ts deleted file mode 100644 index 15d607e8121ee..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/auth/get_started/useLogin.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useState } from 'react'; -import { currentUserActions } from '$app_reducers/current-user/slice'; -import { useAppDispatch } from '$app/stores/store'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../auth.hooks'; -import { nanoid } from 'nanoid'; - -export const useLogin = () => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const appDispatch = useAppDispatch(); - const navigate = useNavigate(); - const { login, register } = useAuth(); - const [authError, setAuthError] = useState(false); - - function onTogglePassword() { - setShowPassword(!showPassword); - } - - // reset error - function _setEmail(v: string) { - setAuthError(false); - setEmail(v); - } - - function _setPassword(v: string) { - setAuthError(false); - setPassword(v); - } - - async function onAutoSignInClick() { - try { - const fakeEmail = nanoid(8) + '@appflowy.io'; - const fakePassword = 'AppFlowy123@'; - const userProfile = await register(fakeEmail, fakePassword, 'Me'); - const { id, name, token } = userProfile; - - appDispatch( - currentUserActions.updateUser({ - id: id, - displayName: name, - email: email, - token: token, - isAuthenticated: true, - }) - ); - navigate('/'); - } catch (e) { - setAuthError(true); - } - } - - async function onSignInClick() { - try { - const userProfile = await login(email, password); - const { id, name, token } = userProfile; - - appDispatch( - currentUserActions.updateUser({ - id: id, - displayName: name, - email: email, - token: token, - isAuthenticated: true, - }) - ); - navigate('/'); - } catch (e) { - setAuthError(true); - } - } - - return { - showPassword, - onTogglePassword, - onSignInClick, - onAutoSignInClick, - email, - setEmail: _setEmail, - password, - setPassword: _setPassword, - authError, - }; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts index 10db88ba4326f..2597c158a137a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts @@ -52,8 +52,6 @@ export const DatabaseProvider = DatabaseContext.Provider; export const useDatabase = () => useSnapshot(useContext(DatabaseContext)); -export const useContextDatabase = () => useContext(DatabaseContext); - export const useSelectorCell = (rowId: string, fieldId: string) => { const database = useContext(DatabaseContext); const cells = useSnapshot(database.cells); @@ -86,10 +84,16 @@ export const useDispatchCell = () => { }; }; -export const useTypeOptions = () => { +export const useDatabaseSorts = () => { const context = useContext(DatabaseContext); - return useSnapshot(context.typeOptions); + return useSnapshot(context.sorts); +}; + +export const useSortsCount = () => { + const { sorts } = useDatabase(); + + return sorts?.length; }; export const useFiltersCount = () => { @@ -102,6 +106,13 @@ export const useFiltersCount = () => { ); }; +export function useStaticTypeOption(fieldId: string) { + const context = useContext(DatabaseContext); + const typeOptions = context.typeOptions; + + return typeOptions[fieldId] as T; +} + export function useTypeOption(fieldId: string) { const context = useContext(DatabaseContext); const typeOptions = useSnapshot(context.typeOptions); @@ -154,8 +165,8 @@ export const useConnectDatabase = (viewId: string) => { [DatabaseNotification.DidUpdateFieldSettings]: (changeset) => { fieldListeners.didUpdateFieldSettings(database, changeset); }, - [DatabaseNotification.DidUpdateViewRows]: (changeset) => { - rowListeners.didUpdateViewRows(database, changeset); + [DatabaseNotification.DidUpdateViewRows]: async (changeset) => { + await rowListeners.didUpdateViewRows(viewId, database, changeset); }, [DatabaseNotification.DidReorderRows]: (changeset) => { rowListeners.didReorderRows(database, changeset); @@ -171,8 +182,8 @@ export const useConnectDatabase = (viewId: string) => { [DatabaseNotification.DidUpdateFilter]: (changeset) => { filterListeners.didUpdateFilter(database, changeset); }, - [DatabaseNotification.DidUpdateViewRowsVisibility]: (changeset) => { - rowListeners.didUpdateViewRowsVisibility(database, changeset); + [DatabaseNotification.DidUpdateViewRowsVisibility]: async (changeset) => { + await rowListeners.didUpdateViewRowsVisibility(viewId, database, changeset); }, }, { id: viewId } @@ -184,8 +195,8 @@ export const useConnectDatabase = (viewId: string) => { return database; }; -const DatabaseRenderedContext = createContext<() => void>(() => { - // do nothing +const DatabaseRenderedContext = createContext<(viewId: string) => void>(() => { + return; }); export const DatabaseRenderedProvider = DatabaseRenderedContext.Provider; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx index 6acf59d7fe711..d5e7bba45ba96 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx @@ -15,6 +15,7 @@ import ExpandRecordModal from '$app/components/database/components/edit_record/E import { subscribeNotifications } from '$app/application/notification'; import { Page } from '$app_reducers/pages/slice'; import { getPage } from '$app/application/folder/page.service'; +import './database.scss'; interface Props { selectedViewId?: string; @@ -25,6 +26,7 @@ export const Database = forwardRef(({ selectedViewId, set const innerRef = useRef(); const databaseRef = (ref ?? innerRef) as React.MutableRefObject; const viewId = useViewId(); + const [settingDom, setSettingDom] = useState(null); const [page, setPage] = useState(null); const { t } = useTranslation(); @@ -56,65 +58,43 @@ export const Database = forwardRef(({ selectedViewId, set } }, [viewId]); + const parentId = page?.parentId; + useEffect(() => { void handleGetPage(); void handleResetDatabaseViews(viewId); - const unsubscribePromise = subscribeNotifications( - { - [FolderNotification.DidUpdateView]: (changeset) => { - setChildViews((prev) => { - const index = prev.findIndex((view) => view.id === changeset.id); - - if (index === -1) { - return prev; - } - - const newViews = [...prev]; - - newViews[index] = { - ...newViews[index], - name: changeset.name, - }; - - return newViews; - }); - }, - [FolderNotification.DidUpdateChildViews]: (changeset) => { - if (changeset.create_child_views.length === 0 && changeset.delete_child_views.length === 0) { - return; - } + const unsubscribePromise = subscribeNotifications({ + [FolderNotification.DidUpdateView]: (changeset) => { + if (changeset.parent_view_id !== viewId && changeset.id !== viewId) return; + setChildViews((prev) => { + const index = prev.findIndex((view) => view.id === changeset.id); - void handleResetDatabaseViews(viewId); - }, - }, - { - id: viewId, - } - ); + if (index === -1) { + return prev; + } - return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); - }, [handleGetPage, handleResetDatabaseViews, viewId]); + const newViews = [...prev]; - useEffect(() => { - const parentId = page?.parentId; + newViews[index] = { + ...newViews[index], + name: changeset.name, + }; - if (!parentId) return; + return newViews; + }); + }, + [FolderNotification.DidUpdateChildViews]: (changeset) => { + if (changeset.parent_view_id !== viewId && changeset.parent_view_id !== parentId) return; + if (changeset.create_child_views.length === 0 && changeset.delete_child_views.length === 0) { + return; + } - const unsubscribePromise = subscribeNotifications( - { - [FolderNotification.DidUpdateChildViews]: (changeset) => { - if (changeset.delete_child_views.includes(viewId)) { - setNotFound(true); - } - }, + void handleResetDatabaseViews(viewId); }, - { - id: parentId, - } - ); + }); return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); - }, [page, viewId]); + }, [handleGetPage, handleResetDatabaseViews, viewId, parentId]); const value = useMemo(() => { return Math.max( @@ -161,12 +141,16 @@ export const Database = forwardRef(({ selectedViewId, set } return ( -
+
(({ selectedViewId, set index={value} > {childViews.map((view, index) => ( - + {selectedViewId === view.id && ( <> - -
+ {settingDom && ( + onToggleCollection(view.id, forceOpen)} /> -
-
+ + )} + {editRecordRowId && ( { const viewId = useViewId(); + const { t } = useTranslation(); const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || ''); const dispatch = useAppDispatch(); @@ -21,7 +22,7 @@ export const DatabaseTitle = () => { return (
`${Math.round(value * 100)}%`, [value]); + + const options = useMemo(() => { + return Array.from({ length: count }, (_, i) => ({ + id: i, + checked: i < selectedCount, + })); + }, [count, selectedCount]); + + const isSplit = count < 6; + + return ( +
+
+ {options.map((option) => ( + + ))} +
+
{result}
+
+ ); +} + +export default LinearProgressWithLabel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx index ab591855ee636..5ecac431c4804 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx @@ -1,16 +1,30 @@ import React, { useState, Suspense, useMemo } from 'react'; import { ChecklistCell as ChecklistCellType, ChecklistField } from '$app/application/database'; -import Typography from '@mui/material/Typography'; import ChecklistCellActions from '$app/components/database/components/field_types/checklist/ChecklistCellActions'; +import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; interface Props { field: ChecklistField; cell: ChecklistCellType; + placeholder?: string; } -function ChecklistCell({ cell }: Props) { - const value = cell?.data.percentage ?? 0; +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'left', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; +function ChecklistCell({ cell, placeholder }: Props) { + const value = cell?.data.percentage ?? 0; + const options = useMemo(() => cell?.data.options ?? [], [cell?.data.options]); + const selectedOptions = useMemo(() => cell?.data.selectedOptions ?? [], [cell?.data.selectedOptions]); const [anchorEl, setAnchorEl] = useState(undefined); const open = Boolean(anchorEl); const handleClick = (e: React.MouseEvent) => { @@ -21,27 +35,32 @@ function ChecklistCell({ cell }: Props) { setAnchorEl(undefined); }; - const result = useMemo(() => `${Math.round(value * 100)}%`, [value]); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 300, + anchorEl, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); return ( <>
- - {result} - + {options.length > 0 ? ( + + ) : ( +
{placeholder}
+ )}
{open && ( {placeholder}
; }, [cell, includeTime, isRange, placeholder]); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered, calculateAnchorSize } = + usePopoverAutoPosition({ + initialPaperWidth: 248, + initialPaperHeight: 500, + anchorEl: ref.current, + initialAnchorOrigin, + initialTransformOrigin, + open, + marginThreshold: 34, + }); + + useEffect(() => { + if (!open) return; + + const anchorEl = ref.current; + + const parent = anchorEl?.parentElement?.parentElement; + + if (!anchorEl || !parent) return; + + let timeout: NodeJS.Timeout; + const handleObserve = () => { + anchorEl.scrollIntoView({ block: 'nearest' }); + + timeout = setTimeout(() => { + calculateAnchorSize(); + }, 200); + }; + + const observer = new MutationObserver(handleObserve); + + observer.observe(parent, { + childList: true, + subtree: true, + }); + return () => { + observer.disconnect(); + clearTimeout(timeout); + }; + }, [calculateAnchorSize, open]); + return ( <> -
+
{content}
{open && ( - + )} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx index 5abe28cbfeec4..a951ddd9e4b6c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx @@ -1,9 +1,21 @@ import { FC, useCallback, useMemo, useState, Suspense, lazy } from 'react'; -import { MenuProps, Menu } from '@mui/material'; +import { MenuProps } from '@mui/material'; import { SelectField, SelectCell as SelectCellType, SelectTypeOption } from '$app/application/database'; import { Tag } from '../field_types/select/Tag'; import { useTypeOption } from '$app/components/database'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import Popover from '@mui/material/Popover'; +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'center', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'center', +}; const SelectCellActions = lazy( () => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions') ); @@ -11,14 +23,6 @@ const menuProps: Partial = { classes: { list: 'py-5', }, - anchorOrigin: { - vertical: 'bottom', - horizontal: 'left', - }, - transformOrigin: { - vertical: 'top', - horizontal: 'left', - }, }; export const SelectCell: FC<{ @@ -43,6 +47,15 @@ export const SelectCell: FC<{ [typeOption] ); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 400, + anchorEl, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + return (
{open ? ( - { + const isInput = (e.target as Element).closest('input'); + + if (isInput) return; + + e.preventDefault(); + e.stopPropagation(); + }} > - - + + ) : null}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx index e07e81c1af671..c041cbde97ff7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx @@ -1,12 +1,11 @@ -import React, { FormEventHandler, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { FormEventHandler, lazy, Suspense, useCallback, useMemo, useRef } from 'react'; import { useInputCell } from '$app/components/database/components/cell/Cell.hooks'; import { Field, UrlCell as URLCellType } from '$app/application/database'; import { CellText } from '$app/components/database/_shared'; +import { openUrl } from '$app/utils/open_url'; const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput')); -const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; - interface Props { field: Field; cell: URLCellType; @@ -14,7 +13,6 @@ interface Props { } function UrlCell({ field, cell, placeholder }: Props) { - const [isUrl, setIsUrl] = useState(false); const cellRef = useRef(null); const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell); const handleClick = useCallback(() => { @@ -33,33 +31,26 @@ function UrlCell({ field, cell, placeholder }: Props) { [setValue] ); - useEffect(() => { - if (editing) return; - const str = cell.data.content; - - if (!str) return; - const isUrl = pattern.test(str); - - setIsUrl(isUrl); - }, [cell, editing]); - const content = useMemo(() => { const str = cell.data.content; if (str) { - if (isUrl) { - return ( - - {str} - - ); - } - - return str; + return ( + { + e.stopPropagation(); + openUrl(str); + }} + target={'_blank'} + className={'cursor-pointer text-content-blue-400 underline'} + > + {str} + + ); } - return
{placeholder}
; - }, [isUrl, cell, placeholder]); + return
{placeholder}
; + }, [cell, placeholder]); return ( <> @@ -68,6 +59,7 @@ function UrlCell({ field, cell, placeholder }: Props) { width: `${field.width}px`, minHeight: 37, }} + className={'cursor-text'} ref={cellRef} onClick={handleClick} > diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx index a972b4cf2806b..0fe9fb6d5b3b3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx @@ -8,9 +8,11 @@ interface Props { export const DatabaseCollection = ({ open }: Props) => { return ( -
- - +
+
+ + +
); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx index b6672ec007b67..ea1378eab88d5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { Stack } from '@mui/material'; import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; import { useTranslation } from 'react-i18next'; @@ -16,10 +15,10 @@ function DatabaseSettings(props: Props) { const [settingAnchorEl, setSettingAnchorEl] = useState(null); return ( - +
- setSettingAnchorEl(e.currentTarget)}> + setSettingAnchorEl(e.currentTarget)}> {t('settings.title')} setSettingAnchorEl(null)} /> - +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx index 61d438c9eb082..d0b89208d01b2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx @@ -8,7 +8,6 @@ function FilterSettings({ onToggleCollection }: { onToggleCollection: (forceOpen const { t } = useTranslation(); const filtersCount = useFiltersCount(); const highlight = filtersCount > 0; - const [filterAnchorEl, setFilterAnchorEl] = useState(null); const open = Boolean(filterAnchorEl); @@ -23,7 +22,7 @@ function FilterSettings({ onToggleCollection }: { onToggleCollection: (forceOpen return ( <> - + {t('grid.settings.filter')} setFilterAnchorEl(null)} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right', diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx index a89b533bcade7..af2bbce21827a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx @@ -17,6 +17,7 @@ function Properties({ onItemClick }: PropertiesProps) { const { fields } = useDatabase(); const [state, setState] = useState(fields as FieldType[]); const viewId = useViewId(); + const [menuPropertyId, setMenuPropertyId] = useState(); useEffect(() => { setState(fields as FieldType[]); @@ -60,7 +61,12 @@ function Properties({ onItemClick }: PropertiesProps) { { + setMenuPropertyId(field.id); + }} key={field.id} >
- + { + setMenuPropertyId(undefined); + }} + menuOpened={menuPropertyId === field.id} + field={field} + />
onItemClick(field)} + onClick={(e) => { + e.stopPropagation(); + onItemClick(field); + }} className={'ml-2'} > {field.visibility !== FieldVisibility.AlwaysHidden ? : } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx index 2b740e6e0be65..c6a9d244f0df8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx @@ -1,16 +1,18 @@ -import React, { useState } from 'react'; -import { Menu, MenuItem, MenuProps, Popover } from '@mui/material'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Menu, MenuProps, Popover } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Properties from '$app/components/database/components/database_settings/Properties'; import { Field } from '$app/application/database'; import { FieldVisibility } from '@/services/backend'; import { updateFieldSetting } from '$app/application/database/field/field_service'; import { useViewId } from '$app/hooks'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; type SettingsMenuProps = MenuProps; function SettingsMenu(props: SettingsMenuProps) { const viewId = useViewId(); + const ref = useRef(null); const { t } = useTranslation(); const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState< | undefined @@ -36,25 +38,39 @@ function SettingsMenu(props: SettingsMenuProps) { }); }; + const options = useMemo(() => { + return [{ key: 'properties', content:
{t('grid.settings.properties')}
}]; + }, [t]); + + const onConfirm = useCallback( + (optionKey: string) => { + if (optionKey === 'properties') { + const target = ref.current?.querySelector(`[data-key=${optionKey}]`) as HTMLElement; + const rect = target.getBoundingClientRect(); + + setPropertiesAnchorElPosition({ + top: rect.top, + left: rect.left + rect.width, + }); + props.onClose?.({}, 'backdropClick'); + } + }, + [props] + ); + return ( <> - - { - const rect = event.currentTarget.getBoundingClientRect(); - - setPropertiesAnchorElPosition({ - top: rect.top, - left: rect.left + rect.width, - }); - props.onClose?.({}, 'backdropClick'); + + { + props.onClose?.({}, 'escapeKeyDown'); }} - > - {t('grid.settings.properties')} - + options={options} + /> { setPropertiesAnchorElPosition(undefined); @@ -65,6 +81,13 @@ function SettingsMenu(props: SettingsMenuProps) { vertical: 'top', horizontal: 'right', }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} > diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx index f256f4bd2e3fa..7f978120df764 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx @@ -31,7 +31,7 @@ function SortSettings({ onToggleCollection }: Props) { return ( <> - + {t('grid.settings.sort')} + + + - - - { + onClose?.({}, 'escapeKeyDown'); + }} onClose={() => setDetailAnchorEl(null)} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx index 81fc0ed146224..412c1a953e6b8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx @@ -9,19 +9,22 @@ import MenuItem from '@mui/material/MenuItem'; interface Props extends MenuProps { rowId: string; + onEscape?: () => void; onClose?: () => void; } -function RecordActions({ anchorEl, open, onClose, rowId }: Props) { +function RecordActions({ anchorEl, open, onEscape, onClose, rowId }: Props) { const viewId = useViewId(); const { t } = useTranslation(); const handleDelRow = useCallback(() => { void rowService.deleteRow(viewId, rowId); - }, [viewId, rowId]); + onEscape?.(); + }, [viewId, rowId, onEscape]); const handleDuplicateRow = useCallback(() => { void rowService.duplicateRow(viewId, rowId); - }, [viewId, rowId]); + onEscape?.(); + }, [viewId, rowId, onEscape]); const menuOptions = [ { @@ -46,6 +49,7 @@ function RecordActions({ anchorEl, open, onClose, rowId }: Props) { onClick={() => { option.onClick(); onClose?.(); + onEscape?.(); }} > {option.icon} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx index ad01a335d4d5d..653f3c5944305 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx @@ -6,7 +6,7 @@ interface Props { } function RecordDocument({ documentId }: Props) { - return ; + return ; } -export default React.memo(RecordDocument); +export default RecordDocument; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx index f3f1820a49f51..d2381ec165dae 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx @@ -28,7 +28,7 @@ function RecordHeader({ page, row }: Props) { }, []); return ( -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx index fc3f72e224c8e..027936d280249 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx @@ -1,11 +1,20 @@ import React, { useState } from 'react'; import { updateChecklistCell } from '$app/application/database/cell/cell_service'; import { useViewId } from '$app/hooks'; -import { ReactComponent as AddIcon } from '$app/assets/add.svg'; -import { IconButton } from '@mui/material'; +import { Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; -function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) { +function AddNewOption({ + rowId, + fieldId, + onClose, + onFocus, +}: { + rowId: string; + fieldId: string; + onClose: () => void; + onFocus: () => void; +}) { const { t } = useTranslation(); const [value, setValue] = useState(''); const viewId = useViewId(); @@ -17,23 +26,36 @@ function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) { }; return ( -
+
{ if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); void createOption(); + return; + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + onClose(); + return; } }} value={value} + spellCheck={false} onChange={(e) => { setValue(e.target.value); }} /> - - - +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx index 49057654814d0..61583c474611e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx @@ -1,39 +1,74 @@ -import React from 'react'; +import React, { useState } from 'react'; import Popover, { PopoverProps } from '@mui/material/Popover'; -import { LinearProgressWithLabel } from '$app/components/database/components/field_types/checklist/LinearProgressWithLabel'; import { Divider } from '@mui/material'; import { ChecklistCell as ChecklistCellType } from '$app/application/database'; import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem'; import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption'; +import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel'; function ChecklistCellActions({ cell, + maxHeight, + maxWidth, ...props }: PopoverProps & { cell: ChecklistCellType; + maxWidth?: number; + maxHeight?: number; }) { const { fieldId, rowId } = cell; - const { percentage, selectedOptions = [], options } = cell.data; + const { percentage, selectedOptions = [], options = [] } = cell.data; + + const [focusedId, setFocusedId] = useState(null); return ( - - -
- {options?.map((option) => { - return ( - - ); - })} -
+ +
+ {options.length > 0 && ( + <> +
+ +
+
+ {options?.map((option) => { + return ( + setFocusedId(option.id)} + onClose={() => props.onClose?.({}, 'escapeKeyDown')} + checked={selectedOptions?.includes(option.id) || false} + /> + ); + })} +
- - + + + )} + + { + setFocusedId(null); + }} + onClose={() => props.onClose?.({}, 'escapeKeyDown')} + fieldId={fieldId} + rowId={rowId} + /> +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx index 9a33c8b9a337b..5c6a55fa60c04 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx @@ -1,27 +1,39 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { SelectOption } from '$app/application/database'; -import { Checkbox, IconButton } from '@mui/material'; +import { IconButton } from '@mui/material'; import { updateChecklistCell } from '$app/application/database/cell/cell_service'; import { useViewId } from '$app/hooks'; import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; +import isHotkey from 'is-hotkey'; +import debounce from 'lodash-es/debounce'; +import { useTranslation } from 'react-i18next'; + +const DELAY_CHANGE = 200; function ChecklistItem({ checked, option, rowId, fieldId, + onClose, + isSelected, + onFocus, }: { checked: boolean; option: SelectOption; rowId: string; fieldId: string; + onClose: () => void; + isSelected: boolean; + onFocus: () => void; }) { - const [hover, setHover] = useState(false); + const inputRef = React.useRef(null); + const { t } = useTranslation(); const [value, setValue] = useState(option.name); const viewId = useViewId(); - const updateText = async () => { + const updateText = useCallback(async () => { await updateChecklistCell(viewId, rowId, fieldId, { updateOptions: [ { @@ -30,49 +42,74 @@ function ChecklistItem({ }, ], }); - }; + }, [fieldId, option, rowId, value, viewId]); - const onCheckedChange = async () => { - void updateChecklistCell(viewId, rowId, fieldId, { - selectedOptionIds: [option.id], - }); - }; + const onCheckedChange = useMemo(() => { + return debounce( + () => + updateChecklistCell(viewId, rowId, fieldId, { + selectedOptionIds: [option.id], + }), + DELAY_CHANGE + ); + }, [fieldId, option.id, rowId, viewId]); - const deleteOption = async () => { + const deleteOption = useCallback(async () => { await updateChecklistCell(viewId, rowId, fieldId, { deleteOptionIds: [option.id], }); - }; + }, [fieldId, option.id, rowId, viewId]); return (
{ - setHover(true); + style={{ + backgroundColor: isSelected ? 'var(--fill-list-active)' : undefined, }} - onMouseLeave={() => { - setHover(false); - }} - className={`flex items-center justify-between gap-2 rounded p-1 text-sm ${hover ? 'bg-fill-list-hover' : ''}`} + className={`checklist-item ${ + isSelected ? 'selected' : '' + } flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-hover`} > - } - checkedIcon={} - /> +
+ {checked ? : } +
+ { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + void updateText(); + onClose(); + return; + } + + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void updateText(); + if (isHotkey('mod+enter', e)) { + void onCheckedChange(); + } + + return; + } + }} + spellCheck={false} onChange={(e) => { setValue(e.target.value); }} /> - - - +
+ + + +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx index d2b904a4a69a8..4a36498bd2f06 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx @@ -5,6 +5,7 @@ import dayjs from 'dayjs'; import { ReactComponent as LeftSvg } from '$app/assets/arrow-left.svg'; import { ReactComponent as RightSvg } from '$app/assets/arrow-right.svg'; import { IconButton } from '@mui/material'; +import './calendar.scss'; function CustomCalendar({ handleChange, @@ -14,18 +15,25 @@ function CustomCalendar({ }: { handleChange: (params: { date?: number; endDate?: number }) => void; isRange: boolean; - timestamp: number; - endTimestamp: number; + timestamp?: number; + endTimestamp?: number; }) { - const [startDate, setStartDate] = useState(new Date(timestamp * 1000)); - const [endDate, setEndDate] = useState(new Date(endTimestamp * 1000)); + const [startDate, setStartDate] = useState(() => { + if (!timestamp) return null; + return new Date(timestamp * 1000); + }); + const [endDate, setEndDate] = useState(() => { + if (!endTimestamp) return null; + return new Date(endTimestamp * 1000); + }); useEffect(() => { - if (!isRange) return; + if (!isRange || !endTimestamp) return; setEndDate(new Date(endTimestamp * 1000)); }, [isRange, endTimestamp]); useEffect(() => { + if (!timestamp) return; setStartDate(new Date(timestamp * 1000)); }, [timestamp]); @@ -33,7 +41,7 @@ function CustomCalendar({
{ return ( @@ -56,8 +64,30 @@ function CustomCalendar({ selected={startDate} onChange={(dates) => { if (!dates) return; - if (isRange) { - const [start, end] = dates as [Date | null, Date | null]; + if (isRange && Array.isArray(dates)) { + let start = dates[0] as Date; + let end = dates[1] as Date; + + if (!end && start && startDate && endDate) { + const currentTime = start.getTime(); + const startTimeStamp = startDate.getTime(); + const endTimeStamp = endDate.getTime(); + const isGreaterThanStart = currentTime > startTimeStamp; + const isGreaterThanEnd = currentTime > endTimeStamp; + const isLessThanStart = currentTime < startTimeStamp; + const isLessThanEnd = currentTime < endTimeStamp; + const isEqualsStart = currentTime === startTimeStamp; + const isEqualsEnd = currentTime === endTimeStamp; + + if ((isGreaterThanStart && isLessThanEnd) || isGreaterThanEnd) { + end = start; + start = startDate; + } else if (isEqualsStart || isEqualsEnd) { + end = start; + } else if (isLessThanStart) { + end = endDate; + } + } setStartDate(start); setEndDate(end); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx index b6f91ca74fdc7..fd5ba57889e90 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx @@ -1,10 +1,13 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MenuItem, Menu } from '@mui/material'; import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; import { DateFormatPB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface Props { value: DateFormatPB; @@ -15,17 +18,44 @@ function DateFormat({ value, onChange }: Props) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - const dateFormatMap = useMemo( - () => ({ - [DateFormatPB.Friendly]: t('grid.field.dateFormatFriendly'), - [DateFormatPB.ISO]: t('grid.field.dateFormatISO'), - [DateFormatPB.US]: t('grid.field.dateFormatUS'), - [DateFormatPB.Local]: t('grid.field.dateFormatLocal'), - [DateFormatPB.DayMonthYear]: t('grid.field.dateFormatDayMonthYear'), - }), - [t] + + const renderOptionContent = useCallback( + (option: DateFormatPB, title: string) => { + return ( +
+
{title}
+ {value === option && } +
+ ); + }, + [value] ); + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: DateFormatPB.Friendly, + content: renderOptionContent(DateFormatPB.Friendly, t('grid.field.dateFormatFriendly')), + }, + { + key: DateFormatPB.ISO, + content: renderOptionContent(DateFormatPB.ISO, t('grid.field.dateFormatISO')), + }, + { + key: DateFormatPB.US, + content: renderOptionContent(DateFormatPB.US, t('grid.field.dateFormatUS')), + }, + { + key: DateFormatPB.Local, + content: renderOptionContent(DateFormatPB.Local, t('grid.field.dateFormatLocal')), + }, + { + key: DateFormatPB.DayMonthYear, + content: renderOptionContent(DateFormatPB.DayMonthYear, t('grid.field.dateFormatDayMonthYear')), + }, + ]; + }, [renderOptionContent, t]); + const handleClick = (option: DateFormatPB) => { onChange(option); setOpen(false); @@ -42,7 +72,6 @@ function DateFormat({ value, onChange }: Props) { setOpen(false)} > - {Object.keys(dateFormatMap).map((option) => { - const optionValue = Number(option) as DateFormatPB; - - return ( - handleClick(optionValue)} - > - {dateFormatMap[optionValue]} - {value === optionValue && } - - ); - })} + { + setOpen(false); + }} + disableFocus={true} + options={options} + onConfirm={handleClick} + /> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx index f0c6a84250412..78e3129d4ff77 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx @@ -13,14 +13,19 @@ import DateTimeFormatSelect from '$app/components/database/components/field_type import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet'; import { useTypeOption } from '$app/components/database'; import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils'; +import { notify } from '$app/components/_shared/notify'; function DateTimeCellActions({ cell, field, + maxWidth, + maxHeight, ...props }: PopoverProps & { field: DateTimeField; cell: DateTimeCell; + maxWidth?: number; + maxHeight?: number; }) { const typeOption = useTypeOption(field.id); @@ -34,10 +39,10 @@ function DateTimeCellActions({ const { includeTime } = cell.data; - const timestamp = useMemo(() => cell.data.timestamp || dayjs().unix(), [cell.data.timestamp]); - const endTimestamp = useMemo(() => cell.data.endTimestamp || dayjs().unix(), [cell.data.endTimestamp]); - const time = useMemo(() => cell.data.time || dayjs().format(timeFormat), [cell.data.time, timeFormat]); - const endTime = useMemo(() => cell.data.endTime || dayjs().format(timeFormat), [cell.data.endTime, timeFormat]); + const timestamp = useMemo(() => cell.data.timestamp || undefined, [cell.data.timestamp]); + const endTimestamp = useMemo(() => cell.data.endTimestamp || undefined, [cell.data.endTimestamp]); + const time = useMemo(() => cell.data.time || undefined, [cell.data.time]); + const endTime = useMemo(() => cell.data.endTime || undefined, [cell.data.endTime]); const viewId = useViewId(); const { t } = useTranslation(); @@ -55,7 +60,7 @@ function DateTimeCellActions({ try { const isRange = params.isRange ?? cell.data.isRange; - await updateDateCell(viewId, cell.rowId, cell.fieldId, { + const data = { date: params.date ?? timestamp, endDate: isRange ? params.endDate ?? endTimestamp : undefined, time: params.time ?? time, @@ -63,9 +68,30 @@ function DateTimeCellActions({ includeTime: params.includeTime ?? includeTime, isRange, clearFlag: params.clearFlag, - }); + }; + + // if isRange and date is greater than endDate, swap date and endDate + if ( + data.isRange && + data.date && + data.endDate && + dayjs(dayjs.unix(data.date).format('YYYY/MM/DD ') + data.time).unix() > + dayjs(dayjs.unix(data.endDate).format('YYYY/MM/DD ') + data.endTime).unix() + ) { + if (params.date || params.time) { + data.endDate = data.date; + data.endTime = data.time; + } + + if (params.endDate || params.endTime) { + data.date = data.endDate; + data.time = data.endTime; + } + } + + await updateDateCell(viewId, cell.rowId, cell.fieldId, data); } catch (e) { - // toast.error(e.message); + notify.error(String(e)); } }, [cell, endTime, endTimestamp, includeTime, time, timestamp, viewId] @@ -75,78 +101,95 @@ function DateTimeCellActions({ return ( { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} > - - - - - -
- { - void handleChange({ - isRange: val, - // reset endTime when isRange is changed - endTime: time, - endDate: timestamp, - }); - }} - checked={isRange} +
+ - { - void handleChange({ - includeTime: val, - // reset time when includeTime is changed - time: val ? dayjs().format(timeFormat) : undefined, - endTime: val && isRange ? dayjs().format(timeFormat) : undefined, - }); - }} - checked={includeTime} + + -
- - - - - { - await handleChange({ - clearFlag: true, - }); - - props.onClose?.({}, 'backdropClick'); - }} - > - {t('grid.field.clearDate')} - - + +
+ { + void handleChange({ + isRange: val, + // reset endTime when isRange is changed + endTime: time, + endDate: timestamp, + }); + }} + checked={isRange} + /> + { + void handleChange({ + includeTime: val, + // reset time when includeTime is changed + time: val ? dayjs().format(timeFormat) : undefined, + endTime: val && isRange ? dayjs().format(timeFormat) : undefined, + }); + }} + checked={includeTime} + /> +
+ + + + + + { + await handleChange({ + isRange: false, + includeTime: false, + }); + await handleChange({ + clearFlag: true, + }); + + props.onClose?.({}, 'backdropClick'); + }} + > + {t('grid.field.clearDate')} + + +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx index 81ea28ed4712b..f0393139b0964 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx @@ -23,7 +23,6 @@ function DateTimeFormatSelect({ field }: Props) { { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + setOpen(false); + } + }} onClose={() => setOpen(false)} MenuListProps={{ className: 'px-2', diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx index 4114c93e4d0fa..82080b7d25841 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { DateField, TimeField } from '@mui/x-date-pickers-pro'; import dayjs from 'dayjs'; import { Divider } from '@mui/material'; +import debounce from 'lodash-es/debounce'; interface Props { onChange: (params: { date?: number; time?: string }) => void; @@ -23,13 +24,17 @@ const sx = { function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) { const date = useMemo(() => { - return dayjs.unix(props.date || dayjs().unix()); + return props.date ? dayjs.unix(props.date) : undefined; }, [props.date]); const time = useMemo(() => { - return dayjs(dayjs().format('YYYY/MM/DD ') + props.time); + return props.time ? dayjs(dayjs().format('YYYY/MM/DD ') + props.time) : undefined; }, [props.time]); + const debounceOnChange = useMemo(() => { + return debounce(props.onChange, 500); + }, [props.onChange]); + return (
{ if (!date) return; - props.onChange({ + debounceOnChange({ date: date.unix(), }); }} @@ -63,7 +68,7 @@ function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) }} onChange={(time) => { if (!time) return; - props.onChange({ + debounceOnChange({ time: time.format(timeFormat), }); }} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx index 8523e4ca75418..89a9ad17566a7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx @@ -1,9 +1,12 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { TimeFormatPB } from '@/services/backend'; import { useTranslation } from 'react-i18next'; import { Menu, MenuItem } from '@mui/material'; import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface Props { value: TimeFormatPB; @@ -13,14 +16,32 @@ function TimeFormat({ value, onChange }: Props) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - const timeFormatMap = useMemo( - () => ({ - [TimeFormatPB.TwelveHour]: t('grid.field.timeFormatTwelveHour'), - [TimeFormatPB.TwentyFourHour]: t('grid.field.timeFormatTwentyFourHour'), - }), - [t] + + const renderOptionContent = useCallback( + (option: TimeFormatPB, title: string) => { + return ( +
+
{title}
+ {value === option && } +
+ ); + }, + [value] ); + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: TimeFormatPB.TwelveHour, + content: renderOptionContent(TimeFormatPB.TwelveHour, t('grid.field.timeFormatTwelveHour')), + }, + { + key: TimeFormatPB.TwentyFourHour, + content: renderOptionContent(TimeFormatPB.TwentyFourHour, t('grid.field.timeFormatTwentyFourHour')), + }, + ]; + }, [renderOptionContent, t]); + const handleClick = (option: TimeFormatPB) => { onChange(option); setOpen(false); @@ -37,7 +58,6 @@ function TimeFormat({ value, onChange }: Props) { setOpen(false)} > - {Object.keys(timeFormatMap).map((option) => { - const optionValue = Number(option) as TimeFormatPB; - - return ( - handleClick(optionValue)} - > - {timeFormatMap[optionValue]} - {value === optionValue && } - - ); - })} + { + setOpen(false); + }} + disableFocus={true} + options={options} + onConfirm={handleClick} + /> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss new file mode 100644 index 0000000000000..257467ed2411f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss @@ -0,0 +1,82 @@ + +.react-datepicker__month-container { + width: 100%; + border-radius: 0; +} +.react-datepicker__header { + border-radius: 0; + background: transparent; + border-bottom: 0; + +} +.react-datepicker__day-names { + border: none; +} +.react-datepicker__day-name { + color: var(--text-caption); +} +.react-datepicker__month { + border: none; +} + +.react-datepicker__day { + border: none; + color: var(--text-title); + border-radius: 100%; +} +.react-datepicker__day:hover { + border-radius: 100%; + background: var(--fill-default); + color: var(--content-on-fill); +} +.react-datepicker__day--outside-month { + color: var(--text-caption); +} +.react-datepicker__day--in-range { + background: var(--fill-hover); + color: var(--content-on-fill); +} + + +.react-datepicker__day--today { + border: 1px solid var(--fill-default); + color: var(--text-title); + border-radius: 100%; + background: transparent; + font-weight: 500; + +} + +.react-datepicker__day--today:hover{ + background: var(--fill-default); + color: var(--content-on-fill); +} + +.react-datepicker__day--in-selecting-range, .react-datepicker__day--today.react-datepicker__day--in-range { + background: var(--fill-hover); + color: var(--content-on-fill); + border-color: transparent; +} + +.react-datepicker__day--keyboard-selected { + background: transparent; +} + + +.react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected { + &.react-datepicker__day--today { + background: var(--fill-default); + color: var(--content-on-fill); + } + background: var(--fill-default) !important; + color: var(--content-on-fill); +} + +.react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected:hover { + background: var(--fill-default); + color: var(--content-on-fill); +} + +.react-swipeable-view-container { + height: 100%; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx index a72b1a3053b1d..d2b538a7e1667 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx @@ -55,6 +55,7 @@ function EditNumberCellInput({ padding: 0, }, }} + spellCheck={false} autoFocus={true} value={value} onInput={handleInput} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx index c91717093b024..eceb12880466d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx @@ -22,8 +22,8 @@ function NumberFieldActions({ field }: { field: NumberField }) { return ( <> -
-
{t('grid.field.format')}
+
+
{t('grid.field.format')}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx index 23088de3428ef..0f9be6a21ae16 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { NumberFormatPB } from '@/services/backend'; -import { Menu, MenuItem, MenuProps } from '@mui/material'; -import { formats } from '$app/components/database/components/field_types/number/const'; +import { Menu, MenuProps } from '@mui/material'; +import { formats, formatText } from '$app/components/database/components/field_types/number/const'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; function NumberFormatMenu({ value, @@ -12,21 +15,48 @@ function NumberFormatMenu({ value: NumberFormatPB; onChangeFormat: (value: NumberFormatPB) => void; }) { + const scrollRef = useRef(null); + const onConfirm = useCallback( + (format: NumberFormatPB) => { + onChangeFormat(format); + props.onClose?.({}, 'backdropClick'); + }, + [onChangeFormat, props] + ); + + const renderContent = useCallback( + (format: NumberFormatPB) => { + return ( + <> + {formatText(format)} + {value === format && } + + ); + }, + [value] + ); + + const options: KeyboardNavigationOption[] = useMemo( + () => + formats.map((format) => ({ + key: format.value as NumberFormatPB, + content: renderContent(format.value as NumberFormatPB), + })), + [renderContent] + ); + return ( - - {formats.map((format) => ( - { - onChangeFormat(format.value as NumberFormatPB); - props.onClose?.({}, 'backdropClick'); - }} - className={'flex justify-between text-xs font-medium'} - key={format.value} - > -
{format.key}
- {value === format.value && } -
- ))} + +
+ props.onClose?.({}, 'escapeKeyDown')} + /> +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx index 1e963e8d373ca..5a02c6759b482 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx @@ -16,7 +16,7 @@ function NumberFormatSelect({ value, onChange }: { value: NumberFormatPB; onChan onClick={() => { setExpanded(!expanded); }} - className={'flex w-full justify-between'} + className={'flex w-full justify-between rounded-none'} >
{formatText(value)}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx similarity index 65% rename from frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionMenu.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx index 7b31d203b43bf..6c6cf37aaeace 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx @@ -1,5 +1,4 @@ -import { FC, useState } from 'react'; -import { t } from 'i18next'; +import { FC, useMemo, useRef, useState } from 'react'; import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material'; import { SelectOptionColorPB } from '@/services/backend'; import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; @@ -13,6 +12,8 @@ import { } from '$app/application/database/field/select_option/select_option_service'; import { useViewId } from '$app/hooks'; import Popover from '@mui/material/Popover'; +import debounce from 'lodash-es/debounce'; +import { useTranslation } from 'react-i18next'; interface SelectOptionMenuProps { fieldId: string; @@ -32,9 +33,11 @@ const Colors = [ SelectOptionColorPB.Blue, ]; -export const SelectOptionMenu: FC = ({ fieldId, option, MenuProps: menuProps }) => { +export const SelectOptionModifyMenu: FC = ({ fieldId, option, MenuProps: menuProps }) => { + const { t } = useTranslation(); const [tagName, setTagName] = useState(option.name); const viewId = useViewId(); + const inputRef = useRef(null); const updateColor = async (color: SelectOptionColorPB) => { await insertOrUpdateSelectOption(viewId, fieldId, [ { @@ -44,15 +47,18 @@ export const SelectOptionMenu: FC = ({ fieldId, option, M ]); }; - const updateName = async () => { - if (tagName === option.name) return; - await insertOrUpdateSelectOption(viewId, fieldId, [ - { - ...option, - name: tagName, - }, - ]); - }; + const updateName = useMemo(() => { + return debounce(async (tagName) => { + if (tagName === option.name) return; + + await insertOrUpdateSelectOption(viewId, fieldId, [ + { + ...option, + name: tagName, + }, + ]); + }, 500); + }, [option, viewId, fieldId]); const onClose = () => { menuProps.onClose?.({}, 'backdropClick'); @@ -78,21 +84,43 @@ export const SelectOptionMenu: FC = ({ fieldId, option, M horizontal: -32, }} {...menuProps} + onClick={(e) => { + e.stopPropagation(); + }} onClose={onClose} - disableRestoreFocus={true} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + e.preventDefault(); + e.stopPropagation(); + }} > { setTagName(e.target.value); + void updateName(e.target.value); }} - onBlur={updateName} onKeyDown={(e) => { - if (e.key === 'Enter') { - void updateName(); + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + void updateName(tagName); + onClose(); } }} + onClick={(e) => { + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} autoFocus={true} placeholder={t('grid.selectOption.tagName')} size='small' @@ -114,15 +142,21 @@ export const SelectOptionMenu: FC = ({ fieldId, option, M {Colors.map((color) => ( { + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); void updateColor(color); }} key={color} value={color} + className={'px-1.5'} > {t(`grid.selectOption.${SelectOptionColorTextMap[color]}`)} - {option.color === color && } + {option.color === color && } ))} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx deleted file mode 100644 index 03ca280599577..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { MenuItem, MenuItemProps } from '@mui/material'; -import { FC } from 'react'; -import { Tag } from '../Tag'; - -export interface CreateOptionProps { - label: React.ReactNode; - onClick?: MenuItemProps['onClick']; -} - -export const CreateOption: FC = ({ label, onClick }) => { - return ( - - - - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx index 2ac5f524ee562..5c8acb47590c1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx @@ -1,16 +1,17 @@ import React, { FormEvent, useCallback } from 'react'; -import { ListSubheader, OutlinedInput } from '@mui/material'; -import { t } from 'i18next'; +import { OutlinedInput } from '@mui/material'; +import { useTranslation } from 'react-i18next'; function SearchInput({ setNewOptionName, newOptionName, - onEnter, + inputRef, }: { newOptionName: string; setNewOptionName: (value: string) => void; - onEnter: () => void; + inputRef?: React.RefObject; }) { + const { t } = useTranslation(); const handleInput = useCallback( (event: FormEvent) => { const value = (event.target as HTMLInputElement).value; @@ -21,20 +22,16 @@ function SearchInput({ ); return ( - - { - if (e.key === 'Enter') { - onEnter(); - } - }} - placeholder={t('grid.selectOption.searchOrCreateOption')} - /> - + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx index fd658fbc5d5c1..e2cd27019fa73 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx @@ -1,7 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { MenuItem } from '@mui/material'; -import { t } from 'i18next'; -import { CreateOption } from '$app/components/database/components/field_types/select/select_cell_actions/CreateOption'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { SelectOptionItem } from '$app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem'; import { cellService, SelectCell as SelectCellType, SelectField, SelectTypeOption } from '$app/application/database'; import { useViewId } from '$app/hooks'; @@ -12,38 +9,67 @@ import { import { FieldType } from '@/services/backend'; import { useTypeOption } from '$app/components/database'; import SearchInput from './SearchInput'; +import { useTranslation } from 'react-i18next'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { Tag } from '$app/components/database/components/field_types/select/Tag'; + +const CREATE_OPTION_KEY = 'createOption'; function SelectCellActions({ field, cell, onUpdated, + onClose, }: { field: SelectField; cell: SelectCellType; onUpdated?: () => void; + onClose?: () => void; }) { + const { t } = useTranslation(); const rowId = cell?.rowId; const viewId = useViewId(); const typeOption = useTypeOption(field.id); const options = useMemo(() => typeOption.options ?? [], [typeOption.options]); - + const scrollRef = useRef(null); + const inputRef = useRef(null); const selectedOptionIds = useMemo(() => cell?.data?.selectedOptionIds ?? [], [cell]); const [newOptionName, setNewOptionName] = useState(''); - const filteredOptions = useMemo( - () => - options.filter((option) => { + + const filteredOptions: KeyboardNavigationOption[] = useMemo(() => { + const result = options + .filter((option) => { return option.name.toLowerCase().includes(newOptionName.toLowerCase()); - }), - [options, newOptionName] - ); + }) + .map((option) => ({ + key: option.id, + content: ( + + ), + })); + + if (result.length === 0 && newOptionName) { + result.push({ + key: CREATE_OPTION_KEY, + content: , + }); + } - const shouldCreateOption = !!newOptionName && filteredOptions.length === 0; + return result; + }, [newOptionName, options, selectedOptionIds, cell?.fieldId]); + + const shouldCreateOption = filteredOptions.length === 1 && filteredOptions[0].key === 'createOption'; const updateCell = useCallback( async (optionIds: string[]) => { if (!cell || !rowId) return; - const prev = selectedOptionIds; - const deleteOptionIds = prev?.filter((id) => optionIds.find((cur) => cur === id) === undefined); + const deleteOptionIds = selectedOptionIds?.filter((id) => optionIds.find((cur) => cur === id) === undefined); await cellService.updateSelectCell(viewId, rowId, field.id, { insertOptionIds: optionIds, @@ -63,83 +89,72 @@ function SelectCellActions({ return option; }, [viewId, field.id, newOptionName]); - const handleClickOption = useCallback( - (optionId: string) => { + const onConfirm = useCallback( + async (key: string) => { + let optionId = key; + + if (key === CREATE_OPTION_KEY) { + const option = await createOption(); + + optionId = option?.id || ''; + } + + if (!optionId) return; + if (field.type === FieldType.SingleSelect) { - void updateCell([optionId]); + const newOptionIds = [optionId]; + + if (selectedOptionIds?.includes(optionId)) { + newOptionIds.pop(); + } + + void updateCell(newOptionIds); return; } - const prev = selectedOptionIds; let newOptionIds = []; - if (!prev) { + if (!selectedOptionIds) { newOptionIds.push(optionId); } else { - const isSelected = prev.includes(optionId); + const isSelected = selectedOptionIds.includes(optionId); if (isSelected) { - newOptionIds = prev.filter((id) => id !== optionId); + newOptionIds = selectedOptionIds.filter((id) => id !== optionId); } else { - newOptionIds = [...prev, optionId]; + newOptionIds = [...selectedOptionIds, optionId]; } } void updateCell(newOptionIds); }, - [field.type, selectedOptionIds, updateCell] + [createOption, field.type, selectedOptionIds, updateCell] ); - const handleNewTagClick = useCallback(async () => { - if (!cell || !rowId) return; - const option = await createOption(); - - if (!option) return; - handleClickOption(option.id); - }, [cell, createOption, handleClickOption, rowId]); - - const handleEnter = useCallback(() => { - if (shouldCreateOption) { - void handleNewTagClick(); - } else { - if (field.type === FieldType.SingleSelect) { - const firstOption = filteredOptions[0]; - - if (!firstOption) return; - - void updateCell([firstOption.id]); - } else { - void updateCell(filteredOptions.map((option) => option.id)); - } - } - - setNewOptionName(''); - }, [field.type, filteredOptions, handleNewTagClick, shouldCreateOption, updateCell]); - return ( -
- -
- {shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')} -
- {shouldCreateOption ? ( - - ) : ( -
- {filteredOptions.map((option) => ( - - { - handleClickOption(option.id); - }} - isSelected={selectedOptionIds?.includes(option.id)} - fieldId={cell?.fieldId || ''} - option={option} - /> - - ))} +
+ + + {filteredOptions.length > 0 && ( +
+ {shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
)} + +
+ null} + /> +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx index 508d1e4aaa2ea..2a855a4085da2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx @@ -2,7 +2,7 @@ import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react'; import { IconButton } from '@mui/material'; import { ReactComponent as DetailsSvg } from '$app/assets/details.svg'; import { SelectOption } from '$app/application/database'; -import { SelectOptionMenu } from '../SelectOptionMenu'; +import { SelectOptionModifyMenu } from '../SelectOptionModifyMenu'; import { Tag } from '../Tag'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; @@ -10,10 +10,9 @@ export interface SelectOptionItemProps { option: SelectOption; fieldId: string; isSelected?: boolean; - onClick?: () => void; } -export const SelectOptionItem: FC = ({ onClick, isSelected, fieldId, option }) => { +export const SelectOptionItem: FC = ({ isSelected, fieldId, option }) => { const [open, setOpen] = useState(false); const anchorEl = useRef(null); const [hovered, setHovered] = useState(false); @@ -25,7 +24,6 @@ export const SelectOptionItem: FC = ({ onClick, isSelecte return ( <>
setHovered(true)} @@ -34,7 +32,7 @@ export const SelectOptionItem: FC = ({ onClick, isSelecte
- {isSelected && !hovered && } + {isSelected && !hovered && } {hovered && ( @@ -42,7 +40,7 @@ export const SelectOptionItem: FC = ({ onClick, isSelecte )}
{open && ( - { + return options.some((option) => option.name === newOptionName); + }, [options, newOptionName]); + const createOption = async () => { + if (!newOptionName) return; + if (isOptionExist) { + notify.error(t('grid.field.optionAlreadyExist')); + return; + } + const option = await createSelectOption(viewId, fieldId, newOptionName); if (!option) return; @@ -31,13 +43,23 @@ function AddAnOption({ fieldId }: { fieldId: string }) { { setNewOptionName(e.target.value); }} value={newOptionName} onKeyDown={(e) => { if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); void createOption(); + return; + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + exitEdit(); } }} className={'mx-2 mb-1'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx index f0190b5c92e91..ad363d4a1d124 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx @@ -3,7 +3,7 @@ import { ReactComponent as MoreIcon } from '$app/assets/more.svg'; import { SelectOption } from '$app/application/database'; // import { ReactComponent as DragIcon } from '$app/assets/drag.svg'; -import { SelectOptionMenu } from '$app/components/database/components/field_types/select/SelectOptionMenu'; +import { SelectOptionModifyMenu } from '$app/components/database/components/field_types/select/SelectOptionModifyMenu'; import Button from '@mui/material/Button'; import { SelectOptionColorMap } from '$app/components/database/components/field_types/select/constants'; @@ -26,7 +26,7 @@ function Option({ option, fieldId }: { option: SelectOption; fieldId: string })
{option.name}
- +
{options.map((option) => { return
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx index 895f3cb39cfbb..fdd7bccb5b225 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx @@ -1,20 +1,20 @@ import React, { FC, useMemo, useState } from 'react'; import { - Filter as FilterType, - Field as FieldData, - UndeterminedFilter, - TextFilterData, - SelectFilterData, - NumberFilterData, CheckboxFilterData, ChecklistFilterData, DateFilterData, + Field as FieldData, + Filter as FilterType, + NumberFilterData, + SelectFilterData, + TextFilterData, + UndeterminedFilter, } from '$app/application/database'; import { Chip, Popover } from '@mui/material'; import { Property } from '$app/components/database/components/property'; import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg'; import TextFilter from './text_filter/TextFilter'; -import { FieldType } from '@/services/backend'; +import { CheckboxFilterConditionPB, ChecklistFilterConditionPB, FieldType } from '@/services/backend'; import FilterActions from '$app/components/database/components/filter/FilterActions'; import { updateFilter } from '$app/application/database/filter/filter_service'; import { useViewId } from '$app/hooks'; @@ -22,6 +22,11 @@ import SelectFilter from './select_filter/SelectFilter'; import DateFilter from '$app/components/database/components/filter/date_filter/DateFilter'; import FilterConditionSelect from '$app/components/database/components/filter/FilterConditionSelect'; +import TextFilterValue from '$app/components/database/components/filter/text_filter/TextFilterValue'; +import SelectFilterValue from '$app/components/database/components/filter/select_filter/SelectFilterValue'; +import NumberFilterValue from '$app/components/database/components/filter/number_filter/NumberFilterValue'; +import { useTranslation } from 'react-i18next'; +import DateFilterValue from '$app/components/database/components/filter/date_filter/DateFilterValue'; interface Props { filter: FilterType; @@ -32,6 +37,7 @@ interface FilterComponentProps { filter: FilterType; field: FieldData; onChange: (data: UndeterminedFilter['data']) => void; + onClose?: () => void; } type FilterComponent = FC; @@ -56,6 +62,7 @@ const getFilterComponent = (field: FieldData) => { function Filter({ filter, field }: Props) { const viewId = useViewId(); + const { t } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const handleClick = (e: React.MouseEvent) => { @@ -69,7 +76,10 @@ function Filter({ filter, field }: Props) { const onDataChange = async (data: UndeterminedFilter['data']) => { const newFilter = { ...filter, - data, + data: { + ...(filter.data || {}), + ...data, + }, } as UndeterminedFilter; try { @@ -104,22 +114,49 @@ function Filter({ filter, field }: Props) { } }, [field, filter]); + const conditionValue = useMemo(() => { + switch (field.type) { + case FieldType.RichText: + case FieldType.URL: + return ; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return ; + case FieldType.Number: + return ; + case FieldType.Checkbox: + return (filter.data as CheckboxFilterData).condition === CheckboxFilterConditionPB.IsChecked + ? t('grid.checkboxFilter.isChecked') + : t('grid.checkboxFilter.isUnchecked'); + case FieldType.Checklist: + return (filter.data as ChecklistFilterData).condition === ChecklistFilterConditionPB.IsComplete + ? t('grid.checklistFilter.isComplete') + : t('grid.checklistFilter.isIncomplted'); + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return ; + default: + return ''; + } + }, [field.id, field.type, filter.data, t]); + return ( <> - - +
+ + {conditionValue} +
} onClick={handleClick} /> {condition !== undefined && open && ( { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + handleClose(); + } + }} >
- {Component && } + {Component && }
)} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx index 090102f34b6ab..ebc9e8982c670 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx @@ -1,17 +1,22 @@ -import React, { useState } from 'react'; -import { IconButton, Menu, MenuItem } from '@mui/material'; +import React, { useMemo, useState } from 'react'; +import { IconButton, Menu } from '@mui/material'; import { ReactComponent as MoreSvg } from '$app/assets/details.svg'; import { Filter } from '$app/application/database'; import { useTranslation } from 'react-i18next'; import { deleteFilter } from '$app/application/database/filter/filter_service'; import { useViewId } from '$app/hooks'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; function FilterActions({ filter }: { filter: Filter }) { const viewId = useViewId(); const { t } = useTranslation(); + const [disableSelect, setDisableSelect] = useState(true); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const onClose = () => { + setDisableSelect(true); setAnchorEl(null); }; @@ -21,8 +26,20 @@ function FilterActions({ filter }: { filter: Filter }) { } catch (e) { // toast.error(e.message); } + + setDisableSelect(true); }; + const options: KeyboardNavigationOption[] = useMemo( + () => [ + { + key: 'delete', + content: t('grid.settings.deleteFilter'), + }, + ], + [t] + ); + return ( <> - - {t('grid.settings.deleteFilter')} - + {open && ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} + keepMounted={false} + open={open} + anchorEl={anchorEl} + onClose={onClose} + > + { + if (e.key === 'ArrowDown') { + setDisableSelect(false); + } + }} + disableSelect={disableSelect} + options={options} + onConfirm={onDelete} + onEscape={onClose} + /> + + )} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx index ca5731222f96d..8b793942daa5f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx @@ -6,7 +6,7 @@ import { DateFilterConditionPB, FieldType, NumberFilterConditionPB, - SelectOptionConditionPB, + SelectOptionFilterConditionPB, TextFilterConditionPB, } from '@/services/backend'; @@ -30,27 +30,27 @@ function FilterConditionSelect({ case FieldType.URL: return [ { - value: TextFilterConditionPB.Contains, + value: TextFilterConditionPB.TextContains, text: t('grid.textFilter.contains'), }, { - value: TextFilterConditionPB.DoesNotContain, + value: TextFilterConditionPB.TextDoesNotContain, text: t('grid.textFilter.doesNotContain'), }, { - value: TextFilterConditionPB.StartsWith, + value: TextFilterConditionPB.TextStartsWith, text: t('grid.textFilter.startWith'), }, { - value: TextFilterConditionPB.EndsWith, + value: TextFilterConditionPB.TextEndsWith, text: t('grid.textFilter.endsWith'), }, { - value: TextFilterConditionPB.Is, + value: TextFilterConditionPB.TextIs, text: t('grid.textFilter.is'), }, { - value: TextFilterConditionPB.IsNot, + value: TextFilterConditionPB.TextIsNot, text: t('grid.textFilter.isNot'), }, { @@ -63,26 +63,51 @@ function FilterConditionSelect({ }, ]; case FieldType.SingleSelect: + return [ + { + value: SelectOptionFilterConditionPB.OptionIs, + text: t('grid.selectOptionFilter.is'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNot, + text: t('grid.selectOptionFilter.isNot'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsEmpty, + text: t('grid.selectOptionFilter.isEmpty'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNotEmpty, + text: t('grid.selectOptionFilter.isNotEmpty'), + }, + ]; case FieldType.MultiSelect: return [ { - value: SelectOptionConditionPB.OptionIs, - text: t('grid.singleSelectOptionFilter.is'), + value: SelectOptionFilterConditionPB.OptionIs, + text: t('grid.selectOptionFilter.is'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNot, + text: t('grid.selectOptionFilter.isNot'), }, { - value: SelectOptionConditionPB.OptionIsNot, - text: t('grid.singleSelectOptionFilter.isNot'), + value: SelectOptionFilterConditionPB.OptionContains, + text: t('grid.selectOptionFilter.contains'), }, { - value: SelectOptionConditionPB.OptionIsEmpty, - text: t('grid.singleSelectOptionFilter.isEmpty'), + value: SelectOptionFilterConditionPB.OptionDoesNotContain, + text: t('grid.selectOptionFilter.doesNotContain'), }, { - value: SelectOptionConditionPB.OptionIsNotEmpty, - text: t('grid.singleSelectOptionFilter.isNotEmpty'), + value: SelectOptionFilterConditionPB.OptionIsEmpty, + text: t('grid.selectOptionFilter.isEmpty'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNotEmpty, + text: t('grid.selectOptionFilter.isNotEmpty'), }, ]; - case FieldType.Number: return [ { @@ -183,14 +208,12 @@ function FilterConditionSelect({ }, [fieldType, t]); return ( -
+
{name}
{ - const value = Number(e.target.value); - - onChange(value); + onChange(e); }} value={condition} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx index 1b6d87296b5aa..e161badbf8e05 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { MenuProps } from '@mui/material'; import PropertiesList from '$app/components/database/components/property/PropertiesList'; import { Field } from '$app/application/database'; @@ -18,7 +18,7 @@ function FilterFieldsMenu({ const { t } = useTranslation(); const addFilter = useCallback( - async (event: MouseEvent, field: Field) => { + async (field: Field) => { const filterData = getDefaultFilter(field.type); await insertFilter({ @@ -34,8 +34,24 @@ function FilterFieldsMenu({ ); return ( - - + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + {...props} + > + { + props.onClose?.({}, 'escapeKeyDown'); + }} + showSearch + searchPlaceholder={t('grid.settings.filterBy')} + onItemClick={addFilter} + /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx index 2ded60dbb40d7..860ce9f69f566 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx @@ -29,9 +29,9 @@ function Filters() { }; return ( -
+
{options.map(({ filter, field }) => (field ? : null))} - { - const now = Date.now() / 1000; - if (isRange) { - return filter.data.start ? filter.data.start : now; + return filter.data.start; } - return filter.data.timestamp ? filter.data.timestamp : now; + return filter.data.timestamp; }, [filter.data.start, filter.data.timestamp, isRange]); const endTimestamp = useMemo(() => { - const now = Date.now() / 1000; - if (isRange) { - return filter.data.end ? filter.data.end : now; + return filter.data.end; } - return now; + return; }, [filter.data.end, isRange]); const timeFormat = useMemo(() => { @@ -64,7 +60,7 @@ function DateFilter({ filter, field, onChange }: Props) { onChange({ condition, timestamp: date, - start: date, + start: endDate ? date : undefined, end: endDate, }); }} @@ -81,7 +77,7 @@ function DateFilter({ filter, field, onChange }: Props) { onChange({ condition, timestamp: date, - start: date, + start: endDate ? date : undefined, end: endDate, }); }} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx new file mode 100644 index 0000000000000..dd75d25852f16 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx @@ -0,0 +1,52 @@ +import React, { useMemo } from 'react'; +import { DateFilterData } from '$app/application/database'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; +import { DateFilterConditionPB } from '@/services/backend'; + +function DateFilterValue({ data }: { data: DateFilterData }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!data.timestamp) return ''; + + let startStr = ''; + let endStr = ''; + + if (data.start) { + const end = data.end ?? data.start; + const moreThanOneYear = dayjs.unix(end).diff(dayjs.unix(data.start), 'year') > 1; + const format = moreThanOneYear ? 'MMM D, YYYY' : 'MMM D'; + + startStr = dayjs.unix(data.start).format(format); + endStr = dayjs.unix(end).format(format); + } + + const timestamp = dayjs.unix(data.timestamp).format('MMM D'); + + switch (data.condition) { + case DateFilterConditionPB.DateIs: + return `: ${timestamp}`; + case DateFilterConditionPB.DateBefore: + return `: ${t('grid.dateFilter.choicechipPrefix.before')} ${timestamp}`; + case DateFilterConditionPB.DateAfter: + return `: ${t('grid.dateFilter.choicechipPrefix.after')} ${timestamp}`; + case DateFilterConditionPB.DateOnOrBefore: + return `: ${t('grid.dateFilter.choicechipPrefix.onOrBefore')} ${timestamp}`; + case DateFilterConditionPB.DateOnOrAfter: + return `: ${t('grid.dateFilter.choicechipPrefix.onOrAfter')} ${timestamp}`; + case DateFilterConditionPB.DateWithIn: + return `: ${startStr} - ${endStr}`; + case DateFilterConditionPB.DateIsEmpty: + return `: ${t('grid.dateFilter.choicechipPrefix.isEmpty')}`; + case DateFilterConditionPB.DateIsNotEmpty: + return `: ${t('grid.dateFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [data, t]); + + return <>{value}; +} + +export default DateFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx new file mode 100644 index 0000000000000..658ef13d69817 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import { NumberFilterData } from '$app/application/database'; +import { NumberFilterConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; + +function NumberFilterValue({ data }: { data: NumberFilterData }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!data.content) { + return ''; + } + + const content = parseInt(data.content); + + switch (data.condition) { + case NumberFilterConditionPB.Equal: + return `= ${content}`; + case NumberFilterConditionPB.NotEqual: + return `!= ${content}`; + case NumberFilterConditionPB.GreaterThan: + return `> ${content}`; + case NumberFilterConditionPB.GreaterThanOrEqualTo: + return `>= ${content}`; + case NumberFilterConditionPB.LessThan: + return `< ${content}`; + case NumberFilterConditionPB.LessThanOrEqualTo: + return `<= ${content}`; + case NumberFilterConditionPB.NumberIsEmpty: + return t('grid.textFilter.isEmpty'); + case NumberFilterConditionPB.NumberIsNotEmpty: + return t('grid.textFilter.isNotEmpty'); + } + }, [data.condition, data.content, t]); + + return <>{value}; +} + +export default NumberFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx index 02ad8d6f25b4a..bd1d1f239adbc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx @@ -1,31 +1,49 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { SelectField, SelectFilter as SelectFilterType, SelectFilterData, SelectTypeOption, } from '$app/application/database'; -import { MenuItem, MenuList } from '@mui/material'; import { Tag } from '$app/components/database/components/field_types/select/Tag'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; -import { SelectOptionConditionPB } from '@/services/backend'; +import { SelectOptionFilterConditionPB } from '@/services/backend'; import { useTypeOption } from '$app/components/database'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface Props { filter: SelectFilterType; field: SelectField; onChange: (filterData: SelectFilterData) => void; + onClose?: () => void; } -function SelectFilter({ filter, field, onChange }: Props) { +function SelectFilter({ onClose, filter, field, onChange }: Props) { + const scrollRef = useRef(null); const condition = filter.data.condition; const typeOption = useTypeOption(field.id); - const options = useMemo(() => typeOption.options ?? [], [typeOption]); + const options: KeyboardNavigationOption[] = useMemo(() => { + return ( + typeOption?.options?.map((option) => { + return { + key: option.id, + content: ( +
+ + {filter.data.optionIds?.includes(option.id) && } +
+ ), + }; + }) ?? [] + ); + }, [filter.data.optionIds, typeOption?.options]); const showOptions = options.length > 0 && - condition !== SelectOptionConditionPB.OptionIsEmpty && - condition !== SelectOptionConditionPB.OptionIsNotEmpty; + condition !== SelectOptionFilterConditionPB.OptionIsEmpty && + condition !== SelectOptionFilterConditionPB.OptionIsNotEmpty; const handleChange = ({ condition, @@ -65,22 +83,9 @@ function SelectFilter({ filter, field, onChange }: Props) { if (!showOptions) return null; return ( - - {options?.map((option) => { - const isSelected = filter.data.optionIds?.includes(option.id); - - return ( - handleSelectOption(option.id)} - key={option.id} - > - - {isSelected && } - - ); - })} - +
+ +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx new file mode 100644 index 0000000000000..72576deae1c99 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react'; +import { SelectFilterData, SelectTypeOption } from '$app/application/database'; +import { useStaticTypeOption } from '$app/components/database'; +import { useTranslation } from 'react-i18next'; +import { SelectOptionFilterConditionPB } from '@/services/backend'; + +function SelectFilterValue({ data, fieldId }: { data: SelectFilterData; fieldId: string }) { + const typeOption = useStaticTypeOption(fieldId); + const { t } = useTranslation(); + const value = useMemo(() => { + if (!data.optionIds?.length) return ''; + + const options = data.optionIds + .map((optionId) => { + const option = typeOption?.options?.find((option) => option.id === optionId); + + return option?.name; + }) + .join(', '); + + switch (data.condition) { + case SelectOptionFilterConditionPB.OptionIs: + return `: ${options}`; + case SelectOptionFilterConditionPB.OptionIsNot: + return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${options}`; + case SelectOptionFilterConditionPB.OptionIsEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`; + case SelectOptionFilterConditionPB.OptionIsNotEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [data.condition, data.optionIds, t, typeOption?.options]); + + return <>{value}; +} + +export default SelectFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx index ccb3e6dde5291..0c7eab6e05c85 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx @@ -1,13 +1,17 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { TextFilter as TextFilterType, TextFilterData } from '$app/application/database'; import { TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { TextFilterConditionPB } from '@/services/backend'; +import debounce from 'lodash-es/debounce'; interface Props { filter: TextFilterType; onChange: (filterData: TextFilterData) => void; } + +const DELAY = 500; + function TextFilter({ filter, onChange }: Props) { const { t } = useTranslation(); const [content, setContext] = useState(filter.data.content); @@ -15,21 +19,29 @@ function TextFilter({ filter, onChange }: Props) { const showField = condition !== TextFilterConditionPB.TextIsEmpty && condition !== TextFilterConditionPB.TextIsNotEmpty; + const onConditionChange = useMemo(() => { + return debounce((content: string) => { + onChange({ + content, + condition, + }); + }, DELAY); + }, [condition, onChange]); + if (!showField) return null; return ( { setContext(e.target.value); - }} - onBlur={() => { - onChange({ - content, - condition, - }); + onConditionChange(e.target.value ?? ''); }} /> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx new file mode 100644 index 0000000000000..5718a3e2b8be2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { TextFilterData } from '$app/application/database'; +import { TextFilterConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; + +function TextFilterValue({ data }: { data: TextFilterData }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!data.content) return ''; + switch (data.condition) { + case TextFilterConditionPB.TextContains: + case TextFilterConditionPB.TextIs: + return `: ${data.content}`; + case TextFilterConditionPB.TextDoesNotContain: + case TextFilterConditionPB.TextIsNot: + return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${data.content}`; + case TextFilterConditionPB.TextStartsWith: + return `: ${t('grid.textFilter.choicechipPrefix.startWith')} ${data.content}`; + case TextFilterConditionPB.TextEndsWith: + return `: ${t('grid.textFilter.choicechipPrefix.endWith')} ${data.content}`; + case TextFilterConditionPB.TextIsEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`; + case TextFilterConditionPB.TextIsNotEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [t, data]); + + return <>{value}; +} + +export default TextFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx index 6ff76392b3dcc..bb71befa8d0e2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx @@ -27,7 +27,12 @@ function NewProperty({ onInserted }: NewPropertyProps) { }, [onInserted, viewId]); return ( - ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx index e47e7200f8ad3..a9865c467f4b6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx @@ -1,16 +1,18 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { OutlinedInput, MenuItem, MenuList } from '@mui/material'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { OutlinedInput } from '@mui/material'; import { Property } from '$app/components/database/components/property/Property'; import { Field as FieldType } from '$app/application/database'; import { useDatabase } from '$app/components/database'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface FieldListProps { searchPlaceholder?: string; showSearch?: boolean; - onItemClick?: (event: React.MouseEvent, field: FieldType) => void; + onItemClick?: (field: FieldType) => void; + onClose?: () => void; } -function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) { +function PropertiesList({ onClose, showSearch, onItemClick, searchPlaceholder }: FieldListProps) { const { fields } = useDatabase(); const [fieldsResult, setFieldsResult] = useState(fields as FieldType[]); @@ -24,38 +26,65 @@ function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldLis [fields] ); + const inputRef = useRef(null); + const searchInput = useMemo(() => { return showSearch ? (
- +
) : null; }, [onInputChange, searchPlaceholder, showSearch]); - const emptyList = useMemo(() => { - return fieldsResult.length === 0 ? ( -
No fields found
- ) : null; + const scrollRef = useRef(null); + + const options = useMemo(() => { + return fieldsResult.map((field) => { + return { + key: field.id, + content: ( +
+ +
+ ), + }; + }); }, [fieldsResult]); + const onConfirm = useCallback( + (key: string) => { + const field = fields.find((field) => field.id === key); + + onItemClick?.(field as FieldType); + }, + [fields, onItemClick] + ); + return (
{searchInput} - {emptyList} - - {fieldsResult.map((field) => ( - { - onItemClick?.(event, field); - }} - > - - - ))} - +
+ +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx index e3a93cd28f9b7..3091ba4ea169c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx @@ -2,25 +2,39 @@ import { FC, useEffect, useRef, useState } from 'react'; import { Field as FieldType } from '$app/application/database'; import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg'; import { PropertyMenu } from '$app/components/database/components/property/PropertyMenu'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; export interface FieldProps { field: FieldType; menuOpened?: boolean; onOpenMenu?: (id: string) => void; onCloseMenu?: (id: string) => void; + className?: string; } -export const Property: FC = ({ field, onCloseMenu, menuOpened }) => { +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'right', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'center', +}; + +export const Property: FC = ({ field, onCloseMenu, className, menuOpened }) => { const ref = useRef(null); const [anchorPosition, setAnchorPosition] = useState< | { top: number; left: number; + height: number; } | undefined >(undefined); - const open = Boolean(anchorPosition) && menuOpened; + const open = Boolean(anchorPosition && menuOpened); useEffect(() => { if (menuOpened) { @@ -28,8 +42,9 @@ export const Property: FC = ({ field, onCloseMenu, menuOpened }) => if (rect) { setAnchorPosition({ - top: rect.top + rect.height, + top: rect.top + 28, left: rect.left, + height: rect.height, }); return; } @@ -38,9 +53,18 @@ export const Property: FC = ({ field, onCloseMenu, menuOpened }) => setAnchorPosition(undefined); }, [menuOpened]); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 300, + initialPaperHeight: 400, + anchorPosition, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + return ( <> -
+
{field.name}
@@ -48,10 +72,20 @@ export const Property: FC = ({ field, onCloseMenu, menuOpened }) => {open && ( { onCloseMenu?.(field.id); }} + transformOrigin={transformOrigin} + anchorOrigin={anchorOrigin} + PaperProps={{ + style: { + maxHeight: paperHeight, + width: paperWidth, + height: 'auto', + }, + className: 'flex h-full flex-col overflow-hidden', + }} anchorPosition={anchorPosition} anchorReference={'anchorPosition'} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx index ce7c57ec0075c..b3199409962e7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx @@ -1,7 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { RefObject, useCallback, useMemo, useState } from 'react'; import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; import { ReactComponent as HideSvg } from '$app/assets/hide.svg'; +import { ReactComponent as ShowSvg } from '$app/assets/eye_open.svg'; + 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'; @@ -9,13 +11,17 @@ import { ReactComponent as RightSvg } from '$app/assets/right.svg'; import { useViewId } from '$app/hooks'; import { fieldService } from '$app/application/database'; import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend'; -import { MenuItem } from '@mui/material'; import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog'; import { useTranslation } from 'react-i18next'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; export enum FieldAction { EditProperty, Hide, + Show, Duplicate, Delete, InsertLeft, @@ -25,6 +31,7 @@ export enum FieldAction { const FieldActionSvgMap = { [FieldAction.EditProperty]: EditSvg, [FieldAction.Hide]: HideSvg, + [FieldAction.Show]: ShowSvg, [FieldAction.Duplicate]: CopySvg, [FieldAction.Delete]: DeleteSvg, [FieldAction.InsertLeft]: LeftSvg, @@ -47,18 +54,28 @@ interface PropertyActionsProps { fieldId: string; actions?: FieldAction[]; isPrimary?: boolean; + inputRef?: RefObject; + onClose?: () => void; onMenuItemClick?: (action: FieldAction, newFieldId?: string) => void; } -function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaultActions }: PropertyActionsProps) { +function PropertyActions({ + onClose, + inputRef, + fieldId, + onMenuItemClick, + isPrimary, + actions = defaultActions, +}: PropertyActionsProps) { const viewId = useViewId(); const { t } = useTranslation(); const [openConfirm, setOpenConfirm] = useState(false); - + const [focusMenu, setFocusMenu] = useState(false); const menuTextMap = useMemo( () => ({ [FieldAction.EditProperty]: t('grid.field.editProperty'), [FieldAction.Hide]: t('grid.field.hide'), + [FieldAction.Show]: t('grid.field.show'), [FieldAction.Duplicate]: t('grid.field.duplicate'), [FieldAction.Delete]: t('grid.field.delete'), [FieldAction.InsertLeft]: t('grid.field.insertLeft'), @@ -101,6 +118,11 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul visibility: FieldVisibility.AlwaysHidden, }); break; + case FieldAction.Show: + await fieldService.updateFieldSetting(viewId, fieldId, { + visibility: FieldVisibility.AlwaysShown, + }); + break; case FieldAction.Duplicate: await fieldService.duplicateField(viewId, fieldId); break; @@ -112,19 +134,124 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul onMenuItemClick?.(action); }; + const renderActionContent = useCallback((item: { text: string; Icon: React.FC> }) => { + const { Icon, text } = item; + + return ( +
+ +
{text}
+
+ ); + }, []); + + const options: KeyboardNavigationOption[] = useMemo( + () => + [ + { + key: FieldAction.EditProperty, + content: renderActionContent({ + text: menuTextMap[FieldAction.EditProperty], + Icon: FieldActionSvgMap[FieldAction.EditProperty], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.EditProperty), + }, + { + key: FieldAction.InsertLeft, + content: renderActionContent({ + text: menuTextMap[FieldAction.InsertLeft], + Icon: FieldActionSvgMap[FieldAction.InsertLeft], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertLeft), + }, + { + key: FieldAction.InsertRight, + content: renderActionContent({ + text: menuTextMap[FieldAction.InsertRight], + Icon: FieldActionSvgMap[FieldAction.InsertRight], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertRight), + }, + { + key: FieldAction.Hide, + content: renderActionContent({ + text: menuTextMap[FieldAction.Hide], + Icon: FieldActionSvgMap[FieldAction.Hide], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Hide), + }, + { + key: FieldAction.Show, + content: renderActionContent({ + text: menuTextMap[FieldAction.Show], + Icon: FieldActionSvgMap[FieldAction.Show], + }), + }, + { + key: FieldAction.Duplicate, + content: renderActionContent({ + text: menuTextMap[FieldAction.Duplicate], + Icon: FieldActionSvgMap[FieldAction.Duplicate], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Duplicate), + }, + { + key: FieldAction.Delete, + content: renderActionContent({ + text: menuTextMap[FieldAction.Delete], + Icon: FieldActionSvgMap[FieldAction.Delete], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Delete), + }, + ].filter((option) => actions.includes(option.key)), + [renderActionContent, menuTextMap, isPrimary, actions] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const isTab = e.key === 'Tab'; + + if (!focusMenu && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + e.stopPropagation(); + notify.clear(); + notify.info(`Press Tab to focus on the menu`); + return; + } + + if (isTab) { + e.preventDefault(); + e.stopPropagation(); + if (focusMenu) { + inputRef?.current?.focus(); + setFocusMenu(false); + } else { + inputRef?.current?.blur(); + setFocusMenu(true); + } + + return; + } + }, + [focusMenu, inputRef] + ); + return ( <> - {actions.map((action) => { - const ActionSvg = FieldActionSvgMap[action]; - const disabled = isPrimary && primaryPreventDefaultActions.includes(action); - - return ( - handleMenuItemClick(action)} key={action} dense> - - {menuTextMap[action]} - - ); - })} + { + setFocusMenu(true); + }} + onBlur={() => { + setFocusMenu(false); + }} + onKeyDown={handleKeyDown} + onConfirm={handleMenuItemClick} + /> { setOpenConfirm(false); - onMenuItemClick?.(FieldAction.Delete); + onClose?.(); }} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx index abd26a62eada1..55b314b821692 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx @@ -1,25 +1,35 @@ -import { Divider, MenuList } from '@mui/material'; -import { FC, useCallback } from 'react'; +import { Divider } from '@mui/material'; +import { FC, useCallback, useMemo, useRef } from 'react'; import { useViewId } from '$app/hooks'; import { Field, fieldService } from '$app/application/database'; import PropertyTypeMenuExtension from '$app/components/database/components/property/property_type/PropertyTypeMenuExtension'; import PropertyTypeSelect from '$app/components/database/components/property/property_type/PropertyTypeSelect'; -import { FieldType } from '@/services/backend'; +import { FieldType, FieldVisibility } from '@/services/backend'; import { Log } from '$app/utils/log'; import Popover, { PopoverProps } from '@mui/material/Popover'; import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput'; import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions'; -const actions = [FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete]; - export interface GridFieldMenuProps extends PopoverProps { field: Field; } export const PropertyMenu: FC = ({ field, ...props }) => { const viewId = useViewId(); + const inputRef = useRef(null); const isPrimary = field.isPrimary; + const actions = useMemo(() => { + const keys = [FieldAction.Duplicate, FieldAction.Delete]; + + if (field.visibility === FieldVisibility.AlwaysHidden) { + keys.unshift(FieldAction.Show); + } else { + keys.unshift(FieldAction.Hide); + } + + return keys; + }, [field.visibility]); const onUpdateFieldType = useCallback( async (type: FieldType) => { @@ -35,39 +45,45 @@ export const PropertyMenu: FC = ({ field, ...props }) => { return ( e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + props.onClose?.({}, 'escapeKeyDown'); + } }} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'left', + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + + e.stopPropagation(); + e.preventDefault(); }} - onClick={(e) => e.stopPropagation()} - keepMounted={false} {...props} > - - -
- {!isPrimary && ( - <> - - - - )} - - { - props.onClose?.({}, 'backdropClick'); - }} - fieldId={field.id} - /> -
-
+ +
+ {!isPrimary && ( +
+ + +
+ )} + + props.onClose?.({}, 'backdropClick')} + isPrimary={isPrimary} + actions={actions} + onMenuItemClick={() => { + props.onClose?.({}, 'backdropClick'); + }} + fieldId={field.id} + /> +
); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx index 31e7a264560d0..4e20531335234 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx @@ -1,47 +1,49 @@ -import React, { ChangeEventHandler, useCallback, useState } from 'react'; +import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import { useViewId } from '$app/hooks'; import { fieldService } from '$app/application/database'; import { Log } from '$app/utils/log'; import TextField from '@mui/material/TextField'; +import debounce from 'lodash-es/debounce'; -function PropertyNameInput({ id, name }: { id: string; name: string }) { +const PropertyNameInput = React.forwardRef(({ id, name }, ref) => { const viewId = useViewId(); const [inputtingName, setInputtingName] = useState(name); - const handleInput = useCallback>((e) => { - setInputtingName(e.target.value); - }, []); - - const handleSubmit = useCallback(async () => { - if (inputtingName !== name) { - try { - await fieldService.updateField(viewId, id, { - name: inputtingName, - }); - } catch (e) { - // TODO - Log.error(`change field ${id} name from '${name}' to ${inputtingName} fail`, e); + const handleSubmit = useCallback( + async (newName: string) => { + if (newName !== name) { + try { + await fieldService.updateField(viewId, id, { + name: newName, + }); + } catch (e) { + // TODO + Log.error(`change field ${id} name from '${name}' to ${newName} fail`, e); + } } - } - }, [viewId, id, name, inputtingName]); + }, + [viewId, id, name] + ); + + const debouncedHandleSubmit = useMemo(() => debounce(handleSubmit, 500), [handleSubmit]); + const handleInput = useCallback>( + (e) => { + setInputtingName(e.target.value); + void debouncedHandleSubmit(e.target.value); + }, + [debouncedHandleSubmit] + ); return ( { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - void handleSubmit(); - } - }} value={inputtingName} onChange={handleInput} - onBlur={handleSubmit} /> ); -} +}); export default PropertyNameInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx index 5c4522904a767..0741bbc05b3be 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx @@ -1,44 +1,84 @@ -import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material'; -import { FC, useCallback } from 'react'; +import { FC, useCallback, useMemo, useRef, useState } from 'react'; import { Field as FieldType } from '$app/application/database'; import { useDatabase } from '../../Database.hooks'; import { Property } from './Property'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { ReactComponent as DropDownSvg } from '$app/assets/more.svg'; +import Popover from '@mui/material/Popover'; -export interface FieldSelectProps extends Omit { +export interface FieldSelectProps { onChange?: (field: FieldType | undefined) => void; + value?: string; } -export const PropertySelect: FC = ({ onChange, ...props }) => { +export const PropertySelect: FC = ({ value, onChange }) => { const { fields } = useDatabase(); - const handleChange = useCallback( - (event: SelectChangeEvent) => { - const selectedId = event.target.value; + const scrollRef = useRef(null); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; - onChange?.(fields.find((field) => field.id === selectedId)); + const options: KeyboardNavigationOption[] = useMemo( + () => + fields.map((field) => { + return { + key: field.id, + content: , + }; + }), + [fields] + ); + + const onConfirm = useCallback( + (optionKey: string) => { + onChange?.(fields.find((field) => field.id === optionKey)); }, [onChange, fields] ); + const selectedField = useMemo(() => fields.find((field) => field.id === value), [fields, value]); + return ( - + <> +
{ + setOpen(true); + }} + > +
{selectedField ? : null}
+ +
+ {open && ( + +
+ +
+
+ )} + ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx index f6bb705fc4f70..e3021249ee61b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx @@ -1,29 +1,13 @@ -import { Divider, Menu, MenuItem, MenuProps } from '@mui/material'; -import { FC, useMemo } from 'react'; +import { Menu, MenuProps } from '@mui/material'; +import { FC, useCallback, useMemo } from 'react'; import { FieldType } from '@/services/backend'; import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property'; import { Field } from '$app/application/database'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; - -const FieldTypeGroup = [ - { - name: 'Basic', - types: [ - FieldType.RichText, - FieldType.Number, - FieldType.SingleSelect, - FieldType.MultiSelect, - FieldType.DateTime, - FieldType.Checkbox, - FieldType.Checklist, - FieldType.URL, - ], - }, - { - name: 'Advanced', - types: [FieldType.LastEditedTime, FieldType.CreatedTime], - }, -]; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import Typography from '@mui/material/Typography'; export const PropertyTypeMenu: FC< MenuProps & { @@ -39,23 +23,99 @@ export const PropertyTypeMenu: FC< [props.PopoverClasses] ); + const renderGroupContent = useCallback((title: string) => { + return ( + + {title} + + ); + }, []); + + const renderContent = useCallback( + (type: FieldType) => { + return ( + <> + + + + + {type === field.type && } + + ); + }, + [field.type] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: 100, + content: renderGroupContent('Basic'), + children: [ + { + key: FieldType.RichText, + content: renderContent(FieldType.RichText), + }, + { + key: FieldType.Number, + content: renderContent(FieldType.Number), + }, + { + key: FieldType.SingleSelect, + content: renderContent(FieldType.SingleSelect), + }, + { + key: FieldType.MultiSelect, + content: renderContent(FieldType.MultiSelect), + }, + { + key: FieldType.DateTime, + content: renderContent(FieldType.DateTime), + }, + { + key: FieldType.Checkbox, + content: renderContent(FieldType.Checkbox), + }, + { + key: FieldType.Checklist, + content: renderContent(FieldType.Checklist), + }, + { + key: FieldType.URL, + content: renderContent(FieldType.URL), + }, + ], + }, + { + key: 101, + content:
, + children: [], + }, + { + key: 102, + content: renderGroupContent('Advanced'), + children: [ + { + key: FieldType.LastEditedTime, + content: renderContent(FieldType.LastEditedTime), + }, + { + key: FieldType.CreatedTime, + content: renderContent(FieldType.CreatedTime), + }, + ], + }, + ]; + }, [renderContent, renderGroupContent]); + return ( - - {FieldTypeGroup.map((group, index) => [ - - {group.name} - , - group.types.map((type) => ( - onClickItem?.(type)} key={type} dense className={'flex justify-between'}> - - - - - {type === field.type && } - - )), - index < FieldTypeGroup.length - 1 && , - ])} + + props?.onClose?.({}, 'escapeKeyDown')} + options={options} + disableFocus={true} + onConfirm={onClickItem} + /> ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx index 27805c0035de5..28d62b82c6823 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx @@ -16,19 +16,21 @@ function PropertyTypeSelect({ field, onUpdateFieldType }: Props) { const ref = useRef(null); return ( -
+
{ setExpanded(!expanded); }} - className={'px-23 mx-0'} + className={'mx-0 rounded-none px-0'} > - - - - - +
+ + + + + +
{expanded && ( { [FieldType.Checklist]: t('grid.field.checklistFieldName'), [FieldType.LastEditedTime]: t('grid.field.updatedAtFieldName'), [FieldType.CreatedTime]: t('grid.field.createdAtFieldName'), + [FieldType.Relation]: t('grid.field.relationFieldName'), }; return map[type] || 'unknown'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx index e2710b06cc979..7ee4e6f83d254 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx @@ -9,6 +9,7 @@ import { ReactComponent as ChecklistSvg } from '$app/assets/database/field-type- import { ReactComponent as CheckboxSvg } from '$app/assets/database/field-type-checkbox.svg'; import { ReactComponent as URLSvg } from '$app/assets/database/field-type-url.svg'; import { ReactComponent as LastEditedTimeSvg } from '$app/assets/database/field-type-last-edited-time.svg'; +import { ReactComponent as RelationSvg } from '$app/assets/database/field-type-relation.svg'; export const FieldTypeSvgMap: Record>> = { [FieldType.RichText]: TextSvg, @@ -21,6 +22,7 @@ export const FieldTypeSvgMap: Record [FieldType.Checklist]: ChecklistSvg, [FieldType.LastEditedTime]: LastEditedTimeSvg, [FieldType.CreatedTime]: LastEditedTimeSvg, + [FieldType.Relation]: RelationSvg, }; export const ProppertyTypeSvg: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx index 0aea34d5f8d60..fdb508cb8f180 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx @@ -1,13 +1,78 @@ -import { t } from 'i18next'; -import { FC } from 'react'; -import { MenuItem, Select, SelectProps } from '@mui/material'; +import { FC, useMemo, useRef, useState } from 'react'; import { SortConditionPB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { Popover } from '@mui/material'; +import { ReactComponent as DropDownSvg } from '$app/assets/more.svg'; +import { useTranslation } from 'react-i18next'; + +export const SortConditionSelect: FC<{ + onChange?: (value: SortConditionPB) => void; + value?: SortConditionPB; +}> = ({ onChange, value }) => { + const { t } = useTranslation(); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: SortConditionPB.Ascending, + content: t('grid.sort.ascending'), + }, + { + key: SortConditionPB.Descending, + content: t('grid.sort.descending'), + }, + ]; + }, [t]); + + const onConfirm = (optionKey: SortConditionPB) => { + onChange?.(optionKey); + handleClose(); + }; + + const selectedField = useMemo(() => options.find((option) => option.key === value), [options, value]); -export const SortConditionSelect: FC> = (props) => { return ( - + <> +
{ + setOpen(true); + }} + > +
{selectedField?.content}
+ +
+ {open && ( + +
+ +
+
+ )} + ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx index ced63779d2d67..724c28467a293 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx @@ -1,4 +1,4 @@ -import React, { FC, MouseEvent, useCallback } from 'react'; +import React, { FC, useCallback } from 'react'; import { MenuProps } from '@mui/material'; import PropertiesList from '$app/components/database/components/property/PropertiesList'; import { Field, sortService } from '$app/application/database'; @@ -15,7 +15,7 @@ const SortFieldsMenu: FC< const { t } = useTranslation(); const viewId = useViewId(); const addSort = useCallback( - async (event: MouseEvent, field: Field) => { + async (field: Field) => { await sortService.insertSort(viewId, { fieldId: field.id, condition: SortConditionPB.Ascending, @@ -27,8 +27,25 @@ const SortFieldsMenu: FC< ); return ( - - + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + keepMounted={false} + {...props} + > + { + props.onClose?.({}, 'escapeKeyDown'); + }} + showSearch={true} + onItemClick={addSort} + searchPlaceholder={t('grid.settings.sortBy')} + /> ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx index bf2e45915fa26..fe1074bbdefaa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx @@ -1,4 +1,4 @@ -import { IconButton, SelectChangeEvent, Stack } from '@mui/material'; +import { IconButton, Stack } from '@mui/material'; import { FC, useCallback } from 'react'; import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; import { Field, Sort, sortService } from '$app/application/database'; @@ -28,10 +28,10 @@ export const SortItem: FC = ({ className, sort }) => { ); const handleConditionChange = useCallback( - (event: SelectChangeEvent) => { + (value: SortConditionPB) => { void sortService.updateSort(viewId, { ...sort, - condition: event.target.value as SortConditionPB, + condition: value, }); }, [viewId, sort] @@ -43,13 +43,8 @@ export const SortItem: FC = ({ className, sort }) => { return ( - - + +
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx index 148d98f1cbb7f..88df70b2e4b4a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx @@ -2,7 +2,7 @@ import { Menu, MenuProps } from '@mui/material'; import { FC, MouseEventHandler, useCallback, useState } from 'react'; import { useViewId } from '$app/hooks'; import { sortService } from '$app/application/database'; -import { useDatabase } from '../../Database.hooks'; +import { useDatabaseSorts } from '../../Database.hooks'; import { SortItem } from './SortItem'; import { useTranslation } from 'react-i18next'; @@ -15,7 +15,7 @@ export const SortMenu: FC = (props) => { const { onClose } = props; const { t } = useTranslation(); const viewId = useViewId(); - const { sorts } = useDatabase(); + const sorts = useDatabaseSorts(); const [anchorEl, setAnchorEl] = useState(null); const openFieldListMenu = Boolean(anchorEl); const handleClick = useCallback>((event) => { @@ -30,25 +30,31 @@ export const SortMenu: FC = (props) => { return ( <> { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} keepMounted={false} MenuListProps={{ - className: 'py-1', + className: 'py-1 w-[360px]', }} {...props} onClose={onClose} > -
+
{sorts.map((sort) => ( ))}
-
+
- + )} + + {open && ( void; } -export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Props) { +export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, onClose, ...props }: Props) { + const inputRef = useRef(null); + return ( - - e.stopPropagation()} - {...props} - keepMounted={false} - > - - - { - if (action === FieldAction.EditProperty) { - onOpenPropertyMenu?.(); - } else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) { - onOpenMenu?.(newFieldId); - } + e.stopPropagation()} + {...props} + onClose={onClose} + keepMounted={false} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + + e.stopPropagation(); + e.preventDefault(); + }} + > + + + onClose?.({}, 'backdropClick')} + onMenuItemClick={(action, newFieldId?: string) => { + if (action === FieldAction.EditProperty) { + onOpenPropertyMenu?.(); + } else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) { + onOpenMenu?.(newFieldId); + } - props.onClose?.({}, 'backdropClick'); - }} - fieldId={field.id} - /> - - - + onClose?.({}, 'backdropClick'); + }} + fieldId={field.id} + /> + + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx index e67345831887d..12aef74996133 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { Field, fieldService } from '$app/application/database'; import { useViewId } from '$app/hooks'; @@ -7,17 +7,16 @@ interface GridResizerProps { onWidthChange?: (width: number) => void; } -const minWidth = 100; +const minWidth = 150; export 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 newWidthRef = useRef(width); const onResize = useCallback( (e: MouseEvent) => { const diff = e.clientX - startX.current; @@ -27,25 +26,21 @@ export function GridResizer({ field, onWidthChange }: GridResizerProps) { return; } - setNewWidth(newWidth); + newWidthRef.current = newWidth; onWidthChange?.(newWidth); }, [width, onWidthChange] ); - useEffect(() => { - if (!isResizing && width !== newWidth) { - void fieldService.updateFieldSetting(viewId, fieldId, { - width: newWidth, - }); - } - }, [fieldId, isResizing, newWidth, viewId, width]); - const onResizeEnd = useCallback(() => { setIsResizing(false); + + void fieldService.updateFieldSetting(viewId, fieldId, { + width: newWidthRef.current, + }); document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', onResizeEnd); - }, [onResize]); + }, [fieldId, onResize, viewId]); const onResizeStart = useCallback( (e: React.MouseEvent) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx index 713430eb51509..4dc70e21dcdd5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx @@ -1,8 +1,8 @@ import React, { useCallback } from 'react'; import { rowService } from '$app/application/database'; import { useViewId } from '$app/hooks'; -import { t } from 'i18next'; import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useTranslation } from 'react-i18next'; interface Props { index: number; @@ -15,6 +15,7 @@ const CSS_HIGHLIGHT_PROPERTY = 'bg-content-blue-50'; function GridNewRow({ index, groupId, getContainerRef }: Props) { const viewId = useViewId(); + const { t } = useTranslation(); const handleClick = useCallback(() => { void rowService.createRow(viewId, { groupId, @@ -49,7 +50,7 @@ function GridNewRow({ index, groupId, getContainerRef }: Props) { toggleCssProperty(false); }} onClick={handleClick} - className={'grid-new-row flex grow cursor-pointer'} + className={'grid-new-row flex grow cursor-pointer text-text-title'} > (); + const { t } = useTranslation(); + const [openConfirm, setOpenConfirm] = useState(false); + const [confirmModalProps, setConfirmModalProps] = useState< + | { + onOk: () => Promise; + onCancel: () => void; + } + | undefined + >(undefined); + const { hoverRowId } = useGridTableHoverState(containerRef); + const handleOpenConfirm = useCallback((onOk: () => Promise, onCancel: () => void) => { + setOpenConfirm(true); + setConfirmModalProps({ onOk, onCancel }); + }, []); + useEffect(() => { const container = containerRef.current; @@ -32,12 +49,25 @@ function GridTableOverlay({ return (
- + + {openConfirm && ( + { + setOpenConfirm(false); + }} + {...confirmModalProps} + /> + )}
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts index 9237bb3c0392a..a4251c9ed5bbc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useViewId } from '$app/hooks'; import { rowService } from '$app/application/database'; import { autoScrollOnEdge, ScrollDirection } from '$app/components/database/_shared/dnd/utils'; +import { useSortsCount } from '$app/components/database'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; export function getCellsWithRowId(rowId: string, container: HTMLDivElement) { return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`)); @@ -64,12 +66,16 @@ function createVirtualDragElement(rowId: string, container: HTMLDivElement) { export function useDraggableGridRow( rowId: string, containerRef: React.RefObject, - getScrollElement: () => HTMLDivElement | null + getScrollElement: () => HTMLDivElement | null, + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void ) { + const viewId = useViewId(); + const sortsCount = useSortsCount(); + const [isDragging, setIsDragging] = useState(false); const dropRowIdRef = useRef(undefined); const previewRef = useRef(); - const viewId = useViewId(); + const onDragStart = useCallback( (e: React.DragEvent) => { e.dataTransfer.effectAllowed = 'move'; @@ -100,6 +106,13 @@ export function useDraggableGridRow( [containerRef, rowId, getScrollElement] ); + const moveRowTo = useCallback( + async (toRowId: string) => { + return rowService.moveRow(viewId, rowId, toRowId); + }, + [viewId, rowId] + ); + useEffect(() => { if (!isDragging) { if (previewRef.current) { @@ -156,8 +169,23 @@ export function useDraggableGridRow( e.stopPropagation(); const dropRowId = dropRowIdRef.current; + toggleProperty(container, rowId, false); if (dropRowId) { - void rowService.moveRow(viewId, rowId, dropRowId); + if (sortsCount > 0) { + onOpenConfirm( + async () => { + await deleteAllSorts(viewId); + await moveRowTo(dropRowId); + }, + () => { + void moveRowTo(dropRowId); + } + ); + } else { + void moveRowTo(dropRowId); + } + + toggleProperty(container, dropRowId, false); } setIsDragging(false); @@ -169,7 +197,7 @@ export function useDraggableGridRow( container.addEventListener('dragover', onDragOver); container.addEventListener('dragend', onDragEnd); container.addEventListener('drop', onDrop); - }, [containerRef, isDragging, rowId, viewId]); + }, [isDragging, containerRef, moveRowTo, onOpenConfirm, rowId, sortsCount, viewId]); return { isDragging, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx index 2813c05f8671c..f4b39e25617dd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx @@ -1,25 +1,31 @@ import React, { useCallback, useState } from 'react'; import { IconButton, Tooltip } from '@mui/material'; -import { t } from 'i18next'; import { ReactComponent as AddSvg } from '$app/assets/add.svg'; import { GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants'; import { rowService } from '$app/application/database'; import { useViewId } from '$app/hooks'; -import { GridRowDragButton, GridRowMenu } from '$app/components/database/grid/grid_row_actions'; +import { GridRowDragButton, GridRowMenu, toggleProperty } from '$app/components/database/grid/grid_row_actions'; import { OrderObjectPositionTypePB } from '@/services/backend'; +import { useSortsCount } from '$app/components/database'; +import { useTranslation } from 'react-i18next'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; export function GridRowActions({ rowId, rowTop, containerRef, getScrollElement, + onOpenConfirm, }: { + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; rowId?: string; rowTop?: string; containerRef: React.MutableRefObject; getScrollElement: () => HTMLDivElement | null; }) { + const { t } = useTranslation(); const viewId = useViewId(); + const sortsCount = useSortsCount(); const [menuRowId, setMenuRowId] = useState(undefined); const [menuPosition, setMenuPosition] = useState< | { @@ -31,17 +37,32 @@ export function GridRowActions({ const openMenu = Boolean(menuPosition); - const handleInsertRecordBelow = useCallback(() => { - void rowService.createRow(viewId, { - position: OrderObjectPositionTypePB.After, - rowId: rowId, - }); - }, [viewId, rowId]); + const handleCloseMenu = useCallback(() => { + setMenuPosition(undefined); + if (containerRef.current && menuRowId) { + toggleProperty(containerRef.current, menuRowId, false); + } + }, [containerRef, menuRowId]); + + const handleInsertRecordBelow = useCallback( + async (rowId: string) => { + await rowService.createRow(viewId, { + position: OrderObjectPositionTypePB.After, + rowId: rowId, + }); + handleCloseMenu(); + }, + [viewId, handleCloseMenu] + ); const handleOpenMenu = (e: React.MouseEvent) => { const target = e.target as HTMLButtonElement; const rect = target.getBoundingClientRect(); + if (containerRef.current && rowId) { + toggleProperty(containerRef.current, rowId, true); + } + setMenuRowId(rowId); setMenuPosition({ top: rect.top + rect.height / 2, @@ -49,11 +70,6 @@ export function GridRowActions({ }); }; - const handleCloseMenu = useCallback(() => { - setMenuPosition(undefined); - setMenuRowId(undefined); - }, []); - return ( <> {rowId && rowTop && ( @@ -64,10 +80,28 @@ export function GridRowActions({ left: GRID_ACTIONS_WIDTH, transform: 'translateY(4px)', }} - className={'z-10 flex w-full items-center justify-end'} + className={'z-10 flex w-full items-center justify-end py-[3px]'} > - - + + { + if (sortsCount > 0) { + onOpenConfirm( + async () => { + await deleteAllSorts(viewId); + void handleInsertRecordBelow(rowId); + }, + () => { + void handleInsertRecordBelow(rowId); + } + ); + } else { + void handleInsertRecordBelow(rowId); + } + }} + > @@ -76,12 +110,14 @@ export function GridRowActions({ rowId={rowId} containerRef={containerRef} onClick={handleOpenMenu} + onOpenConfirm={onOpenConfirm} />
)} - {openMenu && menuRowId && ( + {menuRowId && ( Promise, onCancel: () => void) => void; containerRef: React.MutableRefObject; }) { const [position, setPosition] = useState<{ left: number; top: number } | undefined>(); @@ -23,7 +25,7 @@ export function GridRowContextMenu({ if (!container || !rowId) return; toggleProperty(container, rowId, false); - setRowId(undefined); + // setRowId(undefined); }, [rowId, containerRef]); const openContextMenu = useCallback( @@ -56,8 +58,14 @@ export function GridRowContextMenu({ }; }, [containerRef, openContextMenu]); - return isContextMenuOpen && rowId ? ( - + return rowId ? ( + ) : null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx index 6d271270a974e..0790e481838fd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useDraggableGridRow } from './GridRowActions.hooks'; import { IconButton, Tooltip } from '@mui/material'; import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; @@ -9,7 +9,9 @@ export function GridRowDragButton({ containerRef, onClick, getScrollElement, + onOpenConfirm, }: { + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; rowId: string; onClick?: (e: React.MouseEvent) => void; containerRef: React.MutableRefObject; @@ -17,19 +19,40 @@ export function GridRowDragButton({ }) { const { t } = useTranslation(); - const { onDragStart } = useDraggableGridRow(rowId, containerRef, getScrollElement); + const [openTooltip, setOpenTooltip] = useState(false); + const { onDragStart, isDragging } = useDraggableGridRow(rowId, containerRef, getScrollElement, onOpenConfirm); + + useEffect(() => { + if (isDragging) { + setOpenTooltip(false); + } + }, [isDragging]); return ( - - + { + setOpenTooltip(true); + }} + onClose={() => { + setOpenTooltip(false); + }} + placement='top' + disableInteractive={true} + title={t('grid.row.dragAndClick')} > - - - + + + + + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx index 3daa5041e3d57..2190e8739b608 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; 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'; @@ -7,22 +7,27 @@ import Popover, { PopoverProps } from '@mui/material/Popover'; import { useViewId } from '$app/hooks'; import { useTranslation } from 'react-i18next'; import { rowService } from '$app/application/database'; -import { Icon, MenuItem, MenuList } from '@mui/material'; import { OrderObjectPositionTypePB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { useSortsCount } from '$app/components/database'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; -interface Option { - label: string; - icon: JSX.Element; - onClick: () => void; - divider?: boolean; +enum RowAction { + InsertAbove, + InsertBelow, + Duplicate, + Delete, } - interface Props extends PopoverProps { rowId: string; + onOpenConfirm?: (onOk: () => Promise, onCancel: () => void) => void; } -export function GridRowMenu({ rowId, ...props }: Props) { +export function GridRowMenu({ onOpenConfirm, rowId, onClose, ...props }: Props) { const viewId = useViewId(); + const sortsCount = useSortsCount(); const { t } = useTranslation(); @@ -48,56 +53,107 @@ export function GridRowMenu({ rowId, ...props }: Props) { 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, + const renderContent = useCallback((title: string, Icon: React.FC>) => { + return ( +
+ +
{title}
+
+ ); + }, []); + + const handleAction = useCallback( + (confirmKey?: RowAction) => { + switch (confirmKey) { + case RowAction.InsertAbove: + handleInsertRecordAbove(); + break; + case RowAction.InsertBelow: + handleInsertRecordBelow(); + break; + case RowAction.Duplicate: + handleDuplicateRow(); + break; + case RowAction.Delete: + handleDelRow(); + break; + default: + break; + } }, + [handleDelRow, handleDuplicateRow, handleInsertRecordAbove, handleInsertRecordBelow] + ); - { - label: t('grid.row.delete'), - icon: , - onClick: handleDelRow, - divider: true, + const onConfirm = useCallback( + (key: RowAction) => { + if (sortsCount > 0) { + onOpenConfirm?.( + async () => { + await deleteAllSorts(viewId); + handleAction(key); + }, + () => { + handleAction(key); + } + ); + } else { + handleAction(key); + } + + onClose?.({}, 'backdropClick'); }, - ]; + [handleAction, onClose, onOpenConfirm, sortsCount, viewId] + ); + + const options: KeyboardNavigationOption[] = useMemo( + () => [ + { + key: RowAction.InsertAbove, + content: renderContent(t('grid.row.insertRecordAbove'), UpSvg), + }, + { + key: RowAction.InsertBelow, + content: renderContent(t('grid.row.insertRecordBelow'), AddSvg), + }, + { + key: RowAction.Duplicate, + content: renderContent(t('grid.row.duplicate'), CopySvg), + }, + + { + key: 100, + content:
, + children: [], + }, + { + key: RowAction.Delete, + content: renderContent(t('grid.row.delete'), DelSvg), + }, + ], + [renderContent, t] + ); return ( - - - {options.map((option) => ( -
- {option.divider &&
} - { - option.onClick(); - props.onClose?.({}, 'backdropClick'); - }} - > - {option.icon} - {option.label} - -
- ))} - - + <> + +
+ { + onClose?.({}, 'escapeKeyDown'); + }} + /> +
+
+ ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx index 333edf89bad47..e9d01508b1fd0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx @@ -1,19 +1,29 @@ import React, { useCallback, useState } from 'react'; -import { GridChildComponentProps, VariableSizeGrid as Grid } from 'react-window'; +import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useGridColumn } from '$app/components/database/grid/grid_table'; import { GridField } from 'src/appflowy_app/components/database/grid/grid_field'; import NewProperty from '$app/components/database/components/property/NewProperty'; -import { GridColumn, GridColumnType } from '$app/components/database/grid/constants'; +import { GridColumn, GridColumnType, RenderRow } from '$app/components/database/grid/constants'; import { OpenMenuContext } from '$app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks'; const GridStickyHeader = React.forwardRef< - Grid | null, - { columns: GridColumn[]; getScrollElement?: () => HTMLDivElement | null } ->(({ columns, getScrollElement }, ref) => { + Grid | null, + { + columns: GridColumn[]; + getScrollElement?: () => HTMLDivElement | null; + onScroll?: (props: GridOnScrollProps) => void; + } +>(({ onScroll, columns, getScrollElement }, ref) => { const { columnWidth, resizeColumnWidth } = useGridColumn( columns, - ref as React.MutableRefObject | null> + ref as React.MutableRefObject | null> ); const [openMenuId, setOpenMenuId] = useState(null); @@ -33,9 +43,10 @@ const GridStickyHeader = React.forwardRef< }, []); const Cell = useCallback( - ({ columnIndex, style }: GridChildComponentProps) => { - const column = columns[columnIndex]; + ({ columnIndex, style, data }: GridChildComponentProps) => { + const column = data[columnIndex]; + if (!column || column.type === GridColumnType.Action) return
; if (column.type === GridColumnType.NewProperty) { const width = (style.width || 0) as number; @@ -43,7 +54,7 @@ const GridStickyHeader = React.forwardRef<
@@ -52,10 +63,6 @@ const GridStickyHeader = React.forwardRef< ); } - if (column.type === GridColumnType.Action) { - return
; - } - const field = column.field; if (!field) return
; @@ -72,7 +79,7 @@ const GridStickyHeader = React.forwardRef< /> ); }, - [columns, handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement] + [handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement] ); return ( @@ -81,6 +88,7 @@ const GridStickyHeader = React.forwardRef< {({ height, width }: { height: number; width: number }) => { return ( 36} @@ -88,7 +96,9 @@ const GridStickyHeader = React.forwardRef< columnCount={columns.length} columnWidth={columnWidth} ref={ref} - style={{ overflowX: 'hidden', overscrollBehavior: 'none' }} + onScroll={onScroll} + itemData={columns} + style={{ overscrollBehavior: 'none' }} > {Cell} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts index 3d534fbd3ebb4..0d676f3bb2557 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn } from '$app/components/database/grid/constants'; +import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn, RenderRow } from '$app/components/database/grid/constants'; import { VariableSizeGrid as Grid } from 'react-window'; export function useGridRow() { @@ -12,7 +12,16 @@ export function useGridRow() { }; } -export function useGridColumn(columns: GridColumn[], ref: React.RefObject | null>) { +export function useGridColumn( + columns: GridColumn[], + ref: React.RefObject | null> +) { const [columnWidths, setColumnWidths] = useState([]); useEffect(() => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx index ffaa0f84586b3..0cd17d6a056a5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx @@ -10,6 +10,7 @@ import { useGridColumn, useGridRow } from './GridTable.hooks'; import GridStickyHeader from '$app/components/database/grid/grid_sticky_header/GridStickyHeader'; import GridTableOverlay from '$app/components/database/grid/grid_overlay/GridTableOverlay'; import ReactDOM from 'react-dom'; +import { useViewId } from '$app/hooks'; export interface GridTableProps { onEditRecord: (rowId: string) => void; @@ -20,8 +21,17 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { const fields = useDatabaseVisibilityFields(); const renderRows = useMemo(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]); const columns = useMemo(() => fieldsToColumns(fields), [fields]); - const ref = useRef>(null); - const { columnWidth } = useGridColumn(columns, ref); + const ref = useRef< + Grid<{ + columns: GridColumn[]; + renderRows: RenderRow[]; + }> + >(null); + const { columnWidth } = useGridColumn( + columns, + ref as React.MutableRefObject | null> + ); + const viewId = useViewId(); const { rowHeight } = useGridRow(); const onRendered = useDatabaseRendered(); @@ -54,9 +64,9 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { }, []); const Cell = useCallback( - ({ columnIndex, rowIndex, style }: GridChildComponentProps) => { - const row = renderRows[rowIndex]; - const column = columns[columnIndex]; + ({ columnIndex, rowIndex, style, data }: GridChildComponentProps) => { + const row = data.renderRows[rowIndex]; + const column = data.columns[columnIndex]; return ( = React.memo(({ onEditRecord }) => { /> ); }, - [columns, getContainerRef, renderRows, onEditRecord] + [getContainerRef, onEditRecord] ); - const staticGrid = useRef | null>(null); + const staticGrid = useRef | null>(null); const onScroll = useCallback(({ scrollLeft, scrollUpdateWasRequested }: GridOnScrollProps) => { if (!scrollUpdateWasRequested) { @@ -80,6 +90,10 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { } }, []); + const onHeaderScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => { + ref.current?.scrollTo({ scrollLeft }); + }, []); + const containerRef = useRef(null); const scrollElementRef = useRef(null); @@ -95,7 +109,12 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => {
)}
- +
@@ -110,6 +129,10 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { rowCount={renderRows.length} rowHeight={rowHeight} width={width} + itemData={{ + columns, + renderRows, + }} overscanRowCount={10} itemKey={getItemKey} style={{ @@ -118,7 +141,7 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { className={'grid-scroll-container'} outerRef={(el) => { scrollElementRef.current = el; - onRendered(); + onRendered(viewId); }} innerRef={containerRef} > diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx index c77ee0c6a36cd..079a6fd75fc5d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx @@ -1,12 +1,14 @@ -import React, { useCallback } from 'react'; -import { Editor } from 'src/appflowy_app/components/editor'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Editor from '$app/components/editor/Editor'; import { DocumentHeader } from 'src/appflowy_app/components/document/document_header'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { updatePageName } from '$app_reducers/pages/async_actions'; +import { PageCover } from '$app_reducers/pages/slice'; export function Document({ id }: { id: string }) { const page = useAppSelector((state) => state.pages.pageMap[id]); + const [cover, setCover] = useState(undefined); const dispatch = useAppDispatch(); const onTitleChange = useCallback( @@ -21,12 +23,29 @@ export function Document({ id }: { id: string }) { [dispatch, id] ); + const view = useMemo(() => { + return { + ...page, + cover, + }; + }, [page, cover]); + + useEffect(() => { + return () => { + setCover(undefined); + }; + }, [id]); + if (!page) return null; return ( -
- - +
+ +
+
+ +
+
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx index a944547870fb6..f6e8736c546c0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx @@ -1,15 +1,18 @@ -import React, { memo, useCallback } from 'react'; -import { Page, PageIcon } from '$app_reducers/pages/slice'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice'; import ViewTitle from '$app/components/_shared/view_title/ViewTitle'; import { updatePageIcon } from '$app/application/folder/page.service'; interface DocumentHeaderProps { page: Page; + onUpdateCover: (cover?: PageCover) => void; } -export function DocumentHeader({ page }: DocumentHeaderProps) { +export function DocumentHeader({ page, onUpdateCover }: DocumentHeaderProps) { const pageId = page.id; + const ref = useRef(null); + const [forceHover, setForceHover] = useState(false); const onUpdateIcon = useCallback( async (icon: PageIcon) => { await updatePageIcon(pageId, icon.value ? icon : undefined); @@ -17,10 +20,39 @@ export function DocumentHeader({ page }: DocumentHeaderProps) { [pageId] ); + useEffect(() => { + const parent = ref.current?.parentElement; + + if (!parent) return; + + const documentDom = parent.querySelector('.appflowy-editor') as HTMLElement; + + if (!documentDom) return; + + const handleMouseMove = (e: MouseEvent) => { + const isMoveInTitle = Boolean(e.target instanceof HTMLElement && e.target.closest('.document-title')); + const isMoveInHeader = Boolean(e.target instanceof HTMLElement && e.target.closest('.document-header')); + + setForceHover(isMoveInTitle || isMoveInHeader); + }; + + documentDom.addEventListener('mousemove', handleMouseMove); + return () => { + documentDom.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + if (!page) return null; return ( -
- +
+
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx index 6ba63da50358d..879dc5f9c0467 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx @@ -1,7 +1,6 @@ import React, { memo } from 'react'; import { EditorProps } from '../../application/document/document.types'; -import { Toaster } from 'react-hot-toast'; import { CollaborativeEditor } from '$app/components/editor/components/editor'; import { EditorIdProvider } from '$app/components/editor/Editor.hooks'; import './editor.scss'; @@ -12,7 +11,6 @@ export function Editor(props: EditorProps) {
-
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts index c4c13187b25cc..04a2e7c0f1710 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts @@ -1,5 +1,5 @@ import { ReactEditor } from 'slate-react'; -import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate'; +import { Editor, Element, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate'; import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types'; export function insertFormula(editor: ReactEditor, formula?: string) { @@ -49,6 +49,12 @@ export function wrapFormula(editor: ReactEditor, formula?: string) { Transforms.insertNodes(editor, formulaElement, { select: true, }); + + const path = editor.selection?.anchor.path; + + if (path) { + editor.select(path); + } } export function unwrapFormula(editor: ReactEditor) { @@ -79,9 +85,11 @@ export function unwrapFormula(editor: ReactEditor) { } export function isFormulaActive(editor: ReactEditor) { - const [node] = Editor.nodes(editor, { - match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula, + const [match] = editor.nodes({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Formula; + }, }); - return !!node; + return Boolean(match); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts index 14fcc8b63fe06..557b91f936e33 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts @@ -11,9 +11,10 @@ import { Path, EditorBeforeOptions, Text, + addMark, } from 'slate'; import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab'; -import { isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark'; +import { getAllMarks, isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark'; import { deleteFormula, insertFormula, @@ -30,6 +31,8 @@ import { ToggleListNode, inlineNodeTypes, FormulaNode, + ImageNode, + EditorMarkFormat, } from '$app/application/document/document.types'; import cloneDeep from 'lodash-es/cloneDeep'; import { generateId } from '$app/components/editor/provider/utils/convert'; @@ -39,6 +42,7 @@ export const EmbedTypes: string[] = [ EditorNodeType.DividerBlock, EditorNodeType.EquationBlock, EditorNodeType.GridBlock, + EditorNodeType.ImageBlock, ]; export const CustomEditor = { @@ -73,16 +77,66 @@ export const CustomEditor = { if (!afterPoint) return false; return CustomEditor.isInlineNode(editor, afterPoint); }, - blockEqual: (editor: ReactEditor, point: Point, anotherPoint: Point) => { - const match = CustomEditor.getBlock(editor, point); - const anotherMatch = CustomEditor.getBlock(editor, anotherPoint); - if (!match || !anotherMatch) return false; + /** + * judge if the selection is multiple block + * @param editor + * @param filterEmptyEndSelection if the filterEmptyEndSelection is true, the function will filter the empty end selection + */ + isMultipleBlockSelected: (editor: ReactEditor, filterEmptyEndSelection?: boolean): boolean => { + const { selection } = editor; + + if (!selection) return false; + + if (Range.isCollapsed(selection)) return false; + const start = Range.start(selection); + const end = Range.end(selection); + const isBackward = Range.isBackward(selection); + const startBlock = CustomEditor.getBlock(editor, start); + const endBlock = CustomEditor.getBlock(editor, end); + + if (!startBlock || !endBlock) return false; + + const [, startPath] = startBlock; + const [, endPath] = endBlock; + + const isSomePath = Path.equals(startPath, endPath); + + // if the start and end path is the same, return false + if (isSomePath) { + return false; + } + + if (!filterEmptyEndSelection) { + return true; + } + + // The end point is at the start of the end block + const focusEndStart = Point.equals(end, editor.start(endPath)); - const [node] = match; - const [anotherNode] = anotherMatch; + if (!focusEndStart) { + return true; + } + + // find the previous block + const previous = editor.previous({ + at: endPath, + match: (n) => Element.isElement(n) && n.blockId !== undefined, + }); + + if (!previous) { + return true; + } + + // backward selection + const newEnd = editor.end(editor.range(previous[1])); - return node === anotherNode; + editor.select({ + anchor: isBackward ? newEnd : start, + focus: isBackward ? start : newEnd, + }); + + return false; }, /** @@ -107,6 +161,10 @@ export const CustomEditor = { const cloneNode = CustomEditor.cloneBlock(editor, node); Object.assign(cloneNode, newProperties); + cloneNode.data = { + ...(node.data || {}), + ...(newProperties.data || {}), + }; const isEmbed = editor.isEmbed(cloneNode); @@ -120,7 +178,7 @@ export const CustomEditor = { at: path, }); Transforms.insertNodes(editor, cloneNode, { at: path }); - return; + return cloneNode; } const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType); @@ -148,6 +206,8 @@ export const CustomEditor = { if (selection) { editor.select(selection); } + + return cloneNode; }, tabForward, tabBackward, @@ -177,6 +237,10 @@ export const CustomEditor = { }, toggleAlign(editor: ReactEditor, format: string) { + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); + + if (isIncludeRoot) return; + const matchNodes = Array.from( Editor.nodes(editor, { // Note: we need to select the text node instead of the element node, otherwise the parent node will be selected @@ -229,6 +293,16 @@ export const CustomEditor = { return !!match; }, + formulaActiveNode(editor: ReactEditor) { + const [match] = editor.nodes({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Formula; + }, + }); + + return match ? (match as NodeEntry) : undefined; + }, + isMentionActive(editor: ReactEditor) { const [match] = editor.nodes({ match: (n) => { @@ -240,31 +314,54 @@ export const CustomEditor = { }, insertMention(editor: ReactEditor, mention: Mention) { - const mentionElement = { - type: EditorInlineNodeType.Mention, - children: [{ text: '@' }], - data: { - ...mention, + const mentionElement = [ + { + type: EditorInlineNodeType.Mention, + children: [{ text: '$' }], + data: { + ...mention, + }, }, - }; + ]; Transforms.insertNodes(editor, mentionElement, { select: true, }); + + editor.collapse({ + edge: 'end', + }); }, - toggleTodo(editor: ReactEditor, node: TodoListNode) { - const checked = node.data.checked; - const path = ReactEditor.findPath(editor, node); - const data = node.data || {}; - const newProperties = { - data: { - ...data, - checked: !checked, - }, - } as Partial; + toggleTodo(editor: ReactEditor, at?: Location) { + const selection = at || editor.selection; - Transforms.setNodes(editor, newProperties, { at: path }); + if (!selection) return; + + const nodes = Array.from( + editor.nodes({ + at: selection, + match: (n) => Element.isElement(n) && n.type === EditorNodeType.TodoListBlock, + }) + ); + + const matchUnChecked = nodes.some(([node]) => { + return !(node as TodoListNode).data.checked; + }); + + const checked = Boolean(matchUnChecked); + + nodes.forEach(([node, path]) => { + const data = (node as TodoListNode).data || {}; + const newProperties = { + data: { + ...data, + checked: checked, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }); }, toggleToggleList(editor: ReactEditor, node: ToggleListNode) { @@ -336,6 +433,19 @@ export const CustomEditor = { Transforms.setNodes(editor, newProperties, { at: path }); }, + setImageBlockData(editor: ReactEditor, node: Element, newData: ImageNode['data']) { + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + ...newData, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + cloneBlock(editor: ReactEditor, block: Element): Element { const cloneNode: Element = { ...cloneDeep(block), @@ -519,6 +629,14 @@ export const CustomEditor = { return editor.isEmpty(textNode); }, + includeInlineBlocks: (editor: ReactEditor) => { + const [match] = Editor.nodes(editor, { + match: (n) => Element.isElement(n) && editor.isInline(n), + }); + + return Boolean(match); + }, + getNodeTextContent(node: Node): string { if (Element.isElement(node) && node.type === EditorInlineNodeType.Formula) { return (node as FormulaNode).data || ''; @@ -534,4 +652,64 @@ export const CustomEditor = { isEmbedNode(node: Element): boolean { return EmbedTypes.includes(node.type); }, + + getListLevel(editor: ReactEditor, type: EditorNodeType, path: Path) { + let level = 0; + let currentPath = path; + + while (currentPath.length > 0) { + const parent = editor.parent(currentPath); + + if (!parent) { + break; + } + + const [parentNode, parentPath] = parent as NodeEntry; + + if (parentNode.type !== type) { + break; + } + + level += 1; + currentPath = parentPath; + } + + return level; + }, + + getLinks(editor: ReactEditor): string[] { + const marks = getAllMarks(editor); + + if (!marks) return []; + + return Object.entries(marks) + .filter(([key]) => key === 'href') + .map(([_, val]) => val as string); + }, + + extendLineBackward(editor: ReactEditor) { + Transforms.move(editor, { + unit: 'line', + edge: 'focus', + reverse: true, + }); + }, + + extendLineForward(editor: ReactEditor) { + Transforms.move(editor, { unit: 'line', edge: 'focus' }); + }, + + insertPlainText(editor: ReactEditor, text: string) { + const [appendText, ...lines] = text.split('\n'); + + editor.insertText(appendText); + lines.forEach((line) => { + editor.insertBreak(); + editor.insertText(line); + }); + }, + + highlight(editor: ReactEditor) { + addMark(editor, EditorMarkFormat.BgColor, 'appflowy_them_color_tint5'); + }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts index 9da3ca40aa528..649eaca5647e7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts @@ -1,6 +1,7 @@ import { ReactEditor } from 'slate-react'; -import { Editor, Text, Range } from 'slate'; -import { EditorMarkFormat } from '$app/application/document/document.types'; +import { Editor, Text, Range, Element } from 'slate'; +import { EditorInlineNodeType, EditorMarkFormat } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command/index'; export function toggleMark( editor: ReactEditor, @@ -9,6 +10,10 @@ export function toggleMark( value: string | boolean; } ) { + if (CustomEditor.selectionIncludeRoot(editor)) { + return; + } + const { key, value } = mark; const isActive = isMarkActive(editor, key); @@ -25,7 +30,7 @@ export function toggleMark( * @param editor * @param format */ -export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) { +export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | EditorInlineNodeType) { const selection = editor.selection; if (!selection) return false; @@ -33,18 +38,13 @@ export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) { const isExpanded = Range.isExpanded(selection); if (isExpanded) { - const matches = Array.from(getSelectionNodeEntry(editor) || []); - - return matches.every((match) => { - const [node] = match; + const texts = getSelectionTexts(editor); + return texts.every((node) => { const { text, ...attributes } = node; - if (!text) { - return true; - } - - return !!(attributes as Record)[format]; + if (!text) return true; + return Boolean((attributes as Record)[format]); }); } @@ -53,10 +53,12 @@ export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) { return marks ? !!marks[format] : false; } -function getSelectionNodeEntry(editor: ReactEditor) { +export function getSelectionTexts(editor: ReactEditor) { const selection = editor.selection; - if (!selection) return null; + if (!selection) return []; + + const texts: Text[] = []; const isExpanded = Range.isExpanded(selection); @@ -73,16 +75,25 @@ function getSelectionNodeEntry(editor: ReactEditor) { } } - return Editor.nodes(editor, { - match: Text.isText, - at: { - anchor, - focus, - }, + Array.from( + Editor.nodes(editor, { + at: { + anchor, + focus, + }, + }) + ).forEach((match) => { + const node = match[0] as Element; + + if (Text.isText(node)) { + texts.push(node); + } else if (Editor.isInline(editor, node)) { + texts.push(...(node.children as Text[])); + } }); } - return null; + return texts; } /** @@ -97,13 +108,11 @@ export function getAllMarks(editor: ReactEditor) { const isExpanded = Range.isExpanded(selection); if (isExpanded) { - const matches = Array.from(getSelectionNodeEntry(editor) || []); + const texts = getSelectionTexts(editor); const marks: Record = {}; - matches.forEach((match) => { - const [node] = match; - + texts.forEach((node) => { Object.entries(node).forEach(([key, value]) => { if (key !== 'text') { marks[key] = value; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts index 74ebe80be4a12..819596f92f9db 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts @@ -31,6 +31,10 @@ export function tabForward(editor: ReactEditor) { const [node, path] = match as NodeEntry; + const hasPrevious = Path.hasPrevious(path); + + if (!hasPrevious) return; + const previousPath = Path.previous(path); const previous = editor.node(previousPath); @@ -40,6 +44,7 @@ export function tabForward(editor: ReactEditor) { const type = previousNode.type as EditorNodeType; + if (type === EditorNodeType.Page) return; // the previous node is not a list if (!LIST_TYPES.includes(type)) return; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx index fd12121a09c16..91645e0051f3c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx @@ -24,7 +24,7 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className? }, [editor, node]); const className = useMemo(() => { - return `text-placeholder ${attributes.className ?? ''}`; + return `text-placeholder select-none ${attributes.className ?? ''}`; }, [attributes.className]); const unSelectedPlaceholder = useMemo(() => { @@ -38,15 +38,15 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className? } case EditorNodeType.ToggleListBlock: - return t('document.plugins.toggleList'); + return t('blockPlaceholders.bulletList'); case EditorNodeType.QuoteBlock: - return t('editor.quote'); + return t('blockPlaceholders.quote'); case EditorNodeType.TodoListBlock: - return t('document.plugins.todoList'); + return t('blockPlaceholders.todoList'); case EditorNodeType.NumberedListBlock: - return t('document.plugins.numberedList'); + return t('blockPlaceholders.numberList'); case EditorNodeType.BulletedListBlock: - return t('document.plugins.bulletedList'); + return t('blockPlaceholders.bulletList'); case EditorNodeType.HeadingBlock: { const level = (block as HeadingNode).data.level; @@ -102,11 +102,14 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className? const editorDom = ReactEditor.toDOMNode(editor, editor); + // placeholder should be hidden when composing editorDom.addEventListener('compositionstart', handleCompositionStart); editorDom.addEventListener('compositionend', handleCompositionEnd); + editorDom.addEventListener('compositionupdate', handleCompositionStart); return () => { editorDom.removeEventListener('compositionstart', handleCompositionStart); editorDom.removeEventListener('compositionend', handleCompositionEnd); + editorDom.removeEventListener('compositionupdate', handleCompositionStart); }; }, [editor, selected]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx index c94353d45c4a1..ea0de80f55d92 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx @@ -1,14 +1,49 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { BulletedListNode } from '$app/application/document/document.types'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +enum Letter { + Disc, + Circle, + Square, +} + +function BulletedListIcon({ block, className }: { block: BulletedListNode; className: string }) { + const staticEditor = useSlateStatic(); + const path = ReactEditor.findPath(staticEditor, block); + + const letter = useMemo(() => { + const level = CustomEditor.getListLevel(staticEditor, block.type, path); + + if (level % 3 === 0) { + return Letter.Disc; + } else if (level % 3 === 1) { + return Letter.Circle; + } else { + return Letter.Square; + } + }, [block.type, staticEditor, path]); + + const dataLetter = useMemo(() => { + switch (letter) { + case Letter.Disc: + return '•'; + case Letter.Circle: + return '◦'; + case Letter.Square: + return '▪'; + } + }, [letter]); -function BulletedListIcon({ block: _, className }: { block: BulletedListNode; className: string }) { return ( { e.preventDefault(); }} + data-letter={dataLetter} contentEditable={false} - className={`${className} bulleted-icon flex w-[23px] justify-center pr-1 font-medium`} + className={`${className} bulleted-icon flex min-w-[24px] justify-center pr-1 font-medium`} /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx index 0966edf3848d8..a20300bbc25b5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx @@ -9,10 +9,8 @@ export const Callout = memo(
-
-
+
+
{children}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx index 0fc8601f73ff0..4805233e1dc7f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx @@ -100,6 +100,11 @@ function SelectLanguage({ ref={ref} size={'small'} variant={'standard'} + sx={{ + '& .MuiInputBase-root, & .MuiInputBase-input': { + userSelect: 'none', + }, + }} className={'w-[150px]'} value={language} onClick={() => { @@ -115,6 +120,7 @@ function SelectLanguage({ {open && ( { - return (e: React.MouseEvent | KeyboardEvent) => { + return (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => { e.stopPropagation(); setOpen(open); }; @@ -23,7 +23,7 @@ function DatabaseEmpty({ node }: { node: GridNode }) {
{t('document.plugins.database.noDataSource')}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts index c4753f9124da9..543b9900ca5e8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts @@ -3,8 +3,19 @@ import { ViewLayoutPB } from '@/services/backend'; export function useLoadDatabaseList({ searchText, layout }: { searchText: string; layout: ViewLayoutPB }) { const list = useAppSelector((state) => { + const workspaces = state.workspace.workspaces.map((item) => item.id) ?? []; + return Object.values(state.pages.pageMap).filter((page) => { if (page.layout !== layout) return false; + const parentId = page.parentId; + + if (!parentId) return false; + + const parent = state.pages.pageMap[parentId]; + const parentLayout = parent?.layout; + + if (!workspaces.includes(parentId) && parentLayout !== ViewLayoutPB.Document) return false; + return page.name.toLowerCase().includes(searchText.toLowerCase()); }); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx index 5a342a08aaeb9..5d06a13c06463 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx @@ -17,7 +17,7 @@ function DatabaseList({ toggleDrawer, }: { node: GridNode; - toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent) => void; + toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => void; }) { const scrollRef = React.useRef(null); @@ -35,7 +35,7 @@ function DatabaseList({ return (
-
{item.name || t('document.title.placeholder')}
+
{item.name.trim() || t('menuAppHeader.defaultNewPageName')}
); }, @@ -70,10 +70,12 @@ function DatabaseList({ ); return ( -
+
(e: React.MouseEvent | KeyboardEvent) => void; + toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => void; node: GridNode; }) { const editor = useSlateStatic(); @@ -38,6 +38,13 @@ function Drawer({ width: open ? '250px' : '0px', transition: 'width 0.3s ease-in-out', }} + onMouseDown={(e) => { + const isInput = (e.target as HTMLElement).closest('input'); + + if (isInput) return; + e.stopPropagation(); + e.preventDefault(); + }} >
@@ -46,7 +53,9 @@ function Drawer({
-
{open && }
+
+ {open && } +
+
{children}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx index 9873beaeae3da..695482bbd8818 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx @@ -7,19 +7,21 @@ function GridView({ viewId }: { viewId: string }) { const ref = useRef(null); - const [rendered, setRendered] = useState(false); + const [rendered, setRendered] = useState<{ viewId: string; rendered: boolean } | undefined>(undefined); // delegate wheel event to layout when grid is scrolled to top or bottom useEffect(() => { const element = ref.current; - if (!element) { + const viewId = rendered?.viewId; + + if (!viewId || !element) { return; } - const gridScroller = element.querySelector('.grid-scroll-container') as HTMLDivElement; + const gridScroller = element.querySelector(`[data-view-id="${viewId}"] .grid-scroll-container`) as HTMLDivElement; - const scrollLayout = gridScroller?.closest('.appflowy-layout') as HTMLDivElement; + const scrollLayout = gridScroller?.closest('.appflowy-scroll-container') as HTMLDivElement; if (!gridScroller || !scrollLayout) { return; @@ -29,7 +31,7 @@ function GridView({ viewId }: { viewId: string }) { const deltaY = event.deltaY; const deltaX = event.deltaX; - if (deltaX > 10) { + if (Math.abs(deltaX) > 8) { return; } @@ -50,8 +52,11 @@ function GridView({ viewId }: { viewId: string }) { }; }, [rendered]); - const onRendered = useCallback(() => { - setRendered(true); + const onRendered = useCallback((viewId: string) => { + setRendered({ + viewId, + rendered: true, + }); }, []); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx index 763a0983faa17..d7d475199bfaa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx @@ -15,7 +15,7 @@ export const DividerNode = memo( return (
-
+

diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx new file mode 100644 index 0000000000000..b3d3575af21ca --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useState } from 'react'; +import { ImageNode } from '$app/application/document/document.types'; +import { ReactComponent as CopyIcon } from '$app/assets/copy.svg'; +import { ReactComponent as AlignLeftIcon } from '$app/assets/align-left.svg'; +import { ReactComponent as AlignCenterIcon } from '$app/assets/align-center.svg'; +import { ReactComponent as AlignRightIcon } from '$app/assets/align-right.svg'; +import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; +import { useTranslation } from 'react-i18next'; +import { IconButton } from '@mui/material'; +import { notify } from '$app/components/_shared/notify'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import Popover from '@mui/material/Popover'; +import Tooltip from '@mui/material/Tooltip'; + +enum ImageAction { + Copy = 'copy', + AlignLeft = 'left', + AlignCenter = 'center', + AlignRight = 'right', + Delete = 'delete', +} + +function ImageActions({ node }: { node: ImageNode }) { + const { t } = useTranslation(); + const align = node.data.align; + const editor = useSlateStatic(); + const [alignAnchorEl, setAlignAnchorEl] = useState(null); + const alignOptions = useMemo(() => { + return [ + { + key: ImageAction.AlignLeft, + Icon: AlignLeftIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'left' }); + setAlignAnchorEl(null); + }, + }, + { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'center' }); + setAlignAnchorEl(null); + }, + }, + { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'right' }); + setAlignAnchorEl(null); + }, + }, + ]; + }, [editor, node]); + const options = useMemo(() => { + return [ + { + key: ImageAction.Copy, + Icon: CopyIcon, + tooltip: t('button.copyLink'), + onClick: () => { + if (!node.data.url) return; + void navigator.clipboard.writeText(node.data.url); + notify.success(t('message.copy.success')); + }, + }, + (!align || align === 'left') && { + key: ImageAction.AlignLeft, + Icon: AlignLeftIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'center' && { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'right' && { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + { + key: ImageAction.Delete, + Icon: DeleteIcon, + tooltip: t('button.delete'), + onClick: () => { + CustomEditor.deleteNode(editor, node); + }, + }, + ].filter(Boolean) as { + key: ImageAction; + Icon: React.FC>; + tooltip: string; + onClick: (e: React.MouseEvent) => void; + }[]; + }, [align, node, t, editor]); + + return ( +
+ {options.map((option) => { + const { key, Icon, tooltip, onClick } = option; + + return ( + + + + + + ); + })} + {!!alignAnchorEl && ( + setAlignAnchorEl(null)} + > + {alignOptions.map((option) => { + const { key, Icon, onClick } = option; + + return ( + + + + ); + })} + + )} +
+ ); +} + +export default ImageActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx new file mode 100644 index 0000000000000..661eb3e3deb51 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx @@ -0,0 +1,49 @@ +import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; +import { EditorElementProps, ImageNode } from '$app/application/document/document.types'; +import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; +import ImageRender from '$app/components/editor/components/blocks/image/ImageRender'; +import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpty'; + +export const ImageBlock = memo( + forwardRef>(({ node, children, className, ...attributes }, ref) => { + const selected = useSelected(); + const { url, align } = useMemo(() => node.data || {}, [node.data]); + const containerRef = useRef(null); + const editor = useSlateStatic(); + const onFocusNode = useCallback(() => { + ReactEditor.focus(editor); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + }, [editor, node]); + + return ( +
{ + if (!selected) onFocusNode(); + }} + className={`${className} image-block relative w-full cursor-pointer py-1`} + > +
+ {children} +
+
+ {url ? ( + + ) : ( + + )} +
+
+ ); + }) +); + +export default ImageBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx new file mode 100644 index 0000000000000..e0b649939e18c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx @@ -0,0 +1,63 @@ +import React, { useEffect } from 'react'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; +import { useTranslation } from 'react-i18next'; +import UploadPopover from '$app/components/editor/components/blocks/image/UploadPopover'; +import { EditorNodeType, ImageNode } from '$app/application/document/document.types'; +import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block'; + +function ImageEmpty({ + containerRef, + onEscape, + node, +}: { + containerRef: React.RefObject; + onEscape: () => void; + node: ImageNode; +}) { + const { t } = useTranslation(); + const state = useEditorBlockState(EditorNodeType.ImageBlock); + const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current); + const { openPopover, closePopover } = useEditorBlockDispatch(); + + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + const handleClick = () => { + openPopover(EditorNodeType.ImageBlock, node.blockId); + }; + + container.addEventListener('click', handleClick); + return () => { + container.removeEventListener('click', handleClick); + }; + }, [containerRef, node.blockId, openPopover]); + return ( + <> +
+ + {t('document.plugins.image.addAnImage')} +
+ {open && ( + { + closePopover(EditorNodeType.ImageBlock); + onEscape(); + }} + /> + )} + + ); +} + +export default ImageEmpty; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx new file mode 100644 index 0000000000000..07310b05be028 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ImageNode, ImageType } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; +import { CircularProgress } from '@mui/material'; +import { ErrorOutline } from '@mui/icons-material'; +import ImageResizer from '$app/components/editor/components/blocks/image/ImageResizer'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import ImageActions from '$app/components/editor/components/blocks/image/ImageActions'; +import { LocalImage } from '$app/components/_shared/image_upload'; +import debounce from 'lodash-es/debounce'; + +const MIN_WIDTH = 100; + +const DELAY = 300; + +function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) { + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const imgRef = useRef(null); + const editor = useSlateStatic(); + const { url = '', width: imageWidth, image_type: source } = useMemo(() => node.data || {}, [node.data]); + const { t } = useTranslation(); + const blockId = node.blockId; + + const [showActions, setShowActions] = useState(false); + const [initialWidth, setInitialWidth] = useState(null); + const [newWidth, setNewWidth] = useState(imageWidth ?? null); + + const debounceSubmitWidth = useMemo(() => { + return debounce((newWidth: number) => { + CustomEditor.setImageBlockData(editor, node, { + width: newWidth, + }); + }, DELAY); + }, [editor, node]); + + const handleWidthChange = useCallback( + (newWidth: number) => { + setNewWidth(newWidth); + debounceSubmitWidth(newWidth); + }, + [debounceSubmitWidth] + ); + + useEffect(() => { + if (!loading && !hasError && initialWidth === null && imgRef.current) { + setInitialWidth(imgRef.current.offsetWidth); + } + }, [hasError, initialWidth, loading]); + const imageProps: React.ImgHTMLAttributes = useMemo(() => { + return { + style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 }, + className: 'object-cover', + ref: imgRef, + src: url, + draggable: false, + onLoad: () => { + setHasError(false); + setLoading(false); + }, + onError: () => { + setHasError(true); + setLoading(false); + }, + }; + }, [url, newWidth, loading, hasError, selected]); + + const renderErrorNode = useCallback(() => { + return ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ); + }, [t]); + + if (!url) return null; + + return ( +
{ + setShowActions(true); + }} + onMouseLeave={() => { + setShowActions(false); + }} + style={{ + minWidth: MIN_WIDTH, + width: 'fit-content', + }} + className={`image-render relative min-h-[48px] ${ + hasError || (loading && source !== ImageType.Local) ? 'w-full' : '' + }`} + > + {source === ImageType.Local ? ( + { + setHasError(true); + return null; + }} + loading={'lazy'} + /> + ) : ( + {`image-${blockId}`} + )} + + {initialWidth && ( + <> + + + + )} + {showActions && } + {hasError ? ( + renderErrorNode() + ) : loading && source !== ImageType.Local ? ( +
+ +
{t('editor.loading')}
+
+ ) : null} +
+ ); +} + +export default ImageRender; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx new file mode 100644 index 0000000000000..e0d272acf32b0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useRef } from 'react'; + +function ImageResizer({ + minWidth, + width, + onWidthChange, + isLeft, +}: { + isLeft?: boolean; + minWidth: number; + width: number; + onWidthChange: (newWidth: number) => void; +}) { + const originalWidth = useRef(width); + const startX = useRef(0); + + const onResize = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + const diff = isLeft ? startX.current - e.clientX : e.clientX - startX.current; + const newWidth = originalWidth.current + diff; + + if (newWidth < minWidth) { + return; + } + + onWidthChange(newWidth); + }, + [isLeft, minWidth, onWidthChange] + ); + + const onResizeEnd = useCallback(() => { + document.removeEventListener('mousemove', onResize); + document.removeEventListener('mouseup', onResizeEnd); + }, [onResize]); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + startX.current = e.clientX; + originalWidth.current = width; + document.addEventListener('mousemove', onResize); + document.addEventListener('mouseup', onResizeEnd); + }, + [onResize, onResizeEnd, width] + ); + + return ( +
+
+
+ ); +} + +export default ImageResizer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx new file mode 100644 index 0000000000000..0aff9fb0cc229 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; + +import { useTranslation } from 'react-i18next'; +import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY, UploadImage } from '$app/components/_shared/image_upload'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { ImageNode, ImageType } from '$app/application/document/document.types'; + +const initialOrigin: { + transformOrigin: PopoverOrigin; + anchorOrigin: PopoverOrigin; +} = { + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, +}; + +function UploadPopover({ + open, + anchorEl, + onClose, + node, +}: { + open: boolean; + anchorEl: HTMLDivElement | null; + onClose: () => void; + node: ImageNode; +}) { + const editor = useSlateStatic(); + + const { t } = useTranslation(); + + const { transformOrigin, anchorOrigin, isEntered, paperHeight, paperWidth } = usePopoverAutoPosition({ + initialPaperWidth: 433, + initialPaperHeight: 300, + anchorEl, + initialAnchorOrigin: initialOrigin.anchorOrigin, + initialTransformOrigin: initialOrigin.transformOrigin, + open, + }); + + const tabOptions: TabOption[] = useMemo(() => { + return [ + { + label: t('button.upload'), + key: TAB_KEY.UPLOAD, + Component: UploadImage, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.Local, + }); + onClose(); + }, + }, + { + label: t('document.imageBlock.embedLink.label'), + key: TAB_KEY.EMBED_LINK, + Component: EmbedLink, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.External, + }); + onClose(); + }, + }, + { + key: TAB_KEY.UNSPLASH, + label: t('document.imageBlock.unsplash.label'), + Component: Unsplash, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.External, + }); + onClose(); + }, + }, + ]; + }, [editor, node, onClose, t]); + + return ( + { + e.stopPropagation(); + }, + }} + containerStyle={{ + maxWidth: paperWidth, + maxHeight: paperHeight, + overflow: 'hidden', + }} + tabOptions={tabOptions} + /> + ); +} + +export default UploadPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts new file mode 100644 index 0000000000000..73c3003a92514 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts @@ -0,0 +1 @@ +export * from './ImageBlock'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx index 933766f214755..f44158bdf29b8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx @@ -58,7 +58,7 @@ function EditPopover({ }, [onClose, editor, node]); const handleDone = () => { - if (!node) return; + if (!node || error) return; if (value !== node.data.formula) { CustomEditor.setMathEquationBlockFormula(editor, node, value); } @@ -100,7 +100,7 @@ function EditPopover({ const { transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ initialPaperWidth: 300, - initialPaperHeight: 200, + initialPaperHeight: 170, anchorEl, initialAnchorOrigin: initialOrigin.anchorOrigin, initialTransformOrigin: initialOrigin.transformOrigin, @@ -128,7 +128,7 @@ function EditPopover({ autoComplete={'off'} spellCheck={false} value={value} - minRows={3} + minRows={4} onInput={onInput} onKeyDown={onKeyDown} placeholder={`|x| = \\begin{cases} @@ -138,7 +138,7 @@ function EditPopover({ /> {error && ( -
+
{error.name}: {error.message}
)} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx index 71b5d0f706e34..ee441be624115 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx @@ -1,10 +1,11 @@ -import { forwardRef, memo, useEffect, useRef, useState } from 'react'; -import { EditorElementProps, MathEquationNode } from '$app/application/document/document.types'; +import { forwardRef, memo, useEffect, useRef } from 'react'; +import { EditorElementProps, EditorNodeType, MathEquationNode } from '$app/application/document/document.types'; import KatexMath from '$app/components/_shared/katex_math/KatexMath'; import { useTranslation } from 'react-i18next'; import { FunctionsOutlined } from '@mui/icons-material'; import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover'; import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; +import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block'; export const MathEquation = memo( forwardRef>( @@ -12,7 +13,9 @@ export const MathEquation = memo( const formula = node.data.formula; const { t } = useTranslation(); const containerRef = useRef(null); - const [open, setOpen] = useState(false); + const { openPopover, closePopover } = useEditorBlockDispatch(); + const state = useEditorBlockState(EditorNodeType.EquationBlock); + const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current); const selected = useSelected(); @@ -26,7 +29,7 @@ export const MathEquation = memo( if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); - setOpen(true); + openPopover(EditorNodeType.EquationBlock, node.blockId); } }; @@ -37,7 +40,7 @@ export const MathEquation = memo( return () => { slateDom.removeEventListener('keydown', handleKeyDown); }; - }, [editor, selected]); + }, [editor, node.blockId, openPopover, selected]); return ( <> @@ -45,13 +48,13 @@ export const MathEquation = memo( {...attributes} ref={containerRef} onClick={() => { - setOpen(true); + openPopover(EditorNodeType.EquationBlock, node.blockId); }} - className={`${className} relative w-full cursor-pointer py-2`} + className={`${className} math-equation-block relative w-full cursor-pointer py-2`} >
@@ -71,7 +74,7 @@ export const MathEquation = memo( {open && ( { - setOpen(false); + closePopover(EditorNodeType.EquationBlock); }} node={node} open={open} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx index 9891365b367f7..888b46c98082a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx @@ -1,15 +1,35 @@ import React, { useMemo } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; +import { ReactEditor, useSlate, useSlateStatic } from 'slate-react'; import { Element, Path } from 'slate'; import { NumberedListNode } from '$app/application/document/document.types'; +import { letterize, romanize } from '$app/utils/list'; +import { CustomEditor } from '$app/components/editor/command'; + +enum Letter { + Number = 'number', + Letter = 'letter', + Roman = 'roman', +} + +function getLetterNumber(index: number, letter: Letter) { + if (letter === Letter.Number) { + return index; + } else if (letter === Letter.Letter) { + return letterize(index); + } else { + return romanize(index); + } +} function NumberListIcon({ block, className }: { block: NumberedListNode; className: string }) { const editor = useSlate(); + const staticEditor = useSlateStatic(); const path = ReactEditor.findPath(editor, block); const index = useMemo(() => { let index = 1; + let topNode; let prevPath = Path.previous(path); while (prevPath) { @@ -19,6 +39,7 @@ function NumberListIcon({ block, className }: { block: NumberedListNode; classNa if (prevNode.type === block.type) { index += 1; + topNode = prevNode; } else { break; } @@ -26,17 +47,39 @@ function NumberListIcon({ block, className }: { block: NumberedListNode; classNa prevPath = Path.previous(prevPath); } - return index; + if (!topNode) { + return Number(block.data?.number ?? 1); + } + + const startIndex = (topNode as NumberedListNode).data?.number ?? 1; + + return index + Number(startIndex) - 1; }, [editor, block, path]); + const letter = useMemo(() => { + const level = CustomEditor.getListLevel(staticEditor, block.type, path); + + if (level % 3 === 0) { + return Letter.Number; + } else if (level % 3 === 1) { + return Letter.Letter; + } else { + return Letter.Roman; + } + }, [block.type, staticEditor, path]); + + const dataNumber = useMemo(() => { + return getLetterNumber(index, letter); + }, [index, letter]); + return ( { e.preventDefault(); }} contentEditable={false} - data-number={index} - className={`${className} numbered-icon flex w-[23px] justify-center pr-1 font-medium`} + data-number={dataNumber} + className={`${className} numbered-icon flex w-[24px] min-w-[24px] justify-center pr-1 font-medium`} /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx index f376f9cd75e4c..f93cb897bab4f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx @@ -4,7 +4,7 @@ import { EditorElementProps, PageNode } from '$app/application/document/document export const Page = memo( forwardRef>(({ node: _, children, ...attributes }, ref) => { const className = useMemo(() => { - return `${attributes.className ?? ''} pb-3 text-4xl font-bold`; + return `${attributes.className ?? ''} document-title pb-3 text-5xl font-bold`; }, [attributes.className]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx index b745530acc9f3..acf16581f408f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx @@ -36,7 +36,7 @@ export function useStartIcon(node: TextNode) { return null; } - return ; + return ; }, [Component, block]); return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx index bf8df3c8e71b7..768524394e438 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx @@ -14,14 +14,13 @@ export const Text = memo( {renderIcon()} - - {children} + {children} ); }) diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx index 630aa93fb7eec..d98990c88694f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx @@ -1,7 +1,8 @@ import React, { useCallback } from 'react'; import { TodoListNode } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; -import { useSlateStatic } from 'slate-react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Location } from 'slate'; import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; @@ -9,9 +10,25 @@ function CheckboxIcon({ block, className }: { block: TodoListNode; className: st const editor = useSlateStatic(); const { checked } = block.data; - const toggleTodo = useCallback(() => { - CustomEditor.toggleTodo(editor, block); - }, [editor, block]); + const toggleTodo = useCallback( + (e: React.MouseEvent) => { + const path = ReactEditor.findPath(editor, block); + const start = editor.start(path); + let at: Location = start; + + if (e.shiftKey) { + const end = editor.end(path); + + at = { + anchor: start, + focus: end, + }; + } + + CustomEditor.toggleTodo(editor, at); + }, + [editor, block] + ); return ( >(({ node, children, ...attributes }, ref) => { - const { checked } = node.data; + const { checked = false } = useMemo(() => node.data || {}, [node.data]); const className = useMemo(() => { return `flex w-full flex-col ${checked ? 'checked' : ''} ${attributes.className ?? ''}`; }, [attributes.className, checked]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx index 8af826ae22837..809f3b750d1f7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx @@ -1,9 +1,9 @@ -import React, { forwardRef, memo } from 'react'; +import React, { forwardRef, memo, useMemo } from 'react'; import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types'; export const ToggleList = memo( forwardRef>(({ node, children, ...attributes }, ref) => { - const { collapsed } = node.data; + const { collapsed } = useMemo(() => node.data || {}, [node.data]); const className = `${attributes.className ?? ''} flex w-full flex-col ${collapsed ? 'collapsed' : ''}`; return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx index af636eeb5ad16..2526df895ec83 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx @@ -5,62 +5,89 @@ import { EditorProps } from '$app/application/document/document.types'; import { Provider } from '$app/components/editor/provider'; import { YXmlText } from 'yjs/dist/src/types/YXmlText'; import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation'; +import isEqual from 'lodash-es/isEqual'; -export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleChange }: EditorProps) => { - const [sharedType, setSharedType] = useState(null); - const provider = useMemo(() => { - setSharedType(null); - return new Provider(id, showTitle); - }, [id, showTitle]); +export const CollaborativeEditor = memo( + ({ id, title, cover, showTitle = true, onTitleChange, onCoverChange, ...props }: EditorProps) => { + const [sharedType, setSharedType] = useState(null); + const provider = useMemo(() => { + setSharedType(null); - const root = useMemo(() => { - if (!showTitle || !sharedType || !sharedType.doc) return null; + return new Provider(id); + }, [id]); - return getYTarget(sharedType?.doc, [0]); - }, [sharedType, showTitle]); + const root = useMemo(() => { + if (!showTitle || !sharedType || !sharedType.doc) return null; - const rootText = useMemo(() => { - if (!root) return null; - return getInsertTarget(root, [0]); - }, [root]); + return getYTarget(sharedType?.doc, [0]); + }, [sharedType, showTitle]); - useEffect(() => { - if (!rootText || rootText.toString() === title) return; + const rootText = useMemo(() => { + if (!root) return null; + return getInsertTarget(root, [0]); + }, [root]); - if (rootText.length > 0) { - rootText.delete(0, rootText.length); - } + useEffect(() => { + if (!rootText || rootText.toString() === title) return; - rootText.insert(0, title || ''); - }, [title, rootText]); + if (rootText.length > 0) { + rootText.delete(0, rootText.length); + } - useEffect(() => { - if (!root) return; - const onChange = () => { - onTitleChange?.(root.toString()); - }; + rootText.insert(0, title || ''); + }, [title, rootText]); - root.observeDeep(onChange); - return () => root.unobserveDeep(onChange); - }, [onTitleChange, root]); + useEffect(() => { + if (!root) return; - useEffect(() => { - provider.connect(); - const handleConnected = () => { - setSharedType(provider.sharedType); - }; + const originalCover = root.getAttribute('data')?.cover; - provider.on('ready', handleConnected); - return () => { - setSharedType(null); - provider.off('ready', handleConnected); - provider.disconnect(); - }; - }, [provider]); + if (cover === undefined) return; + if (isEqual(originalCover, cover)) return; + root.setAttribute('data', { cover: cover ? cover : undefined }); + }, [cover, root]); - if (!sharedType || id !== provider.id) { - return null; - } + useEffect(() => { + if (!root) return; + const rootId = root.getAttribute('blockId'); + + if (!rootId) return; + + const getCover = () => { + const data = root.getAttribute('data'); + + onCoverChange?.(data?.cover); + }; + + getCover(); + const onChange = () => { + onTitleChange?.(root.toString()); + getCover(); + }; - return ; -}); + root.observeDeep(onChange); + return () => root.unobserveDeep(onChange); + }, [onTitleChange, root, onCoverChange]); + + useEffect(() => { + provider.connect(); + + const handleConnected = () => { + setSharedType(provider.sharedType); + }; + + provider.on('ready', handleConnected); + void provider.initialDocument(showTitle); + return () => { + provider.off('ready', handleConnected); + provider.disconnect(); + }; + }, [provider, showTitle]); + + if (!sharedType || id !== provider.id) { + return null; + } + + return ; + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx index c6bdd330b6e10..b0bbe0eb288ea 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx @@ -1,18 +1,39 @@ -import React, { ComponentProps } from 'react'; -import { Editable } from 'slate-react'; +import React, { ComponentProps, useCallback } from 'react'; +import { Editable, useSlate } from 'slate-react'; import Element from './Element'; import { Leaf } from './Leaf'; +import { useShortcuts } from '$app/components/editor/plugins/shortcuts'; +import { useInlineKeyDown } from '$app/components/editor/components/editor/Editor.hooks'; type CustomEditableProps = Omit, 'renderElement' | 'renderLeaf'> & - Partial, 'renderElement' | 'renderLeaf'>>; + Partial, 'renderElement' | 'renderLeaf'>> & { + disableFocus?: boolean; + }; + +export function CustomEditable({ + renderElement = Element, + disableFocus = false, + renderLeaf = Leaf, + ...props +}: CustomEditableProps) { + const editor = useSlate(); + const { onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); + const withInlineKeyDown = useInlineKeyDown(editor); + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + withInlineKeyDown(event); + onShortcutsKeyDown(event); + }, + [onShortcutsKeyDown, withInlineKeyDown] + ); -export function CustomEditable({ renderElement = Element, renderLeaf = Leaf, ...props }: CustomEditableProps) { return ( { if (!sharedType) return null; - const e = withShortcuts(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); + const e = withMarkdown(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); // Ensure editor always has at least 1 valid child const { normalizeNode } = e; @@ -47,6 +47,20 @@ export function useEditor(sharedType: Y.XmlText) { }, [editor]); const handleOnClickEnd = useCallback(() => { + const path = [editor.children.length - 1]; + const node = Editor.node(editor, path) as NodeEntry; + const latestNodeIsEmpty = CustomEditor.isEmptyText(editor, node[0]); + + if (latestNodeIsEmpty) { + ReactEditor.focus(editor); + editor.select(path); + editor.collapse({ + edge: 'end', + }); + + return; + } + CustomEditor.insertEmptyLineAtEnd(editor); }, [editor]); @@ -98,7 +112,7 @@ export function useInlineKeyDown(editor: ReactEditor) { const { nativeEvent } = e; if ( - isHotkey('left', nativeEvent) && + createHotkey(HOT_KEY_NAME.LEFT)(nativeEvent) && CustomEditor.beforeIsInlineNode(editor, selection, { unit: 'offset', }) @@ -108,7 +122,10 @@ export function useInlineKeyDown(editor: ReactEditor) { return; } - if (isHotkey('right', nativeEvent) && CustomEditor.afterIsInlineNode(editor, selection, { unit: 'offset' })) { + if ( + createHotkey(HOT_KEY_NAME.RIGHT)(nativeEvent) && + CustomEditor.afterIsInlineNode(editor, selection, { unit: 'offset' }) + ) { e.preventDefault(); Transforms.move(editor, { unit: 'offset' }); return; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx index ee2eced3407cb..d87dbe3f3518f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx @@ -1,17 +1,11 @@ -import React, { memo, useCallback } from 'react'; -import { - useDecorateCodeHighlight, - useEditor, - useInlineKeyDown, -} from '$app/components/editor/components/editor/Editor.hooks'; +import React, { useCallback } from 'react'; +import { useDecorateCodeHighlight, useEditor } from '$app/components/editor/components/editor/Editor.hooks'; import { Slate } from 'slate-react'; import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable'; import { SelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar'; -import { useShortcuts } from 'src/appflowy_app/components/editor/plugins/shortcuts'; import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions'; import { CircularProgress } from '@mui/material'; -import * as Y from 'yjs'; import { NodeEntry } from 'slate'; import { DecorateStateProvider, @@ -21,18 +15,20 @@ import { EditorInlineBlockStateProvider, } from '$app/components/editor/stores'; import CommandPanel from '../tools/command_panel/CommandPanel'; +import { EditorBlockStateProvider } from '$app/components/editor/stores/block'; +import { LocalEditorProps } from '$app/application/document/document.types'; -function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) { +function Editor({ sharedType, disableFocus, caretColor = 'var(--text-title)' }: LocalEditorProps) { const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType); const decorateCodeHighlight = useDecorateCodeHighlight(editor); - const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); - const withInlineKeyDown = useInlineKeyDown(editor); + const { selectedBlocks, decorate: decorateCustomRange, decorateState, slashState, inlineBlockState, + blockState, } = useInitialEditorState(editor); const decorate = useCallback( @@ -45,14 +41,6 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) { [decorateCodeHighlight, decorateCustomRange] ); - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - withInlineKeyDown(event); - onShortcutsKeyDown(event); - }, - [onShortcutsKeyDown, withInlineKeyDown] - ); - if (editor.sharedRoot.length === 0) { return ; } @@ -60,27 +48,31 @@ function Editor({ sharedType }: { sharedType: Y.XmlText; id: string }) { return ( - - - - - + + + + + + - - -
- - - + + +
+ + + + ); } -export default memo(Editor); +export default Editor; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx index f11551c5d8710..1824d8a590126 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx @@ -21,10 +21,13 @@ import { Callout } from '$app/components/editor/components/blocks/callout'; import { Mention } from '$app/components/editor/components/inline_nodes/mention'; import { GridBlock } from '$app/components/editor/components/blocks/database'; import { MathEquation } from '$app/components/editor/components/blocks/math_equation'; +import { ImageBlock } from '$app/components/editor/components/blocks/image'; + import { Text as TextComponent } from '../blocks/text'; import { Page } from '../blocks/page'; import { useElementState } from '$app/components/editor/components/editor/Element.hooks'; import UnSupportBlock from '$app/components/editor/components/blocks/_shared/unSupportBlock'; +import { renderColor } from '$app/utils/color'; function Element({ element, attributes, children }: RenderElementProps) { const node = element; @@ -68,6 +71,8 @@ function Element({ element, attributes, children }: RenderElementProps) { return GridBlock; case EditorNodeType.EquationBlock: return MathEquation; + case EditorNodeType.ImageBlock: + return ImageBlock; default: return UnSupportBlock; } @@ -94,8 +99,8 @@ function Element({ element, attributes, children }: RenderElementProps) { const data = (node.data as BlockData) || {}; return { - backgroundColor: data.bg_color, - color: data.font_color, + backgroundColor: data.bg_color ? renderColor(data.bg_color) : undefined, + color: data.font_color ? renderColor(data.font_color) : undefined, }; }, [node.data]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx index 468cc3d3805e2..188ac333611ad 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties } from 'react'; import { RenderLeafProps } from 'slate-react'; import { Link } from '$app/components/editor/components/inline_nodes/link'; +import { renderColor } from '$app/utils/color'; export function Leaf({ attributes, children, leaf }: RenderLeafProps) { let newChildren = children; @@ -39,11 +40,11 @@ export function Leaf({ attributes, children, leaf }: RenderLeafProps) { const style: CSSProperties = {}; if (leaf.font_color) { - style['color'] = leaf.font_color.replace('0x', '#'); + style['color'] = renderColor(leaf.font_color); } if (leaf.bg_color) { - style['backgroundColor'] = leaf.bg_color.replace('0x', '#'); + style['backgroundColor'] = renderColor(leaf.bg_color); } if (leaf.href) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx index 47aada1143cdf..fb32eb18a99ef 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx @@ -3,10 +3,10 @@ import React from 'react'; // Put this at the start and end of an inline component to work around this Chromium bug: // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 -export const InlineChromiumBugfix = () => ( +export const InlineChromiumBugfix = ({ className }: { className?: string }) => ( void; + onClear: () => void; onDone: (formula: string) => void; }) { const [text, setText] = useState(defaultText); @@ -37,7 +42,7 @@ function FormulaEditPopover({ horizontal: 'center', }} > -
+
- + + onDone(text)}> + + + + + + + +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx index 89ae42291ade1..204047304fbf6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx @@ -1,6 +1,6 @@ -import React, { forwardRef, memo, useCallback, MouseEvent, useRef } from 'react'; +import React, { forwardRef, memo, useCallback, MouseEvent, useRef, useEffect } from 'react'; import { ReactEditor, useSelected, useSlate } from 'slate-react'; -import { Transforms } from 'slate'; +import { Editor, Range, Transforms } from 'slate'; import { EditorElementProps, FormulaNode } from '$app/application/document/document.types'; import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf'; import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover'; @@ -16,19 +16,29 @@ export const InlineFormula = memo( const { popoverOpen = false, setRange, openPopover, closePopover } = useEditorInlineBlockState('formula'); const anchor = useRef(null); const selected = useSelected(); - const open = popoverOpen && selected; + const open = Boolean(popoverOpen && selected); + + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + + useEffect(() => { + if (selected && isCollapsed && !open) { + const afterPoint = editor.selection ? editor.after(editor.selection) : undefined; + + const afterStart = afterPoint ? Editor.start(editor, afterPoint) : undefined; + + if (afterStart) { + editor.select(afterStart); + } + } + }, [editor, isCollapsed, selected, open]); const handleClick = useCallback( (e: MouseEvent) => { const target = e.currentTarget; const path = getNodePath(editor, target); - ReactEditor.focus(editor); - Transforms.select(editor, path); - if (editor.selection) { - setRange(editor.selection); - openPopover(); - } + setRange(path); + openPopover(); }, [editor, openPopover, setRange] ); @@ -42,6 +52,46 @@ export const InlineFormula = memo( moveCursorToNodeEnd(editor, anchor.current); }, [closePopover, editor]); + const selectNode = useCallback(() => { + if (anchor.current === null) { + return; + } + + const path = getNodePath(editor, anchor.current); + + ReactEditor.focus(editor); + Transforms.select(editor, path); + }, [editor]); + + const onClear = useCallback(() => { + selectNode(); + CustomEditor.toggleFormula(editor); + closePopover(); + }, [selectNode, closePopover, editor]); + + const onDone = useCallback( + (newFormula: string) => { + selectNode(); + if (newFormula === '' && anchor.current) { + const path = getNodePath(editor, anchor.current); + const point = editor.before(path); + + CustomEditor.deleteFormula(editor); + closePopover(); + if (point) { + ReactEditor.focus(editor); + editor.select(point); + } + + return; + } else { + CustomEditor.updateFormula(editor, newFormula); + handleEditPopoverClose(); + } + }, + [closePopover, editor, handleEditPopoverClose, selectNode] + ); + return ( <> - + {children} - + {open && ( { - if (anchor.current === null || newFormula === formula) { - handleEditPopoverClose(); - return; - } - - const path = getNodePath(editor, anchor.current); - - // select the node before updating the formula - Transforms.select(editor, path); - if (newFormula === '') { - const point = editor.before(path); - - CustomEditor.deleteFormula(editor); - closePopover(); - if (point) { - ReactEditor.focus(editor); - editor.select(point); - } - - return; - } else { - CustomEditor.updateFormula(editor, newFormula); - handleEditPopoverClose(); - } - }} + onClear={onClear} + onDone={onDone} anchorEl={anchor.current} open={open} onClose={handleEditPopoverClose} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx index dff7b7dae61fa..09095480dcd10 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx @@ -39,7 +39,7 @@ export const Link = memo(({ children }: { leaf: Text; children: React.ReactNode {children} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx index dd980c7faacb9..af62a7b28ff40 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Typography from '@mui/material/Typography'; import { addMark, removeMark } from 'slate'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { open as openWindow } from '@tauri-apps/api/shell'; -import { notify } from '$app/components/editor/components/tools/notify'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; import { CustomEditor } from '$app/components/editor/command'; import { useTranslation } from 'react-i18next'; import { useSlateStatic } from 'slate-react'; @@ -14,7 +13,8 @@ import KeyboardNavigation, { KeyboardNavigationOption, } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import isHotkey from 'is-hotkey'; -import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; +import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; +import { openUrl, isUrl } from '$app/utils/open_url'; function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) { const editor = useSlateStatic(); @@ -44,12 +44,22 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul if (!input) return; + let isComposing = false; + + const handleCompositionUpdate = () => { + isComposing = true; + }; + + const handleCompositionEnd = () => { + isComposing = false; + }; + const handleKeyDown = (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); - if (pattern.test(link)) { + if (isUrl(link)) { onClose(); setNodeMark(); } @@ -69,29 +79,29 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul return; } - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (!isComposing && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { notify.clear(); notify.info(`Press Tab to focus on the menu`); return; } }; + input.addEventListener('compositionstart', handleCompositionUpdate); + input.addEventListener('compositionend', handleCompositionEnd); + input.addEventListener('compositionupdate', handleCompositionUpdate); input.addEventListener('keydown', handleKeyDown); return () => { input.removeEventListener('keydown', handleKeyDown); + input.removeEventListener('compositionstart', handleCompositionUpdate); + input.removeEventListener('compositionend', handleCompositionEnd); + input.removeEventListener('compositionupdate', handleCompositionUpdate); }; }, [link, onClose, setNodeMark]); const onConfirm = useCallback( (key: string) => { if (key === 'open') { - const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:']; - - if (linkPrefix.some((prefix) => link.startsWith(prefix))) { - void openWindow(link); - } else { - void openWindow('https://' + link); - } + openUrl(link); } else if (key === 'copy') { void navigator.clipboard.writeText(link); notify.success(t('message.copy.success')); @@ -115,7 +125,7 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul return [ { key: 'open', - disabled: !pattern.test(link), + disabled: !isUrl(link), content: renderOption(, t('editor.openLink')), }, { @@ -139,9 +149,9 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
{isActivated && ( { setFocusMenu(true); @@ -149,8 +159,8 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul onBlur={() => { setFocusMenu(false); }} - onEscape={onClose} disableSelect={!focusMenu} + onEscape={onClose} onKeyDown={(e) => { e.stopPropagation(); if (isHotkey('Tab', e)) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx index 4a0cc3e33c0e7..6e9a0bb497f80 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from 'react'; import { TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; - -export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; +import { isUrl } from '$app/utils/open_url'; function LinkEditInput({ link, @@ -17,7 +16,7 @@ function LinkEditInput({ const [error, setError] = useState(null); useEffect(() => { - if (pattern.test(link)) { + if (isUrl(link)) { setError(null); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx index 55714a2efb830..2a5e3630da17c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx @@ -40,7 +40,7 @@ export function LinkEditPopover({ initialAnchorOrigin, initialTransformOrigin, initialPaperWidth: 340, - initialPaperHeight: 180, + initialPaperHeight: 200, }); return ( @@ -60,7 +60,7 @@ export function LinkEditPopover({ style={{ maxHeight: paperHeight, }} - className='flex flex-col p-4' + className='flex select-none flex-col p-4' >
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx index 638973d975214..7511147ad09c4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx @@ -7,13 +7,12 @@ import { InlineChromiumBugfix } from '$app/components/editor/components/inline_n export const Mention = memo( forwardRef>(({ node, children, ...attributes }, ref) => { return ( - <> - - - {children} - - - + + + {children} + + + ); }) ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx index fb6f630214f8f..10def395c5507 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx @@ -2,26 +2,47 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Mention, MentionPage } from '$app/application/document/document.types'; import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { pageTypeMap } from '$app_reducers/pages/slice'; import { getPage } from '$app/application/folder/page.service'; -import { useSelected } from 'slate-react'; +import { useSelected, useSlate } from 'slate-react'; import { ReactComponent as EyeClose } from '$app/assets/eye_close.svg'; -import { notify } from '$app/components/editor/components/tools/notify'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; import { subscribeNotifications } from '$app/application/notification'; import { FolderNotification } from '@/services/backend'; +import { Editor, Range } from 'slate'; +import { useAppDispatch } from '$app/stores/store'; +import { openPage } from '$app_reducers/pages/async_actions'; -export function MentionLeaf({ children, mention }: { mention: Mention; children: React.ReactNode }) { +export function MentionLeaf({ mention }: { mention: Mention }) { const { t } = useTranslation(); const [page, setPage] = useState(null); const [error, setError] = useState(false); - const navigate = useNavigate(); + const editor = useSlate(); const selected = useSelected(); + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (selected && isCollapsed && page) { + const afterPoint = editor.selection ? editor.after(editor.selection) : undefined; + + const afterStart = afterPoint ? Editor.start(editor, afterPoint) : undefined; + + if (afterStart) { + editor.select(afterStart); + } + } + }, [editor, isCollapsed, selected, page]); + const loadPage = useCallback(async () => { setError(true); - if (!mention.page) return; + // keep old field for backward compatibility + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const pageId = mention.page_id ?? mention.page; + + if (!pageId) return; try { - const page = await getPage(mention.page); + const page = await getPage(pageId); setPage(page); setError(false); @@ -29,22 +50,20 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children: setPage(null); setError(true); } - }, [mention.page]); + }, [mention]); useEffect(() => { void loadPage(); }, [loadPage]); - const openPage = useCallback(() => { + const handleOpenPage = useCallback(() => { if (!page) { notify.error(t('document.mention.deletedContent')); return; } - const pageType = pageTypeMap[page.layout]; - - navigate(`/page/${pageType}/${page.id}`); - }, [navigate, page, t]); + void dispatch(openPage(page.id)); + }, [page, dispatch, t]); useEffect(() => { if (!page) return; @@ -94,31 +113,27 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children: }, [page]); return ( - - - {page && ( + + {error ? ( + <> + + {t('document.mention.deleted')} + + ) : ( + page && ( <> - {page.icon?.value || } - {page.name || t('document.title.placeholder')} + {page.icon?.value || } + {page.name.trim() || t('menuAppHeader.defaultNewPageName')} - )} - {error && ( - <> - - - - {t('document.mention.deleted')} - - )} - - - {children} + ) + )} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx index 10b86e503a48f..41dea96f1e5ed 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx @@ -6,6 +6,7 @@ import KeyboardNavigation, { import { useTranslation } from 'react-i18next'; import { TitleOutlined } from '@mui/icons-material'; import { EditorMarkFormat } from '$app/application/document/document.types'; +import { ColorEnum, renderColor } from '$app/utils/color'; export interface ColorPickerProps { onChange?: (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => void; @@ -39,8 +40,8 @@ export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerPro >
@@ -118,40 +119,40 @@ export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerPro content: renderColorItem(t('editor.backgroundColorDefault'), '', ''), }, { - key: `bg-gray-rgba(161,161,159,0.61)`, - content: renderColorItem(t('editor.backgroundColorGray'), '', 'rgba(161,161,159,0.61)'), + key: `bg-lime-${ColorEnum.Lime}`, + content: renderColorItem(t('editor.backgroundColorLime'), '', ColorEnum.Lime), }, { - key: `bg-brown-rgba(178,93,37,0.65)`, - content: renderColorItem(t('editor.backgroundColorBrown'), '', 'rgba(178,93,37,0.65)'), + key: `bg-aqua-${ColorEnum.Aqua}`, + content: renderColorItem(t('editor.backgroundColorAqua'), '', ColorEnum.Aqua), }, { - key: `bg-orange-rgba(248,156,71,0.65)`, - content: renderColorItem(t('editor.backgroundColorOrange'), '', 'rgba(248,156,71,0.65)'), + key: `bg-orange-${ColorEnum.Orange}`, + content: renderColorItem(t('editor.backgroundColorOrange'), '', ColorEnum.Orange), }, { - key: `bg-yellow-rgba(229,197,137,0.6)`, - content: renderColorItem(t('editor.backgroundColorYellow'), '', 'rgba(229,197,137,0.6)'), + key: `bg-yellow-${ColorEnum.Yellow}`, + content: renderColorItem(t('editor.backgroundColorYellow'), '', ColorEnum.Yellow), }, { - key: `bg-green-rgba(124,189,111,0.65)`, - content: renderColorItem(t('editor.backgroundColorGreen'), '', 'rgba(124,189,111,0.65)'), + key: `bg-green-${ColorEnum.Green}`, + content: renderColorItem(t('editor.backgroundColorGreen'), '', ColorEnum.Green), }, { - key: `bg-blue-rgba(100,174,199,0.71)`, - content: renderColorItem(t('editor.backgroundColorBlue'), '', 'rgba(100,174,199,0.71)'), + key: `bg-blue-${ColorEnum.Blue}`, + content: renderColorItem(t('editor.backgroundColorBlue'), '', ColorEnum.Blue), }, { - key: `bg-purple-rgba(182,114,234,0.63)`, - content: renderColorItem(t('editor.backgroundColorPurple'), '', 'rgba(182,114,234,0.63)'), + key: `bg-purple-${ColorEnum.Purple}`, + content: renderColorItem(t('editor.backgroundColorPurple'), '', ColorEnum.Purple), }, { - key: `bg-pink-rgba(238,142,179,0.6)`, - content: renderColorItem(t('editor.backgroundColorPink'), '', 'rgba(238,142,179,0.6)'), + key: `bg-pink-${ColorEnum.Pink}`, + content: renderColorItem(t('editor.backgroundColorPink'), '', ColorEnum.Pink), }, { - key: `bg-red-rgba(238,88,98,0.64)`, - content: renderColorItem(t('editor.backgroundColorRed'), '', 'rgba(238,88,98,0.64)'), + key: `bg-red-${ColorEnum.LightPink}`, + content: renderColorItem(t('editor.backgroundColorRed'), '', ColorEnum.LightPink), }, ], }, @@ -159,7 +160,7 @@ export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerPro }, [renderColorItem, t]); return ( -
+
, contextMenuVisible: boolean) { const editor = useSlate(); @@ -45,28 +46,55 @@ export function useBlockActionsToolbar(ref: RefObject, contextMe } let range: Range | null = null; + let node; try { range = ReactEditor.findEventRange(editor, e); } catch { - range = findEventRange(editor, e); + const editorDom = ReactEditor.toDOMNode(editor, editor); + const rect = editorDom.getBoundingClientRect(); + const isOverLeftBoundary = e.clientX < rect.left + 64; + const isOverRightBoundary = e.clientX > rect.right - 64; + let newX = e.clientX; + + if (isOverLeftBoundary) { + newX = rect.left + 64; + } + + if (isOverRightBoundary) { + newX = rect.right - 64; + } + + node = findEventNode(editor, { + x: newX, + y: e.clientY, + }); } - if (!range) return; - const match = editor.above({ - match: (n) => { - return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined; - }, - at: range, - }); + if (!range && !node) { + Log.warn('No range and node found'); + return; + } else if (range) { + const match = editor.above({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined; + }, + at: range, + }); + + if (!match) { + close(); + return; + } - if (!match) { + node = match[0] as Element; + } + + if (!node) { close(); return; } - const node = match[0] as Element; - if (node.type === EditorNodeType.Page) return; const blockElement = ReactEditor.toDOMNode(editor, node); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx index 2f5f7a19d630d..729b4df144226 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx @@ -9,6 +9,9 @@ import { PopoverProps } from '@mui/material/Popover'; import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected'; import withErrorBoundary from '$app/components/_shared/error_boundary/withError'; +import { CustomEditor } from '$app/components/editor/command'; +import isEqual from 'lodash-es/isEqual'; +import { Range } from 'slate'; const Toolbar = () => { const ref = useRef(null); @@ -38,10 +41,42 @@ const Toolbar = () => { if (!node) return; const nodeDom = ReactEditor.toDOMNode(editor, node); const onContextMenu = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); const { clientX, clientY } = e; + e.stopPropagation(); + + const { selection } = editor; + + const editorRange = ReactEditor.findEventRange(editor, e); + + if (!editorRange || !selection) return; + + const rangeBlock = CustomEditor.getBlock(editor, editorRange); + const selectedBlock = CustomEditor.getBlock(editor, selection); + + if ( + Range.intersection(selection, editorRange) || + (rangeBlock && selectedBlock && isEqual(rangeBlock[1], selectedBlock[1])) + ) { + const windowSelection = window.getSelection(); + const range = windowSelection?.rangeCount ? windowSelection?.getRangeAt(0) : null; + const isCollapsed = windowSelection?.isCollapsed; + + if (windowSelection && !isCollapsed) { + if (range && range.endOffset === 0 && range.startContainer !== range.endContainer) { + const newRange = range.cloneRange(); + + newRange.setEnd(range.startContainer, range.startOffset); + windowSelection.removeAllRanges(); + windowSelection.addRange(newRange); + } + } + + return; + } + + e.preventDefault(); + popoverPropsRef.current = { transformOrigin: { vertical: 'top', diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx index 624b9ff0f1c1c..ade981750346a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx @@ -12,7 +12,7 @@ import KeyboardNavigation, { KeyboardNavigationOption, } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { Color } from '$app/components/editor/components/tools/block_actions/color'; -import { getModifier } from '$app/utils/get_modifier'; +import { getModifier } from '$app/utils/hotkeys'; import isHotkey from 'is-hotkey'; import { EditorNodeType } from '$app/application/document/document.types'; @@ -26,6 +26,7 @@ export const canSetColorBlocks: EditorNodeType[] = [ EditorNodeType.NumberedListBlock, EditorNodeType.ToggleListBlock, EditorNodeType.QuoteBlock, + EditorNodeType.CalloutBlock, ]; export function BlockOperationMenu({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx index d0ad1b2b4b1fc..499ab95c76843 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx @@ -14,9 +14,13 @@ const initialOrigin: { anchorOrigin?: PopoverOrigin; } = { anchorOrigin: { - vertical: 'top', + vertical: 'center', horizontal: 'right', }, + transformOrigin: { + vertical: 'center', + horizontal: 'left', + }, }; export function Color({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts index 4166022182c1e..b63afe9dc141b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts @@ -34,63 +34,25 @@ export function getBlockCssProperty(node: Element) { } /** - * Resolve can not find the range when the drop occurs on the icon. * @param editor * @param e */ -export function findEventRange(editor: ReactEditor, e: MouseEvent) { - const { clientX: x, clientY: y } = e; - - // Else resolve a range from the caret position where the drop occured. - let domRange; - const { document } = ReactEditor.getWindow(editor); - - // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) - if (document.caretRangeFromPoint) { - domRange = document.caretRangeFromPoint(x, y); - } else if ('caretPositionFromPoint' in document && typeof document.caretPositionFromPoint === 'function') { - const position = document.caretPositionFromPoint(x, y); - - if (position) { - domRange = document.createRange(); - domRange.setStart(position.offsetNode, position.offset); - domRange.setEnd(position.offsetNode, position.offset); - } +export function findEventNode( + editor: ReactEditor, + { + x, + y, + }: { + x: number; + y: number; } +) { + const element = document.elementFromPoint(x, y); + const nodeDom = element?.closest('[data-block-type]'); - if (domRange && domRange.startContainer) { - const startContainer = domRange.startContainer; - - let element: HTMLElement | null = startContainer as HTMLElement; - const nodeType = element.nodeType; - - if (nodeType === 3 || typeof element === 'string') { - const parent = element.parentElement?.closest('.text-block-icon') as HTMLElement; - - element = parent; - } - - if (element && element.nodeType < 3) { - if (element.classList?.contains('text-block-icon')) { - const sibling = domRange.startContainer.parentElement; - - if (sibling) { - domRange.selectNode(sibling); - } - } - } + if (nodeDom) { + return ReactEditor.toSlateNode(editor, nodeDom) as Element; } - if (!domRange) { - return null; - } - - try { - return ReactEditor.toSlateRange(editor, domRange, { - exactMatch: false, - suppressThrow: false, - }); - } catch { - return null; - } + return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts index 2697187386e08..633d09349de1e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts @@ -50,19 +50,21 @@ export function useCommandPanel() { if (deleteText && startPoint.current && endPoint.current) { const anchor = { path: startPoint.current.path, - offset: startPoint.current.offset - 1, + offset: startPoint.current.offset > 0 ? startPoint.current.offset - 1 : 0, }; const focus = { path: endPoint.current.path, offset: endPoint.current.offset, }; - Transforms.delete(editor, { - at: { - anchor, - focus, - }, - }); + if (!Point.equals(anchor, focus)) { + Transforms.delete(editor, { + at: { + anchor, + focus, + }, + }); + } } setSlashOpen(false); @@ -117,7 +119,7 @@ export function useCommandPanel() { * listen to editor insertText and deleteBackward event */ useEffect(() => { - const { insertText, deleteBackward } = editor; + const { insertText } = editor; /** * insertText: when insert char at after space or at start of element, show the panel @@ -171,50 +173,10 @@ export function useCommandPanel() { openPanel(); }; - /** - * deleteBackward: when delete char at start of panel char, and then it will be deleted, so we should close the panel if it is open - * close condition: - * 1. open is true - * 2. current block is not code block - * 3. current selection is not include root - * 4. current selection is collapsed - * 5. before text is command char - * --------- start ----------------- - * | - selection point - * @ - panel char - * - - other text - * -------- close panel ---------------- - * --@|--- => delete text is panel char, close the panel - * -------- delete text ---------------- - * ---@__|--- => delete text is not panel char, delete the text - */ - editor.deleteBackward = (...args) => { - if (!open || CustomEditor.isCodeBlock(editor)) { - deleteBackward(...args); - return; - } - - const { selection } = editor; - - if (selection && Range.isCollapsed(selection)) { - const { anchor } = selection; - const block = CustomEditor.getBlock(editor); - const path = block ? block[1] : []; - const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }); - - deleteBackward(...args); - // if delete backward at start of panel char, and then it will be deleted, so we should close the panel if it is open - if (beforeText === command) { - closePanel(); - } - } - }; - return () => { editor.insertText = insertText; - editor.deleteBackward = deleteBackward; }; - }, [setSlashOpen, command, open, setPosition, editor, closePanel, openPanel]); + }, [open, editor, openPanel, setSlashOpen]); /** * listen to editor onChange event diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx index 76ba3d34046f3..5d83870719212 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx @@ -1,58 +1,34 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useSlate } from 'slate-react'; import { MentionPage, MentionType } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; -import { useAppSelector } from '$app/stores/store'; import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +// import dayjs from 'dayjs'; +// enum DateKey { +// Today = 'today', +// Tomorrow = 'tomorrow', +// } export function useMentionPanel({ closePanel, - searchText, + pages, }: { - searchText: string; + pages: MentionPage[]; closePanel: (deleteText?: boolean) => void; }) { const { t } = useTranslation(); const editor = useSlate(); - const pagesMap = useAppSelector((state) => state.pages.pageMap); - - const pagesRef = useRef([]); - const [recentPages, setPages] = useState([]); - - const loadPages = useCallback(async () => { - const pages = Object.values(pagesMap); - - pagesRef.current = pages; - setPages(pages); - }, [pagesMap]); - - useEffect(() => { - void loadPages(); - }, [loadPages]); - - useEffect(() => { - if (!searchText) { - setPages(pagesRef.current); - return; - } - - const filteredPages = pagesRef.current.filter((page) => { - return page.name.toLowerCase().includes(searchText.toLowerCase()); - }); - - setPages(filteredPages); - }, [searchText]); - const onConfirm = useCallback( (key: string) => { const [, id] = key.split(','); closePanel(true); CustomEditor.insertMention(editor, { - page: id, + page_id: id, + type: MentionType.PageRef, }); }, [closePanel, editor] @@ -66,7 +42,7 @@ export function useMentionPanel({
{page.icon?.value || }
-
{page.name || t('document.title.placeholder')}
+
{page.name.trim() || t('menuAppHeader.defaultNewPageName')}
), }; @@ -74,15 +50,62 @@ export function useMentionPanel({ [t] ); + // const renderDate = useCallback(() => { + // return [ + // { + // key: DateKey.Today, + // content: ( + //
+ // {t('relativeDates.today')} -{' '} + // {dayjs().format('MMM D, YYYY')} + //
+ // ), + // + // children: [], + // }, + // { + // key: DateKey.Tomorrow, + // content: ( + //
+ // {t('relativeDates.tomorrow')} + //
+ // ), + // children: [], + // }, + // ]; + // }, [t]); + const options: KeyboardNavigationOption[] = useMemo(() => { return [ + // { + // key: MentionType.Date, + // content:
{t('editor.date')}
, + // children: renderDate(), + // }, + { + key: 'divider', + content:
, + children: [], + }, + { key: MentionType.PageRef, content:
{t('document.mention.page.label')}
, - children: recentPages.map(renderPage), + children: + pages.length > 0 + ? pages.map(renderPage) + : [ + { + key: 'noPage', + content: ( +
{t('findAndReplace.noResult')}
+ ), + children: [], + }, + ], }, - ].filter((option) => option.children.length > 0); - }, [recentPages, renderPage, t]); + ]; + }, [pages, renderPage, t]); return { options, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx index eba84e4ac4af9..6ca022557914c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { initialAnchorOrigin, initialTransformOrigin, @@ -9,10 +9,39 @@ import Popover from '@mui/material/Popover'; import MentionPanelContent from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent'; import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { useAppSelector } from '$app/stores/store'; +import { MentionPage } from '$app/application/document/document.types'; export function MentionPanel({ anchorPosition, closePanel, searchText }: PanelProps) { const ref = useRef(null); + const pagesMap = useAppSelector((state) => state.pages.pageMap); + const pagesRef = useRef([]); + const [recentPages, setPages] = useState([]); + + const loadPages = useCallback(async () => { + const pages = Object.values(pagesMap); + + pagesRef.current = pages; + setPages(pages); + }, [pagesMap]); + + useEffect(() => { + void loadPages(); + }, [loadPages]); + + useEffect(() => { + if (!searchText) { + setPages(pagesRef.current); + return; + } + + const filteredPages = pagesRef.current.filter((page) => { + return page.name.toLowerCase().includes(searchText.toLowerCase()); + }); + + setPages(filteredPages); + }, [searchText]); const open = Boolean(anchorPosition); const { @@ -42,12 +71,7 @@ export function MentionPanel({ anchorPosition, closePanel, searchText }: PanelPr transformOrigin={transformOrigin} onClose={() => closePanel(false)} > - + )}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx index 9664479fbda64..36b00ca2b658d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx @@ -2,15 +2,16 @@ import React, { useRef } from 'react'; import { useMentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks'; import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { MentionPage } from '$app/application/document/document.types'; function MentionPanelContent({ closePanel, - searchText, + pages, maxHeight, width, }: { closePanel: (deleteText?: boolean) => void; - searchText: string; + pages: MentionPage[]; maxHeight: number; width: number; }) { @@ -18,7 +19,7 @@ function MentionPanelContent({ const { options, onConfirm } = useMentionPanel({ closePanel, - searchText, + pages, }); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx index 91c9ed7024e9f..c2d9445b56a5f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx @@ -14,79 +14,22 @@ import { ReactComponent as NumberedListIcon } from '$app/assets/numbers.svg'; import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg'; import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg'; import { ReactComponent as GridIcon } from '$app/assets/grid.svg'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material'; import { CustomEditor } from '$app/components/editor/command'; -import { randomEmoji } from '$app/utils/emoji'; import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { YjsEditor } from '@slate-yjs/core'; - -enum SlashCommandPanelTab { - BASIC = 'basic', - ADVANCED = 'advanced', -} - -export enum SlashOptionType { - Paragraph, - TodoList, - Heading1, - Heading2, - Heading3, - BulletedList, - NumberedList, - Quote, - ToggleList, - Divider, - Callout, - Code, - Grid, - MathEquation, -} -const slashOptionGroup = [ - { - key: SlashCommandPanelTab.BASIC, - options: [ - SlashOptionType.Paragraph, - SlashOptionType.TodoList, - SlashOptionType.Heading1, - SlashOptionType.Heading2, - SlashOptionType.Heading3, - SlashOptionType.BulletedList, - SlashOptionType.NumberedList, - SlashOptionType.Quote, - SlashOptionType.ToggleList, - SlashOptionType.Divider, - ], - }, - { - key: SlashCommandPanelTab.ADVANCED, - options: [SlashOptionType.Callout, SlashOptionType.Code, SlashOptionType.Grid, SlashOptionType.MathEquation], - }, -]; - -const slashOptionMapToEditorNodeType = { - [SlashOptionType.Paragraph]: EditorNodeType.Paragraph, - [SlashOptionType.TodoList]: EditorNodeType.TodoListBlock, - [SlashOptionType.Heading1]: EditorNodeType.HeadingBlock, - [SlashOptionType.Heading2]: EditorNodeType.HeadingBlock, - [SlashOptionType.Heading3]: EditorNodeType.HeadingBlock, - [SlashOptionType.BulletedList]: EditorNodeType.BulletedListBlock, - [SlashOptionType.NumberedList]: EditorNodeType.NumberedListBlock, - [SlashOptionType.Quote]: EditorNodeType.QuoteBlock, - [SlashOptionType.ToggleList]: EditorNodeType.ToggleListBlock, - [SlashOptionType.Divider]: EditorNodeType.DividerBlock, - [SlashOptionType.Callout]: EditorNodeType.CalloutBlock, - [SlashOptionType.Code]: EditorNodeType.CodeBlock, - [SlashOptionType.Grid]: EditorNodeType.GridBlock, - [SlashOptionType.MathEquation]: EditorNodeType.EquationBlock, -}; - -const headingTypeToLevelMap: Record = { - [SlashOptionType.Heading1]: 1, - [SlashOptionType.Heading2]: 2, - [SlashOptionType.Heading3]: 3, -}; - -const headingTypes = [SlashOptionType.Heading1, SlashOptionType.Heading2, SlashOptionType.Heading3]; +import { useEditorBlockDispatch } from '$app/components/editor/stores/block'; +import { + headingTypes, + headingTypeToLevelMap, + reorderSlashOptions, + SlashAliases, + SlashCommandPanelTab, + slashOptionGroup, + slashOptionMapToEditorNodeType, + SlashOptionType, +} from '$app/components/editor/components/tools/command_panel/slash_command_panel/const'; export function useSlashCommandPanel({ searchText, @@ -95,6 +38,7 @@ export function useSlashCommandPanel({ searchText: string; closePanel: (deleteText?: boolean) => void; }) { + const { openPopover } = useEditorBlockDispatch(); const { t } = useTranslation(); const editor = useSlate(); const onConfirm = useCallback( @@ -117,7 +61,7 @@ export function useSlashCommandPanel({ if (nodeType === EditorNodeType.CalloutBlock) { Object.assign(data, { - icon: randomEmoji(), + icon: '📌', }); } @@ -127,6 +71,12 @@ export function useSlashCommandPanel({ }); } + if (nodeType === EditorNodeType.ImageBlock) { + Object.assign(data, { + url: '', + }); + } + closePanel(true); const newNode = getBlock(editor); @@ -136,7 +86,7 @@ export function useSlashCommandPanel({ if (!newNode || !path) return; - const isEmpty = CustomEditor.isEmptyText(editor, newNode) && newNode.type === EditorNodeType.Paragraph; + const isEmpty = CustomEditor.isEmptyText(editor, newNode); if (!isEmpty) { const nextPath = Path.next(path); @@ -145,12 +95,20 @@ export function useSlashCommandPanel({ editor.select(nextPath); } - CustomEditor.turnToBlock(editor, { + const turnIntoBlock = CustomEditor.turnToBlock(editor, { type: nodeType, data, }); + + setTimeout(() => { + if (turnIntoBlock && turnIntoBlock.blockId) { + if (turnIntoBlock.type === EditorNodeType.ImageBlock || turnIntoBlock.type === EditorNodeType.EquationBlock) { + openPopover(turnIntoBlock.type, turnIntoBlock.blockId); + } + } + }, 0); }, - [editor, closePanel] + [editor, closePanel, openPopover] ); const typeToLabelIconMap = useMemo(() => { @@ -212,6 +170,10 @@ export function useSlashCommandPanel({ label: t('document.plugins.mathEquation.name'), Icon: FunctionsOutlined, }, + [SlashOptionType.Image]: { + label: t('editor.image'), + Icon: ImageIcon, + }, }; }, [t]); @@ -219,6 +181,8 @@ export function useSlashCommandPanel({ return { [SlashCommandPanelTab.BASIC]: 'Basic', [SlashCommandPanelTab.ADVANCED]: 'Advanced', + [SlashCommandPanelTab.MEDIA]: 'Media', + [SlashCommandPanelTab.DATABASE]: 'Database', }; }, []); @@ -246,6 +210,7 @@ export function useSlashCommandPanel({ key: group.key, content:
{groupTypeToLabelMap[group.key]}
, children: group.options + .map((type) => { return { key: type, @@ -262,8 +227,12 @@ export function useSlashCommandPanel({ newSearchText = searchText.slice(1); } - return label.toLowerCase().includes(newSearchText.toLowerCase()); - }), + return ( + label.toLowerCase().includes(newSearchText.toLowerCase()) || + SlashAliases[option.key].some((alias) => alias.startsWith(newSearchText.toLowerCase())) + ); + }) + .sort(reorderSlashOptions(searchText)), }; }) .filter((group) => group.children.length > 0); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx index c5f2df0ae50d2..256e82f8118b6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx @@ -1,10 +1,8 @@ import React, { useEffect, useRef } from 'react'; import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; -import { - SlashOptionType, - useSlashCommandPanel, -} from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks'; +import { useSlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks'; import { useSlateStatic } from 'slate-react'; +import { SlashOptionType } from '$app/components/editor/components/tools/command_panel/slash_command_panel/const'; const noResultBuffer = 2; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts new file mode 100644 index 0000000000000..7dfaa2b4a03fd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts @@ -0,0 +1,174 @@ +import { EditorNodeType } from '$app/application/document/document.types'; + +export enum SlashCommandPanelTab { + BASIC = 'basic', + MEDIA = 'media', + DATABASE = 'database', + ADVANCED = 'advanced', +} + +export enum SlashOptionType { + Paragraph, + TodoList, + Heading1, + Heading2, + Heading3, + BulletedList, + NumberedList, + Quote, + ToggleList, + Divider, + Callout, + Code, + Grid, + MathEquation, + Image, +} + +export const slashOptionGroup = [ + { + key: SlashCommandPanelTab.BASIC, + options: [ + SlashOptionType.Paragraph, + SlashOptionType.TodoList, + SlashOptionType.Heading1, + SlashOptionType.Heading2, + SlashOptionType.Heading3, + SlashOptionType.BulletedList, + SlashOptionType.NumberedList, + SlashOptionType.Quote, + SlashOptionType.ToggleList, + SlashOptionType.Divider, + SlashOptionType.Callout, + ], + }, + { + key: SlashCommandPanelTab.MEDIA, + options: [SlashOptionType.Code, SlashOptionType.Image], + }, + { + key: SlashCommandPanelTab.DATABASE, + options: [SlashOptionType.Grid], + }, + { + key: SlashCommandPanelTab.ADVANCED, + options: [SlashOptionType.MathEquation], + }, +]; +export const slashOptionMapToEditorNodeType = { + [SlashOptionType.Paragraph]: EditorNodeType.Paragraph, + [SlashOptionType.TodoList]: EditorNodeType.TodoListBlock, + [SlashOptionType.Heading1]: EditorNodeType.HeadingBlock, + [SlashOptionType.Heading2]: EditorNodeType.HeadingBlock, + [SlashOptionType.Heading3]: EditorNodeType.HeadingBlock, + [SlashOptionType.BulletedList]: EditorNodeType.BulletedListBlock, + [SlashOptionType.NumberedList]: EditorNodeType.NumberedListBlock, + [SlashOptionType.Quote]: EditorNodeType.QuoteBlock, + [SlashOptionType.ToggleList]: EditorNodeType.ToggleListBlock, + [SlashOptionType.Divider]: EditorNodeType.DividerBlock, + [SlashOptionType.Callout]: EditorNodeType.CalloutBlock, + [SlashOptionType.Code]: EditorNodeType.CodeBlock, + [SlashOptionType.Grid]: EditorNodeType.GridBlock, + [SlashOptionType.MathEquation]: EditorNodeType.EquationBlock, + [SlashOptionType.Image]: EditorNodeType.ImageBlock, +}; +export const headingTypeToLevelMap: Record = { + [SlashOptionType.Heading1]: 1, + [SlashOptionType.Heading2]: 2, + [SlashOptionType.Heading3]: 3, +}; +export const headingTypes = [SlashOptionType.Heading1, SlashOptionType.Heading2, SlashOptionType.Heading3]; + +export const SlashAliases = { + [SlashOptionType.Paragraph]: ['paragraph', 'text', 'block', 'textblock'], + [SlashOptionType.TodoList]: [ + 'list', + 'todo', + 'todolist', + 'checkbox', + 'block', + 'todoblock', + 'checkboxblock', + 'todolistblock', + ], + [SlashOptionType.Heading1]: ['h1', 'heading1', 'block', 'headingblock', 'h1block'], + [SlashOptionType.Heading2]: ['h2', 'heading2', 'block', 'headingblock', 'h2block'], + [SlashOptionType.Heading3]: ['h3', 'heading3', 'block', 'headingblock', 'h3block'], + [SlashOptionType.BulletedList]: [ + 'list', + 'bulleted', + 'block', + 'bulletedlist', + 'bulletedblock', + 'listblock', + 'bulletedlistblock', + 'bulletelist', + ], + [SlashOptionType.NumberedList]: [ + 'list', + 'numbered', + 'block', + 'numberedlist', + 'numberedblock', + 'listblock', + 'numberedlistblock', + 'numberlist', + ], + [SlashOptionType.Quote]: ['quote', 'block', 'quoteblock'], + [SlashOptionType.ToggleList]: ['list', 'toggle', 'block', 'togglelist', 'toggleblock', 'listblock', 'togglelistblock'], + [SlashOptionType.Divider]: ['divider', 'hr', 'block', 'dividerblock', 'line', 'lineblock'], + [SlashOptionType.Callout]: ['callout', 'info', 'block', 'calloutblock'], + [SlashOptionType.Code]: ['code', 'code', 'block', 'codeblock', 'media'], + [SlashOptionType.Grid]: ['grid', 'table', 'block', 'gridblock', 'database'], + [SlashOptionType.MathEquation]: [ + 'math', + 'equation', + 'block', + 'mathblock', + 'mathequation', + 'mathequationblock', + 'advanced', + ], + [SlashOptionType.Image]: ['img', 'image', 'block', 'imageblock', 'media'], +}; + +export const reorderSlashOptions = (searchText: string) => { + return ( + a: { + key: SlashOptionType; + }, + b: { + key: SlashOptionType; + } + ) => { + const compareIndex = (option: SlashOptionType) => { + const aliases = SlashAliases[option]; + + if (aliases) { + for (const alias of aliases) { + if (alias.startsWith(searchText)) { + return -1; + } + } + } + + return 0; + }; + + const compareLength = (option: SlashOptionType) => { + const aliases = SlashAliases[option]; + + if (aliases) { + for (const alias of aliases) { + if (alias.length < searchText.length) { + return -1; + } + } + } + + return 0; + }; + + return compareIndex(a.key) - compareIndex(b.key) || compareLength(a.key) - compareLength(b.key); + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx index 5cb6e9659682a..9d7c19b999a3f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx @@ -14,7 +14,7 @@ import { Quote } from '$app/components/editor/components/tools/selection_toolbar import { ToggleList } from '$app/components/editor/components/tools/selection_toolbar/actions/toggle_list'; import { BulletedList } from '$app/components/editor/components/tools/selection_toolbar/actions/bulleted_list'; import { NumberedList } from '$app/components/editor/components/tools/selection_toolbar/actions/numbered_list'; -import { Href } from '$app/components/editor/components/tools/selection_toolbar/actions/href'; +import { Href, LinkActions } from '$app/components/editor/components/tools/selection_toolbar/actions/href'; import { Align } from '$app/components/editor/components/tools/selection_toolbar/actions/align'; import { Color } from '$app/components/editor/components/tools/selection_toolbar/actions/color'; @@ -22,12 +22,15 @@ function SelectionActions({ isAcrossBlocks, storeSelection, restoreSelection, + isIncludeRoot, }: { storeSelection: () => void; restoreSelection: () => void; isAcrossBlocks: boolean; visible: boolean; + isIncludeRoot: boolean; }) { + if (isIncludeRoot) return null; return (
{!isAcrossBlocks && ( @@ -62,6 +65,7 @@ function SelectionActions({ {!isAcrossBlocks && } +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts index 648ca84079e52..58834db6d592d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts @@ -3,7 +3,7 @@ import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } f import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils'; import debounce from 'lodash-es/debounce'; import { CustomEditor } from '$app/components/editor/command'; -import { BaseRange, Editor, Range as SlateRange } from 'slate'; +import { BaseRange, Range as SlateRange } from 'slate'; import { useDecorateDispatch } from '$app/components/editor/stores/decorate'; const DELAY = 300; @@ -14,6 +14,7 @@ export function useSelectionToolbar(ref: MutableRefObject const [isAcrossBlocks, setIsAcrossBlocks] = useState(false); const [visible, setVisible] = useState(false); const isFocusedEditor = useFocused(); + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); // paint the selection when the editor is blurred const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch(); @@ -61,12 +62,6 @@ export function useSelectionToolbar(ref: MutableRefObject return; } - // Close toolbar when selection include root - if (CustomEditor.selectionIncludeRoot(editor)) { - closeToolbar(); - return; - } - const position = getSelectionPosition(editor); if (!position) { @@ -114,20 +109,32 @@ export function useSelectionToolbar(ref: MutableRefObject useEffect(() => { const decorateState = getStaticState(); - if (decorateState) return; + if (decorateState) { + setIsAcrossBlocks(false); + return; + } const { selection } = editor; - if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection) || Editor.string(editor, selection) === '') { + const close = () => { debounceRecalculatePosition.cancel(); closeToolbar(); + }; + + if (isIncludeRoot || !isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) { + close(); return; } - const start = selection.anchor; - const end = selection.focus; + // There has a bug which the text of selection is empty when the selection include inline blocks + const isEmptyText = !CustomEditor.includeInlineBlocks(editor) && editor.string(selection) === ''; + + if (isEmptyText) { + close(); + return; + } - setIsAcrossBlocks(!CustomEditor.blockEqual(editor, start, end)); + setIsAcrossBlocks(CustomEditor.isMultipleBlockSelected(editor, true)); debounceRecalculatePosition(); }); @@ -162,6 +169,7 @@ export function useSelectionToolbar(ref: MutableRefObject }; }, [visible, editor, ref]); + // Close toolbar when press ESC useEffect(() => { const slateEditorDom = ReactEditor.toDOMNode(editor, editor); const onKeyDown = (e: KeyboardEvent) => { @@ -188,10 +196,44 @@ export function useSelectionToolbar(ref: MutableRefObject }; }, [closeToolbar, debounceRecalculatePosition, editor, visible]); + // Recalculate position when the scroll container is scrolled + useEffect(() => { + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + const scrollContainer = slateEditorDom.closest('.appflowy-scroll-container'); + + if (!visible) return; + if (!scrollContainer) return; + const handleScroll = () => { + if (isDraggingRef.current) return; + + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rangeRect = domRange?.getBoundingClientRect(); + + // Stop calculating when the range is out of the window + if (!rangeRect?.bottom || rangeRect.bottom < 0) { + return; + } + + recalculatePosition(); + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [visible, editor, recalculatePosition]); + return { visible, restoreSelection, storeSelection, isAcrossBlocks, + isIncludeRoot, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx index 214fa1730a9a7..d4ca9c9de0fc4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx @@ -6,7 +6,7 @@ import withErrorBoundary from '$app/components/_shared/error_boundary/withError' const Toolbar = memo(() => { const ref = useRef(null); - const { visible, restoreSelection, storeSelection, isAcrossBlocks } = useSelectionToolbar(ref); + const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref); return (
{ }} > (({ tooltip, onClick, disabled, children, active, className, ...props }, ref) => { return ( - + { setOpen(false); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx index fe2b69006821d..22be9f970abb7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx @@ -5,13 +5,14 @@ import { useSlateStatic } from 'slate-react'; import { CustomEditor } from '$app/components/editor/command'; import { ReactComponent as BoldSvg } from '$app/assets/bold.svg'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getHotKey } from '$app/components/editor/plugins/shortcuts'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; export function Bold() { const { t } = useTranslation(); const editor = useSlateStatic(); const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold); - const modifier = useMemo(() => getHotKey(EditorMarkFormat.Bold).modifier, []); + + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.BOLD), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { key: EditorMarkFormat.Bold, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx index faf4f2e9fc013..9f007dc7b5f94 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx @@ -47,7 +47,7 @@ function ColorPopover({ const { paperHeight, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ initialPaperWidth: 200, - initialPaperHeight: 360, + initialPaperHeight: 420, anchorEl, initialAnchorOrigin: initialOrigin.anchorOrigin, initialTransformOrigin: initialOrigin.transformOrigin, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx index 5aa190d1a724b..c7bfc113526ac 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx @@ -11,20 +11,30 @@ export function Formula() { const editor = useSlateStatic(); const isActivatedMention = CustomEditor.isMentionActive(editor); + const formulaMatch = CustomEditor.formulaActiveNode(editor); const isActivated = !isActivatedMention && CustomEditor.isFormulaActive(editor); const { setRange, openPopover } = useEditorInlineBlockState('formula'); const onClick = useCallback(() => { - const selection = editor.selection; + let selection = editor.selection; if (!selection) return; - CustomEditor.toggleFormula(editor); + if (formulaMatch) { + selection = editor.range(formulaMatch[1]); + editor.select(selection); + } else { + CustomEditor.toggleFormula(editor); + } requestAnimationFrame(() => { + const selection = editor.selection; + + if (!selection) return; + setRange(selection); openPopover(); }); - }, [editor, setRange, openPopover]); + }, [editor, formulaMatch, setRange, openPopover]); return ( { - const range = decorateState?.range; - - if (!range) return; - - const domRange = ReactEditor.toDOMRange(editor, range); - - const rect = domRange.getBoundingClientRect(); - - return { - top: rect.top, - left: rect.left, - height: rect.height, - }; - }, [decorateState?.range, editor]); - - const defaultHref = useMemo(() => { - const range = decorateState?.range; - - if (!range) return ''; - - const marks = Editor.marks(editor); - - return marks?.href || Editor.string(editor, range); - }, [decorateState?.range, editor]); - - const { add: addDecorate, clear: clearDecorate } = useDecorateDispatch(); + const { add: addDecorate } = useDecorateDispatch(); const onClick = useCallback(() => { if (!editor.selection) return; addDecorate({ @@ -53,30 +24,22 @@ export function Href() { }); }, [addDecorate, editor]); - const handleEditPopoverClose = useCallback(() => { - const range = decorateState?.range; + const tooltip = useMemo(() => { + const modifier = getModifier(); - clearDecorate(); - if (range) { - ReactEditor.focus(editor); - editor.select(range); - } - }, [clearDecorate, decorateState?.range, editor]); + return ( + <> +
{t('editor.link')}
+
{`${modifier} + K`}
+ + ); + }, [t]); return ( <> - + - {openEditPopover && ( - - )} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx new file mode 100644 index 0000000000000..b77a2490516af --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx @@ -0,0 +1,59 @@ +import React, { useCallback, useMemo } from 'react'; +import { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Editor } from 'slate'; +import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link'; + +export function LinkActions() { + const editor = useSlateStatic(); + const decorateState = useDecorateState('link'); + const openEditPopover = !!decorateState; + const { clear: clearDecorate } = useDecorateDispatch(); + + const anchorPosition = useMemo(() => { + const range = decorateState?.range; + + if (!range) return; + + const domRange = ReactEditor.toDOMRange(editor, range); + + const rect = domRange.getBoundingClientRect(); + + return { + top: rect.top, + left: rect.left, + height: rect.height, + }; + }, [decorateState?.range, editor]); + + const defaultHref = useMemo(() => { + const range = decorateState?.range; + + if (!range) return ''; + + const marks = Editor.marks(editor); + + return marks?.href || Editor.string(editor, range); + }, [decorateState?.range, editor]); + + const handleEditPopoverClose = useCallback(() => { + const range = decorateState?.range; + + clearDecorate(); + if (range) { + ReactEditor.focus(editor); + editor.select(range); + } + }, [clearDecorate, decorateState?.range, editor]); + + if (!openEditPopover) return null; + return ( + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts index 758b3b39d3d72..9a7210c1404d9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts @@ -1 +1,2 @@ export * from './Href'; +export * from './LinkActions'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx index ef761e5c8c387..3cf9c7ed858b0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx @@ -5,13 +5,13 @@ import { useSlateStatic } from 'slate-react'; import { CustomEditor } from '$app/components/editor/command'; import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getHotKey } from '$app/components/editor/plugins/shortcuts'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; export function InlineCode() { const { t } = useTranslation(); const editor = useSlateStatic(); const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code); - const modifier = useMemo(() => getHotKey(EditorMarkFormat.Code).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.CODE), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx index cb3f618e243fa..89fff40e6f594 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx @@ -5,13 +5,13 @@ import { useSlateStatic } from 'slate-react'; import { CustomEditor } from '$app/components/editor/command'; import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getHotKey } from '$app/components/editor/plugins/shortcuts'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; export function Italic() { const { t } = useTranslation(); const editor = useSlateStatic(); const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Italic); - const modifier = useMemo(() => getHotKey(EditorMarkFormat.Italic).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.ITALIC), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx index e715411bf528c..006247ca8b9b4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx @@ -12,10 +12,16 @@ export function NumberedList() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.NumberedListBlock); const onClick = useCallback(() => { + let type = EditorNodeType.NumberedListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.NumberedListBlock, + type, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx index 2076c84b1bc12..29ad0de104f81 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx @@ -12,10 +12,16 @@ export function Quote() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.QuoteBlock); const onClick = useCallback(() => { + let type = EditorNodeType.QuoteBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.QuoteBlock, + type, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx index 5cce3a603db69..325f6ac55a5a8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx @@ -5,13 +5,13 @@ import { useSlateStatic } from 'slate-react'; import { CustomEditor } from '$app/components/editor/command'; import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getHotKey } from '$app/components/editor/plugins/shortcuts'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; export function StrikeThrough() { const { t } = useTranslation(); const editor = useSlateStatic(); const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough); - const modifier = useMemo(() => getHotKey(EditorMarkFormat.StrikeThrough).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.STRIKETHROUGH), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx index 127e81106ee5b..cd576edafada7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx @@ -13,10 +13,19 @@ export function TodoList() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.TodoListBlock); const onClick = useCallback(() => { + let type = EditorNodeType.TodoListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.TodoListBlock, + type, + data: { + checked: false, + }, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx index 1302a84a87019..4d8265298883b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx @@ -12,10 +12,19 @@ export function ToggleList() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.ToggleListBlock); const onClick = useCallback(() => { + let type = EditorNodeType.ToggleListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.ToggleListBlock, + type, + data: { + collapsed: false, + }, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx index 7d32b83795179..b0df70e30e414 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx @@ -5,13 +5,13 @@ import { useSlateStatic } from 'slate-react'; import { CustomEditor } from '$app/components/editor/command'; import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getHotKey } from '$app/components/editor/plugins/shortcuts'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; export function Underline() { const { t } = useTranslation(); const editor = useSlateStatic(); const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Underline); - const modifier = useMemo(() => getHotKey(EditorMarkFormat.Underline).modifier, []); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.UNDERLINE), []); const onClick = useCallback(() => { CustomEditor.toggleMark(editor, { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss index 33e1e5fbe8b2f..271dd36cdac81 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss @@ -9,21 +9,21 @@ margin-left: 24px; } - - .block-element.block-align-left { > div > .text-element { + text-align: left; justify-content: flex-start; } } .block-element.block-align-right { - > div > .text-element { + > div > .text-element { + text-align: right; justify-content: flex-end; - } } .block-element.block-align-center { > div > .text-element { + text-align: center; justify-content: center; } @@ -39,6 +39,15 @@ display: none !important; } +[role=textbox] { + .text-element { + &::selection { + @apply bg-transparent; + } + } +} + + span[data-slate-placeholder="true"]:not(.inline-block-content) { @apply text-text-placeholder; @@ -54,6 +63,9 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &::selection { @apply bg-transparent; } + &.selected { + @apply bg-content-blue-100; + } span { &::selection { @apply bg-content-blue-100; @@ -63,7 +75,6 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } - [data-dark-mode="true"] [role="textbox"]{ ::selection { background-color: #1e79a2; @@ -73,6 +84,9 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &::selection { @apply bg-transparent; } + &.selected { + background-color: #1e79a2; + } span { &::selection { background-color: #1e79a2; @@ -82,26 +96,81 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } -.text-element:has(.text-placeholder), .divider-node { +.text-content, [data-dark-mode="true"] .text-content { + @apply min-w-[1px]; + &.empty-text { + span { + &::selection { + @apply bg-transparent; + } + } + } +} + +.text-element:has(.text-placeholder), .divider-node, [data-dark-mode="true"] .text-element:has(.text-placeholder), [data-dark-mode="true"] .divider-node { ::selection { @apply bg-transparent; } } .text-placeholder { - + @apply absolute left-[5px] transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap; &:after { - @apply text-text-placeholder absolute left-1.5 top-1/2 transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap; + @apply text-text-placeholder absolute top-0; content: (attr(placeholder)); } } -.has-start-icon > .text-placeholder { - &:after { - @apply left-[30px]; +.block-align-center { + .text-placeholder { + @apply left-[calc(50%+1px)]; + &:after { + @apply left-0; + } + } + .has-start-icon .text-placeholder { + @apply left-[calc(50%+13px)]; + &:after { + @apply left-0; + } + } + +} + +.block-align-left { + .text-placeholder { + &:after { + @apply left-0; + } + } + .has-start-icon .text-placeholder { + &:after { + @apply left-[24px]; + } + } +} + +.block-align-right { + + .text-placeholder { + + @apply relative w-fit h-0 order-2; + &:after { + @apply relative w-fit top-1/2 left-[-6px]; + } + } + .text-content { + @apply order-1; + } + + .has-start-icon .text-placeholder { + &:after { + @apply left-[-6px]; + } } } + .formula-inline { &.selected { @apply rounded bg-content-blue-100; @@ -110,7 +179,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .bulleted-icon { &:after { - content: "•"; + content: attr(data-letter); } } @@ -124,4 +193,39 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .grid-block .grid-scroll-container::-webkit-scrollbar { width: 0; height: 0; +} + +.image-render { + .image-resizer { + @apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end; + .resize-handle { + @apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0; + background: var(--fill-toolbar); + } + } + &:hover { + .image-resizer{ + .resize-handle { + @apply opacity-90; + } + } + } +} + + +.image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block { + ::selection { + @apply bg-transparent; + } + &:hover { + .container-bg { + background: var(--content-blue-100) !important; + } + } +} + +.mention-inline { + &:hover { + @apply bg-fill-list-active rounded; + } } \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts new file mode 100644 index 0000000000000..bf2b09a1c304d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts @@ -0,0 +1,2 @@ +export * from './withCopy'; +export * from './withPasted'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts new file mode 100644 index 0000000000000..cb377fece424f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts @@ -0,0 +1,311 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Node, Location, Range, Path, Element, Text, Transforms, NodeEntry } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { LIST_TYPES } from '$app/components/editor/command/tab'; + +/** + * Rewrite the insertFragment function to avoid the empty node(doesn't have text node) in the fragment + + * @param editor + * @param fragment + * @param options + */ +export function insertFragment( + editor: ReactEditor, + fragment: (Text | Element)[], + options: { + at?: Location; + hanging?: boolean; + voids?: boolean; + } = {} +) { + Editor.withoutNormalizing(editor, () => { + const { hanging = false, voids = false } = options; + let { at = getDefaultInsertLocation(editor) } = options; + + if (!fragment.length) { + return; + } + + if (Range.isRange(at)) { + if (!hanging) { + at = Editor.unhangRange(editor, at, { voids }); + } + + if (Range.isCollapsed(at)) { + at = at.anchor; + } else { + const [, end] = Range.edges(at); + + if (!voids && Editor.void(editor, { at: end })) { + return; + } + + const pointRef = Editor.pointRef(editor, end); + + Transforms.delete(editor, { at }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + at = pointRef.unref()!; + } + } else if (Path.isPath(at)) { + at = Editor.start(editor, at); + } + + if (!voids && Editor.void(editor, { at })) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const blockMatch = Editor.above(editor, { + match: (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.blockId !== undefined, + at, + voids, + })!; + const [block, blockPath] = blockMatch as NodeEntry; + + const isEmbedBlock = Element.isElement(block) && editor.isEmbed(block); + const isPageBlock = Element.isElement(block) && block.type === EditorNodeType.Page; + const isBlockStart = Editor.isStart(editor, at, blockPath); + const isBlockEnd = Editor.isEnd(editor, at, blockPath); + const isBlockEmpty = isBlockStart && isBlockEnd; + + if (isEmbedBlock) { + insertOnEmbedBlock(editor, fragment, blockPath); + return; + } + + if (isBlockEmpty && !isPageBlock) { + const node = fragment[0] as Element; + + if (block.type !== EditorNodeType.Paragraph) { + node.type = block.type; + node.data = { + ...(node.data || {}), + ...(block.data || {}), + }; + } + + insertOnEmptyBlock(editor, fragment, blockPath); + return; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const fragmentRoot: Node = { + children: fragment, + }; + const [, firstPath] = Node.first(fragmentRoot, []); + const [, lastPath] = Node.last(fragmentRoot, []); + const sameBlock = Path.equals(firstPath.slice(0, -1), lastPath.slice(0, -1)); + + if (sameBlock) { + insertTexts( + editor, + isPageBlock + ? ({ + children: [ + { + text: CustomEditor.getNodeTextContent(fragmentRoot), + }, + ], + } as Node) + : fragmentRoot, + at + ); + return; + } + + const isListTypeBlock = LIST_TYPES.includes(block.type as EditorNodeType); + const [, ...blockChildren] = block.children; + + const blockEnd = editor.end([...blockPath, 0]); + const afterRange: Range = { anchor: at, focus: blockEnd }; + + const afterTexts = getTexts(editor, { + children: editor.fragment(afterRange), + } as Node) as (Text | Element)[]; + + Transforms.delete(editor, { at: afterRange }); + + const { startTexts, startChildren, middles } = getFragmentGroup(editor, fragment); + + insertNodes( + editor, + isPageBlock + ? [ + { + text: CustomEditor.getNodeTextContent({ + children: startTexts, + } as Node), + }, + ] + : startTexts, + { + at, + } + ); + + if (isPageBlock) { + insertNodes(editor, [...startChildren, ...middles], { + at: Path.next(blockPath), + select: true, + }); + } else { + if (blockChildren.length > 0) { + const path = [...blockPath, 1]; + + insertNodes(editor, [...startChildren, ...middles], { + at: path, + select: true, + }); + } else { + const newMiddle = [...middles]; + + if (isListTypeBlock) { + const path = [...blockPath, 1]; + + insertNodes(editor, startChildren, { + at: path, + select: newMiddle.length === 0, + }); + } else { + newMiddle.unshift(...startChildren); + } + + insertNodes(editor, newMiddle, { + at: Path.next(blockPath), + select: true, + }); + } + } + + const { selection } = editor; + + if (!selection) return; + + insertNodes(editor, afterTexts, { + at: selection, + }); + }); +} + +function getFragmentGroup(editor: ReactEditor, fragment: Node[]) { + const startTexts = []; + const startChildren = []; + const middles = []; + + const [firstNode, ...otherNodes] = fragment; + const [firstNodeText, ...firstNodeChildren] = (firstNode as Element).children as Element[]; + + startTexts.push(...firstNodeText.children); + startChildren.push(...firstNodeChildren); + + for (const node of otherNodes) { + if (Element.isElement(node) && node.blockId !== undefined) { + middles.push(node); + } + } + + return { + startTexts, + startChildren, + middles, + }; +} + +function getTexts(editor: ReactEditor, fragment: Node) { + const matches = []; + const matcher = ([n]: NodeEntry) => Text.isText(n) || (Element.isElement(n) && editor.isInline(n)); + + for (const entry of Node.nodes(fragment, { pass: matcher })) { + if (matcher(entry)) { + matches.push(entry[0]); + } + } + + return matches; +} + +function insertTexts(editor: ReactEditor, fragmentRoot: Node, at: Location) { + const matches = getTexts(editor, fragmentRoot); + + insertNodes(editor, matches, { + at, + select: true, + }); +} + +function insertOnEmptyBlock(editor: ReactEditor, fragment: Node[], blockPath: Path) { + editor.removeNodes({ + at: blockPath, + }); + + insertNodes(editor, fragment, { + at: blockPath, + select: true, + }); +} + +function insertOnEmbedBlock(editor: ReactEditor, fragment: Node[], blockPath: Path) { + insertNodes(editor, fragment, { + at: Path.next(blockPath), + select: true, + }); +} + +function insertNodes(editor: ReactEditor, nodes: Node[], options: { at?: Location; select?: boolean } = {}) { + try { + Transforms.insertNodes(editor, nodes, options); + } catch (e) { + try { + editor.move({ + distance: 1, + unit: 'line', + }); + } catch (e) { + // do nothing + } + } +} + +/** + * Copy Code from slate/src/utils/get-default-insert-location.ts + * Get the default location to insert content into the editor. + * By default, use the selection as the target location. But if there is + * no selection, insert at the end of the document since that is such a + * common use case when inserting from a non-selected state. + */ +export const getDefaultInsertLocation = (editor: Editor): Location => { + if (editor.selection) { + return editor.selection; + } else if (editor.children.length > 0) { + return Editor.end(editor, []); + } else { + return [0]; + } +}; + +export function transFragment(editor: ReactEditor, fragment: Node[]) { + // flatten the fragment to avoid the empty node(doesn't have text node) in the fragment + const flatMap = (node: Node): Node[] => { + const isInputElement = + !Editor.isEditor(node) && Element.isElement(node) && node.blockId !== undefined && !editor.isEmbed(node); + + if ( + isInputElement && + node.children?.length > 0 && + Element.isElement(node.children[0]) && + node.children[0].type !== EditorNodeType.Text + ) { + return node.children.flatMap((child) => flatMap(child)); + } + + return [node]; + }; + + const fragmentFlatMap = fragment?.flatMap(flatMap); + + // clone the node to avoid the duplicated block id + return fragmentFlatMap.map((item) => CustomEditor.cloneBlock(editor, item as Element)); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts new file mode 100644 index 0000000000000..c0daab0a8f95b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts @@ -0,0 +1,40 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, Range } from 'slate'; + +export function withCopy(editor: ReactEditor) { + const { setFragmentData } = editor; + + editor.setFragmentData = (...args) => { + if (!editor.selection) { + setFragmentData(...args); + return; + } + + // selection is collapsed and the node is an embed, we need to set the data manually + if (Range.isCollapsed(editor.selection)) { + const match = Editor.above(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + const node = match ? (match[0] as Element) : undefined; + + if (node && editor.isEmbed(node)) { + const fragment = editor.getFragment(); + + if (fragment.length > 0) { + const data = args[0]; + const string = JSON.stringify(fragment); + const encoded = window.btoa(encodeURIComponent(string)); + + const dom = ReactEditor.toDOMNode(editor, node); + + data.setData(`application/x-slate-fragment`, encoded); + data.setData(`text/html`, dom.innerHTML); + } + } + } + + setFragmentData(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts new file mode 100644 index 0000000000000..2266ff41c78e6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts @@ -0,0 +1,59 @@ +import { ReactEditor } from 'slate-react'; +import { insertFragment, transFragment } from './utils'; +import { convertBlockToJson } from '$app/application/document/document.service'; +import { InputType } from '@/services/backend'; +import { CustomEditor } from '$app/components/editor/command'; +import { Log } from '$app/utils/log'; + +export function withPasted(editor: ReactEditor) { + const { insertData } = editor; + + editor.insertData = (data) => { + const fragment = data.getData('application/x-slate-fragment'); + + if (fragment) { + insertData(data); + return; + } + + const html = data.getData('text/html'); + const text = data.getData('text/plain'); + + if (!html && !text) { + insertData(data); + return; + } + + void (async () => { + try { + const nodes = await convertBlockToJson(html, InputType.Html); + + const htmlTransNoText = nodes.every((node) => { + return CustomEditor.getNodeTextContent(node).length === 0; + }); + + if (!htmlTransNoText) { + return editor.insertFragment(nodes); + } + } catch (e) { + Log.warn('pasted html error', e); + // ignore + } + + if (text) { + const nodes = await convertBlockToJson(text, InputType.PlainText); + + editor.insertFragment(nodes); + return; + } + })(); + }; + + editor.insertFragment = (fragment, options = {}) => { + const clonedFragment = transFragment(editor, fragment); + + insertFragment(editor, clonedFragment, options); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/hotkey.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/hotkey.ts deleted file mode 100644 index c0a401ebfd00a..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/hotkey.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { EditorMarkFormat } from '$app/application/document/document.types'; -import { getModifier } from '$app/utils/get_modifier'; - -/** - * Hotkeys shortcuts - * @description - * - bold: Mod+b - * - italic: Mod+i - * - underline: Mod+u - * - strikethrough: Mod+Shift+s - * - code: Mod+Shift+c - */ -export const getHotKeys: () => { - [key: string]: { modifier: string; hotkey: string; markKey: EditorMarkFormat; markValue: string | boolean }; -} = () => { - const modifier = getModifier(); - - return { - [EditorMarkFormat.Bold]: { - hotkey: 'mod+b', - modifier: `${modifier} + B`, - markKey: EditorMarkFormat.Bold, - markValue: true, - }, - [EditorMarkFormat.Italic]: { - hotkey: 'mod+i', - modifier: `${modifier} + I`, - markKey: EditorMarkFormat.Italic, - markValue: true, - }, - [EditorMarkFormat.Underline]: { - hotkey: 'mod+u', - modifier: `${modifier} + U`, - markKey: EditorMarkFormat.Underline, - markValue: true, - }, - [EditorMarkFormat.StrikeThrough]: { - hotkey: 'mod+shift+s', - modifier: `${modifier} + Shift + S`, - markKey: EditorMarkFormat.StrikeThrough, - markValue: true, - }, - [EditorMarkFormat.Code]: { - hotkey: 'mod+shift+c', - modifier: `${modifier} + Shift + C`, - markKey: EditorMarkFormat.Code, - markValue: true, - }, - 'align-left': { - hotkey: 'control+shift+l', - modifier: `Ctrl + Shift + L`, - markKey: EditorMarkFormat.Align, - markValue: 'left', - }, - 'align-center': { - hotkey: 'control+shift+e', - modifier: `Ctrl + Shift + E`, - markKey: EditorMarkFormat.Align, - markValue: 'center', - }, - 'align-right': { - hotkey: 'control+shift+r', - modifier: `Ctrl + Shift + R`, - markKey: EditorMarkFormat.Align, - markValue: 'right', - }, - }; -}; - -export const getHotKey = (key: EditorMarkFormat) => { - return getHotKeys()[key]; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts index fc262b90365b3..0292784ba546c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts @@ -1,3 +1,2 @@ export * from './shortcuts.hooks'; -export * from './withShortcuts'; -export * from './hotkey'; +export * from './withMarkdown'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts new file mode 100644 index 0000000000000..59ff0a859327f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts @@ -0,0 +1,172 @@ +export type MarkdownRegex = { + [key in MarkdownShortcuts]: { + pattern: RegExp; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record; + }[]; +}; + +export type TriggerHotKey = { + [key in MarkdownShortcuts]: string[]; +}; + +export enum MarkdownShortcuts { + Bold, + Italic, + StrikeThrough, + Code, + Equation, + /** block */ + Heading, + BlockQuote, + CodeBlock, + Divider, + /** list */ + BulletedList, + NumberedList, + TodoList, + ToggleList, +} + +const defaultMarkdownRegex: MarkdownRegex = { + [MarkdownShortcuts.Heading]: [ + { + pattern: /^#{1,6}$/, + }, + ], + [MarkdownShortcuts.Bold]: [ + { + pattern: /(\*\*|__)(.*?)(\*\*|__)$/, + }, + ], + [MarkdownShortcuts.Italic]: [ + { + pattern: /([*_])(.*?)([*_])$/, + }, + ], + [MarkdownShortcuts.StrikeThrough]: [ + { + pattern: /(~~)(.*?)(~~)$/, + }, + { + pattern: /(~)(.*?)(~)$/, + }, + ], + [MarkdownShortcuts.Code]: [ + { + pattern: /(`)(.*?)(`)$/, + }, + ], + [MarkdownShortcuts.Equation]: [ + { + pattern: /(\$)(.*?)(\$)$/, + data: { + formula: '', + }, + }, + ], + [MarkdownShortcuts.BlockQuote]: [ + { + pattern: /^([”“"])$/, + }, + ], + [MarkdownShortcuts.CodeBlock]: [ + { + pattern: /^(`{2,})$/, + data: { + language: 'json', + }, + }, + ], + [MarkdownShortcuts.Divider]: [ + { + pattern: /^(([-*]){2,})$/, + }, + ], + + [MarkdownShortcuts.BulletedList]: [ + { + pattern: /^([*\-+])$/, + }, + ], + [MarkdownShortcuts.NumberedList]: [ + { + pattern: /^(\d+)\.$/, + }, + ], + [MarkdownShortcuts.TodoList]: [ + { + pattern: /^(-)?\[ ]$/, + data: { + checked: false, + }, + }, + { + pattern: /^(-)?\[x]$/, + data: { + checked: true, + }, + }, + { + pattern: /^(-)?\[]$/, + data: { + checked: false, + }, + }, + ], + [MarkdownShortcuts.ToggleList]: [ + { + pattern: /^>$/, + data: { + collapsed: false, + }, + }, + ], +}; + +export const defaultTriggerChar: TriggerHotKey = { + [MarkdownShortcuts.Heading]: [' '], + [MarkdownShortcuts.Bold]: ['*', '_'], + [MarkdownShortcuts.Italic]: ['*', '_'], + [MarkdownShortcuts.StrikeThrough]: ['~'], + [MarkdownShortcuts.Code]: ['`'], + [MarkdownShortcuts.BlockQuote]: [' '], + [MarkdownShortcuts.CodeBlock]: ['`'], + [MarkdownShortcuts.Divider]: ['-', '*'], + [MarkdownShortcuts.Equation]: ['$'], + [MarkdownShortcuts.BulletedList]: [' '], + [MarkdownShortcuts.NumberedList]: [' '], + [MarkdownShortcuts.TodoList]: [' '], + [MarkdownShortcuts.ToggleList]: [' '], +}; + +export function isTriggerChar(char: string) { + return Object.values(defaultTriggerChar).some((trigger) => trigger.includes(char)); +} + +export function whatShortcutTrigger(char: string): MarkdownShortcuts[] | null { + const isTrigger = isTriggerChar(char); + + if (!isTrigger) { + return null; + } + + const shortcuts = Object.keys(defaultTriggerChar).map((key) => Number(key) as MarkdownShortcuts); + + return shortcuts.filter((shortcut) => defaultTriggerChar[shortcut].includes(char)); +} + +export function getRegex(shortcut: MarkdownShortcuts) { + return defaultMarkdownRegex[shortcut]; +} + +export function whatShortcutsMatch(text: string) { + const shortcuts = Object.keys(defaultMarkdownRegex).map((key) => Number(key) as MarkdownShortcuts); + + return shortcuts.filter((shortcut) => { + const regexes = defaultMarkdownRegex[shortcut]; + + return regexes.some((regex) => regex.pattern.test(text)); + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts index f375bd9f3cdd9..45d61f847cfe3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts @@ -1,159 +1,349 @@ import { ReactEditor } from 'slate-react'; import { useCallback, KeyboardEvent } from 'react'; -import { - EditorMarkFormat, - EditorNodeType, - TodoListNode, - ToggleListNode, -} from '$app/application/document/document.types'; -import isHotkey from 'is-hotkey'; +import { EditorMarkFormat, EditorNodeType, ToggleListNode } from '$app/application/document/document.types'; import { getBlock } from '$app/components/editor/plugins/utils'; import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; import { CustomEditor } from '$app/components/editor/command'; -import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey'; - -/** - * Hotkeys shortcuts - * @description [getHotKeys] is defined in [hotkey.ts] - * - bold: Mod+b - * - italic: Mod+i - * - underline: Mod+u - * - strikethrough: Mod+Shift+s - * - code: Mod+Shift+c - * - align left: Mod+Shift+l - * - align center: Mod+Shift+e - * - align right: Mod+Shift+r - * - indent: Tab - * - outdent: Shift+Tab - * - split block: Enter - * - insert \n: Shift+Enter - * - toggle todo or toggle: Mod+Enter (toggle todo list or toggle list) - */ - -const inputTypeToFormat: Record = { - formatBold: EditorMarkFormat.Bold, - formatItalic: EditorMarkFormat.Italic, - formatUnderline: EditorMarkFormat.Underline, - formatStrikethrough: EditorMarkFormat.StrikeThrough, - formatCode: EditorMarkFormat.Code, -}; +import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys'; +import { openUrl } from '$app/utils/open_url'; +import { Range } from 'slate'; +import { readText } from '@tauri-apps/api/clipboard'; +import { useDecorateDispatch } from '$app/components/editor/stores'; + +function getScrollContainer(editor: ReactEditor) { + const editorDom = ReactEditor.toDOMNode(editor, editor); + + return editorDom.closest('.appflowy-scroll-container') as HTMLDivElement; +} export function useShortcuts(editor: ReactEditor) { - const onDOMBeforeInput = useCallback( - (e: InputEvent) => { - const inputType = e.inputType; - - const format = inputTypeToFormat[inputType]; - - if (format) { - e.preventDefault(); - if (CustomEditor.selectionIncludeRoot(editor)) return; - return CustomEditor.toggleMark(editor, { - key: format, - value: true, - }); - } - }, - [editor] - ); + const { add: addDecorate } = useDecorateDispatch(); + + const formatLink = useCallback(() => { + const { selection } = editor; + + if (!selection || Range.isCollapsed(selection)) return; + + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); + + if (isIncludeRoot) return; + + const isActivatedInline = CustomEditor.isInlineActive(editor); + + if (isActivatedInline) return; + + addDecorate({ + range: selection, + class_name: 'bg-content-blue-100 rounded', + type: 'link', + }); + }, [addDecorate, editor]); const onKeyDown = useCallback( (e: KeyboardEvent) => { - const isAppleWebkit = navigator.userAgent.includes('AppleWebKit'); + const event = e.nativeEvent; + const hasEditableTarget = ReactEditor.hasEditableTarget(editor, event.target); + + if (!hasEditableTarget) return; + + const node = getBlock(editor); - // Apple Webkit does not support the input event for formatting - if (isAppleWebkit) { - Object.entries(getHotKeys()).forEach(([_, item]) => { - if (isHotkey(item.hotkey, e)) { - e.stopPropagation(); + const { selection } = editor; + const isExpanded = selection && Range.isExpanded(selection); + + switch (true) { + /** + * Select all: Mod+A + * Default behavior: Select all text in the editor + * Special case for select all in code block: Only select all text in code block + */ + case createHotkey(HOT_KEY_NAME.SELECT_ALL)(event): + if (node && node.type === EditorNodeType.CodeBlock) { + e.preventDefault(); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + } + + break; + /** + * Escape: Esc + * Default behavior: Deselect editor + */ + case createHotkey(HOT_KEY_NAME.ESCAPE)(event): + editor.deselect(); + break; + /** + * Indent block: Tab + * Default behavior: Indent block + */ + case createHotkey(HOT_KEY_NAME.INDENT_BLOCK)(event): + e.preventDefault(); + if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { + editor.insertText('\t'); + break; + } + + CustomEditor.tabForward(editor); + break; + /** + * Outdent block: Shift+Tab + * Default behavior: Outdent block + */ + case createHotkey(HOT_KEY_NAME.OUTDENT_BLOCK)(event): + e.preventDefault(); + CustomEditor.tabBackward(editor); + break; + /** + * Split block: Enter + * Default behavior: Split block + * Special case for soft break types: Insert \n + */ + case createHotkey(HOT_KEY_NAME.SPLIT_BLOCK)(event): + if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { e.preventDefault(); - if (CustomEditor.selectionIncludeRoot(editor)) return; - if (item.markKey === EditorMarkFormat.Align) { - CustomEditor.toggleAlign(editor, item.markValue as string); - return; - } - - CustomEditor.toggleMark(editor, { - key: item.markKey, - value: item.markValue, + editor.insertText('\n'); + } + + break; + /** + * Insert soft break: Shift+Enter + * Default behavior: Insert \n + * Special case for soft break types: Split block + */ + case createHotkey(HOT_KEY_NAME.INSERT_SOFT_BREAK)(event): + e.preventDefault(); + if (node && SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) { + editor.splitNodes({ + always: true, }); - return; + } else { + editor.insertText('\n'); } - }); - } - const node = getBlock(editor); + break; + /** + * Toggle todo: Shift+Enter + * Default behavior: Toggle todo + * Special case for toggle list block: Toggle collapse + */ + case createHotkey(HOT_KEY_NAME.TOGGLE_TODO)(event): + case createHotkey(HOT_KEY_NAME.TOGGLE_COLLAPSE)(event): + e.preventDefault(); + if (node && node.type === EditorNodeType.ToggleListBlock) { + CustomEditor.toggleToggleList(editor, node as ToggleListNode); + } else { + CustomEditor.toggleTodo(editor); + } - if (isHotkey('Escape', e)) { - e.preventDefault(); - e.stopPropagation(); - editor.deselect(); - return; - } + break; + /** + * Backspace: Backspace / Shift+Backspace + * Default behavior: Delete backward + */ + case createHotkey(HOT_KEY_NAME.BACKSPACE)(event): + e.stopPropagation(); + break; + /** + * Open link: Alt + enter + * Default behavior: Open one link in selection + */ + case createHotkey(HOT_KEY_NAME.OPEN_LINK)(event): { + if (!isExpanded) break; + e.preventDefault(); + const links = CustomEditor.getLinks(editor); - if (isHotkey('Tab', e)) { - e.preventDefault(); - if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { - editor.insertText('\t'); - return; + if (links.length === 0) break; + openUrl(links[0]); + break; } - return CustomEditor.tabForward(editor); - } + /** + * Open links: Alt + Shift + enter + * Default behavior: Open all links in selection + */ + case createHotkey(HOT_KEY_NAME.OPEN_LINKS)(event): { + if (!isExpanded) break; + e.preventDefault(); + const links = CustomEditor.getLinks(editor); - if (isHotkey('shift+Tab', e)) { - e.preventDefault(); - return CustomEditor.tabBackward(editor); - } + if (links.length === 0) break; + links.forEach((link) => openUrl(link)); + break; + } - if (isHotkey('Enter', e)) { - if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { + /** + * Extend line backward: Opt + Shift + right + * Default behavior: Extend line backward + */ + case createHotkey(HOT_KEY_NAME.EXTEND_LINE_BACKWARD)(event): e.preventDefault(); - editor.insertText('\n'); - return; - } - } + CustomEditor.extendLineBackward(editor); + break; + /** + * Extend line forward: Opt + Shift + left + */ + case createHotkey(HOT_KEY_NAME.EXTEND_LINE_FORWARD)(event): + e.preventDefault(); + CustomEditor.extendLineForward(editor); + break; + + /** + * Paste: Mod + Shift + V + * Default behavior: Paste plain text + */ + case createHotkey(HOT_KEY_NAME.PASTE_PLAIN_TEXT)(event): + e.preventDefault(); + void (async () => { + const text = await readText(); + + if (!text) return; + CustomEditor.insertPlainText(editor, text); + })(); - if (isHotkey('shift+Enter', e) && node) { - e.preventDefault(); - if (SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) { - editor.splitNodes({ - always: true, + break; + /** + * Highlight: Mod + Shift + H + * Default behavior: Highlight selected text + */ + case createHotkey(HOT_KEY_NAME.HIGH_LIGHT)(event): + e.preventDefault(); + CustomEditor.highlight(editor); + break; + /** + * Extend document backward: Mod + Shift + Up + * Don't prevent default behavior + * Default behavior: Extend document backward + */ + case createHotkey(HOT_KEY_NAME.EXTEND_DOCUMENT_BACKWARD)(event): + editor.collapse({ edge: 'start' }); + break; + /** + * Extend document forward: Mod + Shift + Down + * Don't prevent default behavior + * Default behavior: Extend document forward + */ + case createHotkey(HOT_KEY_NAME.EXTEND_DOCUMENT_FORWARD)(event): + editor.collapse({ edge: 'end' }); + break; + + /** + * Scroll to top: Home + * Default behavior: Scroll to top + */ + case createHotkey(HOT_KEY_NAME.SCROLL_TO_TOP)(event): { + const scrollContainer = getScrollContainer(editor); + + scrollContainer.scrollTo({ + top: 0, }); - } else { - editor.insertText('\n'); + break; } - return; - } + /** + * Scroll to bottom: End + * Default behavior: Scroll to bottom + */ + case createHotkey(HOT_KEY_NAME.SCROLL_TO_BOTTOM)(event): { + const scrollContainer = getScrollContainer(editor); - if (isHotkey('mod+Enter', e) && node) { - if (node.type === EditorNodeType.TodoListBlock) { - e.preventDefault(); - CustomEditor.toggleTodo(editor, node as TodoListNode); - return; + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + }); + break; } - if (node.type === EditorNodeType.ToggleListBlock) { + /** + * Align left: Control + Shift + L + * Default behavior: Align left + */ + case createHotkey(HOT_KEY_NAME.ALIGN_LEFT)(event): e.preventDefault(); - CustomEditor.toggleToggleList(editor, node as ToggleListNode); - return; - } - } + CustomEditor.toggleAlign(editor, 'left'); + break; + /** + * Align center: Control + Shift + E + */ + case createHotkey(HOT_KEY_NAME.ALIGN_CENTER)(event): + e.preventDefault(); + CustomEditor.toggleAlign(editor, 'center'); + break; + /** + * Align right: Control + Shift + R + */ + case createHotkey(HOT_KEY_NAME.ALIGN_RIGHT)(event): + e.preventDefault(); + CustomEditor.toggleAlign(editor, 'right'); + break; + /** + * Bold: Mod + B + */ + case createHotkey(HOT_KEY_NAME.BOLD)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Bold, + value: true, + }); + break; + /** + * Italic: Mod + I + */ + case createHotkey(HOT_KEY_NAME.ITALIC)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Italic, + value: true, + }); + break; + /** + * Underline: Mod + U + */ + case createHotkey(HOT_KEY_NAME.UNDERLINE)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Underline, + value: true, + }); + break; + /** + * Strikethrough: Mod + Shift + S / Mod + Shift + X + */ + case createHotkey(HOT_KEY_NAME.STRIKETHROUGH)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.StrikeThrough, + value: true, + }); + break; + /** + * Code: Mod + E + */ + case createHotkey(HOT_KEY_NAME.CODE)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Code, + value: true, + }); + break; + /** + * Format link: Mod + K + */ + case createHotkey(HOT_KEY_NAME.FORMAT_LINK)(event): + formatLink(); + break; - if (isHotkey('shift+backspace', e)) { - e.preventDefault(); - e.stopPropagation(); + case createHotkey(HOT_KEY_NAME.FIND_REPLACE)(event): + console.log('find replace'); + break; - editor.deleteBackward('character'); - return; + default: + break; } }, - [editor] + [formatLink, editor] ); return { - onDOMBeforeInput, onKeyDown, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts new file mode 100644 index 0000000000000..fd7801204ceb1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts @@ -0,0 +1,239 @@ +import { Range, Element, Editor, NodeEntry, Path } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { + defaultTriggerChar, + getRegex, + MarkdownShortcuts, + whatShortcutsMatch, + whatShortcutTrigger, +} from '$app/components/editor/plugins/shortcuts/markdown'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; +import isEqual from 'lodash-es/isEqual'; + +export const withMarkdown = (editor: ReactEditor) => { + const { insertText } = editor; + + editor.insertText = (char) => { + const { selection } = editor; + + insertText(char); + if (!selection || !Range.isCollapsed(selection)) { + return; + } + + const triggerShortcuts = whatShortcutTrigger(char); + + if (!triggerShortcuts) { + return; + } + + const match = CustomEditor.getBlock(editor); + const [node, path] = match as NodeEntry; + + let prevIsNumberedList = false; + + try { + const prevPath = Path.previous(path); + const prev = editor.node(prevPath) as NodeEntry; + + prevIsNumberedList = prev && prev[0].type === EditorNodeType.NumberedListBlock; + } catch (e) { + // do nothing + } + + const start = Editor.start(editor, path); + const beforeRange = { anchor: start, focus: selection.anchor }; + const beforeText = Editor.string(editor, beforeRange); + + const removeBeforeText = (beforeRange: Range) => { + editor.deleteBackward('character'); + editor.delete({ + at: beforeRange, + }); + }; + + const matchBlockShortcuts = whatShortcutsMatch(beforeText); + + for (const shortcut of matchBlockShortcuts) { + const block = whichBlock(shortcut, beforeText); + + // if the block shortcut is matched, remove the before text and turn to the block + // then return + if (block && defaultTriggerChar[shortcut].includes(char)) { + // Don't turn to the block condition + // 1. Heading should be able to co-exist with number list + if (block.type === EditorNodeType.NumberedListBlock && node.type === EditorNodeType.HeadingBlock) { + return; + } + + // 2. If the block is the same type, and data is the same + if (block.type === node.type && isEqual(block.data || {}, node.data || {})) { + return; + } + + // 3. If the block is number list, and the previous block is also number list + if (block.type === EditorNodeType.NumberedListBlock && prevIsNumberedList) { + return; + } + + removeBeforeText(beforeRange); + CustomEditor.turnToBlock(editor, block); + + return; + } + } + + // get the range that matches the mark shortcuts + const markRange = { + anchor: Editor.start(editor, selection.anchor.path), + focus: selection.focus, + }; + const rangeText = Editor.string(editor, markRange) + char; + + if (!rangeText) return; + + // inputting a character that is start of a mark + const isStartTyping = rangeText.indexOf(char) === rangeText.lastIndexOf(char); + + if (isStartTyping) return; + + // if the range text includes a double character mark, and the last one is not finished + const doubleCharNotFinish = + ['*', '_', '~'].includes(char) && + rangeText.indexOf(`${char}${char}`) > -1 && + rangeText.indexOf(`${char}${char}`) === rangeText.lastIndexOf(`${char}${char}`); + + if (doubleCharNotFinish) return; + + const matchMarkShortcuts = whatShortcutsMatch(rangeText); + + for (const shortcut of matchMarkShortcuts) { + const item = getRegex(shortcut).find((p) => p.pattern.test(rangeText)); + const execArr = item?.pattern?.exec(rangeText); + + const removeText = execArr ? execArr[0] : ''; + + const text = execArr ? execArr[2]?.replaceAll(char, '') : ''; + + if (text) { + const index = rangeText.indexOf(removeText); + const removeRange = { + anchor: { + path: markRange.anchor.path, + offset: markRange.anchor.offset + index, + }, + focus: { + path: markRange.anchor.path, + offset: markRange.anchor.offset + index + removeText.length, + }, + }; + + removeBeforeText(removeRange); + insertMark(editor, shortcut, text); + return; + } + } + }; + + return editor; +}; + +function whichBlock(shortcut: MarkdownShortcuts, beforeText: string) { + switch (shortcut) { + case MarkdownShortcuts.Heading: + return { + type: EditorNodeType.HeadingBlock, + data: { + level: beforeText.length, + }, + }; + case MarkdownShortcuts.CodeBlock: + return { + type: EditorNodeType.CodeBlock, + data: { + language: 'json', + }, + }; + case MarkdownShortcuts.BulletedList: + return { + type: EditorNodeType.BulletedListBlock, + data: {}, + }; + case MarkdownShortcuts.NumberedList: + return { + type: EditorNodeType.NumberedListBlock, + data: { + number: Number(beforeText.split('.')[0]) ?? 1, + }, + }; + case MarkdownShortcuts.TodoList: + return { + type: EditorNodeType.TodoListBlock, + data: { + checked: beforeText.includes('[x]'), + }, + }; + case MarkdownShortcuts.BlockQuote: + return { + type: EditorNodeType.QuoteBlock, + data: {}, + }; + case MarkdownShortcuts.Divider: + return { + type: EditorNodeType.DividerBlock, + data: {}, + }; + + case MarkdownShortcuts.ToggleList: + return { + type: EditorNodeType.ToggleListBlock, + data: { + collapsed: false, + }, + }; + + default: + return null; + } +} + +function insertMark(editor: ReactEditor, shortcut: MarkdownShortcuts, text: string) { + switch (shortcut) { + case MarkdownShortcuts.Bold: + case MarkdownShortcuts.Italic: + case MarkdownShortcuts.StrikeThrough: + case MarkdownShortcuts.Code: { + const textNode = { + text, + }; + const attributes = { + [MarkdownShortcuts.Bold]: { + [EditorMarkFormat.Bold]: true, + }, + [MarkdownShortcuts.Italic]: { + [EditorMarkFormat.Italic]: true, + }, + [MarkdownShortcuts.StrikeThrough]: { + [EditorMarkFormat.StrikeThrough]: true, + }, + [MarkdownShortcuts.Code]: { + [EditorMarkFormat.Code]: true, + }, + }; + + Object.assign(textNode, attributes[shortcut]); + + editor.insertNodes(textNode); + return; + } + + case MarkdownShortcuts.Equation: { + CustomEditor.insertFormula(editor, text); + return; + } + + default: + return null; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdownShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdownShortcuts.ts deleted file mode 100644 index f49f4d9dda1bf..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdownShortcuts.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { ReactEditor } from 'slate-react'; -import { Editor, Range, Element as SlateElement, Transforms } from 'slate'; -import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; -import { CustomEditor } from '$app/components/editor/command'; - -/** - * Markdown shortcuts - * @description - * - bold: **bold** or __bold__ - * - italic: *italic* or _italic_ - * - strikethrough: ~~strikethrough~~ or ~strikethrough~ - * - code: `code` - * - heading: # or ## or ### - * - bulleted list: * or - or + - * - number list: 1. or 2. or 3. - * - toggle list: > - * - quote: ” or “ or " - * - todo list: -[ ] or -[x] or -[] or [] or [x] or [ ] - * - code block: ``` - * - callout: [!TIP] or [!INFO] or [!WARNING] or [!DANGER] - * - divider: ---or*** - * - equation: $$formula$$ - */ - -const regexMap: Record< - string, - { - pattern: RegExp; - data?: Record; - }[] -> = { - [EditorNodeType.BulletedListBlock]: [ - { - pattern: /^([*\-+])$/, - }, - ], - [EditorNodeType.ToggleListBlock]: [ - { - pattern: /^>$/, - data: { - collapsed: false, - }, - }, - ], - [EditorNodeType.QuoteBlock]: [ - { - pattern: /^”$/, - }, - { - pattern: /^“$/, - }, - { - pattern: /^"$/, - }, - ], - [EditorNodeType.TodoListBlock]: [ - { - pattern: /^(-)?\[ ]$/, - data: { - checked: false, - }, - }, - { - pattern: /^(-)?\[x]$/, - data: { - checked: true, - }, - }, - { - pattern: /^(-)?\[]$/, - data: { - checked: false, - }, - }, - ], - [EditorNodeType.NumberedListBlock]: [ - { - pattern: /^(\d+)\.$/, - }, - ], - [EditorNodeType.HeadingBlock]: [ - { - pattern: /^#$/, - data: { - level: 1, - }, - }, - { - pattern: /^#{2}$/, - data: { - level: 2, - }, - }, - { - pattern: /^#{3}$/, - data: { - level: 3, - }, - }, - ], - [EditorNodeType.CodeBlock]: [ - { - pattern: /^(`{3,})$/, - data: { - language: 'json', - }, - }, - ], - [EditorNodeType.CalloutBlock]: [ - { - pattern: /^\[!TIP]$/, - data: { - icon: '💡', - }, - }, - { - pattern: /^\[!INFO]$/, - data: { - icon: 'ℹ️', - }, - }, - { - pattern: /^\[!WARNING]$/, - data: { - icon: '⚠️', - }, - }, - { - pattern: /^\[!DANGER]$/, - data: { - icon: '🚨', - }, - }, - ], - [EditorNodeType.DividerBlock]: [ - { - pattern: /^(([-*]){3,})$/, - }, - ], - [EditorNodeType.EquationBlock]: [ - { - pattern: /^\$\$(.*)\$\$$/, - data: { - formula: '', - }, - }, - ], -}; - -const blockCommands = [' ', '-', '`', '$', '*']; - -const CharToMarkTypeMap: Record = { - '**': EditorMarkFormat.Bold, - __: EditorMarkFormat.Bold, - '*': EditorMarkFormat.Italic, - _: EditorMarkFormat.Italic, - '~': EditorMarkFormat.StrikeThrough, - '~~': EditorMarkFormat.StrikeThrough, - '`': EditorMarkFormat.Code, -}; - -const inlineBlockCommands = ['*', '_', '~', '`']; -const doubleCharCommands = ['*', '_', '~']; - -const matchBlockShortcutType = (beforeText: string, endChar: string) => { - // end with divider char: - - if (endChar === '-' || endChar === '*') { - const dividerRegex = regexMap[EditorNodeType.DividerBlock][0]; - - return dividerRegex.pattern.test(beforeText + endChar) - ? { - type: EditorNodeType.DividerBlock, - data: {}, - } - : null; - } - - // end with code block char: ` - if (endChar === '`') { - const codeBlockRegex = regexMap[EditorNodeType.CodeBlock][0]; - - return codeBlockRegex.pattern.test(beforeText + endChar) - ? { - type: EditorNodeType.CodeBlock, - data: codeBlockRegex.data, - } - : null; - } - - if (endChar === '$') { - const equationBlockRegex = regexMap[EditorNodeType.EquationBlock][0]; - - const match = equationBlockRegex.pattern.exec(beforeText + endChar); - - const formula = match?.[1]; - - return equationBlockRegex.pattern.test(beforeText + endChar) - ? { - type: EditorNodeType.EquationBlock, - data: { - formula, - }, - } - : null; - } - - for (const [type, regexes] of Object.entries(regexMap)) { - for (const regex of regexes) { - if (regex.pattern.test(beforeText)) { - return { - type, - data: regex.data, - }; - } - } - } - - return null; -}; - -export const withMarkdownShortcuts = (editor: ReactEditor) => { - const { insertText } = editor; - - editor.insertText = (text) => { - if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) { - insertText(text); - return; - } - - const { selection } = editor; - - if (!selection || !Range.isCollapsed(selection)) { - insertText(text); - return; - } - - // block shortcuts - if (blockCommands.some((char) => text.endsWith(char))) { - const endChar = text.slice(-1); - const [match] = Editor.nodes(editor, { - match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorNodeType.Text, - }); - - if (!match) { - insertText(text); - return; - } - - const [, path] = match; - - const { anchor } = selection; - const start = Editor.start(editor, path); - const range = { anchor, focus: start }; - const beforeText = Editor.string(editor, range) + text.slice(0, -1); - - if (beforeText === undefined) { - insertText(text); - return; - } - - const matchItem = matchBlockShortcutType(beforeText, endChar); - - if (matchItem) { - const { type, data } = matchItem; - - Transforms.select(editor, range); - - if (!Range.isCollapsed(range)) { - Transforms.delete(editor); - } - - const newProperties: Partial = { - type, - data, - }; - - CustomEditor.turnToBlock(editor, newProperties); - - return; - } - } - - // inline shortcuts - // end with inline mark char: * or _ or ~ or ` - // eg: **bold** or *italic* or ~strikethrough~ or `code` or _italic_ or __bold__ or ~~strikethrough~~ - const keyword = inlineBlockCommands.find((char) => text.endsWith(char)); - - if (keyword !== undefined) { - const { focus } = selection; - const start = { - path: focus.path, - offset: 0, - }; - const range = { anchor: start, focus }; - - const rangeText = Editor.string(editor, range); - - if (!rangeText.includes(keyword)) { - insertText(text); - return; - } - - const fullText = rangeText + keyword; - - let matchChar = keyword; - - if (doubleCharCommands.includes(keyword)) { - const doubleKeyword = `${keyword}${keyword}`; - - if (rangeText.includes(doubleKeyword)) { - const match = fullText.match(new RegExp(`\\${keyword}{2}(.*)\\${keyword}{2}`)); - - if (!match) { - insertText(text); - return; - } - - matchChar = doubleKeyword; - } - } - - const markType = CharToMarkTypeMap[matchChar]; - - const startIndex = rangeText.lastIndexOf(matchChar); - const beforeText = rangeText.slice(startIndex + matchChar.length, matchChar.length > 1 ? -1 : undefined); - - if (!beforeText) { - insertText(text); - return; - } - - const anchor = { path: start.path, offset: start.offset + startIndex }; - - const at = { - anchor, - focus, - }; - - editor.select(at); - editor.addMark(markType, true); - editor.insertText(beforeText); - editor.collapse({ - edge: 'end', - }); - return; - } - - insertText(text); - }; - - return editor; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withShortcuts.ts deleted file mode 100644 index 42b37f2a0f700..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withShortcuts.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactEditor } from 'slate-react'; -import { withMarkdownShortcuts } from '$app/components/editor/plugins/shortcuts/withMarkdownShortcuts'; - -export function withShortcuts(editor: ReactEditor) { - return withMarkdownShortcuts(editor); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts index 1421a3c93b5a8..62e3ad945a61a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts @@ -10,6 +10,12 @@ export function getHeadingCssProperty(level: number) { return 'text-2xl pt-[8px] pb-[6px] font-bold'; case 3: return 'text-xl pt-[4px] font-bold'; + case 4: + return 'text-lg pt-[4px] font-bold'; + case 5: + return 'text-base pt-[4px] font-bold'; + case 6: + return 'text-sm pt-[4px] font-bold'; default: return ''; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts index 02ca4dc43b831..0bcd0965a9816 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts @@ -71,8 +71,13 @@ export function withBlockDelete(editor: ReactEditor) { }); } - // if the current node is not a paragraph, convert it to a paragraph - if (node.type !== EditorNodeType.Paragraph && node.type !== EditorNodeType.Page) { + // if the current node is not a paragraph, convert it to a paragraph(except code block and callout block) + if ( + ![EditorNodeType.Paragraph, EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock].includes( + node.type as EditorNodeType + ) && + node.type !== EditorNodeType.Page + ) { CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); return; } @@ -94,6 +99,34 @@ export function withBlockDelete(editor: ReactEditor) { }); } + // if previous node is an embed, merge the current node to another node which is not an embed + if (Element.isElement(previousNode) && editor.isEmbed(previousNode)) { + const previousTextMatch = editor.previous({ + at: path, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId !== undefined, + }); + + if (!previousTextMatch) { + deleteBackward(unit); + return; + } + + const previousTextPath = previousTextMatch[1]; + const textNode = node.children[0] as Element; + + const at = Editor.end(editor, previousTextPath); + + editor.select(at); + editor.insertNodes(textNode.children, { + at, + }); + + editor.removeNodes({ + at: path, + }); + return; + } + deleteBackward(unit); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts index cbb1816db26f6..b6f8da0e564c9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts @@ -1,8 +1,9 @@ import { ReactEditor } from 'slate-react'; import { EditorNodeType } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; -import { Path } from 'slate'; +import { Path, Transforms } from 'slate'; import { YjsEditor } from '@slate-yjs/core'; +import { generateId } from '$app/components/editor/provider/utils/convert'; export function withBlockInsertBreak(editor: ReactEditor) { const { insertBreak } = editor; @@ -16,9 +17,9 @@ export function withBlockInsertBreak(editor: ReactEditor) { const isEmbed = editor.isEmbed(node); - if (isEmbed) { - const nextPath = Path.next(path); + const nextPath = Path.next(path); + if (isEmbed) { CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath); editor.select(nextPath); return; @@ -26,11 +27,63 @@ export function withBlockInsertBreak(editor: ReactEditor) { const type = node.type as EditorNodeType; + const isBeginning = CustomEditor.focusAtStartOfBlock(editor); + const isEmpty = CustomEditor.isEmptyText(editor, node); - // if the node is empty, convert it to a paragraph - if (isEmpty && type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) { - CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + if (isEmpty) { + const depth = path.length; + let hasNextNode = false; + + try { + hasNextNode = Boolean(editor.node(nextPath)); + } catch (e) { + // do nothing + } + + // if the node is empty and the depth is greater than 1, tab backward + if (depth > 1 && !hasNextNode) { + CustomEditor.tabBackward(editor); + return; + } + + // if the node is empty, convert it to a paragraph + if (type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) { + CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + return; + } + } else if (isBeginning) { + // insert line below the current block + const newNodeType = [ + EditorNodeType.TodoListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.NumberedListBlock, + ].includes(type) + ? type + : EditorNodeType.Paragraph; + + Transforms.insertNodes( + editor, + { + type: newNodeType, + data: node.data ?? {}, + blockId: generateId(), + children: [ + { + type: EditorNodeType.Text, + textId: generateId(), + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + at: path, + } + ); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts index ee7489b8bf24f..1e9fc7f1052e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts @@ -3,7 +3,7 @@ import { ReactEditor } from 'slate-react'; import { withBlockDelete } from '$app/components/editor/plugins/withBlockDelete'; import { withBlockInsertBreak } from '$app/components/editor/plugins/withBlockInsertBreak'; import { withSplitNodes } from '$app/components/editor/plugins/withSplitNodes'; -import { withPasted } from '$app/components/editor/plugins/withPasted'; +import { withPasted, withCopy } from '$app/components/editor/plugins/copyPasted'; import { withBlockMove } from '$app/components/editor/plugins/withBlockMove'; import { CustomEditor } from '$app/components/editor/command'; @@ -26,5 +26,5 @@ export function withBlockPlugins(editor: ReactEditor) { return !CustomEditor.isEmbedNode(element) && isEmpty(element); }; - return withBlockMove(withSplitNodes(withBlockInsertBreak(withBlockDelete(withPasted(editor))))); + return withPasted(withBlockMove(withSplitNodes(withBlockInsertBreak(withBlockDelete(withCopy(editor)))))); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts deleted file mode 100644 index 1973cdb5a491e..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { ReactEditor } from 'slate-react'; -import { convertBlockToJson } from '$app/application/document/document.service'; -import { Editor, Element, NodeEntry, Path, Node, Text, Location, Range } from 'slate'; -import { EditorNodeType } from '$app/application/document/document.types'; -import { InputType } from '@/services/backend'; -import { CustomEditor } from '$app/components/editor/command'; -import { generateId } from '$app/components/editor/provider/utils/convert'; -import { LIST_TYPES } from '$app/components/editor/command/tab'; -import { Log } from '$app/utils/log'; - -export function withPasted(editor: ReactEditor) { - const { insertData, insertFragment, setFragmentData } = editor; - - editor.setFragmentData = (...args) => { - if (!editor.selection) { - setFragmentData(...args); - return; - } - - // selection is collapsed and the node is an embed, we need to set the data manually - if (Range.isCollapsed(editor.selection)) { - const match = Editor.above(editor, { - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, - }); - const node = match ? (match[0] as Element) : undefined; - - if (node && editor.isEmbed(node)) { - const fragment = editor.getFragment(); - - if (fragment.length > 0) { - const data = args[0]; - const string = JSON.stringify(fragment); - const encoded = window.btoa(encodeURIComponent(string)); - - const dom = ReactEditor.toDOMNode(editor, node); - - data.setData(`application/x-slate-fragment`, encoded); - data.setData(`text/html`, dom.innerHTML); - } - } - } - - setFragmentData(...args); - }; - - editor.insertData = (data) => { - const fragment = data.getData('application/x-slate-fragment'); - - if (fragment) { - insertData(data); - return; - } - - const html = data.getData('text/html'); - const text = data.getData('text/plain'); - - if (!html && !text) { - insertData(data); - return; - } - - void (async () => { - try { - const nodes = await convertBlockToJson(html, InputType.Html); - - const htmlTransNoText = nodes.every((node) => { - return CustomEditor.getNodeTextContent(node).length === 0; - }); - - if (!htmlTransNoText) { - return editor.insertFragment(nodes); - } - } catch (e) { - Log.warn('pasted html error', e); - // ignore - } - - if (text) { - const nodes = await convertBlockToJson(text, InputType.PlainText); - - editor.insertFragment(nodes); - return; - } - })(); - }; - - editor.insertFragment = (fragment, options = {}) => { - Editor.withoutNormalizing(editor, () => { - const { at = getDefaultInsertLocation(editor) } = options; - - if (!fragment.length) { - return; - } - - if (Range.isRange(at) && !Range.isCollapsed(at)) { - editor.delete({ - unit: 'character', - }); - } - - const selection = editor.selection; - - if (!selection) return; - - const [node] = editor.node(selection); - const isText = Text.isText(node); - const parent = Editor.above(editor, { - at: selection, - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, - }); - - if (isText && parent) { - const [parentNode, parentPath] = parent as NodeEntry; - const pastedNodeIsPage = parentNode.type === EditorNodeType.Page; - const clonedFragment = transFragment(editor, fragment); - - const [firstNode, ...otherNodes] = clonedFragment; - const lastNode = getLastNode(otherNodes[otherNodes.length - 1]); - const firstIsEmbed = editor.isEmbed(firstNode); - const insertNodes: Element[] = [...otherNodes]; - const needMoveChildren = parentNode.children.length > 1 && !pastedNodeIsPage; - let moveStartIndex = 0; - - if (firstIsEmbed) { - insertNodes.unshift(firstNode); - } else { - // merge the first fragment node with the current text node - const [textNode, ...children] = firstNode.children as Element[]; - - const textElements = textNode.children; - - const end = Editor.end(editor, [...parentPath, 0]); - - // merge text node - editor.insertNodes(textElements, { - at: end, - select: true, - }); - - if (children.length > 0) { - if (pastedNodeIsPage) { - // lift the children of the first fragment node to current node - insertNodes.unshift(...children); - } else { - const lastChild = getLastNode(children[children.length - 1]); - - const lastIsEmbed = lastChild && editor.isEmbed(lastChild); - - // insert the children of the first fragment node to current node - editor.insertNodes(children, { - at: [...parentPath, 1], - select: !lastIsEmbed, - }); - - moveStartIndex += children.length; - } - } - } - - if (insertNodes.length === 0) return; - - // insert a new paragraph if the last node is an embed - if ((!lastNode && firstIsEmbed) || (lastNode && editor.isEmbed(lastNode))) { - insertNodes.push(generateNewParagraph()); - } - - const pastedPath = Path.next(parentPath); - - // insert the sibling of the current node - editor.insertNodes(insertNodes, { - at: pastedPath, - select: true, - }); - - if (!needMoveChildren) return; - - if (!editor.selection) return; - - // current node is the last node of the pasted fragment - const currentPath = editor.selection.anchor.path; - const current = editor.above({ - at: currentPath, - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, - }); - - if (!current) return; - - const [currentNode, currentNodePath] = current as NodeEntry; - - // split the operation into the next tick to avoid the wrong path - if (LIST_TYPES.includes(currentNode.type as EditorNodeType)) { - const length = currentNode.children.length; - - setTimeout(() => { - // move the children of the current node to the last node of the pasted fragment - for (let i = parentNode.children.length - 1; i > 0; i--) { - editor.moveNodes({ - at: [...parentPath, i + moveStartIndex], - to: [...currentNodePath, length], - }); - } - }, 0); - } else { - // if the current node is not a list, we need to move these children to the next path - setTimeout(() => { - const nextPath = Path.next(currentNodePath); - - for (let i = parentNode.children.length - 1; i > 0; i--) { - editor.moveNodes({ - at: [...parentPath, i + moveStartIndex], - to: nextPath, - }); - } - }, 0); - } - } else { - insertFragment(fragment); - return; - } - }); - }; - - return editor; -} - -export const getDefaultInsertLocation = (editor: Editor): Location => { - if (editor.selection) { - return editor.selection; - } else if (editor.children.length > 0) { - return Editor.end(editor, []); - } else { - return [0]; - } -}; - -export const generateNewParagraph = (): Element => ({ - type: EditorNodeType.Paragraph, - blockId: generateId(), - children: [ - { - type: EditorNodeType.Text, - textId: generateId(), - children: [{ text: '' }], - }, - ], -}); - -function getLastNode(node: Node): Element | undefined { - if (!Element.isElement(node) || node.blockId === undefined) return; - - if (Element.isElement(node) && node.blockId !== undefined && node.children.length > 0) { - const child = getLastNode(node.children[node.children.length - 1]); - - if (!child) { - return node; - } else { - return child; - } - } - - return node; -} - -function transFragment(editor: ReactEditor, fragment: Node[]) { - // flatten the fragment to avoid the empty node(doesn't have text node) in the fragment - const flatMap = (node: Node): Node[] => { - const isInputElement = - !Editor.isEditor(node) && Element.isElement(node) && node.blockId !== undefined && !editor.isEmbed(node); - - if ( - isInputElement && - node.children?.length > 0 && - Element.isElement(node.children[0]) && - node.children[0].type !== EditorNodeType.Text - ) { - return node.children.flatMap((child) => flatMap(child)); - } - - return [node]; - }; - - const fragmentFlatMap = fragment?.flatMap(flatMap); - - // clone the node to avoid the duplicated block id - return fragmentFlatMap.map((item) => CustomEditor.cloneBlock(editor, item as Element)); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts index 55c9b8b8f2c22..eee7dd92d08c9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts @@ -30,8 +30,6 @@ export function withSplitNodes(editor: ReactEditor) { const { splitNodes } = editor; editor.splitNodes = (...args) => { - const selection = editor.selection; - const isInsertBreak = args.length === 1 && JSON.stringify(args[0]) === JSON.stringify({ always: true }); if (!isInsertBreak) { @@ -39,6 +37,8 @@ export function withSplitNodes(editor: ReactEditor) { return; } + const selection = editor.selection; + const isCollapsed = selection && Range.isCollapsed(selection); if (!isCollapsed) { @@ -106,10 +106,14 @@ export function withSplitNodes(editor: ReactEditor) { Transforms.insertNodes(editor, newNode, { at: newNodePath, - select: true, }); + editor.select(newNodePath); + CustomEditor.removeMarks(editor); + editor.collapse({ + edge: 'start', + }); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts index 14c3b408df9ec..026ee5722294b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts @@ -13,6 +13,7 @@ describe('Transform events to actions', () => { let provider: Provider; beforeEach(() => { provider = new Provider(generateId()); + provider.initialDocument(true); provider.connect(); applyActions.mockClear(); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts index f7823e2c9d308..0937d265edc09 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts @@ -11,6 +11,7 @@ describe('Provider connected', () => { beforeEach(() => { provider = new Provider(generateId()); + provider.initialDocument(true); provider.connect(); applyActions.mockClear(); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts index c74f0e993bb86..727b33ec69d19 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts @@ -13,10 +13,9 @@ export class Provider extends EventEmitter { dataClient: DataClient; // get origin data after document updated backupDoc: Y.Doc = new Y.Doc(); - constructor(public id: string, includeRoot?: boolean) { + constructor(public id: string) { super(); this.dataClient = new DataClient(id); - void this.initialDocument(includeRoot); this.document.on('update', this.documentUpdate); } @@ -28,8 +27,11 @@ export class Provider extends EventEmitter { sharedType.applyDelta(delta); const rootId = this.dataClient.rootId as string; + const root = delta[0].insert as Y.XmlText; + const data = root.getAttribute('data'); sharedType.setAttribute('blockId', rootId); + sharedType.setAttribute('data', data); this.sharedType = sharedType; this.sharedType?.observeDeep(this.onChange); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts index 1cd6e7c57ab7a..447a8f95f9139 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts @@ -4,7 +4,7 @@ import { generateId } from '$app/components/editor/provider/utils/convert'; import { YDelta2Delta } from '$app/components/editor/provider/utils/delta'; import { YDelta } from '$app/components/editor/provider/types/y_event'; import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation'; -import { EditorNodeType } from '$app/application/document/document.types'; +import { EditorInlineNodeType, EditorNodeType } from '$app/application/document/document.types'; import { Log } from '$app/utils/log'; export function YEvents2BlockActions( @@ -36,6 +36,30 @@ export function YEvent2BlockActions( const backupTarget = getYTarget(backupDoc, path) as Readonly; const actions = []; + if ([EditorInlineNodeType.Formula, EditorInlineNodeType.Mention].includes(yXmlText.getAttribute('type'))) { + const parentYXmlText = yXmlText.parent as Y.XmlText; + const parentDelta = parentYXmlText.toDelta() as YDelta; + const index = parentDelta.findIndex((op) => op.insert === yXmlText); + const ops = YDelta2Delta(parentDelta); + + const retainIndex = ops.reduce((acc, op, currentIndex) => { + if (currentIndex < index) { + return acc + (op.insert as string).length ?? 0; + } + + return acc; + }, 0); + + const newDelta = [ + { + retain: retainIndex, + }, + ...delta, + ]; + + actions.push(...generateApplyTextActions(parentYXmlText, newDelta)); + } + if (yXmlText.getAttribute('type') === 'text') { actions.push(...textOps2BlockActions(rootId, yXmlText, delta)); } @@ -137,6 +161,8 @@ function blockOps2BlockActions( ids: [deletedId], }) ); + } else { + Log.error('blockOps2BlockActions', 'deletedId is not exist'); } } } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts index d63e09c796b9b..b4da4b3ca7946 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts @@ -7,76 +7,90 @@ export function generateId() { return nanoid(10); } -export function transformToInlineElement(op: Op): Element | null { +export function transformToInlineElement(op: Op): Element[] { const attributes = op.attributes; - if (!attributes) return null; - const formula = attributes.formula as string; + if (!attributes) return []; + const { formula, mention, ...attrs } = attributes; if (formula) { - return { - type: EditorInlineNodeType.Formula, - data: formula, - children: [ - { - text: op.insert as string, - }, - ], - }; + const texts = (op.insert as string).split(''); + + return texts.map((text) => { + return { + type: EditorInlineNodeType.Formula, + data: formula, + children: [ + { + text, + ...attrs, + }, + ], + }; + }); } - const matchMention = attributes.mention as Mention; - - if (matchMention) { - return { - type: EditorInlineNodeType.Mention, - children: [ - { - text: op.insert as string, + if (mention) { + const texts = (op.insert as string).split(''); + + return texts.map((text) => { + return { + type: EditorInlineNodeType.Mention, + children: [ + { + text, + ...attrs, + }, + ], + data: { + ...(mention as Mention), }, - ], - data: { - ...matchMention, - }, - }; + }; + }); } - return null; + return []; } export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] { - return delta && delta.length > 0 - ? delta.map((op) => { - const matchInline = transformToInlineElement(op); + const newDelta: (Text | Element)[] = []; - if (matchInline) { - return matchInline; - } + if (!delta || !delta.length) + return [ + { + text: '', + }, + ]; - if (op.attributes) { - if ('font_color' in op.attributes && op.attributes['font_color'] === '') { - delete op.attributes['font_color']; - } + delta.forEach((op) => { + const matchInlines = transformToInlineElement(op); - if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') { - delete op.attributes['bg_color']; - } + if (matchInlines.length > 0) { + newDelta.push(...matchInlines); + return; + } - if ('code' in op.attributes && !op.attributes['code']) { - delete op.attributes['code']; - } - } - - return { - text: op.insert as string, - ...op.attributes, - }; - }) - : [ - { - text: '', - }, - ]; + if (op.attributes) { + if ('font_color' in op.attributes && op.attributes['font_color'] === '') { + delete op.attributes['font_color']; + } + + if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') { + delete op.attributes['bg_color']; + } + + if ('code' in op.attributes && !op.attributes['code']) { + delete op.attributes['code']; + } + } + + newDelta.push({ + text: op.insert as string, + ...op.attributes, + }); + }); + + return newDelta; } export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts new file mode 100644 index 0000000000000..00992964fb10d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts @@ -0,0 +1,70 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { proxy, useSnapshot } from 'valtio'; +import { EditorNodeType } from '$app/application/document/document.types'; + +export interface EditorBlockState { + [EditorNodeType.ImageBlock]: { + popoverOpen: boolean; + blockId?: string; + }; + [EditorNodeType.EquationBlock]: { + popoverOpen: boolean; + blockId?: string; + }; +} + +const initialState = { + [EditorNodeType.ImageBlock]: { + popoverOpen: false, + blockId: undefined, + }, + [EditorNodeType.EquationBlock]: { + popoverOpen: false, + blockId: undefined, + }, +}; + +export const EditorBlockStateContext = createContext(initialState); + +export const EditorBlockStateProvider = EditorBlockStateContext.Provider; + +export function useEditorInitialBlockState() { + const state = useMemo(() => { + return proxy({ + ...initialState, + }); + }, []); + + return state; +} + +export function useEditorBlockState(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) { + const context = useContext(EditorBlockStateContext); + + return useSnapshot(context[key]); +} + +export function useEditorBlockDispatch() { + const context = useContext(EditorBlockStateContext); + + const openPopover = useCallback( + (key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock, blockId: string) => { + context[key].popoverOpen = true; + context[key].blockId = blockId; + }, + [context] + ); + + const closePopover = useCallback( + (key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) => { + context[key].popoverOpen = false; + context[key].blockId = undefined; + }, + [context] + ); + + return { + openPopover, + closePopover, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts index e93794da88920..22f0bb81be2df 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts @@ -3,6 +3,7 @@ import { useInitialDecorateState } from '$app/components/editor/stores/decorate' import { useInitialSelectedBlocks } from '$app/components/editor/stores/selected'; import { useInitialSlashState } from '$app/components/editor/stores/slash'; import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node'; +import { useEditorInitialBlockState } from '$app/components/editor/stores/block'; export * from './decorate'; export * from './selected'; @@ -14,6 +15,7 @@ export function useInitialEditorState(editor: ReactEditor) { const selectedBlocks = useInitialSelectedBlocks(editor); const slashState = useInitialSlashState(); const inlineBlockState = useInitialEditorInlineBlockState(); + const blockState = useEditorInitialBlockState(); return { selectedBlocks, @@ -21,5 +23,6 @@ export function useInitialEditorState(editor: ReactEditor) { decorateState, slashState, inlineBlockState, + blockState, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts index a6d536522e0aa..6607a546d88de 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; -import { BaseRange } from 'slate'; +import { BaseRange, Path } from 'slate'; import { proxy, useSnapshot } from 'valtio'; export interface EditorInlineBlockState { @@ -43,8 +43,10 @@ export function useEditorInlineBlockState(key: 'formula') { }, [context, key]); const setRange = useCallback( - (range: BaseRange) => { - context[key].range = range; + (at: BaseRange | Path) => { + const range = Path.isPath(at) ? { anchor: at, focus: at } : at; + + context[key].range = range as BaseRange; }, [context, key] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts index e3a28ff5fd034..803f474723545 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts @@ -1,20 +1,8 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useEffect, useMemo, useState } from 'react'; import { proxySet, subscribeKey } from 'valtio/utils'; import { ReactEditor } from 'slate-react'; import { Element } from 'slate'; -export function useSelectedBlocksSize() { - const selectedBlocks = useContext(EditorSelectedBlockContext); - - const [selectedLength, setSelectedLength] = useState(0); - - useEffect(() => { - subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v)); - }, [selectedBlocks]); - - return selectedLength; -} - export function useInitialSelectedBlocks(editor: ReactEditor) { const selectedBlocks = useMemo(() => proxySet([]), []); const [selectedLength, setSelectedLength] = useState(0); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx index 30b9822fa4e0b..6da2ee96d01df 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx @@ -1,24 +1,22 @@ -import { InformationSvg } from '../_shared/svg/InformationSvg'; -import { CloseSvg } from '../_shared/svg/CloseSvg'; +import { ReactComponent as InformationSvg } from '$app/assets/information.svg'; +import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => { return (
-
- +
+

Oops.. something went wrong

{message}

diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts new file mode 100644 index 0000000000000..807c1e68111d3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions, ThemeMode } from '$app_reducers/current-user/slice'; +import { UserService } from '$app/application/user/user.service'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; + +export function useShortcuts() { + const dispatch = useAppDispatch(); + const userSettingState = useAppSelector((state) => state.currentUser.userSetting); + const { isDark } = userSettingState; + + const switchThemeMode = useCallback(() => { + const newSetting = { + themeMode: isDark ? ThemeMode.Light : ThemeMode.Dark, + isDark: !isDark, + }; + + dispatch(currentUserActions.setUserSetting(newSetting)); + void UserService.setAppearanceSetting({ + theme_mode: newSetting.themeMode, + }); + }, [dispatch, isDark]); + + const toggleSidebar = useCallback(() => { + dispatch(sidebarActions.toggleCollapse()); + }, [dispatch]); + + return useCallback( + (e: KeyboardEvent) => { + switch (true) { + /** + * Toggle theme: Mod+L + * Switch between light and dark theme + */ + case createHotkey(HOT_KEY_NAME.TOGGLE_THEME)(e): + switchThemeMode(); + break; + /** + * Toggle sidebar: Mod+. (period) + * Prevent the default behavior of the browser (Exit full screen) + * Collapse or expand the sidebar + */ + case createHotkey(HOT_KEY_NAME.TOGGLE_SIDEBAR)(e): + e.preventDefault(); + toggleSidebar(); + break; + default: + break; + } + }, + [toggleSidebar, switchThemeMode] + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx index c5bbb17424904..509aa388cf6e5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx @@ -1,43 +1,62 @@ -import React, { ReactNode, useEffect } from 'react'; +import React, { ReactNode, useEffect, useMemo } from 'react'; import SideBar from '$app/components/layout/side_bar/SideBar'; import TopBar from '$app/components/layout/top_bar/TopBar'; import { useAppSelector } from '$app/stores/store'; import './layout.scss'; +import { AFScroller } from '../_shared/scroller'; +import { useNavigate } from 'react-router-dom'; +import { pageTypeMap } from '$app_reducers/pages/slice'; +import { useShortcuts } from '$app/components/layout/Layout.hooks'; function Layout({ children }: { children: ReactNode }) { const { isCollapsed, width } = useAppSelector((state) => state.sidebar); + const currentUser = useAppSelector((state) => state.currentUser); + const navigate = useNavigate(); + const { id: latestOpenViewId, layout } = useMemo( + () => + currentUser?.workspaceSetting?.latestView || { + id: undefined, + layout: undefined, + }, + [currentUser?.workspaceSetting?.latestView] + ); - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Backspace' && e.target instanceof HTMLBodyElement) { - e.preventDefault(); - } - }; + const onKeyDown = useShortcuts(); + useEffect(() => { window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('keydown', onKeyDown); }; - }, []); + }, [onKeyDown]); + + useEffect(() => { + if (latestOpenViewId) { + const pageType = pageTypeMap[layout]; + + navigate(`/page/${pageType}/${latestOpenViewId}`); + } + }, [latestOpenViewId, navigate, layout]); return ( <> -
+
-
{children} -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx index 986ba9337d191..ec9e990cdba40 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx @@ -1,31 +1,29 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { useLoadExpandedPages } from '$app/components/layout/bread_crumb/Breadcrumb.hooks'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import { Page, pageTypeMap } from '$app_reducers/pages/slice'; -import { useNavigate } from 'react-router-dom'; +import { Page } from '$app_reducers/pages/slice'; import { useTranslation } from 'react-i18next'; import { getPageIcon } from '$app/hooks/page.hooks'; +import { useAppDispatch } from '$app/stores/store'; +import { openPage } from '$app_reducers/pages/async_actions'; function Breadcrumb() { const { t } = useTranslation(); const { isTrash, pagePath, currentPage } = useLoadExpandedPages(); - const navigate = useNavigate(); + const dispatch = useAppDispatch(); - const parentPages = useMemo(() => pagePath.slice(1, -1).filter(Boolean) as Page[], [pagePath]); const navigateToPage = useCallback( (page: Page) => { - const pageType = pageTypeMap[page.layout]; - - navigate(`/page/${pageType}/${page.id}`); + void dispatch(openPage(page.id)); }, - [navigate] + [dispatch] ); if (!currentPage) { if (isTrash) { - return {t('trash.text')}; + return {t('trash.text')}; } return null; @@ -33,25 +31,32 @@ function Breadcrumb() { return ( - {parentPages?.map((page: Page) => ( - { - navigateToPage(page); - }} - > -
{getPageIcon(page)}
- - {page.name || t('document.title.placeholder')} - - ))} - -
{getPageIcon(currentPage)}
- {currentPage?.name || t('menuAppHeader.defaultNewPageName')} -
+ {pagePath?.map((page: Page, index) => { + if (index === pagePath.length - 1) { + return ( +
+
{getPageIcon(page)}
+ {page.name.trim() || t('menuAppHeader.defaultNewPageName')} +
+ ); + } + + return ( + { + navigateToPage(page); + }} + > +
{getPageIcon(page)}
+ + {page.name.trim() || t('menuAppHeader.defaultNewPageName')} + + ); + })}
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts index 5d65e6ef08c3b..f2bec915d9a9f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts @@ -1,72 +1,34 @@ import { useAppSelector } from '$app/stores/store'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { Page } from '$app_reducers/pages/slice'; -import { getPage } from '$app/application/folder/page.service'; export function useLoadExpandedPages() { const params = useParams(); const location = useLocation(); const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]); const currentPageId = params.id; - const pageMap = useAppSelector((state) => state.pages.pageMap); - const currentPage = currentPageId ? pageMap[currentPageId] : null; + const currentPage = useAppSelector((state) => (currentPageId ? state.pages.pageMap[currentPageId] : undefined)); - const [pagePath, setPagePath] = useState< - ( - | Page - | { - name: string; - } - )[] - >([]); + const pagePath = useAppSelector((state) => { + const result: Page[] = []; - const loadPagePath = useCallback( - async (pageId: string) => { - let page = pageMap[pageId]; + if (!currentPage) return result; - if (!page) { - try { - page = await getPage(pageId); - } catch (e) { - // do nothing - } + const findParent = (page: Page) => { + if (!page.parentId) return; + const parent = state.pages.pageMap[page.parentId]; - if (!page) { - return; - } + if (parent) { + result.unshift(parent); + findParent(parent); } + }; - setPagePath((prev) => { - return [page, ...prev]; - }); - - if (page.parentId) { - await loadPagePath(page.parentId); - } - }, - [pageMap] - ); - - useEffect(() => { - setPagePath([]); - if (!currentPageId) { - return; - } - - void loadPagePath(currentPageId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPageId]); - - useEffect(() => { - setPagePath((prev) => { - return prev.map((page, index) => { - if (!page) return page; - if (index === 0) return page; - return 'id' in page && page.id ? pageMap[page.id] : page; - }); - }); - }, [pageMap]); + findParent(currentPage); + result.push(currentPage); + return result; + }); return { pagePath, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx index e1b6f9e414b53..87662a99bbc4d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx @@ -1,12 +1,11 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { IconButton, Tooltip } from '@mui/material'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { sidebarActions } from '$app_reducers/sidebar/slice'; import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg'; import { useTranslation } from 'react-i18next'; -import { getModifier } from '$app/utils/get_modifier'; -import isHotkey from 'is-hotkey'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; function CollapseMenuButton() { const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); @@ -21,29 +20,15 @@ function CollapseMenuButton() { return (
{isCollapsed ? t('sideBar.openSidebar') : t('sideBar.closeSidebar')}
-
{`${getModifier()} + \\`}
+
{createHotKeyLabel(HOT_KEY_NAME.TOGGLE_SIDEBAR)}
); }, [isCollapsed, t]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (isHotkey('mod+\\', e)) { - e.preventDefault(); - handleClick(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [handleClick]); - return ( - - {isCollapsed ? : } + + ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss b/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss index a708777326293..43f4f5589273c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss @@ -1,12 +1,81 @@ + + +.sketch-picker { + background-color: var(--bg-body) !important; + border-color: transparent !important; + box-shadow: none !important; +} +.sketch-picker .flexbox-fix { + border-color: var(--line-divider) !important; +} +.sketch-picker [id^='rc-editable-input'] { + background-color: var(--bg-body) !important; + border-color: var(--line-divider) !important; + color: var(--text-title) !important; + box-shadow: var(--line-border) 0px 0px 0px 1px inset !important; +} + +.appflowy-date-picker-calendar { + width: 100%; + +} + +.grid-sticky-header::-webkit-scrollbar { + width: 0; + height: 0; +} +.grid-scroll-container::-webkit-scrollbar { + width: 0; + height: 0; +} + + +.appflowy-scroll-container { + &::-webkit-scrollbar { + width: 0; + } +} + +.appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical { + background-color: var(--scrollbar-thumb); + border-radius: 4px; + opacity: 60%; +} + .workspaces { ::-webkit-scrollbar { width: 0px; } } + + .MuiPopover-root, .MuiPaper-root { ::-webkit-scrollbar { width: 0; height: 0; } -} \ No newline at end of file +} + +.view-icon { + &:hover { + background-color: rgba(156, 156, 156, 0.20); + } +} + +.theme-mode-item { + @apply relative flex h-[72px] w-[88px] cursor-pointer items-end justify-end rounded border hover:shadow; + background: linear-gradient(150.74deg, rgba(231, 231, 231, 0) 17.95%, #C5C5C5 95.51%); +} + +[data-dark-mode="true"] { + .theme-mode-item { + background: linear-gradient(150.74deg, rgba(128, 125, 125, 0) 17.95%, #4d4d4d 95.51%); + } +} + +.document-header { + .view-banner { + @apply items-center; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx index e0a36a5903846..94a86655ac17a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx @@ -10,7 +10,7 @@ import RenameDialog from '../../_shared/confirm_dialog/RenameDialog'; import { Page } from '$app_reducers/pages/slice'; import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog'; import OperationMenu from '$app/components/layout/nested_page/OperationMenu'; -import { getModifier } from '$app/utils/get_modifier'; +import { getModifier } from '$app/utils/hotkeys'; import isHotkey from 'is-hotkey'; function MoreButton({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts index f2c2164f8cc08..d43499e801e34 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect } from 'react'; -import { pagesActions, pageTypeMap, parserViewPBToPage } from '$app_reducers/pages/slice'; +import { pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { FolderNotification, ViewLayoutPB } from '@/services/backend'; -import { useNavigate, useParams } from 'react-router-dom'; -import { updatePageName } from '$app_reducers/pages/async_actions'; +import { useParams } from 'react-router-dom'; +import { openPage, updatePageName } from '$app_reducers/pages/async_actions'; import { createPage, deletePage, duplicatePage, getChildPages } from '$app/application/folder/page.service'; import { subscribeNotifications } from '$app/application/notification'; @@ -82,14 +82,10 @@ export function usePageActions(pageId: string) { const dispatch = useAppDispatch(); const params = useParams(); const currentPageId = params.id; - const navigate = useNavigate(); const onPageClick = useCallback(() => { - if (!page) return; - const pageType = pageTypeMap[page.layout]; - - navigate(`/page/${pageType}/${pageId}`); - }, [navigate, page, pageId]); + void dispatch(openPage(pageId)); + }, [dispatch, pageId]); const onAddPage = useCallback( async (layout: ViewLayoutPB) => { @@ -112,21 +108,19 @@ export function usePageActions(pageId: string) { ); dispatch(pagesActions.expandPage(pageId)); - const pageType = pageTypeMap[layout]; - - navigate(`/page/${pageType}/${newViewId}`); + await dispatch(openPage(newViewId)); }, - [dispatch, navigate, pageId] + [dispatch, pageId] ); const onDeletePage = useCallback(async () => { if (currentPageId === pageId) { - navigate(`/`); + dispatch(pagesActions.setTrashSnackbar(true)); } await deletePage(pageId); dispatch(pagesActions.deletePages([pageId])); - }, [dispatch, currentPageId, navigate, pageId]); + }, [dispatch, pageId, currentPageId]); const onDuplicatePage = useCallback(async () => { await duplicatePage(page); @@ -149,7 +143,5 @@ export function usePageActions(pageId: string) { } export function useSelectedPage(pageId: string) { - const id = useParams().id; - - return id === pageId; + return useParams().id === pageId; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx index cf03327302318..e423f05517645 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx @@ -12,7 +12,16 @@ function NestedPage({ pageId }: { pageId: string }) { const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId); const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId); const dispatch = useAppDispatch(); - const page = useAppSelector((state) => state.pages.pageMap[pageId]); + const { page, parentLayout } = useAppSelector((state) => { + const page = state.pages.pageMap[pageId]; + const parent = state.pages.pageMap[page?.parentId || '']; + + return { + page, + parentLayout: parent?.layout, + }; + }); + const disableChildren = useAppSelector((state) => { if (!page) return true; const layout = state.pages.pageMap[page.parentId]?.layout; @@ -65,6 +74,9 @@ function NestedPage({ pageId }: { pageId: string }) { } }, [dropPosition, isDragging, isDraggingOver, page?.layout]); + // Only allow dragging if the parent layout is undefined or a document + const draggable = parentLayout === undefined || parentLayout === ViewLayoutPB.Document; + return (
- {page?.name || t('menuAppHeader.defaultNewPageName')} + {page?.name.trim() || t('menuAppHeader.defaultNewPageName')}
e.stopPropagation()} className={'min:w-14 flex items-center justify-end px-2'}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx index 5e284a94bef28..639d5283e0dfb 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx @@ -43,7 +43,7 @@ function Resizer() {
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx index cee598b66d1f1..5cdbfb125be83 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx @@ -1,28 +1,60 @@ -import React from 'react'; -import { useAppSelector } from '$app/stores/store'; -import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark'; -import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight'; +import React, { useEffect, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { ReactComponent as AppflowyLogoDark } from '$app/assets/dark-logo.svg'; +import { ReactComponent as AppflowyLogoLight } from '$app/assets/light-logo.svg'; import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; import Resizer from '$app/components/layout/side_bar/Resizer'; import UserInfo from '$app/components/layout/side_bar/UserInfo'; import WorkspaceManager from '$app/components/layout/workspace_manager/WorkspaceManager'; +import { ThemeMode } from '$app_reducers/current-user/slice'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; function SideBar() { const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar); - const isDark = useAppSelector((state) => state.currentUser?.userSetting?.isDark); + const dispatch = useAppDispatch(); + const themeMode = useAppSelector((state) => state.currentUser?.userSetting?.themeMode); + const isDark = + themeMode === ThemeMode.Dark || + (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); + + const lastCollapsedRef = useRef(isCollapsed); + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth; + + if (width <= 800 && !isCollapsed) { + lastCollapsedRef.current = false; + dispatch(sidebarActions.setCollapse(true)); + } else if (width > 800 && !lastCollapsedRef.current) { + lastCollapsedRef.current = true; + dispatch(sidebarActions.setCollapse(false)); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [dispatch, isCollapsed]); return ( <>
- {isDark ? : } + {isDark ? ( + + ) : ( + + )}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx index b02620e88c43b..62763c670e9ee 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { useAppSelector } from '$app/stores/store'; -import { Avatar, IconButton } from '@mui/material'; -import PersonOutline from '@mui/icons-material/PersonOutline'; -import UserSetting from '../user_setting/UserSetting'; +import { IconButton } from '@mui/material'; import { ReactComponent as SettingIcon } from '$app/assets/settings.svg'; import Tooltip from '@mui/material/Tooltip'; import { useTranslation } from 'react-i18next'; +import { SettingsDialog } from '$app/components/settings/SettingsDialog'; +import { ProfileAvatar } from '$app/components/_shared/avatar'; function UserInfo() { const currentUser = useAppSelector((state) => state.currentUser); @@ -16,19 +16,9 @@ function UserInfo() { return ( <>
-
- - {currentUser.displayName ? currentUser.displayName[0] : } - - {currentUser.displayName} +
+ + {currentUser.displayName}
@@ -43,7 +33,7 @@ function UserInfo() {
- setShowUserSetting(false)} /> + {showUserSetting && setShowUserSetting(false)} />} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx new file mode 100644 index 0000000000000..f5638362b9e8e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx @@ -0,0 +1,104 @@ +import React, { useEffect } from 'react'; +import { Alert, Snackbar } from '@mui/material'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { useParams } from 'react-router-dom'; +import { pagesActions } from '$app_reducers/pages/slice'; +import Slide, { SlideProps } from '@mui/material/Slide'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { useTrashActions } from '$app/components/trash/Trash.hooks'; +import { openPage } from '$app_reducers/pages/async_actions'; + +function SlideTransition(props: SlideProps) { + return ; +} + +function DeletePageSnackbar() { + const firstViewId = useAppSelector((state) => { + const workspaceId = state.workspace.currentWorkspaceId; + const children = workspaceId ? state.pages.relationMap[workspaceId] : undefined; + + if (!children) return null; + + return children[0]; + }); + + const showTrashSnackbar = useAppSelector((state) => state.pages.showTrashSnackbar); + const dispatch = useAppDispatch(); + const { onPutback, onDelete } = useTrashActions(); + const { id } = useParams(); + + const { t } = useTranslation(); + + useEffect(() => { + dispatch(pagesActions.setTrashSnackbar(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const handleBack = () => { + if (firstViewId) { + void dispatch(openPage(firstViewId)); + } + }; + + const handleClose = (toBack = true) => { + dispatch(pagesActions.setTrashSnackbar(false)); + if (toBack) { + handleBack(); + } + }; + + const handleRestore = () => { + if (!id) return; + void onPutback(id); + handleClose(false); + }; + + const handleDelete = () => { + if (!id) return; + void onDelete([id]); + + if (!firstViewId) { + handleClose(false); + return; + } + + handleBack(); + }; + + return ( + + handleClose()} + severity='info' + variant='standard' + sx={{ + width: '100%', + '.MuiAlert-action': { + padding: 0, + }, + }} + > +
+ {t('deletePagePrompt.text')} + + +
+
+
+ ); +} + +export default DeletePageSnackbar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx index 1ffae1eb854a5..d37d1bf060cf9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx @@ -1,7 +1,7 @@ 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 { ReactComponent as Details2Svg } from '$app/assets/details.svg'; import Tooltip from '@mui/material/Tooltip'; import MoreOptions from '$app/components/layout/top_bar/MoreOptions'; import { useMoreOptionsConfig } from '$app/components/layout/top_bar/MoreOptions.hooks'; @@ -18,8 +18,8 @@ function MoreButton() { return ( <> - toggleDrawer(true)} className={'h-8 w-8 text-icon-primary'}> - + toggleDrawer(true)} className={'text-icon-primary'}> + toggleDrawer(false)}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx index d1b0fead634ff..173bf86caba0f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx @@ -2,14 +2,15 @@ import React from 'react'; import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; import { useAppSelector } from '$app/stores/store'; import Breadcrumb from '$app/components/layout/bread_crumb/BreadCrumb'; +import DeletePageSnackbar from '$app/components/layout/top_bar/DeletePageSnackbar'; function TopBar() { const sidebarIsCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); return ( -
+
{sidebarIsCollapsed && ( -
+
)} @@ -18,6 +19,7 @@ function TopBar() {
+
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/AppearanceSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/AppearanceSetting.tsx deleted file mode 100644 index e28a467526c91..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/AppearanceSetting.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import Select from '@mui/material/Select'; -import { Theme, ThemeMode, UserSetting } from '$app/stores/reducers/current-user/slice'; -import MenuItem from '@mui/material/MenuItem'; -import { useTranslation } from 'react-i18next'; - -function AppearanceSetting({ - themeMode = ThemeMode.System, - onChange, -}: { - theme?: Theme; - themeMode?: ThemeMode; - onChange: (setting: UserSetting) => void; -}) { - const { t } = useTranslation(); - - const themeModeOptions = useMemo( - () => [ - { - value: ThemeMode.Light, - content: t('settings.appearance.themeMode.light'), - }, - { - value: ThemeMode.Dark, - content: t('settings.appearance.themeMode.dark'), - }, - { - value: ThemeMode.System, - content: t('settings.appearance.themeMode.system'), - }, - ], - [t] - ); - - const renderSelect = useCallback( - ( - items: { - options: { value: ThemeMode | Theme; content: string }[]; - label: string; - value: ThemeMode | Theme; - onChange: (newValue: ThemeMode | Theme) => void; - }[] - ) => { - return items.map((item) => { - const { value, options, label, onChange } = item; - - return ( -
-
{label}
-
- -
-
- ); - }); - }, - [] - ); - - return ( -
- {renderSelect([ - { - options: themeModeOptions, - label: t('settings.appearance.themeMode.label'), - value: themeMode, - onChange: (newValue) => { - onChange({ - themeMode: newValue as ThemeMode, - isDark: - newValue === ThemeMode.Dark || - (newValue === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches), - }); - }, - }, - ])} -
- ); -} - -export default AppearanceSetting; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/LanguageSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/LanguageSetting.tsx deleted file mode 100644 index 81e7d067a4442..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/LanguageSetting.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import Select from '@mui/material/Select'; -import { UserSetting } from '$app/stores/reducers/current-user/slice'; -import MenuItem from '@mui/material/MenuItem'; - -const languages = [ - { - key: 'ar-SA', - title: 'العربية', - }, - { key: 'ca-ES', title: 'Català' }, - { key: 'de-DE', title: 'Deutsch' }, - { key: 'en', title: 'English' }, - { key: 'es-VE', title: 'Español (Venezuela)' }, - { key: 'eu-ES', title: 'Español' }, - { key: 'fr-FR', title: 'Français' }, - { key: 'hu-HU', title: 'Magyar' }, - { key: 'id-ID', title: 'Bahasa Indonesia' }, - { key: 'it-IT', title: 'Italiano' }, - { key: 'ja-JP', title: '日本語' }, - { key: 'ko-KR', title: '한국어' }, - { key: 'pl-PL', title: 'Polski' }, - { key: 'pt-BR', title: 'Português' }, - { key: 'pt-PT', title: 'Português' }, - { key: 'ru-RU', title: 'Русский' }, - { key: 'sv', title: 'Svenska' }, - { key: 'th-TH', title: 'ไทย' }, - { key: 'tr-TR', title: 'Türkçe' }, - { key: 'zh-CN', title: '简体中文' }, - { key: 'zh-TW', title: '繁體中文' }, -]; - -function LanguageSetting({ - language = 'en', - onChange, -}: { - language?: string; - onChange: (setting: UserSetting) => void; -}) { - const { t, i18n } = useTranslation(); - - return ( -
-
-
{t('settings.menu.language')}
-
- -
-
-
- ); -} - -export default LanguageSetting; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/Menu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/Menu.tsx deleted file mode 100644 index 9da3cb8f74242..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/Menu.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useMemo } from 'react'; -import LanguageIcon from '@mui/icons-material/Language'; -import PaletteOutlined from '@mui/icons-material/PaletteOutlined'; -import { useTranslation } from 'react-i18next'; - -export enum MenuItem { - Appearance = 'Appearance', - Language = 'Language', -} - -function UserSettingMenu({ selected, onSelect }: { onSelect: (selected: MenuItem) => void; selected: MenuItem }) { - const { t } = useTranslation(); - - const options = useMemo(() => { - return [ - { - label: t('settings.menu.appearance'), - value: MenuItem.Appearance, - icon: , - }, - { - label: t('settings.menu.language'), - value: MenuItem.Language, - icon: , - }, - ]; - }, [t]); - - return ( -
- {options.map((option) => { - return ( -
{ - onSelect(option.value); - }} - className={`my-1 flex w-full cursor-pointer items-center justify-start rounded-md p-2 text-xs text-text-title ${ - selected === option.value ? 'bg-fill-list-hover' : 'hover:text-content-blue-300' - }`} - > -
{option.icon}
-
{option.label}
-
- ); - })} -
- ); -} - -export default UserSettingMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/SettingPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/SettingPanel.tsx deleted file mode 100644 index c88fe1f2e3fea..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/SettingPanel.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useMemo } from 'react'; -import { MenuItem } from './Menu'; -import AppearanceSetting from './AppearanceSetting'; -import LanguageSetting from './LanguageSetting'; - -import { UserSetting } from '$app/stores/reducers/current-user/slice'; - -function UserSettingPanel({ - selected, - userSettingState = {}, - onChange, -}: { - selected: MenuItem; - userSettingState?: UserSetting; - onChange: (setting: Partial) => void; -}) { - const { theme, themeMode, language } = userSettingState; - - const options = useMemo(() => { - return [ - { - value: MenuItem.Appearance, - content: , - }, - { - value: MenuItem.Language, - content: , - }, - ]; - }, [language, onChange, theme, themeMode]); - - const option = options.find((option) => option.value === selected); - - return
{option?.content}
; -} - -export default UserSettingPanel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/UserSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/UserSetting.tsx deleted file mode 100644 index 7cfbac4a7646d..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/user_setting/UserSetting.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import Slide, { SlideProps } from '@mui/material/Slide'; -import UserSettingMenu, { MenuItem } from './Menu'; -import UserSettingPanel from './SettingPanel'; -import { UserSetting } from '$app/stores/reducers/current-user/slice'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { currentUserActions } from '$app_reducers/current-user/slice'; -import { useTranslation } from 'react-i18next'; -import { UserService } from '$app/application/user/user.service'; - -const SlideTransition = React.forwardRef((props: SlideProps, ref) => { - return ; -}); - -function UserSettings({ open, onClose }: { open: boolean; onClose: () => void }) { - const userSettingState = useAppSelector((state) => state.currentUser.userSetting); - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const [selected, setSelected] = useState(MenuItem.Appearance); - const handleChange = useCallback( - (setting: Partial) => { - const newSetting = { ...userSettingState, ...setting }; - - dispatch(currentUserActions.setUserSetting(newSetting)); - const language = newSetting.language || 'en'; - - void UserService.setAppearanceSetting({ - theme: newSetting.theme, - theme_mode: newSetting.themeMode, - locale: { - language_code: language.split('-')[0], - country_code: language.split('-')[1], - }, - }); - }, - [dispatch, userSettingState] - ); - - return ( - e.stopPropagation()} - open={open} - TransitionComponent={SlideTransition} - keepMounted={false} - onClose={onClose} - > - {t('settings.title')} - - { - setSelected(selected); - }} - selected={selected} - /> - - - - ); -} - -export default UserSettings; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx index e89af3686993a..984ed6f67f68f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { TrashSvg } from '$app/components/_shared/svg/TrashSvg'; +import { ReactComponent as TrashSvg } from '$app/assets/delete.svg'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { useDrag } from 'src/appflowy_app/components/_shared/drag_block'; @@ -34,9 +34,7 @@ function TrashButton() { selected ? 'bg-fill-list-active' : '' } ${isDraggingOver ? 'bg-fill-list-hover' : ''}`} > -
- -
+ {t('trash.text')}
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts index 7d77b12d6934b..86bca45ada926 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice'; import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; @@ -6,33 +6,33 @@ import { subscribeNotifications } from '$app/application/notification'; import { FolderNotification, ViewLayoutPB } from '@/services/backend'; import * as workspaceService from '$app/application/folder/workspace.service'; import { createCurrentWorkspaceChildView } from '$app/application/folder/workspace.service'; -import { useNavigate } from 'react-router-dom'; +import { openPage } from '$app_reducers/pages/async_actions'; export function useLoadWorkspaces() { const dispatch = useAppDispatch(); - const { workspaces, currentWorkspace } = useAppSelector((state) => state.workspace); + const { workspaces, currentWorkspaceId } = useAppSelector((state) => state.workspace); + + const currentWorkspace = useMemo(() => { + return workspaces.find((workspace) => workspace.id === currentWorkspaceId); + }, [workspaces, currentWorkspaceId]); const initializeWorkspaces = useCallback(async () => { const workspaces = await workspaceService.getWorkspaces(); - const currentWorkspace = await workspaceService.getCurrentWorkspace(); + + const currentWorkspaceId = await workspaceService.getCurrentWorkspace(); dispatch( workspaceActions.initWorkspaces({ workspaces, - currentWorkspace, + currentWorkspaceId, }) ); }, [dispatch]); - useEffect(() => { - void (async () => { - await initializeWorkspaces(); - })(); - }, [initializeWorkspaces]); - return { workspaces, currentWorkspace, + initializeWorkspaces, }; } @@ -80,6 +80,15 @@ export function useLoadWorkspace(workspace: WorkspaceItem) { useEffect(() => { const unsubscribePromise = subscribeNotifications( { + [FolderNotification.DidUpdateWorkspace]: async (changeset) => { + dispatch( + workspaceActions.updateWorkspace({ + id: String(changeset.id), + name: changeset.name, + icon: changeset.icon_url, + }) + ); + }, [FolderNotification.DidUpdateWorkspaceViews]: async (changeset) => { const res = changeset.items; @@ -90,7 +99,7 @@ export function useLoadWorkspace(workspace: WorkspaceItem) { ); return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); - }, [id, onChildPagesChanged]); + }, [dispatch, id, onChildPagesChanged]); return { openWorkspace, @@ -99,8 +108,7 @@ export function useLoadWorkspace(workspace: WorkspaceItem) { } export function useWorkspaceActions(workspaceId: string) { - const navigate = useNavigate(); - + const dispatch = useAppDispatch(); const newPage = useCallback(async () => { const { id } = await createCurrentWorkspaceChildView({ name: '', @@ -108,8 +116,19 @@ export function useWorkspaceActions(workspaceId: string) { parent_view_id: workspaceId, }); - navigate(`/page/document/${id}`); - }, [navigate, workspaceId]); + dispatch( + pagesActions.addPage({ + page: { + id: id, + parentId: workspaceId, + layout: ViewLayoutPB.Document, + name: '', + }, + isLast: true, + }) + ); + void dispatch(openPage(id)); + }, [dispatch, workspaceId]); return { newPage, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx index 165a9ab1d1fb5..24fc7be91e647 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx @@ -2,11 +2,11 @@ import React, { useState } from 'react'; import { WorkspaceItem } from '$app_reducers/workspace/slice'; import NestedViews from '$app/components/layout/workspace_manager/NestedPages'; import { useLoadWorkspace, useWorkspaceActions } from '$app/components/layout/workspace_manager/Workspace.hooks'; -import Typography from '@mui/material/Typography'; import { useTranslation } from 'react-i18next'; import { ReactComponent as AddIcon } from '$app/assets/add.svg'; import { IconButton } from '@mui/material'; import Tooltip from '@mui/material/Tooltip'; +import { WorkplaceAvatar } from '$app/components/_shared/avatar'; function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) { useLoadWorkspace(workspace); @@ -29,7 +29,9 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo onClick={(e) => { e.stopPropagation(); e.preventDefault(); - setShowPages(!showPages); + setShowPages((prev) => { + return !prev; + }); }} onMouseEnter={() => { setShowAdd(true); @@ -40,9 +42,22 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo className={'mt-2 flex h-[22px] w-full cursor-pointer select-none items-center justify-between px-4'} > - - {t('sideBar.personal')} - +
+ {!workspace.name ? ( + t('sideBar.personal') + ) : ( + <> + + {workspace.name} + + )} +
{showAdd && ( @@ -59,7 +74,9 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo )}
- {showPages && } +
+ +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx index c6404d435ce7a..083dd61ec3fca 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx @@ -1,21 +1,32 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import NewPageButton from '$app/components/layout/workspace_manager/NewPageButton'; import { useLoadWorkspaces } from '$app/components/layout/workspace_manager/Workspace.hooks'; import Workspace from './Workspace'; import TrashButton from '$app/components/layout/workspace_manager/TrashButton'; +import { useAppSelector } from '@/appflowy_app/stores/store'; +import { LoginState } from '$app_reducers/current-user/slice'; +import { AFScroller } from '$app/components/_shared/scroller'; function WorkspaceManager() { - const { workspaces, currentWorkspace } = useLoadWorkspaces(); + const { workspaces, currentWorkspace, initializeWorkspaces } = useLoadWorkspaces(); + + const loginState = useAppSelector((state) => state.currentUser.loginState); + + useEffect(() => { + if (loginState === LoginState.Success || loginState === undefined) { + void initializeWorkspaces(); + } + }, [initializeWorkspaces, loginState]); return (
-
+
{workspaces.map((workspace) => ( ))}
-
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx new file mode 100644 index 0000000000000..d5ecc4bc0ce98 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx @@ -0,0 +1,22 @@ +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { LoginButtonGroup } from '$app/components/auth/LoginButtonGroup'; + +export const Login = ({ onBack }: { onBack?: () => void }) => { + const { t } = useTranslation(); + + return ( +
+ + {t('button.login')} + +
+ + +
+
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx new file mode 100644 index 0000000000000..1d9f3c0cd9dbe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react'; +import { Box, Tab, Tabs } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { MyAccount } from '$app/components/settings/my_account'; +import { ReactComponent as AccountIcon } from '$app/assets/settings/account.svg'; +import { ReactComponent as WorkplaceIcon } from '$app/assets/settings/workplace.svg'; +import { Workplace } from '$app/components/settings/workplace'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +export const Settings = ({ onForward }: { onForward: (route: SettingsRoutes) => void }) => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(0); + + const tabOptions = useMemo(() => { + return [ + { + label: t('newSettings.myAccount.title'), + Icon: AccountIcon, + Component: MyAccount, + }, + { + label: t('newSettings.workplace.name'), + Icon: WorkplaceIcon, + Component: Workplace, + }, + ]; + }, [t]); + + const handleChangeTab = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + + {tabOptions.map((tab, index) => ( + + + {tab.label} +
+ } + onClick={() => setActiveTab(index)} + sx={{ '&.Mui-selected': { borderColor: 'transparent', backgroundColor: 'var(--fill-list-active)' } }} + /> + ))} + + {tabOptions.map((tab, index) => ( + + + + ))} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000000000..b53f8a6002d8a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx @@ -0,0 +1,108 @@ +/** + * @figmaUrl https://www.figma.com/file/MF5CWlOzBRuGHp45zAXyUH/Appflowy%3A-Desktop-Settings?type=design&node-id=100%3A2119&mode=design&t=4Wb0Zg5NOFO36kOf-1 + */ + +import Dialog, { DialogProps } from '@mui/material/Dialog'; +import { Settings } from '$app/components/settings/Settings'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import DialogTitle from '@mui/material/DialogTitle'; +import { IconButton } from '@mui/material'; +import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as UpIcon } from '$app/assets/up.svg'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; +import DialogContent from '@mui/material/DialogContent'; +import { Login } from '$app/components/settings/Login'; +import SwipeableViews from 'react-swipeable-views'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; + +export const SettingsDialog = (props: DialogProps) => { + const dispatch = useAppDispatch(); + const [routes, setRoutes] = useState([]); + const loginState = useAppSelector((state) => state.currentUser.loginState); + const lastLoginStateRef = useRef(loginState); + const { t } = useTranslation(); + const handleForward = useCallback((route: SettingsRoutes) => { + setRoutes((prev) => { + return [...prev, route]; + }); + }, []); + + const handleBack = useCallback(() => { + setRoutes((prevState) => { + return prevState.slice(0, -1); + }); + dispatch(currentUserActions.resetLoginState()); + }, [dispatch]); + + const handleClose = useCallback(() => { + dispatch(currentUserActions.resetLoginState()); + props?.onClose?.({}, 'backdropClick'); + }, [dispatch, props]); + + const currentRoute = routes[routes.length - 1]; + + useEffect(() => { + if (lastLoginStateRef.current === LoginState.Loading && loginState === LoginState.Success) { + handleClose(); + return; + } + + lastLoginStateRef.current = loginState; + }, [loginState, handleClose]); + + return ( + { + if (e.key === 'Escape') { + e.preventDefault(); + } + }} + scroll={'paper'} + > + + + + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts new file mode 100644 index 0000000000000..0f0a2c23f4e33 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts @@ -0,0 +1 @@ +export * from './my_account'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx new file mode 100644 index 0000000000000..05b375c920936 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { Divider } from '@mui/material'; +import { DeleteAccount } from '$app/components/settings/my_account/DeleteAccount'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; +import { useAuth } from '$app/components/auth/auth.hooks'; + +export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes) => void }) => { + const { t } = useTranslation(); + const { currentUser, logout } = useAuth(); + + const isLocal = currentUser.isLocal; + + return ( + <> +
+ + {t('newSettings.myAccount.accountLogin')} + + + + +
+ + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx new file mode 100644 index 0000000000000..82a909180e23b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { DeleteAccountDialog } from '$app/components/settings/my_account/DeleteAccountDialog'; + +export const DeleteAccount = () => { + const { t } = useTranslation(); + + const [openDialog, setOpenDialog] = useState(false); + + return ( +
+
+ + {t('newSettings.myAccount.deleteAccount.title')} + + + {t('newSettings.myAccount.deleteAccount.subtitle')} + +
+
+ +
+ { + setOpenDialog(false); + }} + /> +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx new file mode 100644 index 0000000000000..2f8cc37258ff1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx @@ -0,0 +1,50 @@ +import Dialog, { DialogProps } from '@mui/material/Dialog'; +import { useTranslation } from 'react-i18next'; +import DialogTitle from '@mui/material/DialogTitle'; +import { DialogActions, DialogContentText, IconButton } from '@mui/material'; +import Button from '@mui/material/Button'; +import DialogContent from '@mui/material/DialogContent'; +import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; + +export const DeleteAccountDialog = (props: DialogProps) => { + const { t } = useTranslation(); + + const handleClose = () => { + props?.onClose?.({}, 'backdropClick'); + }; + + const handleOk = () => { + //123 + }; + + return ( + + {t('newSettings.myAccount.deleteAccount.dialogTitle')} + + {t('newSettings.myAccount.deleteAccount.dialogContent1')} + {t('newSettings.myAccount.deleteAccount.dialogContent2')} + + +
+ +
+
+ +
+
+ + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx new file mode 100644 index 0000000000000..b3a315994bd9c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { Profile } from './Profile'; +import { AccountLogin } from './AccountLogin'; +import { Divider } from '@mui/material'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; + +export const MyAccount = ({ onForward }: { onForward?: (route: SettingsRoutes) => void }) => { + const { t } = useTranslation(); + + return ( +
+ + {t('newSettings.myAccount.title')} + + + {t('newSettings.myAccount.subtitle')} + + + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx new file mode 100644 index 0000000000000..2ac672b0e5483 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx @@ -0,0 +1,180 @@ +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import { IconButton, InputAdornment, OutlinedInput } from '@mui/material'; +import { useAppSelector } from '$app/stores/store'; +import React, { useState } from 'react'; +import { ReactComponent as CheckIcon } from '$app/assets/select-check.svg'; +import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; +import { ReactComponent as EditIcon } from '$app/assets/edit.svg'; + +import Tooltip from '@mui/material/Tooltip'; +import { UserService } from '$app/application/user/user.service'; +import { notify } from '$app/components/_shared/notify'; +import { ProfileAvatar } from '$app/components/_shared/avatar'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import Button from '@mui/material/Button'; + +export const Profile = () => { + const { displayName, id } = useAppSelector((state) => state.currentUser); + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const [newName, setNewName] = useState(displayName ?? 'Me'); + const [error, setError] = useState(false); + const [emojiPickerAnchor, setEmojiPickerAnchor] = useState(null); + const openEmojiPicker = Boolean(emojiPickerAnchor); + const handleSave = async () => { + setError(false); + if (!newName) { + setError(true); + return; + } + + if (newName === displayName) { + setIsEditing(false); + return; + } + + try { + await UserService.updateUserProfile({ + id, + name: newName, + }); + setIsEditing(false); + } catch { + setError(true); + notify.error(t('newSettings.myAccount.updateNameError')); + } + }; + + const handleEmojiSelect = async (emoji: string) => { + try { + await UserService.updateUserProfile({ + id, + icon_url: emoji, + }); + } catch { + notify.error(t('newSettings.myAccount.updateIconError')); + } + }; + + const handleCancel = () => { + setNewName(displayName ?? 'Me'); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void handleSave(); + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + handleCancel(); + } + }; + + return ( +
+ + {t('newSettings.myAccount.profileLabel')} + +
+ + +
+ {isEditing ? ( + setNewName(e.target.value)} + spellCheck={false} + autoFocus={true} + autoCorrect={'off'} + autoCapitalize={'off'} + fullWidth + endAdornment={ + +
+ + + + + + + + + + +
+
+ } + sx={{ + '&.MuiOutlinedInput-root': { + borderRadius: '8px', + }, + }} + placeholder={t('newSettings.myAccount.profileNamePlaceholder')} + value={newName} + /> + ) : ( + + {newName} + + setIsEditing(true)} size={'small'} className={'ml-1'}> + + + + + )} +
+
+ {openEmojiPicker && ( + { + setEmojiPickerAnchor(null); + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + { + setEmojiPickerAnchor(null); + }} + onEmojiSelect={handleEmojiSelect} + /> + + )} +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts new file mode 100644 index 0000000000000..d923fcefce11a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts @@ -0,0 +1 @@ +export * from './MyAccount'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx new file mode 100644 index 0000000000000..1dc8581dae184 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next'; +import { ThemeModeSwitch } from '$app/components/settings/workplace/appearance/ThemeModeSwitch'; +import Typography from '@mui/material/Typography'; +import { Divider } from '@mui/material'; +import { LanguageSetting } from '$app/components/settings/workplace/appearance/LanguageSetting'; + +export const Appearance = () => { + const { t } = useTranslation(); + + return ( + <> + + {t('newSettings.workplace.appearance.name')} + + + + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx new file mode 100644 index 0000000000000..8af69eec51182 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { WorkplaceDisplay } from '$app/components/settings/workplace/WorkplaceDisplay'; +import { Divider } from '@mui/material'; +import { Appearance } from '$app/components/settings/workplace/Appearance'; + +export const Workplace = () => { + const { t } = useTranslation(); + + return ( +
+ + {t('newSettings.workplace.title')} + + + {t('newSettings.workplace.subtitle')} + + + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx new file mode 100644 index 0000000000000..3a71c5f07010e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx @@ -0,0 +1,155 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { Divider, OutlinedInput } from '@mui/material'; +import React, { useMemo, useState } from 'react'; +import Button from '@mui/material/Button'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { changeWorkspaceIcon, renameWorkspace } from '$app/application/folder/workspace.service'; +import { notify } from '$app/components/_shared/notify'; +import { WorkplaceAvatar } from '$app/components/_shared/avatar'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import { workspaceActions } from '$app_reducers/workspace/slice'; +import debounce from 'lodash-es/debounce'; + +export const WorkplaceDisplay = () => { + const { t } = useTranslation(); + const isLocal = useAppSelector((state) => state.currentUser.isLocal); + const { workspaces, currentWorkspaceId } = useAppSelector((state) => state.workspace); + const workspace = useMemo( + () => workspaces.find((workspace) => workspace.id === currentWorkspaceId), + [workspaces, currentWorkspaceId] + ); + const [name, setName] = useState(workspace?.name ?? ''); + const [emojiPickerAnchor, setEmojiPickerAnchor] = useState(null); + const openEmojiPicker = Boolean(emojiPickerAnchor); + const dispatch = useAppDispatch(); + + const debounceUpdateWorkspace = useMemo(() => { + return debounce(async ({ id, name, icon }: { id: string; name?: string; icon?: string }) => { + if (!id || !name) return; + + if (icon) { + try { + await changeWorkspaceIcon(id, icon); + } catch { + notify.error(t('newSettings.workplace.updateIconError')); + } + } + + if (name) { + try { + await renameWorkspace(id, name); + } catch { + notify.error(t('newSettings.workplace.renameError')); + } + } + }, 500); + }, [t]); + + const handleSave = async () => { + if (!workspace || !name) return; + dispatch(workspaceActions.updateWorkspace({ id: workspace.id, name })); + + await debounceUpdateWorkspace({ id: workspace.id, name }); + }; + + const handleEmojiSelect = async (icon: string) => { + if (!workspace) return; + dispatch(workspaceActions.updateWorkspace({ id: workspace.id, icon })); + + await debounceUpdateWorkspace({ id: workspace.id, icon }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void handleSave(); + } + }; + + return ( +
+ + {t('newSettings.workplace.workplaceName')} + +
+
+ setName(e.target.value)} + sx={{ + '&.MuiOutlinedInput-root': { + borderRadius: '8px', + }, + }} + placeholder={t('newSettings.workplace.workplaceNamePlaceholder')} + value={name} + /> +
+ +
+ + + {t('newSettings.workplace.workplaceIcon')} + + + {t('newSettings.workplace.workplaceIconSubtitle')} + + + {openEmojiPicker && ( + { + setEmojiPickerAnchor(null); + }} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + { + setEmojiPickerAnchor(null); + }} + onEmojiSelect={handleEmojiSelect} + /> + + )} +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx new file mode 100644 index 0000000000000..41a42bd011abe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx @@ -0,0 +1,115 @@ +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import React, { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions } from '$app_reducers/current-user/slice'; +import { UserService } from '$app/application/user/user.service'; + +const languages = [ + { + key: 'ar-SA', + title: 'العربية', + }, + { key: 'ca-ES', title: 'Català' }, + { key: 'de-DE', title: 'Deutsch' }, + { key: 'en', title: 'English' }, + { key: 'es-VE', title: 'Español (Venezuela)' }, + { key: 'eu-ES', title: 'Español' }, + { key: 'fr-FR', title: 'Français' }, + { key: 'hu-HU', title: 'Magyar' }, + { key: 'id-ID', title: 'Bahasa Indonesia' }, + { key: 'it-IT', title: 'Italiano' }, + { key: 'ja-JP', title: '日本語' }, + { key: 'ko-KR', title: '한국어' }, + { key: 'pl-PL', title: 'Polski' }, + { key: 'pt-BR', title: 'Português' }, + { key: 'pt-PT', title: 'Português' }, + { key: 'ru-RU', title: 'Русский' }, + { key: 'sv', title: 'Svenska' }, + { key: 'th-TH', title: 'ไทย' }, + { key: 'tr-TR', title: 'Türkçe' }, + { key: 'zh-CN', title: '简体中文' }, + { key: 'zh-TW', title: '繁體中文' }, +]; + +export const LanguageSetting = () => { + const { t, i18n } = useTranslation(); + const userSettingState = useAppSelector((state) => state.currentUser.userSetting); + const dispatch = useAppDispatch(); + const selectedLanguage = userSettingState.language; + + const [hoverKey, setHoverKey] = React.useState(null); + + const handleChange = useCallback( + (language: string) => { + const newSetting = { ...userSettingState, language }; + + dispatch(currentUserActions.setUserSetting(newSetting)); + const newLanguage = newSetting.language || 'en'; + + void UserService.setAppearanceSetting({ + theme: newSetting.theme, + theme_mode: newSetting.themeMode, + locale: { + language_code: newLanguage.split('-')[0], + country_code: newLanguage.split('-')[1], + }, + }); + }, + [dispatch, userSettingState] + ); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + } + }, []); + + return ( + <> + + {t('newSettings.workplace.appearance.language')} + + + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx new file mode 100644 index 0000000000000..34fdb8e598a1b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { useCallback, useMemo } from 'react'; +import { ThemeModePB } from '@/services/backend'; +import darkSrc from '$app/assets/settings/dark.png'; +import lightSrc from '$app/assets/settings/light.png'; +import { currentUserActions, ThemeMode } from '$app_reducers/current-user/slice'; +import { UserService } from '$app/application/user/user.service'; +import { ReactComponent as CheckCircle } from '$app/assets/settings/check_circle.svg'; + +export const ThemeModeSwitch = () => { + const { t } = useTranslation(); + const userSettingState = useAppSelector((state) => state.currentUser.userSetting); + const dispatch = useAppDispatch(); + + const selectedMode = userSettingState.themeMode; + const themeModes = useMemo(() => { + return [ + { + name: t('newSettings.workplace.appearance.themeMode.auto'), + value: ThemeModePB.System, + img: ( +
+ + +
+ ), + }, + { + name: t('newSettings.workplace.appearance.themeMode.light'), + value: ThemeModePB.Light, + img: , + }, + { + name: t('newSettings.workplace.appearance.themeMode.dark'), + value: ThemeModePB.Dark, + img: , + }, + ]; + }, [t]); + + const handleChange = useCallback( + (newValue: ThemeModePB) => { + const newSetting = { + ...userSettingState, + ...{ + themeMode: newValue, + isDark: + newValue === ThemeMode.Dark || + (newValue === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches), + }, + }; + + dispatch(currentUserActions.setUserSetting(newSetting)); + + void UserService.setAppearanceSetting({ + theme_mode: newSetting.themeMode, + }); + }, + [dispatch, userSettingState] + ); + + const renderThemeModeItem = useCallback( + (option: { name: string; value: ThemeModePB; img: JSX.Element }) => { + return ( +
handleChange(option.value)} + className={'flex cursor-pointer flex-col items-center gap-2'} + > +
+ {option.img} + +
+
{option.name}
+
+ ); + }, + [handleChange, selectedMode] + ); + + return
{themeModes.map((mode) => renderThemeModeItem(mode))}
; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts new file mode 100644 index 0000000000000..075e2744a5c42 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts @@ -0,0 +1,3 @@ +export enum SettingsRoutes { + LOGIN = 'login', +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts new file mode 100644 index 0000000000000..a64592ac8b737 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts @@ -0,0 +1 @@ +export * from './Workplace'; 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 e98a846da0a18..b6748614b8305 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 @@ -39,6 +39,9 @@ export function useLoadTrash() { export function useTrashActions() { const [restoreAllDialogOpen, setRestoreAllDialogOpen] = useState(false); const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const [deleteId, setDeleteId] = useState(''); const onClickRestoreAll = () => { setRestoreAllDialogOpen(true); @@ -51,9 +54,18 @@ export function useTrashActions() { const closeDialog = () => { setRestoreAllDialogOpen(false); setDeleteAllDialogOpen(false); + setDeleteDialogOpen(false); + }; + + const onClickDelete = (id: string) => { + setDeleteId(id); + setDeleteDialogOpen(true); }; return { + onClickDelete, + deleteDialogOpen, + deleteId, onPutback: putback, onDelete: deleteTrashItem, onDeleteAll: deleteAll, 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 40f51d1fbf5dc..f10848dc9b35b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx @@ -20,6 +20,9 @@ function Trash() { onRestoreAll, onDeleteAll, closeDialog, + deleteDialogOpen, + deleteId, + onClickDelete, } = useTrashActions(); const [hoverId, setHoverId] = useState(''); @@ -50,7 +53,7 @@ function Trash() { item={item} key={item.id} onPutback={onPutback} - onDelete={onDelete} + onDelete={onClickDelete} hoverId={hoverId} setHoverId={setHoverId} /> @@ -62,6 +65,7 @@ function Trash() { subtitle={t('trash.confirmRestoreAll.caption')} onOk={onRestoreAll} onClose={closeDialog} + okText={t('trash.restoreAll')} /> + onDelete([deleteId])} + onClose={closeDialog} + />
); } 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 9d4bb15628457..d266005612d6b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx @@ -17,7 +17,7 @@ function TrashItem({ item: Trash; hoverId: string; onPutback: (id: string) => void; - onDelete: (ids: string[]) => void; + onDelete: (id: string) => void; }) { const { t } = useTranslation(); @@ -35,7 +35,9 @@ function TrashItem({ }} >
-
{item.name || t('document.title.placeholder')}
+
+ {item.name.trim() || t('menuAppHeader.defaultNewPageName')} +
{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}
{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}
- onDelete([item.id])}> + onDelete(item.id)}> 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 13ad58117583b..464b7428a3398 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,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { WorkspaceSettingPB } from '@/services/backend/models/flowy-folder/workspace'; import { ThemeModePB as ThemeMode } from '@/services/backend'; +import { Page, parserViewPBToPage } from '$app_reducers/pages/slice'; export { ThemeMode }; @@ -17,35 +18,55 @@ export enum Theme { Lavender = 'lavender', } +export enum LoginState { + Loading = 'loading', + Success = 'success', + Error = 'error', +} + +export interface UserWorkspaceSetting { + workspaceId: string; + latestView?: Page; + hasLatestView: boolean; +} + +export function parseWorkspaceSettingPBToSetting(workspaceSetting: WorkspaceSettingPB): UserWorkspaceSetting { + return { + workspaceId: workspaceSetting.workspace_id, + latestView: workspaceSetting.latest_view ? parserViewPBToPage(workspaceSetting.latest_view) : undefined, + hasLatestView: !!workspaceSetting.latest_view, + }; +} + export interface ICurrentUser { id?: number; + deviceId?: string; displayName?: string; email?: string; token?: string; + iconUrl?: string; isAuthenticated: boolean; - workspaceSetting?: WorkspaceSettingPB; + workspaceSetting?: UserWorkspaceSetting; userSetting: UserSetting; + isLocal: boolean; + loginState?: LoginState; } const initialState: ICurrentUser | null = { isAuthenticated: false, userSetting: {}, + isLocal: true, }; export const currentUserSlice = createSlice({ name: 'currentUser', initialState: initialState, reducers: { - checkUser: (state, action: PayloadAction>) => { - return { - ...state, - ...action.payload, - }; - }, updateUser: (state, action: PayloadAction>) => { return { ...state, ...action.payload, + loginState: LoginState.Success, }; }, logout: () => { @@ -57,6 +78,21 @@ export const currentUserSlice = createSlice({ ...action.payload, }; }, + + setLoginState: (state, action: PayloadAction) => { + state.loginState = action.payload; + }, + + resetLoginState: (state) => { + state.loginState = undefined; + }, + + setLatestView: (state, action: PayloadAction) => { + if (state.workspaceSetting) { + state.workspaceSetting.latestView = action.payload; + state.workspaceSetting.hasLatestView = true; + } + }, }, }); 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 ebb78bb7fa37f..90014c1e7f4cf 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 @@ -1,7 +1,9 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '$app/stores/store'; import { pagesActions } from '$app_reducers/pages/slice'; -import { movePage, updatePage } from '$app/application/folder/page.service'; +import { movePage, setLatestOpenedPage, updatePage } from '$app/application/folder/page.service'; +import debounce from 'lodash-es/debounce'; +import { currentUserActions } from '$app_reducers/current-user/slice'; export const movePageThunk = createAsyncThunk( 'pages/movePage', @@ -61,12 +63,14 @@ export const movePageThunk = createAsyncThunk( } ); +const debounceUpdateName = debounce(updatePage, 1000); + export const updatePageName = createAsyncThunk( 'pages/updateName', - async (payload: { id: string; name: string }, thunkAPI) => { + async (payload: { id: string; name: string; immediate?: boolean }, thunkAPI) => { const { dispatch, getState } = thunkAPI; const { pageMap } = (getState() as RootState).pages; - const { id, name } = payload; + const { id, name, immediate } = payload; const page = pageMap[id]; if (name === page.name) return; @@ -78,9 +82,25 @@ export const updatePageName = createAsyncThunk( }) ); - await updatePage({ - id, - name, - }); + if (immediate) { + await updatePage({ id, name }); + } else { + await debounceUpdateName({ + id, + name, + }); + } } ); + +export const openPage = createAsyncThunk('pages/openPage', async (id: string, thunkAPI) => { + const { dispatch, getState } = thunkAPI; + const { pageMap } = (getState() as RootState).pages; + + const page = pageMap[id]; + + if (!page) return; + + dispatch(currentUserActions.setLatestView(page)); + await setLatestOpenedPage(id); +}); 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 8d3f07507ec5d..dbf313ecc1c80 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 @@ -1,6 +1,8 @@ import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import isEqual from 'lodash-es/isEqual'; +import { ImageType } from '$app/application/document/document.types'; +import { Nullable } from 'unsplash-js/dist/helpers/typescript'; export const pageTypeMap = { [ViewLayoutPB.Document]: 'document', @@ -14,6 +16,7 @@ export interface Page { name: string; layout: ViewLayoutPB; icon?: PageIcon; + cover?: PageCover; } export interface PageIcon { @@ -21,6 +24,17 @@ export interface PageIcon { value: string; } +export enum CoverType { + Color = 'CoverType.color', + Image = 'CoverType.file', + Asset = 'CoverType.asset', +} +export type PageCover = Nullable<{ + image_type?: ImageType; + cover_selection_type?: CoverType; + cover_selection?: string; +}>; + export function parserViewPBToPage(view: ViewPB): Page { const icon = view.icon; @@ -42,6 +56,7 @@ export interface PageState { pageMap: Record; relationMap: Record; expandedIdMap: Record; + showTrashSnackbar: boolean; } export const initialState: PageState = { @@ -51,6 +66,7 @@ export const initialState: PageState = { acc[id] = true; return acc; }, {} as Record), + showTrashSnackbar: false, }; export const pagesSlice = createSlice({ @@ -187,6 +203,10 @@ export const pagesSlice = createSlice({ storeExpandedPageIds(ids); }, + + setTrashSnackbar(state, action: PayloadAction) { + state.showTrashSnackbar = action.payload; + }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts index c8a9d864ac563..fae1d59214984 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts @@ -19,6 +19,9 @@ export const sidebarSlice = createSlice({ toggleCollapse(state) { state.isCollapsed = !state.isCollapsed; }, + setCollapse(state, action: PayloadAction) { + state.isCollapsed = action.payload; + }, changeWidth(state, action: PayloadAction) { state.width = action.payload; }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts index 090382de7075a..d071de846e91d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts @@ -3,16 +3,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export interface WorkspaceItem { id: string; name: string; + icon?: string; } interface WorkspaceState { workspaces: WorkspaceItem[]; - currentWorkspace: WorkspaceItem | null; + currentWorkspaceId: string | null; } const initialState: WorkspaceState = { workspaces: [], - currentWorkspace: null, + currentWorkspaceId: null, }; export const workspaceSlice = createSlice({ @@ -23,11 +24,22 @@ export const workspaceSlice = createSlice({ state, action: PayloadAction<{ workspaces: WorkspaceItem[]; - currentWorkspace: WorkspaceItem | null; + currentWorkspaceId: string | null; }> ) => { return action.payload; }, + + updateWorkspace: (state, action: PayloadAction>) => { + const index = state.workspaces.findIndex((workspace) => workspace.id === action.payload.id); + + if (index !== -1) { + state.workspaces[index] = { + ...state.workspaces[index], + ...action.payload, + }; + } + }, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts new file mode 100644 index 0000000000000..a9a752c57965d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts @@ -0,0 +1,26 @@ +export function stringToColor(string: string) { + let hash = 0; + let i; + + /* eslint-disable no-bitwise */ + for (i = 0; i < string.length; i += 1) { + hash = string.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = '#'; + + for (i = 0; i < 3; i += 1) { + const value = (hash >> (i * 8)) & 0xff; + + color += `00${value.toString(16)}`.slice(-2); + } + /* eslint-enable no-bitwise */ + + return color; +} + +export function stringToShortName(string: string) { + const [firstName, lastName = ''] = string.split(' '); + + return `${firstName.charAt(0)}${lastName.charAt(0)}`; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts new file mode 100644 index 0000000000000..025c8c45ed324 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts @@ -0,0 +1,50 @@ +export enum ColorEnum { + Purple = 'appflowy_them_color_tint1', + Pink = 'appflowy_them_color_tint2', + LightPink = 'appflowy_them_color_tint3', + Orange = 'appflowy_them_color_tint4', + Yellow = 'appflowy_them_color_tint5', + Lime = 'appflowy_them_color_tint6', + Green = 'appflowy_them_color_tint7', + Aqua = 'appflowy_them_color_tint8', + Blue = 'appflowy_them_color_tint9', +} + +export const colorMap = { + [ColorEnum.Purple]: 'var(--tint-purple)', + [ColorEnum.Pink]: 'var(--tint-pink)', + [ColorEnum.LightPink]: 'var(--tint-red)', + [ColorEnum.Orange]: 'var(--tint-orange)', + [ColorEnum.Yellow]: 'var(--tint-yellow)', + [ColorEnum.Lime]: 'var(--tint-lime)', + [ColorEnum.Green]: 'var(--tint-green)', + [ColorEnum.Aqua]: 'var(--tint-aqua)', + [ColorEnum.Blue]: 'var(--tint-blue)', +}; + +// Convert ARGB to RGBA +// Flutter uses ARGB, but CSS uses RGBA +function argbToRgba(color: string): string { + const hex = color.replace(/^#|0x/, ''); + + const hasAlpha = hex.length === 8; + + if (!hasAlpha) { + return color.replace('0x', '#'); + } + + const r = parseInt(hex.slice(2, 4), 16); + const g = parseInt(hex.slice(4, 6), 16); + const b = parseInt(hex.slice(6, 8), 16); + const a = hasAlpha ? parseInt(hex.slice(0, 2), 16) / 255 : 1; + + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +export function renderColor(color: string) { + if (colorMap[color as ColorEnum]) { + return colorMap[color as ColorEnum]; + } + + return argbToRgba(color); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts deleted file mode 100644 index a81e5e9093d5b..0000000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const isMac = () => { - return navigator.userAgent.includes('Mac OS X'); -}; - -const MODIFIERS = { - control: 'Ctrl', - meta: '⌘', -}; - -export const getModifier = () => { - return isMac() ? MODIFIERS.meta : MODIFIERS.control; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts new file mode 100644 index 0000000000000..20aa05db27039 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts @@ -0,0 +1,134 @@ +import isHotkey from 'is-hotkey'; + +export const isMac = () => { + return navigator.userAgent.includes('Mac OS X'); +}; + +const MODIFIERS = { + control: 'Ctrl', + meta: '⌘', +}; + +export const getModifier = () => { + return isMac() ? MODIFIERS.meta : MODIFIERS.control; +}; + +export enum HOT_KEY_NAME { + LEFT = 'left', + RIGHT = 'right', + SELECT_ALL = 'select-all', + ESCAPE = 'escape', + ALIGN_LEFT = 'align-left', + ALIGN_CENTER = 'align-center', + ALIGN_RIGHT = 'align-right', + BOLD = 'bold', + ITALIC = 'italic', + UNDERLINE = 'underline', + STRIKETHROUGH = 'strikethrough', + CODE = 'code', + TOGGLE_TODO = 'toggle-todo', + TOGGLE_COLLAPSE = 'toggle-collapse', + INDENT_BLOCK = 'indent-block', + OUTDENT_BLOCK = 'outdent-block', + INSERT_SOFT_BREAK = 'insert-soft-break', + SPLIT_BLOCK = 'split-block', + BACKSPACE = 'backspace', + OPEN_LINK = 'open-link', + OPEN_LINKS = 'open-links', + EXTEND_LINE_BACKWARD = 'extend-line-backward', + EXTEND_LINE_FORWARD = 'extend-line-forward', + PASTE = 'paste', + PASTE_PLAIN_TEXT = 'paste-plain-text', + HIGH_LIGHT = 'high-light', + EXTEND_DOCUMENT_BACKWARD = 'extend-document-backward', + EXTEND_DOCUMENT_FORWARD = 'extend-document-forward', + SCROLL_TO_TOP = 'scroll-to-top', + SCROLL_TO_BOTTOM = 'scroll-to-bottom', + FORMAT_LINK = 'format-link', + FIND_REPLACE = 'find-replace', + /** + * Navigation + */ + TOGGLE_THEME = 'toggle-theme', + TOGGLE_SIDEBAR = 'toggle-sidebar', +} + +const defaultHotKeys = { + [HOT_KEY_NAME.ALIGN_LEFT]: ['control+shift+l'], + [HOT_KEY_NAME.ALIGN_CENTER]: ['control+shift+e'], + [HOT_KEY_NAME.ALIGN_RIGHT]: ['control+shift+r'], + [HOT_KEY_NAME.BOLD]: ['mod+b'], + [HOT_KEY_NAME.ITALIC]: ['mod+i'], + [HOT_KEY_NAME.UNDERLINE]: ['mod+u'], + [HOT_KEY_NAME.STRIKETHROUGH]: ['mod+shift+s', 'mod+shift+x'], + [HOT_KEY_NAME.CODE]: ['mod+e'], + [HOT_KEY_NAME.TOGGLE_TODO]: ['mod+enter'], + [HOT_KEY_NAME.TOGGLE_COLLAPSE]: ['mod+enter'], + [HOT_KEY_NAME.SELECT_ALL]: ['mod+a'], + [HOT_KEY_NAME.ESCAPE]: ['esc'], + [HOT_KEY_NAME.INDENT_BLOCK]: ['tab'], + [HOT_KEY_NAME.OUTDENT_BLOCK]: ['shift+tab'], + [HOT_KEY_NAME.SPLIT_BLOCK]: ['enter'], + [HOT_KEY_NAME.INSERT_SOFT_BREAK]: ['shift+enter'], + [HOT_KEY_NAME.BACKSPACE]: ['backspace', 'shift+backspace'], + [HOT_KEY_NAME.OPEN_LINK]: ['opt+enter'], + [HOT_KEY_NAME.OPEN_LINKS]: ['opt+shift+enter'], + [HOT_KEY_NAME.EXTEND_LINE_BACKWARD]: ['opt+shift+left'], + [HOT_KEY_NAME.EXTEND_LINE_FORWARD]: ['opt+shift+right'], + [HOT_KEY_NAME.PASTE]: ['mod+v'], + [HOT_KEY_NAME.PASTE_PLAIN_TEXT]: ['mod+shift+v'], + [HOT_KEY_NAME.HIGH_LIGHT]: ['mod+shift+h'], + [HOT_KEY_NAME.EXTEND_DOCUMENT_BACKWARD]: ['mod+shift+up'], + [HOT_KEY_NAME.EXTEND_DOCUMENT_FORWARD]: ['mod+shift+down'], + [HOT_KEY_NAME.SCROLL_TO_TOP]: ['home'], + [HOT_KEY_NAME.SCROLL_TO_BOTTOM]: ['end'], + [HOT_KEY_NAME.TOGGLE_THEME]: ['mod+shift+l'], + [HOT_KEY_NAME.TOGGLE_SIDEBAR]: ['mod+.'], + [HOT_KEY_NAME.FORMAT_LINK]: ['mod+k'], + [HOT_KEY_NAME.LEFT]: ['left'], + [HOT_KEY_NAME.RIGHT]: ['right'], + [HOT_KEY_NAME.FIND_REPLACE]: ['mod+f'], +}; + +const replaceModifier = (hotkey: string) => { + return hotkey.replace('mod', getModifier()).replace('control', 'ctrl'); +}; + +/** + * Create a hotkey checker. + * @example trigger strike through when user press "Cmd + Shift + S" or "Cmd + Shift + X" + * @param hotkeyName + * @param customHotKeys + */ +export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + const keys = customHotKeys || defaultHotKeys; + const hotkeys = keys[hotkeyName]; + + return (event: KeyboardEvent) => { + return hotkeys.some((hotkey) => { + return isHotkey(hotkey, event); + }); + }; +}; + +/** + * Create a hotkey label. + * eg. "Ctrl + B / ⌘ + B" + * @param hotkeyName + * @param customHotKeys + */ +export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + const keys = customHotKeys || defaultHotKeys; + const hotkeys = keys[hotkeyName].map((key) => replaceModifier(key)); + + return hotkeys + .map((hotkey) => + hotkey + .split('+') + .map((key) => { + return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1); + }) + .join(' + ') + ) + .join(' / '); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts new file mode 100644 index 0000000000000..6e5d22ccda741 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts @@ -0,0 +1,45 @@ +const romanMap: [number, string][] = [ + [1000, 'M'], + [900, 'CM'], + [500, 'D'], + [400, 'CD'], + [100, 'C'], + [90, 'XC'], + [50, 'L'], + [40, 'XL'], + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'], +]; + +export function romanize(num: number): string { + let result = ''; + let nextNum = num; + + for (const [value, symbol] of romanMap) { + const count = Math.floor(nextNum / value); + + nextNum -= value * count; + result += symbol.repeat(count); + if (nextNum === 0) break; + } + + return result; +} + +export function letterize(num: number): string { + let nextNum = num; + let letters = ''; + + while (nextNum > 0) { + nextNum--; + const letter = String.fromCharCode((nextNum % 26) + 'a'.charCodeAt(0)); + + letters = letter + letters; + nextNum = Math.floor(nextNum / 26); + } + + return letters; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts index fa2520bb7a0ea..94e2cf94d5000 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts @@ -39,12 +39,28 @@ export const getDesignTokens = (isDark: boolean): ThemeOptions => { styleOverrides: { contained: { color: 'var(--content-on-fill)', + boxShadow: 'var(--shadow)', }, containedPrimary: { '&:hover': { backgroundColor: 'var(--fill-default)', }, }, + containedInherit: { + color: 'var(--text-title)', + backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)', + '&:hover': { + backgroundColor: 'var(--bg-body)', + boxShadow: 'var(--shadow)', + }, + }, + outlinedInherit: { + color: 'var(--text-title)', + borderColor: 'var(--line-border)', + '&:hover': { + boxShadow: 'var(--shadow)', + }, + }, }, }, MuiButtonBase: { diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts new file mode 100644 index 0000000000000..d854be521162f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts @@ -0,0 +1,23 @@ +import { open as openWindow } from '@tauri-apps/api/shell'; + +const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/; +const ipPattern = /^(https?:\/\/)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d{1,5})?$/; + +export function isUrl(str: string) { + return urlPattern.test(str) || ipPattern.test(str); +} + +export function openUrl(str: string) { + if (isUrl(str)) { + const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:']; + + if (linkPrefix.some((prefix) => str.startsWith(prefix))) { + void openWindow(str); + } else { + void openWindow('https://' + str); + } + } else { + // open google search + void openWindow('https://www.google.com/search?q=' + encodeURIComponent(str)); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts new file mode 100644 index 0000000000000..22213ac8b37be --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts @@ -0,0 +1,9 @@ +export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB +export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; +export const IMAGE_DIR = 'images'; + +export function getFileName(url: string) { + const [...parts] = url.split('/'); + + return parts.pop() ?? url; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx index cbd71ec7d4efe..03ba493c10550 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx @@ -8,13 +8,7 @@ function DocumentPage() { const documentId = params.id; if (!documentId) return null; - return ( -
-
- -
-
- ); + return ; } export default DocumentPage; diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css index f17595b74a934..1bff6bdc76bc8 100644 --- a/frontend/appflowy_tauri/src/styles/template.css +++ b/frontend/appflowy_tauri/src/styles/template.css @@ -1,5 +1,10 @@ @import './variables/index.css'; +* { + margin: 0; + padding: 0; +} + /* stop body from scrolling */ html, body { @@ -53,96 +58,3 @@ th { @apply text-left font-normal; } - -.sketch-picker { - background-color: var(--bg-body) !important; - border-color: transparent !important; - box-shadow: none !important; -} -.sketch-picker .flexbox-fix { - border-color: var(--line-divider) !important; -} -.sketch-picker [id^='rc-editable-input'] { - background-color: var(--bg-body) !important; - border-color: var(--line-divider) !important; - color: var(--text-title) !important; - box-shadow: var(--line-border) 0px 0px 0px 1px inset !important; -} - -.appflowy-date-picker-calendar { - width: 100%; - -} - -.react-datepicker__month-container { - width: 100%; - border-radius: 0; -} -.react-datepicker__header { - border-radius: 0; - background: transparent; - border-bottom: 0; - -} -.react-datepicker__day-names { - border: none; -} -.react-datepicker__day-name { - color: var(--text-caption); -} -.react-datepicker__month { - border: none; -} - -.react-datepicker__day { - border: none; - color: var(--text-title); - border-radius: 100%; -} -.react-datepicker__day:hover { - border-radius: 100%; - background: var(--fill-hover); -} -.react-datepicker__day--outside-month { - color: var(--text-caption); -} -.react-datepicker__day--in-range { - background: var(--fill-active); -} - -.react-datepicker__day--in-selecting-range { - background: var(--fill-active) !important; -} - - - -.react-datepicker__day--today { - border: 1px solid var(--fill-hover); - color: var(--text-title); - border-radius: 100%; - background: transparent; - font-weight: 500; - -} - -.react-datepicker__day--today:hover{ - background: var(--fill-hover); -} - -.react-datepicker__day--keyboard-selected { - background: transparent; -} - - -.react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected { - background: var(--fill-default); - color: var(--content-on-fill); -} - -.react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected:hover { - background: var(--fill-hover); -} - -.react-swipeable-view-container { - height: 100%; -} diff --git a/frontend/appflowy_tauri/src/styles/variables/dark.variables.css b/frontend/appflowy_tauri/src/styles/variables/dark.variables.css index 9d8bb9decdcc7..ca7544687b4bb 100644 --- a/frontend/appflowy_tauri/src/styles/variables/dark.variables.css +++ b/frontend/appflowy_tauri/src/styles/variables/dark.variables.css @@ -1,6 +1,6 @@ /** * Do not edit directly -* Generated on Mon, 29 Jan 2024 03:52:24 GMT +* Generated on Tue, 19 Mar 2024 03:48:58 GMT * Generated from $pnpm css:variables */ @@ -36,7 +36,7 @@ --base-light-color-light-green: #ddffd6; --base-light-color-light-aqua: #defff1; --base-light-color-light-blue: #e1fbff; - --base-light-color-light-red: #ffe7ee; + --base-light-color-light-red: #ffdddd; --base-black-neutral-100: #252F41; --base-black-neutral-200: #313c51; --base-black-neutral-300: #3c4557; @@ -88,7 +88,7 @@ --fill-hover: #005174; --fill-toolbar: #0F111C; --fill-selector: #232b38; - --fill-list-active: #252F41; + --fill-list-active: #3c4557; --fill-list-hover: #005174; --content-blue-400: #00bcf0; --content-blue-300: #52d1f4; @@ -116,4 +116,6 @@ --tint-aqua: #1B3849; --tint-orange: #5E3C3C; --shadow: 0px 0px 25px 0px rgba(0,0,0,0.3); + --scrollbar-track: #252F41; + --scrollbar-thumb: #3c4557; } \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/styles/variables/light.variables.css b/frontend/appflowy_tauri/src/styles/variables/light.variables.css index b0ce72e1a57e8..26acc76f0a788 100644 --- a/frontend/appflowy_tauri/src/styles/variables/light.variables.css +++ b/frontend/appflowy_tauri/src/styles/variables/light.variables.css @@ -1,6 +1,6 @@ /** * Do not edit directly -* Generated on Mon, 29 Jan 2024 03:52:24 GMT +* Generated on Tue, 19 Mar 2024 03:48:58 GMT * Generated from $pnpm css:variables */ @@ -36,7 +36,7 @@ --base-light-color-light-green: #ddffd6; --base-light-color-light-aqua: #defff1; --base-light-color-light-blue: #e1fbff; - --base-light-color-light-red: #ffe7ee; + --base-light-color-light-red: #ffdddd; --base-black-neutral-100: #252F41; --base-black-neutral-200: #313c51; --base-black-neutral-300: #3c4557; @@ -111,7 +111,7 @@ --function-info: #00bcf0; --tint-purple: #e8e0ff; --tint-pink: #ffe7ee; - --tint-red: #ffe7ee; + --tint-red: #ffdddd; --tint-lime: #f5ffdc; --tint-green: #ddffd6; --tint-aqua: #defff1; @@ -119,4 +119,6 @@ --tint-orange: #ffefe3; --tint-yellow: #fff2cd; --shadow: 0px 0px 10px 0px rgba(0,0,0,0.1); + --scrollbar-thumb: #bdbdbd; + --scrollbar-track: #edeef2; } \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs b/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs index a3b36ef1c0d6e..e9d8024320c17 100644 --- a/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs +++ b/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs @@ -1,6 +1,6 @@ /** * Do not edit directly -* Generated on Mon, 29 Jan 2024 03:52:24 GMT +* Generated on Tue, 19 Mar 2024 03:48:58 GMT * Generated from $pnpm css:variables */ diff --git a/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs b/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs index 1cd0a67ada623..bfa25fa56f485 100644 --- a/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs +++ b/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs @@ -1,6 +1,6 @@ /** * Do not edit directly -* Generated on Mon, 29 Jan 2024 03:52:24 GMT +* Generated on Tue, 19 Mar 2024 03:48:58 GMT * Generated from $pnpm css:variables */ @@ -67,5 +67,9 @@ module.exports = { "lime": "var(--tint-lime)", "aqua": "var(--tint-aqua)", "orange": "var(--tint-orange)" + }, + "scrollbar": { + "track": "var(--scrollbar-track)", + "thumb": "var(--scrollbar-thumb)" } } \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/base.json b/frontend/appflowy_tauri/style-dictionary/tokens/base.json index 48ff92e680c09..4e31b0523d424 100644 --- a/frontend/appflowy_tauri/style-dictionary/tokens/base.json +++ b/frontend/appflowy_tauri/style-dictionary/tokens/base.json @@ -134,7 +134,7 @@ "type": "color" }, "red": { - "value": "#ffe7ee", + "value": "#ffdddd", "type": "color" } } diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/dark.json b/frontend/appflowy_tauri/style-dictionary/tokens/dark.json index ea97844f34298..c67af7c9ec704 100644 --- a/frontend/appflowy_tauri/style-dictionary/tokens/dark.json +++ b/frontend/appflowy_tauri/style-dictionary/tokens/dark.json @@ -80,7 +80,7 @@ }, "list": { "active": { - "value": "{Base.black.neutral.100}", + "value": "{Base.black.neutral.300}", "type": "color" }, "hover": { @@ -207,5 +207,15 @@ "type": "innerShadow" }, "type": "boxShadow" + }, + "scrollbar": { + "track": { + "value": "{Base.black.neutral.100}", + "type": "color" + }, + "thumb": { + "value": "{Base.black.neutral.300}", + "type": "color" + } } } \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/light.json b/frontend/appflowy_tauri/style-dictionary/tokens/light.json index 98dcb215059f2..173f3d35aafb3 100644 --- a/frontend/appflowy_tauri/style-dictionary/tokens/light.json +++ b/frontend/appflowy_tauri/style-dictionary/tokens/light.json @@ -219,5 +219,15 @@ "type": "dropShadow" }, "type": "boxShadow" + }, + "scrollbar": { + "thumb": { + "value": "{Base.Light.neutral.500}", + "type": "color" + }, + "track": { + "value": "{Base.Light.neutral.100}", + "type": "color" + } } } \ No newline at end of file diff --git a/frontend/appflowy_tauri/vite.config.ts b/frontend/appflowy_tauri/vite.config.ts index a0153b9276e9a..b571cc40dea72 100644 --- a/frontend/appflowy_tauri/vite.config.ts +++ b/frontend/appflowy_tauri/vite.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ }, }), ], - publicDir: '../appflowy_flutter/assets', + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // prevent vite from obscuring rust errors clearScreen: false, diff --git a/frontend/appflowy_web/wasm-libs/Cargo.lock b/frontend/appflowy_web/wasm-libs/Cargo.lock index 36621755a2ba1..bc93a7fbc0c29 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.lock +++ b/frontend/appflowy_web/wasm-libs/Cargo.lock @@ -221,7 +221,7 @@ checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "again", "anyhow", @@ -555,6 +555,7 @@ dependencies = [ "brotli", "bytes", "chrono", + "client-websocket", "collab", "collab-entity", "database-entity", @@ -572,6 +573,7 @@ dependencies = [ "realtime-protocol", "reqwest", "scraper 0.17.1", + "semver", "serde", "serde_json", "serde_repr", @@ -584,10 +586,27 @@ dependencies = [ "url", "uuid", "wasm-bindgen-futures", - "websocket", "yrs", ] +[[package]] +name = "client-websocket" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" +dependencies = [ + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "percent-encoding", + "thiserror", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "cmd_lib" version = "1.9.3" @@ -617,12 +636,13 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-trait", "bincode", "bytes", + "chrono", "js-sys", "parking_lot 0.12.1", "serde", @@ -632,6 +652,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "unicode-segmentation", "web-sys", "yrs", ] @@ -639,7 +660,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "collab", @@ -658,7 +679,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "bytes", @@ -673,7 +694,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "chrono", @@ -699,6 +720,7 @@ dependencies = [ "collab", "collab-entity", "collab-plugins", + "futures", "lib-infra", "parking_lot 0.12.1", "serde", @@ -710,7 +732,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-stream", @@ -748,7 +770,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "collab", @@ -900,7 +922,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.2", + "phf 0.8.0", "smallvec", ] @@ -945,7 +967,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -1291,6 +1313,7 @@ dependencies = [ "bytes", "client-api", "collab-document", + "collab-folder", "collab-plugins", "fancy-regex 0.11.0", "flowy-codegen", @@ -1397,6 +1420,7 @@ dependencies = [ "mime_guess", "parking_lot 0.12.1", "postgrest", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -1698,7 +1722,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "futures-util", @@ -1715,7 +1739,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -2049,7 +2073,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "reqwest", @@ -2778,7 +2802,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", + "phf_macros", "phf_shared 0.8.0", "proc-macro-hack", ] @@ -2798,7 +2822,6 @@ 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", ] @@ -2866,19 +2889,6 @@ 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.48", -] - [[package]] name = "phf_shared" version = "0.8.0" @@ -3307,11 +3317,12 @@ dependencies = [ [[package]] name = "realtime-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", "bytes", + "client-websocket", "collab", "collab-entity", "database-entity", @@ -3321,16 +3332,16 @@ dependencies = [ "realtime-protocol", "serde", "serde_json", + "serde_repr", "thiserror", "tokio-tungstenite", - "websocket", "yrs", ] [[package]] name = "realtime-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", @@ -3663,6 +3674,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "semver" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" + [[package]] name = "serde" version = "1.0.195" @@ -3777,7 +3794,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -4143,9 +4160,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.35.1" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", "bytes", @@ -4716,23 +4733,6 @@ version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" -[[package]] -name = "websocket" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" -dependencies = [ - "futures-channel", - "futures-util", - "http", - "httparse", - "js-sys", - "thiserror", - "tokio", - "tokio-tungstenite", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wee_alloc" version = "0.4.5" @@ -5026,4 +5026,4 @@ dependencies = [ [[patch.unused]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2de6d172f56fed29ee6f32b82040cca4867647ac#2de6d172f56fed29ee6f32b82040cca4867647ac" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml index ab51d15e388a3..fd574f9e8bb54 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ b/frontend/appflowy_web/wasm-libs/Cargo.toml @@ -55,7 +55,7 @@ codegen-units = 1 # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "29a0851f485957cc6410ccf9d261c781c1d2f757" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ab9496c248b7c733d1aa160062abeb66c4e41325" } # Please use the following script to update collab. # Working directory: frontend # @@ -65,10 +65,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "29a # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } diff --git a/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs b/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs index 4d64464d42051..98931395ae6db 100644 --- a/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs +++ b/frontend/appflowy_web/wasm-libs/af-user/src/manager.rs @@ -2,7 +2,7 @@ use crate::authenticate_user::AuthenticateUser; use crate::define::{user_profile_key, user_workspace_key, AF_USER_SESSION_KEY}; use af_persistence::store::{AppFlowyWASMStore, IndexddbStore}; use anyhow::Context; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab_entity::CollabType; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; use collab_integrate::{CollabKVDB, MutexCollab}; @@ -200,7 +200,7 @@ impl UserManager { &self, session: &Session, collab_db: Weak, - raw_data: CollabDocState, + raw_data: Vec, ) -> Result, FlowyError> { let collab_builder = self.collab_builder.upgrade().ok_or(FlowyError::new( ErrorCode::Internal, @@ -212,7 +212,7 @@ impl UserManager { session.user_id, &user_awareness_id.to_string(), CollabType::UserAwareness, - raw_data, + DocStateSource::FromDocState(raw_data), collab_db, CollabBuilderConfig::default().sync_enable(true), ) diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs index 0dfbf45f05f59..6f3c71025ac08 100644 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs +++ b/frontend/appflowy_web/wasm-libs/af-wasm/src/integrate/server.rs @@ -55,8 +55,8 @@ impl CollabCloudPluginProvider for ServerProviderWASM { CollabPluginProviderType::AppFlowyCloud } - fn get_plugins(&self, _context: CollabPluginProviderContext) -> Fut>> { - to_fut(async move { vec![] }) + fn get_plugins(&self, _context: CollabPluginProviderContext) -> Vec> { + vec![] } fn is_sync_enabled(&self) -> bool { diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs b/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs index ab017366bde9d..efe3855f284f6 100644 --- a/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs +++ b/frontend/appflowy_web/wasm-libs/af-wasm/src/lib.rs @@ -49,14 +49,14 @@ pub fn init_wasm_core() -> js_sys::Promise { #[cfg(feature = "localhost_dev")] let config = AFCloudConfiguration { base_url: "http://localhost".to_string(), - ws_base_url: "ws://localhost/ws".to_string(), + ws_base_url: "ws://localhost/ws/v1".to_string(), gotrue_url: "http://localhost/gotrue".to_string(), }; #[cfg(not(feature = "localhost_dev"))] let config = AFCloudConfiguration { base_url: "https://beta.appflowy.cloud".to_string(), - ws_base_url: "wss://beta.appflowy.cloud/ws".to_string(), + ws_base_url: "wss://beta.appflowy.cloud/ws/v1".to_string(), gotrue_url: "https://beta.appflowy.cloud/gotrue".to_string(), }; diff --git a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs b/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs index b8744bdeba6d8..5142d8012f4b8 100644 --- a/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs +++ b/frontend/appflowy_web/wasm-libs/af-wasm/tests/util/tester.rs @@ -24,7 +24,7 @@ impl WASMEventTester { setup_log(); let config = AFCloudConfiguration { base_url: "http://localhost".to_string(), - ws_base_url: "ws://localhost/ws".to_string(), + ws_base_url: "ws://localhost/ws/v1".to_string(), gotrue_url: "http://localhost/gotrue".to_string(), }; let core = Arc::new(AppFlowyWASMCore::new("device_id", config).await.unwrap()); diff --git a/frontend/resources/flowy_icons/16x/m_aa_indent.svg b/frontend/resources/flowy_icons/16x/m_aa_indent.svg index 42dbcd6051b21..43d1d43786e1b 100644 --- a/frontend/resources/flowy_icons/16x/m_aa_indent.svg +++ b/frontend/resources/flowy_icons/16x/m_aa_indent.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_aa_outdent.svg b/frontend/resources/flowy_icons/16x/m_aa_outdent.svg index 43d1d43786e1b..42dbcd6051b21 100644 --- a/frontend/resources/flowy_icons/16x/m_aa_outdent.svg +++ b/frontend/resources/flowy_icons/16x/m_aa_outdent.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_bottom_sheet_close.svg b/frontend/resources/flowy_icons/16x/m_bottom_sheet_close.svg new file mode 100644 index 0000000000000..9be5ee420eb97 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_bottom_sheet_close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/invite_member_link.svg b/frontend/resources/flowy_icons/24x/invite_member_link.svg new file mode 100644 index 0000000000000..c9cf445a8fe7b --- /dev/null +++ b/frontend/resources/flowy_icons/24x/invite_member_link.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_align_center.svg b/frontend/resources/flowy_icons/24x/m_aa_align_center.svg new file mode 100644 index 0000000000000..1a287877df833 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_align_center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_align_left.svg b/frontend/resources/flowy_icons/24x/m_aa_align_left.svg new file mode 100644 index 0000000000000..8b26e2bddfe32 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_align_left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_align_right.svg b/frontend/resources/flowy_icons/24x/m_aa_align_right.svg new file mode 100644 index 0000000000000..54f91608b6017 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_align_right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_code.svg b/frontend/resources/flowy_icons/24x/m_aa_code.svg new file mode 100644 index 0000000000000..5e7ee42d4cfce --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_font_color.svg b/frontend/resources/flowy_icons/24x/m_aa_font_color.svg new file mode 100644 index 0000000000000..919aa91c5963a --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_font_color.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_h1.svg b/frontend/resources/flowy_icons/24x/m_aa_h1.svg new file mode 100644 index 0000000000000..478192490ce96 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_h1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_h2.svg b/frontend/resources/flowy_icons/24x/m_aa_h2.svg new file mode 100644 index 0000000000000..49b99983f5fda --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_h2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_h3.svg b/frontend/resources/flowy_icons/24x/m_aa_h3.svg new file mode 100644 index 0000000000000..0d1a57cd8f8e1 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_h3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_indent.svg b/frontend/resources/flowy_icons/24x/m_aa_indent.svg new file mode 100644 index 0000000000000..6dd3e72a1618a --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_indent.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_math.svg b/frontend/resources/flowy_icons/24x/m_aa_math.svg new file mode 100644 index 0000000000000..0590a34a9d414 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_math.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_outdent.svg b/frontend/resources/flowy_icons/24x/m_aa_outdent.svg new file mode 100644 index 0000000000000..2194ecc2592da --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_outdent.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_paragraph.svg b/frontend/resources/flowy_icons/24x/m_aa_paragraph.svg new file mode 100644 index 0000000000000..5b04dce7f171f --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_paragraph.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_aa_quote.svg b/frontend/resources/flowy_icons/24x/m_aa_quote.svg new file mode 100644 index 0000000000000..01a92b4f99fd6 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_aa_quote.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_bottom_sheet_close.svg b/frontend/resources/flowy_icons/24x/m_bottom_sheet_close.svg new file mode 100644 index 0000000000000..9be5ee420eb97 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_bottom_sheet_close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_aa.svg b/frontend/resources/flowy_icons/24x/m_toolbar_aa.svg new file mode 100644 index 0000000000000..6fb13d985a62d --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_aa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_add.svg b/frontend/resources/flowy_icons/24x/m_toolbar_add.svg new file mode 100644 index 0000000000000..651c3d16382b3 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_add.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_bold.svg b/frontend/resources/flowy_icons/24x/m_toolbar_bold.svg new file mode 100644 index 0000000000000..a3302284a5455 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_bold.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_bulleted_list.svg b/frontend/resources/flowy_icons/24x/m_toolbar_bulleted_list.svg new file mode 100644 index 0000000000000..46dbd0f2fdd48 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_bulleted_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_calendar.svg b/frontend/resources/flowy_icons/24x/m_toolbar_calendar.svg new file mode 100644 index 0000000000000..43a60cfe08e21 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_calendar.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_checkbox.svg b/frontend/resources/flowy_icons/24x/m_toolbar_checkbox.svg new file mode 100644 index 0000000000000..a84bbc94b052d --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_checkbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_image.svg b/frontend/resources/flowy_icons/24x/m_toolbar_image.svg new file mode 100644 index 0000000000000..f3fc20769ea79 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_italic.svg b/frontend/resources/flowy_icons/24x/m_toolbar_italic.svg new file mode 100644 index 0000000000000..7543a1eceb3f7 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_italic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_keyboard.svg b/frontend/resources/flowy_icons/24x/m_toolbar_keyboard.svg new file mode 100644 index 0000000000000..42c7a390b7523 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_keyboard.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_link.svg b/frontend/resources/flowy_icons/24x/m_toolbar_link.svg new file mode 100644 index 0000000000000..7ee84011b9234 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_link.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_numbered_list.svg b/frontend/resources/flowy_icons/24x/m_toolbar_numbered_list.svg new file mode 100644 index 0000000000000..787a05fa0d466 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_numbered_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_redo.svg b/frontend/resources/flowy_icons/24x/m_toolbar_redo.svg new file mode 100644 index 0000000000000..3b521ae091c3e --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_strike.svg b/frontend/resources/flowy_icons/24x/m_toolbar_strike.svg new file mode 100644 index 0000000000000..209ea728c74e0 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_strike.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_underline.svg b/frontend/resources/flowy_icons/24x/m_toolbar_underline.svg new file mode 100644 index 0000000000000..f96282ca4fe38 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_underline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/24x/m_toolbar_undo.svg b/frontend/resources/flowy_icons/24x/m_toolbar_undo.svg new file mode 100644 index 0000000000000..617dac39fe36e --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_toolbar_undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/am-ET.json b/frontend/resources/translations/am-ET.json index 88d526ae095e1..b07649f0e46bd 100644 --- a/frontend/resources/translations/am-ET.json +++ b/frontend/resources/translations/am-ET.json @@ -193,7 +193,7 @@ "editContact": "እውቂያ ያርትዑ" }, "button": { - "OK": "እሺ", + "ok": "እሺ", "cancel": "ይቅር", "signIn": "ይግቡ", "signOut": "ዘግተው ውጣ", @@ -422,13 +422,9 @@ "isComplete": "አይደለም", "isIncomplted": "ባዶ ነው" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "ነው", "isNot": "አይደለም", - "isEmpty": "ባዶ ነው", - "isNotEmpty": "ባዶ አይደለም" - }, - "multiSelectOptionFilter": { "contains": "ይይዛል", "doesNotContain": "አይይዝም", "isEmpty": "ባዶ ነው", @@ -860,4 +856,4 @@ "noResult": "ምንም ውጤቶች የሉም", "caseSensitive": "መጪ" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index b20bdc5e1b9ed..dc41c2149a40a 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -70,12 +70,12 @@ "copyLink": "نسخ الرابط" }, "moreAction": { - "fontSize": "حجم الخط", - "import": "استيراد", - "moreOptions": "المزيد من الخيارات", "small": "صغير", "medium": "متوسط", - "large": "كبير" + "large": "كبير", + "fontSize": "حجم الخط", + "import": "استيراد", + "moreOptions": "المزيد من الخيارات" }, "importPanel": { "textAndMarkdown": "نص و Markdown", @@ -178,9 +178,7 @@ "dragRow": "اضغط مطولاً لإعادة ترتيب الصف", "viewDataBase": "عرض قاعدة البيانات", "referencePage": "تمت الإشارة إلى هذا {name}", - "addBlockBelow": "إضافة كتلة أدناه", - "urlLaunchAccessory": "فتح في المتصفح", - "urlCopyAccessory": "إنسخ الرابط" + "addBlockBelow": "إضافة كتلة أدناه" }, "sideBar": { "closeSidebar": "إغلاق الشريط الجانبي", @@ -488,13 +486,9 @@ "isComplete": "كاملة", "isIncomplted": "غير مكتمل" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "يكون", "isNot": "ليس", - "isEmpty": "فارغ", - "isNotEmpty": "ليس فارغا" - }, - "multiSelectOptionFilter": { "contains": "يتضمن", "doesNotContain": "لا يحتوي", "isEmpty": "فارغ", @@ -625,6 +619,10 @@ "hideComplete": "إخفاء المهام المكتملة", "showComplete": "إظهار كافة المهام" }, + "url": { + "launch": "فتح في المتصفح", + "copy": "إنسخ الرابط" + }, "menuName": "شبكة", "referencedGridPrefix": "نظرا ل" }, @@ -848,10 +846,10 @@ "addToColumnBottomTooltip": "أضف بطاقة جديدة في الأسفل", "renameColumn": "إعادة تسمية", "hideColumn": "اخفاء", - "groupActions": "إجراءات المجموعة", "newGroup": "مجموعة جديدة", "deleteColumn": "مسح", - "deleteColumnConfirmation": "سيؤدي هذا إلى حذف هذه المجموعة وجميع البطاقات الموجودة فيها. هل أنت متأكد من الاستمرار؟" + "deleteColumnConfirmation": "سيؤدي هذا إلى حذف هذه المجموعة وجميع البطاقات الموجودة فيها. هل أنت متأكد من الاستمرار؟", + "groupActions": "إجراءات المجموعة" }, "hiddenGroupSection": { "sectionTitle": "المجموعات المخفية", @@ -1170,4 +1168,4 @@ "addField": "إضافة حقل", "userIcon": "رمز المستخدم" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/ca-ES.json b/frontend/resources/translations/ca-ES.json index acf4f93c97157..26ccc6a5ba3c2 100644 --- a/frontend/resources/translations/ca-ES.json +++ b/frontend/resources/translations/ca-ES.json @@ -69,12 +69,12 @@ "copyLink": "Copiar l'enllaç" }, "moreAction": { - "fontSize": "Mida de la font", - "import": "Importar", - "moreOptions": "Més opcions", "small": "petit", "medium": "mitjà", - "large": "gran" + "large": "gran", + "fontSize": "Mida de la font", + "import": "Importar", + "moreOptions": "Més opcions" }, "importPanel": { "textAndMarkdown": "Text i rebaixa", @@ -176,9 +176,7 @@ "dragRow": "Premeu llargament per reordenar la fila", "viewDataBase": "Veure base de dades", "referencePage": "Es fa referència a aquest {nom}", - "addBlockBelow": "Afegeix un bloc a continuació", - "urlLaunchAccessory": "Oberta al navegador", - "urlCopyAccessory": "Copia l'URL" + "addBlockBelow": "Afegeix un bloc a continuació" }, "sideBar": { "closeSidebar": "Close sidebar", @@ -461,13 +459,9 @@ "isComplete": "està completa", "isIncomplted": "és incompleta" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "És", "isNot": "No és", - "isEmpty": "Està buit", - "isNotEmpty": "No està buit" - }, - "multiSelectOptionFilter": { "contains": "Conté", "doesNotContain": "No conté", "isEmpty": "Està buit", @@ -573,6 +567,10 @@ "addNew": "Afegeix un element", "submitNewTask": "Crear" }, + "url": { + "launch": "Oberta al navegador", + "copy": "Copia l'URL" + }, "menuName": "Quadrícula", "referencedGridPrefix": "Vista de" }, @@ -809,4 +807,4 @@ "deleteContentTitle": "Esteu segur que voleu suprimir {pageType}?", "deleteContentCaption": "si suprimiu aquest {pageType}, podeu restaurar-lo des de la paperera." } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/ckb-KUR.json b/frontend/resources/translations/ckb-KU.json similarity index 99% rename from frontend/resources/translations/ckb-KUR.json rename to frontend/resources/translations/ckb-KU.json index f5509b5ea1ae8..1a46660fc749e 100644 --- a/frontend/resources/translations/ckb-KUR.json +++ b/frontend/resources/translations/ckb-KU.json @@ -356,13 +356,9 @@ "isComplete": "تەواوە", "isIncomplted": "ناتەواوە" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "هەیە", "isNot": "نییە", - "isEmpty": "به‌تاڵه‌", - "isNotEmpty": "بەتاڵ نییە" - }, - "multiSelectOptionFilter": { "contains": "لەخۆ دەگرێت", "doesNotContain": "لەخۆناگرێت", "isEmpty": "به‌تاڵه‌", @@ -673,4 +669,4 @@ "frequentlyUsed": "زۆرجار بەکارت هێناوە" } } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/cs-CZ.json b/frontend/resources/translations/cs-CZ.json new file mode 100644 index 0000000000000..2920856c3f369 --- /dev/null +++ b/frontend/resources/translations/cs-CZ.json @@ -0,0 +1,1097 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "Já", + "welcomeText": "Vítej", + "githubStarText": "Ohvě", + "subscribeNewsletterText": "Přihlásit k odběru newsletteru", + "letsGoButtonText": "Rychlá", + "title": "Název", + "youCanAlso": "Můžete také", + "and": "a", + "blockActions": { + "addBelowTooltip": "Kliknutím přidat pod", + "addAboveCmd": "Alt+kliknutí", + "addAboveMacCmd": "Option+kliknutí", + "addAboveTooltip": "Přidat ", + "dragTooltip": "Posouvejte přetažením", + "openMenuTooltip": "Kliknutím otevřete menu" + }, + "signUp": { + "buttonText": "Regi", + "title": "Zaregistrujte se do @:appName", + "getStartedText": "Začínáme", + "emptyPasswordError": "Heslo nesmí být prázdné", + "repeatPasswordEmptyError": "Heslo pro potvrzení nesmí být prázdné", + "unmatchedPasswordError": "Hesla se neshodují", + "alreadyHaveAnAccount": "Už máte účet?", + "emailHint": "E-mail", + "passwordHint": "Heslo", + "repeatPasswordHint": "Heslo znovu", + "signUpWith": "Zaregistrovat přes:" + }, + "signIn": { + "loginTitle": "Přihlásit do @:appName", + "loginButtonText": "Přihlásit", + "loginStartWithAnonymous": "Začít anonymní sezení", + "continueAnonymousUser": "Pokra", + "buttonText": "Přihlásit se", + "forgotPassword": "Zapomenuté heslo?", + "emailHint": "E-mail", + "passwordHint": "Heslo", + "dontHaveAnAccount": "Nemáte účet?", + "repeatPasswordEmptyError": "Heslo nesmí být prázné", + "unmatchedPasswordError": "Hesla se neshodují", + "syncPromptMessage": "Synchronizace dat může chvilku trvat. Nezavírejte prosím tuto stránku.", + "or": "NEBO", + "LogInWithGoogle": "Přihlásit přes Google", + "LogInWithGithub": "Přihlásit přes GitHub", + "LogInWithDiscord": "Přihlásit přes Discord", + "signInWith": "Přihlásit přes:" + }, + "workspace": { + "chooseWorkspace": "Vyberte pracovní ůplochu", + "create": "Vytvořit pracovní prostor", + "reset": "Resetovat plochu", + "resetWorkspacePrompt": "Obnovením pracovního prostoru smažete všechny stránky a data v nich. Opravdu chcete obnovit pracovní prostor? Pro obnovení pracovního prostoru můžete kontaktovat podporu.", + "hint": "pracovní plocha", + "notFoundError": "Pracovní prostor nenalezen", + "failedToLoad": "Něco se pokazilo! Nepodařilo se načíst pracovní prostor. Zkuste zavřít a znovu otevřít AppFlowy a zkuste to znovu.", + "errorActions": { + "reportIssue": "Nahlásit problém", + "reachOut": "Ozvat se na Discordu" + } + }, + "shareAction": { + "buttonText": "Sdílet", + "workInProgress": "Ji brzy", + "markdown": "Markdown", + "csv": "CSV", + "copyLink": "Kopírovat odkaz" + }, + "moreAction": { + "small": "malé", + "medium": "střední", + "large": "velké", + "fontSize": "Velikost písma", + "import": "Importovat", + "moreOptions": "Ví" + }, + "importPanel": { + "textAndMarkdown": "Text a Markdown", + "documentFromV010": "Dokument z v0.1.0", + "databaseFromV010": "Databázi z v0.1.0", + "csv": "CSV", + "database": "Databáze" + }, + "disclosureAction": { + "rename": "Přejmenovat", + "delete": "Smazat", + "duplicate": "Duplikovat", + "unfavorite": "Odebrat z oblíbených", + "favorite": "Přidat do oblíbených", + "openNewTab": "Otevřít v novém panelu", + "moveTo": "Přesunout do", + "addToFavorites": "Přidat do oblíbených", + "copyLink": "Kopírovat odkaz" + }, + "blankPageTitle": "Prázdná stránka", + "newPageText": "Nová stránka", + "newDocumentText": "Nový dokument", + "newGridText": "Nová mřížka", + "newCalendarText": "Nový kalendář", + "newBoardText": "Nová nástěnka", + "trash": { + "text": "Koš", + "restoreAll": "Obnovit vše", + "deleteAll": "Smazat vše", + "pageHeader": { + "fileName": "Název souboru", + "lastModified": "Změněno", + "created": "Vytvořeno" + }, + "confirmDeleteAll": { + "title": "Opravdu chcete smazat všechny stránky v Koši?", + "caption": "Tento krok nelze vrátit." + }, + "confirmRestoreAll": { + "title": "Opravdu chcete obnovit všechny stránky z Koše?", + "caption": "Tuto kci ne" + }, + "mobile": { + "actions": "Koš - akce", + "empty": "Koš je prázdný", + "emptyDescription": "Nemáte žádný smazaný soubor", + "isDeleted": "je smazaný", + "isRestored": "je " + } + }, + "deletePagePrompt": { + "text": "Tato stránka je v Koši", + "restore": "Obnovit stránku", + "deletePermanent": "Trvale smazat" + }, + "dialogCreatePageNameHint": "Název stránky", + "questionBubble": { + "shortcuts": "Klávesové zkratky", + "whatsNew": "Co je nového?", + "help": "Pomoc a podpora", + "markdown": "Markdown", + "debug": { + "name": "Debug informace", + "success": "Debug informace zkopírovány do schránky!", + "fail": "Nepodařilo se zkopáí" + }, + "feedback": "Zpětná vazba" + }, + "menuAppHeader": { + "moreButtonToolTip": "Smazat, přejmenovat, a další...", + "addPageTooltip": "Rychle přidat podstránku", + "defaultNewPageName": "Nepojmenovaný", + "renameDialog": "Přejmenovat" + }, + "noPagesInside": "Neobsahuje žádné stránky", + "toolbar": { + "undo": "Zpět", + "redo": "Znovu", + "bold": "Tučné", + "italic": "Kurzíva", + "underline": "Podtřžení", + "strike": "Přeškrtnutí", + "numList": "Číslovaný seznam", + "bulletList": "Seznam s odrážkami", + "checkList": "Zaškrtávací seznam", + "inlineCode": "Vložený kód", + "quote": "Blok s citací", + "header": "Nadpis", + "highlight": "Zv", + "color": "Barva", + "addLink": "Přidat odkaz", + "link": "Odkaz" + }, + "tooltip": { + "lightMode": "Přepnout na světlý režim", + "darkMode": "Přepnout na tmavý režim", + "openAsPage": "Otevřít jako stránku", + "addNewRow": "Přidat nový řádek", + "openMenu": "Kliknutím otevřete menu", + "dragRow": "Dlou", + "viewDataBase": "Zobrazit databázi", + "referencePage": "Zdroj", + "addBlockBelow": "Přidat blok pod" + }, + "sideBar": { + "closeSidebar": "Zavřít postranní panel", + "openSidebar": "Otevřít postranní panel", + "personal": "Osobní", + "favorites": "Oblíbené", + "clickToHidePersonal": "Kliknutím schováte sekci Osobní", + "clickToHideFavorites": "Kliknutím schováte sekci Oblíbené", + "addAPage": "Přidat stránku", + "recent": "Nedávné" + }, + "notifications": { + "export": { + "markdown": "Poznámka exportována do Markdownu", + "path": "Dokumenty/flowy" + } + }, + "contactsPage": { + "title": "KontaktyCo se ", + "whatsHappening": "Co se děje v tomto týdnu?", + "addContact": "Přidat kontakt", + "editContact": "Upravit kontakt" + }, + "button": { + "ok": "OK", + "done": "Hotovo", + "cancel": "Zrušit", + "signIn": "Přihlásit se", + "signOut": "Odhlásit se", + "complete": "Dokončit", + "save": "Uložit", + "generate": "Vygenerovat", + "esc": "ESC", + "keep": "Z", + "tryAgain": "Zkusit znovu", + "discard": "Zahodit", + "replace": "Nahradit", + "insertBelow": "Vložit pod", + "insertAbove": "Vložit nad", + "upload": "Nahrát", + "edit": "Upravit", + "delete": "Smazat", + "duplicate": "Duplikovat", + "putback": "Odložit", + "update": "Aktualizovat", + "share": "Sdílet", + "removeFromFavorites": "Odstranit z oblíbených", + "addToFavorites": "Přidat do oblíbených", + "rename": "Přejmenovat", + "helpCenter": "Centrum pomoci" + }, + "label": { + "welcome": "Vítejte!", + "firstName": "Křestní jméno", + "middleName": "Prostřední jméno", + "lastName": "Příjmení", + "stepX": "Krok {X}" + }, + "oAuth": { + "err": { + "failedTitle": "Nepodařilo se připojit k Vašemu účtu.", + "failedMsg": "Ujistěte se prosím, že jste se v prohlížeči přihlásili." + }, + "google": { + "title": "Goohle přihlašování", + "instruction1": "Tuto aplikaci musíte autorizovat, aby mohla importovat Vaše kontakty z Google Contacts.", + "instruction2": "Zkopírujte tento k=od do schránky kliknutím na ikonku nebo označením textu", + "instruction3": "Přejděte na tento odkaz kam vložte kód:", + "instruction4": "Po dokončení registrace stiskněte tlačítko níže:" + } + }, + "settings": { + "title": "Nastavení", + "menu": { + "appearance": "Vzhled", + "language": "Jazyk", + "user": "Uživatel", + "files": "Soubory", + "notifications": "Upozornění", + "open": "Otevřít nastavení", + "logout": "Odhlásit se", + "logoutPrompt": "Opravdu se chcete odhlásit?", + "selfEncryptionLogoutPrompt": "Opravdu se chcete odhlásit? Ujistěte se prosím, že jste si zkopírovali šifrovací klíč", + "syncSetting": "Synchronizovat nastavení", + "enableSync": "Zapnout synchronizaci", + "enableEncrypt": "Šifrovat data", + "cloudURL": "URL adresa serveru", + "enableEncryptPrompt": "Zapněte šifrování a zabezpečte svá ", + "inputEncryptPrompt": "Vložte prosím Váš šifrovací klíč k", + "clickToCopySecret": "Kliknutím zkopírujete šifrovací klíč", + "inputTextFieldHint": "Váš klíč", + "historicalUserList": "Historie přihlášení uživatele", + "historicalUserListTooltip": "V tomto seznamu vidíte anonymní účty. Kliknutím na účet zobrazíte jeho detaily. Anonymní účty vznikají kliknutím na tlačítko \"Začínáme\"", + "openHistoricalUser": "Kliknutím založíte anonymní účet", + "customPathPrompt": "Uložením složky s daty AppFlowy ve složce synchronizovanéí jako např. Google Drive může nést rizika. Pokud se databáze v složce navštíví nebo změní ", + "cloudSetting": "Nastavení cloudu" + }, + "notifications": { + "enableNotifications": { + "label": "Po", + "hint": "Vypn" + } + }, + "appearance": { + "resetSetting": "Obnovit tato nastavení", + "fontFamily": { + "label": "Písmo", + "search": "Vyhledávání" + }, + "themeMode": { + "label": "Téma vzhledu", + "light": "Světlý vzhled", + "dark": "Tmavý vzhled", + "system": "Přizpůsobit systému" + }, + "layoutDirection": { + "label": "Směr zobrazení", + "hint": "Ovládejte tok obsahu na ", + "ltr": "Zleva doprava", + "rtl": "Zprava doleva" + }, + "textDirection": { + "label": "Výchozí směr textu", + "hint": "Vyberte, jestli má text ve výchozím nastavení začínat zprava nebo zleva.", + "ltr": "Zleva doprava", + "rtl": "Zprava doleva", + "auto": "Automaticky", + "fallback": "Stejné jako směr rozvržení" + }, + "themeUpload": { + "button": "Nahrát", + "uploadTheme": "Nahrát motiv vzhledu", + "description": "Nahrajte vlastní motiv vzhledu pro AppFlowy stisknutím tlačítka níže.", + "loading": "Prosím počkejte dokud nedokončíme kontrolu a nahrávání vašeho motivu vzhledu...", + "uploadSuccess": "Váš motiv vzhledu byl úspěšně nahrán", + "deletionFailure": "Nepodařilo se smazat motiv vzhledu. Zkuste ho smazat ručně.", + "filePickerDialogTitle": "Vyberte soubor typu .flowy_plugin", + "urlUploadFailure": "Nepodařilo se otevřít URL adresu: {}", + "failure": "Nahrané téma vzhledu má neplatný formát." + }, + "theme": "Motiv vzhledu", + "builtInsLabel": "Vestavěné motivy vzhledu", + "pluginsLabel": "Dopl", + "dateFormat": { + "label": "Formát data", + "local": "Místní", + "us": "US", + "iso": "ISO", + "friendly": "Přátelský", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "Formát času", + "twelveHour": "12hodinový", + "twentyFourHour": "24hodinový" + }, + "showNamingDialogWhenCreatingPage": "Zobrazit d" + }, + "files": { + "copy": "Kopíá", + "defaultLocation": "Umístění pro čtení a ukládání dat", + "exportData": "Exportovat data", + "doubleTapToCopy": "Dvojitým klepnutím zkopírujete cestu", + "restoreLocation": "Obnovit výchozí AppFlowy cestu", + "customizeLocation": "OtevřítProsím tre další složku", + "restartApp": "Aby se projevily změny, restartujte prosím aplikaci.", + "exportDatabase": "Exportovat databázi", + "selectFiles": "Vyberte soubory k exportování", + "selectAll": "Označit vše", + "deselectAll": "Odznačit vše", + "createNewFolder": "V", + "createNewFolderDesc": "Řekněte nám, kam uložit Vaše data", + "defineWhereYourDataIsStored": "Vyberte kde jsou ukládána Vaše data", + "open": "Otevřít", + "openFolder": "Otevřít existující složku", + "openFolderDesc": "Číst a zapisovat do existující AppFlowy složky", + "folderHintText": "název složky", + "location": "Vytváření nové složky", + "locationDesc": "Vyberte název pro složku, kam bude AppFlowy ukládat Vaše data", + "browser": "Procházet", + "create": "Vytvořit", + "set": "Nastavit", + "folderPath": "Umístění složkyU", + "locationCannotBeEmpty": "Umístění nesmí být prázdné", + "pathCopiedSnackbar": "Umístění souboru zkopírováno do schránky", + "changeLocationTooltips": "Změnit složku pro ukládání dat", + "change": "Změnit", + "openLocationTooltips": "Otevřít jinou složku pro ukládání dat", + "openCurrentDataFolder": "Otevřít současnou složku pro ukládání dat", + "recoverLocationTooltips": "Obnovit výchozí složku s daty ", + "exportFileSuccess": "Soubor byl úspěšně exportován!", + "exportFileFail": "Soubor se nepodařilo exportovat!", + "export": "Exportovat" + }, + "user": { + "name": "Jméno", + "email": "E-mail", + "tooltipSelectIcon": "Vyberte ikonu", + "selectAnIcon": "Vyberte ikonu", + "pleaseInputYourOpenAIKey": "Prosím vložte svůj OpenAI klíč", + "pleaseInputYourStabilityAIKey": "Prosím vložte svůj Stability AI klíč", + "clickToLogout": "Klin" + }, + "shortcuts": { + "shortcutsLabel": "Klávesové zkratky", + "command": "Příkaz", + "keyBinding": "Přiřazená klávesa", + "addNewCommand": "Přidat nový příkaz", + "updateShortcutStep": "Stiskněte požadovanou kombinaci kláves a stiskněte ENTER", + "shortcutIsAlreadyUsed": "Tato zkratka je již použita pro: @@", + "resetToDefault": "Obnovit výchozí klávesové zkratky", + "couldNotLoadErrorMsg": "Nepodařilo se načíst klávesové zkratky, zkuste to znovu", + "couldNotSaveErrorMsg": "Nepodařilo seuložit klávesové zkta" + }, + "mobile": { + "personalInfo": "Osobní informace", + "username": "Uživatelské jméno", + "usernameEmptyError": "Uživatelské jméno nesmí být prázdné", + "about": "O aplikaci", + "pushNotifications": "Push upozornění", + "support": "Podpora", + "joinDiscord": "Přidejte se k nám na Discordu", + "privacyPolicy": "Podmínky použití osobních údajů", + "userAgreement": "Uživatels", + "userprofileError": "Nepodařilo se načíst uživatelský profil", + "userprofileErrorDescription": "Prosím zkuste se odhlásit a znovu přihlásit a zkontrolujte, zda problém přetrvává" + } + }, + "grid": { + "deleteView": "Opravdu chcete tento pohled odstranit?", + "createView": "Nový", + "title": { + "placeholder": "Bez nýzvu" + }, + "settings": { + "filter": "Filtr", + "sort": "Řadit", + "sortBy": "Řadit podle", + "properties": "Vlastnosti", + "reorderPropertiesTooltip": "Přetažením uspořádáte vlastnosti", + "group": "Skupiny", + "addFilter": "Přidat filtr", + "deleteFilter": "Smazat filtr", + "filterBy": "Filtrovat podle...", + "typeAValue": "Napište hodnotu...", + "layout": "Rozložení", + "databaseLayout": "Rozložení" + }, + "textFilter": { + "contains": "Obsahuje", + "doesNotContain": "Neobsahuje", + "endsWith": "Končí", + "startWith": "Začíná", + "is": "Je", + "isNot": "Není", + "isEmpty": "Je pr", + "isNotEmpty": "Není prázdné", + "choicechipPrefix": { + "isNot": "Kromě", + "startWith": "Začíná", + "endWith": "Končí", + "isEmpty": "je prázdné", + "isNotEmpty": "není prázdné" + } + }, + "checkboxFilter": { + "isChecked": "Zaškrtnuto", + "isUnchecked": "Nezaškrtnuto", + "choicechipPrefix": { + "is": "je" + } + }, + "checklistFilter": { + "isComplete": "je hotový", + "isIncomplted": "není hotový" + }, + "singleSelectOptionFilter": { + "is": "Je", + "isNot": "Není", + "isEmpty": "Je prázdné", + "isNotEmpty": "Není prázdné" + }, + "multiSelectOptionFilter": { + "contains": "Obsahuje", + "doesNotContain": "Neobsahuje", + "isEmpty": "Je prázdné", + "isNotEmpty": "Není prázdný" + }, + "field": { + "hide": "Schovat", + "show": "Ukázat", + "insertLeft": "Vložit vlevo", + "insertRight": "Vložit vpravo", + "duplicate": "Duplikovat", + "delete": "Smazat", + "textFieldName": "Text", + "checkboxFieldName": "Zaškrtávací políčko", + "dateFieldName": "Datum", + "updatedAtFieldName": "Datum poslední úpravy", + "createdAtFieldName": "Datum vytvoření", + "numberFieldName": "Čísla", + "singleSelectFieldName": "Výběr", + "multiSelectFieldName": "Multivýběr", + "urlFieldName": "URL adresa", + "checklistFieldName": "Zaškrtávací seznam", + "numberFormat": "Formát čásel", + "dateFormat": "Formát data", + "includeTime": "Včetně času", + "isRange": "Datum kon", + "dateFormatFriendly": "Měsíc Den, Rok", + "dateFormatISO": "Rok-Měsíc-Den", + "dateFormatLocal": "Měsíc/Den/Rok", + "dateFormatUS": "Rok/Měsíc/Day", + "dateFormatDayMonthYear": "Den/Měsíc/Rok", + "timeFormat": "Formát času", + "invalidTimeFormat": "Neplatný formát", + "timeFormatTwelveHour": "12hodinový", + "timeFormatTwentyFourHour": "24hodinový", + "clearDate": "Vyčistit datum", + "dateTime": "Vyčistit čas", + "startDateTime": "Datum a čas začátku", + "endDateTime": "Datum a čas konce", + "failedToLoadDate": "Nepodařilo načíst datum", + "selectTime": "Vyberte čas", + "selectDate": "Vyberte datum", + "visibility": "Viditelnost", + "propertyType": "Typ vlastnosti", + "addSelectOption": "Přidat možnost", + "optionTitle": "Možnosti", + "addOption": "Přidat možnost", + "editProperty": "Upravit vlastnost", + "newProperty": "Nová vlastnost", + "deleteFieldPromptMessage": "Jste si jistí? Tato vlastnost bude smazána", + "newColumn": "Nový sloupeček" + }, + "rowPage": { + "newField": "Přidat nové pole", + "fieldDragElementTooltip": "Kli", + "showHiddenFields": { + "one": "Zobrazit {} skryté pole", + "many": "Zobrazit {} skrytá pole", + "other": "Zobrazit {} skrytá pole" + }, + "hideHiddenFields": { + "one": "Skrýt {} skryté pole", + "many": "Skrýt {} skrytá pole", + "other": "Skrýt {} skrytá pole" + } + }, + "sort": { + "ascending": "Vzestupně", + "descending": "Sestupně", + "deleteAllSorts": "Smazat všechna řazení", + "addSort": "Přidat řazení" + }, + "row": { + "duplicate": "Duplikovat", + "delete": "Smazat", + "titlePlaceholder": "Bez názvu", + "textPlaceholder": "Prázdné", + "copyProperty": "Vlastnost zkopírována do schránky", + "count": "Počet", + "newRow": "Nový řádek", + "action": "Akce", + "add": "Kliknutím přidáte pod", + "drag": "Přetáhnout podržením", + "dragAndClick": "Přetáhnout podržením, kliknutí otevírá menu", + "insertRecordAbove": "Vložte záznam nad", + "insertRecordBelow": "Vložte záznam pod" + }, + "selectOption": { + "create": "Vytvořit", + "purpleColor": "Fialová", + "pinkColor": "Růžová", + "lightPinkColor": "Světle růžová", + "orangeColor": "Oranžová", + "yellowColor": "Žlutá", + "limeColor": "Limetková", + "greenColor": "Zelená", + "aquaColor": "Akvamarínová", + "blueColor": "Modrá", + "deleteTag": "Smazat štítek", + "colorPanelTitle": "Barvy", + "panelTitle": "Vyberte nebo vytvořte možnost", + "searchOption": "Hledat možnost", + "searchOrCreateOption": "Hledat nebo vytvořit možnost...", + "createNew": "Vytvořit novou", + "orSelectOne": "Nebo vybrat možnost" + }, + "checklist": { + "taskHint": "Popis úkolu", + "addNew": "Přidat úkol", + "submitNewTask": "Vytvořit", + "hideComplete": "Skrýt dokončené úkoly", + "showComplete": "Zobrazit všechny úkoly" + }, + "menuName": "Mřížka", + "referencedGridPrefix": "Pohled na" + }, + "document": { + "menuName": "Dokument", + "date": { + "timeHintTextInTwelveHour": "01:00 Odpoledne", + "timeHintTextInTwentyFourHour": "13:00" + }, + "slashMenu": { + "board": { + "selectABoardToLinkTo": "Propojit s nástěnkou", + "createANewBoard": "Vytvořit novou nástěnku" + }, + "grid": { + "selectAGridToLinkTo": "Propojit s mřížkou", + "createANewGrid": "Vytvořit mřížku" + }, + "calendar": { + "selectACalendarToLinkTo": "Propojit s kalendářem", + "createANewCalendar": "Vyto" + }, + "document": { + "selectADocumentToLinkTo": "Propojit s dokumentem" + } + }, + "selectionMenu": { + "outline": "Obrys", + "codeBlock": "Blok kódu" + }, + "plugins": { + "referencedBoard": "Odkazovaná nástěnka", + "referencedGrid": "Odkazovaná mřížka", + "referencedCalendar": "Odkazovaný kalendář", + "referencedDocument": "Odkazovaný dokument", + "autoGeneratorMenuItemName": "OpenAI Writer", + "autoGeneratorTitleName": "OpenAI: Zeptej se AI na cokoliv...", + "autoGeneratorLearnMore": "Zjistit více", + "autoGeneratorGenerate": "Vygenerovat", + "autoGeneratorHintText": "Zeptat se OpenAI...", + "autoGeneratorCantGetOpenAIKey": "Nepodařilo se získat klíč OpenAI", + "autoGeneratorRewrite": "Přepsat", + "smartEdit": "AI asistenti", + "openAI": "OpenAI", + "smartEditFixSpelling": "Opravit pravopis", + "warning": "⚠️ odpovědi AI mohou být nepřesné nebo zavádějící.", + "smartEditSummarize": "Shrnout", + "smartEditImproveWriting": "Vylepšit styl psaní", + "smartEditMakeLonger": "Prodloužit", + "smartEditCouldNotFetchResult": "Nepodařilo se stáhnout výsledek z OpenAI", + "smartEditCouldNotFetchKey": "Nepodařilo se stáhnout klíč OpenAI", + "smartEditDisabled": "Propojit s OpenAI v Nastavení", + "discardResponse": "Opravdu chcete zahodit odpovědi od AI?", + "createInlineMathEquation": "Vytvořit rovnici", + "fonts": "Písma", + "toggleList": "Rozbalovací seznam", + "quoteList": "Seznam citátů", + "numberedList": "Číslovaný seznam", + "bulletedList": "Odrážkový seznam", + "todoList": "Úkolníček", + "callout": "Vyhlásit", + "cover": { + "changeCover": "Změnit přebal", + "colors": "Barvy", + "images": "Obrázky", + "clearAll": "Vyčistit vše", + "abstract": "Abstraktní", + "addCover": "Přidat přebal", + "addLocalImage": "Přidat obrázek z lokálního úložiště", + "invalidImageUrl": "URL adresa obrázku je neplatná", + "failedToAddImageToGallery": "Nepodařilo se přidat obrázek do galerie", + "enterImageUrl": "Zadejte URL adresu obrázku", + "add": "Přidat", + "back": "Zpět", + "saveToGallery": "Uložit do galerie", + "removeIcon": "Smazat ikonu", + "pasteImageUrl": "Vložit URL adresu obrázku", + "or": "NEBO", + "pickFromFiles": "Vyberte ze souborů", + "couldNotFetchImage": "Nepodařilo se načíst obrázky", + "imageSavingFailed": "Ukládání obrázku se nezdařilo", + "addIcon": "Přidat ikonu", + "changeIcon": "Změnit ikonu", + "coverRemoveAlert": "Po smazání bude odstraněno také z přebalu.", + "alertDialogConfirmation": "Jste si jistí, že chcete pokračovat?" + }, + "mathEquation": { + "name": "Matematick", + "addMathEquation": "Přidat TeX ", + "editMathEquation": "Upravit matematickou rovnici" + }, + "optionAction": { + "click": "Kliknutím", + "toOpenMenu": " otevřete menu", + "delete": "Smazat", + "duplicate": "Duplikovat", + "turnInto": "Změnit na", + "moveUp": "Posunout nahoru", + "moveDown": "Posunout dolů", + "color": "Barva", + "align": "Zarovnání", + "left": "Vlevo", + "center": "Doprostřed", + "right": "Vpravo", + "defaultColor": "Výchozí" + }, + "image": { + "copiedToPasteBoard": "Odkaz na obrázek byl zkopírován do schránky", + "addAnImage": "Přidat obrázek" + }, + "outline": { + "addHeadingToCreateOutline": "Přidáním nadpisů vytvoříte obsah dokumentu" + }, + "table": { + "addAfter": "Přidat za", + "addBefore": "Přidat před", + "delete": "Smazat", + "clear": "Vyčistit obsah", + "duplicate": "Duplikovat", + "bgColor": "Barva pozadí" + }, + "contextMenu": { + "copy": "Kopírovat", + "cut": "Vyjmout", + "paste": "Vložit" + }, + "action": "Příkazy" + }, + "textBlock": { + "placeholder": "Napište \"/\" pro zadání příkazu" + }, + "title": { + "placeholder": "Bez názvu" + }, + "imageBlock": { + "placeholder": "Kliknutím přidáte obrázek", + "upload": { + "label": "Nahrát", + "placeholder": "Kliknutím nahrajete obrázek" + }, + "url": { + "label": "URL adresa obrázku", + "placeholder": "Vlože URL adresu obrázku" + }, + "ai": { + "label": "Vygenerujte obrázek pomocí OpenAI", + "placeholder": "Prosím vlo" + }, + "stability_ai": { + "label": "Generovat obrázek ze Stability AI", + "placeholder": "Zadejte prosím prompt pro generování obrázku pomocí Stability AI" + }, + "support": "Maximální velikost obrázku je 5MB. Podporované formáty: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "Neplatný obrázek", + "invalidImageSize": "Velikost obrázku musí být menší než 5MB", + "invalidImageFormat": "Formát obrázku není podporovaný. Podporované formáty: JPEG, PNG, GIF, SVG", + "invalidImageUrl": "Neplatná URL adresa obrázku" + }, + "embedLink": { + "label": "Vložit odkaz (embed)", + "placeholder": "Vložte nebo napište odkaz na obrázek" + }, + "searchForAnImage": "Hledat obrázek", + "pleaseInputYourOpenAIKey": "zadejte prosím svůj OpenAI klíč v Nastavení", + "pleaseInputYourStabilityAIKey": "prosím vložte svůjStability AI klíč v Nastavení", + "saveImageToGallery": "Uložit obrázek", + "failedToAddImageToGallery": "Nepodařilo se přidat obrázek do galerie", + "successToAddImageToGallery": "Obrázek byl úspěšně přidán do galerie", + "unableToLoadImage": "Nepodařilo se nahrát obrázek" + }, + "codeBlock": { + "language": { + "label": "Jazyk", + "placeholder": "Vyberte jazyk" + } + }, + "inlineLink": { + "placeholder": "Vložte nebo napište odkaz", + "openInNewTab": "Otevřít v novém panelu", + "copyLink": "Kopírovat odkaz", + "removeLink": "Odstranit odkaz", + "url": { + "label": "URL adresa odkazu", + "placeholder": "Zadejte URL adresu odkazu" + }, + "title": { + "label": "Název odkazu", + "placeholder": "Zadejte název odkazu" + } + }, + "mention": { + "placeholder": "Označit člověka, stránku nebo datum...", + "page": { + "label": "Odkaz na stránku", + "tooltip": "Kliknutím otevřete stránku" + } + }, + "toolbar": { + "resetToDefaultFont": "Obnovit výchozí" + }, + "errorBlock": { + "theBlockIsNotSupported": "Aktuální verze tento blok nepodporuje.", + "blockContentHasBeenCopied": "Obsah bloku byl zkopírován." + } + }, + "board": { + "column": { + "createNewCard": "Nová", + "renameGroupTooltip": "Zmáčknutím přejmenujete skupinu", + "createNewColumn": "Přidat novou skupinu", + "addToColumnTopTooltip": "Přidá novou kartičku nahoru", + "renameColumn": "Přejmenovat", + "hideColumn": "Skrýt" + }, + "hiddenGroupSection": { + "sectionTitle": "Skryté skupiny", + "collapseTooltip": "Skrýt skryté skupiny", + "expandTooltip": "Zobrazit skryté skupiny" + }, + "cardDetail": "Detail kartičky", + "cardActions": "Kartička - příkazy", + "cardDuplicated": "Kartička byla duplikována", + "cardDeleted": "Kartička smazána", + "menuName": "Nástěnka", + "showUngrouped": "Zobrazit položky bez skupiny", + "ungroupedButtonText": "Bez skupiny", + "ungroupedButtonTooltip": "Obsahuje karty, které ", + "ungroupedItemsTitle": "Kliknutím přidáte na nástěnku", + "groupBy": "Seskupit podle", + "referencedBoardPrefix": "Pohled", + "mobile": { + "editURL": "Upravit URL adresu" + } + }, + "calendar": { + "menuName": "Kalendář", + "defaultNewCalendarTitle": "Nepojmenovaný", + "newEventButtonTooltip": "Přidat novou událost", + "navigation": { + "today": "Dnes", + "jumpToday": "Přejít na dnešek", + "previousMonth": "Předchozí měsíc", + "nextMonth": "Následující měsíc" + }, + "settings": { + "showWeekNumbers": "Zobrazit číslo týdne", + "showWeekends": "Zobrazit víkendy", + "firstDayOfWeek": "Počátek týdne", + "layoutDateField": "Rozložit kalendář podle", + "noDateTitle": "Žádné datum", + "noDateHint": { + "zero": "Zde uvidíte nenaplánované události", + "one": "{} nenaplánovaná událost", + "other": "{} nenaplánovaných událostí" + }, + "clickToAdd": "Přidat do kalendáře", + "name": "Rozložení kalendáře" + }, + "referencedCalendarPrefix": "Pohled na" + }, + "errorDialog": { + "title": "Chyba AppFlowy", + "howToFixFallback": "Omlouváme se za nepříjemnost! Pošlete hlášení na náš GitHub, kde popíšete chybu na kterou jste narazili.", + "github": "Zobrazit na GitHubu" + }, + "search": { + "label": "Hledat", + "placeholder": { + "actions": "Hledat - příkazy..." + } + }, + "message": { + "copy": { + "success": "Zkopírováno!", + "fail": "Nepodařilo se zkopírovat" + } + }, + "unSupportBlock": "Aktuální verze tento blok nepodporuje.", + "views": { + "deleteContentTitle": "Opravdu chcete smazat {pageType}?", + "deleteContentCaption": "pokud " + }, + "colors": { + "custom": "Vlastní", + "default": "Výchozí", + "red": "Červená", + "orange": "Oranžová", + "yellow": "Žlutá", + "green": "Zelená", + "blue": "Modrá", + "purple": "Fialová", + "pink": "Růžová", + "brown": "Hnědá", + "gray": "Šedá" + }, + "emoji": { + "emojiTab": "Emoji", + "search": "Hledat emoji", + "noRecent": "Žádné nedávné emoji", + "noEmojiFound": "Nenalezeno žádné emoji", + "filter": "Filtr", + "random": "Náhodný", + "selectSkinTone": "Vybrat tón pleti", + "remove": "Smazat emoji", + "categories": { + "smileys": "Smajlíci a emoce", + "people": "Lidé a tělo", + "animals": "Zvířata a příroda", + "food": "Jídlo a pití", + "activities": "Aktivity", + "places": "Cestování a místa", + "objects": "Věci", + "symbols": "Symboly", + "flags": "Vlajky", + "nature": "Příroda", + "frequentlyUsed": "Často používané" + }, + "skinTone": { + "default": "Výchozí", + "light": "Světlý", + "mediumLight": "Středně světlý", + "medium": "Střední", + "mediumDark": "Středně tmavý", + "dark": "Tmavý" + } + }, + "inlineActions": { + "noResults": "Žádné výsledky", + "pageReference": "Odkazovaná stránka", + "date": "Datum", + "reminder": { + "groupTitle": "Připomenutí", + "shortKeyword": "připomenout" + } + }, + "datePicker": { + "dateTimeFormatTooltip": "Formát data a času změníte v Nastavení" + }, + "relativeDates": { + "yesterday": "Včera", + "today": "Dnes", + "tomorrow": "Zítra", + "oneWeek": "1 týden" + }, + "notificationHub": { + "title": "Upozornění", + "emptyTitle": "Nic Vám neuteklo!", + "emptyBody": "Žádné nevyřízené akce nebo upozornění. Užijte si klid.", + "tabs": { + "inbox": "Schránka", + "upcoming": "Nadcházející" + }, + "actions": { + "markAllRead": "Označit vše jako přečtené", + "showAll": "Vše", + "showUnreads": "Nepřečtené" + }, + "filters": { + "ascending": "Vzestupně", + "descending": "Sestupně", + "groupByDate": "Seskupit podle data", + "showUnreadsOnly": "Zobrazit pouze nepřečtené", + "resetToDefault": "Obnovit výchozí" + } + }, + "reminderNotification": { + "title": "Upomínka", + "message": "Nezapomeňte to zkontrolovat než zapomenete!", + "tooltipDelete": "Smazat", + "tooltipMarkRead": "Označit jako přečtené", + "tooltipMarkUnread": "Označit jako nepřečtené" + }, + "findAndReplace": { + "find": "Najít", + "previousMatch": "Předchozí shoda", + "nextMatch": "Další shoda", + "close": "Zavřít", + "replace": "Nahradit", + "replaceAll": "Nahradit vše", + "noResult": "Žádné výsledky", + "caseSensitive": "Citlivý na malá/velká písmena" + }, + "error": { + "weAreSorry": "Omluváme se", + "loadingViewError": "Nedaří se nám načíst tento pohled. Zkontrolujte prosím Vaše internetové připojení, obnovte aplikaci a neváhejte nás kontaktovat pokud problém přetrvává." + }, + "editor": { + "bold": "Tučné", + "bulletedList": "Odrážkový seznam", + "checkbox": "Zaškrtávací políčko", + "embedCode": "Vložit kód (embed)", + "heading1": "Nadpis 1", + "heading2": "Nadpis 2", + "heading3": "Nadpis 3", + "highlight": "Zvýrazenění", + "color": "Barva", + "image": "Obrázek", + "italic": "Kurzíva", + "link": "Odkaz", + "numberedList": "Číslovaný seznam", + "quote": "Citace", + "strikethrough": "Přeškrtnutí", + "text": "Text", + "underline": "Podtržení", + "fontColorDefault": "Výchozí", + "fontColorGray": "Šedá", + "fontColorBrown": "Hnědá", + "fontColorOrange": "Oranžová", + "fontColorYellow": "Žlutá", + "fontColorGreen": "Zelená", + "fontColorBlue": "Modrá", + "fontColorPurple": "Fialová", + "fontColorPink": "Růžová", + "fontColorRed": "Červená", + "backgroundColorDefault": "Výchozí pozadí", + "backgroundColorGray": "Šedé pozadí", + "backgroundColorBrown": "Hnědé pozadí", + "backgroundColorOrange": "Oranžové pozadí", + "backgroundColorYellow": "Žluté pozadí", + "backgroundColorGreen": "Zelené pozadí", + "backgroundColorBlue": "Modré pozadí", + "backgroundColorPurple": "Fialové pozadí", + "backgroundColorPink": "Růžové pozadí", + "backgroundColorRed": "Červené pozadí", + "done": "Hotovo", + "cancel": "Zrušit", + "tint1": "Odstín 1", + "tint2": "Odstín 2", + "tint3": "Odstín 3", + "tint4": "Odstín 4", + "tint5": "Odstín 5", + "tint6": "Odstín 6", + "tint7": "Odstín 7", + "tint8": "Odstín 8", + "tint9": "Odstín 9", + "lightLightTint1": "Fialová", + "lightLightTint2": "Růžová", + "lightLightTint3": "Lehce růžová", + "lightLightTint4": "Oranžová", + "lightLightTint5": "Žlutá", + "lightLightTint6": "Limetková", + "lightLightTint7": "Zelená", + "lightLightTint8": "Akvamarínová", + "lightLightTint9": "Modrá", + "urlHint": "URL adresa", + "mobileHeading1": "Nadpis 1", + "mobileHeading2": "Nadpis 2", + "mobileHeading3": "Nadpis 3", + "textColor": "Barva textu", + "backgroundColor": "Barva pozadí", + "addYourLink": "Přidejte odkaz", + "openLink": "Otevřít odkaz", + "copyLink": "Kopírovat odkaz", + "removeLink": "Smazat odkaz", + "editLink": "Upravit odkaz", + "linkText": "Text", + "linkTextHint": "Zadejte prosím text", + "linkAddressHint": "Zadejte prosím URL adresu", + "highlightColor": "Barva zvýraznění", + "clearHighlightColor": "Obnovit barvu zvýraznění", + "customColor": "Vlastní barva", + "hexValue": "Hex hodnota", + "opacity": "Průhlednost", + "resetToDefaultColor": "Obnovit výchozí barvu", + "ltr": "Zleva doprava", + "rtl": "Zprava doleva", + "auto": "Automaticky", + "cut": "Vyjmout", + "copy": "Kopírovat", + "paste": "Vložit", + "find": "Najít", + "previousMatch": "Předchozí shoda", + "nextMatch": "Další shoda", + "closeFind": "Zavřít", + "replace": "Nahradit", + "replaceAll": "Nahradit vše", + "regex": "Regulární výraz", + "caseSensitive": "Citlivý na malá/velká písmena", + "uploadImage": "Nahrát obrázek", + "urlImage": "URL adresa obrázku", + "incorrectLink": "Nesprávný odkaz", + "upload": "Nahrát", + "chooseImage": "Vyberte obrázek", + "loading": "Načítání", + "imageLoadFailed": "Nepodařilo se načíst obrázek", + "divider": "Oddělovač", + "table": "Tabulka", + "colAddBefore": "Přidat před", + "rowAddBefore": "Přidat za", + "colAddAfter": "Přidat po", + "rowAddAfter": "Přidat po", + "colRemove": "Odstranit", + "rowRemove": "Odstranit", + "colDuplicate": "Duplikovat", + "rowDuplicate": "Duplikovat", + "colClear": "Vyčistit obsah", + "rowClear": "Vyčistit obsah", + "slashPlaceHolder": "Zadejte \"/\" pro vložení bloku, nebo začněte psát" + }, + "favorite": { + "noFavorite": "Žádné oblíbené stránky", + "noFavoriteHintText": "Swipnutím doleva přidáte stránku do oblíbených" + }, + "cardDetails": { + "notesPlaceholder": "Napište / k " + }, + "blockPlaceholders": { + "todoList": "Úkolníček", + "bulletList": "Seznam", + "numberList": "Seznam", + "quote": "Citace", + "heading": "Nadpis {}" + }, + "titleBar": { + "pageIcon": "Ikona stránky", + "language": "Jazyk", + "font": "Písmo", + "actions": "Příkazy" + } +} \ No newline at end of file diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index 79d1a26434909..d6b0ec368e5bc 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -2,31 +2,33 @@ "appName": "AppFlowy", "defaultUsername": "Ich", "welcomeText": "Willkommen bei @:appName", + "welcomeTo": "Willkommen zu", "githubStarText": "Mit einem Stern auf GitHub markieren", "subscribeNewsletterText": "Abonniere den Newsletter", "letsGoButtonText": "Los geht's", "title": "Titel", "youCanAlso": "Du kannst auch", "and": "und", + "failedToOpenUrl": "URL konnte nicht geöffnet werden: {}", "blockActions": { - "addBelowTooltip": "Klicken, um etwas unten hinzuzufügen", + "addBelowTooltip": "Unten klicken um etwas hinzuzufügen", "addAboveCmd": "Alt+Klick", "addAboveMacCmd": "Option+Klick", "addAboveTooltip": "oben hinzufügen", - "dragTooltip": "Verschieben durch ziehen", + "dragTooltip": "Drag to Drop", "openMenuTooltip": "Klicken, um das Menü zu öffnen" }, "signUp": { "buttonText": "Registrieren", "title": "Registriere dich bei @:appName", "getStartedText": "Erste Schritte", - "emptyPasswordError": "Das Passwort darf nicht leer sein", - "repeatPasswordEmptyError": "Die Passwortwiederholung darf nicht leer sein", - "unmatchedPasswordError": "Die Passwörter stimmen nicht überein", - "alreadyHaveAnAccount": "Bereits registriert?", + "emptyPasswordError": "Passwort darf nicht leer sein", + "repeatPasswordEmptyError": "Passwortwiederholung darf nicht leer sein", + "unmatchedPasswordError": "Passwörter stimmen nicht überein", + "alreadyHaveAnAccount": "Hast du schon ein Account?", "emailHint": "E-Mail", "passwordHint": "Passwort", - "repeatPasswordHint": "Wiederhole Passwort", + "repeatPasswordHint": "Passwort wiederholen", "signUpWith": "Anmelden mit:" }, "signIn": { @@ -35,13 +37,14 @@ "loginStartWithAnonymous": "Anonyme Sitzung starten", "continueAnonymousUser": "in anonymer Sitzung fortfahren", "buttonText": "Anmelden", + "signingInText": "Anmelden...", "forgotPassword": "Passwort vergessen?", "emailHint": "E-Mail", "passwordHint": "Passwort", "dontHaveAnAccount": "Noch kein Konto?", "repeatPasswordEmptyError": "Passwortwiederholung darf nicht leer sein", "unmatchedPasswordError": "Passwörter stimmen nicht überein", - "syncPromptMessage": "Die Synchronisation kann ein paar Minuten dauern. Bitte diese Seite nicht schließen", + "syncPromptMessage": "Synchronisation kann ein paar Minuten dauern. Diese Seite bitte nicht schließen", "or": "ODER", "LogInWithGoogle": "Mit Google-Account anmelden", "LogInWithGithub": "Mit GitHub-Account anmelden", @@ -49,17 +52,30 @@ "signInWith": "Anmeldeoptionen:" }, "workspace": { - "chooseWorkspace": "Arbeitsbereich wählen", - "create": "Arbeitsbereich erstellen", - "reset": "Arbeitsbereich zurücksetzen", - "resetWorkspacePrompt": "Das zurücksetzen des Arbeitsbereiches löscht alle enthaltenen Seiten und Daten. Sind sie sicher dass sie den Arbeitsbereich zurücksetzen wollen? ", - "hint": "Arbeitsbereich", - "notFoundError": "Arbeitsbereich nicht gefunden", - "failedToLoad": "Etwas ist schief gelaufen! Der Arbeitsbereich konnte nicht geladen werden. Versuchen Sie, alle AppFlowy Instanzen zu schließen, und versuchen Sie es erneut.", + "chooseWorkspace": "Workspace wählen", + "create": "Workspace erstellen", + "reset": "Workspace zurücksetzen", + "resetWorkspacePrompt": "Das Zurücksetzen des Workspace löscht alle enthaltenen Seiten und Daten. Bist du sicher, dass du den Arbeitsbereich zurücksetzen möchstest? ", + "hint": "Workspace", + "notFoundError": "Workspace nicht gefunden", + "failedToLoad": "Etwas ist schief gelaufen! Der Workspace konnte nicht geladen werden. Versuche, alle AppFlowy-Instanzen zu schließen & versuche es erneut.", "errorActions": { "reportIssue": "Problem melden", - "reachOut": "Kontaktieren Sie uns auf Discord" - } + "reportIssueOnGithub": "Melde ein Problem auf Github", + "exportLogFiles": "Exportiere Log-Dateien", + "reachOut": "Kontaktiere uns auf Discord" + }, + "deleteWorkspaceHintText": "Sicher, dass du dein Workspace löschen möchtest?\nDies kann nicht mehr Rückgängig gemacht werden.", + "createSuccess": "Workspace erfolgreich erstellt", + "createFailed": "Der Workspace konnte nicht erstellt werden", + "deleteSuccess": "Workspace erfolgreich gelöscht", + "deleteFailed": "Der Workspace konnte nicht gelöscht werden", + "openSuccess": "Workspace erfolgreich geöffnet", + "openFailed": "Der Workspace konnte nicht geöffnet werden", + "renameSuccess": "Workspace erfolgreich umbenannt", + "renameFailed": "Der Workspace konnte nicht umbenannt werden", + "updateIconSuccess": "Workspace erfolgreich zurückgesetzt", + "updateIconFailed": "Der Workspace konnte nicht zurückgesetzt werden" }, "shareAction": { "buttonText": "Teilen", @@ -69,12 +85,17 @@ "copyLink": "Link kopieren" }, "moreAction": { - "fontSize": "Schriftgröße", - "import": "Importieren", - "moreOptions": "Mehr Optionen", "small": "klein", "medium": "mittel", - "large": "groß" + "large": "groß", + "fontSize": "Schriftgröße", + "import": "Importieren", + "moreOptions": "Weitere Optionen", + "wordCount": "Wortanzahl: {}", + "charCount": "Zeichenanzahl: {}", + "createdAt": "Erstellt am: {}", + "deleteView": "Löschen", + "duplicateView": "Duplizieren" }, "importPanel": { "textAndMarkdown": "Text & Markdown", @@ -110,23 +131,23 @@ "created": "Erstellt" }, "confirmDeleteAll": { - "title": "Sicher, dass alle Seiten im Papierkorb gelöscht werden?", + "title": "Bist du dir sicher? Das löscht alle Seiten in den Papierkorb.", "caption": "Diese Aktion kann nicht rückgängig gemacht werden." }, "confirmRestoreAll": { - "title": "Sicher, dass alle Seiten im Papierkorb wiederhergestellt werden?", + "title": "Möchtest du wirklich alle Seiten aus dem Papierkorb wiederherstellen?", "caption": "Diese Aktion kann nicht rückgängig gemacht werden." }, "mobile": { - "actions": "Papierkorbaktionen", - "empty": "Der Papierkorb ist leer", - "emptyDescription": "Es sind keine gelöschten Dateien vorhanden", - "isDeleted": "ist gelöscht", - "isRestored": "ist wiederhergestellt" + "actions": "Papierkorb-Einstellungen", + "empty": "Der Papierkorb ist leer.", + "emptyDescription": "Es sind keine gelöschten Dateien vorhanden.", + "isDeleted": "wurde gelöscht", + "isRestored": "wurde wiederhergestellt" } }, "deletePagePrompt": { - "text": "Diese Seite ist im Papierkorb", + "text": "Diese Seite befindet sich im Papierkorb", "restore": "Seite wiederherstellen", "deletePermanent": "Dauerhaft löschen" }, @@ -139,13 +160,13 @@ "debug": { "name": "Debug-Informationen", "success": "Debug-Informationen in die Zwischenablage kopiert!", - "fail": "Debug-Informationen können nicht in die Zwischenablage kopiert werden" + "fail": "Debug-Informationen konnten nicht in die Zwischenablage kopiert werden" }, "feedback": "Feedback" }, "menuAppHeader": { "moreButtonToolTip": "Entfernen, umbenennen und mehr...", - "addPageTooltip": "Hier eine Seite direkt hinzufügen", + "addPageTooltip": "Schnell eine Seite hineinfügen", "defaultNewPageName": "Unbenannt", "renameDialog": "Umbenennen" }, @@ -165,8 +186,8 @@ "header": "Überschrift", "highlight": "Hervorhebung", "color": "Farbe", - "addLink": "Verknüpfung hinzufügen", - "link": "Verknüpfung" + "addLink": "Link hinzufügen", + "link": "Link" }, "tooltip": { "lightMode": "In den hellen Modus wechseln", @@ -177,7 +198,7 @@ "dragRow": "Gedrückt halten, um die Zeile neu anzuordnen", "viewDataBase": "Datenbank ansehen", "referencePage": "Auf diesen {Name} wird verwiesen", - "addBlockBelow": "Einen Block unten hinzufügen", + "addBlockBelow": "Einen Block hinzufügen", "urlLaunchAccessory": "Im Browser öffnen", "urlCopyAccessory": "Webadresse kopieren." }, @@ -189,7 +210,7 @@ "clickToHidePersonal": "Klicken, um den persönlichen Abschnitt zu verbergen", "clickToHideFavorites": "Klicken, um Favoriten zu verbergen", "addAPage": "Seite hinzufügen", - "recent": "Zueletzt" + "recent": "Zuletzt" }, "notifications": { "export": { @@ -200,12 +221,12 @@ "contactsPage": { "title": "Kontakte", "whatsHappening": "Was passiert diese Woche?", - "addContact": "Kontakt hinzufügen", - "editContact": "Kontakt bearbeiten" + "addContact": "Kontakte hinzufügen", + "editContact": "Kontakte bearbeiten" }, "button": { "ok": "OK", - "done": "Erledigt", + "done": "Erledigt!", "cancel": "Abbrechen", "signIn": "Anmelden", "signOut": "Abmelden", @@ -213,7 +234,7 @@ "save": "Speichern", "generate": "Erstellen", "esc": "ESC", - "keep": "Halten", + "keep": "behalten", "tryAgain": "Nochmal versuchen", "discard": "Verwerfen", "replace": "Ersetzen", @@ -223,15 +244,20 @@ "edit": "Bearbeiten", "delete": "Löschen", "duplicate": "Duplikat", - "putback": "Zurück geben", + "putback": "wieder zurückgeben", "update": "Update", "share": "Teilen", - "removeFromFavorites": "Von den Favoriten entfernen", + "removeFromFavorites": "Aus den Favoriten entfernen", "addToFavorites": "Zu den Favoriten hinzufügen", "rename": "Umbenennen", - "helpCenter": "Hilfe", + "helpCenter": "Hilfe Center", "add": "Hinzufügen", - "yes": "Ja" + "yes": "Ja", + "clear": "Leeren", + "remove": "Entfernen", + "dontRemove": "Nicht entfernen", + "copyLink": "Link kopieren", + "align": "zentrieren" }, "label": { "welcome": "Willkommen!", @@ -242,15 +268,15 @@ }, "oAuth": { "err": { - "failedTitle": "Keine Verbindung zu Ihrem Konto möglich.", - "failedMsg": "Bitte prüfen, ob der Anmeldevorgang im Browser abgeschlossen wurde." + "failedTitle": "Keine Verbindung zum Konto möglich.", + "failedMsg": "Prüfe, ob der Anmeldevorgang im Browser abgeschlossen wurde." }, "google": { - "title": "GOOGLE ANMELDUNG", - "instruction1": "Um Ihre Google-Kontakte zu importieren, müssen Sie diese Anwendung über Ihren Webbrowser autorisieren.", - "instruction2": "Kopieren Sie diesen Code in Ihre Zwischenablage, indem Sie auf das Symbol klicken oder den Text auswählen:", - "instruction3": "Rufen Sie den folgenden Link in Ihrem Webbrowser auf, und geben Sie den obigen Code ein:", - "instruction4": "Klicken Sie unten auf die Schaltfläche, wenn Sie die Anmeldung abgeschlossen haben:" + "title": "Google Sign-In", + "instruction1": "Um die Google-Kontakte zu importieren, muss die Anwendung über den Webbrowser autorisiert werden.", + "instruction2": "Kopiere den Code in die Zwischenablage, über das Symbol oder indem du den Text auswählst:", + "instruction3": "Rufe den folgenden Link im Webbrowser auf und gebe den Code ein:", + "instruction4": "Klicke unten auf die Schaltfläche, wenn die Anmeldung abgeschlossen ist:" } }, "settings": { @@ -264,7 +290,7 @@ "open": "Einstellungen öffnen", "logout": "Abmelden", "logoutPrompt": "Wollen sie sich wirklich Abmelden?", - "selfEncryptionLogoutPrompt": "Wollen sie sich wirklich Abmelden? Bitte sicherstellen, dass der Encryption Secret Code kopiert wurde.", + "selfEncryptionLogoutPrompt": "Willst du dich wirklich Abmelden? Bitte stelle sicher, dass der Encryption Secret Code kopiert wurde.", "syncSetting": "Sync Einstellung", "cloudSettings": "Cloud Einstellungen", "enableSync": "Sync aktivieren", @@ -272,42 +298,47 @@ "cloudURL": "Basis URL", "invalidCloudURLScheme": "Ungültiges Format", "cloudServerType": "Cloud Server", - "cloudServerTypeTip": "Bitte beachten, dass Sie vom aktuellen Account ausgeloggt werden, nach dem Wechsel zum Cloud Server", + "cloudServerTypeTip": "Bitte beachte, dass der aktuelle Benutzer ausgeloggt wird beim wechsel des Cloud-Servers", "cloudLocal": "Lokal", "cloudSupabase": "Supabase", "cloudSupabaseUrl": "Supabase URL", + "cloudSupabaseUrlCanNotBeEmpty": "Die Supabase-URL darf nicht leer sein", "cloudSupabaseAnonKey": "Supabase anonymer Schlüssel", - "cloudSupabaseAnonKeyCanNotBeEmpty": "Der anonyme Schlüssel darf nicht leer sein, wenn die Supabase URL gesetzt ist.", - "cloudAppFlowy": "AppFlowy Cloud", + "cloudSupabaseAnonKeyCanNotBeEmpty": "Der anonyme Schlüssel darf nicht leer sein", + "cloudAppFlowy": "AppFlowy Cloud [BETA]", + "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", + "appFlowyCloudUrlCanNotBeEmpty": "Die Cloud-URL darf nicht leer sein", "clickToCopy": "Klicken, um zu kopieren", - "selfHostStart": "Falls Sie keinen Server haben, verweisen Sie bitte auf", + "selfHostStart": "Falls du keinen Server hast, nehme lieber folgende", "selfHostContent": "Dokument", - "selfHostEnd": "für Hilfe, um einen einen Server aufzusetzen", + "selfHostEnd": "für Hilfe, um einen einen eigenen Server aufzusetzen", "cloudURLHint": "Eingabe der Basis- URL Ihres Servers", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Eingbe der Websocket Adresse Ihres Servers", "restartApp": "Neustart", - "restartAppTip": "Programm neustarten, um die Änderungen zu übernehmen. Bitte bachten, dass Sie vom aktuellen Account eventuell ausgeloggt werden.", - "enableEncryptPrompt": "Verschlüsselung aktivieren, um Ihre Daten mit dem Secret Key zu verschlüsseln. Verwahren Sie den Schlüssel sicher. Einmal aktiviert kann es nicht mehr rückgängig gemacht werden. Falls der Schlüssel verloren geht sind Ihre Daten unwiderbringlich verloren. Klicken, um zu kopieren.", + "restartAppTip": "Programm neustarten, um die Änderungen zu übernehmen. Bitte bachten, dass der aktuelle Account eventuell ausgeloggt wird.", + "changeServerTip": "Nach dem Wechsel des Servers muss auf die Schaltfläche „Neustart“ geklickt werden, damit die Änderungen wirksam werden", + "enableEncryptPrompt": "Verschlüsselung aktivieren, um deine Daten mit dem Secret Key zu verschlüsseln. Verwahre den Schlüssel sicher! \nEinmal aktiviert kann es nicht mehr rückgängig gemacht werden.\nFalls der Schlüssel verloren geht sind die Daten unwiderbringlich verloren.\nKlicken, um zu kopieren.", "inputEncryptPrompt": "Bitte den Encryption Secret Code eingeben", "clickToCopySecret": "Klicken, um den Secret Code zu kopieren", - "configServerSetting": "Ihre Servereinstellungen anpassen", - "configServerGuide": "Zu erst `Schnellstart/Quick Start` auswählen, dann zu den `Einstellungen/Settings` wechseln und dann die Cloud-Einstellungen \"Cloud Settings\" auswählen, um Ihren Server zu konfigurieren.", - "inputTextFieldHint": "Ihr Secret Code", - "historicalUserList": "Nutzer-Anmelde-Historie", - "historicalUserListTooltip": "Diese Liste zeigt Ihre anonymen Accounts. Sie können einen Account anklicken, um die Detailinformationen zu sehen. Anonyme Accounts werden über den 'Erste Schritte' Button erstellt", - "openHistoricalUser": "Klicken, um den anonymen Account zu öffnen", + "configServerSetting": "Deine Servereinstellungen anpassen", + "configServerGuide": "`Schnellstart/Quick Start` auswählen, dann zu den `Einstellungen/Settings` wechseln und dann die Cloud-Einstellungen \"Cloud Settings\" auswählen, um deinen Server zu konfigurieren.", + "inputTextFieldHint": "Dein Secret-Code", + "historicalUserList": "Anmeldeverlauf", + "historicalUserListTooltip": "Diese Liste zeigt deine anonymen Accounts. Du kannst einen Account anklicken, um mehr Informationen zu sehen.\nAnonyme Accounts werden über den 'Erste Schritte' Button erstellt.", + "openHistoricalUser": "Klicken, um einen anonymen Account zu öffnen", "customPathPrompt": "Den AppFlowy Daten-Ordner in einem mit der Cloud synchronisierten Ordner (z.B. Google Drive) zu speichern, könnte Risiken bergen. Falls die Datenbank innerhalb dieses Ordners gleichzeitig von mehreren Orten zugegriffen oder verändert wird könnte es zu Synchronisationskonflikten und potentiellen Daten-Beschädigung führen", "importAppFlowyData": "Daten von einem externen AppFlowy Ordner importieren.", + "importingAppFlowyDataTip": "Der Datenimport läuft. Bitte die App nicht schließen oder in den Hintergrund setzten", "importAppFlowyDataDescription": "Daten von einem externen AppFlowy Ordner kopieren und in den aktuellen AppFlowy Datenordner importieren.", - "importSuccess": "Der AppFlowy Datenordner wurde erfolgreich importiert", - "importFailed": "Der AppFlowy Datenordner-Import ist fehlgeschlagen", + "importSuccess": "Der AppFlowy Dateienordner wurde erfolgreich importiert", + "importFailed": "Der AppFlowy Dateienordner-Import ist fehlgeschlagen", "importGuide": "Für weitere Details, bitte das verlinkte Dokument prüfen" }, "notifications": { "enableNotifications": { "label": "Benachrichtigungen aktivieren", - "hint": "Ausschalten, damit die lokalen Benachrichtigungen nicht mehr angezeigt werden" + "hint": "Wenn diese Funktion ausgeschaltet ist, werden keine lokalen Benachrichtigungen mehr angezeigt." } }, "appearance": { @@ -317,11 +348,12 @@ "search": "Suchen" }, "themeMode": { - "label": "Designmodus", + "label": "Design", "light": "Helles Design", "dark": "Dunkles Design", - "system": "Automatisch, wie Betriebssystem" + "system": "Wie Betriebssystem" }, + "fontScaleFactor": "Schriftgröße", "documentSettings": { "cursorColor": "Dokument Cursor-Farbe", "selectionColor": "Dokument Auswahl-Farbe", @@ -332,35 +364,35 @@ "opacityRangeError": "Transparenz ist ein Wert zwischen 1 und 100", "app": "App", "flowy": "Flowy", - "apply": "Apply" + "apply": "Verwenden" }, "layoutDirection": { "label": "Layoutrichtung", "hint": "Steuere den Umlauf der Inhalte auf deinem Bildschirm: Von Links nach Rechts oder von Rechts nach Links.", - "ltr": "LNR", - "rtl": "RNL" + "ltr": "Links nach Rechts", + "rtl": "Rechts nach Links" }, "textDirection": { "label": "Textrichtung", "hint": "Wie soll der Text laufen: von Links nach Rechts oder von Rechts nach Links?", - "ltr": "LNR", - "rtl": "RNL", + "ltr": "Links nach Rechts", + "rtl": "Rechts nach Links", "auto": "AUTO", - "fallback": "Wie die Layoutrichtung" + "fallback": "Wie Layoutrichtung" }, "themeUpload": { "button": "Hochladen", "uploadTheme": "Theme hochladen", - "description": "Laden Sie Ihr eigenes AppFlowy-Theme über die Schaltfläche unten hoch.", - "loading": "Bitte warten Sie, während wir Ihr Theme validieren und hochladen ...", - "uploadSuccess": "Ihr Theme wurde erfolgreich hochgeladen", - "deletionFailure": "Das Thema konnte nicht gelöscht werden. Versuchen Sie, es manuell zu löschen.", - "filePickerDialogTitle": "Wählen Sie eine .flowy_plugin-Datei", + "description": "Lade eigenes AppFlowy-Theme über die Schaltfläche unten hoch.", + "loading": "Bitte warte einen Moment . . .\nWir validieren gerade dein Theme und laden es hoch.", + "uploadSuccess": "Das Theme wurde erfolgreich hochgeladen", + "deletionFailure": "Das Theme konnte nicht gelöscht werden. Versuche, es manuell zu löschen.", + "filePickerDialogTitle": "Wähle eine .flowy_plugin-Datei", "urlUploadFailure": "URL konnte nicht geöffnet werden: {}", "failure": "Das hochgeladene Theme hatte ein ungültiges Format." }, "theme": "Theme", - "builtInsLabel": "Integrierte Themes", + "builtInsLabel": "Integrierte Theme", "pluginsLabel": "Plugins", "dateFormat": { "label": "Datumsformat", @@ -368,14 +400,33 @@ "us": "US", "iso": "ISO", "friendly": "Freundlich", - "dmy": "T/M/J" + "dmy": "TT/MM/JJJJ" }, "timeFormat": { "label": "Zeitformat", "twelveHour": "12 Stunden", "twentyFourHour": "24 Stunden" }, - "showNamingDialogWhenCreatingPage": "Zeige Bennenungsfenster wenn eine neue Seite erstellt wird" + "showNamingDialogWhenCreatingPage": "Zeige Bennenungsfenster, wenn eine neue Seite erstellt wird", + "enableRTLToolbarItems": "Aktivieren Sie RTL-Symbolleiste", + "members": { + "title": "Mitglieder-Einstellungen", + "inviteMembers": "Mitglieder einladen", + "sendInvite": "Einladung senden", + "copyInviteLink": "Kopiere Einladungslink", + "label": "Mitglieder", + "user": "Nutzer", + "role": "Rolle", + "removeFromWorkspace": "Vom Workspace entfernen", + "owner": "Besitzer", + "guest": "Gast", + "member": "Mitglied", + "memberHintText": "Ein Mitglied kann Seiten lesen, kommentieren und bearbeiten, sowie Einladungen an Mitglieder & Gäste versenden.", + "guestHintText": "Ein Gast kann mit Erlaubnis bestimmte Seiten lesen, reagieren, kommentieren und bearbeiten.", + "emailInvalidError": "Ungültige E-Mail. Bitte prüfe die E-Mail und versuche es erneut.", + "emailSent": "E-Mail gesendet. Prüfe den Posteingang.", + "members": "Mitglieder" + } }, "files": { "copy": "Kopieren", @@ -411,15 +462,19 @@ "recoverLocationTooltips": "Zurücksetzen auf das Standarddatenverzeichnis von AppFlowy", "exportFileSuccess": "Datei erfolgreich exportiert!", "exportFileFail": "Datei-Export fehlgeschlagen!", - "export": "Export" + "export": "Export", + "clearCache": "Cache leeren", + "clearCacheDesc": "Wenn Probleme auftreten, dass Bilder nicht geladen werden oder Schriftarten nicht richtig angezeigt werden, versuche, den Cache zu leeren. Durch diese Aktion werden die Benutzerdaten nicht entfernt.", + "areYouSureToClearCache": "Möchtest du den Cache wirklich leeren?", + "clearCacheSuccess": "Cache erfolgreich geleert!" }, "user": { "name": "Name", "email": "E-Mail", "tooltipSelectIcon": "Symbol auswählen", "selectAnIcon": "Ein Symbol auswählen", - "pleaseInputYourOpenAIKey": "Bitte geben Sie Ihren OpenAI-Schlüssel ein", - "pleaseInputYourStabilityAIKey": "Bitte geben Sie Ihren Stability AI Schlüssel ein", + "pleaseInputYourOpenAIKey": "Bitte gebe den OpenAI-Schlüssel ein", + "pleaseInputYourStabilityAIKey": "Bitte gebe den Stability AI Schlüssel ein", "clickToLogout": "Klicken, um den aktuellen Nutzer auszulogen" }, "shortcuts": { @@ -440,13 +495,15 @@ "about": "Über", "pushNotifications": "Push Benachrichtigungen", "support": "Support", - "joinDiscord": "Komm zu uns bei Discord", + "joinDiscord": "Komm zu uns auf Discord", "privacyPolicy": "Datenschutz", "userAgreement": "Nutzungsbedingungen", + "termsAndConditions": "Geschäftsbedingungen", "userprofileError": "Das Nutzerprofil konnte nicht geladen werden", "userprofileErrorDescription": "Bitte abmelden und wieder anmelden, um zu prüfen ob das Problem weiterhin bestehen bleibt.", "selectLayout": "Layout auswählen", - "selectStartingDay": "Ersten Tag auswählen" + "selectStartingDay": "Ersten Tag auswählen", + "version": "Version" } }, "grid": { @@ -468,14 +525,14 @@ "typeAValue": "Einen Wert eingeben...", "layout": "Layout", "databaseLayout": "Layout", + "viewList": "Datenbank-Ansichten", "editView": "Ansicht editieren", "boardSettings": "Board-Einstellungen", "calendarSettings": "Kalender-Einstellungen", "createView": "New Ansicht", "duplicateView": "Ansicht duplizieren", "deleteView": "Anslicht löschen", - "numberOfVisibleFields": "{} zeigen", - "viewList": "Datenbank-Ansichten" + "numberOfVisibleFields": "{} angezeigt" }, "textFilter": { "contains": "Enthält", @@ -505,13 +562,9 @@ "isComplete": "ist komplett", "isIncomplted": "ist unvollständig" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Ist", "isNot": "Ist nicht", - "isEmpty": "Ist leer", - "isNotEmpty": "Ist nicht leer" - }, - "multiSelectOptionFilter": { "contains": "Enthält", "doesNotContain": "Beinhaltet nicht", "isEmpty": "Ist leer", @@ -521,11 +574,29 @@ "is": "Ist", "before": "Ist bevor", "after": "Ist nach", - "onOrBefore": "Ist am oder bevor", + "onOrBefore": "Ist am oder vor", "onOrAfter": "Ist am oder nach", "between": "Ist zwischen", "empty": "Ist leer", - "notEmpty": "Ist nicht leer" + "notEmpty": "Ist nicht leer", + "choicechipPrefix": { + "before": "Vorher", + "after": "Danach", + "onOrBefore": "Am oder davor", + "onOrAfter": "Während oder danach", + "isEmpty": "leer", + "isNotEmpty": "nicht leer" + } + }, + "numberFilter": { + "equal": "gleich", + "notEqual": "ungleich", + "lessThan": "weniger als", + "greaterThan": "größer als", + "lessThanOrEqualTo": "weniger als oder gleich wie", + "greaterThanOrEqualTo": "größer als oder gleich wie", + "isEmpty": "leer", + "isNotEmpty": "nicht leer" }, "field": { "hide": "Verstecken", @@ -544,6 +615,7 @@ "multiSelectFieldName": "Mehrfachauswahl", "urlFieldName": "URL", "checklistFieldName": "Checkliste", + "relationFieldName": "Beziehung", "numberFormat": "Zahlenformat", "dateFormat": "Datumsformat", "includeTime": "Zeitangabe", @@ -566,7 +638,7 @@ "selectDate": "Auswahl Datum", "visibility": "Sichtbarkeit", "propertyType": "Eigenschaftstyp", - "addSelectOption": "Fügen Sie eine Option hinzu", + "addSelectOption": "Füge Option hinzu", "typeANewOption": "Eine neue Option eingeben", "optionTitle": "Optionen", "addOption": "Option hinzufügen", @@ -574,7 +646,9 @@ "newProperty": "Neue Eigenschaft", "deleteFieldPromptMessage": "Sicher? Diese Eigenschaft wird gelöscht", "newColumn": "Neue Spalte", - "format": "Format" + "format": "Format", + "reminderOnDateTooltip": "Diese Zeile hat eine terminierte Erinnerung", + "optionAlreadyExist": "Einstellung existiert bereits" }, "rowPage": { "newField": "Ein neues Feld hinzufügen", @@ -593,8 +667,13 @@ "sort": { "ascending": "Aufsteigend", "descending": "Absteigend", + "by": "von", + "empty": "Keine Sortierung", + "cannotFindCreatableField": "Es konnte kein geeignetes Feld zum Sortieren gefunden werden", "deleteAllSorts": "Alle Sortierungen entfernen", - "addSort": "Sortierung hinzufügen" + "addSort": "Sortierung hinzufügen", + "removeSorting": "Möchten Sie die Sortierung entfernen?", + "fieldInUse": "Sie sortieren bereits nach diesem Feld" }, "row": { "duplicate": "Duplikat", @@ -639,8 +718,32 @@ "hideComplete": "Blende abgeschlossene Aufgaben aus", "showComplete": "Zeige alle Aufgaben" }, + "relation": { + "relatedDatabasePlaceLabel": "Verwandte Datenbank", + "relatedDatabasePlaceholder": "Nichts", + "inRelatedDatabase": "in", + "emptySearchResult": "Nichts gefunden" + }, + "url": { + "launch": "Im Browser öffnen", + "copy": "Webadresse kopieren" + }, "menuName": "Raster", - "referencedGridPrefix": "Sicht von" + "referencedGridPrefix": "Sicht von", + "calculate": "berechnet", + "calculationTypeLabel": { + "none": "nichts", + "average": "Durchschnitt", + "max": "Max", + "median": "Mittelwert", + "min": "Min", + "sum": "Ergebnis", + "count": "Zahl", + "countEmpty": "Zahl leer", + "countEmptyShort": "leer", + "countNonEmpty": "Zahl nicht leer", + "countNonEmptyShort": "nicht leer" + } }, "document": { "menuName": "Dokument", @@ -743,17 +846,21 @@ "left": "Links", "center": "Zentriert", "right": "Rechts", - "defaultColor": "Standard" + "defaultColor": "Standard", + "depth": "Tiefe" }, "image": { "copiedToPasteBoard": "Der Bildlink wurde in die Zwischenablage kopiert", - "addAnImage": "Ein Bild hinzufügen" + "addAnImage": "Ein Bild hinzufügen", + "imageUploadFailed": "Bild hochladen gescheitert" }, "urlPreview": { - "copiedToPasteBoard": "Der Link wurde in die Zwischenablage kopiert" + "copiedToPasteBoard": "Der Link wurde in die Zwischenablage kopiert", + "convertToLink": "Konvertieren zum eingebetteten Link" }, "outline": { - "addHeadingToCreateOutline": "Fügen Sie Überschriften hinzu, um ein Inhaltsverzeichnis zu erstellen." + "addHeadingToCreateOutline": "Fügen Sie Überschriften hinzu, um ein Inhaltsverzeichnis zu erstellen.", + "noMatchHeadings": "Keine passenden Überschriften gefunden." }, "table": { "addAfter": "Danach einfügen", @@ -776,7 +883,12 @@ "toContinue": "fortfahren", "newDatabase": "Neue Datenbank", "linkToDatabase": "Verknüpfung zur Datenbank" - } + }, + "date": "Datum", + "emoji": "Emoji" + }, + "outlineBlock": { + "placeholder": "Inhaltsverzeichnis" }, "textBlock": { "placeholder": "Geben Sie „/“ für Inhaltsblöcke ein" @@ -807,7 +919,8 @@ "invalidImage": "Ungültiges Bild", "invalidImageSize": "Die Bildgröße muss kleiner als 5 MB sein", "invalidImageFormat": "Das Bildformat wird nicht unterstützt. Unterstützte Formate: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "Ungültige Bild-URL" + "invalidImageUrl": "Ungültige Bild-URL", + "noImage": "Keine Datei oder Verzeichnis" }, "embedLink": { "label": "Eingebetteter Link", @@ -822,7 +935,10 @@ "saveImageToGallery": "Bild speichern", "failedToAddImageToGallery": "Das Bild konnte nicht zur Galerie hinzugefügt werden", "successToAddImageToGallery": "Das Bild wurde zur Galerie hinzugefügt werden", - "unableToLoadImage": "Das Bild konnte nicht geladen werden" + "unableToLoadImage": "Das Bild konnte nicht geladen werden", + "maximumImageSize": "Die maximal unterstützte Upload-Bildgröße beträgt 10 MB", + "uploadImageErrorImageSizeTooBig": "Die Bildgröße muss weniger als 10 MB betragen", + "imageIsUploading": "Bild wird hochgeladen" }, "codeBlock": { "language": { @@ -849,7 +965,9 @@ "page": { "label": "Link zur Seite", "tooltip": "Klicken, um die Seite zu öffnen" - } + }, + "deleted": "gelöscht", + "deletedContent": "Dieser Inhalt existiert nicht oder wurde gelöscht" }, "toolbar": { "resetToDefaultFont": "Auf den Standard zurücksetzen" @@ -868,10 +986,10 @@ "addToColumnBottomTooltip": "Eine neue Karte am unteren Ende hinzufügen", "renameColumn": "Umbenennen", "hideColumn": "Verstecken", - "groupActions": "Gruppenaktion", "newGroup": "Neue Gruppe", "deleteColumn": "Löschen", - "deleteColumnConfirmation": "Das wird diese Gruppe und alle enthaltenen Karten löschen.\nSicher, dass Sie fortsetzen möchte?" + "deleteColumnConfirmation": "Das wird diese Gruppe und alle enthaltenen Karten löschen.\nSicher, dass du fortsetzen möchtest?", + "groupActions": "Gruppenaktion" }, "hiddenGroupSection": { "sectionTitle": "Versteckte Gruppen", @@ -897,7 +1015,7 @@ "editURL": "Bearbeite URL", "unhideGroup": "Zeige die Gruppe", "unhideGroupContent": "Sicher, dass diese Gruppe auf dem Board angezeigt werden soll?", - "faildToLoad": "Board Sicht konnte nicht geladen werden" + "faildToLoad": "Boardansicht konnte nicht geladen werden" } }, "calendar": { @@ -910,6 +1028,10 @@ "previousMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat" }, + "mobileEventScreen": { + "emptyTitle": "Noch keine Events", + "emptyBody": "Drücke die Plus-Taste, um für heute ein Ereignis zu erstellen." + }, "settings": { "showWeekNumbers": "Wochennummern anzeigen", "showWeekends": "Wochenenden anzeigen", @@ -922,7 +1044,7 @@ "one": "{count} Ereignisse ohne Datum", "other": "{count} Ereignisse ohne Datum" }, - "unscheduledEventsTitle": "Unscheduled events", + "unscheduledEventsTitle": "Ungeplante Events", "clickToAdd": "Klicken Sie, um es zum Kalender hinzuzufügen", "name": "Kalendereinstellungen" }, @@ -931,7 +1053,7 @@ }, "errorDialog": { "title": "AppFlowy-Fehler", - "howToFixFallback": "Wir entschuldigen uns für die Unannehmlichkeiten! Reichen Sie auf unserer GitHub-Seite ein Problem ein, das Ihren Fehler beschreibt.", + "howToFixFallback": "Wir entschuldigen uns für die Unannehmlichkeiten! Reiche auf unserer GitHub-Seite ein Problem ein, das Ihren Fehler beschreibt.", "github": "Auf GitHub ansehen" }, "search": { @@ -998,6 +1120,10 @@ "inlineActions": { "noResults": "Keine Ergebnisse", "pageReference": "Seitenreferenz", + "docReference": "Dokumentverweis", + "boardReference": "Board-Referenz", + "calReference": "Kalenderreferenz", + "gridReference": "Gitter Referenz", "date": "Datum", "reminder": { "groupTitle": "Erinnerung", @@ -1010,7 +1136,24 @@ "includeTime": "Inkl. Zeit", "isRange": "Enddatum", "timeFormat": "Zeitformat", - "clearDate": "Datum löschen" + "clearDate": "Datum löschen", + "reminderLabel": "Erinnerung", + "selectReminder": "Erinnerung auswählen", + "reminderOptions": { + "none": "nichts", + "atTimeOfEvent": "Uhrzeit des Events", + "fiveMinsBefore": "5Min. vorher", + "tenMinsBefore": "10Min. vorher", + "fifteenMinsBefore": "15Min. vorher", + "thirtyMinsBefore": "30Min. vorher", + "oneHourBefore": "1Std. vorher", + "twoHoursBefore": "2Std. vorher", + "onDayOfEvent": "Am Tag des Events", + "oneDayBefore": "1Tag vorher", + "twoDaysBefore": "2Tage vorher", + "oneWeekBefore": "1Woche vorher", + "custom": "Benutzerdefiniert" + } }, "relativeDates": { "yesterday": "Gestern", @@ -1024,7 +1167,7 @@ "title": "Neuigkeiten" }, "emptyTitle": "Leer", - "emptyBody": "Keine offenen Benachrichtigungen oder Aktionen. Genießen Sie die Ruhe.", + "emptyBody": "Keine offenen Benachrichtigungen oder Aktionen. Genieße die Ruhe.", "tabs": { "inbox": "Eingang", "upcoming": "Demnächst" @@ -1044,7 +1187,7 @@ }, "reminderNotification": { "title": "Erinnerung", - "message": "Bitte denken Sie daran das hier zu prüfen bevor Sie es vergessen!", + "message": "Bitte denke daran, dass hier zu prüfen bevor du es vergisst.", "tooltipDelete": "Löschen", "tooltipMarkRead": "Als gelesen markieren", "tooltipMarkUnread": "Als ungelesen markieren" @@ -1057,15 +1200,17 @@ "replace": "Ersetzen", "replaceAll": "Alle ersetzen", "noResult": "Keine Ergebnisse", - "caseSensitive": "Groß-/Kleinschreibung beachten" + "caseSensitive": "Groß-/Kleinschreibung beachten", + "searchMore": "Suche für mehr Ergebnisse" }, "error": { "weAreSorry": "Das tut uns leid", - "loadingViewError": "Wir haben Schwierigkeiten diese Ansicht zu laden. Bitte prüfen Sie Ihre Internetverbindung, laden die App neu und zögern Sie nicht, das Team zu kontaktieren, falls das Problem weiterhin bestehen sollte." + "loadingViewError": "Wir haben Schwierigkeiten diese Ansicht zu laden. Bitte prüfe die Internetverbindung, lade die App neu und zögere Sie nicht, dass Team zu kontaktieren, falls das Problem weiterhin besteht." }, "editor": { "bold": "Fett", "bulletedList": "Stichpunktliste", + "bulletedListShortForm": "Mit Aufzählungszeichen", "checkbox": "Checkbox", "embedCode": "Eingebetteter Code", "heading1": "Überschrift 1", @@ -1074,9 +1219,11 @@ "highlight": "Hervorhebung", "color": "Farbe", "image": "Bild", + "date": "Datum", "italic": "Kursiv", "link": "Link", "numberedList": "Nummerierte Liste", + "numberedListShortForm": "Nummeriert", "quote": "Zitat", "strikethrough": "Durgestrichen", "text": "Text", @@ -1101,6 +1248,8 @@ "backgroundColorPurple": "Lila Hintergrund", "backgroundColorPink": "Pinker Hintergrund", "backgroundColorRed": "Roter Hintergrund", + "backgroundColorLime": "Lime-Hintergrund", + "backgroundColorAqua": "Aqua-Hintergrund", "done": "Erledigt", "cancel": "Abbrechen", "tint1": "Farbton 1", @@ -1175,17 +1324,21 @@ "colClear": "Inhalt löschen", "rowClear": "Inhalt löschen", "slashPlaceHolder": "'/'-Taste, um einen Block einzufügen oder Text eingeben", - "typeSomething": "Etwas eingeben..." + "typeSomething": "Etwas eingeben...", + "toggleListShortForm": "Umschalten", + "quoteListShortForm": "Zitat", + "mathEquationShortForm": "Formel", + "codeBlockShortForm": "Code" }, "favorite": { - "noFavorite": "Keine Favoritenseite", + "noFavorite": "Leere Favoritenseite", "noFavoriteHintText": "Nach links wischen, um es den Favoriten hinzuzufügen" }, "cardDetails": { "notesPlaceholder": "'/'-Taste, um einen Block einzufügen oder Text eingeben" }, "blockPlaceholders": { - "todoList": "To-do", + "todoList": "To-Do", "bulletList": "Liste", "numberList": "Liste", "quote": "Zitat", @@ -1199,5 +1352,6 @@ "date": "Datum", "addField": "Ein Feld hinzufügen", "userIcon": "Nutzerbild" - } -} + }, + "noLogFiles": "Hier gibt es kein Log-File" +} \ No newline at end of file diff --git a/frontend/resources/translations/el-GR.json b/frontend/resources/translations/el-GR.json new file mode 100644 index 0000000000000..3c57a4db1e9ae --- /dev/null +++ b/frontend/resources/translations/el-GR.json @@ -0,0 +1,1411 @@ +{ + "appName": "AppFlowy", + "defaultUsername": "Me", + "welcomeText": "Καλωσορίσατε στο @:appName", + "welcomeTo": "Καλωσορίσατε στο", + "githubStarText": "Star on GitHub", + "subscribeNewsletterText": "Εγγραφείτε στο Newsletter", + "letsGoButtonText": "Γρήγορη Εκκίνηση", + "title": "Τίτλος", + "youCanAlso": "Μπορείτε επίσης", + "and": "και", + "failedToOpenUrl": "Αποτυχία ανοίγματος διεύθυνσης url: {}", + "blockActions": { + "addBelowTooltip": "Κάντε κλικ για να προσθέσετε παρακάτω", + "addAboveCmd": "Alt+click", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "για να προσθέσετε παραπάνω", + "dragTooltip": "Σύρετε για μετακίνηση", + "openMenuTooltip": "Κάντε κλικ για άνοιγμα μενού" + }, + "signUp": { + "buttonText": "Εγγραφή", + "title": "Εγγραφείτε στο @:appName", + "getStartedText": "Ξεκινήστε", + "emptyPasswordError": "Ο κωδικός πρόσβασης δεν μπορεί να είναι κενός", + "repeatPasswordEmptyError": "Η επανάληψη κωδικού πρόσβασης δεν μπορεί να είναι κενή", + "unmatchedPasswordError": "Η επανάληψη κωδικού πρόσβασης δεν είναι ίδια με τον κωδικό πρόσβασης", + "alreadyHaveAnAccount": "Έχετε ήδη λογαριασμό;", + "emailHint": "Email", + "passwordHint": "Κωδικός", + "repeatPasswordHint": "Επαναλάβετε τον κωδικό πρόσβασης", + "signUpWith": "Εγγραφή με:" + }, + "signIn": { + "loginTitle": "Συνδεθείτε στο @:appName", + "loginButtonText": "Σύνδεση", + "loginStartWithAnonymous": "Έναρξη με ανώνυμη συνεδρία", + "continueAnonymousUser": "Συνέχεια με ανώνυμη συνεδρία", + "buttonText": "Είσοδος", + "signingInText": "Πραγματοποιείται σύνδεση...", + "forgotPassword": "Ξεχάσατε το κωδικό;", + "emailHint": "Email", + "passwordHint": "Κωδικός", + "dontHaveAnAccount": "Δεν έχετε λογαριασμό;", + "repeatPasswordEmptyError": "Η επανάληψη κωδικού πρόσβασης δεν μπορεί να είναι κενή", + "unmatchedPasswordError": "Η επανάληψη κωδικού πρόσβασης δεν είναι ίδια με τον κωδικό πρόσβασης", + "syncPromptMessage": "Ο συγχρονισμός των δεδομένων μπορεί να διαρκέσει λίγο. Παρακαλώ μην κλείσετε αυτήν τη σελίδα", + "or": "- Ή -", + "LogInWithGoogle": "Σύνδεση μέσω Google", + "LogInWithGithub": "Σύνδεση μέσω Github", + "LogInWithDiscord": "Σύνδεση μέσω Discord", + "signInWith": "Συνδεθείτε με:" + }, + "workspace": { + "chooseWorkspace": "Επιλέξτε το χώρο εργασίας σας", + "create": "Δημιουργία χώρου εργασίας", + "reset": "Επαναφορά χώρου εργασίας", + "resetWorkspacePrompt": "Η επαναφορά του χώρου εργασίας θα διαγράψει όλες τις σελίδες και τα δεδομένα μέσα σε αυτό. Είστε βέβαιοι ότι θέλετε να επαναφέρετε το χώρο εργασίας? Εναλλακτικά, μπορείτε να επικοινωνήσετε με την ομάδα υποστήριξης για να επαναφέρετε το χώρο εργασίας", + "hint": "workspace", + "notFoundError": "Workspace not found", + "failedToLoad": "Something went wrong! Failed to load the workspace. Try to close any open instance of AppFlowy and try again.", + "errorActions": { + "reportIssue": "Report an issue", + "reportIssueOnGithub": "Report an issue on Github", + "exportLogFiles": "Export log files", + "reachOut": "Reach out on Discord" + }, + "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone.", + "createSuccess": "Workspace created successfully", + "createFailed": "Failed to create workspace", + "deleteSuccess": "Workspace deleted successfully", + "deleteFailed": "Failed to delete workspace", + "openSuccess": "Open workspace successfully", + "openFailed": "Failed to open workspace", + "renameSuccess": "Workspace renamed successfully", + "renameFailed": "Failed to rename workspace", + "updateIconSuccess": "Updated workspace icon successfully", + "updateIconFailed": "Updated workspace icon failed" + }, + "shareAction": { + "buttonText": "Share", + "workInProgress": "Coming soon", + "markdown": "Markdown", + "csv": "CSV", + "copyLink": "Copy Link" + }, + "moreAction": { + "small": "small", + "medium": "medium", + "large": "large", + "fontSize": "Font size", + "import": "Import", + "moreOptions": "More options", + "wordCount": "Word count: {}", + "charCount": "Character count: {}", + "createdAt": "Created: {}", + "deleteView": "Delete", + "duplicateView": "Duplicate" + }, + "importPanel": { + "textAndMarkdown": "Text & Markdown", + "documentFromV010": "Document from v0.1.0", + "databaseFromV010": "Database from v0.1.0", + "csv": "CSV", + "database": "Database" + }, + "disclosureAction": { + "rename": "Rename", + "delete": "Delete", + "duplicate": "Duplicate", + "unfavorite": "Remove from favorites", + "favorite": "Προσθήκη στα αγαπημένα", + "openNewTab": "Άνοιγμα σε νέα καρτέλα", + "moveTo": "Μετακίνηση στο", + "addToFavorites": "Προσθήκη στα Αγαπημένα", + "copyLink": "Αντιγραφή Συνδέσμου" + }, + "blankPageTitle": "Κενή σελίδα", + "newPageText": "Νέα σελίδα", + "newDocumentText": "Νέο έγγραφο", + "newGridText": "Νέο πλέγμα", + "newCalendarText": "Νέο ημερολόγιο", + "newBoardText": "Νέος πίνακας", + "trash": { + "text": "Κάδος απορριμμάτων", + "restoreAll": "Επαναφορά Όλων", + "deleteAll": "Διαγραφή Όλων", + "pageHeader": { + "fileName": "Όνομα αρχείου", + "lastModified": "Τελευταία Τροποποίηση", + "created": "Δημιουργήθηκε" + }, + "confirmDeleteAll": { + "title": "Είστε βέβαιοι οτι θέλετε να διαγράψετε όλες τις σελίδες στον κάδο απορριμμάτων;", + "caption": "Αυτή η ενέργεια δεν μπορεί να ανεραιθεί." + }, + "confirmRestoreAll": { + "title": "Είστε βέβαιοι οτι θέλετε να επαναφέρετε όλες τις σελίδες στον κάδο απορριμμάτων;", + "caption": "Αυτή η ενέργεια δεν μπορεί να ανεραιθεί." + }, + "mobile": { + "actions": "Ενέργειες Απορριμμάτων", + "empty": "Ο κάδος απορριμμάτων είναι άδειος", + "emptyDescription": "Δεν έχετε διαγράψει κανένα αρχείο", + "isDeleted": "έχει διαγραφεί", + "isRestored": "έγινε επαναφορά" + } + }, + "deletePagePrompt": { + "text": "Αυτή η σελίδα βρίσκεται στον κάδο απορριμμάτων", + "restore": "Επαναφορά σελίδας", + "deletePermanent": "Οριστική διαγραφή" + }, + "dialogCreatePageNameHint": "Όνομα σελίδας", + "questionBubble": { + "shortcuts": "Συντομεύσεις", + "whatsNew": "Τι νέο υπάρχει;", + "help": "Βοήθεια & Υποστήριξη", + "markdown": "Markdown", + "debug": { + "name": "Debug Info", + "success": "Copied debug info to clipboard!", + "fail": "Unable to copy debug info to clipboard" + }, + "feedback": "Σχόλια" + }, + "menuAppHeader": { + "moreButtonToolTip": "Αφαίρεση, μετονομασία και άλλα...", + "addPageTooltip": "Γρήγορη προσθήκη σελίδας", + "defaultNewPageName": "Χωρίς τίτλο", + "renameDialog": "Μετονομασία" + }, + "noPagesInside": "Δεν υπάρχουν σελίδες", + "toolbar": { + "undo": "Αναίρεση", + "redo": "Επαναφορά", + "bold": "Έντονo", + "italic": "Πλάγια", + "underline": "Υπογράμμιση", + "strike": "Διακριτή διαγραφή", + "numList": "Αριθμημένη λίστα", + "bulletList": "Bulleted List", + "checkList": "Check List", + "inlineCode": "Inline Code", + "quote": "Quote Block", + "header": "Header", + "highlight": "Highlight", + "color": "Color", + "addLink": "Add Link", + "link": "Link" + }, + "tooltip": { + "lightMode": "Switch to Light mode", + "darkMode": "Switch to Dark mode", + "openAsPage": "Open as a Page", + "addNewRow": "Add a new row", + "openMenu": "Click to open menu", + "dragRow": "Long press to reorder the row", + "viewDataBase": "View database", + "referencePage": "This {name} is referenced", + "addBlockBelow": "Add a block below" + }, + "sideBar": { + "closeSidebar": "Close side bar", + "openSidebar": "Open side bar", + "personal": "Personal", + "favorites": "Favorites", + "clickToHidePersonal": "Click to hide personal section", + "clickToHideFavorites": "Click to hide favorite section", + "addAPage": "Add a page", + "recent": "Recent" + }, + "notifications": { + "export": { + "markdown": "Exported Note To Markdown", + "path": "Documents/flowy" + } + }, + "contactsPage": { + "title": "Contacts", + "whatsHappening": "What's happening this week?", + "addContact": "Add Contact", + "editContact": "Edit Contact" + }, + "button": { + "ok": "OK", + "done": "Done", + "cancel": "Cancel", + "signIn": "Sign In", + "signOut": "Sign Out", + "complete": "Complete", + "save": "Save", + "generate": "Generate", + "esc": "ESC", + "keep": "Keep", + "tryAgain": "Try again", + "discard": "Discard", + "replace": "Replace", + "insertBelow": "Insert below", + "insertAbove": "Εισαγωγή από επάνω", + "upload": "Μεταφόρτωση", + "edit": "Επεξεργασία", + "delete": "Διαγραφή", + "duplicate": "Δημιουργία διπλότυπου", + "putback": "Βάλτε Πίσω", + "update": "Ενημέρωση", + "share": "Κοινοποίηση", + "removeFromFavorites": "Κατάργηση από τα αγαπημένα", + "addToFavorites": "Προσθήκη στα αγαπημένα", + "rename": "Μετονομασία", + "helpCenter": "Κέντρο Βοήθειας", + "add": "Προσθήκη", + "yes": "Ναι", + "clear": "Καθαρισμός", + "remove": "Αφαίρεση", + "dontRemove": "Να μην αφαιρεθεί", + "copyLink": "Αντιγραφή Συνδέσμου", + "align": "Στοίχιση", + "login": "Σύνδεση", + "logout": "Αποσύνδεση", + "deleteAccount": "Διαγραφή λογαριασμού", + "back": "Πίσω", + "signInGoogle": "Συνδεθείτε μέσω λογαριασμού Google", + "signInGithub": "Συνδεθείτε μέσω λογαριασμού Github", + "signInDiscord": "Συνδεθείτε μέσω λογαριασμού Discord" + }, + "label": { + "welcome": "Καλώς ήρθατε!", + "firstName": "Όνομα", + "middleName": "Μεσαίο όνομα", + "lastName": "Επώνυμο", + "stepX": "Step {X}" + }, + "oAuth": { + "err": { + "failedTitle": "Αδυναμία σύνδεσης στο λογαριασμό σας.", + "failedMsg": "Παρακαλούμε βεβαιωθείτε ότι έχετε ολοκληρώσει τη διαδικασία εισόδου στο πρόγραμμα περιήγησης." + }, + "google": { + "title": "GOOGLE SIGN-IN", + "instruction1": "Για να εισαγάγετε τις Επαφές Google σας, θα πρέπει να εξουσιοδοτήσετε αυτήν την εφαρμογή χρησιμοποιώντας το πρόγραμμα περιήγησής σας.", + "instruction2": "Αντιγράψτε αυτόν τον κώδικα στο πρόχειρο κάνοντας κλικ στο εικονίδιο ή επιλέγοντας το κείμενο:", + "instruction3": "Μεταβείτε στον ακόλουθο σύνδεσμο στο πρόγραμμα περιήγησής σας και πληκτρολογήστε τον παραπάνω κωδικό:", + "instruction4": "Πατήστε το κουμπί παρακάτω όταν ολοκληρώσετε την εγγραφή:" + } + }, + "settings": { + "title": "Ρυθμίσεις", + "menu": { + "appearance": "Εμφάνιση", + "language": "Γλώσσα", + "user": "Χρήστης", + "files": "Αρχεία", + "notifications": "Ειδοποιήσεις", + "open": "Άνοιγμα Ρυθμίσεων", + "logout": "Αποσυνδέση", + "logoutPrompt": "Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε;", + "selfEncryptionLogoutPrompt": "Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε; Παρακαλούμε βεβαιωθείτε ότι έχετε αντιγράψει το κρυπτογραφημένο μυστικό", + "syncSetting": "Ρυθμίσεις συγχρονισμού", + "cloudSettings": "Ρυθμίσεις Cloud", + "enableSync": "Enable sync", + "enableEncrypt": "Encrypt data", + "cloudURL": "Base URL", + "invalidCloudURLScheme": "Invalid Scheme", + "cloudServerType": "Cloud server", + "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", + "cloudLocal": "Local", + "cloudSupabase": "Supabase", + "cloudSupabaseUrl": "Supabase URL", + "cloudSupabaseUrlCanNotBeEmpty": "The supabase url can't be empty", + "cloudSupabaseAnonKey": "Supabase anon key", + "cloudSupabaseAnonKeyCanNotBeEmpty": "The anon key can't be empty", + "cloudAppFlowy": "AppFlowy Cloud Beta", + "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", + "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", + "clickToCopy": "Click to copy", + "selfHostStart": "If you don't have a server, please refer to the", + "selfHostContent": "document", + "selfHostEnd": "for guidance on how to self-host your own server", + "cloudURLHint": "Input the base URL of your server", + "cloudWSURL": "Websocket URL", + "cloudWSURLHint": "Input the websocket address of your server", + "restartApp": "Restart", + "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account", + "changeServerTip": "After changing the server, you must click the restart button for the changes to take effect", + "enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy", + "inputEncryptPrompt": "Please enter your encryption secret for", + "clickToCopySecret": "Click to copy secret", + "configServerSetting": "Configurate your server settings", + "configServerGuide": "After selecting `Quick Start`, navigate to `Settings` and then \"Cloud Settings\" to configure your self-hosted server.", + "inputTextFieldHint": "Your secret", + "historicalUserList": "User login history", + "historicalUserListTooltip": "This list displays your anonymous accounts. You can click on an account to view its details. Anonymous accounts are created by clicking the 'Get Started' button", + "openHistoricalUser": "Click to open the anonymous account", + "customPathPrompt": "Storing the AppFlowy data folder in a cloud-synced folder such as Google Drive can pose risks. If the database within this folder is accessed or modified from multiple locations at the same time, it may result in synchronization conflicts and potential data corruption", + "importAppFlowyData": "Import Data from External AppFlowy Folder", + "importingAppFlowyDataTip": "Data import is in progress. Please do not close the app", + "importAppFlowyDataDescription": "Copy data from an external AppFlowy data folder and import it into the current AppFlowy data folder", + "importSuccess": "Successfully imported the AppFlowy data folder", + "importFailed": "Importing the AppFlowy data folder failed", + "importGuide": "For further details, please check the referenced document" + }, + "notifications": { + "enableNotifications": { + "label": "Enable notifications", + "hint": "Turn off to stop local notifications from appearing." + } + }, + "appearance": { + "resetSetting": "Reset", + "fontFamily": { + "label": "Font Family", + "search": "Search" + }, + "themeMode": { + "label": "Theme Mode", + "light": "Light Mode", + "dark": "Σκοτεινό Θέμα", + "system": "Προσαρμογή στο σύστημα" + }, + "fontScaleFactor": "Font Scale Factor", + "documentSettings": { + "cursorColor": "Χρώμα κέρσορα εγγράφου", + "selectionColor": "Χρώμα επιλογής κειμένου", + "hexEmptyError": "Το χρώμα σε δεκαεξαδική μορφή δεν μπορεί να είναι κενό", + "hexLengthError": "Η τιμή δεκαεξαδικού πρέπει να είναι 6 ψηφία", + "hexInvalidError": "Μη έγκυρη τιμή δεκαεξαδικού", + "opacityEmptyError": "Η διαφάνεια δεν μπορεί να είναι κενή", + "opacityRangeError": "Η διαφάνεια πρέπει να είναι μεταξύ 1 και 100", + "app": "Εφαρμογή", + "flowy": "Flowy", + "apply": "Apply" + }, + "layoutDirection": { + "label": "Κατεύθυνση Διάταξης", + "hint": "Ελέγξτε τη ροή του περιεχομένου στην οθόνη σας, από αριστερά προς τα δεξιά ή δεξιά προς τα αριστερά.", + "ltr": "LTR", + "rtl": "RTL" + }, + "textDirection": { + "label": "Προεπιλεγμένη κατεύθυνση κειμένου", + "hint": "Καθορίστε αν το κείμενο θα ξεκινά από αριστερά ή δεξιά ως προεπιλογή.", + "ltr": "LTR", + "rtl": "RTL", + "auto": "AUTO", + "fallback": "Ίδια με την κατεύθυνση διάταξης" + }, + "themeUpload": { + "button": "Μεταφόρτωση", + "uploadTheme": "Μεταφόρτωση θέματος", + "description": "Ανεβάστε το δικό σας θέμα για το AppFlowy χρησιμοποιώντας το παρακάτω κουμπί.", + "loading": "Παρακαλώ περιμένετε ενώ επικυρώνουμε και ανεβάζουμε το θέμα σας...", + "uploadSuccess": "Το θέμα σας μεταφορτώθηκε με επιτυχία", + "deletionFailure": "Αποτυχία διαγραφής του θέματος. Προσπαθήστε να το διαγράψετε χειροκίνητα.", + "filePickerDialogTitle": "Επιλέξτε ένα αρχείο .flowy_plugin", + "urlUploadFailure": "Αποτυχία ανοίγματος url: {}" + }, + "theme": "Θέμα", + "builtInsLabel": "Ενσωματωμένα Θέματα", + "pluginsLabel": "Πρόσθετα", + "dateFormat": { + "label": "Μορφή ημερομηνίας", + "local": "Τοπική", + "us": "US", + "iso": "ISO", + "friendly": "Friendly", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "Μορφή ώρας", + "twelveHour": "12 ώρες", + "twentyFourHour": "24 ώρες" + }, + "showNamingDialogWhenCreatingPage": "Εμφάνιση διαλόγου ονομασίας κατά τη δημιουργία μιας σελίδας", + "enableRTLToolbarItems": "Enable RTL toolbar items", + "members": { + "title": "Members Settings", + "inviteMembers": "Πρόσκληση Μέλους", + "sendInvite": "Αποστολή Πρόσκλησης", + "copyInviteLink": "Αντιγραφή Συνδέσμου Πρόσκλησης", + "label": "Μέλη", + "user": "User", + "role": "Role", + "removeFromWorkspace": "Remove from Workspace", + "owner": "Owner", + "guest": "Guest", + "member": "Member", + "memberHintText": "A member can read, comment, and edit pages. Invite members and guests.", + "guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.", + "emailInvalidError": "Invalid email, please check and try again", + "emailSent": "Email sent, please check the inbox", + "members": "members" + } + }, + "files": { + "copy": "Copy", + "defaultLocation": "Read files and data storage location", + "exportData": "Export your data", + "doubleTapToCopy": "Double tap to copy the path", + "restoreLocation": "Restore to AppFlowy default path", + "customizeLocation": "Open another folder", + "restartApp": "Please restart app for the changes to take effect.", + "exportDatabase": "Export database", + "selectFiles": "Select the files that need to be export", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "createNewFolder": "Create a new folder", + "createNewFolderDesc": "Tell us where you want to store your data", + "defineWhereYourDataIsStored": "Define where your data is stored", + "open": "Open", + "openFolder": "Open an existing folder", + "openFolderDesc": "Read and write it to your existing AppFlowy folder", + "folderHintText": "folder name", + "location": "Creating a new folder", + "locationDesc": "Pick a name for your AppFlowy data folder", + "browser": "Browse", + "create": "Create", + "set": "Set", + "folderPath": "Path to store your folder", + "locationCannotBeEmpty": "Path cannot be empty", + "pathCopiedSnackbar": "File storage path copied to clipboard!", + "changeLocationTooltips": "Change the data directory", + "change": "Αλλαγή", + "openLocationTooltips": "Open another data directory", + "openCurrentDataFolder": "Άνοιγμα του τρέχοντος φακέλου", + "recoverLocationTooltips": "Reset to AppFlowy's default data directory", + "exportFileSuccess": "Επιτυχής εξαγωγή αρχείου!", + "exportFileFail": "Η εξαγωγή αρχείου απέτυχε!", + "export": "Εξαγωγή", + "clearCache": "Εκκαθάριση προσωρινής μνήμης", + "clearCacheDesc": "Αν αντιμετωπίζετε προβλήματα με εικόνες που δεν φορτώνουν ή γραμματοσειρές που δεν εμφανίζονται σωστά, δοκιμάστε να καθαρίσετε την προσωρινή μνήμη. Αυτή η ενέργεια δεν θα διαγράψει τα δεδομένα χρήστη σας.", + "areYouSureToClearCache": "Σίγουρα θέλετε να καθαρίσετε την προσωρινή μνήμη;", + "clearCacheSuccess": "Επιτυχής εκκαθάριση προσωρινής μνήμης!" + }, + "user": { + "name": "Όνομα", + "email": "Email", + "tooltipSelectIcon": "Select icon", + "selectAnIcon": "Select an icon", + "pleaseInputYourOpenAIKey": "παρακαλώ εισάγετε το OpenAI κλειδί σας", + "pleaseInputYourStabilityAIKey": "παρακαλώ εισάγετε το Stability AI κλειδί σας", + "clickToLogout": "Κάντε κλικ για αποσύνδεση του τρέχοντος χρήστη" + }, + "shortcuts": { + "shortcutsLabel": "Συντομεύσεις", + "command": "Command", + "keyBinding": "Keybinding", + "addNewCommand": "Προσθήκη Νέας Εντολής", + "updateShortcutStep": "Πατήστε τον επιθυμητό συνδυασμό πλήκτρων και πατήστε ENTER", + "shortcutIsAlreadyUsed": "Αυτή η συντόμευση χρησιμοποιείται ήδη για: {conflict}", + "resetToDefault": "Επαναφορά προεπιλεγμένων συντομεύσεων πληκτρολογίου", + "couldNotLoadErrorMsg": "Αδυναμία φόρτωσης συντομεύσεων, Προσπαθήστε ξανά", + "couldNotSaveErrorMsg": "Δεν ήταν δυνατή η αποθήκευση συντομεύσεων, Προσπαθήστε ξανά" + }, + "mobile": { + "personalInfo": "Προσωπικά Στοιχεία", + "username": "Όνομα Χρήστη", + "usernameEmptyError": "Το όνομα χρήστη δεν μπορεί να είναι κενό", + "about": "Σχετικά", + "pushNotifications": "Ειδοποιήσεις Push", + "support": "Υποστήριξη", + "joinDiscord": "Ελάτε μαζί μας στο Discord", + "privacyPolicy": "Πολιτική Απορρήτου", + "userAgreement": "Όροι Χρήσης", + "termsAndConditions": "Όροι και Προϋποθέσεις", + "userprofileError": "Αποτυχία φόρτωσης προφίλ χρήστη", + "userprofileErrorDescription": "Παρακαλώ προσπαθήστε να αποσυνδεθείτε και να συνδεθείτε ξανά για να ελέγξετε αν το πρόβλημα εξακολουθεί να υπάρχει.", + "selectLayout": "Επιλέξτε διάταξη", + "selectStartingDay": "Επιλέξτε ημέρα έναρξης", + "version": "Έκδοση" + } + }, + "grid": { + "deleteView": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν τη προβολή;", + "createView": "Νέο", + "title": { + "placeholder": "Χωρίς τίτλο" + }, + "settings": { + "filter": "Φίλτρο", + "sort": "Ταξινόμηση", + "sortBy": "Ταξινόμηση κατά", + "properties": "Properties", + "reorderPropertiesTooltip": "Drag to reorder properties", + "group": "Group", + "addFilter": "Add Filter", + "deleteFilter": "Delete filter", + "filterBy": "Filter by...", + "typeAValue": "Type a value...", + "layout": "Layout", + "databaseLayout": "Layout", + "viewList": { + "zero": "0 views", + "one": "{count} view", + "other": "{count} views" + }, + "editView": "Edit View", + "boardSettings": "Board settings", + "calendarSettings": "Calendar settings", + "createView": "New view", + "duplicateView": "Duplicate view", + "deleteView": "Delete view", + "numberOfVisibleFields": "{} shown" + }, + "textFilter": { + "contains": "Contains", + "doesNotContain": "Does not contain", + "endsWith": "Ends with", + "startWith": "Starts with", + "is": "Is", + "isNot": "Is not", + "isEmpty": "Is empty", + "isNotEmpty": "Is not empty", + "choicechipPrefix": { + "isNot": "Not", + "startWith": "Starts with", + "endWith": "Ends with", + "isEmpty": "is empty", + "isNotEmpty": "is not empty" + } + }, + "checkboxFilter": { + "isChecked": "Checked", + "isUnchecked": "Unchecked", + "choicechipPrefix": { + "is": "is" + } + }, + "checklistFilter": { + "isComplete": "is complete", + "isIncomplted": "is incomplete" + }, + "selectOptionFilter": { + "is": "Is", + "isNot": "Is not", + "contains": "Contains", + "doesNotContain": "Does not contain", + "isEmpty": "Είναι κενό", + "isNotEmpty": "Δεν είναι κενό" + }, + "dateFilter": { + "is": "Is", + "before": "Is before", + "after": "Is after", + "onOrBefore": "Is on or before", + "onOrAfter": "Is on or after", + "between": "Is between", + "empty": "Είναι κενό", + "notEmpty": "Δεν είναι κενό", + "choicechipPrefix": { + "before": "Before", + "after": "After", + "onOrBefore": "On or before", + "onOrAfter": "On or after", + "isEmpty": "Is empty", + "isNotEmpty": "Is not empty" + } + }, + "numberFilter": { + "equal": "Equals", + "notEqual": "Δεν ισούται με", + "lessThan": "Είναι μικρότερο από", + "greaterThan": "Είναι μεγαλύτερο από", + "lessThanOrEqualTo": "Είναι μικρότερο από ή ίσο με", + "greaterThanOrEqualTo": "Είναι μεγαλύτερο από ή ίσο με", + "isEmpty": "Είναι κενό", + "isNotEmpty": "Δεν είναι κενό" + }, + "field": { + "hide": "Απόκρυψη", + "show": "Εμφάνιση", + "insertLeft": "Εισαγωγή από αριστερά", + "insertRight": "Εισαγωγή από δεξιά", + "duplicate": "Διπλότυπο", + "delete": "Διαγραφή", + "textFieldName": "Κείμενο", + "checkboxFieldName": "Checkbox", + "dateFieldName": "Date", + "updatedAtFieldName": "Τελευταία τροποποίηση", + "createdAtFieldName": "Δημιουργήθηκε στις", + "numberFieldName": "Numbers", + "singleSelectFieldName": "Επιλογή", + "multiSelectFieldName": "Multiselect", + "urlFieldName": "URL", + "checklistFieldName": "Checklist", + "relationFieldName": "Relation", + "numberFormat": "Μορφή αριθμού", + "dateFormat": "Μορφή ημερομηνίας", + "includeTime": "Περιλαμβάνει χρόνο", + "isRange": "End date", + "dateFormatFriendly": "Μήνας Ημέρα, Έτος", + "dateFormatISO": "Έτος-Μήνας-Ημέρα", + "dateFormatLocal": "Μήνας/Ημέρα/Έτος", + "dateFormatUS": "Έτος/Μήνας/Ημέρα", + "dateFormatDayMonthYear": "Ημέρα/Μήνας/Έτος", + "timeFormat": "Time format", + "invalidTimeFormat": "Invalid format", + "timeFormatTwelveHour": "12 hour", + "timeFormatTwentyFourHour": "24 hour", + "clearDate": "Clear date", + "dateTime": "Date time", + "startDateTime": "Start date time", + "endDateTime": "End date time", + "failedToLoadDate": "Failed to load date value", + "selectTime": "Select time", + "selectDate": "Select date", + "visibility": "Visibility", + "propertyType": "Property type", + "addSelectOption": "Add an option", + "typeANewOption": "Type a new option", + "optionTitle": "Options", + "addOption": "Add option", + "editProperty": "Edit property", + "newProperty": "New property", + "deleteFieldPromptMessage": "Are you sure? This property will be deleted", + "newColumn": "New Column", + "format": "Format", + "reminderOnDateTooltip": "This cell has a scheduled reminder", + "optionAlreadyExist": "Option already exists" + }, + "rowPage": { + "newField": "Add a new field", + "fieldDragElementTooltip": "Click to open menu", + "showHiddenFields": { + "one": "Show {count} hidden field", + "many": "Show {count} hidden fields", + "other": "Show {count} hidden fields" + }, + "hideHiddenFields": { + "one": "Απόκρυψη {count} κρυφού πεδίου", + "many": "Απόκρυψη {count} κρυφών πεδίων", + "other": "Απόκρυψη {count} κρυφών πεδίων" + } + }, + "sort": { + "ascending": "Αύξουσα", + "descending": "Φθίνουσα", + "by": "By", + "empty": "No active sorts", + "cannotFindCreatableField": "Αδυναμία εύρεσης κατάλληλου πεδίου για ταξινόμηση", + "deleteAllSorts": "Delete all sorts", + "addSort": "Add new sort", + "removeSorting": "Θα θέλατε να αφαιρέσετε τη ταξινόμηση;", + "fieldInUse": "You are already sorting by this field" + }, + "row": { + "duplicate": "Duplicate", + "delete": "Διαγραφή στήλης", + "titlePlaceholder": "Χωρίς τίτλο", + "textPlaceholder": "Άδειο", + "copyProperty": "Copied property to clipboard", + "count": "Count", + "newRow": "Νέα γραμμή", + "action": "Action", + "add": "Click add to below", + "drag": "Σύρετε για μετακίνηση", + "dragAndClick": "Drag to move, click to open menu", + "insertRecordAbove": "Εισαγωγή εγγραφής επάνω", + "insertRecordBelow": "Εισαγωγή εγγραφής κάτω" + }, + "selectOption": { + "create": "Δημιουργία", + "purpleColor": "Μωβ", + "pinkColor": "Ροζ", + "lightPinkColor": "Απαλό ροζ", + "orangeColor": "Πορτοκαλί", + "yellowColor": "Κίτρινο", + "limeColor": "Λάιμ", + "greenColor": "Πράσινο", + "aquaColor": "Θαλασσί", + "blueColor": "Μπλέ", + "deleteTag": "Διαγραφή ετικέτας", + "colorPanelTitle": "Χρώμα", + "panelTitle": "Select an option or create one", + "searchOption": "Search for an option", + "searchOrCreateOption": "Search or create an option...", + "createNew": "Δημιουργία νέας", + "orSelectOne": "Or select an option", + "typeANewOption": "Type a new option", + "tagName": "Όνομα ετικέτας" + }, + "checklist": { + "taskHint": "Περιγραφή εργασίας", + "addNew": "Προσθήκη νέας εργασίας", + "submitNewTask": "Δημιουργία", + "hideComplete": "Απόκρυψη ολοκληρωμένων εργασιών", + "showComplete": "Εμφάνιση όλων των εργασιών" + }, + "url": { + "launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης", + "copy": "Copy link to clipboard", + "textFieldHint": "Enter a URL", + "copiedNotification": "Copied to clipboard!" + }, + "relation": { + "relatedDatabasePlaceLabel": "Related Database", + "relatedDatabasePlaceholder": "None", + "inRelatedDatabase": "In", + "rowSearchTextFieldPlaceholder": "Search", + "noDatabaseSelected": "No database selected, please select one first from the list below:", + "emptySearchResult": "No records found" + }, + "menuName": "Grid", + "referencedGridPrefix": "View of", + "calculate": "Calculate", + "calculationTypeLabel": { + "none": "None", + "average": "Average", + "max": "Max", + "median": "Median", + "min": "Min", + "sum": "Sum", + "count": "Count", + "countEmpty": "Count empty", + "countEmptyShort": "EMPTY", + "countNonEmpty": "Count not empty", + "countNonEmptyShort": "FILLED" + } + }, + "document": { + "menuName": "Document", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "slashMenu": { + "board": { + "selectABoardToLinkTo": "Select a Board to link to", + "createANewBoard": "Create a new Board" + }, + "grid": { + "selectAGridToLinkTo": "Select a Grid to link to", + "createANewGrid": "Create a new Grid" + }, + "calendar": { + "selectACalendarToLinkTo": "Select a Calendar to link to", + "createANewCalendar": "Create a new Calendar" + }, + "document": { + "selectADocumentToLinkTo": "Select a Document to link to" + } + }, + "selectionMenu": { + "outline": "Outline", + "codeBlock": "Code Block" + }, + "plugins": { + "referencedBoard": "Referenced Board", + "referencedGrid": "Referenced Grid", + "referencedCalendar": "Referenced Calendar", + "referencedDocument": "Referenced Document", + "autoGeneratorMenuItemName": "OpenAI Writer", + "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", + "autoGeneratorLearnMore": "Μάθετε περισσότερα", + "autoGeneratorGenerate": "Generate", + "autoGeneratorHintText": "Ρωτήστε Το OpenAI ...", + "autoGeneratorCantGetOpenAIKey": "Αδυναμία λήψης κλειδιού OpenAI", + "autoGeneratorRewrite": "Rewrite", + "smartEdit": "AI Assistants", + "openAI": "OpenAI", + "smartEditFixSpelling": "Διόρθωση ορθογραφίας", + "warning": "⚠️ Οι απαντήσεις AI μπορεί να είναι ανακριβείς ή παραπλανητικές.", + "smartEditSummarize": "Summarize", + "smartEditImproveWriting": "Improve writing", + "smartEditMakeLonger": "Make longer", + "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI", + "smartEditCouldNotFetchKey": "Could not fetch OpenAI key", + "smartEditDisabled": "Connect OpenAI in Settings", + "discardResponse": "Do you want to discard the AI responses?", + "createInlineMathEquation": "Create equation", + "fonts": "Γραμματοσειρές", + "toggleList": "Toggle list", + "quoteList": "Quote list", + "numberedList": "Αριθμημένη λίστα", + "bulletedList": "Bulleted list", + "todoList": "Todo List", + "callout": "Callout", + "cover": { + "changeCover": "Change Cover", + "colors": "Χρώματα", + "images": "Εικόνες", + "clearAll": "Εκκαθάριση όλων", + "abstract": "Abstract", + "addCover": "Προσθέστε ένα εξώφυλλο", + "addLocalImage": "Add local image", + "invalidImageUrl": "Μη έγκυρο URL εικόνας", + "failedToAddImageToGallery": "Failed to add image to gallery", + "enterImageUrl": "Enter image URL", + "add": "Add", + "back": "Back", + "saveToGallery": "Save to gallery", + "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", + "changeIcon": "Change icon", + "coverRemoveAlert": "It will be removed from cover after it is deleted.", + "alertDialogConfirmation": "Are you sure, you want to continue?" + }, + "mathEquation": { + "name": "Math Equation", + "addMathEquation": "Add a TeX equation", + "editMathEquation": "Edit Math Equation" + }, + "optionAction": { + "click": "Click", + "toOpenMenu": " to open menu", + "delete": "Delete", + "duplicate": "Duplicate", + "turnInto": "Turn into", + "moveUp": "Move up", + "moveDown": "Move down", + "color": "Color", + "align": "Align", + "left": "Left", + "center": "Center", + "right": "Right", + "defaultColor": "Default", + "depth": "Depth" + }, + "image": { + "copiedToPasteBoard": "The image link has been copied to the clipboard", + "addAnImage": "Add an image", + "imageUploadFailed": "Image upload failed" + }, + "urlPreview": { + "copiedToPasteBoard": "The link has been copied to the clipboard", + "convertToLink": "Convert to embed link" + }, + "outline": { + "addHeadingToCreateOutline": "Add headings to create a table of contents.", + "noMatchHeadings": "No matching headings found." + }, + "table": { + "addAfter": "Add after", + "addBefore": "Add before", + "delete": "Delete", + "clear": "Clear content", + "duplicate": "Duplicate", + "bgColor": "Background color" + }, + "contextMenu": { + "copy": "Copy", + "cut": "Cut", + "paste": "Paste" + }, + "action": "Actions", + "database": { + "selectDataSource": "Select data source", + "noDataSource": "No data source", + "selectADataSource": "Select a data source", + "toContinue": "to continue", + "newDatabase": "New Database", + "linkToDatabase": "Link to Database" + }, + "date": "Date", + "emoji": "Emoji" + }, + "outlineBlock": { + "placeholder": "Table of Contents" + }, + "textBlock": { + "placeholder": "Type '/' for commands" + }, + "title": { + "placeholder": "Untitled" + }, + "imageBlock": { + "placeholder": "Click to add image", + "upload": { + "label": "Upload", + "placeholder": "Click to upload image" + }, + "url": { + "label": "Image URL", + "placeholder": "Enter image URL" + }, + "ai": { + "label": "Generate image from OpenAI", + "placeholder": "Please input the prompt for OpenAI to generate image" + }, + "stability_ai": { + "label": "Generate image from Stability AI", + "placeholder": "Please input the prompt for Stability AI to generate image" + }, + "support": "Image size limit is 5MB. Supported formats: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "Invalid image", + "invalidImageSize": "Image size must be less than 5MB", + "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "Invalid image URL", + "noImage": "No such file or directory" + }, + "embedLink": { + "label": "Embed link", + "placeholder": "Paste or type an image link" + }, + "unsplash": { + "label": "Unsplash" + }, + "searchForAnImage": "Search for an image", + "pleaseInputYourOpenAIKey": "please input your OpenAI 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", + "maximumImageSize": "Maximum supported upload image size is 10MB", + "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", + "imageIsUploading": "Image is uploading" + }, + "codeBlock": { + "language": { + "label": "Language", + "placeholder": "Select language" + } + }, + "inlineLink": { + "placeholder": "Paste or type a link", + "openInNewTab": "Open in new tab", + "copyLink": "Copy link", + "removeLink": "Remove link", + "url": { + "label": "Link URL", + "placeholder": "Enter link URL" + }, + "title": { + "label": "Link Title", + "placeholder": "Enter link title" + } + }, + "mention": { + "placeholder": "Mention a person or a page or date...", + "page": { + "label": "Link to page", + "tooltip": "Click to open page" + }, + "deleted": "Deleted", + "deletedContent": "This content does not exist or has been deleted" + }, + "toolbar": { + "resetToDefaultFont": "Reset to default" + }, + "errorBlock": { + "theBlockIsNotSupported": "The current version does not support this block.", + "blockContentHasBeenCopied": "The block content has been copied." + } + }, + "board": { + "column": { + "createNewCard": "New", + "renameGroupTooltip": "Press to rename group", + "createNewColumn": "Add a new group", + "addToColumnTopTooltip": "Add a new card at the top", + "addToColumnBottomTooltip": "Add a new card at the bottom", + "renameColumn": "Rename", + "hideColumn": "Hide", + "newGroup": "New Group", + "deleteColumn": "Delete", + "deleteColumnConfirmation": "This will delete this group and all the cards in it.\nAre you sure you want to continue?" + }, + "hiddenGroupSection": { + "sectionTitle": "Hidden Groups", + "collapseTooltip": "Hide the hidden groups", + "expandTooltip": "View the hidden groups" + }, + "cardDetail": "Card Detail", + "cardActions": "Card Actions", + "cardDuplicated": "Card has been duplicated", + "cardDeleted": "Card has been deleted", + "showOnCard": "Show on card detail", + "setting": "Setting", + "propertyName": "Property name", + "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", + "notesTooltip": "Notes inside", + "mobile": { + "editURL": "Edit URL", + "unhideGroup": "Unhide group", + "unhideGroupContent": "Are you sure you want to show this group on the board?", + "faildToLoad": "Failed to load board view" + } + }, + "calendar": { + "menuName": "Calendar", + "defaultNewCalendarTitle": "Untitled", + "newEventButtonTooltip": "Add a new event", + "navigation": { + "today": "Today", + "jumpToday": "Jump to Today", + "previousMonth": "Previous Month", + "nextMonth": "Next Month" + }, + "mobileEventScreen": { + "emptyTitle": "No events yet", + "emptyBody": "Press the plus button to create an event on this day." + }, + "settings": { + "showWeekNumbers": "Show week numbers", + "showWeekends": "Show weekends", + "firstDayOfWeek": "Start week on", + "layoutDateField": "Layout calendar by", + "changeLayoutDateField": "Change layout field", + "noDateTitle": "No Date", + "noDateHint": { + "zero": "Unscheduled events will show up here", + "one": "{count} unscheduled event", + "other": "{count} unscheduled events" + }, + "unscheduledEventsTitle": "Unscheduled events", + "clickToAdd": "Click to add to the calendar", + "name": "Calendar settings" + }, + "referencedCalendarPrefix": "View of", + "quickJumpYear": "Jump to", + "duplicateEvent": "Duplicate event" + }, + "errorDialog": { + "title": "AppFlowy Error", + "howToFixFallback": "We're sorry for the inconvenience! Submit an issue on our GitHub page that describes your error.", + "github": "View on GitHub" + }, + "search": { + "label": "Search", + "placeholder": { + "actions": "Search actions..." + } + }, + "message": { + "copy": { + "success": "Copied!", + "fail": "Unable to copy" + } + }, + "unSupportBlock": "The current version does not support this Block.", + "views": { + "deleteContentTitle": "Are you sure want to delete the {pageType}?", + "deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash." + }, + "colors": { + "custom": "Custom", + "default": "Default", + "red": "Red", + "orange": "Orange", + "yellow": "Yellow", + "green": "Green", + "blue": "Blue", + "purple": "Purple", + "pink": "Pink", + "brown": "Brown", + "gray": "Gray" + }, + "emoji": { + "emojiTab": "Emoji", + "search": "Search emoji", + "noRecent": "No recent emoji", + "noEmojiFound": "No emoji found", + "filter": "Filter", + "random": "Random", + "selectSkinTone": "Select skin tone", + "remove": "Remove emoji", + "categories": { + "smileys": "Smileys & Emotion", + "people": "People & Body", + "animals": "Animals & Nature", + "food": "Food & Drink", + "activities": "Activities", + "places": "Travel & Places", + "objects": "Objects", + "symbols": "Symbols", + "flags": "Flags", + "nature": "Nature", + "frequentlyUsed": "Frequently Used" + }, + "skinTone": { + "default": "Default", + "light": "Light", + "mediumLight": "Medium-Light", + "medium": "Medium", + "mediumDark": "Medium-Dark", + "dark": "Dark" + } + }, + "inlineActions": { + "noResults": "No results", + "pageReference": "Page reference", + "docReference": "Document reference", + "boardReference": "Board reference", + "calReference": "Calendar reference", + "gridReference": "Grid reference", + "date": "Date", + "reminder": { + "groupTitle": "Reminder", + "shortKeyword": "remind" + } + }, + "datePicker": { + "dateTimeFormatTooltip": "Change the date and time format in settings", + "dateFormat": "Date format", + "includeTime": "Include time", + "isRange": "End date", + "timeFormat": "Time format", + "clearDate": "Clear date", + "reminderLabel": "Reminder", + "selectReminder": "Select reminder", + "reminderOptions": { + "none": "None", + "atTimeOfEvent": "Time of event", + "fiveMinsBefore": "5 mins before", + "tenMinsBefore": "10 mins before", + "fifteenMinsBefore": "15 mins before", + "thirtyMinsBefore": "30 mins before", + "oneHourBefore": "1 hour before", + "twoHoursBefore": "2 hours before", + "onDayOfEvent": "On day of event", + "oneDayBefore": "1 day before", + "twoDaysBefore": "2 days before", + "oneWeekBefore": "1 week before", + "custom": "Custom" + } + }, + "relativeDates": { + "yesterday": "Yesterday", + "today": "Today", + "tomorrow": "Tomorrow", + "oneWeek": "1 week" + }, + "notificationHub": { + "title": "Notifications", + "mobile": { + "title": "Updates" + }, + "emptyTitle": "All caught up!", + "emptyBody": "No pending notifications or actions. Enjoy the calm.", + "tabs": { + "inbox": "Inbox", + "upcoming": "Upcoming" + }, + "actions": { + "markAllRead": "Mark all as read", + "showAll": "All", + "showUnreads": "Unread" + }, + "filters": { + "ascending": "Ascending", + "descending": "Descending", + "groupByDate": "Group by date", + "showUnreadsOnly": "Show unreads only", + "resetToDefault": "Reset to default" + } + }, + "reminderNotification": { + "title": "Reminder", + "message": "Remember to check this before you forget!", + "tooltipDelete": "Delete", + "tooltipMarkRead": "Mark as read", + "tooltipMarkUnread": "Mark as unread" + }, + "findAndReplace": { + "find": "Find", + "previousMatch": "Previous match", + "nextMatch": "Next match", + "close": "Close", + "replace": "Replace", + "replaceAll": "Replace all", + "noResult": "No results", + "caseSensitive": "Case sensitive", + "searchMore": "Search to find more results" + }, + "error": { + "weAreSorry": "We're sorry", + "loadingViewError": "We're having trouble loading this view. Please check your internet connection, refresh the app, and do not hesitate to reach out to the team if the issue continues." + }, + "editor": { + "bold": "Bold", + "bulletedList": "Bulleted List", + "bulletedListShortForm": "Bulleted", + "checkbox": "Checkbox", + "embedCode": "Embed Code", + "heading1": "H1", + "heading2": "H2", + "heading3": "H3", + "highlight": "Highlight", + "color": "Color", + "image": "Image", + "date": "Date", + "italic": "Italic", + "link": "Link", + "numberedList": "Numbered List", + "numberedListShortForm": "Numbered", + "quote": "Quote", + "strikethrough": "Strikethrough", + "text": "Text", + "underline": "Underline", + "fontColorDefault": "Default", + "fontColorGray": "Gray", + "fontColorBrown": "Brown", + "fontColorOrange": "Orange", + "fontColorYellow": "Yellow", + "fontColorGreen": "Green", + "fontColorBlue": "Blue", + "fontColorPurple": "Purple", + "fontColorPink": "Pink", + "fontColorRed": "Red", + "backgroundColorDefault": "Default background", + "backgroundColorGray": "Gray background", + "backgroundColorBrown": "Brown background", + "backgroundColorOrange": "Orange background", + "backgroundColorYellow": "Yellow background", + "backgroundColorGreen": "Green background", + "backgroundColorBlue": "Blue background", + "backgroundColorPurple": "Purple background", + "backgroundColorPink": "Pink background", + "backgroundColorRed": "Red background", + "backgroundColorLime": "Lime background", + "backgroundColorAqua": "Aqua background", + "done": "Done", + "cancel": "Cancel", + "tint1": "Tint 1", + "tint2": "Tint 2", + "tint3": "Tint 3", + "tint4": "Tint 4", + "tint5": "Tint 5", + "tint6": "Tint 6", + "tint7": "Tint 7", + "tint8": "Tint 8", + "tint9": "Tint 9", + "lightLightTint1": "Purple", + "lightLightTint2": "Pink", + "lightLightTint3": "Light Pink", + "lightLightTint4": "Orange", + "lightLightTint5": "Yellow", + "lightLightTint6": "Lime", + "lightLightTint7": "Green", + "lightLightTint8": "Aqua", + "lightLightTint9": "Blue", + "urlHint": "URL", + "mobileHeading1": "Heading 1", + "mobileHeading2": "Heading 2", + "mobileHeading3": "Heading 3", + "textColor": "Text Color", + "backgroundColor": "Background Color", + "addYourLink": "Add your link", + "openLink": "Open link", + "copyLink": "Copy link", + "removeLink": "Remove link", + "editLink": "Edit link", + "linkText": "Text", + "linkTextHint": "Please enter text", + "linkAddressHint": "Please enter URL", + "highlightColor": "Highlight color", + "clearHighlightColor": "Clear highlight color", + "customColor": "Custom color", + "hexValue": "Hex value", + "opacity": "Opacity", + "resetToDefaultColor": "Reset to default color", + "ltr": "LTR", + "rtl": "RTL", + "auto": "Auto", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "find": "Find", + "previousMatch": "Previous match", + "nextMatch": "Next match", + "closeFind": "Close", + "replace": "Replace", + "replaceAll": "Replace all", + "regex": "Regex", + "caseSensitive": "Case sensitive", + "uploadImage": "Upload Image", + "urlImage": "URL Image", + "incorrectLink": "Incorrect Link", + "upload": "Upload", + "chooseImage": "Choose an image", + "loading": "Loading", + "imageLoadFailed": "Could not load the image", + "divider": "Divider", + "table": "Table", + "colAddBefore": "Add before", + "rowAddBefore": "Add before", + "colAddAfter": "Add after", + "rowAddAfter": "Add after", + "colRemove": "Remove", + "rowRemove": "Remove", + "colDuplicate": "Duplicate", + "rowDuplicate": "Duplicate", + "colClear": "Clear Content", + "rowClear": "Clear Content", + "slashPlaceHolder": "Type '/' to insert a block, or start typing", + "typeSomething": "Type something...", + "toggleListShortForm": "Toggle", + "quoteListShortForm": "Quote", + "mathEquationShortForm": "Formula", + "codeBlockShortForm": "Code" + }, + "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" + }, + "blockPlaceholders": { + "todoList": "To-do", + "bulletList": "List", + "numberList": "List", + "quote": "Quote", + "heading": "Heading {}" + }, + "titleBar": { + "pageIcon": "Page icon", + "language": "Language", + "font": "Font", + "actions": "Actions", + "date": "Date", + "addField": "Add field", + "userIcon": "User icon" + }, + "noLogFiles": "There're no log files", + "newSettings": { + "myAccount": { + "title": "My account", + "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", + "profileLabel": "Account name & Profile image", + "profileNamePlaceholder": "Enter your name", + "accountSecurity": "Account security", + "2FA": "2-Step Authentication", + "aiKeys": "AI keys", + "accountLogin": "Account Login", + "updateNameError": "Failed to update name", + "updateIconError": "Failed to update icon", + "deleteAccount": { + "title": "Delete Account", + "subtitle": "Permanently delete your account and all of your data.", + "deleteMyAccount": "Delete my account", + "dialogTitle": "Delete account", + "dialogContent1": "Are you sure you want to permanently delete your account?", + "dialogContent2": "This action cannot be undone, and will remove access from all teamspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces." + } + }, + "workplace": { + "name": "Workplace", + "title": "Workplace Settings", + "subtitle": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", + "workplaceName": "Workplace name", + "workplaceNamePlaceholder": "Enter workplace name", + "workplaceIcon": "Workplace icon", + "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications", + "renameError": "Failed to rename workplace", + "updateIconError": "Failed to update icon", + "appearance": { + "name": "Appearance", + "themeMode": { + "auto": "Auto", + "light": "Light", + "dark": "Dark" + }, + "language": "Language" + } + } + } +} \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 20c3e1c9e14c0..bc840b3f58351 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -9,6 +9,7 @@ "title": "Title", "youCanAlso": "You can also", "and": "and", + "failedToOpenUrl": "Failed to open url: {}", "blockActions": { "addBelowTooltip": "Click to add below", "addAboveCmd": "Alt+click", @@ -63,7 +64,21 @@ "reportIssueOnGithub": "Report an issue on Github", "exportLogFiles": "Export log files", "reachOut": "Reach out on Discord" - } + }, + "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone.", + "createSuccess": "Workspace created successfully", + "createFailed": "Failed to create workspace", + "createLimitExceeded": "You've reached the maximum workspace limit allowed for your account. If you need additional workspaces to continue your work, please request on Github", + "deleteSuccess": "Workspace deleted successfully", + "deleteFailed": "Failed to delete workspace", + "openSuccess": "Open workspace successfully", + "openFailed": "Failed to open workspace", + "renameSuccess": "Workspace renamed successfully", + "renameFailed": "Failed to rename workspace", + "updateIconSuccess": "Updated workspace icon successfully", + "updateIconFailed": "Updated workspace icon failed", + "cannotDeleteTheOnlyWorkspace": "Cannot delete the only workspace", + "fetchWorkspacesFailed": "Failed to fetch workspaces" }, "shareAction": { "buttonText": "Share", @@ -78,7 +93,12 @@ "large": "large", "fontSize": "Font size", "import": "Import", - "moreOptions": "More options" + "moreOptions": "More options", + "wordCount": "Word count: {}", + "charCount": "Character count: {}", + "createdAt": "Created: {}", + "deleteView": "Delete", + "duplicateView": "Duplicate" }, "importPanel": { "textAndMarkdown": "Text & Markdown", @@ -127,7 +147,8 @@ "emptyDescription": "You don't have any deleted file", "isDeleted": "is deleted", "isRestored": "is restored" - } + }, + "confirmDeleteTitle": "Are you sure you want to delete this page permanently?" }, "deletePagePrompt": { "text": "This page is in Trash", @@ -181,18 +202,22 @@ "dragRow": "Long press to reorder the row", "viewDataBase": "View database", "referencePage": "This {name} is referenced", - "addBlockBelow": "Add a block below", - "urlLaunchAccessory": "Open in browser", - "urlCopyAccessory": "Copy URL" + "addBlockBelow": "Add a block below" }, "sideBar": { "closeSidebar": "Close side bar", "openSidebar": "Open side bar", "personal": "Personal", + "private": "Private", + "public": "Public", "favorites": "Favorites", + "clickToHidePrivate": "Click to hide private section\nPages you created here are only visible to you", + "clickToHidePublic": "Click to hide public section\nPages you created here are visible to every member", "clickToHidePersonal": "Click to hide personal section", "clickToHideFavorites": "Click to hide favorite section", "addAPage": "Add a page", + "addAPageToPrivate": "Add a page to private section", + "addAPageToPublic": "Add a page to public section", "recent": "Recent" }, "notifications": { @@ -235,7 +260,19 @@ "rename": "Rename", "helpCenter": "Help Center", "add": "Add", - "yes": "Yes" + "yes": "Yes", + "clear": "Clear", + "remove": "Remove", + "dontRemove": "Don't remove", + "copyLink": "Copy Link", + "align": "Align", + "login": "Login", + "logout": "Log out", + "deleteAccount": "Delete account", + "back": "Back", + "signInGoogle": "Sign in with Google", + "signInGithub": "Sign in with Github", + "signInDiscord": "Sign in with Discord" }, "label": { "welcome": "Welcome!", @@ -384,7 +421,36 @@ "twelveHour": "Twelve hour", "twentyFourHour": "Twenty four hour" }, - "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page" + "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page", + "enableRTLToolbarItems": "Enable RTL toolbar items", + "members": { + "title": "Members Settings", + "inviteMembers": "Invite Members", + "sendInvite": "Send Invite", + "copyInviteLink": "Copy Invite Link", + "label": "Members", + "user": "User", + "role": "Role", + "removeFromWorkspace": "Remove from Workspace", + "owner": "Owner", + "guest": "Guest", + "member": "Member", + "memberHintText": "A member can read, comment, and edit pages. Invite members and guests.", + "guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.", + "emailInvalidError": "Invalid email, please check and try again", + "emailSent": "Email sent, please check the inbox", + "members": "members", + "membersCount": { + "zero": "{} members", + "one": "{} member", + "other": "{} members" + }, + "memberLimitExceeded": "You've reached the maximum member limit allowed for your account. If you want to add more additional members to continue your work, please request on Github", + "failedToAddMember": "Failed to add member", + "addMemberSuccess": "Member added successfully", + "removeMember": "Remove Member", + "areYouSureToRemoveMember": "Are you sure you want to remove this member?" + } }, "files": { "copy": "Copy", @@ -420,7 +486,11 @@ "recoverLocationTooltips": "Reset to AppFlowy's default data directory", "exportFileSuccess": "Export file successfully!", "exportFileFail": "Export file failed!", - "export": "Export" + "export": "Export", + "clearCache": "Clear cache", + "clearCacheDesc": "If you encounter issues with images not loading or fonts not displaying correctly, try clearing your cache. This action will not remove your user data.", + "areYouSureToClearCache": "Are you sure to clear the cache?", + "clearCacheSuccess": "Cache cleared successfully!" }, "user": { "name": "Name", @@ -520,13 +590,9 @@ "isComplete": "is complete", "isIncomplted": "is incomplete" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Is", "isNot": "Is not", - "isEmpty": "Is empty", - "isNotEmpty": "Is not empty" - }, - "multiSelectOptionFilter": { "contains": "Contains", "doesNotContain": "Does not contain", "isEmpty": "Is empty", @@ -540,7 +606,15 @@ "onOrAfter": "Is on or after", "between": "Is between", "empty": "Is empty", - "notEmpty": "Is not empty" + "notEmpty": "Is not empty", + "choicechipPrefix": { + "before": "Before", + "after": "After", + "onOrBefore": "On or before", + "onOrAfter": "On or after", + "isEmpty": "Is empty", + "isNotEmpty": "Is not empty" + } }, "numberFilter": { "equal": "Equals", @@ -559,6 +633,7 @@ "insertRight": "Insert Right", "duplicate": "Duplicate", "delete": "Delete", + "clear": "Clear cells", "textFieldName": "Text", "checkboxFieldName": "Checkbox", "dateFieldName": "Date", @@ -569,6 +644,7 @@ "multiSelectFieldName": "Multiselect", "urlFieldName": "URL", "checklistFieldName": "Checklist", + "relationFieldName": "Relation", "numberFormat": "Number format", "dateFormat": "Date format", "includeTime": "Include time", @@ -598,9 +674,11 @@ "editProperty": "Edit property", "newProperty": "New property", "deleteFieldPromptMessage": "Are you sure? This property will be deleted", + "clearFieldPromptMessage": "Are you sure? All cells in this column will be emptied", "newColumn": "New Column", "format": "Format", - "reminderOnDateTooltip": "This cell has a scheduled reminder" + "reminderOnDateTooltip": "This cell has a scheduled reminder", + "optionAlreadyExist": "Option already exists" }, "rowPage": { "newField": "Add a new field", @@ -623,7 +701,9 @@ "empty": "No active sorts", "cannotFindCreatableField": "Cannot find a suitable field to sort by", "deleteAllSorts": "Delete all sorts", - "addSort": "Add new sort" + "addSort": "Add new sort", + "removeSorting": "Would you like to remove sorting?", + "fieldInUse": "You are already sorting by this field" }, "row": { "duplicate": "Duplicate", @@ -668,6 +748,20 @@ "hideComplete": "Hide completed tasks", "showComplete": "Show all tasks" }, + "url": { + "launch": "Open link in browser", + "copy": "Copy link to clipboard", + "textFieldHint": "Enter a URL", + "copiedNotification": "Copied to clipboard!" + }, + "relation": { + "relatedDatabasePlaceLabel": "Related Database", + "relatedDatabasePlaceholder": "None", + "inRelatedDatabase": "In", + "rowSearchTextFieldPlaceholder": "Search", + "noDatabaseSelected": "No database selected, please select one first from the list below:", + "emptySearchResult": "No records found" + }, "menuName": "Grid", "referencedGridPrefix": "View of", "calculate": "Calculate", @@ -677,7 +771,12 @@ "max": "Max", "median": "Median", "min": "Min", - "sum": "Sum" + "sum": "Sum", + "count": "Count", + "countEmpty": "Count empty", + "countEmptyShort": "EMPTY", + "countNonEmpty": "Count not empty", + "countNonEmptyShort": "FILLED" } }, "document": { @@ -786,13 +885,16 @@ }, "image": { "copiedToPasteBoard": "The image link has been copied to the clipboard", - "addAnImage": "Add an image" + "addAnImage": "Add an image", + "imageUploadFailed": "Image upload failed" }, "urlPreview": { - "copiedToPasteBoard": "The link has been copied to the clipboard" + "copiedToPasteBoard": "The link has been copied to the clipboard", + "convertToLink": "Convert to embed link" }, "outline": { - "addHeadingToCreateOutline": "Add headings to create a table of contents." + "addHeadingToCreateOutline": "Add headings to create a table of contents.", + "noMatchHeadings": "No matching headings found." }, "table": { "addAfter": "Add after", @@ -850,8 +952,9 @@ "error": { "invalidImage": "Invalid image", "invalidImageSize": "Image size must be less than 5MB", - "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "Invalid image URL" + "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", + "invalidImageUrl": "Invalid image URL", + "noImage": "No such file or directory" }, "embedLink": { "label": "Embed link", @@ -917,7 +1020,6 @@ "addToColumnBottomTooltip": "Add a new card at the bottom", "renameColumn": "Rename", "hideColumn": "Hide", - "groupActions": "Group Actions", "newGroup": "New Group", "deleteColumn": "Delete", "deleteColumnConfirmation": "This will delete this group and all the cards in it.\nAre you sure you want to continue?" @@ -980,7 +1082,8 @@ "name": "Calendar settings" }, "referencedCalendarPrefix": "View of", - "quickJumpYear": "Jump to" + "quickJumpYear": "Jump to", + "duplicateEvent": "Duplicate event" }, "errorDialog": { "title": "AppFlowy Error", @@ -1131,7 +1234,8 @@ "replace": "Replace", "replaceAll": "Replace all", "noResult": "No results", - "caseSensitive": "Case sensitive" + "caseSensitive": "Case sensitive", + "searchMore": "Search to find more results" }, "error": { "weAreSorry": "We're sorry", @@ -1178,6 +1282,8 @@ "backgroundColorPurple": "Purple background", "backgroundColorPink": "Pink background", "backgroundColorRed": "Red background", + "backgroundColorLime": "Lime background", + "backgroundColorAqua": "Aqua background", "done": "Done", "cancel": "Cancel", "tint1": "Tint 1", @@ -1225,6 +1331,8 @@ "copy": "Copy", "paste": "Paste", "find": "Find", + "select": "Select", + "selectAll": "Select all", "previousMatch": "Previous match", "nextMatch": "Next match", "closeFind": "Close", @@ -1281,5 +1389,52 @@ "addField": "Add field", "userIcon": "User icon" }, - "noLogFiles": "There're no log files" + "noLogFiles": "There're no log files", + "newSettings": { + "myAccount": { + "title": "My account", + "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", + "profileLabel": "Account name & Profile image", + "profileNamePlaceholder": "Enter your name", + "accountSecurity": "Account security", + "2FA": "2-Step Authentication", + "aiKeys": "AI keys", + "accountLogin": "Account Login", + "updateNameError": "Failed to update name", + "updateIconError": "Failed to update icon", + "deleteAccount": { + "title": "Delete Account", + "subtitle": "Permanently delete your account and all of your data.", + "deleteMyAccount": "Delete my account", + "dialogTitle": "Delete account", + "dialogContent1": "Are you sure you want to permanently delete your account?", + "dialogContent2": "This action cannot be undone, and will remove access from all teamspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces." + } + }, + "workplace": { + "name": "Workplace", + "title": "Workplace Settings", + "subtitle": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", + "workplaceName": "Workplace name", + "workplaceNamePlaceholder": "Enter workplace name", + "workplaceIcon": "Workplace icon", + "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications", + "renameError": "Failed to rename workplace", + "updateIconError": "Failed to update icon", + "appearance": { + "name": "Appearance", + "themeMode": { + "auto": "Auto", + "light": "Light", + "dark": "Dark" + }, + "language": "Language" + } + }, + "syncState": { + "syncing": "Syncing", + "synced": "Synced", + "noNetworkConnected": "No network connected" + } + } } \ No newline at end of file diff --git a/frontend/resources/translations/es-VE.json b/frontend/resources/translations/es-VE.json index 81e41a0306400..47d734d59ddb8 100644 --- a/frontend/resources/translations/es-VE.json +++ b/frontend/resources/translations/es-VE.json @@ -9,6 +9,7 @@ "title": "Título", "youCanAlso": "Tú también puedes", "and": "y", + "failedToOpenUrl": "No se pudo abrir la URL: {}", "blockActions": { "addBelowTooltip": "Haga clic para agregar a continuación", "addAboveCmd": "Alt+clic", @@ -61,8 +62,18 @@ "failedToLoad": "¡Algo salió mal! No se pudo cargar el espacio de trabajo. Intente cerrar cualquier instancia abierta de AppFlowy y vuelva a intentarlo.", "errorActions": { "reportIssue": "Reportar un problema", + "reportIssueOnGithub": "Informar un problema en Github", + "exportLogFiles": "Exportar archivos de registro (logs)", "reachOut": "Comuníquese con Discord" - } + }, + "deleteWorkspaceHintText": "¿Está seguro de que desea eliminar el espacio de trabajo? Esta acción no se puede deshacer.", + "createSuccess": "Espacio de trabajo creado exitosamente", + "createFailed": "No se pudo crear el espacio de trabajo", + "deleteSuccess": "Espacio de trabajo eliminado correctamente", + "deleteFailed": "No se pudo eliminar el espacio de trabajo", + "openFailed": "No se pudo abrir el espacio de trabajo", + "renameSuccess": "Espacio de trabajo renombrado exitosamente", + "renameFailed": "No se pudo cambiar el nombre del espacio de trabajo" }, "shareAction": { "buttonText": "Compartir", @@ -72,12 +83,16 @@ "copyLink": "Copiar enlace" }, "moreAction": { + "small": "pequeño", + "medium": "medio", + "large": "grande", "fontSize": "Tamaño de fuente", "import": "Importar", "moreOptions": "Mas opciones", - "small": "pequeño", - "medium": "medio", - "large": "grande" + "wordCount": "El recuento de palabras: {}", + "charCount": "Número de caracteres : {}", + "deleteView": "Borrar", + "duplicateView": "Duplicar" }, "importPanel": { "textAndMarkdown": "Texto y descuento", @@ -180,9 +195,7 @@ "dragRow": "Pulsación larga para reordenar la fila", "viewDataBase": "Ver base de datos", "referencePage": "Se hace referencia a este {nombre}", - "addBlockBelow": "Añadir un bloque a continuación", - "urlLaunchAccessory": "Abrir en el navegador", - "urlCopyAccessory": "Copiar URL" + "addBlockBelow": "Añadir un bloque a continuación" }, "sideBar": { "closeSidebar": "Cerrar panel lateral", @@ -234,7 +247,18 @@ "rename": "Renombrar", "helpCenter": "Centro de ayuda", "add": "Añadir", - "yes": "Si" + "yes": "Si", + "remove": "Eliminar", + "dontRemove": "no quitar", + "copyLink": "Copiar enlace", + "align": "Alinear", + "login": "Inciar sessión", + "logout": "Cerrar sesión", + "deleteAccount": "Borrar cuenta", + "back": "Atrás", + "signInGoogle": "Inicia sesión con Google", + "signInGithub": "Iniciar sesión con Github", + "signInDiscord": "Iniciar sesión con discordia" }, "label": { "welcome": "¡Bienvenido!", @@ -502,13 +526,9 @@ "isComplete": "Esta completo", "isIncomplted": "esta incompleto" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Es", "isNot": "No es", - "isEmpty": "Esta vacio", - "isNotEmpty": "No está vacío" - }, - "multiSelectOptionFilter": { "contains": "Contiene", "doesNotContain": "No contiene", "isEmpty": "Esta vacio", @@ -627,6 +647,10 @@ "hideComplete": "Ocultar tareas completadas", "showComplete": "Mostrar todas las tareas" }, + "url": { + "launch": "Abrir en el navegador", + "copy": "Copiar URL" + }, "menuName": "Cuadrícula", "referencedGridPrefix": "Vista de" }, @@ -834,9 +858,9 @@ "createNewColumn": "Agregar un nuevo grupo", "renameColumn": "Renombrar", "hideColumn": "Ocultar", - "groupActions": "Acciones grupales", "newGroup": "Nuevo grupo", - "deleteColumn": "Borrar" + "deleteColumn": "Borrar", + "groupActions": "Acciones grupales" }, "hiddenGroupSection": { "sectionTitle": "Grupos ocultos" @@ -1061,4 +1085,4 @@ "backgroundColorPink": "fondo rosa", "backgroundColorRed": "fondo rojo" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/eu-ES.json b/frontend/resources/translations/eu-ES.json index 6e481da4a7d35..913452408422a 100644 --- a/frontend/resources/translations/eu-ES.json +++ b/frontend/resources/translations/eu-ES.json @@ -50,12 +50,12 @@ "copyLink": "Esteka kopiatu" }, "moreAction": { - "fontSize": "Letra tamaina", - "import": "Inportatu", - "moreOptions": "Aukera gehiago", "small": "txikia", "medium": "ertaina", - "large": "handia" + "large": "handia", + "fontSize": "Letra tamaina", + "import": "Inportatu", + "moreOptions": "Aukera gehiago" }, "importPanel": { "textAndMarkdown": "Testua eta Markdown", @@ -159,7 +159,9 @@ "editContact": "Kontaktua editatu" }, "button": { + "ok": "OK", "done": "Eginda", + "cancel": "Ezteztatu", "signIn": "Saioa hasi", "signOut": "Saioa itxi", "complete": "Burututa", @@ -175,9 +177,7 @@ "edit": "Editatu", "delete": "Ezabatu", "duplicate": "Bikoiztu", - "putback": "Jarri Atzera", - "cancel": "Ezteztatu", - "ok": "OK" + "putback": "Jarri Atzera" }, "label": { "welcome": "Ongi etorri!", @@ -323,13 +323,9 @@ "isComplete": "osatu da", "isIncomplted": "osatu gabe dago" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "da", "isNot": "Ez da", - "isEmpty": "Hutsa dago", - "isNotEmpty": "Ez dago hutsik" - }, - "multiSelectOptionFilter": { "contains": "Duen", "doesNotContain": "Ez dauka", "isEmpty": "Hutsa dago", @@ -600,4 +596,4 @@ "deleteContentTitle": "Ziur {pageType} ezabatu nahi duzula?", "deleteContentCaption": "{pageType} hau ezabatzen baduzu, zaborrontzitik leheneratu dezakezu." } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/fa.json b/frontend/resources/translations/fa.json index a0c5c6069ec6e..9abbf686f9a3b 100644 --- a/frontend/resources/translations/fa.json +++ b/frontend/resources/translations/fa.json @@ -55,12 +55,12 @@ "copyLink": "کپی کردن لینک" }, "moreAction": { - "fontSize": "اندازه قلم", - "import": "اضافه کردن", - "moreOptions": "گزینه های بیشتر", "small": "کوچک", "medium": "متوسط", - "large": "بزرگ" + "large": "بزرگ", + "fontSize": "اندازه قلم", + "import": "اضافه کردن", + "moreOptions": "گزینه های بیشتر" }, "importPanel": { "textAndMarkdown": "Text & Markdown", @@ -175,7 +175,9 @@ "editContact": "ویرایش مخاطب" }, "button": { + "ok": "باشه", "done": "انجام شد", + "cancel": "لغو", "signIn": "ورود", "signOut": "خروج", "complete": "کامل شد", @@ -191,9 +193,7 @@ "edit": "ویرایش", "delete": "حذف کردن", "duplicate": "تکرار کردن", - "putback": "بازگشت", - "cancel": "لغو", - "ok": "باشه" + "putback": "بازگشت" }, "label": { "welcome": "خوش آمدید!", @@ -356,13 +356,9 @@ "isComplete": "کامل است", "isIncomplted": "کامل نیست" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "است", "isNot": "نیست", - "isEmpty": "خالی است", - "isNotEmpty": "خالی نیست" - }, - "multiSelectOptionFilter": { "contains": "شامل", "doesNotContain": "شامل نیست", "isEmpty": "خالی است", @@ -673,4 +669,4 @@ "frequentlyUsed": "استفاده‌شده" } } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/fr-CA.json b/frontend/resources/translations/fr-CA.json index f84eccfb67af6..c5c5b4fa5157f 100644 --- a/frontend/resources/translations/fr-CA.json +++ b/frontend/resources/translations/fr-CA.json @@ -182,9 +182,7 @@ "dragRow": "Appuyez longuement pour réorganiser la ligne", "viewDataBase": "Voir la base de données", "referencePage": "Ce {nom} est référencé", - "addBlockBelow": "Ajouter un bloc ci-dessous", - "urlLaunchAccessory": "Ouvrir dans le navigateur", - "urlCopyAccessory": "Copier l'URL" + "addBlockBelow": "Ajouter un bloc ci-dessous" }, "sideBar": { "closeSidebar": "Fermer le menu latéral", @@ -314,7 +312,7 @@ "importSuccess": "Importation réussie du dossier de données AppFlowy", "importFailed": "L'importation du dossier de données AppFlowy a échoué", "importGuide": "Pour plus de détails, veuillez consulter le document référencé", - "supabaseSetting": "Paramètre Supbase" + "supabaseSetting": "Paramètre Supabase" }, "notifications": { "enableNotifications": { @@ -523,13 +521,9 @@ "isComplete": "fait", "isIncomplted": "pas fait" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Est", "isNot": "N'est pas", - "isEmpty": "Est vide", - "isNotEmpty": "N'est pas vide" - }, - "multiSelectOptionFilter": { "contains": "Contient", "doesNotContain": "Ne contient pas", "isEmpty": "Est vide", @@ -659,6 +653,10 @@ "hideComplete": "Cacher les tâches terminées", "showComplete": "Afficher toutes les tâches" }, + "url": { + "launch": "Ouvrir dans le navigateur", + "copy": "Copier l'URL" + }, "menuName": "Grille", "referencedGridPrefix": "Vue", "calculate": "Calculer", @@ -903,10 +901,10 @@ "addToColumnBottomTooltip": "Ajouter une nouvelle carte en bas", "renameColumn": "Renommer", "hideColumn": "Cacher", - "groupActions": "Actions de groupe", "newGroup": "Nouveau groupe", "deleteColumn": "Supprimer", - "deleteColumnConfirmation": "Cela supprimera ce groupe et toutes les cartes qu'il contient. \nÊtes-vous sûr de vouloir continuer?" + "deleteColumnConfirmation": "Cela supprimera ce groupe et toutes les cartes qu'il contient. \nÊtes-vous sûr de vouloir continuer?", + "groupActions": "Actions de groupe" }, "hiddenGroupSection": { "sectionTitle": "Groupes cachés", @@ -1264,4 +1262,4 @@ "userIcon": "Icône utilisateur" }, "noLogFiles": "Il n'y a pas de log" -} +} \ No newline at end of file diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index 91c5e89716c74..33257f77054f5 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -55,10 +55,10 @@ "chooseWorkspace": "Choisissez votre espace de travail", "create": "Créer un espace de travail", "reset": "Réinitialiser l'espace de travail", - "resetWorkspacePrompt": "La réinitialisation de l'espace de travail supprimera toutes les pages et données qu'elles contiennent. Êtes-vous sûr de vouloir réinitialiser l'espace de travail ? Alternativement, vous pouvez contacter l'équipe d'assistance pour restaurer l'espace de travail", + "resetWorkspacePrompt": "La réinitialisation de l'espace de travail supprimera toutes les pages et données qu'elles contiennent. Êtes-vous sûr de vouloir réinitialiser l'espace de travail ? Alternativement, vous pouvez contacter l'équipe d'assistance pour restaurer l'espace de travail", "hint": "Espace de travail", "notFoundError": "Espace de travail introuvable", - "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'AppFlowy et réessayez.", + "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'AppFlowy et réessayez.", "errorActions": { "reportIssue": "Signaler un problème", "reportIssueOnGithub": "Signaler un bug sur Github", @@ -79,7 +79,12 @@ "large": "grand", "fontSize": "Taille de police", "import": "Importer", - "moreOptions": "Plus d'options" + "moreOptions": "Plus d'options", + "wordCount": "Compteur de mot: {}", + "charCount": "Compteur de caractère: {}", + "createdAt": "Créé à: {}", + "deleteView": "Supprimer", + "duplicateView": "Dupliquer" }, "importPanel": { "textAndMarkdown": "Texte et Markdown", @@ -115,11 +120,11 @@ "created": "Créé" }, "confirmDeleteAll": { - "title": "Voulez-vous vraiment supprimer toutes les pages de la corbeille ?", + "title": "Voulez-vous vraiment supprimer toutes les pages de la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, "confirmRestoreAll": { - "title": "Êtes-vous sûr de vouloir restaurer toutes les pages dans la corbeille ?", + "title": "Êtes-vous sûr de vouloir restaurer toutes les pages dans la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, "mobile": { @@ -182,9 +187,7 @@ "dragRow": "Appuyez longuement pour réorganiser la ligne", "viewDataBase": "Voir la base de données", "referencePage": "Ce {nom} est référencé", - "addBlockBelow": "Ajouter un bloc ci-dessous", - "urlLaunchAccessory": "Ouvrir dans le navigateur", - "urlCopyAccessory": "Copier l'URL" + "addBlockBelow": "Ajouter un bloc ci-dessous" }, "sideBar": { "closeSidebar": "Fermer le menu latéral", @@ -237,6 +240,9 @@ "helpCenter": "Centre d'aide", "add": "Ajouter", "yes": "Oui", + "remove": "Retirer", + "dontRemove": "Ne pas retirer", + "align": "Aligner", "tryAGain": "Réessayer" }, "label": { @@ -269,7 +275,7 @@ "notifications": "Notifications", "open": "Ouvrir les paramètres", "logout": "Se déconnecter", - "logoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ?", + "logoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ?", "selfEncryptionLogoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ? Veuillez vous assurer d'avoir copié la clé de chiffrement.", "syncSetting": "Paramètres de synchronisation", "cloudSettings": "Paramètres cloud", @@ -298,14 +304,14 @@ "restartApp": "Redémarer", "restartAppTip": "Redémarrez l'application pour que les modifications prennent effet. Veuillez noter que cela pourrait déconnecter votre compte actuel.", "changeServerTip": "Après avoir changé de serveur, vous devez cliquer sur le bouton de redémarrer pour que les modifications prennent effet", - "enableEncryptPrompt": "Activez le chiffrement pour sécuriser vos données avec cette clé. Rangez-la en toute sécurité ; une fois activé, il ne peut pas être désactivé. En cas de perte, vos données deviennent irrécupérables. Cliquez pour copier", + "enableEncryptPrompt": "Activez le chiffrement pour sécuriser vos données avec cette clé. Rangez-la en toute sécurité ; une fois activé, il ne peut pas être désactivé. En cas de perte, vos données deviennent irrécupérables. Cliquez pour copier", "inputEncryptPrompt": "Veuillez saisir votre mot ou phrase de passe pour", "clickToCopySecret": "Cliquez pour copier le mot ou la phrase de passe", "configServerSetting": "Configurez les paramètres de votre serveur", "configServerGuide": "Après avoir sélectionné « Démarrage rapide », accédez à « Paramètres » puis « Paramètres Cloud » pour configurer votre serveur auto-hébergé.", "inputTextFieldHint": "Votre mot ou phrase de passe", "historicalUserList": "Historique de connexion d'utilisateurs", - "historicalUserListTooltip": "Cette liste affiche vos comptes anonymes. Vous pouvez cliquer sur un compte pour afficher ses détails. Les comptes anonymes sont créés en cliquant sur le bouton « Commencer »", + "historicalUserListTooltip": "Cette liste affiche vos comptes anonymes. Vous pouvez cliquer sur un compte pour afficher ses détails. Les comptes anonymes sont créés en cliquant sur le bouton « Commencer »", "openHistoricalUser": "Cliquez pour ouvrir le compte anonyme", "customPathPrompt": "Le stockage du dossier de données AppFlowy dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", "importAppFlowyData": "Importer des données à partir du dossier AppFlowy externe", @@ -314,7 +320,7 @@ "importSuccess": "Importation réussie du dossier de données AppFlowy", "importFailed": "L'importation du dossier de données AppFlowy a échoué", "importGuide": "Pour plus de détails, veuillez consulter le document référencé", - "supabaseSetting": "Paramètre Supbase" + "supabaseSetting": "Paramètre Supabase" }, "notifications": { "enableNotifications": { @@ -338,7 +344,7 @@ "cursorColor": "Couleur du curseur du document", "selectionColor": "Couleur de sélection du document", "hexEmptyError": "La couleur hexadécimale ne peut pas être vide", - "hexLengthError": "La valeur hexadécimale doit comporter 6 chiffres", + "hexLengthError": "La valeur hexadécimale doit comporter 6 chiffres", "hexInvalidError": "Valeur hexadécimale invalide", "opacityEmptyError": "L'opacité ne peut pas être vide", "opacityRangeError": "L'opacité doit être comprise entre 1 et 100", @@ -368,7 +374,7 @@ "uploadSuccess": "Votre thème a été téléversé avec succès", "deletionFailure": "Échec de la suppression du thème. Essayez de le supprimer manuellement.", "filePickerDialogTitle": "Choisissez un fichier .flowy_plugin", - "urlUploadFailure": "Échec de l'ouverture de l'URL : {}", + "urlUploadFailure": "Échec de l'ouverture de l'URL : {}", "failure": "Le thème qui a été téléchargé avait un format non valide." }, "theme": "Thème", @@ -387,7 +393,23 @@ "twelveHour": "Douze heures", "twentyFourHour": "Vingt-quatre heures" }, - "showNamingDialogWhenCreatingPage": "Afficher la boîte de dialogue de nommage lors de la création d'une page" + "showNamingDialogWhenCreatingPage": "Afficher la boîte de dialogue de nommage lors de la création d'une page", + "members": { + "inviteMembers": "Inviter des membres", + "sendInvite": "Envoyer une invitation", + "copyInviteLink": "Copier le lien d'invitation", + "label": "Membres", + "user": "Utilisateur", + "role": "Rôle", + "removeFromWorkspace": "Retirer de l'espace de travail", + "owner": "Propriétaire", + "guest": "Invité", + "member": "Membre", + "memberHintText": "Un membre peut lire, commenter, et éditer des pages. Inviter des membres et des invités.", + "guestHintText": "Un invité peut lire, réagir, commenter, et peut éditer certaines pages avec une permission", + "emailInvalidError": "Email invalide, veuillez le vérifier et recommencer", + "emailSent": "Email envoyé, veuillez vérifier dans votre boîte mail." + } }, "files": { "copy": "Copie", @@ -415,14 +437,14 @@ "set": "Définir", "folderPath": "Chemin pour stocker votre dossier", "locationCannotBeEmpty": "Le chemin ne peut pas être vide", - "pathCopiedSnackbar": "Chemin de stockage des fichiers copié dans le presse-papier !", + "pathCopiedSnackbar": "Chemin de stockage des fichiers copié dans le presse-papier !", "changeLocationTooltips": "Changer le répertoire de données", "change": "Changer", "openLocationTooltips": "Ouvrir un autre répertoire de données", "openCurrentDataFolder": "Ouvrir le répertoire de données actuel", "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'AppFlowy", - "exportFileSuccess": "Exporter le fichier avec succès !", - "exportFileFail": "Échec de l'export du fichier !", + "exportFileSuccess": "Exporter le fichier avec succès !", + "exportFileFail": "Échec de l'export du fichier !", "export": "Exporter" }, "user": { @@ -440,7 +462,7 @@ "keyBinding": "Racourcis clavier", "addNewCommand": "Ajouter une Nouvelle Commande", "updateShortcutStep": "Appuyez sur la combinaison de touches souhaitée et appuyez sur ENTER", - "shortcutIsAlreadyUsed": "Ce raccourci est déjà utilisé pour : {conflict}", + "shortcutIsAlreadyUsed": "Ce raccourci est déjà utilisé pour : {conflict}", "resetToDefault": "Réinitialiser les raccourcis clavier par défaut", "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis. Réessayez" @@ -464,7 +486,7 @@ } }, "grid": { - "deleteView": "Voulez-vous vraiment supprimer cette vue ?", + "deleteView": "Voulez-vous vraiment supprimer cette vue ?", "createView": "Nouveau", "title": { "placeholder": "Sans titre" @@ -520,13 +542,9 @@ "isComplete": "fait", "isIncomplted": "pas fait" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Est", "isNot": "N'est pas", - "isEmpty": "Est vide", - "isNotEmpty": "N'est pas vide" - }, - "multiSelectOptionFilter": { "contains": "Contient", "doesNotContain": "Ne contient pas", "isEmpty": "Est vide", @@ -540,7 +558,25 @@ "onOrAfter": "Est le ou après", "between": "Est entre", "empty": "Est vide", - "notEmpty": "N'est pas vide" + "notEmpty": "N'est pas vide", + "choicechipPrefix": { + "before": "Avant", + "after": "Après", + "onOrBefore": "Pendant ou avant", + "onOrAfter": "Pendant ou après", + "isEmpty": "Est vide", + "isNotEmpty": "N'est pas vide" + } + }, + "numberFilter": { + "equal": "Égal", + "notEqual": "N'est pas égal", + "lessThan": "Est moins que", + "greaterThan": "Est plus que", + "lessThanOrEqualTo": "Est inférieur ou égal à", + "greaterThanOrEqualTo": "Est supérieur ou égal à ", + "isEmpty": "Est vide", + "isNotEmpty": "N'est pas vide" }, "field": { "hide": "Cacher", @@ -559,6 +595,7 @@ "multiSelectFieldName": "Sélection multiple", "urlFieldName": "URL", "checklistFieldName": "Check-list", + "relationFieldName": "Relation", "numberFormat": "Format du nombre", "dateFormat": "Format de la date", "includeTime": "Inclure l'heure", @@ -590,27 +627,31 @@ "deleteFieldPromptMessage": "Vous voulez supprimer cette propriété ?", "newColumn": "Nouvelle colonne", "format": "Format", - "reminderOnDateTooltip": "Cette cellule a un rappel programmé" + "reminderOnDateTooltip": "Cette cellule a un rappel programmé", + "optionAlreadyExist": "L'option existe déjà" }, "rowPage": { "newField": "Ajouter un nouveau champ", "fieldDragElementTooltip": "Cliquez pour ouvrir le menu", "showHiddenFields": { - "one": "Afficher {count} champ masqué", - "many": "Afficher {count} champs masqués", - "other": "Afficher {count} champs masqués" + "one": "Afficher {count} champ masqué", + "many": "Afficher {count} champs masqués", + "other": "Afficher {count} champs masqués" }, "hideHiddenFields": { - "one": "Cacher {count} champ caché", - "many": "Cacher {count} champs masqués", - "other": "Cacher {count} champs masqués" + "one": "Cacher {count} champ caché", + "many": "Cacher {count} champs masqués", + "other": "Cacher {count} champs masqués" } }, "sort": { "ascending": "Ascendant", "descending": "Descendant", + "by": "Par", + "empty": "Tri pas actif", "deleteAllSorts": "Supprimer tous les tris", "addSort": "Ajouter un tri", + "removeSorting": "Voulez-vous supprimer le tri ?", "deleteSort": "Supprimer le tri" }, "row": { @@ -656,6 +697,14 @@ "hideComplete": "Cacher les tâches terminées", "showComplete": "Afficher toutes les tâches" }, + "url": { + "launch": "Ouvrir dans le navigateur", + "copy": "Copier l'URL" + }, + "relation": { + "inRelatedDatabase": "Dans", + "emptySearchResult": "Aucun enregistrement trouvé" + }, "menuName": "Grille", "referencedGridPrefix": "Vue", "calculate": "Calculer", @@ -701,7 +750,7 @@ "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", "autoGeneratorMenuItemName": "Rédacteur OpenAI", - "autoGeneratorTitleName": "OpenAI : Demandez à l'IA d'écrire quelque chose...", + "autoGeneratorTitleName": "OpenAI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", "autoGeneratorHintText": "Demandez à OpenAI...", @@ -717,7 +766,7 @@ "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'OpenAI", "smartEditCouldNotFetchKey": "Impossible de récupérer la clé OpenAI", "smartEditDisabled": "Connectez OpenAI dans les paramètres", - "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", + "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", "toggleList": "Liste pliable", @@ -769,11 +818,13 @@ "left": "Gauche", "center": "Centre", "right": "Droite", - "defaultColor": "Défaut" + "defaultColor": "Défaut", + "depth": "Profond" }, "image": { "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers", - "addAnImage": "Ajouter une image" + "addAnImage": "Ajouter une image", + "imageUploadFailed": "Téléchargement de l'image échoué" }, "urlPreview": { "copiedToPasteBoard": "Le lien a été copié dans le presse-papier" @@ -806,6 +857,9 @@ "date": "Date", "emoji": "Emoji" }, + "outlineBlock": { + "placeholder": "Table de contenu" + }, "textBlock": { "placeholder": "Tapez '/' pour les commandes" }, @@ -828,7 +882,7 @@ }, "stability_ai": { "label": "Générer une image à partir de Stability AI", - "placeholder": "Veuillez saisir l'invite permettant à Stability AI de générer une image." + "placeholder": "Veuillez saisir l'invite permettant à Stability AI de générer une image." }, "support": "La limite de taille d'image est de 5 Mo. Formats pris en charge : JPEG, PNG, GIF, SVG", "error": { @@ -852,7 +906,8 @@ "successToAddImageToGallery": "Image ajoutée à la galerie avec succès", "unableToLoadImage": "Impossible de charger l'image", "maximumImageSize": "La taille d'image maximale est 10Mo", - "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo" + "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo", + "imageIsUploading": "L'image est en cours de téléchargement" }, "codeBlock": { "language": { @@ -900,10 +955,10 @@ "addToColumnBottomTooltip": "Ajouter une nouvelle carte en bas", "renameColumn": "Renommer", "hideColumn": "Cacher", - "groupActions": "Actions de groupe", "newGroup": "Nouveau groupe", "deleteColumn": "Supprimer", - "deleteColumnConfirmation": "Cela supprimera ce groupe et toutes les cartes qu'il contient. \nÊtes-vous sûr de vouloir continuer?" + "deleteColumnConfirmation": "Cela supprimera ce groupe et toutes les cartes qu'il contient. \nÊtes-vous sûr de vouloir continuer?", + "groupActions": "Actions de groupe" }, "hiddenGroupSection": { "sectionTitle": "Groupes cachés", @@ -928,7 +983,7 @@ "mobile": { "editURL": "Modifier l'URL", "unhideGroup": "Afficher le groupe", - "unhideGroupContent": "Êtes-vous sûr de vouloir afficher ce groupe sur le tableau ?", + "unhideGroupContent": "Êtes-vous sûr de vouloir afficher ce groupe sur le tableau ?", "faildToLoad": "Échec du chargement de la vue du tableau" } }, @@ -974,13 +1029,13 @@ }, "message": { "copy": { - "success": "Copié !", + "success": "Copié !", "fail": "Impossible de copier" } }, "unSupportBlock": "La version actuelle ne prend pas en charge ce bloc.", "views": { - "deleteContentTitle": "Voulez-vous vraiment supprimer le {pageType} ?", + "deleteContentTitle": "Voulez-vous vraiment supprimer le {pageType}?", "deleteContentCaption": "si vous supprimez ce {pageType}, vous pouvez le restaurer à partir de la corbeille." }, "colors": { @@ -1110,7 +1165,8 @@ "replace": "Remplacer", "replaceAll": "Tout remplacer", "noResult": "Aucun résultat", - "caseSensitive": "Sensible à la casse" + "caseSensitive": "Sensible à la casse", + "searchMore": "Chercher pour trouver plus de résultat" }, "error": { "weAreSorry": "Nous sommes désolés", @@ -1261,4 +1317,4 @@ "userIcon": "Icône utilisateur" }, "noLogFiles": "Il n'y a pas de log" -} +} \ No newline at end of file diff --git a/frontend/resources/translations/hin.json b/frontend/resources/translations/hin.json index 55cbce7f05828..8ce86ed96b649 100644 --- a/frontend/resources/translations/hin.json +++ b/frontend/resources/translations/hin.json @@ -1,743 +1,739 @@ { - "appName": "AppFlowy", - "defaultUsername": "मैं", - "welcomeText": " @:appName में आपका स्वागत है", - "githubStarText": "गिटहब पर स्टार करे", - "subscribeNewsletterText": "समाचार पत्रिका के लिए सदस्यता लें", - "letsGoButtonText": "जल्दी शुरू करे", - "title": "शीर्षक", - "youCanAlso": "आप भी कर सकते हैं", - "and": "और", - "blockActions": { - "addBelowTooltip": "नीचे जोड़ने के लिए क्लिक करें", - "addAboveCmd": "Alt+click ", - "addAboveMacCmd": "Option+click", - "addAboveTooltip": "ऊपर जोड़ने के लिए", - "dragTooltip": "ले जाने के लिए ड्रैग करें", - "openMenuTooltip": "मेनू खोलने के लिए क्लिक करें" - }, - "signUp": { - "buttonText": "साइन अप करें", - "title": "साइन अप करें @:appName", - "getStartedText": "शुरू करे", - "emptyPasswordError": "पासवर्ड खाली नहीं हो सकता", - "repeatPasswordEmptyError": "रिपीट पासवर्ड खाली नहीं हो सकता", - "unmatchedPasswordError": "रिपीट पासवर्ड और पासवर्ड एक नहीं है", - "alreadyHaveAnAccount": "क्या आपके पास पहले से एक खाता मौजूद है?", - "emailHint": "ईमेल", - "passwordHint": "पासवर्ड", - "repeatPasswordHint": "रिपीट पासवर्ड", - "signUpWith": "इसके साथ साइन अप करें:" - }, - "signIn": { - "loginTitle": "लॉग इन करें @:appName", - "loginButtonText": "लॉग इन करें", - "loginStartWithAnonymous": "एक अज्ञात सत्र से प्रारंभ करें", - "continueAnonymousUser": "अज्ञात सत्र जारी रखें", - "buttonText": "साइन इन", - "forgotPassword": "पासवर्ड भूल गए?", - "emailHint": "ईमेल", - "passwordHint": "पासवर्ड", - "dontHaveAnAccount": "कोई खाता नहीं है?", - "repeatPasswordEmptyError": "रिपीट पासवर्ड खाली नहीं हो सकता", - "unmatchedPasswordError": "रिपीट पासवर्ड और पासवर्ड एक नहीं है", - "syncPromptMessage": "डेटा को सिंक करने में कुछ समय लग सकता है. कृपया इस पेज को बंद न करें", - "or": "या", - "LogInWithGoogle": "गूगल से लॉग इन करें", - "LogInWithGithub": "गिटहब से लॉग इन करें", - "LogInWithDiscord": "डिस्कॉर्ड से लॉग इन करें", - "signInWith": "इसके साथ साइन इन करें:" - }, - "workspace": { - "chooseWorkspace": "अपना कार्यक्षेत्र चुनें", - "create": "कार्यक्षेत्र बनाएं", - "reset": "कार्यक्षेत्र रीसेट करें", - "resetWorkspacePrompt": "कार्यक्षेत्र को रीसेट करने से उसमें मौजूद सभी पृष्ठ और डेटा हट जाएंगे। क्या आप वाकई कार्यक्षेत्र को रीसेट करना चाहते हैं? वैकल्पिक रूप से, आप कार्यक्षेत्र को पुनर्स्थापित करने के लिए सहायता टीम से संपर्क कर सकते हैं", - "hint": "कार्यक्षेत्र", - "notFoundError": "कार्यस्थल नहीं मिला" - }, - "shareAction": { - "buttonText": "शेयर", - "workInProgress": "जल्द आ रहा है", - "markdown": "markdown", - "csv": "csv", - "copyLink": "लिंक कॉपी करें" - }, - "moreAction": { - "small": "छोटा", - "medium": "मध्यम", - "large": "बड़ा", - "fontSize": "अक्षर का आकर", - "import": "आयात", - "moreOptions": "अधिक विकल्प" - }, - "importPanel": { - "textAndMarkdown": "Text & Markdown", - "documentFromV010": "Document from v0.1.0", - "databaseFromV010": "Database from v0.1.0", - "csv": "CSV", - "database": "Database" - }, - "disclosureAction": { - "rename": "नाम बदलें", - "delete": "हटाएं", - "duplicate": "डुप्लीकेट", - "unfavorite": "पसंदीदा से हटाएँ", - "favorite": "पसंदीदा में जोड़ें", - "openNewTab": "एक नए टैब में खोलें", - "moveTo": "स्थानांतरित करें", - "addToFavorites": "पसंदीदा में जोड़ें", - "copyLink": "कॉपी लिंक" - }, - "blankPageTitle": "रिक्त पेज", - "newPageText": "नया पेज", - "newDocumentText": "नया दस्तावेज़", - "newGridText": "नया ग्रिड", - "newCalendarText": "नया कैलेंडर", - "newBoardText": "नया बोर्ड", - "trash": { - "text": "कचरा", - "restoreAll": "सभी पुनर्स्थापित करें", - "deleteAll": "सभी हटाएँ", - "pageHeader": { - "fileName": "फ़ाइलनाम", - "lastModified": "अंतिम संशोधित", - "created": "बनाया गया" - }, - "confirmDeleteAll": { - "title": "क्या आप निश्चित रूप से ट्रैश में मौजूद सभी पेज को हटाना चाहते हैं?", - "caption": "यह कार्रवाई पूर्ववत नहीं की जा सकती।" - }, - "confirmRestoreAll": { - "title": "क्या आप निश्चित रूप से ट्रैश में सभी पेज को पुनर्स्थापित करना चाहते हैं?", - "caption": "यह कार्रवाई पूर्ववत नहीं की जा सकती।" - } + "appName": "AppFlowy", + "defaultUsername": "मैं", + "welcomeText": " @:appName में आपका स्वागत है", + "githubStarText": "गिटहब पर स्टार करे", + "subscribeNewsletterText": "समाचार पत्रिका के लिए सदस्यता लें", + "letsGoButtonText": "जल्दी शुरू करे", + "title": "शीर्षक", + "youCanAlso": "आप भी कर सकते हैं", + "and": "और", + "blockActions": { + "addBelowTooltip": "नीचे जोड़ने के लिए क्लिक करें", + "addAboveCmd": "Alt+click ", + "addAboveMacCmd": "Option+click", + "addAboveTooltip": "ऊपर जोड़ने के लिए", + "dragTooltip": "ले जाने के लिए ड्रैग करें", + "openMenuTooltip": "मेनू खोलने के लिए क्लिक करें" + }, + "signUp": { + "buttonText": "साइन अप करें", + "title": "साइन अप करें @:appName", + "getStartedText": "शुरू करे", + "emptyPasswordError": "पासवर्ड खाली नहीं हो सकता", + "repeatPasswordEmptyError": "रिपीट पासवर्ड खाली नहीं हो सकता", + "unmatchedPasswordError": "रिपीट पासवर्ड और पासवर्ड एक नहीं है", + "alreadyHaveAnAccount": "क्या आपके पास पहले से एक खाता मौजूद है?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "repeatPasswordHint": "रिपीट पासवर्ड", + "signUpWith": "इसके साथ साइन अप करें:" + }, + "signIn": { + "loginTitle": "लॉग इन करें @:appName", + "loginButtonText": "लॉग इन करें", + "loginStartWithAnonymous": "एक अज्ञात सत्र से प्रारंभ करें", + "continueAnonymousUser": "अज्ञात सत्र जारी रखें", + "buttonText": "साइन इन", + "forgotPassword": "पासवर्ड भूल गए?", + "emailHint": "ईमेल", + "passwordHint": "पासवर्ड", + "dontHaveAnAccount": "कोई खाता नहीं है?", + "repeatPasswordEmptyError": "रिपीट पासवर्ड खाली नहीं हो सकता", + "unmatchedPasswordError": "रिपीट पासवर्ड और पासवर्ड एक नहीं है", + "syncPromptMessage": "डेटा को सिंक करने में कुछ समय लग सकता है. कृपया इस पेज को बंद न करें", + "or": "या", + "LogInWithGoogle": "गूगल से लॉग इन करें", + "LogInWithGithub": "गिटहब से लॉग इन करें", + "LogInWithDiscord": "डिस्कॉर्ड से लॉग इन करें", + "signInWith": "इसके साथ साइन इन करें:" + }, + "workspace": { + "chooseWorkspace": "अपना कार्यक्षेत्र चुनें", + "create": "कार्यक्षेत्र बनाएं", + "reset": "कार्यक्षेत्र रीसेट करें", + "resetWorkspacePrompt": "कार्यक्षेत्र को रीसेट करने से उसमें मौजूद सभी पृष्ठ और डेटा हट जाएंगे। क्या आप वाकई कार्यक्षेत्र को रीसेट करना चाहते हैं? वैकल्पिक रूप से, आप कार्यक्षेत्र को पुनर्स्थापित करने के लिए सहायता टीम से संपर्क कर सकते हैं", + "hint": "कार्यक्षेत्र", + "notFoundError": "कार्यस्थल नहीं मिला" + }, + "shareAction": { + "buttonText": "शेयर", + "workInProgress": "जल्द आ रहा है", + "markdown": "markdown", + "csv": "csv", + "copyLink": "लिंक कॉपी करें" + }, + "moreAction": { + "small": "छोटा", + "medium": "मध्यम", + "large": "बड़ा", + "fontSize": "अक्षर का आकर", + "import": "आयात", + "moreOptions": "अधिक विकल्प" + }, + "importPanel": { + "textAndMarkdown": "Text & Markdown", + "documentFromV010": "Document from v0.1.0", + "databaseFromV010": "Database from v0.1.0", + "csv": "CSV", + "database": "Database" + }, + "disclosureAction": { + "rename": "नाम बदलें", + "delete": "हटाएं", + "duplicate": "डुप्लीकेट", + "unfavorite": "पसंदीदा से हटाएँ", + "favorite": "पसंदीदा में जोड़ें", + "openNewTab": "एक नए टैब में खोलें", + "moveTo": "स्थानांतरित करें", + "addToFavorites": "पसंदीदा में जोड़ें", + "copyLink": "कॉपी लिंक" + }, + "blankPageTitle": "रिक्त पेज", + "newPageText": "नया पेज", + "newDocumentText": "नया दस्तावेज़", + "newGridText": "नया ग्रिड", + "newCalendarText": "नया कैलेंडर", + "newBoardText": "नया बोर्ड", + "trash": { + "text": "कचरा", + "restoreAll": "सभी पुनर्स्थापित करें", + "deleteAll": "सभी हटाएँ", + "pageHeader": { + "fileName": "फ़ाइलनाम", + "lastModified": "अंतिम संशोधित", + "created": "बनाया गया" }, - "deletePagePrompt": { - "text": "यह पेज कूड़ेदान में है", - "restore": "पुनर्स्थापित पेज", - "deletePermanent": "स्थायी रूप से हटाएँ" - }, - "dialogCreatePageNameHint": "पेज का नाम", - "questionBubble": { - "shortcuts": "शॉर्टकट", - "whatsNew": "क्या नया है?", - "help": "सहायता", - "markdown": "markdown", - "debug": { - "name": "डीबग जानकारी", - "success": "डिबग जानकारी क्लिपबोर्ड पर कॉपी की गई!", - "fail": "डिबग जानकारी को क्लिपबोर्ड पर कॉपी करने में असमर्थ" - }, - "feedback": "जानकारी देना" + "confirmDeleteAll": { + "title": "क्या आप निश्चित रूप से ट्रैश में मौजूद सभी पेज को हटाना चाहते हैं?", + "caption": "यह कार्रवाई पूर्ववत नहीं की जा सकती।" }, - "menuAppHeader": { - "moreButtonToolTip": "निकालें, नाम बदलें, और भी बहुत कुछ...", - "addPageTooltip": "जल्दी से अंदर एक पेज जोड़ें", - "defaultNewPageName": "शीर्षकहीन", - "renameDialog": "नाम बदलें" + "confirmRestoreAll": { + "title": "क्या आप निश्चित रूप से ट्रैश में सभी पेज को पुनर्स्थापित करना चाहते हैं?", + "caption": "यह कार्रवाई पूर्ववत नहीं की जा सकती।" + } + }, + "deletePagePrompt": { + "text": "यह पेज कूड़ेदान में है", + "restore": "पुनर्स्थापित पेज", + "deletePermanent": "स्थायी रूप से हटाएँ" + }, + "dialogCreatePageNameHint": "पेज का नाम", + "questionBubble": { + "shortcuts": "शॉर्टकट", + "whatsNew": "क्या नया है?", + "help": "सहायता", + "markdown": "markdown", + "debug": { + "name": "डीबग जानकारी", + "success": "डिबग जानकारी क्लिपबोर्ड पर कॉपी की गई!", + "fail": "डिबग जानकारी को क्लिपबोर्ड पर कॉपी करने में असमर्थ" }, - "toolbar": { - "undo": "अनडू", - "redo": "रीडू", - "bold": "बोल्ड", - "italic": "इटैलिक", - "underline": "अंडरलाइन", - "strike": "स्ट्राइकथ्रू", - "numList": "क्रमांकित सूची", - "bulletList": "बुलेट सूची", - "checkList": "चेकलिस्ट", - "inlineCode": "इनलाइन कोड", - "quote": "कोट", - "header": "हेडर", - "highlight": "हाइलाइट करें", - "color": "रंग", - "addLink": "लिंक जोड़ें", - "link": "लिंक" - }, - "tooltip": { - "lightMode": "लाइट मोड पर स्विच करें", - "darkMode": "डार्क मोड पर स्विच करें", - "openAsPage": "पेज के रूप में खोलें", - "addNewRow": "एक नई पंक्ति जोड़ें", - "openMenu": "मेनू खोलने के लिए क्लिक करें", - "dragRow": "पंक्ति को पुनः व्यवस्थित करने के लिए देर तक दबाएँ", - "viewDataBase": "डेटाबेस देखें", - "referencePage": "यह {name} रफेरेंसेड है", - "addBlockBelow": "नीचे एक ब्लॉक जोड़ें" - }, - "sideBar": { - "closeSidebar": "साइड बार बंद करें", - "openSidebar": "साइड बार खोलें", - "personal": "व्यक्तिगत", - "favorites": "पसंदीदा", - "clickToHidePersonal": "व्यक्तिगत अनुभाग को छिपाने के लिए क्लिक करें", - "clickToHideFavorites": "पसंदीदा अनुभाग को छिपाने के लिए क्लिक करें", - "addAPage": "एक पेज जोड़ें" - }, - "notifications": { - "export": { - "markdown": "आपका नोट मार्कडाउन के रूप में सफलतापूर्वक निर्यात कर दिया गया है।", - "path": "दस्तावेज़/प्रवाह" - } + "feedback": "जानकारी देना" + }, + "menuAppHeader": { + "moreButtonToolTip": "निकालें, नाम बदलें, और भी बहुत कुछ...", + "addPageTooltip": "जल्दी से अंदर एक पेज जोड़ें", + "defaultNewPageName": "शीर्षकहीन", + "renameDialog": "नाम बदलें" + }, + "toolbar": { + "undo": "अनडू", + "redo": "रीडू", + "bold": "बोल्ड", + "italic": "इटैलिक", + "underline": "अंडरलाइन", + "strike": "स्ट्राइकथ्रू", + "numList": "क्रमांकित सूची", + "bulletList": "बुलेट सूची", + "checkList": "चेकलिस्ट", + "inlineCode": "इनलाइन कोड", + "quote": "कोट", + "header": "हेडर", + "highlight": "हाइलाइट करें", + "color": "रंग", + "addLink": "लिंक जोड़ें", + "link": "लिंक" + }, + "tooltip": { + "lightMode": "लाइट मोड पर स्विच करें", + "darkMode": "डार्क मोड पर स्विच करें", + "openAsPage": "पेज के रूप में खोलें", + "addNewRow": "एक नई पंक्ति जोड़ें", + "openMenu": "मेनू खोलने के लिए क्लिक करें", + "dragRow": "पंक्ति को पुनः व्यवस्थित करने के लिए देर तक दबाएँ", + "viewDataBase": "डेटाबेस देखें", + "referencePage": "यह {name} रफेरेंसेड है", + "addBlockBelow": "नीचे एक ब्लॉक जोड़ें" + }, + "sideBar": { + "closeSidebar": "साइड बार बंद करें", + "openSidebar": "साइड बार खोलें", + "personal": "व्यक्तिगत", + "favorites": "पसंदीदा", + "clickToHidePersonal": "व्यक्तिगत अनुभाग को छिपाने के लिए क्लिक करें", + "clickToHideFavorites": "पसंदीदा अनुभाग को छिपाने के लिए क्लिक करें", + "addAPage": "एक पेज जोड़ें" + }, + "notifications": { + "export": { + "markdown": "आपका नोट मार्कडाउन के रूप में सफलतापूर्वक निर्यात कर दिया गया है।", + "path": "दस्तावेज़/प्रवाह" + } + }, + "contactsPage": { + "title": "संपर्क", + "whatsHappening": "इस सप्ताह क्या हो रहा है?", + "addContact": "संपर्क जोड़ें", + "editContact": "संपर्क संपादित करें" + }, + "button": { + "ok": "ठीक है", + "cancel": "रद्द करें", + "signIn": "साइन इन करें", + "signOut": "साइन आउट करें", + "complete": "पूर्ण", + "save": "सेव", + "generate": "उत्पन्न करें", + "esc": "एस्केप", + "keep": "रखें", + "tryAgain": "फिर से प्रयास करें", + "discard": "त्यागें", + "replace": "बदलें", + "insertBelow": "नीचे डालें", + "upload": "अपलोड करें", + "edit": "संपादित करें", + "delete": "हटाएं", + "duplicate": "डुप्लिकेट", + "done": "किया", + "putback": "पुन्हा डालिए" + }, + "label": { + "welcome": "आपका स्वागत है", + "firstName": "पहला नाम", + "middleName": "मध्य नाम", + "lastName": "अंतिम नाम", + "stepX": "स्टेप {X}" + }, + "oAuth": { + "err": { + "failedTitle": "आपके खाते से जुड़ने में असमर्थ।", + "failedMsg": "कृपया सुनिश्चित करें कि आपने अपने ब्राउज़र में साइन-इन प्रक्रिया पूरी कर ली है।" }, - "contactsPage": { - "title": "संपर्क", - "whatsHappening": "इस सप्ताह क्या हो रहा है?", - "addContact": "संपर्क जोड़ें", - "editContact": "संपर्क संपादित करें" - }, - "button": { - "ok": "ठीक है", - "cancel": "रद्द करें", - "signIn": "साइन इन करें", - "signOut": "साइन आउट करें", - "complete": "पूर्ण", - "save": "सेव", - "generate": "उत्पन्न करें", - "esc": "एस्केप", - "keep": "रखें", - "tryAgain": "फिर से प्रयास करें", - "discard": "त्यागें", - "replace": "बदलें", - "insertBelow": "नीचे डालें", - "upload": "अपलोड करें", - "edit": "संपादित करें", - "delete": "हटाएं", - "duplicate": "डुप्लिकेट", - "done": "किया", - "putback": "पुन्हा डालिए" - }, - "label": { - "welcome": "आपका स्वागत है", - "firstName": "पहला नाम", - "middleName": "मध्य नाम", - "lastName": "अंतिम नाम", - "stepX": "स्टेप {X}" - }, - "oAuth": { - "err": { - "failedTitle": "आपके खाते से जुड़ने में असमर्थ।", - "failedMsg": "कृपया सुनिश्चित करें कि आपने अपने ब्राउज़र में साइन-इन प्रक्रिया पूरी कर ली है।" - }, - "google": { - "title": "Google साइन-इन", - "instruction1": "अपने Google संपर्कों को आयात करने के लिए, आपको अपने वेब ब्राउज़र का उपयोग करके इस एप्लिकेशन को अधिकृत करना होगा।", - "instruction2": "आइकन पर क्लिक करके या टेक्स्ट का चयन करके इस कोड को अपने क्लिपबोर्ड पर कॉपी करें:", - "instruction3": "अपने वेब ब्राउज़र में निम्नलिखित लिंक पर जाएँ, और उपरोक्त कोड दर्ज करें", - "instruction4": "साइनअप पूरा होने पर नीचे दिया गया बटन दबाएँ:" - } + "google": { + "title": "Google साइन-इन", + "instruction1": "अपने Google संपर्कों को आयात करने के लिए, आपको अपने वेब ब्राउज़र का उपयोग करके इस एप्लिकेशन को अधिकृत करना होगा।", + "instruction2": "आइकन पर क्लिक करके या टेक्स्ट का चयन करके इस कोड को अपने क्लिपबोर्ड पर कॉपी करें:", + "instruction3": "अपने वेब ब्राउज़र में निम्नलिखित लिंक पर जाएँ, और उपरोक्त कोड दर्ज करें", + "instruction4": "साइनअप पूरा होने पर नीचे दिया गया बटन दबाएँ:" + } + }, + "settings": { + "title": "सेटिंग्स", + "menu": { + "appearance": "दृश्य", + "language": "भाषा", + "user": "उपयोगकर्ता", + "files": "फ़ाइलें", + "open": "सेटिंग्स खोलें", + "logout": "लॉगआउट", + "logoutPrompt": "क्या आप निश्चित रूप से लॉगआउट करना चाहते हैं?", + "selfEncryptionLogoutPrompt": "क्या आप वाकई लॉग आउट करना चाहते हैं? कृपया सुनिश्चित करें कि आपने एन्क्रिप्शन रहस्य की कॉपी बना ली है", + "syncSetting": "सिंक सेटिंग", + "enableSync": "सिंक इनेबल करें", + "enableEncrypt": "डेटा एन्क्रिप्ट करें", + "enableEncryptPrompt": "इस रहस्य के साथ अपने डेटा को सुरक्षित करने के लिए एन्क्रिप्शन सक्रिय करें। इसे सुरक्षित रूप से संग्रहीत करें; एक बार सक्षम होने के बाद, इसे बंद नहीं किया जा सकता है। यदि खो जाता है, तो आपका डेटा पुनर्प्राप्त नहीं किया जा सकता है। कॉपी करने के लिए क्लिक करें", + "inputEncryptPrompt": "कृपया अपना एन्क्रिप्शन रहस्य दर्ज करें", + "clickToCopySecret": "गुप्त कॉपी बनाने के लिए क्लिक करें", + "inputTextFieldHint": "आपका रहस्य", + "historicalUserList": "उपयोगकर्ता लॉगिन इतिहास", + "historicalUserListTooltip": "यह सूची आपके अज्ञात खातों को प्रदर्शित करती है। आप किसी खाते का विवरण देखने के लिए उस पर क्लिक कर सकते हैं। 'आरंभ करें' बटन पर क्लिक करके अज्ञात खाते बनाए जाते हैं", + "openHistoricalUser": "अज्ञात खाता खोलने के लिए क्लिक करें" + }, + "appearance": { + "resetSetting": "इस सेटिंग को रीसेट करें", + "fontFamily": { + "label": "फ़ॉन्ट फॅमिली", + "search": "खोजें" + }, + "themeMode": { + "label": "थीम मोड", + "light": "लाइट मोड", + "dark": "डार्क मोड", + "system": "सिस्टम के अनुसार अनुकूलित करें" + }, + "layoutDirection": { + "label": "लेआउट दिशा", + "hint": "अपनी स्क्रीन पर सामग्री के प्रवाह को बाएँ से दाएँ या दाएँ से बाएँ नियंत्रित करें।", + "ltr": "एलटीआर", + "rtl": "आरटीएल" + }, + "textDirection": { + "label": "डिफ़ॉल्ट वाक्य दिशा", + "hint": "निर्दिष्ट करें कि वाक्य को डिफ़ॉल्ट के रूप में बाएँ या दाएँ से प्रारंभ करना चाहिए।", + "ltr": "एलटीआर", + "rtl": "आरटीएल", + "auto": "ऑटो", + "fallback": "लेआउट दिशा के समान" + }, + "themeUpload": { + "button": "अपलोड करें", + "uploadTheme": "थीम अपलोड करें", + "description": "नीचे दिए गए बटन का उपयोग करके अपनी खुद की AppFlowy थीम अपलोड करें।", + "failure": "जो थीम अपलोड किया गया था उसका प्रारूप अमान्य था।", + "loading": "कृपया तब तक प्रतीक्षा करें जब तक हम आपकी थीम को सत्यापित और अपलोड नहीं कर देते...", + "uploadSuccess": "आपका थीम सफलतापूर्वक अपलोड किया गया", + "deletionFailure": "थीम को हटाने में विफल। इसे मैन्युअल रूप से हटाने का प्रयास करें।", + "filePickerDialogTitle": "एक .flowy_plugin फ़ाइल चुनें", + "urlUploadFailure": "URL खोलने में विफल: {}" + }, + "theme": "थीम", + "builtInsLabel": "डिफ़ॉल्ट थीम", + "pluginsLabel": "प्लगइन्स", + "showNamingDialogWhenCreatingPage": "पेज बनाते समय उसका नाम लेने के लिए डायलॉग देखे" + }, + "files": { + "copy": "कॉपी करें", + "defaultLocation": "फ़ाइलें और डेटा संग्रहण स्थान पढ़ें", + "exportData": "अपना डेटा निर्यात करें", + "doubleTapToCopy": "पथ को कॉपी करने के लिए दो बार टैप करें", + "restoreLocation": "AppFlowy डिफ़ॉल्ट पथ पर रीस्टार्ट करें", + "customizeLocation": "कोई अन्य फ़ोल्डर खोलें", + "restartApp": "परिवर्तनों को प्रभावी बनाने के लिए कृपया ऐप को रीस्टार्ट करें।", + "exportDatabase": "डेटाबेस निर्यात करें", + "selectFiles": "उन फ़ाइलों का चयन करें जिन्हें निर्यात करने की आवश्यकता है", + "selectAll": "सभी का चयन करें", + "deselectAll": "सभी को अचयनित करें", + "createNewFolder": "एक नया फ़ोल्डर बनाएँ", + "createNewFolderDesc": "हमें बताएं कि आप अपना डेटा कहां संग्रहीत करना चाहते हैं", + "defineWhereYourDataIsStored": "परिभाषित करें कि आपका डेटा कहाँ संग्रहीत है", + "open": "खोलें", + "openFolder": "मौजूदा फ़ोल्डर खोलें", + "openFolderDesc": "इसे पढ़ें और इसे अपने मौजूदा AppFlowy फ़ोल्डर में लिखें", + "folderHintText": "फ़ोल्डर का नाम", + "location": "एक नया फ़ोल्डर बनाना", + "locationDesc": "अपने AppFlowy डेटा फ़ोल्डर के लिए एक नाम चुनें", + "browser": "ब्राउज़ करें", + "create": "बनाएँ", + "set": "सेट", + "folderPath": "आपके फ़ोल्डर को संग्रहीत करने का पथ", + "locationCannotBeEmpty": "पथ खाली नहीं हो सकता", + "pathCopiedSnackbar": "फ़ाइल संग्रहण पथ क्लिपबोर्ड पर कॉपी किया गया!", + "changeLocationTooltips": "डेटा निर्देशिका बदलें", + "change": "परिवर्तन", + "openLocationTooltips": "अन्य डेटा निर्देशिका खोलें", + "openCurrentDataFolder": "वर्तमान डेटा निर्देशिका खोलें", + "recoverLocationTooltips": "AppFlowy की डिफ़ॉल्ट डेटा निर्देशिका पर रीसेट करें", + "exportFileSuccess": "फ़ाइल सफलतापूर्वक निर्यात हुई", + "exportFileFail": "फ़ाइल निर्यात विफल रहा!", + "export": "निर्यात" + }, + "user": { + "name": "नाम", + "email": "ईमेल", + "tooltipSelectIcon": "आइकन चुनें", + "selectAnIcon": "एक आइकन चुनें", + "pleaseInputYourOpenAIKey": "कृपया अपनी OpenAI key इनपुट करें", + "clickToLogout": "वर्तमान उपयोगकर्ता को लॉगआउट करने के लिए क्लिक करें" + }, + "shortcuts": { + "shortcutsLabel": "शॉर्टकट", + "command": "कमांड", + "keyBinding": "कीबाइंडिंग", + "addNewCommand": "नया कमांड जोड़ें", + "updateShortcutStep": "इच्छित key संयोजन दबाएँ और ENTER दबाएँ", + "shortcutIsAlreadyUsed": "यह शॉर्टकट पहले से ही इसके लिए उपयोग किया जा चुका है: {conflict}", + "resetToDefault": "डिफ़ॉल्ट कीबाइंडिंग पर रीसेट करें", + "couldNotLoadErrorMsg": "शॉर्टकट लोड नहीं हो सका, पुनः प्रयास करें", + "couldNotSaveErrorMsg": "शॉर्टकट सेव नहीं किये जा सके, पुनः प्रयास करें" + } + }, + "grid": { + "deleteView": "क्या आप वाकई इस दृश्य को हटाना चाहते हैं?", + "createView": "नया", + "title": { + "placeholder": "शीर्षकहीन" }, "settings": { - "title": "सेटिंग्स", - "menu": { - "appearance": "दृश्य", - "language": "भाषा", - "user": "उपयोगकर्ता", - "files": "फ़ाइलें", - "open": "सेटिंग्स खोलें", - "logout": "लॉगआउट", - "logoutPrompt": "क्या आप निश्चित रूप से लॉगआउट करना चाहते हैं?", - "selfEncryptionLogoutPrompt": "क्या आप वाकई लॉग आउट करना चाहते हैं? कृपया सुनिश्चित करें कि आपने एन्क्रिप्शन रहस्य की कॉपी बना ली है", - "syncSetting": "सिंक सेटिंग", - "enableSync": "सिंक इनेबल करें", - "enableEncrypt": "डेटा एन्क्रिप्ट करें", - "enableEncryptPrompt": "इस रहस्य के साथ अपने डेटा को सुरक्षित करने के लिए एन्क्रिप्शन सक्रिय करें। इसे सुरक्षित रूप से संग्रहीत करें; एक बार सक्षम होने के बाद, इसे बंद नहीं किया जा सकता है। यदि खो जाता है, तो आपका डेटा पुनर्प्राप्त नहीं किया जा सकता है। कॉपी करने के लिए क्लिक करें", - "inputEncryptPrompt": "कृपया अपना एन्क्रिप्शन रहस्य दर्ज करें", - "clickToCopySecret": "गुप्त कॉपी बनाने के लिए क्लिक करें", - "inputTextFieldHint": "आपका रहस्य", - "historicalUserList": "उपयोगकर्ता लॉगिन इतिहास", - "historicalUserListTooltip": "यह सूची आपके अज्ञात खातों को प्रदर्शित करती है। आप किसी खाते का विवरण देखने के लिए उस पर क्लिक कर सकते हैं। 'आरंभ करें' बटन पर क्लिक करके अज्ञात खाते बनाए जाते हैं", - "openHistoricalUser": "अज्ञात खाता खोलने के लिए क्लिक करें" - }, - "appearance": { - "resetSetting": "इस सेटिंग को रीसेट करें", - "fontFamily": { - "label": "फ़ॉन्ट फॅमिली", - "search": "खोजें" - }, - "themeMode": { - "label": "थीम मोड", - "light": "लाइट मोड", - "dark": "डार्क मोड", - "system": "सिस्टम के अनुसार अनुकूलित करें" - }, - "layoutDirection": { - "label": "लेआउट दिशा", - "hint": "अपनी स्क्रीन पर सामग्री के प्रवाह को बाएँ से दाएँ या दाएँ से बाएँ नियंत्रित करें।", - "ltr": "एलटीआर", - "rtl": "आरटीएल" - }, - "textDirection": { - "label": "डिफ़ॉल्ट वाक्य दिशा", - "hint": "निर्दिष्ट करें कि वाक्य को डिफ़ॉल्ट के रूप में बाएँ या दाएँ से प्रारंभ करना चाहिए।", - "ltr": "एलटीआर", - "rtl": "आरटीएल", - "auto": "ऑटो", - "fallback": "लेआउट दिशा के समान" - }, - "themeUpload": { - "button": "अपलोड करें", - "uploadTheme": "थीम अपलोड करें", - "description": "नीचे दिए गए बटन का उपयोग करके अपनी खुद की AppFlowy थीम अपलोड करें।", - "failure": "जो थीम अपलोड किया गया था उसका प्रारूप अमान्य था।", - "loading": "कृपया तब तक प्रतीक्षा करें जब तक हम आपकी थीम को सत्यापित और अपलोड नहीं कर देते...", - "uploadSuccess": "आपका थीम सफलतापूर्वक अपलोड किया गया", - "deletionFailure": "थीम को हटाने में विफल। इसे मैन्युअल रूप से हटाने का प्रयास करें।", - "filePickerDialogTitle": "एक .flowy_plugin फ़ाइल चुनें", - "urlUploadFailure": "URL खोलने में विफल: {}" - }, - "theme": "थीम", - "builtInsLabel": "डिफ़ॉल्ट थीम", - "pluginsLabel": "प्लगइन्स", - "showNamingDialogWhenCreatingPage": "पेज बनाते समय उसका नाम लेने के लिए डायलॉग देखे" - }, - "files": { - "copy": "कॉपी करें", - "defaultLocation": "फ़ाइलें और डेटा संग्रहण स्थान पढ़ें", - "exportData": "अपना डेटा निर्यात करें", - "doubleTapToCopy": "पथ को कॉपी करने के लिए दो बार टैप करें", - "restoreLocation": "AppFlowy डिफ़ॉल्ट पथ पर रीस्टार्ट करें", - "customizeLocation": "कोई अन्य फ़ोल्डर खोलें", - "restartApp": "परिवर्तनों को प्रभावी बनाने के लिए कृपया ऐप को रीस्टार्ट करें।", - "exportDatabase": "डेटाबेस निर्यात करें", - "selectFiles": "उन फ़ाइलों का चयन करें जिन्हें निर्यात करने की आवश्यकता है", - "selectAll": "सभी का चयन करें", - "deselectAll": "सभी को अचयनित करें", - "createNewFolder": "एक नया फ़ोल्डर बनाएँ", - "createNewFolderDesc": "हमें बताएं कि आप अपना डेटा कहां संग्रहीत करना चाहते हैं", - "defineWhereYourDataIsStored": "परिभाषित करें कि आपका डेटा कहाँ संग्रहीत है", - "open": "खोलें", - "openFolder": "मौजूदा फ़ोल्डर खोलें", - "openFolderDesc": "इसे पढ़ें और इसे अपने मौजूदा AppFlowy फ़ोल्डर में लिखें", - "folderHintText": "फ़ोल्डर का नाम", - "location": "एक नया फ़ोल्डर बनाना", - "locationDesc": "अपने AppFlowy डेटा फ़ोल्डर के लिए एक नाम चुनें", - "browser": "ब्राउज़ करें", - "create": "बनाएँ", - "set": "सेट", - "folderPath": "आपके फ़ोल्डर को संग्रहीत करने का पथ", - "locationCannotBeEmpty": "पथ खाली नहीं हो सकता", - "pathCopiedSnackbar": "फ़ाइल संग्रहण पथ क्लिपबोर्ड पर कॉपी किया गया!", - "changeLocationTooltips": "डेटा निर्देशिका बदलें", - "change": "परिवर्तन", - "openLocationTooltips": "अन्य डेटा निर्देशिका खोलें", - "openCurrentDataFolder": "वर्तमान डेटा निर्देशिका खोलें", - "recoverLocationTooltips": "AppFlowy की डिफ़ॉल्ट डेटा निर्देशिका पर रीसेट करें", - "exportFileSuccess": "फ़ाइल सफलतापूर्वक निर्यात हुई", - "exportFileFail": "फ़ाइल निर्यात विफल रहा!", - "export": "निर्यात" - }, - "user": { - "name": "नाम", - "email": "ईमेल", - "tooltipSelectIcon": "आइकन चुनें", - "selectAnIcon": "एक आइकन चुनें", - "pleaseInputYourOpenAIKey": "कृपया अपनी OpenAI key इनपुट करें", - "clickToLogout": "वर्तमान उपयोगकर्ता को लॉगआउट करने के लिए क्लिक करें" - }, - "shortcuts": { - "shortcutsLabel": "शॉर्टकट", - "command": "कमांड", - "keyBinding": "कीबाइंडिंग", - "addNewCommand": "नया कमांड जोड़ें", - "updateShortcutStep": "इच्छित key संयोजन दबाएँ और ENTER दबाएँ", - "shortcutIsAlreadyUsed": "यह शॉर्टकट पहले से ही इसके लिए उपयोग किया जा चुका है: {conflict}", - "resetToDefault": "डिफ़ॉल्ट कीबाइंडिंग पर रीसेट करें", - "couldNotLoadErrorMsg": "शॉर्टकट लोड नहीं हो सका, पुनः प्रयास करें", - "couldNotSaveErrorMsg": "शॉर्टकट सेव नहीं किये जा सके, पुनः प्रयास करें" - } + "filter": "फ़िल्टर", + "sort": "क्रमबद्ध करें", + "sortBy": "क्रमबद्ध करें", + "properties": "गुण", + "reorderPropertiesTooltip": "गुणों को पुनः व्यवस्थित करने के लिए खींचें", + "group": "समूह", + "addFilter": "फ़िल्टर करें...", + "deleteFilter": "फ़िल्टर हटाएँ", + "filterBy": "फ़िल्टरबाय...", + "typeAValue": "एक वैल्यू टाइप करें...", + "layout": "लेआउट", + "databaseLayout": "लेआउट" }, - "grid": { - "deleteView": "क्या आप वाकई इस दृश्य को हटाना चाहते हैं?", - "createView": "नया", - "title": { - "placeholder": "शीर्षकहीन" - }, - "settings": { - "filter": "फ़िल्टर", - "sort": "क्रमबद्ध करें", - "sortBy": "क्रमबद्ध करें", - "properties": "गुण", - "reorderPropertiesTooltip": "गुणों को पुनः व्यवस्थित करने के लिए खींचें", - "group": "समूह", - "addFilter": "फ़िल्टर करें...", - "deleteFilter": "फ़िल्टर हटाएँ", - "filterBy": "फ़िल्टरबाय...", - "typeAValue": "एक वैल्यू टाइप करें...", - "layout": "लेआउट", - "databaseLayout": "लेआउट" - }, - "textFilter": { - "contains": "शामिल है", - "doesNotContain": "इसमें शामिल नहीं है", - "endsWith": "समाप्त होता है", - "startWith": "से प्रारंभ होता है", - "is": "है", - "isNot": "नहीं है", - "isEmpty": "खाली है", - "isNotEmpty": "खाली नहीं है", - "choicechipPrefix": { - "isNot": "नहीं है", - "startWith": "से प्रारंभ होता है", - "endWith": "के साथ समाप्त होता है", - "isEmpty": "खाली है", - "isNotEmpty": "खाली नहीं है" - } - }, - "checkboxFilter": { - "isChecked": "चेक किया गया", - "isUnchecked": "अनचेक किया हुआ", - "choicechipPrefix": { - "is": "है" - } - }, - "checklistFilter": { - "isComplete": "पूर्ण है", - "isIncomplted": "अपूर्ण है" - }, - "singleSelectOptionFilter": { - "is": "है", + "textFilter": { + "contains": "शामिल है", + "doesNotContain": "इसमें शामिल नहीं है", + "endsWith": "समाप्त होता है", + "startWith": "से प्रारंभ होता है", + "is": "है", + "isNot": "नहीं है", + "isEmpty": "खाली है", + "isNotEmpty": "खाली नहीं है", + "choicechipPrefix": { "isNot": "नहीं है", + "startWith": "से प्रारंभ होता है", + "endWith": "के साथ समाप्त होता है", "isEmpty": "खाली है", "isNotEmpty": "खाली नहीं है" - }, - "multiSelectOptionFilter": { - "contains": "शामिल है", - "doesNotContain": "इसमें शामिल नहीं है", - "isEmpty": "खाली है", - "isNotEmpty": "खाली नहीं है" - }, - "field": { - "hide": "छिपाएँ", - "insertLeft": "बायाँ सम्मिलित करें", - "insertRight": "दाएँ सम्मिलित करें", + } + }, + "checkboxFilter": { + "isChecked": "चेक किया गया", + "isUnchecked": "अनचेक किया हुआ", + "choicechipPrefix": { + "is": "है" + } + }, + "checklistFilter": { + "isComplete": "पूर्ण है", + "isIncomplted": "अपूर्ण है" + }, + "selectOptionFilter": { + "is": "है", + "isNot": "नहीं है", + "contains": "शामिल है", + "doesNotContain": "इसमें शामिल नहीं है", + "isEmpty": "खाली है", + "isNotEmpty": "खाली नहीं है" + }, + "field": { + "hide": "छिपाएँ", + "insertLeft": "बायाँ सम्मिलित करें", + "insertRight": "दाएँ सम्मिलित करें", + "duplicate": "डुप्लिकेट", + "delete": "हटाएं", + "textFieldName": "लेख", + "checkboxFieldName": "चेकबॉक्स", + "dateFieldName": "दिनांक", + "updatedAtFieldName": "अंतिम संशोधित समय", + "createdAtFieldName": "बनाने का समय", + "numberFieldName": "संख्या", + "singleSelectFieldName": "चुनाव", + "multiSelectFieldName": "बहु चुनाव", + "urlFieldName": "URL", + "checklistFieldName": "चेकलिस्ट", + "numberFormat": "संख्या प्रारूप", + "dateFormat": "दिनांक प्रारूप", + "includeTime": "समय शामिल करें", + "isRange": "अंतिम तिथि", + "dateFormatFriendly": "माह दिन, वर्ष", + "dateFormatISO": "वर्ष-महीना-दिन", + "dateFormatLocal": "महीना/दिन/वर्ष", + "dateFormatUS": "वर्ष/महीना/दिन", + "dateFormatDayMonthYear": "दिन/माह/वर्ष", + "timeFormat": "समय प्रारूप", + "invalidTimeFormat": "अमान्य प्रारूप", + "timeFormatTwelveHour": "१२ घंटा", + "timeFormatTwentyFourHour": "२४ घंटे", + "clearDate": "तिथि मिटाए", + "addSelectOption": "एक विकल्प जोड़ें", + "optionTitle": "विकल्प", + "addOption": "विकल्प जोड़ें", + "editProperty": "डेटा का प्रकार संपादित करें", + "newProperty": "नया डेटा का प्रकार", + "deleteFieldPromptMessage": "क्या आप निश्चित हैं? यह डेटा का प्रकार हटा दी जाएगी", + "newColumn": "नया कॉलम" + }, + "sort": { + "ascending": "असेंडिंग", + "descending": "डिसेंडिंग", + "deleteAllSorts": "सभी प्रकार हटाएँ", + "addSort": "सॉर्ट जोड़ें" + }, + "row": { + "duplicate": "डुप्लिकेट", + "delete": "डिलीट", + "titlePlaceholder": "शीर्षकहीन", + "textPlaceholder": "रिक्त", + "copyProperty": "डेटा के प्रकार को क्लिपबोर्ड पर कॉपी किया गया", + "count": "गिनती", + "newRow": "नई पंक्ति", + "action": "कार्रवाई", + "add": "नीचे जोड़ें पर क्लिक करें", + "drag": "स्थानांतरित करने के लिए खींचें" + }, + "selectOption": { + "create": "बनाएँ", + "purpleColor": "बैंगनी", + "pinkColor": "गुलाबी", + "lightPinkColor": "हल्का गुलाबी", + "orangeColor": "नारंगी", + "yellowColor": "पीला", + "limeColor": "नींबू", + "greenColor": "हरा", + "aquaColor": "एक्वा", + "blueColor": "नीला", + "deleteTag": "टैग हटाएँ", + "colorPanelTitle": "रंग", + "panelTitle": "एक विकल्प चुनें या एक बनाएं", + "searchOption": "एक विकल्प खोजें", + "searchOrCreateOption": "कोई विकल्प खोजें या बनाएँ...", + "createNew": "एक नया बनाएँ", + "orSelectOne": "या एक विकल्प चुनें" + }, + "checklist": { + "taskHint": "कार्य विवरण", + "addNew": "एक नया कार्य जोड़ें", + "submitNewTask": "बनाएँ" + }, + "menuName": "ग्रिड", + "referencedGridPrefix": "का दृश्य" + }, + "document": { + "menuName": "दस्तावेज़ ", + "date": { + "timeHintTextInTwelveHour": "01:00 PM", + "timeHintTextInTwentyFourHour": "13:00" + }, + "slashMenu": { + "board": { + "selectABoardToLinkTo": "लिंक करने के लिए एक बोर्ड चुनें", + "createANewBoard": "एक नया बोर्ड बनाएं" + }, + "grid": { + "selectAGridToLinkTo": "लिंक करने के लिए एक ग्रिड चुनें", + "createANewGrid": "एक नया ग्रिड बनाएं" + }, + "calendar": { + "selectACalendarToLinkTo": "लिंक करने के लिए एक कैलेंडर चुनें", + "createANewCalendar": "एक नया कैलेंडर बनाएं" + } + }, + "selectionMenu": { + "outline": "रूपरेखा", + "codeBlock": "कोड ब्लॉक" + }, + "plugins": { + "referencedBoard": "रेफेरेंस बोर्ड", + "referencedGrid": "रेफेरेंस ग्रिड", + "referencedCalendar": "रेफेरेंस कैलेंडर", + "autoGeneratorMenuItemName": "OpenAI लेखक", + "autoGeneratorTitleName": "OpenAI: AI को कुछ भी लिखने के लिए कहें...", + "autoGeneratorLearnMore": "और जानें", + "autoGeneratorGenerate": "उत्पन्न करें", + "autoGeneratorHintText": "OpenAI से पूछें...", + "autoGeneratorCantGetOpenAIKey": "OpenAI key नहीं मिल सकी", + "autoGeneratorRewrite": "पुनः लिखें", + "smartEdit": "AI सहायक", + "openAI": "OpenAI", + "smartEditFixSpelling": "वर्तनी ठीक करें", + "warning": "⚠️ AI प्रतिक्रियाएँ गलत या भ्रामक हो सकती हैं।", + "smartEditSummarize": "सारांश", + "smartEditImproveWriting": "लेख में सुधार करें", + "smartEditMakeLonger": "लंबा बनाएं", + "smartEditCouldNotFetchResult": "OpenAI से परिणाम प्राप्त नहीं किया जा सका", + "smartEditCouldNotFetchKey": "OpenAI key नहीं लायी जा सकी", + "smartEditDisabled": "सेटिंग्स में OpenAI कनेक्ट करें", + "discardResponse": "क्या आप AI प्रतिक्रियाओं को छोड़ना चाहते हैं?", + "createInlineMathEquation": "समीकरण बनाएं", + "toggleList": "सूची टॉगल करें", + "cover": { + "changeCover": "कवर बदलें", + "colors": "रंग", + "images": "छवियां", + "clearAll": "सभी साफ़ करें", + "abstract": "सार", + "addCover": "कवर जोड़ें", + "addLocalImage": "स्थानीय छवि जोड़ें", + "invalidImageUrl": "अमान्य छवि URL", + "failedToAddImageToGallery": "गैलरी में छवि जोड़ने में विफल", + "enterImageUrl": "छवि URL दर्ज करें", + "add": "जोड़ें", + "back": "पीछे", + "saveToGallery": "गैलरी में सेव करे", + "removeIcon": "आइकन हटाएँ", + "pasteImageUrl": "छवि URL चिपकाएँ", + "or": "या", + "pickFromFiles": "फ़ाइलों में से चुनें", + "couldNotFetchImage": "छवि नहीं लाया जा सका", + "imageSavingFailed": "छवि सहेजना विफल", + "addIcon": "आइकन जोड़ें", + "coverRemoveAlert": "हटाने के बाद इसे कवर से हटा दिया जाएगा।", + "alertDialogConfirmation": "क्या आप निश्चित हैं, आप जारी रखना चाहते हैं?" + }, + "mathEquation": { + "addMathEquation": "गणित समीकरण जोड़ें", + "editMathEquation": "गणित समीकरण संपादित करें" + }, + "optionAction": { + "click": "क्लिक करें", + "toOpenMenu": "मेनू खोलने के लिए", + "delete": "हटाएं", "duplicate": "डुप्लिकेट", + "turnInto": "टर्नइनटू", + "moveUp": "ऊपर बढ़ें", + "moveDown": "नीचे जाएँ", + "color": "रंग", + "align": "संरेखित करें", + "left": "बांया", + "center": "केंद्र", + "right": "सही", + "defaultColor": "डिफ़ॉल्ट" + }, + "image": { + "copiedToPasteBoard": "छवि लिंक को क्लिपबोर्ड पर कॉपी कर दिया गया है" + }, + "outline": { + "addHeadingToCreateOutline": "सामग्री की तालिका बनाने के लिए शीर्षक जोड़ें।" + }, + "table": { + "addAfter": "बाद में जोड़ें", + "addBefore": "पहले जोड़ें", "delete": "हटाएं", - "textFieldName": "लेख", - "checkboxFieldName": "चेकबॉक्स", - "dateFieldName": "दिनांक", - "updatedAtFieldName": "अंतिम संशोधित समय", - "createdAtFieldName": "बनाने का समय", - "numberFieldName": "संख्या", - "singleSelectFieldName": "चुनाव", - "multiSelectFieldName": "बहु चुनाव", - "urlFieldName": "URL", - "checklistFieldName": "चेकलिस्ट", - "numberFormat": "संख्या प्रारूप", - "dateFormat": "दिनांक प्रारूप", - "includeTime": "समय शामिल करें", - "isRange": "अंतिम तिथि", - "dateFormatFriendly": "माह दिन, वर्ष", - "dateFormatISO": "वर्ष-महीना-दिन", - "dateFormatLocal": "महीना/दिन/वर्ष", - "dateFormatUS": "वर्ष/महीना/दिन", - "dateFormatDayMonthYear": "दिन/माह/वर्ष", - "timeFormat": "समय प्रारूप", - "invalidTimeFormat": "अमान्य प्रारूप", - "timeFormatTwelveHour": "१२ घंटा", - "timeFormatTwentyFourHour": "२४ घंटे", - "clearDate": "तिथि मिटाए", - "addSelectOption": "एक विकल्प जोड़ें", - "optionTitle": "विकल्प", - "addOption": "विकल्प जोड़ें", - "editProperty": "डेटा का प्रकार संपादित करें", - "newProperty": "नया डेटा का प्रकार", - "deleteFieldPromptMessage": "क्या आप निश्चित हैं? यह डेटा का प्रकार हटा दी जाएगी", - "newColumn": "नया कॉलम" - }, - "sort": { - "ascending": "असेंडिंग", - "descending": "डिसेंडिंग", - "deleteAllSorts": "सभी प्रकार हटाएँ", - "addSort": "सॉर्ट जोड़ें" - }, - "row": { + "clear": "साफ़ करें", "duplicate": "डुप्लिकेट", - "delete": "डिलीट", - "titlePlaceholder": "शीर्षकहीन", - "textPlaceholder": "रिक्त", - "copyProperty": "डेटा के प्रकार को क्लिपबोर्ड पर कॉपी किया गया", - "count": "गिनती", - "newRow": "नई पंक्ति", - "action": "कार्रवाई", - "add": "नीचे जोड़ें पर क्लिक करें", - "drag": "स्थानांतरित करने के लिए खींचें" - }, - "selectOption": { - "create": "बनाएँ", - "purpleColor": "बैंगनी", - "pinkColor": "गुलाबी", - "lightPinkColor": "हल्का गुलाबी", - "orangeColor": "नारंगी", - "yellowColor": "पीला", - "limeColor": "नींबू", - "greenColor": "हरा", - "aquaColor": "एक्वा", - "blueColor": "नीला", - "deleteTag": "टैग हटाएँ", - "colorPanelTitle": "रंग", - "panelTitle": "एक विकल्प चुनें या एक बनाएं", - "searchOption": "एक विकल्प खोजें", - "searchOrCreateOption": "कोई विकल्प खोजें या बनाएँ...", - "createNew": "एक नया बनाएँ", - "orSelectOne": "या एक विकल्प चुनें" - }, - "checklist": { - "taskHint": "कार्य विवरण", - "addNew": "एक नया कार्य जोड़ें", - "submitNewTask": "बनाएँ" - }, - "menuName": "ग्रिड", - "referencedGridPrefix": "का दृश्य" - }, - "document": { - "menuName": "दस्तावेज़ ", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - }, - "slashMenu": { - "board": { - "selectABoardToLinkTo": "लिंक करने के लिए एक बोर्ड चुनें", - "createANewBoard": "एक नया बोर्ड बनाएं" - }, - "grid": { - "selectAGridToLinkTo": "लिंक करने के लिए एक ग्रिड चुनें", - "createANewGrid": "एक नया ग्रिड बनाएं" - }, - "calendar": { - "selectACalendarToLinkTo": "लिंक करने के लिए एक कैलेंडर चुनें", - "createANewCalendar": "एक नया कैलेंडर बनाएं" - } - }, - "selectionMenu": { - "outline": "रूपरेखा", - "codeBlock": "कोड ब्लॉक" - }, - "plugins": { - "referencedBoard": "रेफेरेंस बोर्ड", - "referencedGrid": "रेफेरेंस ग्रिड", - "referencedCalendar": "रेफेरेंस कैलेंडर", - "autoGeneratorMenuItemName": "OpenAI लेखक", - "autoGeneratorTitleName": "OpenAI: AI को कुछ भी लिखने के लिए कहें...", - "autoGeneratorLearnMore": "और जानें", - "autoGeneratorGenerate": "उत्पन्न करें", - "autoGeneratorHintText": "OpenAI से पूछें...", - "autoGeneratorCantGetOpenAIKey": "OpenAI key नहीं मिल सकी", - "autoGeneratorRewrite": "पुनः लिखें", - "smartEdit": "AI सहायक", - "openAI": "OpenAI", - "smartEditFixSpelling": "वर्तनी ठीक करें", - "warning": "⚠️ AI प्रतिक्रियाएँ गलत या भ्रामक हो सकती हैं।", - "smartEditSummarize": "सारांश", - "smartEditImproveWriting": "लेख में सुधार करें", - "smartEditMakeLonger": "लंबा बनाएं", - "smartEditCouldNotFetchResult": "OpenAI से परिणाम प्राप्त नहीं किया जा सका", - "smartEditCouldNotFetchKey": "OpenAI key नहीं लायी जा सकी", - "smartEditDisabled": "सेटिंग्स में OpenAI कनेक्ट करें", - "discardResponse": "क्या आप AI प्रतिक्रियाओं को छोड़ना चाहते हैं?", - "createInlineMathEquation": "समीकरण बनाएं", - "toggleList": "सूची टॉगल करें", - "cover": { - "changeCover": "कवर बदलें", - "colors": "रंग", - "images": "छवियां", - "clearAll": "सभी साफ़ करें", - "abstract": "सार", - "addCover": "कवर जोड़ें", - "addLocalImage": "स्थानीय छवि जोड़ें", - "invalidImageUrl": "अमान्य छवि URL", - "failedToAddImageToGallery": "गैलरी में छवि जोड़ने में विफल", - "enterImageUrl": "छवि URL दर्ज करें", - "add": "जोड़ें", - "back": "पीछे", - "saveToGallery": "गैलरी में सेव करे", - "removeIcon": "आइकन हटाएँ", - "pasteImageUrl": "छवि URL चिपकाएँ", - "or": "या", - "pickFromFiles": "फ़ाइलों में से चुनें", - "couldNotFetchImage": "छवि नहीं लाया जा सका", - "imageSavingFailed": "छवि सहेजना विफल", - "addIcon": "आइकन जोड़ें", - "coverRemoveAlert": "हटाने के बाद इसे कवर से हटा दिया जाएगा।", - "alertDialogConfirmation": "क्या आप निश्चित हैं, आप जारी रखना चाहते हैं?" - }, - "mathEquation": { - "addMathEquation": "गणित समीकरण जोड़ें", - "editMathEquation": "गणित समीकरण संपादित करें" - }, - "optionAction": { - "click": "क्लिक करें", - "toOpenMenu": "मेनू खोलने के लिए", - "delete": "हटाएं", - "duplicate": "डुप्लिकेट", - "turnInto": "टर्नइनटू", - "moveUp": "ऊपर बढ़ें", - "moveDown": "नीचे जाएँ", - "color": "रंग", - "align": "संरेखित करें", - "left": "बांया", - "center": "केंद्र", - "right": "सही", - "defaultColor": "डिफ़ॉल्ट" - }, - "image": { - "copiedToPasteBoard": "छवि लिंक को क्लिपबोर्ड पर कॉपी कर दिया गया है" - }, - "outline": { - "addHeadingToCreateOutline": "सामग्री की तालिका बनाने के लिए शीर्षक जोड़ें।" - }, - "table": { - "addAfter": "बाद में जोड़ें", - "addBefore": "पहले जोड़ें", - "delete": "हटाएं", - "clear": "साफ़ करें", - "duplicate": "डुप्लिकेट", - "bgColor": "पृष्ठभूमि रंग" - }, - "contextMenu": { - "copy": "कॉपी करें", - "cut": "कट करे", - "paste": "पेस्ट करें" - } - }, - "textBlock": { - "placeholder": "कमांड के लिए '/' टाइप करें" - }, - "title": { - "placeholder": "शीर्षकहीन" - }, - "imageBlock": { - "placeholder": "छवि जोड़ने के लिए क्लिक करें", - "अपलोड करें": { - "label": "अपलोड करें", - "placeholder": "छवि अपलोड करने के लिए क्लिक करें" - }, - "url": { - "label": "छवि URL ", - "placeholder": "छवि URL दर्ज करें" - }, - "support": "छवि आकार सीमा 5 एमबी है। समर्थित प्रारूप: JPEG, PNG, GIF, SVG", - "error": { - "invalidImage": "अमान्य छवि", - "invalidImageSize": "छवि का आकार 5MB से कम होना चाहिए", - "invalidImageFormat": "छवि प्रारूप समर्थित नहीं है। समर्थित प्रारूप: JPEG, PNG, GIF, SVG", - "invalidImageUrl": "अमान्य छवि URL" - } - }, - "codeBlock": { - "language": { - "label": "भाषा", - "placeholder": "भाषा चुनें" - } + "bgColor": "पृष्ठभूमि रंग" }, - "inlineLink": { - "placeholder": "लिंक चिपकाएँ या टाइप करें", - "openInNewTab": "नए टैब में खोलें", - "copyLink": "लिंक कॉपी करें", - "removeLink": "लिंक हटाएँ", - "url": { - "label": "लिंक URL", - "placeholder": "लिंक URL दर्ज करें" - }, - "title": { - "label": "लिंक शीर्षक", - "placeholder": "लिंक शीर्षक दर्ज करें" - } - }, - "mention": { - "placeholder": "किसी व्यक्ति या पेज या दिनांक का उल्लेख करें...", - "page": { - "label": "पेज से लिंक करें", - "tooltip": "पेज खोलने के लिए क्लिक करें" - } - }, - "toolbar": { - "resetToDefaultFont": "डिफ़ॉल्ट पर रीसेट करें" + "contextMenu": { + "copy": "कॉपी करें", + "cut": "कट करे", + "paste": "पेस्ट करें" } }, - "board": { - "column": { - "createNewCard": "नया" - }, - "menuName": "बोर्ड", - "referencedBoardPrefix": "का दृश्य" - }, - "calendar": { - "menuName": "कैलेंडर", - "defaultNewCalendarTitle": "शीर्षकहीन", - "newEventButtonTooltip": "एक नया ईवेंट जोड़ें", - "navigation": { - "today": "आज", - "jumpToday": "जम्प टू टुडे", - "previousMonth": "पिछला महीना", - "nextMonth": "अगले महीने" - }, - "settings": { - "showWeekNumbers": "सप्ताह संख्याएँ दिखाएँ", - "showWeekends": "सप्ताहांत दिखाएँ", - "firstDayOfWeek": "सप्ताह प्रारंभ करें", - "layoutDateField": "लेआउट कैलेंडर", - "noDateTitle": "कोई दिनांक नहीं", - "noDateHint": "अनिर्धारित घटनाएँ यहाँ दिखाई देंगी", - "clickToAdd": "कैलेंडर में जोड़ने के लिए क्लिक करें", - "name": "कैलेंडर लेआउट" - }, - "referencedCalendarPrefix": "का दृश्य" + "textBlock": { + "placeholder": "कमांड के लिए '/' टाइप करें" }, - "errorDialog": { - "title": "AppFlowy error", - "howToFixFallback": "असुविधा के लिए हमें खेद है! हमारे GitHub पेज पर एक मुद्दा सबमिट करें जो आपकी error का वर्णन करता है।", - "github": "GitHub पर देखें " + "title": { + "placeholder": "शीर्षकहीन" }, - "search": { - "label": "खोजें", - "placeholder": { - "actions": "खोज क्रियाएँ..." + "imageBlock": { + "placeholder": "छवि जोड़ने के लिए क्लिक करें", + "अपलोड करें": { + "label": "अपलोड करें", + "placeholder": "छवि अपलोड करने के लिए क्लिक करें" + }, + "url": { + "label": "छवि URL ", + "placeholder": "छवि URL दर्ज करें" + }, + "support": "छवि आकार सीमा 5 एमबी है। समर्थित प्रारूप: JPEG, PNG, GIF, SVG", + "error": { + "invalidImage": "अमान्य छवि", + "invalidImageSize": "छवि का आकार 5MB से कम होना चाहिए", + "invalidImageFormat": "छवि प्रारूप समर्थित नहीं है। समर्थित प्रारूप: JPEG, PNG, GIF, SVG", + "invalidImageUrl": "अमान्य छवि URL" } }, - "message": { - "copy": { - "success": "कॉपी सफलता पूर्ण हुआ!", - "fail": "कॉपी करने में असमर्थ" + "codeBlock": { + "language": { + "label": "भाषा", + "placeholder": "भाषा चुनें" } }, - "unSupportBlock": "वर्तमान संस्करण इस ब्लॉक का समर्थन नहीं करता है।", - "views": { - "deleteContentTitle": "क्या आप वाकई {pageType} को हटाना चाहते हैं?", - "deleteContentCaption": "यदि आप इस {pageType} को हटाते हैं, तो आप इसे ट्रैश से पुनर्स्थापित कर सकते हैं।" - }, - "colors": { - "custom": "कस्टम", - "default": "डिफ़ॉल्ट", - "red": "लाल", - "orange": "नारंगी", - "yellow": "पीला", - "green": "हरा", - "blue": "नीला", - "purple": "बैंगनी", - "pink": "गुलाबी", - "brown": "भूरा", - "gray": "ग्रे" - }, - "emoji": { - "filter": "फ़िल्टर", - "random": "रैंडम", - "selectSkinTone": "त्वचा का रंग चुनें", - "remove": "इमोजी हटाएं", - "categories": { - "smileys": "स्माइलीज़ एंड इमोशन", - "people": "लोग और शरीर", - "animals": "जानवर और प्रकृति", - "food": "खाद्य और पेय", - "activities": "गतिविधियाँ", - "places": "यात्रा एवं स्थान", - "objects": "ऑब्जेक्ट्स", - "symbols": "प्रतीक", - "flags": "झंडे", - "nature": "प्रकृति", - "frequentlyUsed": "अक्सर उपयोग किया जाता है" + "inlineLink": { + "placeholder": "लिंक चिपकाएँ या टाइप करें", + "openInNewTab": "नए टैब में खोलें", + "copyLink": "लिंक कॉपी करें", + "removeLink": "लिंक हटाएँ", + "url": { + "label": "लिंक URL", + "placeholder": "लिंक URL दर्ज करें" + }, + "title": { + "label": "लिंक शीर्षक", + "placeholder": "लिंक शीर्षक दर्ज करें" } + }, + "mention": { + "placeholder": "किसी व्यक्ति या पेज या दिनांक का उल्लेख करें...", + "page": { + "label": "पेज से लिंक करें", + "tooltip": "पेज खोलने के लिए क्लिक करें" + } + }, + "toolbar": { + "resetToDefaultFont": "डिफ़ॉल्ट पर रीसेट करें" + } + }, + "board": { + "column": { + "createNewCard": "नया" + }, + "menuName": "बोर्ड", + "referencedBoardPrefix": "का दृश्य" + }, + "calendar": { + "menuName": "कैलेंडर", + "defaultNewCalendarTitle": "शीर्षकहीन", + "newEventButtonTooltip": "एक नया ईवेंट जोड़ें", + "navigation": { + "today": "आज", + "jumpToday": "जम्प टू टुडे", + "previousMonth": "पिछला महीना", + "nextMonth": "अगले महीने" + }, + "settings": { + "showWeekNumbers": "सप्ताह संख्याएँ दिखाएँ", + "showWeekends": "सप्ताहांत दिखाएँ", + "firstDayOfWeek": "सप्ताह प्रारंभ करें", + "layoutDateField": "लेआउट कैलेंडर", + "noDateTitle": "कोई दिनांक नहीं", + "noDateHint": "अनिर्धारित घटनाएँ यहाँ दिखाई देंगी", + "clickToAdd": "कैलेंडर में जोड़ने के लिए क्लिक करें", + "name": "कैलेंडर लेआउट" + }, + "referencedCalendarPrefix": "का दृश्य" + }, + "errorDialog": { + "title": "AppFlowy error", + "howToFixFallback": "असुविधा के लिए हमें खेद है! हमारे GitHub पेज पर एक मुद्दा सबमिट करें जो आपकी error का वर्णन करता है।", + "github": "GitHub पर देखें " + }, + "search": { + "label": "खोजें", + "placeholder": { + "actions": "खोज क्रियाएँ..." + } + }, + "message": { + "copy": { + "success": "कॉपी सफलता पूर्ण हुआ!", + "fail": "कॉपी करने में असमर्थ" + } + }, + "unSupportBlock": "वर्तमान संस्करण इस ब्लॉक का समर्थन नहीं करता है।", + "views": { + "deleteContentTitle": "क्या आप वाकई {pageType} को हटाना चाहते हैं?", + "deleteContentCaption": "यदि आप इस {pageType} को हटाते हैं, तो आप इसे ट्रैश से पुनर्स्थापित कर सकते हैं।" + }, + "colors": { + "custom": "कस्टम", + "default": "डिफ़ॉल्ट", + "red": "लाल", + "orange": "नारंगी", + "yellow": "पीला", + "green": "हरा", + "blue": "नीला", + "purple": "बैंगनी", + "pink": "गुलाबी", + "brown": "भूरा", + "gray": "ग्रे" + }, + "emoji": { + "filter": "फ़िल्टर", + "random": "रैंडम", + "selectSkinTone": "त्वचा का रंग चुनें", + "remove": "इमोजी हटाएं", + "categories": { + "smileys": "स्माइलीज़ एंड इमोशन", + "people": "लोग और शरीर", + "animals": "जानवर और प्रकृति", + "food": "खाद्य और पेय", + "activities": "गतिविधियाँ", + "places": "यात्रा एवं स्थान", + "objects": "ऑब्जेक्ट्स", + "symbols": "प्रतीक", + "flags": "झंडे", + "nature": "प्रकृति", + "frequentlyUsed": "अक्सर उपयोग किया जाता है" } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/frontend/resources/translations/hu-HU.json b/frontend/resources/translations/hu-HU.json index 84ba5fd067aef..60373c39f378b 100644 --- a/frontend/resources/translations/hu-HU.json +++ b/frontend/resources/translations/hu-HU.json @@ -50,12 +50,12 @@ "copyLink": "Link másolása" }, "moreAction": { - "fontSize": "Betűméret", - "import": "Importálás", - "moreOptions": "Több lehetőség", "small": "kicsi", "medium": "közepes", - "large": "nagy" + "large": "nagy", + "fontSize": "Betűméret", + "import": "Importálás", + "moreOptions": "Több lehetőség" }, "importPanel": { "textAndMarkdown": "Szöveg & Markdown", @@ -163,7 +163,9 @@ "editContact": "Kontakt Szerkesztése" }, "button": { + "ok": "OK", "done": "Kész", + "cancel": "Mégse", "signIn": "Bejelentkezés", "signOut": "Kijelentkezés", "complete": "Kész", @@ -179,9 +181,7 @@ "edit": "Szerkesztés", "delete": "Töröl", "duplicate": "Másolat", - "putback": "Visszatesz", - "cancel": "Mégse", - "ok": "OK" + "putback": "Visszatesz" }, "label": { "welcome": "Üdvözlünk!", @@ -325,13 +325,9 @@ "isComplete": "teljes", "isIncomplted": "hiányos" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Is", "isNot": "Nem", - "isEmpty": "Üres", - "isNotEmpty": "Nem üres" - }, - "multiSelectOptionFilter": { "contains": "Tartalmaz", "doesNotContain": "Nem tartalmaz", "isEmpty": "Üres", @@ -598,4 +594,4 @@ "deleteContentTitle": "Biztosan törli a következőt: {pageType}?", "deleteContentCaption": "ha törli ezt a {pageType} oldalt, visszaállíthatja a kukából." } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/id-ID.json b/frontend/resources/translations/id-ID.json index 8ffc31acd5b8b..2bc864d67f7bf 100644 --- a/frontend/resources/translations/id-ID.json +++ b/frontend/resources/translations/id-ID.json @@ -72,12 +72,12 @@ "copyLink": "Salin tautan" }, "moreAction": { - "fontSize": "Ukuran huruf", - "import": "Impor", - "moreOptions": "Lebih banyak pilihan", "small": "kecil", "medium": "sedang", - "large": "besar" + "large": "besar", + "fontSize": "Ukuran huruf", + "import": "Impor", + "moreOptions": "Lebih banyak pilihan" }, "importPanel": { "textAndMarkdown": "Teks & Markdown", @@ -452,13 +452,9 @@ "isComplete": "selesai", "isIncomplted": "tidak lengkap" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Adalah", "isNot": "Tidak", - "isEmpty": "Kosong", - "isNotEmpty": "Tidak kosong" - }, - "multiSelectOptionFilter": { "contains": "Mengandung", "doesNotContain": "Tidak mengandung", "isEmpty": "Kosong", @@ -1021,4 +1017,4 @@ "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 72a03f8302756..fd11a78d02fb1 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -59,6 +59,8 @@ "failedToLoad": "Qualcosa è andato storto! Impossibile caricare lo spazio di lavoro. Prova a chiudere qualsiasi istanza aperta di AppFlowy e riprova.", "errorActions": { "reportIssue": "Segnala un problema", + "reportIssueOnGithub": "Segnalate un problema su Github", + "exportLogFiles": "Esporta i file di log", "reachOut": "Contattaci su Discord" } }, @@ -70,12 +72,16 @@ "copyLink": "Copia Link" }, "moreAction": { + "small": "piccolo", + "medium": "medio", + "large": "grande", "fontSize": "Dimensione del font", "import": "Importare", "moreOptions": "Più opzioni", - "small": "piccolo", - "medium": "medio", - "large": "grande" + "wordCount": "Conteggio parole: {}", + "charCount": "Numero di caratteri: {}", + "deleteView": "Cancella", + "duplicateView": "Duplica" }, "importPanel": { "textAndMarkdown": "Testo e markdown", @@ -177,9 +183,7 @@ "dragRow": "Premere a lungo per riordinare la riga", "viewDataBase": "Visualizza banca dati", "referencePage": "Questo {nome} è referenziato", - "addBlockBelow": "Aggiungi un blocco qui sotto", - "urlLaunchAccessory": "Apri nel browser", - "urlCopyAccessory": "Copia l'URL" + "addBlockBelow": "Aggiungi un blocco qui sotto" }, "sideBar": { "closeSidebar": "Close sidebar", @@ -231,7 +235,9 @@ "rename": "Rinomina", "helpCenter": "Centro assistenza", "add": "Aggiungi", - "yes": "SÌ" + "yes": "SÌ", + "remove": "Rimuovi", + "dontRemove": "Non rimuovere" }, "label": { "welcome": "Benvenuto!", @@ -276,7 +282,10 @@ "cloudLocal": "Locale", "cloudSupabase": "Supabase", "cloudSupabaseUrl": "URL di Supabase", + "cloudSupabaseUrlCanNotBeEmpty": "L'url di supabase non può essere vuoto", "cloudAppFlowy": "AppFlowy Cloud", + "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted (autogestito)", + "appFlowyCloudUrlCanNotBeEmpty": "L'url del cloud non può essere vuoto", "clickToCopy": "Fare clic per copiare", "selfHostStart": "Se non disponi di un server, fai riferimento a", "selfHostContent": "documento", @@ -286,6 +295,7 @@ "cloudWSURLHint": "Inserisci l'indirizzo websocket del tuo server", "restartApp": "Riavvia", "restartAppTip": "Riavvia l'applicazione affinché le modifiche abbiano effetto. Tieni presente che ciò potrebbe disconnettere il tuo account corrente", + "changeServerTip": "Dopo aver modificato il server, è necessario fare clic sul pulsante di riavvio affinché le modifiche abbiano effetto.", "enableEncryptPrompt": "Attiva la crittografia per proteggere i tuoi dati con questo segreto. Conservarlo in modo sicuro; una volta abilitato, non può essere spento. In caso di perdita, i tuoi dati diventano irrecuperabili. Fare clic per copiare", "inputEncryptPrompt": "Inserisci il tuo segreto di crittografia per", "clickToCopySecret": "Fare clic per copiare il segreto", @@ -297,6 +307,7 @@ "openHistoricalUser": "Fare clic per aprire l'account anonimo", "customPathPrompt": "L'archiviazione della cartella dati di AppFlowy in una cartella sincronizzata sul cloud come Google Drive può comportare rischi. Se si accede o si modifica il database all'interno di questa cartella da più posizioni contemporaneamente, potrebbero verificarsi conflitti di sincronizzazione e potenziale danneggiamento dei dati", "importAppFlowyData": "Importa dati dalla cartella AppFlowy esterna", + "importingAppFlowyDataTip": "L'importazione dei dati è in corso. Non chiudere l'applicazione", "importAppFlowyDataDescription": "Copia i dati da una cartella dati AppFlowy esterna e importali nella cartella dati AppFlowy corrente", "importSuccess": "Importazione della cartella dati AppFlowy riuscita", "importFailed": "L'importazione della cartella dati di AppFlowy non è riuscita", @@ -441,10 +452,12 @@ "joinDiscord": "Unisciti a noi su Discord", "privacyPolicy": "Politica sulla riservatezza", "userAgreement": "Accordo per gli utenti", + "termsAndConditions": "Termini e Condizioni", "userprofileError": "Impossibile caricare il profilo utente", "userprofileErrorDescription": "Prova a disconnetterti e ad accedere nuovamente per verificare se il problema persiste.", "selectLayout": "Seleziona disposizione", - "selectStartingDay": "Seleziona il giorno di inizio" + "selectStartingDay": "Seleziona il giorno di inizio", + "version": "Versione" } }, "grid": { @@ -503,13 +516,9 @@ "isComplete": "è completo", "isIncomplted": "è incompleto" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "È", "isNot": "Non è", - "isEmpty": "È vuoto", - "isNotEmpty": "Non è vuoto" - }, - "multiSelectOptionFilter": { "contains": "Contiene", "doesNotContain": "Non contiene", "isEmpty": "È vuoto", @@ -525,6 +534,16 @@ "empty": "È vuoto", "notEmpty": "Non è vuoto" }, + "numberFilter": { + "equal": "Uguale", + "notEqual": "Non è uguale", + "lessThan": "È meno di", + "greaterThan": "È maggiore di", + "lessThanOrEqualTo": "È inferiore o uguale a", + "greaterThanOrEqualTo": "È maggiore o uguale a", + "isEmpty": "È vuoto", + "isNotEmpty": "Non è vuoto" + }, "field": { "hide": "Nascondere", "show": "Mostra", @@ -556,6 +575,7 @@ "timeFormatTwelveHour": "12 ore", "timeFormatTwentyFourHour": "24 ore", "clearDate": "Pulisci data", + "dateTime": "Data e ora", "failedToLoadDate": "Impossibile caricare il valore della data", "selectTime": "Seleziona l'ora", "selectDate": "Seleziona la data", @@ -569,7 +589,9 @@ "newProperty": "Nuova proprietà", "deleteFieldPromptMessage": "Sei sicuro? Questa proprietà verrà eliminata", "newColumn": "Nuova colonna", - "format": "Formato" + "format": "Formato", + "reminderOnDateTooltip": "Questa cella ha un promemoria programmato", + "optionAlreadyExist": "L'opzione esiste già" }, "rowPage": { "newField": "Aggiungi un nuovo campo", @@ -588,8 +610,10 @@ "sort": { "ascending": "Ascendente", "descending": "Discendente", + "cannotFindCreatableField": "Impossibile trovare un campo adatto per l'ordinamento", "deleteAllSorts": "Elimina tutti gli ordinamenti", "addSort": "Aggiungi ordinamento", + "removeSorting": "Si desidera rimuovere l'ordinamento?", "deleteSort": "Elimina ordinamento" }, "row": { @@ -635,8 +659,20 @@ "hideComplete": "Nascondi le attività completate", "showComplete": "Mostra tutte le attività" }, + "url": { + "launch": "Apri nel browser", + "copy": "Copia l'URL" + }, "menuName": "Griglia", - "referencedGridPrefix": "Vista di" + "referencedGridPrefix": "Vista di", + "calculate": "Calcolare", + "calculationTypeLabel": { + "average": "Media", + "max": "Massimo", + "median": "Medio", + "min": "Minimo", + "sum": "Somma" + } }, "document": { "menuName": "Documento", @@ -742,13 +778,16 @@ }, "image": { "copiedToPasteBoard": "Il link dell'immagine è stato copiato negli appunti", - "addAnImage": "Aggiungi un'immagine" + "addAnImage": "Aggiungi un'immagine", + "imageUploadFailed": "Caricamento dell'immagine non riuscito" }, "urlPreview": { - "copiedToPasteBoard": "Il link è stato copiato negli appunti" + "copiedToPasteBoard": "Il link è stato copiato negli appunti", + "convertToLink": "Convertire in link da incorporare" }, "outline": { - "addHeadingToCreateOutline": "Aggiungi intestazioni per creare un sommario." + "addHeadingToCreateOutline": "Aggiungi intestazioni per creare un sommario.", + "noMatchHeadings": "Non sono stati trovati titoli corrispondenti." }, "table": { "addAfter": "Aggiungi dopo", @@ -771,7 +810,9 @@ "toContinue": "continuare", "newDatabase": "Nuova banca dati", "linkToDatabase": "Collegamento alla banca dati" - } + }, + "date": "Data", + "emoji": "Emoji" }, "textBlock": { "placeholder": "Digita '/' per i comandi" @@ -817,7 +858,10 @@ "saveImageToGallery": "Salva immagine", "failedToAddImageToGallery": "Impossibile aggiungere l'immagine alla galleria", "successToAddImageToGallery": "Immagine aggiunta alla galleria con successo", - "unableToLoadImage": "Impossibile caricare l'immagine" + "unableToLoadImage": "Impossibile caricare l'immagine", + "maximumImageSize": "La dimensione massima supportata per il caricamento delle immagini è di 10 MB", + "uploadImageErrorImageSizeTooBig": "Le dimensioni dell'immagine devono essere inferiori a 10 MB", + "imageIsUploading": "L'immagine si sta caricando" }, "codeBlock": { "language": { @@ -844,7 +888,9 @@ "page": { "label": "Collegamento alla pagina", "tooltip": "Fare clic per aprire la pagina" - } + }, + "deleted": "Eliminato", + "deletedContent": "Questo contenuto non esiste o è stato cancellato" }, "toolbar": { "resetToDefaultFont": "Ripristina alle condizioni predefinite" @@ -876,6 +922,7 @@ "cardDuplicated": "La carta è stata duplicata", "cardDeleted": "La carta è stata eliminata", "showOnCard": "Mostra sui dettagli della carta", + "setting": "Impostazioni", "propertyName": "Nome della proprietà", "menuName": "Bacheca", "showUngrouped": "Mostra elementi non raggruppati", @@ -902,11 +949,16 @@ "previousMonth": "Il mese scorso", "nextMonth": "Il prossimo mese" }, + "mobileEventScreen": { + "emptyTitle": "Non ci sono ancora eventi", + "emptyBody": "Premere il pulsante più per creare un evento in questo giorno." + }, "settings": { "showWeekNumbers": "Mostra i numeri della settimana", "showWeekends": "Mostra i fine settimana", "firstDayOfWeek": "Inizia la settimana", "layoutDateField": "Layout calendario per", + "changeLayoutDateField": "Modifica del campo di layout", "noDateTitle": "Nessuna data", "unscheduledEventsTitle": "Eventi non programmati", "clickToAdd": "Fare clic per aggiungere al calendario", @@ -983,9 +1035,14 @@ }, "inlineActions": { "noResults": "Nessun risultato", + "pageReference": "Riferimento della pagina", + "docReference": "Riferimento al documento", + "calReference": "Calendario di riferimento", + "gridReference": "Riferimento griglia", "date": "Data", "reminder": { - "groupTitle": "Promemoria" + "groupTitle": "Promemoria", + "shortKeyword": "ricordare" } }, "datePicker": { @@ -994,7 +1051,23 @@ "includeTime": "Includere l'orario", "isRange": "Data di fine", "timeFormat": "Formato orario", - "clearDate": "Rimuovi data" + "clearDate": "Rimuovi data", + "reminderLabel": "Promemoria", + "selectReminder": "Seleziona il promemoria", + "reminderOptions": { + "atTimeOfEvent": "Ora dell'evento", + "fiveMinsBefore": "5 minuti prima", + "tenMinsBefore": "10 minuti prima", + "fifteenMinsBefore": "15 minuti prima", + "thirtyMinsBefore": "30 minuti prima", + "oneHourBefore": "1 ora prima", + "twoHoursBefore": "2 hours before", + "onDayOfEvent": "Il giorno dell'evento", + "oneDayBefore": "1 giorno prima", + "twoDaysBefore": "2 giorni prima", + "oneWeekBefore": "1 settimana prima", + "custom": "Personalizzato" + } }, "relativeDates": { "yesterday": "Ieri", @@ -1010,6 +1083,7 @@ "emptyTitle": "Tutto a posto!", "emptyBody": "Nessuna notifica o azione in sospeso. Goditi la calma.", "tabs": { + "inbox": "Posta in arrivo", "upcoming": "Prossimamente" }, "actions": { @@ -1057,6 +1131,7 @@ "highlight": "Evidenzia", "color": "Colore", "image": "Immagine", + "date": "Data", "italic": "Corsivo", "link": "Link", "numberedList": "Elenco numerato", @@ -1158,7 +1233,10 @@ "colClear": "Rimuovi contenuto", "rowClear": "Rimuovi contenuto", "slashPlaceHolder": "Digita \"/\" per inserire un blocco o inizia a digitare", - "typeSomething": "Scrivi qualcosa..." + "typeSomething": "Scrivi qualcosa...", + "quoteListShortForm": "Citazione", + "mathEquationShortForm": "Formula", + "codeBlockShortForm": "Codice" }, "favorite": { "noFavorite": "Nessuna pagina preferita", @@ -1182,5 +1260,6 @@ "date": "Data", "addField": "Aggiungi campo", "userIcon": "Icona utente" - } -} + }, + "noLogFiles": "Non ci sono file di log" +} \ No newline at end of file diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index 3a7e2863ddf9b..251fb50d01147 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -63,12 +63,12 @@ "copyLink": "リンクをコピー" }, "moreAction": { - "fontSize": "フォントサイズ", - "import": "取り込む", - "moreOptions": "より多くのオプション", "small": "小さい", "medium": "中くらい", - "large": "大きい" + "large": "大きい", + "fontSize": "フォントサイズ", + "import": "取り込む", + "moreOptions": "より多くのオプション" }, "importPanel": { "textAndMarkdown": "テキストとマークダウン", @@ -110,7 +110,7 @@ "caption": "この操作は元に戻すことができません。" }, "mobile": { - "empty": "ゴミ箱を殻にする", + "empty": "ゴミ箱を空にする", "emptyDescription": "削除されたファイルはありません", "isDeleted": "削除済み" } @@ -412,13 +412,9 @@ "isComplete": "完了", "isIncomplted": "未完了" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "等しい", "isNot": "等しくない", - "isEmpty": "空である", - "isNotEmpty": "空ではない" - }, - "multiSelectOptionFilter": { "contains": "を含む", "doesNotContain": "を含まない", "isEmpty": "空である", diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index a1e9e2f48b6f2..0ca45882bfc5f 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -50,12 +50,12 @@ "copyLink": "링크 복사" }, "moreAction": { - "fontSize": "글꼴 크기", - "import": "수입", - "moreOptions": "추가 옵션", "small": "작은", "medium": "중간", - "large": "크기가 큰" + "large": "크기가 큰", + "fontSize": "글꼴 크기", + "import": "수입", + "moreOptions": "추가 옵션" }, "importPanel": { "textAndMarkdown": "텍스트 및 마크다운", @@ -159,7 +159,9 @@ "editContact": "연락처 편집" }, "button": { + "ok": "확인", "done": "완료", + "cancel": "취소", "signIn": "로그인", "signOut": "로그아웃", "complete": "완료", @@ -175,9 +177,7 @@ "edit": "편집하다", "delete": "삭제", "duplicate": "복제하다", - "putback": "다시 집어 넣어", - "cancel": "취소", - "ok": "확인" + "putback": "다시 집어 넣어" }, "label": { "welcome": "환영합니다!", @@ -324,13 +324,9 @@ "isComplete": "완료되었습니다", "isIncomplted": "불완전하다" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "~이다", "isNot": "아니다", - "isEmpty": "비었다", - "isNotEmpty": "비어 있지 않음" - }, - "multiSelectOptionFilter": { "contains": "포함", "doesNotContain": "포함되어 있지 않다", "isEmpty": "비었다", @@ -597,4 +593,4 @@ "deleteContentTitle": "{pageType}을(를) 삭제하시겠습니까?", "deleteContentCaption": "이 {pageType}을(를) 삭제하면 휴지통에서 복원할 수 있습니다." } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/pl-PL.json b/frontend/resources/translations/pl-PL.json index cb62b3a338b54..2d1f66fddd44b 100644 --- a/frontend/resources/translations/pl-PL.json +++ b/frontend/resources/translations/pl-PL.json @@ -70,12 +70,12 @@ "copyLink": "Skopiuj link" }, "moreAction": { - "fontSize": "Rozmiar czcionki", - "import": "Import", - "moreOptions": "Więcej opcji", "small": "mały", "medium": "średni", - "large": "duży" + "large": "duży", + "fontSize": "Rozmiar czcionki", + "import": "Import", + "moreOptions": "Więcej opcji" }, "importPanel": { "textAndMarkdown": "Tekst i Markdown", @@ -454,13 +454,9 @@ "isComplete": "jest kompletna", "isIncomplted": "jest niekompletna" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Jest", "isNot": "Nie jest", - "isEmpty": "Jest pusty", - "isNotEmpty": "Nie jest pusty" - }, - "multiSelectOptionFilter": { "contains": "Zawiera", "doesNotContain": "Nie zawiera", "isEmpty": "Jest pusty", @@ -1076,4 +1072,4 @@ "language": "Język", "font": "Czcionka" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index ae32e52bafe8c..b6e33f0b9a006 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -72,12 +72,12 @@ "copyLink": "Copiar link" }, "moreAction": { - "fontSize": "Tamanho da fonte", - "import": "Importar", - "moreOptions": "Mais opções", "small": "pequeno", "medium": "médio", - "large": "grande" + "large": "grande", + "fontSize": "Tamanho da fonte", + "import": "Importar", + "moreOptions": "Mais opções" }, "importPanel": { "textAndMarkdown": "Texto e Remarcação", @@ -180,9 +180,7 @@ "dragRow": "Pressione e segure para reordenar a linha", "viewDataBase": "Visualizar banco de dados", "referencePage": "Esta {name} é uma referência", - "addBlockBelow": "Adicione um bloco abaixo", - "urlLaunchAccessory": "Abrir com o navegador", - "urlCopyAccessory": "Copiar URL" + "addBlockBelow": "Adicione um bloco abaixo" }, "sideBar": { "closeSidebar": "Fechar barra lateral", @@ -513,13 +511,9 @@ "isComplete": "está completo", "isIncomplted": "está imcompleto" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Está", "isNot": "Não está", - "isEmpty": "Está vazio", - "isNotEmpty": "Não está vazio" - }, - "multiSelectOptionFilter": { "contains": "Contém", "doesNotContain": "Não contém", "isEmpty": "Está vazio", @@ -648,6 +642,10 @@ "hideComplete": "Ocultar tarefas concluídas", "showComplete": "Mostrar todas as tarefas" }, + "url": { + "launch": "Abrir com o navegador", + "copy": "Copiar URL" + }, "menuName": "Grade", "referencedGridPrefix": "Vista de" }, @@ -1209,4 +1207,4 @@ "addField": "Adicionar campo", "userIcon": "Ícone do usuário" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/pt-PT.json b/frontend/resources/translations/pt-PT.json index 4808fa67fdc29..ef506dd6ecbc8 100644 --- a/frontend/resources/translations/pt-PT.json +++ b/frontend/resources/translations/pt-PT.json @@ -70,12 +70,12 @@ "copyLink": "Copiar o link" }, "moreAction": { - "fontSize": "Tamanho da fonte", - "import": "Importar", - "moreOptions": "Mais opções", "small": "pequeno", "medium": "médio", - "large": "grande" + "large": "grande", + "fontSize": "Tamanho da fonte", + "import": "Importar", + "moreOptions": "Mais opções" }, "importPanel": { "textAndMarkdown": "Texto e Remarcação", @@ -194,7 +194,9 @@ "editContact": "Editar um conctato" }, "button": { + "ok": "OK", "done": "Feito", + "cancel": "Cancelar", "signIn": "Entrar", "signOut": "Sair", "complete": "Completar", @@ -210,9 +212,7 @@ "edit": "Editar", "delete": "Excluir", "duplicate": "Duplicado", - "putback": "Por de volta", - "cancel": "Cancelar", - "ok": "OK" + "putback": "Por de volta" }, "label": { "welcome": "Bem vindo!", @@ -426,13 +426,9 @@ "isComplete": "está completo", "isIncomplted": "está incompleto" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "É", "isNot": "não é", - "isEmpty": "Está vazia", - "isNotEmpty": "Não está vazio" - }, - "multiSelectOptionFilter": { "contains": "contém", "doesNotContain": "Não contém", "isEmpty": "Está vazia", @@ -856,4 +852,4 @@ "noResult": "Nenhum resultado", "caseSensitive": "Maiúsculas e minúsculas" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/ru-RU.json b/frontend/resources/translations/ru-RU.json index 041bfbfb93ce0..c617118ff7c8b 100644 --- a/frontend/resources/translations/ru-RU.json +++ b/frontend/resources/translations/ru-RU.json @@ -2,8 +2,9 @@ "appName": "AppFlowy", "defaultUsername": "Я", "welcomeText": "Добро пожаловать в @:appName", + "welcomeTo": "Добро пожаловать в", "githubStarText": "Поставить звезду на GitHub", - "subscribeNewsletterText": "Подписаться на новостную рассылку", + "subscribeNewsletterText": "Подписаться на новости", "letsGoButtonText": "Быстрый старт", "title": "Заголовок", "youCanAlso": "Вы также можете", @@ -35,6 +36,7 @@ "loginStartWithAnonymous": "Начать анонимную сессию", "continueAnonymousUser": "Продолжить анонимную сессию", "buttonText": "Авторизация", + "signingInText": "Вход…", "forgotPassword": "Забыли пароль?", "emailHint": "Электронная почта", "passwordHint": "Пароль", @@ -59,7 +61,9 @@ "failedToLoad": "Что-то пошло не так! Не удалось загрузить рабочее пространство. Попробуйте закрыть все открытые экземпляры AppFlowy и повторите попытку.", "errorActions": { "reportIssue": "Сообщить о проблеме", - "reachOut": "Обратиться в Discord" + "reportIssueOnGithub": "Сообщить о проблеме на Github", + "exportLogFiles": "Экспорт логов", + "reachOut": "Написать в Discord" } }, "shareAction": { @@ -70,12 +74,17 @@ "copyLink": "Скопировать ссылку" }, "moreAction": { + "small": "маленький", + "medium": "средний", + "large": "большой", "fontSize": "Размер шрифта", "import": "Импорт", "moreOptions": "Дополнительные опции", - "small": "маленький", - "medium": "средний", - "large": "большой" + "wordCount": "Количество слов: {}", + "charCount": "Количество символов: {}", + "createdAt": "Создан в: {}", + "deleteView": "Удалить", + "duplicateView": "Дублировать" }, "importPanel": { "textAndMarkdown": "Текст и Markdown", @@ -178,9 +187,7 @@ "dragRow": "Перетащите для изменения порядка строк", "viewDataBase": "Просмотр базы данных", "referencePage": "Ссылки на {name}", - "addBlockBelow": "Добавьте блок ниже", - "urlLaunchAccessory": "Открыть в браузере", - "urlCopyAccessory": "Скопировать URL" + "addBlockBelow": "Добавьте блок ниже" }, "sideBar": { "closeSidebar": "Закрыть боковое меню", @@ -232,7 +239,12 @@ "rename": "Переименовать", "helpCenter": "Центр помощи", "add": "Добавить", - "yes": "Да" + "yes": "Да", + "clear": "Очистить", + "remove": "Удалить", + "dontRemove": "Не удалять", + "copyLink": "Скопировать ссылку", + "align": "Выровнять" }, "label": { "welcome": "Добро пожаловать!", @@ -277,9 +289,12 @@ "cloudLocal": "Локально", "cloudSupabase": "Supabase", "cloudSupabaseUrl": "Supabase URL", + "cloudSupabaseUrlCanNotBeEmpty": "URL-адрес Supabase не может быть пустым.", "cloudSupabaseAnonKey": "Анонимный ключ Supabase", "cloudSupabaseAnonKeyCanNotBeEmpty": "Анонимный ключ не может быть пустым, если URL Supabase не пуст", "cloudAppFlowy": "AppFlowy Cloud", + "cloudAppFlowySelfHost": "AppFlowy Cloud на своём сервере", + "appFlowyCloudUrlCanNotBeEmpty": "URL облака не может быть пустым.", "clickToCopy": "Нажмите, чтобы скопировать", "selfHostStart": "Если у вас нет сервера, пожалуйста, обратитесь к", "selfHostContent": "документации", @@ -289,6 +304,7 @@ "cloudWSURLHint": "Введите адрес вебсокета вашего сервера", "restartApp": "Перезапуск", "restartAppTip": "Перезапустите приложение, чтобы изменения вступили в силу. Обратите внимание, что это может привести к выходу из текущего аккаунта", + "changeServerTip": "После смены сервера необходимо нажать кнопку перезагрузки, чтобы изменения вступили в силу.", "enableEncryptPrompt": "Активируйте шифрование для защиты ваших данных с этим секретом. Храните его безопасно; после включения, он не может быть отключён. В случае потери секрета ваши данные будут также потеряны. Нажмите, чтобы скопировать", "inputEncryptPrompt": "Пожалуйста, введите ваш секрет шифрования для", "clickToCopySecret": "Нажмите, чтобы скопировать секрет", @@ -300,6 +316,7 @@ "openHistoricalUser": "Нажмите, чтобы открыть анонимный аккаунт", "customPathPrompt": "Хранение папки данных AppFlowy в папке с облачной синхронизацией, например на Google Диске, может представлять риск. Если база данных в этой папке будет доступна или изменена с нескольких мест одновременно, это может привести к конфликтам синхронизации и потенциальному повреждению данных", "importAppFlowyData": "Импортировать данные из внешней папки AppFlowy", + "importingAppFlowyDataTip": "Выполняется импорт данных. Пожалуйста, не закрывайте приложение", "importAppFlowyDataDescription": "Скопируйте данные из внешней папки данных AppFlowy и импортируйте их в текущую папку данных AppFlowy.", "importSuccess": "Папка данных AppFlowy успешно импортирована", "importFailed": "Не удалось импортировать папку данных AppFlowy", @@ -324,6 +341,7 @@ "dark": "Тёмная", "system": "Системная" }, + "fontScaleFactor": "Масштаб шрифта", "documentSettings": { "cursorColor": "Цвет курсора в документе", "selectionColor": "Цвет выделения в документе", @@ -377,11 +395,12 @@ "twelveHour": "12Ч", "twentyFourHour": "24Ч" }, - "showNamingDialogWhenCreatingPage": "Показывать диалоговое окно именования при создании страницы" + "showNamingDialogWhenCreatingPage": "Показывать диалоговое окно именования при создании страницы", + "enableRTLToolbarItems": "Включить режим панели слева-направо" }, "files": { "copy": "Копировать", - "defaultLocation": "Где сейчас хранятся ваши данные", + "defaultLocation": "Путь до хранилища", "exportData": "Экспорт данных", "doubleTapToCopy": "Нажмите дважды, чтобы скопировать путь", "restoreLocation": "Восстановить путь AppFlowy по умолчанию", @@ -445,10 +464,12 @@ "joinDiscord": "Присоединяйтесь к нам в Discord", "privacyPolicy": "Политика Конфиденциальности", "userAgreement": "Пользовательское Соглашение", + "termsAndConditions": "Условия и положения", "userprofileError": "Не удалось загрузить профиль пользователя", "userprofileErrorDescription": "Пожалуйста, попробуйте разлогиниться и войти снова, чтобы проверить, сохранится ли проблема.", "selectLayout": "Выбрать раскладку", - "selectStartingDay": "Выбрать день начала" + "selectStartingDay": "Выбрать день начала", + "version": "Версия" } }, "grid": { @@ -507,13 +528,9 @@ "isComplete": "завершено", "isIncomplted": "не завершено" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Является", "isNot": "Не является", - "isEmpty": "Пусто", - "isNotEmpty": "Не пусто" - }, - "multiSelectOptionFilter": { "contains": "Содержит", "doesNotContain": "Не содержит", "isEmpty": "Пусто", @@ -527,7 +544,25 @@ "onOrAfter": "Равно или после", "between": "Между", "empty": "Пусто", - "notEmpty": "Не пусто" + "notEmpty": "Не пусто", + "choicechipPrefix": { + "before": "До", + "after": "После", + "onOrBefore": "Не позднее", + "onOrAfter": "После", + "isEmpty": "Пусто", + "isNotEmpty": "Не пусто" + } + }, + "numberFilter": { + "equal": "Равно", + "notEqual": "Не равно", + "lessThan": "Меньше чем", + "greaterThan": "Больше, чем", + "lessThanOrEqualTo": "Меньше или равно", + "greaterThanOrEqualTo": "Больше или равно", + "isEmpty": "Пусто", + "isNotEmpty": "Не пусто" }, "field": { "hide": "Скрыть", @@ -546,6 +581,7 @@ "multiSelectFieldName": "Выбор нескольких", "urlFieldName": "URL", "checklistFieldName": "To-Do лист", + "relationFieldName": "Связь", "numberFormat": "Формат числа", "dateFormat": "Формат даты", "includeTime": "Время", @@ -576,7 +612,9 @@ "newProperty": "Новое свойство", "deleteFieldPromptMessage": "Вы уверены, что хотите удалить?", "newColumn": "Новый столбец", - "format": "Формат" + "format": "Формат", + "reminderOnDateTooltip": "В этой ячейке есть запланированное напоминание", + "optionAlreadyExist": "Вариант уже существует" }, "rowPage": { "newField": "Добавить новое поле", @@ -595,8 +633,12 @@ "sort": { "ascending": "По возрастанию", "descending": "По убыванию", + "by": "По", + "empty": "Нет активных сортировок", + "cannotFindCreatableField": "Не могу найти подходящее поле для сортировки", "deleteAllSorts": "Удалить все сортировки", - "addSort": "Добавить сортировку" + "addSort": "Добавить сортировку", + "removeSorting": "Убрать сортировку?" }, "row": { "duplicate": "Дублировать", @@ -641,8 +683,27 @@ "hideComplete": "Скрыть выполненные задачи", "showComplete": "Показать все задачи" }, + "url": { + "launch": "Открыть в браузере", + "copy": "Скопировать URL" + }, + "relation": { + "relatedDatabasePlaceLabel": "Связанная база данных", + "relatedDatabasePlaceholder": "Пусто", + "inRelatedDatabase": "В", + "emptySearchResult": "записей не найдено" + }, "menuName": "Сетка", - "referencedGridPrefix": "Просмотр" + "referencedGridPrefix": "Просмотр", + "calculate": "Рассчитать", + "calculationTypeLabel": { + "none": "Пусто", + "average": "Среднее", + "max": "Максимум", + "median": "Медиана", + "min": "Минимум", + "sum": "Сумма" + } }, "document": { "menuName": "Документ", @@ -745,17 +806,21 @@ "left": "Слева", "center": "По центру", "right": "Справа", - "defaultColor": "Цвет по умолчанию" + "defaultColor": "Цвет по умолчанию", + "depth": "Глубина" }, "image": { "copiedToPasteBoard": "Ссылка на изображение скопирована в буфер обмена", - "addAnImage": "Добавить изображение" + "addAnImage": "Добавить изображение", + "imageUploadFailed": "Не удалось загрузить изображение." }, "urlPreview": { - "copiedToPasteBoard": "Ссылка скопирована в буфер обмена" + "copiedToPasteBoard": "Ссылка скопирована в буфер обмена", + "convertToLink": "Сделать встроенной ссылкой" }, "outline": { - "addHeadingToCreateOutline": "Добавьте заголовки, чтобы создать оглавление." + "addHeadingToCreateOutline": "Добавьте заголовки, чтобы создать оглавление.", + "noMatchHeadings": "Соответствующие заголовки не найдены." }, "table": { "addAfter": "Добавить после", @@ -778,7 +843,12 @@ "toContinue": "чтобы продолжить", "newDatabase": "Новая база данных", "linkToDatabase": "Связать базу данных" - } + }, + "date": "Дата", + "emoji": "Эмодзи" + }, + "outlineBlock": { + "placeholder": "Оглавление" }, "textBlock": { "placeholder": "Введите '/' для команд" @@ -824,7 +894,10 @@ "saveImageToGallery": "Сохранить изображение", "failedToAddImageToGallery": "Ошибка добавления изображения в галерею", "successToAddImageToGallery": "Изображение успешно добавлено", - "unableToLoadImage": "Ошибка загрузки изображения" + "unableToLoadImage": "Ошибка загрузки изображения", + "maximumImageSize": "Максимальный поддерживаемый размер загружаемого изображения — 10 МБ.", + "uploadImageErrorImageSizeTooBig": "Размер изображения должен быть меньше 10 МБ.", + "imageIsUploading": "Изображение загружается" }, "codeBlock": { "language": { @@ -851,7 +924,9 @@ "page": { "label": "Ссылка на страницу", "tooltip": "Нажмите, чтобы открыть страницу" - } + }, + "deleted": "Удалено", + "deletedContent": "Этот контент не существует или был удален" }, "toolbar": { "resetToDefaultFont": "Восстановить по умолчанию" @@ -870,10 +945,10 @@ "addToColumnBottomTooltip": "Add a new card at the bottom", "renameColumn": "Переименовать", "hideColumn": "Скрыть", - "groupActions": "Групповые действия", "newGroup": "Новая группа", "deleteColumn": "Удалить", - "deleteColumnConfirmation": "Это удалит колонку и все карточки в ней.\nТочно продолжить?" + "deleteColumnConfirmation": "Это удалит колонку и все карточки в ней.\nТочно продолжить?", + "groupActions": "Групповые действия" }, "hiddenGroupSection": { "sectionTitle": "Скрытые группы", @@ -912,6 +987,10 @@ "previousMonth": "Предыдущий месяц", "nextMonth": "Следующий месяц" }, + "mobileEventScreen": { + "emptyTitle": "Мероприятий пока нет", + "emptyBody": "Нажмите кнопку «плюс», чтобы создать событие в этот день." + }, "settings": { "showWeekNumbers": "Показывать номера недель", "showWeekends": "Показывать выходные", @@ -1000,6 +1079,10 @@ "inlineActions": { "noResults": "Нет результатов", "pageReference": "Ссылка на страницу", + "docReference": "Ссылка на документ", + "boardReference": "Ссылка на доску", + "calReference": "Ссылка на календарь", + "gridReference": "Ссылка на таблицу", "date": "Дата", "reminder": { "groupTitle": "Напоминание", @@ -1008,11 +1091,28 @@ }, "datePicker": { "dateTimeFormatTooltip": "Измените формат даты и времени в настройках", - "dateFormat": "Date format", - "includeTime": "Include time", - "isRange": "End date", - "timeFormat": "Time format", - "clearDate": "Clear date" + "dateFormat": "Формат даты", + "includeTime": "Добавить время", + "isRange": "Дата завершения", + "timeFormat": "Формат времени", + "clearDate": "Очистить дату", + "reminderLabel": "Напоминание", + "selectReminder": "Выбрать напоминание", + "reminderOptions": { + "none": "Пусто", + "atTimeOfEvent": "Время события", + "fiveMinsBefore": "за 5 минут до", + "tenMinsBefore": "за 10 минут до этого", + "fifteenMinsBefore": "за 15 минут до этого", + "thirtyMinsBefore": "за 30 минут до", + "oneHourBefore": "за 1 час до", + "twoHoursBefore": "за 2 часа до", + "onDayOfEvent": "В день мероприятия", + "oneDayBefore": "за 1 день до", + "twoDaysBefore": "за 2 дня до", + "oneWeekBefore": "за 1 неделю до", + "custom": "Настраиваемое" + } }, "relativeDates": { "yesterday": "Вчера", @@ -1023,7 +1123,7 @@ "notificationHub": { "title": "Уведомления", "mobile": { - "title": "Updates" + "title": "Обновления" }, "emptyTitle": "Всё прочитано!", "emptyBody": "Никаких ожидающих уведомлений или действий. Наслаждайтесь спокойствием.", @@ -1059,7 +1159,8 @@ "replace": "Заменить", "replaceAll": "Заменить всё", "noResult": "Нет результатов", - "caseSensitive": "С учётом регистра" + "caseSensitive": "С учётом регистра", + "searchMore": "Выполните поиск, чтобы найти больше результатов" }, "error": { "weAreSorry": "Мы сожалеем", @@ -1068,6 +1169,7 @@ "editor": { "bold": "Жирный", "bulletedList": "Маркированный список", + "bulletedListShortForm": "Маркированный", "checkbox": "Чекбокс", "embedCode": "Встроенный код", "heading1": "H1", @@ -1076,9 +1178,11 @@ "highlight": "Выделить", "color": "Цвет", "image": "Изображение", + "date": "Дата", "italic": "Курсив", "link": "Ссылка", "numberedList": "Нумерованный список", + "numberedListShortForm": "Пронумерованный", "quote": "Цитировать", "strikethrough": "Зачёркнутый", "text": "Текст", @@ -1177,10 +1281,14 @@ "colClear": "Очистить контент", "rowClear": "Очистить содержимое строки", "slashPlaceHolder": "Введите '/' чтобы вставить блок, или начните писать.", - "typeSomething": "Введите что-либо..." + "typeSomething": "Введите что-либо...", + "toggleListShortForm": "Спойлер", + "quoteListShortForm": "Цитата", + "mathEquationShortForm": "Формула", + "codeBlockShortForm": "Код" }, "favorite": { - "noFavorite": "Нет избранной страницы", + "noFavorite": "Нет избранных страниц", "noFavoriteHintText": "Проведите по странице влево, чтобы добавить ее в избранное." }, "cardDetails": { @@ -1201,5 +1309,6 @@ "date": "Дата", "addField": "Добавить поле", "userIcon": "Пользовательская иконка" - } -} + }, + "noLogFiles": "Нет файлов журналов" +} \ No newline at end of file diff --git a/frontend/resources/translations/sv-SE.json b/frontend/resources/translations/sv-SE.json index c7791cfea5ed7..054ab9dff3eb1 100644 --- a/frontend/resources/translations/sv-SE.json +++ b/frontend/resources/translations/sv-SE.json @@ -322,13 +322,9 @@ "isComplete": "är komplett", "isIncomplted": "är ofullständig" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Är", "isNot": "Är inte", - "isEmpty": "Är tom", - "isNotEmpty": "Är inte tom" - }, - "multiSelectOptionFilter": { "contains": "Innehåller", "doesNotContain": "Innehåller inte", "isEmpty": "Är tom", @@ -595,4 +591,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/th-TH.json b/frontend/resources/translations/th-TH.json index 0f8a9d816147e..993846528fe47 100644 --- a/frontend/resources/translations/th-TH.json +++ b/frontend/resources/translations/th-TH.json @@ -177,9 +177,7 @@ "dragRow": "กดค้างเพื่อเรียงลำดับแถวใหม่", "viewDataBase": "ดูฐานข้อมูล", "referencePage": "{name} ถูกอ้างอิงถึง", - "addBlockBelow": "เพิ่มบล็อกด้านล่าง", - "urlLaunchAccessory": "เปิดในเบราว์เซอร์", - "urlCopyAccessory": "คัดลอก URL" + "addBlockBelow": "เพิ่มบล็อกด้านล่าง" }, "sideBar": { "closeSidebar": "ปิดแถบด้านข้าง", @@ -480,13 +478,9 @@ "isComplete": "เสร็จสมบูรณ์", "isIncomplted": "ไม่เสร็จสมบูรณ์" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "เป็น", "isNot": "ไม่เป็น", - "isEmpty": "ว่างเปล่า", - "isNotEmpty": "ไม่ว่างเปล่า" - }, - "multiSelectOptionFilter": { "contains": "ประกอบด้วย", "doesNotContain": "ไม่ประกอบด้วย", "isEmpty": "ว่างเปล่า", @@ -557,7 +551,7 @@ "showHiddenFields": { "one": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "many": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", - "other": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์" + "other": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์" }, "hideHiddenFields": { "one": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", @@ -614,6 +608,10 @@ "hideComplete": "ซ่อนงานเสร็จ", "showComplete": "แสดงงานทั้งหมด" }, + "url": { + "launch": "เปิดในเบราว์เซอร์", + "copy": "คัดลอก URL" + }, "menuName": "ตาราง", "referencedGridPrefix": "มุมมองของ" }, @@ -630,11 +628,11 @@ }, "grid": { "selectAGridToLinkTo": "เลือกตารางเพื่อเชื่อมโยง", - "createANewGrid": "สร้างตารางใหม่" + "createANewGrid": "สร้างตารางใหม่" }, "calendar": { "selectACalendarToLinkTo": "เลือกปฏิทินเพื่อเชื่อมโยง", - "createANewCalendar": "สร้างปฏิทินใหม่" + "createANewCalendar": "สร้างปฏิทินใหม่" }, "document": { "selectADocumentToLinkTo": "เลือกเอกสารเพื่อเชื่อมโยง" @@ -764,7 +762,7 @@ }, "stability_ai": { "label": "สร้างรูปภาพจาก Stability AI", - "placeholder": "โปรดระบุคำขอใช้ Stability AI สร้างรูปภาพ" + "placeholder": "โปรดระบุคำขอใช้ Stability AI สร้างรูปภาพ" }, "support": "ขนาดรูปภาพจำกัดอยู่ที่ 5MB รูปแบบที่รองรับ: JPEG, PNG, GIF, SVG", "error": { diff --git a/frontend/resources/translations/tr-TR.json b/frontend/resources/translations/tr-TR.json index dfc3cc29ac08f..37c3554e52a70 100644 --- a/frontend/resources/translations/tr-TR.json +++ b/frontend/resources/translations/tr-TR.json @@ -69,12 +69,12 @@ "copyLink": "Bağlantıyı kopyala" }, "moreAction": { - "fontSize": "Yazı boyutu", - "import": "İçe aktar", - "moreOptions": "Diğer seçenekler", "small": "Küçük", "medium": "Orta", - "large": "Büyük" + "large": "Büyük", + "fontSize": "Yazı boyutu", + "import": "İçe aktar", + "moreOptions": "Diğer seçenekler" }, "importPanel": { "textAndMarkdown": "Metin ve Markdown", @@ -454,13 +454,9 @@ "isComplete": "Tamamlanmış", "isIncomplted": "Tamamlanmamış" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Şu olan", "isNot": "Şu olmayan", - "isEmpty": "Boş olan", - "isNotEmpty": "Boş olmayan" - }, - "multiSelectOptionFilter": { "contains": "Şunu içeren", "doesNotContain": "Şunu içermeyen", "isEmpty": "Boş olan", @@ -1093,4 +1089,4 @@ "language": "Dil", "font": "Yazı tipi" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index c714e62a887af..3c36875f96fdb 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -281,7 +281,6 @@ "auto": "АВТО", "fallback": "Такий же, як і напрямок макету" }, - "themeUpload": { "button": "Завантажити", "uploadTheme": "Завантажити тему", @@ -347,7 +346,6 @@ "exportFileFail": "Помилка експорту файлу!", "export": "Експорт" }, - "user": { "name": "Ім'я", "email": "Електронна пошта", @@ -416,13 +414,9 @@ "isComplete": "є завершено", "isIncomplted": "є незавершено" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "є", "isNot": "не є", - "isEmpty": "порожнє", - "isNotEmpty": "не порожнє" - }, - "multiSelectOptionFilter": { "contains": "Містить", "doesNotContain": "Не містить", "isEmpty": "порожнє", diff --git a/frontend/resources/translations/ur.json b/frontend/resources/translations/ur.json index 0ec8763bcf591..1d4f936d37bed 100644 --- a/frontend/resources/translations/ur.json +++ b/frontend/resources/translations/ur.json @@ -391,7 +391,7 @@ "isComplete": "مکمل ہے", "isIncomplted": "نامکمل ہے" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "ہے", "isNot": "نہیں ہے", "isEmpty": "خالی ہے", diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index eaf600420d1ec..9a34912a30489 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -72,12 +72,12 @@ "copyLink": "Sao chép đường dẫn" }, "moreAction": { - "fontSize": "Cỡ chữ", - "import": "Import", - "moreOptions": "Lựa chọn khác", "small": "nhỏ", "medium": "trung bình", - "large": "lớn" + "large": "lớn", + "fontSize": "Cỡ chữ", + "import": "Import", + "moreOptions": "Lựa chọn khác" }, "importPanel": { "textAndMarkdown": "Văn bản & Markdown", @@ -175,8 +175,7 @@ "openMenu": "Bấm để mở menu", "dragRow": "Nhấn và giữ để sắp xếp lại hàng", "viewDataBase": "Xem cơ sở dữ liệu", - "referencePage": "{name} này được tham chiếu", - "urlCopyAccessory": "Sao chép URL" + "referencePage": "{name} này được tham chiếu" }, "sideBar": { "closeSidebar": "Đóng thanh bên", @@ -492,13 +491,9 @@ "isComplete": "hoàn tất", "isIncomplted": "chưa hoàn tất" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "Là", "isNot": "Không phải", - "isEmpty": "Rỗng", - "isNotEmpty": "Không rỗng" - }, - "multiSelectOptionFilter": { "contains": "Chứa", "doesNotContain": "Không chứa", "isEmpty": "Rỗng", @@ -612,6 +607,9 @@ "searchOrCreateOption": "Tìm kiếm hoặc tạo một tùy chọn...", "tagName": "Tên thẻ" }, + "url": { + "copy": "Sao chép URL" + }, "menuName": "Lưới" }, "document": { @@ -809,4 +807,4 @@ "font": "Phông chữ", "date": "Ngày" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 036e4718398e6..005ab00593e2f 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -74,12 +74,17 @@ "copyLink": "复制链接" }, "moreAction": { + "small": "小", + "medium": "中", + "large": "大", "fontSize": "字体大小", "import": "导入", "moreOptions": "更多选项", - "small": "小", - "medium": "中", - "large": "大" + "wordCount": "字数: {}", + "charCount": "字符数:{}", + "createdAt": "创建于:{}", + "deleteView": "删除", + "duplicateView": "复制" }, "importPanel": { "textAndMarkdown": "文本和Markdown", @@ -182,9 +187,7 @@ "dragRow": "长按重新排序该行", "viewDataBase": "查看数据库", "referencePage": "这个 {name} 已被引用", - "addBlockBelow": "在下面添加一个块", - "urlLaunchAccessory": "在浏览器中打开", - "urlCopyAccessory": "复制链接" + "addBlockBelow": "在下面添加一个块" }, "sideBar": { "closeSidebar": "关闭侧边栏", @@ -236,7 +239,12 @@ "rename": "重命名", "helpCenter": "帮助中心", "add": "添加", - "yes": "是" + "yes": "是", + "clear": "清空", + "remove": "删除", + "dontRemove": "请勿删除", + "copyLink": "复制链接", + "align": "对齐" }, "label": { "welcome": "欢迎!", @@ -281,10 +289,12 @@ "cloudLocal": "本地", "cloudSupabase": "Supabase", "cloudSupabaseUrl": "Supabase URL", + "cloudSupabaseUrlCanNotBeEmpty": "SUPABASE 地址不能为空", "cloudSupabaseAnonKey": "Supabase anon key", "cloudSupabaseAnonKeyCanNotBeEmpty": "如果 Supabase url 不为空,则 Anon key 不能为空", "cloudAppFlowy": "AppFlowy云", "cloudAppFlowySelfHost": "AppFlowy 云自行托管", + "appFlowyCloudUrlCanNotBeEmpty": "云地址不能为空", "clickToCopy": "点击复制", "selfHostStart": "如果您没有服务器,请参阅", "selfHostContent": "文档", @@ -332,6 +342,7 @@ "dark": "深色", "system": "跟随系统" }, + "fontScaleFactor": "字体缩放比例", "documentSettings": { "cursorColor": "光标颜色", "selectionColor": "文本选择颜色", @@ -520,13 +531,9 @@ "isComplete": "已完成", "isIncomplted": "未完成" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "是", "isNot": "不是", - "isEmpty": "为空", - "isNotEmpty": "不为空" - }, - "multiSelectOptionFilter": { "contains": "包含", "doesNotContain": "不包含", "isEmpty": "为空", @@ -540,7 +547,25 @@ "onOrAfter": "在或之后", "between": "之间", "empty": "为空", - "notEmpty": "不为空" + "notEmpty": "不为空", + "choicechipPrefix": { + "before": "之前", + "after": "之后", + "onOrBefore": "当天或之前", + "onOrAfter": "当天或之后", + "isEmpty": "为空", + "isNotEmpty": "不为空" + } + }, + "numberFilter": { + "equal": "等于", + "notEqual": "不等于", + "lessThan": "小于", + "greaterThan": "大于", + "lessThanOrEqualTo": "小于等于", + "greaterThanOrEqualTo": "大于等于", + "isEmpty": "为空", + "isNotEmpty": "不为空" }, "field": { "hide": "隐藏", @@ -559,6 +584,7 @@ "multiSelectFieldName": "多选", "urlFieldName": "网址", "checklistFieldName": "清单", + "relationFieldName": "关系", "numberFormat": "数字格式", "dateFormat": "日期格式", "includeTime": "包含时/分", @@ -590,7 +616,8 @@ "deleteFieldPromptMessage": "确定要删除这个属性吗? ", "newColumn": "新建列", "format": "格式", - "reminderOnDateTooltip": "这个单元格设置了一个预定的提醒" + "reminderOnDateTooltip": "这个单元格设置了一个预定的提醒", + "optionAlreadyExist": "选项已存在" }, "rowPage": { "newField": "添加新字段", @@ -611,6 +638,7 @@ "descending": "降序", "deleteAllSorts": "删除所有排序", "addSort": "添加排序", + "removeSorting": "你确定要移除排序吗?", "deleteSort": "取消排序" }, "row": { @@ -657,8 +685,24 @@ "hideComplete": "隐藏已完成的任务", "showComplete": "显示所有任务" }, + "url": { + "launch": "在浏览器中打开", + "copy": "复制链接" + }, + "relation": { + "emptySearchResult": "无结果" + }, "menuName": "网格", - "referencedGridPrefix": "视图" + "referencedGridPrefix": "视图", + "calculate": "计算", + "calculationTypeLabel": { + "none": "无", + "average": "均值", + "max": "最大值", + "median": "中位数", + "min": "最小值", + "sum": "求和" + } }, "document": { "menuName": "文档", @@ -888,10 +932,10 @@ "addToColumnBottomTooltip": "在底部添加一张新卡片", "renameColumn": "改名", "hideColumn": "隐藏", - "groupActions": "组操作", "newGroup": "新建组", "deleteColumn": "删除", - "deleteColumnConfirmation": "这将删除该组及其中的所有卡片。你确定你要继续吗?" + "deleteColumnConfirmation": "这将删除该组及其中的所有卡片。你确定你要继续吗?", + "groupActions": "组操作" }, "hiddenGroupSection": { "sectionTitle": "私密组", @@ -1245,4 +1289,4 @@ "userIcon": "用户图标" }, "noLogFiles": "没有日志文件" -} +} \ No newline at end of file diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index 7d222cc4b0770..30bb7526eb5dd 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -73,12 +73,12 @@ "copyLink": "複製連結" }, "moreAction": { - "fontSize": "字型大小", - "import": "匯入", - "moreOptions": "更多選項", "small": "小", "medium": "中", - "large": "大" + "large": "大", + "fontSize": "字型大小", + "import": "匯入", + "moreOptions": "更多選項" }, "importPanel": { "textAndMarkdown": "文字 & Markdown", @@ -181,9 +181,7 @@ "dragRow": "長按以重新排序列", "viewDataBase": "檢視資料庫", "referencePage": "這個 {name} 已被引用", - "addBlockBelow": "在下方新增一個區塊", - "urlLaunchAccessory": "在瀏覽器中開啟", - "urlCopyAccessory": "複製網址" + "addBlockBelow": "在下方新增一個區塊" }, "sideBar": { "closeSidebar": "關閉側欄", @@ -515,13 +513,9 @@ "isComplete": "已完成", "isIncomplted": "未完成" }, - "singleSelectOptionFilter": { + "selectOptionFilter": { "is": "是", "isNot": "不是", - "isEmpty": "為空", - "isNotEmpty": "不為空" - }, - "multiSelectOptionFilter": { "contains": "包含", "doesNotContain": "不包含", "isEmpty": "為空", @@ -650,6 +644,10 @@ "hideComplete": "隱藏已完成任務", "showComplete": "顯示所有任務" }, + "url": { + "launch": "在瀏覽器中開啟", + "copy": "複製網址" + }, "menuName": "網格", "referencedGridPrefix": "檢視", "calculate": "計算", @@ -894,10 +892,10 @@ "addToColumnBottomTooltip": "在底端新增卡片", "renameColumn": "重新命名", "hideColumn": "隱藏", - "groupActions": "群組操作", "newGroup": "新增群組", "deleteColumn": "刪除", - "deleteColumnConfirmation": "這將刪除此群組及其中所有卡片。\n您確定要繼續嗎?" + "deleteColumnConfirmation": "這將刪除此群組及其中所有卡片。\n您確定要繼續嗎?", + "groupActions": "群組操作" }, "hiddenGroupSection": { "sectionTitle": "隱藏的群組", @@ -1259,4 +1257,4 @@ "userIcon": "使用者圖示" }, "noLogFiles": "這裡沒有日誌記錄檔案" -} +} \ No newline at end of file diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index d63672345d67f..cab5329f937e9 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -163,7 +163,7 @@ checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "app-error" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", @@ -673,7 +673,7 @@ dependencies = [ [[package]] name = "client-api" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "again", "anyhow", @@ -683,6 +683,7 @@ dependencies = [ "brotli", "bytes", "chrono", + "client-websocket", "collab", "collab-entity", "database-entity", @@ -700,6 +701,7 @@ dependencies = [ "realtime-protocol", "reqwest", "scraper 0.17.1", + "semver", "serde", "serde_json", "serde_repr", @@ -712,11 +714,28 @@ dependencies = [ "url", "uuid", "wasm-bindgen-futures", - "websocket", "workspace-template", "yrs", ] +[[package]] +name = "client-websocket" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" +dependencies = [ + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "percent-encoding", + "thiserror", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "cmd_lib" version = "1.3.0" @@ -745,12 +764,13 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-trait", "bincode", "bytes", + "chrono", "js-sys", "parking_lot 0.12.1", "serde", @@ -760,6 +780,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "unicode-segmentation", "web-sys", "yrs", ] @@ -767,7 +788,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-trait", @@ -781,6 +802,7 @@ dependencies = [ "lru", "nanoid", "parking_lot 0.12.1", + "rayon", "serde", "serde_json", "serde_repr", @@ -796,7 +818,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "collab", @@ -815,7 +837,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "bytes", @@ -830,7 +852,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "chrono", @@ -856,6 +878,7 @@ dependencies = [ "collab", "collab-entity", "collab-plugins", + "futures", "lib-infra", "parking_lot 0.12.1", "serde", @@ -867,7 +890,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "async-stream", @@ -906,7 +929,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=195fabd025c3021ce0a42a3f76b8f2bad44cb45c#195fabd025c3021ce0a42a3f76b8f2bad44cb45c" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=79be7f4c6e8e672b6f08ffda866876c01fc28e62#79be7f4c6e8e672b6f08ffda866876c01fc28e62" dependencies = [ "anyhow", "collab", @@ -1103,7 +1126,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.2", "smallvec", ] @@ -1236,7 +1259,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" [[package]] name = "database-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -1730,6 +1753,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "sysinfo", "tokio", "tokio-stream", "tracing", @@ -1899,6 +1923,7 @@ dependencies = [ "client-api", "collab-database", "collab-document", + "collab-folder", "collab-plugins", "fancy-regex 0.11.0", "flowy-codegen", @@ -2010,6 +2035,7 @@ dependencies = [ "mime_guess", "parking_lot 0.12.1", "postgrest", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -2408,7 +2434,7 @@ dependencies = [ [[package]] name = "gotrue" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "futures-util", @@ -2425,7 +2451,7 @@ dependencies = [ [[package]] name = "gotrue-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -2696,7 +2722,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows", + "windows 0.48.0", ] [[package]] @@ -2819,7 +2845,7 @@ dependencies = [ [[package]] name = "infra" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "reqwest", @@ -3001,8 +3027,7 @@ checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "librocksdb-sys" version = "0.11.0+8.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e" +source = "git+https://github.com/LucasXu0/rust-rocksdb?rev=21cf4a23ec131b9d82dc94e178fe8efc0c147b09#21cf4a23ec131b9d82dc94e178fe8efc0c147b09" dependencies = [ "bindgen", "bzip2-sys", @@ -3317,6 +3342,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3630,7 +3664,7 @@ 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", ] @@ -3650,6 +3684,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", ] @@ -3717,6 +3752,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.47", +] + [[package]] name = "phf_shared" version = "0.8.0" @@ -4263,9 +4311,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" +checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd" dependencies = [ "either", "rayon-core", @@ -4273,14 +4321,12 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ - "crossbeam-channel", "crossbeam-deque", "crossbeam-utils", - "num_cpus", ] [[package]] @@ -4295,11 +4341,12 @@ dependencies = [ [[package]] name = "realtime-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", "bytes", + "client-websocket", "collab", "collab-entity", "database-entity", @@ -4309,16 +4356,16 @@ dependencies = [ "realtime-protocol", "serde", "serde_json", + "serde_repr", "thiserror", "tokio-tungstenite", - "websocket", "yrs", ] [[package]] name = "realtime-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "bincode", @@ -4534,8 +4581,7 @@ dependencies = [ [[package]] name = "rocksdb" version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe" +source = "git+https://github.com/LucasXu0/rust-rocksdb?rev=21cf4a23ec131b9d82dc94e178fe8efc0c147b09#21cf4a23ec131b9d82dc94e178fe8efc0c147b09" dependencies = [ "libc", "librocksdb-sys", @@ -4781,6 +4827,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "semver" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" + [[package]] name = "serde" version = "1.0.195" @@ -4906,7 +4958,7 @@ dependencies = [ [[package]] name = "shared-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "app-error", @@ -5146,6 +5198,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sysinfo" +version = "0.30.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb4f3438c8f6389c864e61221cbc97e9bca98b4daf39a5beb7bea660f528bb2" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows 0.52.0", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -5325,9 +5392,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.35.1" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", "bytes", @@ -6078,23 +6145,6 @@ version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" -[[package]] -name = "websocket" -version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" -dependencies = [ - "futures-channel", - "futures-util", - "http", - "httparse", - "js-sys", - "thiserror", - "tokio", - "tokio-tungstenite", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "which" version = "4.4.2" @@ -6157,6 +6207,25 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -6302,7 +6371,7 @@ dependencies = [ [[package]] name = "workspace-template" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=29a0851f485957cc6410ccf9d261c781c1d2f757#29a0851f485957cc6410ccf9d261c781c1d2f757" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ab9496c248b7c733d1aa160062abeb66c4e41325#ab9496c248b7c733d1aa160062abeb66c4e41325" dependencies = [ "anyhow", "async-trait", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 7c911891913b6..016af82cc61eb 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -100,12 +100,17 @@ lto = false incremental = false [patch.crates-io] + +# TODO(Lucas.Xu) Upgrade to the latest version of RocksDB once PR(https://github.com/rust-rocksdb/rust-rocksdb/pull/869) is merged. +# Currently, using the following revision id. This commit is patched to fix the 32-bit build issue and it's checked out from 0.21.0, not 0.22.0. +rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec131b9d82dc94e178fe8efc0c147b09" } + # Please using the following command to update the revision id # Current directory: frontend # Run the script: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ -client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "29a0851f485957cc6410ccf9d261c781c1d2f757" } +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ab9496c248b7c733d1aa160062abeb66c4e41325" } # Please use the following script to update collab. # Working directory: frontend # @@ -115,10 +120,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "29a # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "195fabd025c3021ce0a42a3f76b8f2bad44cb45c" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "79be7f4c6e8e672b6f08ffda866876c01fc28e62" } diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.rs b/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.rs index 82ca535578db9..ecd7b63927ff6 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.rs +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.rs @@ -32,7 +32,7 @@ impl EventTemplate { .tera_context .insert("has_input", &ctx.input_deserializer.is_some()); match ctx.input_deserializer { - None => self.tera_context.insert("input_deserializer", "Unit"), + None => self.tera_context.insert("input_deserializer", "void"), Some(ref input) => self.tera_context.insert("input_deserializer", input), } @@ -45,7 +45,7 @@ impl EventTemplate { self.tera_context.insert("has_output", &has_output); match ctx.output_deserializer { - None => self.tera_context.insert("output_deserializer", "Unit"), + None => self.tera_context.insert("output_deserializer", "void"), Some(ref output) => self.tera_context.insert("output_deserializer", output), } diff --git a/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.tera b/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.tera index 445807ea333f7..b54b5262b79a8 100644 --- a/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.tera +++ b/frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.tera @@ -6,7 +6,7 @@ class {{ event_class }} { {{ event_class }}(); {%- endif %} - Future> send() { + Future> send() { {%- if has_input %} final request = FFIRequest.create() @@ -17,11 +17,11 @@ class {{ event_class }} { .then((bytesResult) => bytesResult.fold( {%- if has_output %} - (okBytes) => left({{ output_deserializer }}.fromBuffer(okBytes)), + (okBytes) => FlowySuccess({{ output_deserializer }}.fromBuffer(okBytes)), {%- else %} - (bytes) => left(unit), + (bytes) => FlowySuccess(null), {%- endif %} - (errBytes) => right({{ error_deserializer }}.fromBuffer(errBytes)), + (errBytes) => FlowyFailure({{ error_deserializer }}.fromBuffer(errBytes)), )); {%- else %} @@ -33,11 +33,11 @@ class {{ event_class }} { return Dispatch.asyncRequest(request).then((bytesResult) => bytesResult.fold( {%- if has_output %} - (okBytes) => left({{ output_deserializer }}.fromBuffer(okBytes)), + (okBytes) => FlowySuccess({{ output_deserializer }}.fromBuffer(okBytes)), {%- else %} - (bytes) => left(unit), + (bytes) => FlowySuccess(null), {%- endif %} - (errBytes) => right({{ error_deserializer }}.fromBuffer(errBytes)), + (errBytes) => FlowyFailure({{ error_deserializer }}.fromBuffer(errBytes)), )); {%- endif %} } diff --git a/frontend/rust-lib/collab-integrate/Cargo.toml b/frontend/rust-lib/collab-integrate/Cargo.toml index 19f5e879ab7e5..048eecabf5950 100644 --- a/frontend/rust-lib/collab-integrate/Cargo.toml +++ b/frontend/rust-lib/collab-integrate/Cargo.toml @@ -19,6 +19,7 @@ parking_lot.workspace = true async-trait.workspace = true tokio = { workspace = true, features = ["sync"]} lib-infra = { workspace = true } +futures = "0.3" [features] default = [] \ No newline at end of file diff --git a/frontend/rust-lib/collab-integrate/src/collab_builder.rs b/frontend/rust-lib/collab-integrate/src/collab_builder.rs index 6efb41a01f480..38c8f9d64ae33 100644 --- a/frontend/rust-lib/collab-integrate/src/collab_builder.rs +++ b/frontend/rust-lib/collab-integrate/src/collab_builder.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Weak}; use crate::CollabKVDB; use anyhow::Error; -use collab::core::collab::{CollabDocState, MutexCollab}; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab::preclude::CollabBuilder; use collab_entity::{CollabObject, CollabType}; use collab_plugins::connect_state::{CollabConnectReachability, CollabConnectState}; @@ -68,7 +68,7 @@ impl Display for CollabPluginProviderContext { pub struct AppFlowyCollabBuilder { network_reachability: CollabConnectReachability, workspace_id: RwLock>, - plugin_provider: tokio::sync::RwLock>, + plugin_provider: RwLock>, snapshot_persistence: Mutex>>, #[cfg(not(target_arch = "wasm32"))] rocksdb_backup: Mutex>>, @@ -97,7 +97,7 @@ impl AppFlowyCollabBuilder { Self { network_reachability: CollabConnectReachability::new(), workspace_id: Default::default(), - plugin_provider: tokio::sync::RwLock::new(Arc::new(storage_provider)), + plugin_provider: RwLock::new(Arc::new(storage_provider)), snapshot_persistence: Default::default(), #[cfg(not(target_arch = "wasm32"))] rocksdb_backup: Default::default(), @@ -167,22 +167,20 @@ impl AppFlowyCollabBuilder { uid: i64, object_id: &str, object_type: CollabType, - collab_doc_state: CollabDocState, + collab_doc_state: DocStateSource, collab_db: Weak, build_config: CollabBuilderConfig, ) -> Result, Error> { let persistence_config = CollabPersistenceConfig::default(); - self - .build_with_config( - uid, - object_id, - object_type, - collab_db, - collab_doc_state, - persistence_config, - build_config, - ) - .await + self.build_with_config( + uid, + object_id, + object_type, + collab_db, + collab_doc_state, + persistence_config, + build_config, + ) } /// Creates a new collaboration builder with the custom configuration. @@ -200,13 +198,13 @@ impl AppFlowyCollabBuilder { /// - `collab_db`: A weak reference to the [CollabKVDB]. /// #[allow(clippy::too_many_arguments)] - pub async fn build_with_config( + pub fn build_with_config( &self, uid: i64, object_id: &str, object_type: CollabType, collab_db: Weak, - collab_doc_state: CollabDocState, + collab_doc_state: DocStateSource, #[allow(unused_variables)] persistence_config: CollabPersistenceConfig, build_config: CollabBuilderConfig, ) -> Result, Error> { @@ -240,23 +238,22 @@ impl AppFlowyCollabBuilder { { let collab_object = self.collab_object(uid, object_id, object_type)?; if build_config.sync_enable { - let provider_type = self.plugin_provider.read().await.provider_type(); + let provider_type = self.plugin_provider.read().provider_type(); let span = tracing::span!(tracing::Level::TRACE, "collab_builder", object_id = %object_id); let _enter = span.enter(); match provider_type { CollabPluginProviderType::AppFlowyCloud => { trace!("init appflowy cloud collab plugins"); let local_collab = Arc::downgrade(&collab); - let plugins = self - .plugin_provider - .read() - .await - .get_plugins(CollabPluginProviderContext::AppFlowyCloud { - uid, - collab_object, - local_collab, - }) - .await; + let plugins = + self + .plugin_provider + .read() + .get_plugins(CollabPluginProviderContext::AppFlowyCloud { + uid, + collab_object, + local_collab, + }); trace!("add appflowy cloud collab plugins: {}", plugins.len()); for plugin in plugins { @@ -269,17 +266,16 @@ impl AppFlowyCollabBuilder { trace!("init supabase collab plugins"); let local_collab = Arc::downgrade(&collab); let local_collab_db = collab_db.clone(); - let plugins = self - .plugin_provider - .read() - .await - .get_plugins(CollabPluginProviderContext::Supabase { - uid, - collab_object, - local_collab, - local_collab_db, - }) - .await; + let plugins = + self + .plugin_provider + .read() + .get_plugins(CollabPluginProviderContext::Supabase { + uid, + collab_object, + local_collab, + local_collab_db, + }); for plugin in plugins { collab.lock().add_plugin(plugin); } @@ -291,7 +287,7 @@ impl AppFlowyCollabBuilder { } #[cfg(target_arch = "wasm32")] - collab.lock().initialize().await; + futures::executor::block_on(collab.lock().initialize()); #[cfg(not(target_arch = "wasm32"))] collab.lock().initialize(); diff --git a/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs b/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs index f5b6ce522535b..94256bb439b2b 100644 --- a/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs +++ b/frontend/rust-lib/collab-integrate/src/native/plugin_provider.rs @@ -1,12 +1,11 @@ use crate::collab_builder::{CollabPluginProviderContext, CollabPluginProviderType}; use collab::preclude::CollabPlugin; -use lib_infra::future::Fut; use std::sync::Arc; pub trait CollabCloudPluginProvider: Send + Sync + 'static { fn provider_type(&self) -> CollabPluginProviderType; - fn get_plugins(&self, context: CollabPluginProviderContext) -> Fut>>; + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; fn is_sync_enabled(&self) -> bool; } @@ -19,7 +18,7 @@ where (**self).provider_type() } - fn get_plugins(&self, context: CollabPluginProviderContext) -> Fut>> { + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { (**self).get_plugins(context) } diff --git a/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs b/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs index 9a954783abada..545e6c461cf6c 100644 --- a/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs +++ b/frontend/rust-lib/collab-integrate/src/wasm/plugin_provider.rs @@ -2,12 +2,11 @@ use crate::collab_builder::{CollabPluginProviderContext, CollabPluginProviderTyp use collab::preclude::CollabPlugin; use lib_infra::future::Fut; use std::rc::Rc; -use std::sync::Arc; pub trait CollabCloudPluginProvider: 'static { fn provider_type(&self) -> CollabPluginProviderType; - fn get_plugins(&self, context: CollabPluginProviderContext) -> Fut>>; + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; fn is_sync_enabled(&self) -> bool; } @@ -20,7 +19,7 @@ where (**self).provider_type() } - fn get_plugins(&self, context: CollabPluginProviderContext) -> Fut>> { + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { (**self).get_plugins(context) } diff --git a/frontend/rust-lib/dart-ffi/binding.h b/frontend/rust-lib/dart-ffi/binding.h index 3fe1f39faaf71..77d9b96ec163d 100644 --- a/frontend/rust-lib/dart-ffi/binding.h +++ b/frontend/rust-lib/dart-ffi/binding.h @@ -13,6 +13,6 @@ int32_t set_stream_port(int64_t port); void link_me_please(void); -void backend_log(int64_t level, const char *data); +void rust_log(int64_t level, const char *data); void set_env(const char *data); diff --git a/frontend/rust-lib/dart-ffi/src/lib.rs b/frontend/rust-lib/dart-ffi/src/lib.rs index e7c536822778e..0ae56ce015172 100644 --- a/frontend/rust-lib/dart-ffi/src/lib.rs +++ b/frontend/rust-lib/dart-ffi/src/lib.rs @@ -5,7 +5,7 @@ use std::{ffi::CStr, os::raw::c_char}; use lazy_static::lazy_static; use parking_lot::Mutex; -use tracing::{error, trace}; +use tracing::{debug, error, info, trace, warn}; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::*; @@ -175,18 +175,50 @@ async fn post_to_flutter(response: AFPluginEventResponse, port: i64) { } #[no_mangle] -pub extern "C" fn backend_log(level: i64, data: *const c_char) { - let c_str = unsafe { CStr::from_ptr(data) }; - let log_str = c_str.to_str().unwrap(); - - // Don't change the mapping relation between number and level - match level { - 0 => tracing::info!("{}", log_str), - 1 => tracing::debug!("{}", log_str), - 2 => tracing::trace!("{}", log_str), - 3 => tracing::warn!("{}", log_str), - 4 => tracing::error!("{}", log_str), - _ => (), +pub extern "C" fn rust_log(level: i64, data: *const c_char) { + // Check if the data pointer is not null + if data.is_null() { + error!("[flutter error]: null pointer provided to backend_log"); + return; + } + + let log_result = unsafe { CStr::from_ptr(data) }.to_str(); + + // Handle potential UTF-8 conversion error + let log_str = match log_result { + Ok(str) => str, + Err(e) => { + error!( + "[flutter error]: Failed to convert C string to Rust string: {:?}", + e + ); + return; + }, + }; + + // Simplify logging by determining the log level outside of the match + let log_level = match level { + 0 => "info", + 1 => "debug", + 2 => "trace", + 3 => "warn", + 4 => "error", + _ => { + warn!("[flutter error]: Unsupported log level: {}", level); + return; + }, + }; + + // Log the message at the appropriate level + match log_level { + "info" => info!("[Flutter]: {}", log_str), + "debug" => debug!("[Flutter]: {}", log_str), + "trace" => trace!("[Flutter]: {}", log_str), + "warn" => warn!("[Flutter]: {}", log_str), + "error" => error!("[Flutter]: {}", log_str), + _ => { + warn!("[flutter error]: Unsupported log level: {}", log_level); + }, } } diff --git a/frontend/rust-lib/event-integration/src/database_event.rs b/frontend/rust-lib/event-integration/src/database_event.rs index ec25fafef2fa3..424936b6a8d16 100644 --- a/frontend/rust-lib/event-integration/src/database_event.rs +++ b/frontend/rust-lib/event-integration/src/database_event.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::convert::TryFrom; use bytes::Bytes; @@ -35,6 +36,7 @@ impl EventIntegrationTest { meta: Default::default(), set_as_current: true, index: None, + section: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -65,6 +67,7 @@ impl EventIntegrationTest { meta: Default::default(), set_as_current: true, index: None, + section: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -90,6 +93,7 @@ impl EventIntegrationTest { meta: Default::default(), set_as_current: true, index: None, + section: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -200,7 +204,7 @@ impl EventIntegrationTest { &self, view_id: &str, row_position: OrderObjectPositionPB, - data: Option, + data: Option>, ) -> RowMetaPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::CreateRow) @@ -208,7 +212,7 @@ impl EventIntegrationTest { view_id: view_id.to_string(), row_position, group_id: None, - data, + data: data.unwrap_or_default(), }) .async_send() .await @@ -335,6 +339,16 @@ impl EventIntegrationTest { ChecklistCellDataPB::try_from(Bytes::from(cell.data)).unwrap() } + pub async fn get_relation_cell( + &self, + view_id: &str, + field_id: &str, + row_id: &str, + ) -> RelationCellDataPB { + let cell = self.get_cell(view_id, row_id, field_id).await; + RelationCellDataPB::try_from(Bytes::from(cell.data)).unwrap_or_default() + } + pub async fn update_checklist_cell( &self, changeset: ChecklistCellDataChangesetPB, @@ -469,4 +483,33 @@ impl EventIntegrationTest { .parse::() .items } + + pub async fn update_relation_cell( + &self, + changeset: RelationCellChangesetPB, + ) -> Option { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::UpdateRelationCell) + .payload(changeset) + .async_send() + .await + .error() + } + + pub async fn get_related_row_data( + &self, + database_id: String, + row_ids: Vec, + ) -> Vec { + EventBuilder::new(self.clone()) + .event(DatabaseEvent::GetRelatedRowDatas) + .payload(RepeatedRowIdPB { + database_id, + row_ids, + }) + .async_send() + .await + .parse::() + .rows + } } 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 2070b6adf9959..49f0f62a9bd95 100644 --- a/frontend/rust-lib/event-integration/src/document/document_event.rs +++ b/frontend/rust-lib/event-integration/src/document/document_event.rs @@ -64,6 +64,7 @@ impl DocumentEventTest { meta: Default::default(), set_as_current: true, index: None, + section: None, }; EventBuilder::new(core.clone()) .event(FolderEvent::CreateView) diff --git a/frontend/rust-lib/event-integration/src/document_event.rs b/frontend/rust-lib/event-integration/src/document_event.rs index c4904042efbc3..95103159fdcd0 100644 --- a/frontend/rust-lib/event-integration/src/document_event.rs +++ b/frontend/rust-lib/event-integration/src/document_event.rs @@ -41,6 +41,7 @@ impl EventIntegrationTest { meta: Default::default(), set_as_current: true, index: None, + section: None, }; let view = EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -103,7 +104,7 @@ impl EventIntegrationTest { } pub fn assert_document_data_equal(doc_state: &[u8], doc_id: &str, expected: DocumentData) { - let collab = MutexCollab::new(CollabOrigin::Server, doc_id, vec![]); + let collab = MutexCollab::new(CollabOrigin::Server, doc_id, vec![], false); collab.lock().with_origin_transact_mut(|txn| { let update = Update::decode_v1(doc_state).unwrap(); txn.apply_update(update); diff --git a/frontend/rust-lib/event-integration/src/event_builder.rs b/frontend/rust-lib/event-integration/src/event_builder.rs index fa20de726ff2f..0d083b1037217 100644 --- a/frontend/rust-lib/event-integration/src/event_builder.rs +++ b/frontend/rust-lib/event-integration/src/event_builder.rs @@ -63,19 +63,11 @@ impl EventBuilder { match response.clone().parse::() { Ok(Ok(data)) => data, Ok(Err(e)) => { - panic!( - "Parser {:?} failed: {:?}, response {:?}", - std::any::type_name::(), - e, - response - ) + panic!("Parser {:?} failed: {:?}", std::any::type_name::(), e) + }, + Err(e) => { + panic!("Parser {:?} failed: {:?}", std::any::type_name::(), e) }, - Err(e) => panic!( - "Dispatch {:?} failed: {:?}, response {:?}", - std::any::type_name::(), - e, - response - ), } } diff --git a/frontend/rust-lib/event-integration/src/folder_event.rs b/frontend/rust-lib/event-integration/src/folder_event.rs index af426d161590b..604bd1475d1b8 100644 --- a/frontend/rust-lib/event-integration/src/folder_event.rs +++ b/frontend/rust-lib/event-integration/src/folder_event.rs @@ -57,7 +57,7 @@ impl EventIntegrationTest { pub async fn get_all_workspace_views(&self) -> Vec { EventBuilder::new(self.clone()) - .event(FolderEvent::ReadWorkspaceViews) + .event(FolderEvent::ReadCurrentWorkspaceViews) .async_send() .await .parse::() @@ -115,6 +115,7 @@ impl EventIntegrationTest { meta: Default::default(), set_as_current: false, index: None, + section: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) @@ -134,6 +135,15 @@ impl EventIntegrationTest { .await .parse::() } + + pub async fn import_data(&self, data: ImportPB) -> ViewPB { + EventBuilder::new(self.clone()) + .event(FolderEvent::ImportData) + .payload(data) + .async_send() + .await + .parse::() + } } pub struct ViewTest { @@ -156,6 +166,7 @@ impl ViewTest { meta: Default::default(), set_as_current: true, index: None, + section: None, }; let view = EventBuilder::new(sdk.clone()) @@ -192,7 +203,7 @@ async fn create_workspace(sdk: &EventIntegrationTest, name: &str, desc: &str) -> }; EventBuilder::new(sdk.clone()) - .event(CreateWorkspace) + .event(CreateFolderWorkspace) .payload(request) .async_send() .await diff --git a/frontend/rust-lib/event-integration/src/lib.rs b/frontend/rust-lib/event-integration/src/lib.rs index a91125ca54a17..f335e290e874b 100644 --- a/frontend/rust-lib/event-integration/src/lib.rs +++ b/frontend/rust-lib/event-integration/src/lib.rs @@ -1,4 +1,4 @@ -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::core::origin::CollabOrigin; use collab_document::blocks::DocumentData; use collab_document::document::Document; @@ -108,31 +108,34 @@ impl EventIntegrationTest { pub async fn get_collab_doc_state( &self, oid: &str, - collay_type: CollabType, - ) -> Result { + collab_type: CollabType, + ) -> Result, FlowyError> { let server = self.server_provider.get_server().unwrap(); let workspace_id = self.get_current_workspace().await.id; let uid = self.get_user_profile().await?.id; let doc_state = server .folder_service() - .get_folder_doc_state(&workspace_id, uid, collay_type, oid) + .get_folder_doc_state(&workspace_id, uid, collab_type, oid) .await?; Ok(doc_state) } } -pub fn document_data_from_document_doc_state( - doc_id: &str, - doc_state: CollabDocState, -) -> DocumentData { +pub fn document_data_from_document_doc_state(doc_id: &str, doc_state: Vec) -> DocumentData { document_from_document_doc_state(doc_id, doc_state) .get_document_data() .unwrap() } -pub fn document_from_document_doc_state(doc_id: &str, doc_state: CollabDocState) -> Document { - Document::from_doc_state(CollabOrigin::Empty, doc_state, doc_id, vec![]).unwrap() +pub fn document_from_document_doc_state(doc_id: &str, doc_state: Vec) -> Document { + Document::from_doc_state( + CollabOrigin::Empty, + DocStateSource::FromDocState(doc_state), + doc_id, + vec![], + ) + .unwrap() } async fn init_core(config: AppFlowyCoreConfig) -> AppFlowyCore { diff --git a/frontend/rust-lib/event-integration/src/user_event.rs b/frontend/rust-lib/event-integration/src/user_event.rs index 768a6e1a5c433..07c8560a09cf8 100644 --- a/frontend/rust-lib/event-integration/src/user_event.rs +++ b/frontend/rust-lib/event-integration/src/user_event.rs @@ -4,25 +4,27 @@ use std::sync::Arc; use bytes::Bytes; +use flowy_folder::entities::{RepeatedViewPB, WorkspacePB}; use nanoid::nanoid; use protobuf::ProtobufError; use tokio::sync::broadcast::{channel, Sender}; use tracing::error; use uuid::Uuid; +use flowy_folder::event_map::FolderEvent; use flowy_notification::entities::SubscribeObject; use flowy_notification::NotificationSender; use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_URL, USER_UUID}; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ - AuthenticatorPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, OauthSignInPB, - RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, - UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, + AuthenticatorPB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, + OauthSignInPB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, + SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, + UserWorkspaceIdPB, UserWorkspacePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; -use flowy_user::event_map::UserEvent::*; use lib_dispatch::prelude::{af_spawn, AFPluginDispatcher, AFPluginRequest, ToBytes}; use crate::event_builder::EventBuilder; @@ -31,7 +33,7 @@ use crate::EventIntegrationTest; impl EventIntegrationTest { pub async fn enable_encryption(&self) -> String { let config = EventBuilder::new(self.clone()) - .event(GetCloudConfig) + .event(UserEvent::GetCloudConfig) .async_send() .await .parse::(); @@ -40,7 +42,7 @@ impl EventIntegrationTest { enable_encrypt: Some(true), }; let error = EventBuilder::new(self.clone()) - .event(SetCloudConfig) + .event(UserEvent::SetCloudConfig) .payload(update) .async_send() .await @@ -68,7 +70,7 @@ impl EventIntegrationTest { .into_bytes() .unwrap(); - let request = AFPluginRequest::new(SignUp).payload(payload); + let request = AFPluginRequest::new(UserEvent::SignUp).payload(payload); let user_profile = AFPluginDispatcher::async_send(&self.appflowy_core.dispatcher(), request) .await .parse::() @@ -95,7 +97,7 @@ impl EventIntegrationTest { }; EventBuilder::new(self.clone()) - .event(OauthSignIn) + .event(UserEvent::OauthSignIn) .payload(payload) .async_send() .await @@ -104,7 +106,7 @@ impl EventIntegrationTest { pub async fn sign_out(&self) { EventBuilder::new(self.clone()) - .event(SignOut) + .event(UserEvent::SignOut) .async_send() .await; } @@ -119,7 +121,7 @@ impl EventIntegrationTest { pub async fn get_user_profile(&self) -> Result { EventBuilder::new(self.clone()) - .event(GetUserProfile) + .event(UserEvent::GetUserProfile) .async_send() .await .try_parse::() @@ -127,7 +129,7 @@ impl EventIntegrationTest { pub async fn update_user_profile(&self, params: UpdateUserProfilePayloadPB) { EventBuilder::new(self.clone()) - .event(UpdateUserProfile) + .event(UserEvent::UpdateUserProfile) .payload(params) .async_send() .await; @@ -139,7 +141,7 @@ impl EventIntegrationTest { authenticator: AuthenticatorPB::AppFlowyCloud, }; let sign_in_url = EventBuilder::new(self.clone()) - .event(GenerateSignInURL) + .event(UserEvent::GenerateSignInURL) .payload(payload) .async_send() .await @@ -155,7 +157,7 @@ impl EventIntegrationTest { }; let user_profile = EventBuilder::new(self.clone()) - .event(OauthSignIn) + .event(UserEvent::OauthSignIn) .payload(payload) .async_send() .await @@ -182,7 +184,7 @@ impl EventIntegrationTest { }; let user_profile = EventBuilder::new(self.clone()) - .event(OauthSignIn) + .event(UserEvent::OauthSignIn) .payload(payload) .async_send() .await @@ -217,16 +219,74 @@ impl EventIntegrationTest { name: name.to_string(), }; EventBuilder::new(self.clone()) - .event(CreateWorkspace) + .event(UserEvent::CreateWorkspace) .payload(payload) .async_send() .await .parse::() } + pub async fn rename_workspace( + &self, + workspace_id: &str, + new_name: &str, + ) -> Result<(), FlowyError> { + let payload = RenameWorkspacePB { + workspace_id: workspace_id.to_owned(), + new_name: new_name.to_owned(), + }; + match EventBuilder::new(self.clone()) + .event(UserEvent::RenameWorkspace) + .payload(payload) + .async_send() + .await + .error() + { + Some(err) => Err(err), + None => Ok(()), + } + } + + pub async fn change_workspace_icon( + &self, + workspace_id: &str, + new_icon: &str, + ) -> Result<(), FlowyError> { + let payload = ChangeWorkspaceIconPB { + workspace_id: workspace_id.to_owned(), + new_icon: new_icon.to_owned(), + }; + match EventBuilder::new(self.clone()) + .event(UserEvent::ChangeWorkspaceIcon) + .payload(payload) + .async_send() + .await + .error() + { + Some(err) => Err(err), + None => Ok(()), + } + } + + pub async fn folder_read_current_workspace(&self) -> WorkspacePB { + EventBuilder::new(self.clone()) + .event(FolderEvent::ReadCurrentWorkspace) + .async_send() + .await + .parse() + } + + pub async fn folder_read_current_workspace_views(&self) -> RepeatedViewPB { + EventBuilder::new(self.clone()) + .event(FolderEvent::ReadCurrentWorkspaceViews) + .async_send() + .await + .parse() + } + pub async fn get_all_workspaces(&self) -> RepeatedUserWorkspacePB { EventBuilder::new(self.clone()) - .event(GetAllWorkspace) + .event(UserEvent::GetAllWorkspace) .async_send() .await .parse::() @@ -237,7 +297,7 @@ impl EventIntegrationTest { workspace_id: workspace_id.to_string(), }; EventBuilder::new(self.clone()) - .event(DeleteWorkspace) + .event(UserEvent::DeleteWorkspace) .payload(payload) .async_send() .await; @@ -248,7 +308,7 @@ impl EventIntegrationTest { workspace_id: workspace_id.to_string(), }; EventBuilder::new(self.clone()) - .event(OpenWorkspace) + .event(UserEvent::OpenWorkspace) .payload(payload) .async_send() .await; @@ -371,7 +431,7 @@ pub async fn user_localhost_af_cloud() { let base_url = std::env::var("af_cloud_test_base_url").unwrap_or("http://localhost:8000".to_string()); let ws_base_url = - std::env::var("af_cloud_test_ws_url").unwrap_or("ws://localhost:8000/ws".to_string()); + std::env::var("af_cloud_test_ws_url").unwrap_or("ws://localhost:8000/ws/v1".to_string()); let gotrue_url = std::env::var("af_cloud_test_gotrue_url").unwrap_or("http://localhost:9999".to_string()); AFCloudConfiguration { @@ -387,7 +447,7 @@ pub async fn user_localhost_af_cloud() { #[allow(dead_code)] pub async fn user_localhost_af_cloud_with_nginx() { std::env::set_var("af_cloud_test_base_url", "http://localhost"); - std::env::set_var("af_cloud_test_ws_url", "ws://localhost/ws"); + std::env::set_var("af_cloud_test_ws_url", "ws://localhost/ws/v1"); std::env::set_var("af_cloud_test_gotrue_url", "http://localhost/gotrue"); user_localhost_af_cloud().await } diff --git a/frontend/rust-lib/event-integration/tests/asset/csv_10240r_15c.csv.zip b/frontend/rust-lib/event-integration/tests/asset/csv_10240r_15c.csv.zip new file mode 100644 index 0000000000000..9d6b57efc3e26 Binary files /dev/null and b/frontend/rust-lib/event-integration/tests/asset/csv_10240r_15c.csv.zip differ diff --git a/frontend/rust-lib/event-integration/tests/asset/csv_492r_17c.csv.zip b/frontend/rust-lib/event-integration/tests/asset/csv_492r_17c.csv.zip new file mode 100644 index 0000000000000..0d38422ac397e Binary files /dev/null and b/frontend/rust-lib/event-integration/tests/asset/csv_492r_17c.csv.zip differ 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 index e9ed09813a39d..556624e7ff1ec 100644 --- 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 @@ -89,7 +89,6 @@ async fn rename_group_event_test() { .create_board(¤t_workspace.id, "my board view".to_owned(), vec![]) .await; - // Empty to group id let groups = test.get_groups(&board_view.id).await; let error = test .update_group( @@ -101,9 +100,6 @@ async fn rename_group_event_test() { ) .await; assert!(error.is_none()); - - let groups = test.get_groups(&board_view.id).await; - assert_eq!(groups[1].group_name, "new name".to_owned()); } #[tokio::test] @@ -144,9 +140,6 @@ async fn update_group_name_test() { 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( @@ -160,8 +153,6 @@ async fn update_group_name_test() { 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"); } #[tokio::test] @@ -174,14 +165,9 @@ async fn delete_group_test() { 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.delete_group(&board_view.id, &groups[1].group_id).await; let groups = test.get_groups(&board_view.id).await; assert_eq!(groups.len(), 3); - assert_eq!(groups[1].group_name, "Doing"); - assert_eq!(groups[2].group_name, "Done"); } 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 c05b00a8b8926..1c2edd339dc01 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 @@ -7,7 +7,7 @@ use event_integration::EventIntegrationTest; use flowy_database2::entities::{ CellChangesetPB, CellIdPB, CheckboxCellDataPB, ChecklistCellDataChangesetPB, DatabaseLayoutPB, DatabaseSettingChangesetPB, DatabaseViewIdPB, DateCellChangesetPB, FieldType, - OrderObjectPositionPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB, + OrderObjectPositionPB, RelationCellChangesetPB, SelectOptionCellDataPB, UpdateRowMetaChangesetPB, }; use lib_infra::util::timestamp; @@ -778,3 +778,123 @@ async fn create_calendar_event_test() { let events = test.get_all_calendar_events(&calendar_view.id).await; assert_eq!(events.len(), 1); } + +#[tokio::test] +async fn update_relation_cell_test() { + let test = EventIntegrationTest::new_with_guest_user().await; + let current_workspace = test.get_current_workspace().await; + let grid_view = test + .create_grid(¤t_workspace.id, "my grid view".to_owned(), vec![]) + .await; + let relation_field = test.create_field(&grid_view.id, FieldType::Relation).await; + let database = test.get_database(&grid_view.id).await; + + // update the relation cell + let changeset = RelationCellChangesetPB { + view_id: grid_view.id.clone(), + cell_id: CellIdPB { + view_id: grid_view.id.clone(), + field_id: relation_field.id.clone(), + row_id: database.rows[0].id.clone(), + }, + inserted_row_ids: vec![ + "row1rowid".to_string(), + "row2rowid".to_string(), + "row3rowid".to_string(), + ], + ..Default::default() + }; + test.update_relation_cell(changeset).await; + + // get the cell + let cell = test + .get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id) + .await; + + assert_eq!(cell.row_ids.len(), 3); + + // update the relation cell + let changeset = RelationCellChangesetPB { + view_id: grid_view.id.clone(), + cell_id: CellIdPB { + view_id: grid_view.id.clone(), + field_id: relation_field.id.clone(), + row_id: database.rows[0].id.clone(), + }, + removed_row_ids: vec![ + "row1rowid".to_string(), + "row3rowid".to_string(), + "row4rowid".to_string(), + ], + ..Default::default() + }; + test.update_relation_cell(changeset).await; + + // get the cell + let cell = test + .get_relation_cell(&grid_view.id, &relation_field.id, &database.rows[0].id) + .await; + + assert_eq!(cell.row_ids.len(), 1); +} + +#[tokio::test] +async fn get_detailed_relation_cell_data() { + let test = EventIntegrationTest::new_with_guest_user().await; + let current_workspace = test.get_current_workspace().await; + + let origin_grid_view = test + .create_grid(¤t_workspace.id, "origin".to_owned(), vec![]) + .await; + let relation_grid_view = test + .create_grid(¤t_workspace.id, "relation grid".to_owned(), vec![]) + .await; + let relation_field = test + .create_field(&relation_grid_view.id, FieldType::Relation) + .await; + + let origin_database = test.get_database(&origin_grid_view.id).await; + let origin_fields = test.get_all_database_fields(&origin_grid_view.id).await; + let linked_row = origin_database.rows[0].clone(); + + test + .update_cell(CellChangesetPB { + view_id: origin_grid_view.id.clone(), + row_id: linked_row.id.clone(), + field_id: origin_fields.items[0].id.clone(), + cell_changeset: "hello world".to_string(), + }) + .await; + + let new_database = test.get_database(&relation_grid_view.id).await; + + // update the relation cell + let changeset = RelationCellChangesetPB { + view_id: relation_grid_view.id.clone(), + cell_id: CellIdPB { + view_id: relation_grid_view.id.clone(), + field_id: relation_field.id.clone(), + row_id: new_database.rows[0].id.clone(), + }, + inserted_row_ids: vec![linked_row.id.clone()], + ..Default::default() + }; + test.update_relation_cell(changeset).await; + + // get the cell + let cell = test + .get_relation_cell( + &relation_grid_view.id, + &relation_field.id, + &new_database.rows[0].id, + ) + .await; + + // using the row ids, get the row data + let rows = test + .get_related_row_data(origin_database.id.clone(), cell.row_ids) + .await; + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "hello world"); +} 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 73488492da3a2..6c69258917297 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 @@ -81,7 +81,7 @@ pub fn assert_database_collab_content( collab_update: &[u8], expected: JsonValue, ) { - let collab = MutexCollab::new(CollabOrigin::Server, database_id, vec![]); + let collab = MutexCollab::new(CollabOrigin::Server, database_id, vec![], false); collab.lock().with_origin_transact_mut(|txn| { let update = Update::decode_v1(collab_update).unwrap(); txn.apply_update(update); 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 8c94fceab018a..c0165bd8ca749 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 @@ -6,7 +6,7 @@ use event_integration::document_event::assert_document_data_equal; use event_integration::user_event::user_localhost_af_cloud; use event_integration::EventIntegrationTest; use flowy_core::DEFAULT_NAME; -use flowy_document::entities::DocumentSyncStatePB; +use flowy_document::entities::{DocumentSyncState, DocumentSyncStatePB}; use crate::util::{receive_with_timeout, unzip_history_user_db}; @@ -30,7 +30,9 @@ async fn af_cloud_edit_document_test() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; let document_data = test.get_document_data(&document_id).await; @@ -61,7 +63,9 @@ async fn af_cloud_sync_anon_user_document_test() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); let _ = receive_with_timeout(rx, Duration::from_secs(30)).await; let doc_state = test.get_document_doc_state(&document_id).await; 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 f11b4acb7cd7e..ba761d347dd58 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 @@ -1,7 +1,7 @@ use std::time::Duration; use event_integration::document_event::assert_document_data_equal; -use flowy_document::entities::DocumentSyncStatePB; +use flowy_document::entities::{DocumentSyncState, DocumentSyncStatePB}; use crate::document::supabase_test::helper::FlowySupabaseDocumentTest; use crate::util::receive_with_timeout; @@ -23,7 +23,9 @@ async fn supabase_document_edit_sync_test() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); @@ -49,7 +51,9 @@ async fn supabase_document_edit_sync_test2() { // wait all update are send to the remote let rx = test .notification_sender - .subscribe_with_condition::(&document_id, |pb| !pb.is_syncing); + .subscribe_with_condition::(&document_id, |pb| { + pb.value != DocumentSyncState::Syncing + }); receive_with_timeout(rx, Duration::from_secs(30)) .await .unwrap(); diff --git a/frontend/rust-lib/event-integration/tests/folder/local_test/import_test.rs b/frontend/rust-lib/event-integration/tests/folder/local_test/import_test.rs new file mode 100644 index 0000000000000..7b812fc8218ab --- /dev/null +++ b/frontend/rust-lib/event-integration/tests/folder/local_test/import_test.rs @@ -0,0 +1,55 @@ +use crate::util::unzip_history_user_db; +use event_integration::EventIntegrationTest; +use flowy_core::DEFAULT_NAME; +use flowy_folder::entities::{ImportPB, ImportTypePB, ViewLayoutPB}; + +#[tokio::test] +async fn import_492_row_csv_file_test() { + // csv_500r_15c.csv is a file with 492 rows and 17 columns + let file_name = "csv_492r_17c.csv".to_string(); + let (cleaner, csv_file_path) = unzip_history_user_db("./tests/asset", &file_name).unwrap(); + + let csv_string = std::fs::read_to_string(csv_file_path).unwrap(); + let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; + test.sign_up_as_guest().await; + + let workspace_id = test.get_current_workspace().await.id; + let import_data = gen_import_data(file_name, csv_string, workspace_id); + + let view = test.import_data(import_data).await; + let database = test.get_database(&view.id).await; + assert_eq!(database.rows.len(), 492); + drop(cleaner); +} + +#[tokio::test] +async fn import_10240_row_csv_file_test() { + // csv_22577r_15c.csv is a file with 10240 rows and 15 columns + let file_name = "csv_10240r_15c.csv".to_string(); + let (cleaner, csv_file_path) = unzip_history_user_db("./tests/asset", &file_name).unwrap(); + + let csv_string = std::fs::read_to_string(csv_file_path).unwrap(); + let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await; + test.sign_up_as_guest().await; + + let workspace_id = test.get_current_workspace().await.id; + let import_data = gen_import_data(file_name, csv_string, workspace_id); + + let view = test.import_data(import_data).await; + let database = test.get_database(&view.id).await; + assert_eq!(database.rows.len(), 10240); + + drop(cleaner); +} + +fn gen_import_data(file_name: String, csv_string: String, workspace_id: String) -> ImportPB { + let import_data = ImportPB { + parent_view_id: workspace_id.clone(), + name: file_name, + data: Some(csv_string.as_bytes().to_vec()), + file_path: None, + view_layout: ViewLayoutPB::Grid, + import_type: ImportTypePB::CSV, + }; + import_data +} diff --git a/frontend/rust-lib/event-integration/tests/folder/local_test/mod.rs b/frontend/rust-lib/event-integration/tests/folder/local_test/mod.rs index e13c3c1e76972..aa58a02baf11c 100644 --- a/frontend/rust-lib/event-integration/tests/folder/local_test/mod.rs +++ b/frontend/rust-lib/event-integration/tests/folder/local_test/mod.rs @@ -1,4 +1,5 @@ mod folder_test; +mod import_test; mod script; mod subscription_test; mod test; 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 a43aafc144925..b2a1ee98d34ec 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 @@ -210,7 +210,7 @@ pub async fn create_workspace(sdk: &EventIntegrationTest, name: &str, desc: &str }; EventBuilder::new(sdk.clone()) - .event(CreateWorkspace) + .event(CreateFolderWorkspace) .payload(request) .async_send() .await @@ -246,6 +246,7 @@ pub async fn create_view( meta: Default::default(), set_as_current: true, index: None, + section: None, }; EventBuilder::new(sdk.clone()) .event(CreateView) @@ -275,6 +276,8 @@ pub async fn move_view( view_id, new_parent_id: parent_id, prev_view_id, + from_section: None, + to_section: None, }; let error = EventBuilder::new(sdk.clone()) .event(MoveNestedView) 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 0c67bf7373647..8e60baef3a822 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 @@ -12,7 +12,7 @@ async fn create_workspace_event_test() { desc: "".to_owned(), }; let view_pb = EventBuilder::new(test) - .event(flowy_folder::event_map::FolderEvent::CreateWorkspace) + .event(flowy_folder::event_map::FolderEvent::CreateFolderWorkspace) .payload(request) .async_send() .await @@ -474,7 +474,7 @@ async fn create_parent_view_with_invalid_name() { }; assert_eq!( EventBuilder::new(sdk) - .event(flowy_folder::event_map::FolderEvent::CreateWorkspace) + .event(flowy_folder::event_map::FolderEvent::CreateFolderWorkspace) .payload(request) .async_send() .await @@ -549,6 +549,8 @@ async fn move_folder_nested_view( view_id, new_parent_id, prev_view_id, + from_section: None, + to_section: None, }; EventBuilder::new(sdk) .event(flowy_folder::event_map::FolderEvent::MoveNestedView) 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 c00f4750f71e8..17497f14ddcb6 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 @@ -67,7 +67,7 @@ pub fn assert_folder_collab_content(workspace_id: &str, collab_update: &[u8], ex panic!("collab update is empty"); } - let collab = MutexCollab::new(CollabOrigin::Server, workspace_id, vec![]); + let collab = MutexCollab::new(CollabOrigin::Server, workspace_id, vec![], false); collab.lock().with_origin_transact_mut(|txn| { let update = Update::decode_v1(collab_update).unwrap(); txn.apply_update(update); diff --git a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs index 18b367e619c59..8f1968cec3a55 100644 --- a/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs +++ b/frontend/rust-lib/event-integration/tests/user/af_cloud_test/workspace_test.rs @@ -2,34 +2,95 @@ use std::time::Duration; use event_integration::user_event::user_localhost_af_cloud; use event_integration::EventIntegrationTest; -use flowy_user::entities::RepeatedUserWorkspacePB; +use flowy_user::entities::{RepeatedUserWorkspacePB, UserWorkspacePB}; use flowy_user::protobuf::UserNotification; use crate::util::receive_with_timeout; #[tokio::test] -async fn af_cloud_create_workspace_test() { +async fn af_cloud_workspace_delete() { user_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; let user_profile_pb = test.af_cloud_sign_up().await; + let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; + assert_eq!(workspaces.len(), 1); - let workspaces = test.get_all_workspaces().await.items; + let created_workspace = test.create_workspace("my second workspace").await; + assert_eq!(created_workspace.name, "my second workspace"); + let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; + assert_eq!(workspaces.len(), 2); + + test.delete_workspace(&created_workspace.workspace_id).await; + let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 1); - test.create_workspace("my second workspace").await; - let _workspaces = test.get_all_workspaces().await.items; + let workspaces = test.get_all_workspaces().await.items; + assert_eq!(workspaces.len(), 1); +} - let a = user_profile_pb.id.to_string(); - let rx = test - .notification_sender - .subscribe::(&a, UserNotification::DidUpdateUserWorkspaces as i32); - let workspaces = receive_with_timeout(rx, Duration::from_secs(30)) +#[tokio::test] +async fn af_cloud_workspace_change_name_and_icon() { + user_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + let user_profile_pb = test.af_cloud_sign_up().await; + let workspaces = test.get_all_workspaces().await; + let workspace_id = workspaces.items[0].workspace_id.as_str(); + let new_workspace_name = "new_workspace_name".to_string(); + let new_icon = "🚀".to_string(); + test + .rename_workspace(workspace_id, &new_workspace_name) .await - .unwrap() - .items; + .expect("failed to rename workspace"); + test + .change_workspace_icon(workspace_id, &new_icon) + .await + .expect("failed to change workspace icon"); + let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; + assert_eq!(workspaces[0].name, new_workspace_name); + assert_eq!(workspaces[0].icon, new_icon); + let local_workspaces = test.get_all_workspaces().await; + assert_eq!(local_workspaces.items[0].name, new_workspace_name); + assert_eq!(local_workspaces.items[0].icon, new_icon); +} +#[tokio::test] +async fn af_cloud_create_workspace_test() { + user_localhost_af_cloud().await; + let test = EventIntegrationTest::new().await; + let user_profile_pb = test.af_cloud_sign_up().await; + + let workspaces = test.get_all_workspaces().await.items; + let first_workspace_id = workspaces[0].workspace_id.as_str(); + assert_eq!(workspaces.len(), 1); + + let created_workspace = test.create_workspace("my second workspace").await; + assert_eq!(created_workspace.name, "my second workspace"); + + let workspaces = get_synced_workspaces(&test, user_profile_pb.id).await; assert_eq!(workspaces.len(), 2); - assert_eq!(workspaces[1].name, "my second workspace".to_string()); + let _second_workspace = workspaces + .iter() + .find(|w| w.name == "my second workspace") + .expect("created workspace not found"); + + { + // before opening new workspace + let folder_ws = test.folder_read_current_workspace().await; + assert_eq!(&folder_ws.id, first_workspace_id); + let views = test.folder_read_current_workspace_views().await; + assert_eq!(views.items[0].parent_view_id.as_str(), first_workspace_id); + } + { + // after opening new workspace + test.open_workspace(&created_workspace.workspace_id).await; + let folder_ws = test.folder_read_current_workspace().await; + assert_eq!(folder_ws.id, created_workspace.workspace_id); + let views = test.folder_read_current_workspace_views().await; + assert_eq!( + views.items[0].parent_view_id.as_str(), + created_workspace.workspace_id + ); + } } #[tokio::test] @@ -50,3 +111,18 @@ async fn af_cloud_open_workspace_test() { assert_eq!(views[1].name, "my first document".to_string()); assert_eq!(views[2].name, "my second document".to_string()); } + +async fn get_synced_workspaces(test: &EventIntegrationTest, user_id: i64) -> Vec { + let _workspaces = test.get_all_workspaces().await.items; + let sub_id = user_id.to_string(); + let rx = test + .notification_sender + .subscribe::( + &sub_id, + UserNotification::DidUpdateUserWorkspaces as i32, + ); + receive_with_timeout(rx, Duration::from_secs(30)) + .await + .unwrap() + .items +} 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 f42671cb1c436..8ca0b4e696d16 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 @@ -123,7 +123,7 @@ async fn sign_up_as_guest_and_then_update_to_new_cloud_user_test() { let test = EventIntegrationTest::new_with_guest_user().await; let old_views = test .folder_manager - .get_current_workspace_views() + .get_current_workspace_public_views() .await .unwrap(); let old_workspace = test.folder_manager.get_current_workspace().await.unwrap(); @@ -132,7 +132,7 @@ async fn sign_up_as_guest_and_then_update_to_new_cloud_user_test() { test.supabase_sign_up_with_uuid(&uuid, None).await.unwrap(); let new_views = test .folder_manager - .get_current_workspace_views() + .get_current_workspace_public_views() .await .unwrap(); let new_workspace = test.folder_manager.get_current_workspace().await.unwrap(); @@ -163,7 +163,7 @@ async fn sign_up_as_guest_and_then_update_to_existing_cloud_user_test() { let old_cloud_workspace = test.folder_manager.get_current_workspace().await.unwrap(); let old_cloud_views = test .folder_manager - .get_current_workspace_views() + .get_current_workspace_public_views() .await .unwrap(); assert_eq!(old_cloud_views.len(), 1); @@ -189,7 +189,7 @@ async fn sign_up_as_guest_and_then_update_to_existing_cloud_user_test() { let new_cloud_workspace = test.folder_manager.get_current_workspace().await.unwrap(); let new_cloud_views = test .folder_manager - .get_current_workspace_views() + .get_current_workspace_public_views() .await .unwrap(); assert_eq!(new_cloud_workspace, old_cloud_workspace); diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 59ddd300d15ad..1798e1fefb324 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -47,6 +47,7 @@ serde_json.workspace = true serde_repr.workspace = true futures.workspace = true walkdir = "2.4.0" +sysinfo = "0.30.5" [features] default = ["rev-sqlite"] 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 4becb362411b9..d422478923a5e 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 @@ -360,8 +360,11 @@ impl FolderOperationHandler for DatabaseFolderOperation { _ => CSVFormat::Original, }; FutureResult::new(async move { - let content = - String::from_utf8(bytes).map_err(|err| FlowyError::internal().with_context(err))?; + let content = tokio::task::spawn_blocking(move || { + String::from_utf8(bytes).map_err(|err| FlowyError::internal().with_context(err)) + }) + .await??; + database_manager .import_csv(view_id, content, format) .await?; 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 dc700096f0cf4..b920e4116d487 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs @@ -2,8 +2,8 @@ use flowy_storage::{ObjectIdentity, ObjectStorageService}; use std::sync::Arc; use anyhow::Error; -use client_api::collab_sync::{SinkConfig, SinkStrategy, SyncObject, SyncPlugin}; -use collab::core::collab::CollabDocState; +use client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin}; + use collab::core::origin::{CollabClient, CollabOrigin}; use collab::preclude::CollabPlugin; use collab_entity::CollabType; @@ -26,7 +26,7 @@ use flowy_server_pub::supabase_config::SupabaseConfiguration; use flowy_storage::ObjectValue; use flowy_user_pub::cloud::{UserCloudService, UserCloudServiceProvider}; use flowy_user_pub::entities::{Authenticator, UserTokenState}; -use lib_infra::future::{to_fut, Fut, FutureResult}; +use lib_infra::future::FutureResult; use crate::integrate::server::{Server, ServerProvider}; @@ -184,7 +184,7 @@ impl FolderCloudService for ServerProvider { uid: i64, collab_type: CollabType, object_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { let object_id = object_id.to_string(); let workspace_id = workspace_id.to_string(); let server = self.get_server(); @@ -225,7 +225,7 @@ impl DatabaseCloudService for ServerProvider { object_id: &str, collab_type: CollabType, workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { let workspace_id = workspace_id.to_string(); let server = self.get_server(); let database_id = object_id.to_string(); @@ -274,7 +274,7 @@ impl DocumentCloudService for ServerProvider { &self, document_id: &str, workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, FlowyError> { let workspace_id = workspace_id.to_string(); let document_id = document_id.to_string(); let server = self.get_server(); @@ -326,64 +326,58 @@ impl CollabCloudPluginProvider for ServerProvider { } #[instrument(level = "debug", skip(self, context), fields(server_type = %self.get_server_type()))] - fn get_plugins(&self, context: CollabPluginProviderContext) -> Fut>> { + fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { // If the user is local, we don't need to create a sync plugin. if self.get_server_type().is_local() { debug!( "User authenticator is local, skip create sync plugin for: {}", context ); - return to_fut(async move { vec![] }); + return vec![]; } match context { - CollabPluginProviderContext::Local => to_fut(async move { vec![] }), + CollabPluginProviderContext::Local => vec![], CollabPluginProviderContext::AppFlowyCloud { uid: _, collab_object, local_collab, } => { if let Ok(server) = self.get_server() { - to_fut(async move { - let mut plugins: Vec> = vec![]; - - // If the user is local, we don't need to create a sync plugin. - - match server.collab_ws_channel(&collab_object.object_id).await { - Ok(Some((channel, ws_connect_state, is_connected))) => { - let origin = CollabOrigin::Client(CollabClient::new( - collab_object.uid, - collab_object.device_id.clone(), - )); - let sync_object = SyncObject::from(collab_object); - let (sink, stream) = (channel.sink(), channel.stream()); - let sink_config = SinkConfig::new() - .send_timeout(8) - .with_max_payload_size(1024 * 10) - .with_strategy(sink_strategy_from_object(&sync_object)); - let sync_plugin = SyncPlugin::new( - origin, - sync_object, - local_collab, - sink, - sink_config, - stream, - Some(channel), - !is_connected, - ws_connect_state, - ); - plugins.push(Box::new(sync_plugin)); - }, - Ok(None) => { - tracing::error!("🔴Failed to get collab ws channel: channel is none"); - }, - Err(err) => tracing::error!("🔴Failed to get collab ws channel: {:?}", err), - } - - plugins - }) + // to_fut(async move { + let mut plugins: Vec> = vec![]; + // If the user is local, we don't need to create a sync plugin. + + match server.collab_ws_channel(&collab_object.object_id) { + Ok(Some((channel, ws_connect_state, is_connected))) => { + let origin = CollabOrigin::Client(CollabClient::new( + collab_object.uid, + collab_object.device_id.clone(), + )); + let sync_object = SyncObject::from(collab_object); + let (sink, stream) = (channel.sink(), channel.stream()); + let sink_config = SinkConfig::new().send_timeout(8); + let sync_plugin = SyncPlugin::new( + origin, + sync_object, + local_collab, + sink, + sink_config, + stream, + Some(channel), + !is_connected, + ws_connect_state, + ); + plugins.push(Box::new(sync_plugin)); + }, + Ok(None) => { + tracing::error!("🔴Failed to get collab ws channel: channel is none"); + }, + Err(err) => tracing::error!("🔴Failed to get collab ws channel: {:?}", err), + } + plugins } else { - to_fut(async move { vec![] }) + vec![] } }, CollabPluginProviderContext::Supabase { @@ -407,8 +401,7 @@ impl CollabCloudPluginProvider for ServerProvider { local_collab_db, ))); } - - to_fut(async move { plugins }) + plugins }, } } @@ -417,14 +410,3 @@ impl CollabCloudPluginProvider for ServerProvider { *self.user_enable_sync.read() } } - -fn sink_strategy_from_object(object: &SyncObject) -> SinkStrategy { - match object.collab_type { - CollabType::Document => SinkStrategy::FixInterval(std::time::Duration::from_millis(300)), - CollabType::Folder => SinkStrategy::ASAP, - CollabType::Database => SinkStrategy::ASAP, - CollabType::WorkspaceDatabase => SinkStrategy::ASAP, - CollabType::DatabaseRow => SinkStrategy::ASAP, - CollabType::UserAwareness => SinkStrategy::ASAP, - } -} diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index 041e407661ce2..c1e2fbcb82012 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -3,6 +3,7 @@ use flowy_storage::ObjectStorageService; use std::sync::Arc; use std::time::Duration; +use sysinfo::System; use tokio::sync::RwLock; use tracing::{debug, error, event, info, instrument}; @@ -79,6 +80,8 @@ impl AppFlowyCore { // Init the key value database let store_preference = Arc::new(StorePreferences::new(&config.storage_path).unwrap()); info!("🔥{:?}", &config); + info!("💡System info: {:?}", System::long_os_version()); + 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())); diff --git a/frontend/rust-lib/flowy-database-pub/src/cloud.rs b/frontend/rust-lib/flowy-database-pub/src/cloud.rs index 5e1bb5e1c9e75..b92beb4fd1a41 100644 --- a/frontend/rust-lib/flowy-database-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-database-pub/src/cloud.rs @@ -1,12 +1,10 @@ -use std::collections::HashMap; - use anyhow::Error; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab_entity::CollabType; - use lib_infra::future::FutureResult; +use std::collections::HashMap; -pub type CollabDocStateByOid = HashMap; +pub type CollabDocStateByOid = HashMap; /// A trait for database cloud service. /// Each kind of server should implement this trait. Check out the [AppFlowyServerProvider] of @@ -17,7 +15,7 @@ pub trait DatabaseCloudService: Send + Sync { object_id: &str, collab_type: CollabType, workspace_id: &str, - ) -> FutureResult; + ) -> FutureResult, Error>; fn batch_get_database_object_doc_state( &self, diff --git a/frontend/rust-lib/flowy-database2/Cargo.toml b/frontend/rust-lib/flowy-database2/Cargo.toml index 2d6cc2ec97d1d..930cad50d2048 100644 --- a/frontend/rust-lib/flowy-database2/Cargo.toml +++ b/frontend/rust-lib/flowy-database2/Cargo.toml @@ -40,7 +40,7 @@ futures.workspace = true dashmap = "5" anyhow.workspace = true async-stream = "0.3.4" -rayon = "1.6.1" +rayon = "1.9.0" nanoid = "0.4.0" async-trait.workspace = true chrono-tz = "0.8.2" diff --git a/frontend/rust-lib/flowy-database2/build.rs b/frontend/rust-lib/flowy-database2/build.rs index 7d0351bcf6392..90f29201b0910 100644 --- a/frontend/rust-lib/flowy-database2/build.rs +++ b/frontend/rust-lib/flowy-database2/build.rs @@ -1,14 +1,17 @@ fn main() { - let crate_name = env!("CARGO_PKG_NAME"); #[cfg(feature = "dart")] { - flowy_codegen::protobuf_file::dart_gen(crate_name); - flowy_codegen::dart_event::gen(crate_name); + flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); + flowy_codegen::dart_event::gen(env!("CARGO_PKG_NAME")); } #[cfg(feature = "ts")] { - flowy_codegen::ts_event::gen(crate_name, flowy_codegen::Project::Tauri); - flowy_codegen::protobuf_file::ts_gen(crate_name, crate_name, flowy_codegen::Project::Tauri); + flowy_codegen::ts_event::gen(env!("CARGO_PKG_NAME"), flowy_codegen::Project::Tauri); + flowy_codegen::protobuf_file::ts_gen( + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + flowy_codegen::Project::Tauri, + ); } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs index 479156bf6a213..8137b905d9df9 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs @@ -6,7 +6,7 @@ use std::{ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use serde_repr::{Deserialize_repr, Serialize_repr}; -use crate::{impl_into_calculation_type, services::calculations::Calculation}; +use crate::{entities::FieldType, impl_into_calculation_type, services::calculations::Calculation}; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct CalculationPB { @@ -56,10 +56,13 @@ impl std::convert::From<&Arc> for CalculationPB { pub enum CalculationType { #[default] Average = 0, // Number - Max = 1, // Number - Median = 2, // Number - Min = 3, // Number - Sum = 4, // Number + Max = 1, // Number + Median = 2, // Number + Min = 3, // Number + Sum = 4, // Number + Count = 5, // All + CountEmpty = 6, // All + CountNonEmpty = 7, // All } impl Display for CalculationType { @@ -102,6 +105,28 @@ impl From<&CalculationType> for i64 { } } +impl CalculationType { + pub fn is_allowed(&self, field_type: FieldType) -> bool { + match self { + // Number fields only + CalculationType::Max + | CalculationType::Min + | CalculationType::Average + | CalculationType::Median + | CalculationType::Sum => { + matches!(field_type, FieldType::Number) + }, + // Exclude some fields from CountNotEmpty & CountEmpty + CalculationType::CountEmpty | CalculationType::CountNonEmpty => !matches!( + field_type, + FieldType::URL | FieldType::Checkbox | FieldType::CreatedTime | FieldType::LastEditedTime + ), + // All fields + CalculationType::Count => true, + } + } +} + #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct RepeatedCalculationsPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs index 3a718cef299cc..dc82ba7cfa706 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/database_entities.rs @@ -1,6 +1,5 @@ use collab::core::collab_state::SyncState; use collab_database::rows::RowId; -use collab_database::user::DatabaseViewTracker; use collab_database::views::DatabaseLayout; use flowy_derive::ProtoBuf; @@ -69,6 +68,12 @@ impl AsRef for DatabaseIdPB { } } +#[derive(Clone, ProtoBuf, Default, Debug)] +pub struct RepeatedDatabaseIdPB { + #[pb(index = 1)] + pub value: Vec, +} + #[derive(Clone, ProtoBuf, Default, Debug, Validate)] pub struct DatabaseViewIdPB { #[pb(index = 1)] @@ -197,23 +202,18 @@ impl TryInto for MoveGroupRowPayloadPB { } #[derive(Debug, Default, ProtoBuf)] -pub struct DatabaseDescriptionPB { +pub struct DatabaseMetaPB { #[pb(index = 1)] pub database_id: String, -} -impl From for DatabaseDescriptionPB { - fn from(data: DatabaseViewTracker) -> Self { - Self { - database_id: data.database_id, - } - } + #[pb(index = 2)] + pub inline_view_id: String, } #[derive(Debug, Default, ProtoBuf)] pub struct RepeatedDatabaseDescriptionPB { #[pb(index = 1)] - pub items: Vec, + pub items: Vec, } #[derive(Debug, Clone, Default, ProtoBuf)] 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 5b5cba55827a1..22b8c29858312 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -10,6 +10,7 @@ use strum_macros::{EnumCount as EnumCountMacro, EnumIter}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; +use validator::Validate; use crate::entities::parser::NotEmptyStr; use crate::entities::position_entities::OrderObjectPositionPB; @@ -473,6 +474,7 @@ pub enum FieldType { Checklist = 7, LastEditedTime = 8, CreatedTime = 9, + Relation = 10, } impl Display for FieldType { @@ -509,8 +511,9 @@ impl FieldType { FieldType::Checkbox => "Checkbox", FieldType::URL => "URL", FieldType::Checklist => "Checklist", - FieldType::LastEditedTime => "Last edited time", + FieldType::LastEditedTime => "Last modified", FieldType::CreatedTime => "Created time", + FieldType::Relation => "Relation", }; s.to_string() } @@ -559,6 +562,10 @@ impl FieldType { matches!(self, FieldType::Checklist) } + pub fn is_relation(&self) -> bool { + matches!(self, FieldType::Relation) + } + pub fn can_be_group(&self) -> bool { self.is_select_option() || self.is_checkbox() || self.is_url() } @@ -614,6 +621,30 @@ impl TryInto for DuplicateFieldPayloadPB { } } +#[derive(Debug, Clone, Default, ProtoBuf, Validate)] +pub struct ClearFieldPayloadPB { + #[pb(index = 1)] + #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + pub field_id: String, + + #[pb(index = 2)] + #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] + pub view_id: String, +} + +impl TryInto for ClearFieldPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::DatabaseIdIsEmpty)?; + let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; + Ok(FieldIdParams { + view_id: view_id.0, + field_id: field_id.0, + }) + } +} + #[derive(Debug, Clone, Default, ProtoBuf)] pub struct DeleteFieldPayloadPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checkbox_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checkbox_filter.rs index 4b2f9fb888f3a..6dde92ac3d4c7 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checkbox_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checkbox_filter.rs @@ -1,7 +1,7 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use crate::services::filter::{Filter, FromFilterString}; +use crate::services::filter::ParseFilterData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct CheckboxFilterPB { @@ -9,9 +9,8 @@ pub struct CheckboxFilterPB { pub condition: CheckboxFilterConditionPB, } -#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum)] +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] #[repr(u8)] -#[derive(Default)] pub enum CheckboxFilterConditionPB { #[default] IsChecked = 0, @@ -24,7 +23,7 @@ impl std::convert::From for u32 { } } -impl std::convert::TryFrom for CheckboxFilterConditionPB { +impl TryFrom for CheckboxFilterConditionPB { type Error = ErrorCode; fn try_from(value: u8) -> Result { @@ -36,22 +35,10 @@ impl std::convert::TryFrom for CheckboxFilterConditionPB { } } -impl FromFilterString for CheckboxFilterPB { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized, - { +impl ParseFilterData for CheckboxFilterPB { + fn parse(condition: u8, _content: String) -> Self { CheckboxFilterPB { - condition: CheckboxFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(CheckboxFilterConditionPB::IsChecked), - } - } -} - -impl std::convert::From<&Filter> for CheckboxFilterPB { - fn from(filter: &Filter) -> Self { - CheckboxFilterPB { - condition: CheckboxFilterConditionPB::try_from(filter.condition as u8) + condition: CheckboxFilterConditionPB::try_from(condition) .unwrap_or(CheckboxFilterConditionPB::IsChecked), } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checklist_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checklist_filter.rs index 0c2e7fc037b39..97597f2d9bfc1 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checklist_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/checklist_filter.rs @@ -1,7 +1,7 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use crate::services::filter::{Filter, FromFilterString}; +use crate::services::filter::ParseFilterData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct ChecklistFilterPB { @@ -9,12 +9,11 @@ pub struct ChecklistFilterPB { pub condition: ChecklistFilterConditionPB, } -#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum)] +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] #[repr(u8)] -#[derive(Default)] pub enum ChecklistFilterConditionPB { - IsComplete = 0, #[default] + IsComplete = 0, IsIncomplete = 1, } @@ -36,22 +35,10 @@ impl std::convert::TryFrom for ChecklistFilterConditionPB { } } -impl FromFilterString for ChecklistFilterPB { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized, - { - ChecklistFilterPB { - condition: ChecklistFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(ChecklistFilterConditionPB::IsIncomplete), - } - } -} - -impl std::convert::From<&Filter> for ChecklistFilterPB { - fn from(filter: &Filter) -> Self { - ChecklistFilterPB { - condition: ChecklistFilterConditionPB::try_from(filter.condition as u8) +impl ParseFilterData for ChecklistFilterPB { + fn parse(condition: u8, _content: String) -> Self { + Self { + condition: ChecklistFilterConditionPB::try_from(condition) .unwrap_or(ChecklistFilterConditionPB::IsIncomplete), } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs index 75891cea6f941..01c3c9687c671 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use crate::services::filter::{Filter, FromFilterString}; +use crate::services::filter::ParseFilterData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct DateFilterPB { @@ -23,19 +23,19 @@ pub struct DateFilterPB { } #[derive(Deserialize, Serialize, Default, Clone, Debug)] -pub struct DateFilterContentPB { +pub struct DateFilterContent { pub start: Option, pub end: Option, pub timestamp: Option, } -impl ToString for DateFilterContentPB { +impl ToString for DateFilterContent { fn to_string(&self) -> String { serde_json::to_string(self).unwrap() } } -impl FromStr for DateFilterContentPB { +impl FromStr for DateFilterContent { type Err = serde_json::Error; fn from_str(s: &str) -> Result { @@ -79,37 +79,17 @@ impl std::convert::TryFrom for DateFilterConditionPB { } } } -impl FromFilterString for DateFilterPB { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized, - { - let condition = DateFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(DateFilterConditionPB::DateIs); - let mut date_filter = DateFilterPB { - condition, - ..Default::default() - }; - if let Ok(content) = DateFilterContentPB::from_str(&filter.content) { - date_filter.start = content.start; - date_filter.end = content.end; - date_filter.timestamp = content.timestamp; - }; - - date_filter - } -} -impl std::convert::From<&Filter> for DateFilterPB { - fn from(filter: &Filter) -> Self { - let condition = DateFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(DateFilterConditionPB::DateIs); - let mut date_filter = DateFilterPB { +impl ParseFilterData for DateFilterPB { + fn parse(condition: u8, content: String) -> Self { + let condition = + DateFilterConditionPB::try_from(condition).unwrap_or(DateFilterConditionPB::DateIs); + let mut date_filter = Self { condition, ..Default::default() }; - if let Ok(content) = DateFilterContentPB::from_str(&filter.content) { + if let Ok(content) = DateFilterContent::from_str(&content) { date_filter.start = content.start; date_filter.end = content.end; date_filter.timestamp = content.timestamp; diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/filter_changeset.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/filter_changeset.rs index 05a0fbd4ea28b..06f18be2892bf 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/filter_changeset.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/filter_changeset.rs @@ -1,54 +1,22 @@ -use crate::entities::FilterPB; use flowy_derive::ProtoBuf; +use crate::entities::RepeatedFilterPB; +use crate::services::filter::Filter; + #[derive(Debug, Default, ProtoBuf)] pub struct FilterChangesetNotificationPB { #[pb(index = 1)] pub view_id: String, #[pb(index = 2)] - pub insert_filters: Vec, - - #[pb(index = 3)] - pub delete_filters: Vec, - - #[pb(index = 4)] - pub update_filters: Vec, -} - -#[derive(Debug, Default, ProtoBuf)] -pub struct UpdatedFilter { - #[pb(index = 1)] - pub filter_id: String, - - #[pb(index = 2, one_of)] - pub filter: Option, + pub filters: RepeatedFilterPB, } impl FilterChangesetNotificationPB { - pub fn from_insert(view_id: &str, filters: Vec) -> Self { - Self { - view_id: view_id.to_string(), - insert_filters: filters, - delete_filters: Default::default(), - update_filters: Default::default(), - } - } - pub fn from_delete(view_id: &str, filters: Vec) -> Self { - Self { - view_id: view_id.to_string(), - insert_filters: Default::default(), - delete_filters: filters, - update_filters: Default::default(), - } - } - - pub fn from_update(view_id: &str, filters: Vec) -> Self { + pub fn from_filters(view_id: &str, filters: &Vec) -> Self { Self { view_id: view_id.to_string(), - insert_filters: Default::default(), - delete_filters: Default::default(), - update_filters: filters, + filters: filters.into(), } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs index d628a13801322..7840bd4ff626b 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs @@ -3,6 +3,7 @@ mod checklist_filter; mod date_filter; mod filter_changeset; mod number_filter; +mod relation_filter; mod select_option_filter; mod text_filter; mod util; @@ -12,6 +13,7 @@ pub use checklist_filter::*; pub use date_filter::*; pub use filter_changeset::*; pub use number_filter::*; +pub use relation_filter::*; pub use select_option_filter::*; pub use text_filter::*; pub use util::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/number_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/number_filter.rs index 17f43c8640061..6ea5e9ac7e4b3 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/number_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/number_filter.rs @@ -1,7 +1,7 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use crate::services::filter::{Filter, FromFilterString}; +use crate::services::filter::ParseFilterData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct NumberFilterPB { @@ -49,24 +49,12 @@ impl std::convert::TryFrom for NumberFilterConditionPB { } } -impl FromFilterString for NumberFilterPB { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized, - { +impl ParseFilterData for NumberFilterPB { + fn parse(condition: u8, content: String) -> Self { NumberFilterPB { - condition: NumberFilterConditionPB::try_from(filter.condition as u8) + condition: NumberFilterConditionPB::try_from(condition) .unwrap_or(NumberFilterConditionPB::Equal), - content: filter.content.clone(), - } - } -} -impl std::convert::From<&Filter> for NumberFilterPB { - fn from(filter: &Filter) -> Self { - NumberFilterPB { - condition: NumberFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(NumberFilterConditionPB::Equal), - content: filter.content.clone(), + content, } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs new file mode 100644 index 0000000000000..1a186eb0383a1 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs @@ -0,0 +1,22 @@ +use collab_database::{fields::Field, rows::Cell}; +use flowy_derive::ProtoBuf; + +use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct RelationFilterPB { + #[pb(index = 1)] + pub condition: i64, +} + +impl ParseFilterData for RelationFilterPB { + fn parse(_condition: u8, _content: String) -> Self { + RelationFilterPB { condition: 0 } + } +} + +impl PreFillCellsWithFilter for RelationFilterPB { + fn get_compliant_cell(&self, _field: &Field) -> (Option, bool) { + (None, false) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs index 86698b39048a2..1643116ccb687 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs @@ -1,69 +1,60 @@ +use std::str::FromStr; + use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use crate::services::field::SelectOptionIds; -use crate::services::filter::{Filter, FromFilterString}; +use crate::services::{field::SelectOptionIds, filter::ParseFilterData}; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct SelectOptionFilterPB { #[pb(index = 1)] - pub condition: SelectOptionConditionPB, + pub condition: SelectOptionFilterConditionPB, #[pb(index = 2)] pub option_ids: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum)] +#[derive(Debug, Default, Clone, PartialEq, Eq, ProtoBuf_Enum)] #[repr(u8)] -#[derive(Default)] -pub enum SelectOptionConditionPB { +pub enum SelectOptionFilterConditionPB { #[default] OptionIs = 0, OptionIsNot = 1, - OptionIsEmpty = 2, - OptionIsNotEmpty = 3, + OptionContains = 2, + OptionDoesNotContain = 3, + OptionIsEmpty = 4, + OptionIsNotEmpty = 5, } -impl std::convert::From for u32 { - fn from(value: SelectOptionConditionPB) -> Self { +impl From for u32 { + fn from(value: SelectOptionFilterConditionPB) -> Self { value as u32 } } -impl std::convert::TryFrom for SelectOptionConditionPB { +impl TryFrom for SelectOptionFilterConditionPB { type Error = ErrorCode; fn try_from(value: u8) -> Result { match value { - 0 => Ok(SelectOptionConditionPB::OptionIs), - 1 => Ok(SelectOptionConditionPB::OptionIsNot), - 2 => Ok(SelectOptionConditionPB::OptionIsEmpty), - 3 => Ok(SelectOptionConditionPB::OptionIsNotEmpty), + 0 => Ok(SelectOptionFilterConditionPB::OptionIs), + 1 => Ok(SelectOptionFilterConditionPB::OptionIsNot), + 2 => Ok(SelectOptionFilterConditionPB::OptionContains), + 3 => Ok(SelectOptionFilterConditionPB::OptionDoesNotContain), + 4 => Ok(SelectOptionFilterConditionPB::OptionIsEmpty), + 5 => Ok(SelectOptionFilterConditionPB::OptionIsNotEmpty), _ => Err(ErrorCode::InvalidParams), } } } -impl FromFilterString for SelectOptionFilterPB { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized, - { - let ids = SelectOptionIds::from(filter.content.clone()); - SelectOptionFilterPB { - condition: SelectOptionConditionPB::try_from(filter.condition as u8) - .unwrap_or(SelectOptionConditionPB::OptionIs), - option_ids: ids.into_inner(), - } - } -} - -impl std::convert::From<&Filter> for SelectOptionFilterPB { - fn from(filter: &Filter) -> Self { - let ids = SelectOptionIds::from(filter.content.clone()); - SelectOptionFilterPB { - condition: SelectOptionConditionPB::try_from(filter.condition as u8) - .unwrap_or(SelectOptionConditionPB::OptionIs), - option_ids: ids.into_inner(), +impl ParseFilterData for SelectOptionFilterPB { + fn parse(condition: u8, content: String) -> Self { + Self { + condition: SelectOptionFilterConditionPB::try_from(condition) + .unwrap_or(SelectOptionFilterConditionPB::OptionIs), + option_ids: SelectOptionIds::from_str(&content) + .unwrap_or_default() + .into_inner(), } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/text_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/text_filter.rs index 0956fdb894fbb..d3a2b8988330a 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/text_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/text_filter.rs @@ -1,7 +1,7 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use crate::services::filter::{Filter, FromFilterString}; +use crate::services::filter::ParseFilterData; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct TextFilterPB { @@ -12,17 +12,16 @@ pub struct TextFilterPB { pub content: String, } -#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum)] +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] #[repr(u8)] -#[derive(Default)] pub enum TextFilterConditionPB { #[default] - Is = 0, - IsNot = 1, - Contains = 2, - DoesNotContain = 3, - StartsWith = 4, - EndsWith = 5, + TextIs = 0, + TextIsNot = 1, + TextContains = 2, + TextDoesNotContain = 3, + TextStartsWith = 4, + TextEndsWith = 5, TextIsEmpty = 6, TextIsNotEmpty = 7, } @@ -38,12 +37,12 @@ impl std::convert::TryFrom for TextFilterConditionPB { fn try_from(value: u8) -> Result { match value { - 0 => Ok(TextFilterConditionPB::Is), - 1 => Ok(TextFilterConditionPB::IsNot), - 2 => Ok(TextFilterConditionPB::Contains), - 3 => Ok(TextFilterConditionPB::DoesNotContain), - 4 => Ok(TextFilterConditionPB::StartsWith), - 5 => Ok(TextFilterConditionPB::EndsWith), + 0 => Ok(TextFilterConditionPB::TextIs), + 1 => Ok(TextFilterConditionPB::TextIsNot), + 2 => Ok(TextFilterConditionPB::TextContains), + 3 => Ok(TextFilterConditionPB::TextDoesNotContain), + 4 => Ok(TextFilterConditionPB::TextStartsWith), + 5 => Ok(TextFilterConditionPB::TextEndsWith), 6 => Ok(TextFilterConditionPB::TextIsEmpty), 7 => Ok(TextFilterConditionPB::TextIsNotEmpty), _ => Err(ErrorCode::InvalidParams), @@ -51,25 +50,12 @@ impl std::convert::TryFrom for TextFilterConditionPB { } } -impl FromFilterString for TextFilterPB { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized, - { - TextFilterPB { - condition: TextFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(TextFilterConditionPB::Is), - content: filter.content.clone(), - } - } -} - -impl std::convert::From<&Filter> for TextFilterPB { - fn from(filter: &Filter) -> Self { - TextFilterPB { - condition: TextFilterConditionPB::try_from(filter.condition as u8) - .unwrap_or(TextFilterConditionPB::Is), - content: filter.content.clone(), +impl ParseFilterData for TextFilterPB { + fn parse(condition: u8, content: String) -> Self { + Self { + condition: TextFilterConditionPB::try_from(condition) + .unwrap_or(TextFilterConditionPB::TextIs), + content, } } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs index 4d2d9283c7839..6d48abf0e8c47 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs @@ -1,56 +1,162 @@ use std::convert::TryInto; -use std::sync::Arc; use bytes::Bytes; -use collab_database::fields::Field; -use flowy_derive::ProtoBuf; +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; +use lib_infra::box_any::BoxAny; +use protobuf::ProtobufError; use validator::Validate; -use crate::entities::parser::NotEmptyStr; use crate::entities::{ - CheckboxFilterPB, ChecklistFilterPB, DateFilterContentPB, DateFilterPB, FieldType, - NumberFilterPB, SelectOptionFilterPB, TextFilterPB, + CheckboxFilterPB, ChecklistFilterPB, DateFilterPB, FieldType, NumberFilterPB, RelationFilterPB, + SelectOptionFilterPB, TextFilterPB, }; -use crate::services::field::SelectOptionIds; -use crate::services::filter::Filter; +use crate::services::filter::{Filter, FilterChangeset, FilterInner}; -#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +#[derive(Debug, Default, Clone, ProtoBuf_Enum, Eq, PartialEq, Copy)] +#[repr(u8)] +pub enum FilterType { + #[default] + Data = 0, + And = 1, + Or = 2, +} + +impl From<&FilterInner> for FilterType { + fn from(value: &FilterInner) -> Self { + match value { + FilterInner::And { .. } => Self::And, + FilterInner::Or { .. } => Self::Or, + FilterInner::Data { .. } => Self::Data, + } + } +} + +#[derive(Debug, Default, Clone, ProtoBuf, Eq, PartialEq)] pub struct FilterPB { #[pb(index = 1)] pub id: String, #[pb(index = 2)] - pub field_id: String, + pub filter_type: FilterType, #[pb(index = 3)] + pub children: Vec, + + #[pb(index = 4, one_of)] + pub data: Option, +} + +#[derive(Debug, Default, Clone, ProtoBuf, Eq, PartialEq)] +pub struct FilterDataPB { + #[pb(index = 1)] + pub field_id: String, + + #[pb(index = 2)] pub field_type: FieldType, - #[pb(index = 4)] + #[pb(index = 3)] pub data: Vec, } -impl std::convert::From<&Filter> for FilterPB { +impl From<&Filter> for FilterPB { fn from(filter: &Filter) -> Self { - let bytes: Bytes = match filter.field_type { - FieldType::RichText => TextFilterPB::from(filter).try_into().unwrap(), - FieldType::Number => NumberFilterPB::from(filter).try_into().unwrap(), + match &filter.inner { + FilterInner::And { children } | FilterInner::Or { children } => Self { + id: filter.id.clone(), + filter_type: FilterType::from(&filter.inner), + children: children.iter().map(FilterPB::from).collect(), + data: None, + }, + FilterInner::Data { + field_id, + field_type, + condition_and_content, + } => { + let bytes: Result = match field_type { + FieldType::RichText | FieldType::URL => condition_and_content + .cloned::() + .unwrap() + .try_into(), + FieldType::Number => condition_and_content + .cloned::() + .unwrap() + .try_into(), + FieldType::DateTime | FieldType::CreatedTime | FieldType::LastEditedTime => { + condition_and_content + .cloned::() + .unwrap() + .try_into() + }, + FieldType::SingleSelect | FieldType::MultiSelect => condition_and_content + .cloned::() + .unwrap() + .try_into(), + FieldType::Checklist => condition_and_content + .cloned::() + .unwrap() + .try_into(), + FieldType::Checkbox => condition_and_content + .cloned::() + .unwrap() + .try_into(), + + FieldType::Relation => condition_and_content + .cloned::() + .unwrap() + .try_into(), + }; + + Self { + id: filter.id.clone(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: field_id.clone(), + field_type: *field_type, + data: bytes.unwrap().to_vec(), + }), + } + }, + } + } +} + +impl TryFrom for FilterInner { + type Error = ErrorCode; + + fn try_from(value: FilterDataPB) -> Result { + let bytes: &[u8] = value.data.as_ref(); + let condition_and_content = match value.field_type { + FieldType::RichText | FieldType::URL => { + BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::Checkbox => { + BoxAny::new(CheckboxFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::Number => { + BoxAny::new(NumberFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, FieldType::DateTime | FieldType::LastEditedTime | FieldType::CreatedTime => { - DateFilterPB::from(filter).try_into().unwrap() + BoxAny::new(DateFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::SingleSelect | FieldType::MultiSelect => { + BoxAny::new(SelectOptionFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::Checklist => { + BoxAny::new(ChecklistFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, + FieldType::Relation => { + BoxAny::new(RelationFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) }, - FieldType::SingleSelect => SelectOptionFilterPB::from(filter).try_into().unwrap(), - FieldType::MultiSelect => SelectOptionFilterPB::from(filter).try_into().unwrap(), - FieldType::Checklist => ChecklistFilterPB::from(filter).try_into().unwrap(), - FieldType::Checkbox => CheckboxFilterPB::from(filter).try_into().unwrap(), - FieldType::URL => TextFilterPB::from(filter).try_into().unwrap(), }; - Self { - id: filter.id.clone(), - field_id: filter.field_id.clone(), - field_type: filter.field_type, - data: bytes.to_vec(), - } + + Ok(Self::Data { + field_id: value.field_id, + field_type: value.field_type, + condition_and_content, + }) } } @@ -60,152 +166,109 @@ pub struct RepeatedFilterPB { pub items: Vec, } -impl std::convert::From>> for RepeatedFilterPB { - fn from(filters: Vec>) -> Self { +impl From<&Vec> for RepeatedFilterPB { + fn from(filters: &Vec) -> Self { RepeatedFilterPB { - items: filters.into_iter().map(|rev| rev.as_ref().into()).collect(), + items: filters.iter().map(|filter| filter.into()).collect(), } } } -impl std::convert::From> for RepeatedFilterPB { +impl From> for RepeatedFilterPB { fn from(items: Vec) -> Self { Self { items } } } #[derive(ProtoBuf, Debug, Default, Clone, Validate)] -pub struct DeleteFilterPayloadPB { - #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] - pub field_id: String, +pub struct InsertFilterPB { + /// If None, the filter will be the root of a new filter tree + #[pb(index = 1, one_of)] + #[validate(custom = "crate::entities::utils::validate_filter_id")] + pub parent_filter_id: Option, #[pb(index = 2)] - pub field_type: FieldType, + pub data: FilterDataPB, +} - #[pb(index = 3)] +#[derive(ProtoBuf, Debug, Default, Clone, Validate)] +pub struct UpdateFilterTypePB { + #[pb(index = 1)] #[validate(custom = "crate::entities::utils::validate_filter_id")] pub filter_id: String, - #[pb(index = 4)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] - pub view_id: String, + #[pb(index = 2)] + pub filter_type: FilterType, } #[derive(ProtoBuf, Debug, Default, Clone, Validate)] -pub struct UpdateFilterPayloadPB { +pub struct UpdateFilterDataPB { #[pb(index = 1)] - #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] - pub field_id: String, + #[validate(custom = "crate::entities::utils::validate_filter_id")] + pub filter_id: String, #[pb(index = 2)] - pub field_type: FieldType, + pub data: FilterDataPB, +} - /// Create a new filter if the filter_id is None - #[pb(index = 3, one_of)] +#[derive(ProtoBuf, Debug, Default, Clone, Validate)] +pub struct DeleteFilterPB { + #[pb(index = 1)] #[validate(custom = "crate::entities::utils::validate_filter_id")] - pub filter_id: Option, - - #[pb(index = 4)] - pub data: Vec, + pub filter_id: String, - #[pb(index = 5)] + #[pb(index = 2)] #[validate(custom = "lib_infra::validator_fn::required_not_empty_str")] - pub view_id: String, -} - -impl UpdateFilterPayloadPB { - #[allow(dead_code)] - pub fn new>( - view_id: &str, - field: &Field, - data: T, - ) -> Self { - let data = data.try_into().unwrap_or_else(|_| Bytes::new()); - let field_type = FieldType::from(field.field_type); - Self { - view_id: view_id.to_owned(), - field_id: field.id.clone(), - field_type, - filter_id: None, - data: data.to_vec(), - } + pub field_id: String, +} + +impl TryFrom for FilterChangeset { + type Error = ErrorCode; + + fn try_from(value: InsertFilterPB) -> Result { + let changeset = Self::Insert { + parent_filter_id: value.parent_filter_id, + data: value.data.try_into()?, + }; + + Ok(changeset) } } -impl TryInto for UpdateFilterPayloadPB { +impl TryFrom for FilterChangeset { type Error = ErrorCode; - fn try_into(self) -> Result { - let view_id = NotEmptyStr::parse(self.view_id) - .map_err(|_| ErrorCode::DatabaseViewIdIsEmpty)? - .0; - - let field_id = NotEmptyStr::parse(self.field_id) - .map_err(|_| ErrorCode::FieldIdIsEmpty)? - .0; - let filter_id = match self.filter_id { - None => None, - Some(filter_id) => Some( - NotEmptyStr::parse(filter_id) - .map_err(|_| ErrorCode::FilterIdIsEmpty)? - .0, - ), + fn try_from(value: UpdateFilterDataPB) -> Result { + let changeset = Self::UpdateData { + filter_id: value.filter_id, + data: value.data.try_into()?, }; - let condition; - let mut content = "".to_string(); - let bytes: &[u8] = self.data.as_ref(); - match self.field_type { - FieldType::RichText | FieldType::URL => { - let filter = TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?; - condition = filter.condition as u8; - content = filter.content; - }, - FieldType::Checkbox => { - let filter = CheckboxFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?; - condition = filter.condition as u8; - }, - FieldType::Number => { - let filter = NumberFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?; - condition = filter.condition as u8; - content = filter.content; - }, - FieldType::DateTime | FieldType::LastEditedTime | FieldType::CreatedTime => { - let filter = DateFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?; - condition = filter.condition as u8; - content = DateFilterContentPB { - start: filter.start, - end: filter.end, - timestamp: filter.timestamp, - } - .to_string(); - }, - FieldType::SingleSelect | FieldType::MultiSelect | FieldType::Checklist => { - let filter = SelectOptionFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?; - condition = filter.condition as u8; - content = SelectOptionIds::from(filter.option_ids).to_string(); - }, + Ok(changeset) + } +} + +impl TryFrom for FilterChangeset { + type Error = ErrorCode; + + fn try_from(value: UpdateFilterTypePB) -> Result { + if matches!(value.filter_type, FilterType::Data) { + return Err(ErrorCode::InvalidParams); } - Ok(UpdateFilterParams { - view_id, - field_id, - filter_id, - field_type: self.field_type, - condition: condition as i64, - content, - }) + let changeset = Self::UpdateType { + filter_id: value.filter_id, + filter_type: value.filter_type, + }; + Ok(changeset) } } -#[derive(Debug)] -pub struct UpdateFilterParams { - pub view_id: String, - pub field_id: String, - /// Create a new filter if the filter_id is None - pub filter_id: Option, - pub field_type: FieldType, - pub condition: i64, - pub content: String, +impl From for FilterChangeset { + fn from(value: DeleteFilterPB) -> Self { + Self::Delete { + filter_id: value.filter_id, + field_id: value.field_id, + } + } } 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 05cc0c272395d..9f40685702658 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 @@ -76,9 +76,6 @@ pub struct GroupPB { #[pb(index = 2)] pub group_id: String, - #[pb(index = 3)] - pub group_name: String, - #[pb(index = 4)] pub rows: Vec, @@ -94,7 +91,6 @@ impl std::convert::From for GroupPB { Self { field_id: group_data.field_id, group_id: group_data.id, - group_name: group_data.name, rows: group_data.rows.into_iter().map(RowMetaPB::from).collect(), is_default: group_data.is_default, is_visible: group_data.is_visible, diff --git a/frontend/rust-lib/flowy-database2/src/entities/group_entities/group_changeset.rs b/frontend/rust-lib/flowy-database2/src/entities/group_entities/group_changeset.rs index 59bab13169ab5..f002e93bd26e1 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/group_entities/group_changeset.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/group_entities/group_changeset.rs @@ -11,16 +11,13 @@ pub struct GroupRowsNotificationPB { #[pb(index = 1)] pub group_id: String, - #[pb(index = 2, one_of)] - pub group_name: Option, - - #[pb(index = 3)] + #[pb(index = 2)] pub inserted_rows: Vec, - #[pb(index = 4)] + #[pb(index = 3)] pub deleted_rows: Vec, - #[pb(index = 5)] + #[pb(index = 4)] pub updated_rows: Vec, } @@ -43,10 +40,7 @@ impl std::fmt::Display for GroupRowsNotificationPB { impl GroupRowsNotificationPB { pub fn is_empty(&self) -> bool { - self.group_name.is_none() - && self.inserted_rows.is_empty() - && self.deleted_rows.is_empty() - && self.updated_rows.is_empty() + self.inserted_rows.is_empty() && self.deleted_rows.is_empty() && self.updated_rows.is_empty() } pub fn new(group_id: String) -> Self { @@ -56,14 +50,6 @@ impl GroupRowsNotificationPB { } } - pub fn name(group_id: String, name: &str) -> Self { - Self { - group_id, - group_name: Some(name.to_owned()), - ..Default::default() - } - } - pub fn insert(group_id: String, inserted_rows: Vec) -> Self { Self { group_id, diff --git a/frontend/rust-lib/flowy-database2/src/entities/macros.rs b/frontend/rust-lib/flowy-database2/src/entities/macros.rs index 03b9b2021d32a..032785dc0f635 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/macros.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/macros.rs @@ -14,8 +14,9 @@ macro_rules! impl_into_field_type { 7 => FieldType::Checklist, 8 => FieldType::LastEditedTime, 9 => FieldType::CreatedTime, + 10 => FieldType::Relation, _ => { - tracing::error!("🔴Can't parser FieldType from value: {}", ty); + tracing::error!("🔴Can't parse FieldType from value: {}", ty); FieldType::RichText }, } @@ -34,7 +35,7 @@ macro_rules! impl_into_field_visibility { 1 => FieldVisibility::HideWhenEmpty, 2 => FieldVisibility::AlwaysHidden, _ => { - tracing::error!("🔴Can't parser FieldVisibility from value: {}", ty); + tracing::error!("🔴Can't parse FieldVisibility from value: {}", ty); FieldVisibility::AlwaysShown }, } @@ -54,6 +55,9 @@ macro_rules! impl_into_calculation_type { 2 => CalculationType::Median, 3 => CalculationType::Min, 4 => CalculationType::Sum, + 5 => CalculationType::Count, + 6 => CalculationType::CountEmpty, + 7 => CalculationType::CountNonEmpty, _ => { tracing::error!("🔴 Can't parse CalculationType from value: {}", ty); CalculationType::Average 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 42e934183926b..597bb293cc197 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs @@ -1,10 +1,13 @@ use std::collections::HashMap; use collab_database::rows::{Row, RowDetail, RowId}; -use collab_database::views::{OrderObjectPosition, RowOrder}; +use collab_database::views::RowOrder; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; +use lib_infra::validator_fn::required_not_empty_str; +use serde::{Deserialize, Serialize}; +use validator::Validate; use crate::entities::parser::NotEmptyStr; use crate::entities::position_entities::OrderObjectPositionPB; @@ -47,7 +50,7 @@ impl From for RowPB { } } -#[derive(Debug, Default, Clone, ProtoBuf)] +#[derive(Debug, Default, Clone, ProtoBuf, Serialize, Deserialize)] pub struct RowMetaPB { #[pb(index = 1)] pub id: String, @@ -335,46 +338,25 @@ impl TryInto for RowIdPB { } } -#[derive(ProtoBuf, Default)] +#[derive(ProtoBuf, Default, Validate)] pub struct CreateRowPayloadPB { #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] pub view_id: String, #[pb(index = 2)] pub row_position: OrderObjectPositionPB, #[pb(index = 3, one_of)] + #[validate(custom = "required_not_empty_str")] pub group_id: Option, - #[pb(index = 4, one_of)] - pub data: Option, -} - -#[derive(ProtoBuf, Default)] -pub struct RowDataPB { - #[pb(index = 1)] - pub cell_data_by_field_id: HashMap, + #[pb(index = 4)] + pub data: HashMap, } #[derive(Default)] pub struct CreateRowParams { - pub view_id: String, - pub row_position: OrderObjectPosition, - pub group_id: Option, - pub cell_data_by_field_id: Option>, -} - -impl TryInto for CreateRowPayloadPB { - type Error = ErrorCode; - - fn try_into(self) -> Result { - let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::ViewIdIsInvalid)?; - let position = self.row_position.try_into()?; - Ok(CreateRowParams { - view_id: view_id.0, - row_position: position, - group_id: self.group_id, - cell_data_by_field_id: self.data.map(|data| data.cell_data_by_field_id), - }) - } + pub collab_params: collab_database::rows::CreateRowParams, + pub open_after_create: bool, } 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 e1cefc07cc75a..2cb2f3613d5be 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs @@ -9,9 +9,9 @@ use validator::Validate; use crate::entities::parser::NotEmptyStr; use crate::entities::{ - CalendarLayoutSettingPB, DeleteFilterPayloadPB, DeleteSortPayloadPB, RepeatedFieldSettingsPB, - RepeatedFilterPB, RepeatedGroupSettingPB, RepeatedSortPB, UpdateFilterPayloadPB, UpdateGroupPB, - UpdateSortPayloadPB, + CalendarLayoutSettingPB, DeleteFilterPB, DeleteSortPayloadPB, InsertFilterPB, + RepeatedFieldSettingsPB, RepeatedFilterPB, RepeatedGroupSettingPB, RepeatedSortPB, + UpdateFilterDataPB, UpdateFilterTypePB, UpdateGroupPB, UpdateSortPayloadPB, }; use crate::services::setting::{BoardLayoutSetting, CalendarLayoutSetting}; @@ -79,26 +79,34 @@ pub struct DatabaseSettingChangesetPB { #[pb(index = 3, one_of)] #[validate] - pub update_filter: Option, + pub insert_filter: Option, #[pb(index = 4, one_of)] #[validate] - pub delete_filter: Option, + pub update_filter_type: Option, #[pb(index = 5, one_of)] #[validate] - pub update_group: Option, + pub update_filter_data: Option, #[pb(index = 6, one_of)] #[validate] - pub update_sort: Option, + pub delete_filter: Option, #[pb(index = 7, one_of)] #[validate] - pub reorder_sort: Option, + pub update_group: Option, #[pb(index = 8, one_of)] #[validate] + pub update_sort: Option, + + #[pb(index = 9, one_of)] + #[validate] + pub reorder_sort: Option, + + #[pb(index = 10, one_of)] + #[validate] pub delete_sort: Option, } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs index 45e7bfb8a3096..f97afeb75bce0 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs @@ -2,6 +2,7 @@ mod checkbox_entities; mod checklist_entities; mod date_entities; mod number_entities; +mod relation_entities; mod select_option_entities; mod text_entities; mod timestamp_entities; @@ -11,6 +12,7 @@ pub use checkbox_entities::*; pub use checklist_entities::*; pub use date_entities::*; pub use number_entities::*; +pub use relation_entities::*; pub use select_option_entities::*; pub use text_entities::*; pub use timestamp_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs new file mode 100644 index 0000000000000..bebcb6189efe4 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs @@ -0,0 +1,87 @@ +use flowy_derive::ProtoBuf; + +use crate::entities::CellIdPB; +use crate::services::field::{RelationCellData, RelationTypeOption}; + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RelationCellDataPB { + #[pb(index = 1)] + pub row_ids: Vec, +} + +impl From for RelationCellDataPB { + fn from(data: RelationCellData) -> Self { + Self { + row_ids: data.row_ids.into_iter().map(Into::into).collect(), + } + } +} + +impl From for RelationCellData { + fn from(data: RelationCellDataPB) -> Self { + Self { + row_ids: data.row_ids.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RelationCellChangesetPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub cell_id: CellIdPB, + + #[pb(index = 3)] + pub inserted_row_ids: Vec, + + #[pb(index = 4)] + pub removed_row_ids: Vec, +} + +#[derive(Clone, Debug, Default, ProtoBuf)] +pub struct RelationTypeOptionPB { + #[pb(index = 1)] + pub database_id: String, +} + +impl From for RelationTypeOptionPB { + fn from(value: RelationTypeOption) -> Self { + RelationTypeOptionPB { + database_id: value.database_id, + } + } +} + +impl From for RelationTypeOption { + fn from(value: RelationTypeOptionPB) -> Self { + RelationTypeOption { + database_id: value.database_id, + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RelatedRowDataPB { + #[pb(index = 1)] + pub row_id: String, + + #[pb(index = 2)] + pub name: String, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RepeatedRelatedRowDataPB { + #[pb(index = 1)] + pub rows: Vec, +} + +#[derive(Debug, Default, Clone, ProtoBuf)] +pub struct RepeatedRowIdPB { + #[pb(index = 1)] + pub database_id: String, + + #[pb(index = 2)] + pub row_ids: Vec, +} diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index b92bfc9484c94..78528f255f084 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -1,19 +1,18 @@ use std::sync::{Arc, Weak}; -use collab_database::database::gen_row_id; use collab_database::rows::RowId; use lib_infra::box_any::BoxAny; use tokio::sync::oneshot; +use tracing::error; use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::{af_spawn, data_result_ok, AFPluginData, AFPluginState, DataResult}; -use lib_infra::util::timestamp; use crate::entities::*; use crate::manager::DatabaseManager; -use crate::services::cell::CellBuilder; use crate::services::field::{ - type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, SelectOptionCellChangeset, + type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, RelationCellChangeset, + SelectOptionCellChangeset, }; use crate::services::field_settings::FieldSettingsChangesetParams; use crate::services::group::GroupChangeset; @@ -90,14 +89,28 @@ pub(crate) async fn update_database_setting_handler( let params = data.try_into_inner()?; let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - if let Some(update_filter) = params.update_filter { + if let Some(payload) = params.insert_filter { database_editor - .create_or_update_filter(update_filter.try_into()?) + .modify_view_filters(¶ms.view_id, payload.try_into()?) .await?; } - if let Some(delete_filter) = params.delete_filter { - database_editor.delete_filter(delete_filter).await?; + if let Some(payload) = params.update_filter_type { + database_editor + .modify_view_filters(¶ms.view_id, payload.try_into()?) + .await?; + } + + if let Some(payload) = params.update_filter_data { + database_editor + .modify_view_filters(¶ms.view_id, payload.try_into()?) + .await?; + } + + if let Some(payload) = params.delete_filter { + database_editor + .modify_view_filters(¶ms.view_id, payload.into()) + .await?; } if let Some(update_sort) = params.update_sort { @@ -244,6 +257,20 @@ pub(crate) async fn delete_field_handler( Ok(()) } +#[tracing::instrument(level = "trace", skip(data, manager), err)] +pub(crate) async fn clear_field_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + let params: FieldIdParams = data.into_inner().try_into()?; + let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + database_editor + .clear_field(¶ms.view_id, ¶ms.field_id) + .await?; + Ok(()) +} + #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn switch_to_field_handler( data: AFPluginData, @@ -281,7 +308,7 @@ pub(crate) async fn duplicate_field_handler( manager: AFPluginState>, ) -> Result<(), FlowyError> { let manager = upgrade_manager(manager)?; - let params: FieldIdParams = data.into_inner().try_into()?; + let params: DuplicateFieldPayloadPB = data.into_inner(); let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; database_editor .duplicate_field(¶ms.view_id, ¶ms.field_id) @@ -378,7 +405,7 @@ pub(crate) async fn duplicate_row_handler( let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; database_editor .duplicate_row(¶ms.view_id, ¶ms.row_id) - .await; + .await?; Ok(()) } @@ -402,27 +429,12 @@ pub(crate) async fn create_row_handler( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let params: CreateRowParams = data.into_inner().try_into()?; - let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; - let fields = database_editor.get_fields(¶ms.view_id, None); - let cells = - CellBuilder::with_cells(params.cell_data_by_field_id.unwrap_or_default(), &fields).build(); - let view_id = params.view_id; - let group_id = params.group_id; - let params = collab_database::rows::CreateRowParams { - id: gen_row_id(), - cells, - height: 60, - visibility: true, - row_position: params.row_position, - timestamp: timestamp(), - }; - match database_editor - .create_row(&view_id, group_id, params) - .await? - { - None => Err(FlowyError::internal().with_context("Create row fail")), + let params = data.try_into_inner()?; + let database_editor = manager.get_database_with_view_id(¶ms.view_id).await?; + + match database_editor.create_row(params).await? { Some(row) => data_result_ok(RowMetaPB::from(row)), + None => Err(FlowyError::internal().with_context("Error creating row")), } } @@ -670,7 +682,7 @@ pub(crate) async fn update_group_handler( let (tx, rx) = oneshot::channel(); af_spawn(async move { let result = database_editor - .update_group(&view_id, vec![group_changeset].into()) + .update_group(&view_id, vec![group_changeset]) .await; let _ = tx.send(result); }); @@ -744,7 +756,22 @@ pub(crate) async fn get_databases_handler( manager: AFPluginState>, ) -> DataResult { let manager = upgrade_manager(manager)?; - let data = manager.get_all_databases_description().await; + let metas = manager.get_all_databases_meta().await; + + let mut items = Vec::with_capacity(metas.len()); + for meta in metas { + match manager.get_database_inline_view_id(&meta.database_id).await { + Ok(view_id) => items.push(DatabaseMetaPB { + database_id: meta.database_id, + inline_view_id: view_id, + }), + Err(err) => { + error!(?err); + }, + } + } + + let data = RepeatedDatabaseDescriptionPB { items }; data_result_ok(data) } @@ -978,3 +1005,81 @@ pub(crate) async fn remove_calculation_handler( Ok(()) } + +pub(crate) async fn get_related_database_ids_handler( + _data: AFPluginData, + _manager: AFPluginState>, +) -> FlowyResult<()> { + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_relation_cell_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let manager = upgrade_manager(manager)?; + let params: RelationCellChangesetPB = data.into_inner(); + let view_id = parser::NotEmptyStr::parse(params.view_id) + .map_err(|_| flowy_error::ErrorCode::DatabaseViewIdIsEmpty)? + .0; + let cell_id: CellIdParams = params.cell_id.try_into()?; + let params = RelationCellChangeset { + inserted_row_ids: params + .inserted_row_ids + .into_iter() + .map(Into::into) + .collect(), + removed_row_ids: params.removed_row_ids.into_iter().map(Into::into).collect(), + }; + + let database_editor = manager.get_database_with_view_id(&view_id).await?; + + // // get the related database + // let related_database_id = database_editor + // .get_related_database_id(&cell_id.field_id) + // .await?; + // let related_database_editor = manager.get_database(&related_database_id).await?; + + // // validate the changeset contents + // related_database_editor + // .validate_row_ids_exist(¶ms) + // .await?; + + // update the cell in the database + database_editor + .update_cell_with_changeset( + &view_id, + cell_id.row_id, + &cell_id.field_id, + BoxAny::new(params), + ) + .await?; + Ok(()) +} + +pub(crate) async fn get_related_row_datas_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let params: RepeatedRowIdPB = data.into_inner(); + let database_editor = manager.get_database(¶ms.database_id).await?; + let row_datas = database_editor + .get_related_rows(Some(¶ms.row_ids)) + .await?; + + data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas }) +} + +pub(crate) async fn get_related_database_rows_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> DataResult { + let manager = upgrade_manager(manager)?; + let database_id = data.into_inner().value; + let database_editor = manager.get_database(&database_id).await?; + let row_datas = database_editor.get_related_rows(None).await?; + + data_result_ok(RepeatedRelatedRowDataPB { rows: row_datas }) +} diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 94ab6d49edf4d..f753c34d5f725 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -27,6 +27,7 @@ pub fn init(database_manager: Weak) -> AFPlugin { .event(DatabaseEvent::UpdateField, update_field_handler) .event(DatabaseEvent::UpdateFieldTypeOption, update_field_type_option_handler) .event(DatabaseEvent::DeleteField, delete_field_handler) + .event(DatabaseEvent::ClearField, clear_field_handler) .event(DatabaseEvent::UpdateFieldType, switch_to_field_handler) .event(DatabaseEvent::DuplicateField, duplicate_field_handler) .event(DatabaseEvent::MoveField, move_field_handler) @@ -83,6 +84,11 @@ pub fn init(database_manager: Weak) -> AFPlugin { .event(DatabaseEvent::GetAllCalculations, get_all_calculations_handler) .event(DatabaseEvent::UpdateCalculation, update_calculation_handler) .event(DatabaseEvent::RemoveCalculation, remove_calculation_handler) + // Relation + .event(DatabaseEvent::GetRelatedDatabaseIds, get_related_database_ids_handler) + .event(DatabaseEvent::UpdateRelationCell, update_relation_cell_handler) + .event(DatabaseEvent::GetRelatedRowDatas, get_related_row_datas_handler) + .event(DatabaseEvent::GetRelatedDatabaseRows, get_related_database_rows_handler) } /// [DatabaseEvent] defines events that are used to interact with the Grid. You could check [this](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/protobuf) @@ -156,6 +162,11 @@ pub enum DatabaseEvent { #[event(input = "DeleteFieldPayloadPB")] DeleteField = 14, + /// [ClearField] event is used to clear all Cells in a Field. [ClearFieldPayloadPB] is the context that + /// is used to clear the field from the Database. + #[event(input = "ClearFieldPayloadPB")] + ClearField = 15, + /// [UpdateFieldType] event is used to update the current Field's type. /// It will insert a new FieldTypeOptionData if the new FieldType doesn't exist before, otherwise /// reuse the existing FieldTypeOptionData. You could check the [DatabaseRevisionPad] for more details. @@ -342,4 +353,22 @@ pub enum DatabaseEvent { #[event(input = "RemoveCalculationChangesetPB")] RemoveCalculation = 165, + + /// Currently unused. Get a list of database ids that this database relates + /// to. + #[event(input = "DatabaseViewIdPB", output = "RepeatedDatabaseIdPB")] + GetRelatedDatabaseIds = 170, + + /// Updates a relation cell, adding or removing links to rows in another + /// database + #[event(input = "RelationCellChangesetPB")] + UpdateRelationCell = 171, + + /// Get the names of the linked rows in a relation cell. + #[event(input = "RepeatedRowIdPB", output = "RepeatedRelatedRowDataPB")] + GetRelatedRowDatas = 172, + + /// Get the names of all the rows in a related database. + #[event(input = "DatabaseIdPB", output = "RepeatedRelatedRowDataPB")] + GetRelatedDatabaseRows = 173, } diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 7dc17d9e8d636..2d61efb89c238 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -2,17 +2,17 @@ use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::{Arc, Weak}; -use collab::core::collab::{CollabDocState, MutexCollab}; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab_database::blocks::BlockEvent; -use collab_database::database::{DatabaseData, MutexDatabase}; +use collab_database::database::{get_inline_view_id, DatabaseData, MutexDatabase}; use collab_database::error::DatabaseError; use collab_database::user::{ - CollabDocStateByOid, CollabFuture, DatabaseCollabService, WorkspaceDatabase, + CollabDocStateByOid, CollabFuture, DatabaseCollabService, DatabaseMeta, WorkspaceDatabase, }; use collab_database::views::{CreateDatabaseParams, CreateViewParams, DatabaseLayout}; use collab_entity::CollabType; use collab_plugins::local_storage::kv::KVTransactionDB; -use futures::executor::block_on; + use lru::LruCache; use tokio::sync::{Mutex, RwLock}; use tracing::{event, instrument, trace}; @@ -24,10 +24,7 @@ use flowy_error::{internal_error, FlowyError, FlowyResult}; use lib_dispatch::prelude::af_spawn; use lib_infra::priority_task::TaskDispatcher; -use crate::entities::{ - DatabaseDescriptionPB, DatabaseLayoutPB, DatabaseSnapshotPB, DidFetchRowPB, - RepeatedDatabaseDescriptionPB, -}; +use crate::entities::{DatabaseLayoutPB, DatabaseSnapshotPB, DidFetchRowPB}; use crate::notification::{send_notification, DatabaseNotification}; use crate::services::database::DatabaseEditor; use crate::services::database_view::DatabaseLayoutDepsResolver; @@ -101,7 +98,7 @@ impl DatabaseManager { }; let config = CollabPersistenceConfig::new().snapshot_per_update(100); - let mut workspace_database_doc_state = CollabDocState::default(); + let mut workspace_database_doc_state = DocStateSource::FromDisk; // If the workspace database not exist in disk, try to fetch from remote. if !self.is_collab_exist(uid, &collab_db, &workspace_database_object_id) { trace!("workspace database not exist, try to fetch from remote"); @@ -114,8 +111,8 @@ impl DatabaseManager { ) .await { - Ok(remote_doc_state) => { - workspace_database_doc_state = remote_doc_state; + Ok(doc_state) => { + workspace_database_doc_state = DocStateSource::FromDocState(doc_state); }, Err(err) => { return Err(FlowyError::record_not_found().with_context(format!( @@ -164,16 +161,27 @@ impl DatabaseManager { Ok(()) } - pub async fn get_all_databases_description(&self) -> RepeatedDatabaseDescriptionPB { + pub async fn get_database_inline_view_id(&self, database_id: &str) -> FlowyResult { + let wdb = self.get_workspace_database().await?; + let database_collab = wdb.get_database_collab(database_id).await.ok_or_else(|| { + FlowyError::record_not_found().with_context(format!("The database:{} not found", database_id)) + })?; + + let inline_view_id = get_inline_view_id(&database_collab.lock()).ok_or_else(|| { + FlowyError::record_not_found().with_context(format!( + "Can't find the inline view for database:{}", + database_id + )) + })?; + Ok(inline_view_id) + } + + pub async fn get_all_databases_meta(&self) -> Vec { let mut items = vec![]; if let Ok(wdb) = self.get_workspace_database().await { - items = wdb - .get_all_databases() - .into_iter() - .map(DatabaseDescriptionPB::from) - .collect(); + items = wdb.get_all_database_meta() } - RepeatedDatabaseDescriptionPB { items } + items } pub async fn track_database( @@ -415,7 +423,7 @@ impl DatabaseCollabService for UserDatabaseCollabServiceImpl { &self, object_id: &str, object_ty: CollabType, - ) -> CollabFuture> { + ) -> CollabFuture> { let workspace_id = self.workspace_id.clone(); let object_id = object_id.to_string(); let weak_cloud_service = Arc::downgrade(&self.cloud_service); @@ -423,13 +431,13 @@ impl DatabaseCollabService for UserDatabaseCollabServiceImpl { match weak_cloud_service.upgrade() { None => { tracing::warn!("Cloud service is dropped"); - Ok(vec![]) + Ok(DocStateSource::FromDocState(vec![])) }, Some(cloud_service) => { - let updates = cloud_service + let doc_state = cloud_service .get_database_object_doc_state(&object_id, object_ty, &workspace_id) .await?; - Ok(updates) + Ok(DocStateSource::FromDocState(doc_state)) }, } }) @@ -464,18 +472,20 @@ impl DatabaseCollabService for UserDatabaseCollabServiceImpl { object_id: &str, object_type: CollabType, collab_db: Weak, - collab_raw_data: CollabDocState, + collab_raw_data: DocStateSource, persistence_config: CollabPersistenceConfig, ) -> Arc { - block_on(self.collab_builder.build_with_config( - uid, - object_id, - object_type, - collab_db, - collab_raw_data, - persistence_config, - CollabBuilderConfig::default().sync_enable(true), - )) - .unwrap() + self + .collab_builder + .build_with_config( + uid, + object_id, + object_type, + collab_db, + collab_raw_data, + persistence_config, + CollabBuilderConfig::default().sync_enable(true), + ) + .unwrap() } } diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs index 849a4f9deb915..5e199b84ad68d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs @@ -1,7 +1,6 @@ use std::str::FromStr; use std::sync::Arc; -use collab::core::any_map::AnyMapExtension; use collab_database::fields::Field; use collab_database::rows::{Row, RowCell}; use flowy_error::FlowyResult; @@ -24,6 +23,7 @@ pub trait CalculationsDelegate: Send + Sync + 'static { fn get_cells_for_field(&self, view_id: &str, field_id: &str) -> Fut>>; fn get_field(&self, field_id: &str) -> Option; fn get_calculation(&self, view_id: &str, field_id: &str) -> Fut>>; + fn get_all_calculations(&self, view_id: &str) -> Fut>>>; fn update_calculation(&self, view_id: &str, calculation: Calculation); fn remove_calculation(&self, view_id: &str, calculation_id: &str); } @@ -77,7 +77,7 @@ impl CalculationsController { } } - #[tracing::instrument(name = "schedule_filter_task", level = "trace", skip(self))] + #[tracing::instrument(name = "schedule_calculation_task", level = "trace", skip(self))] async fn gen_task(&self, task_type: CalculationEvent, qos: QualityOfService) { let task_id = self.task_scheduler.read().await.next_task_id(); let task = Task::new( @@ -90,10 +90,10 @@ impl CalculationsController { } #[tracing::instrument( - name = "process_filter_task", + name = "process_calculation_task", level = "trace", skip_all, - fields(filter_result), + fields(calculation_result), err )] pub async fn process(&self, predicate: &str) -> FlowyResult<()> { @@ -161,7 +161,8 @@ impl CalculationsController { .await; if let Some(calculation) = calculation { - if new_field_type != FieldType::Number { + let calc_type: CalculationType = calculation.calculation_type.into(); + if !calc_type.is_allowed(new_field_type) { self .delegate .remove_calculation(&self.view_id, &calculation.id); @@ -227,25 +228,31 @@ impl CalculationsController { async fn handle_row_changed(&self, row: Row) { let cells = row.cells.iter(); - let mut updates = vec![]; + // In case there are calculations where empty cells are counted + // as a contribution to the value. + if cells.len() == 0 { + let calculations = self.delegate.get_all_calculations(&self.view_id).await; + for calculation in calculations.iter() { + let update = self.get_updated_calculation(calculation.clone()).await; + if let Some(update) = update { + updates.push(CalculationPB::from(&update)); + self.delegate.update_calculation(&self.view_id, update); + } + } + } + // Iterate each cell in the row for cell in cells { let field_id = cell.0; - let value = cell.1.value().get("data"); - - // Only continue if there is a value in the cell - if let Some(_cell_value) = value { - let calculation = self.delegate.get_calculation(&self.view_id, field_id).await; + let calculation = self.delegate.get_calculation(&self.view_id, field_id).await; + if let Some(calculation) = calculation { + let update = self.get_updated_calculation(calculation.clone()).await; - if let Some(calculation) = calculation { - let update = self.get_updated_calculation(calculation.clone()).await; - - if let Some(update) = update { - updates.push(CalculationPB::from(&update)); - self.delegate.update_calculation(&self.view_id, update); - } + if let Some(update) = update { + updates.push(CalculationPB::from(&update)); + self.delegate.update_calculation(&self.view_id, update); } } } @@ -262,21 +269,19 @@ impl CalculationsController { } async fn get_updated_calculation(&self, calculation: Arc) -> Option { - let row_cells = self + let field_cells = self .delegate .get_cells_for_field(&self.view_id, &calculation.field_id) .await; let field = self.delegate.get_field(&calculation.field_id)?; - if !row_cells.is_empty() { - let value = - self - .calculations_service - .calculate(&field, calculation.calculation_type, row_cells); + let value = + self + .calculations_service + .calculate(&field, calculation.calculation_type, field_cells); - if value != calculation.value { - return Some(calculation.with_value(value)); - } + if value != calculation.value { + return Some(calculation.with_value(value)); } None diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs index 76abb05f926cc..9c0c1b1713920 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs @@ -26,6 +26,9 @@ impl CalculationsService { CalculationType::Median => self.calculate_median(field, row_cells), CalculationType::Min => self.calculate_min(field, row_cells), CalculationType::Sum => self.calculate_sum(field, row_cells), + CalculationType::Count => self.calculate_count(row_cells), + CalculationType::CountEmpty => self.calculate_count_empty(field, row_cells), + CalculationType::CountNonEmpty => self.calculate_count_non_empty(field, row_cells), } } @@ -33,8 +36,8 @@ impl CalculationsService { let mut sum = 0.0; let mut len = 0.0; let field_type = FieldType::from(field.field_type); - if let Some(handler) = TypeOptionCellExt::new_with_cell_data_cache(field, None) - .get_type_option_cell_data_handler(&field_type) + if let Some(handler) = + TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler(&field_type) { for row_cell in row_cells { if let Some(cell) = &row_cell.cell { @@ -49,7 +52,7 @@ impl CalculationsService { if len > 0.0 { format!("{:.5}", sum / len) } else { - "0".to_owned() + String::new() } } @@ -62,7 +65,7 @@ impl CalculationsService { if !values.is_empty() { format!("{:.5}", Self::median(&values)) } else { - "".to_owned() + String::new() } } @@ -89,7 +92,7 @@ impl CalculationsService { } } - "".to_owned() + String::new() } fn calculate_max(&self, field: &Field, row_cells: Vec>) -> String { @@ -105,7 +108,7 @@ impl CalculationsService { } } - "".to_owned() + String::new() } fn calculate_sum(&self, field: &Field, row_cells: Vec>) -> String { @@ -114,10 +117,65 @@ impl CalculationsService { if !values.is_empty() { format!("{:.5}", values.iter().sum::()) } else { - "".to_owned() + String::new() } } + fn calculate_count(&self, row_cells: Vec>) -> String { + if !row_cells.is_empty() { + format!("{}", row_cells.len()) + } else { + String::new() + } + } + + fn calculate_count_empty(&self, field: &Field, row_cells: Vec>) -> String { + let field_type = FieldType::from(field.field_type); + if let Some(handler) = + TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler(&field_type) + { + if !row_cells.is_empty() { + return format!( + "{}", + row_cells + .iter() + .filter(|c| c.is_none() + || handler + .handle_stringify_cell(&c.cell.clone().unwrap_or_default(), &field_type, field) + .is_empty()) + .collect::>() + .len() + ); + } + } + + String::new() + } + + fn calculate_count_non_empty(&self, field: &Field, row_cells: Vec>) -> String { + let field_type = FieldType::from(field.field_type); + if let Some(handler) = + TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler(&field_type) + { + if !row_cells.is_empty() { + return format!( + "{}", + row_cells + .iter() + // Check the Cell has data and that the stringified version is not empty + .filter(|c| c.is_some() + && !handler + .handle_stringify_cell(&c.cell.clone().unwrap_or_default(), &field_type, field) + .is_empty()) + .collect::>() + .len() + ); + } + } + + String::new() + } + fn reduce_values_f64(&self, field: &Field, row_cells: Vec>, f: F) -> T where F: FnOnce(&mut Vec) -> T, @@ -125,8 +183,8 @@ impl CalculationsService { let mut values = vec![]; let field_type = FieldType::from(field.field_type); - if let Some(handler) = TypeOptionCellExt::new_with_cell_data_cache(field, None) - .get_type_option_cell_data_handler(&field_type) + if let Some(handler) = + TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler(&field_type) { for row_cell in row_cells { if let Some(cell) = &row_cell.cell { diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs index 30e61dd098436..07864351d452b 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs @@ -4,4 +4,3 @@ use std::sync::Arc; use crate::utils::cache::AnyTypeCache; pub type CellCache = Arc>>; -pub type CellFilterCache = Arc>>; diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs index d669ec6ff0439..4d9140c2b692c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::str::FromStr; use collab_database::fields::Field; use collab_database::rows::{get_field_type_from_cell, Cell, Cells}; @@ -74,7 +75,7 @@ pub fn apply_cell_changeset( cell_data_cache: Option, ) -> Result { let field_type = FieldType::from(field.field_type); - match TypeOptionCellExt::new_with_cell_data_cache(field, cell_data_cache) + match TypeOptionCellExt::new(field, cell_data_cache) .get_type_option_cell_data_handler(&field_type) { None => Ok(Cell::default()), @@ -94,13 +95,8 @@ pub fn get_cell_protobuf( let from_field_type = from_field_type.unwrap(); let to_field_type = FieldType::from(field.field_type); - match try_decode_cell_str_to_cell_protobuf( - cell, - &from_field_type, - &to_field_type, - field, - cell_cache, - ) { + match try_decode_cell_to_cell_protobuf(cell, &from_field_type, &to_field_type, field, cell_cache) + { Ok(cell_bytes) => cell_bytes, Err(e) => { tracing::error!("Decode cell data failed, {:?}", e); @@ -125,18 +121,18 @@ pub fn get_cell_protobuf( /// /// returns: CellBytes /// -pub fn try_decode_cell_str_to_cell_protobuf( +pub fn try_decode_cell_to_cell_protobuf( cell: &Cell, from_field_type: &FieldType, to_field_type: &FieldType, field: &Field, cell_data_cache: Option, ) -> FlowyResult { - match TypeOptionCellExt::new_with_cell_data_cache(field, cell_data_cache) + match TypeOptionCellExt::new(field, cell_data_cache) .get_type_option_cell_data_handler(to_field_type) { None => Ok(CellProtobufBlob::default()), - Some(handler) => handler.handle_cell_str(cell, from_field_type, field), + Some(handler) => handler.handle_cell_protobuf(cell, from_field_type, field), } } @@ -147,7 +143,7 @@ pub fn try_decode_cell_to_cell_data( field: &Field, cell_data_cache: Option, ) -> Option { - let handler = TypeOptionCellExt::new_with_cell_data_cache(field, cell_data_cache) + let handler = TypeOptionCellExt::new(field, cell_data_cache) .get_type_option_cell_data_handler(to_field_type)?; handler .get_cell_data(cell, from_field_type, field) @@ -156,8 +152,8 @@ pub fn try_decode_cell_to_cell_data( } /// Returns a string that represents the current field_type's cell data. -/// For example, The string of the Multi-Select cell will be a list of the option's name -/// separated by a comma. +/// For example, a Multi-Select cell will be represented by a list of the options' names +/// separated by commas. /// /// # Arguments /// @@ -166,16 +162,13 @@ pub fn try_decode_cell_to_cell_data( /// * `from_field_type`: the original field type of the passed-in cell data. /// * `field`: used to get the corresponding TypeOption for the specified field type. /// -/// returns: String pub fn stringify_cell_data( cell: &Cell, to_field_type: &FieldType, from_field_type: &FieldType, field: &Field, ) -> String { - match TypeOptionCellExt::new_with_cell_data_cache(field, None) - .get_type_option_cell_data_handler(from_field_type) - { + match TypeOptionCellExt::new(field, None).get_type_option_cell_data_handler(from_field_type) { None => "".to_string(), Some(handler) => handler.handle_stringify_cell(cell, to_field_type, field), } @@ -245,13 +238,6 @@ pub fn delete_select_option_cell(option_ids: Vec, field: &Field) -> Cell apply_cell_changeset(BoxAny::new(changeset), None, field, None).unwrap() } -/// Deserialize the String into cell specific data type. -pub trait FromCellString { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized; -} - pub struct CellBuilder<'a> { cells: Cells, field_maps: HashMap, @@ -290,12 +276,12 @@ impl<'a> CellBuilder<'a> { tracing::warn!("Shouldn't insert cell data to cell whose field type is LastEditedTime or CreatedTime"); }, FieldType::SingleSelect | FieldType::MultiSelect => { - if let Ok(ids) = SelectOptionIds::from_cell_str(&cell_str) { + if let Ok(ids) = SelectOptionIds::from_str(&cell_str) { cells.insert(field_id, insert_select_option_cell(ids.into_inner(), field)); } }, FieldType::Checkbox => { - if let Ok(value) = CheckboxCellDataPB::from_cell_str(&cell_str) { + if let Ok(value) = CheckboxCellDataPB::from_str(&cell_str) { cells.insert(field_id, insert_checkbox_cell(value.is_checked, field)); } }, @@ -303,10 +289,13 @@ impl<'a> CellBuilder<'a> { cells.insert(field_id, insert_url_cell(cell_str, field)); }, FieldType::Checklist => { - if let Ok(ids) = SelectOptionIds::from_cell_str(&cell_str) { + if let Ok(ids) = SelectOptionIds::from_str(&cell_str) { cells.insert(field_id, insert_select_option_cell(ids.into_inner(), field)); } }, + FieldType::Relation => { + cells.insert(field_id, (&RelationCellData::from(cell_str)).into()); + }, } } } diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs b/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs index 4d6e9cd0802f4..ccc877059a80a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs @@ -1,109 +1,6 @@ use bytes::Bytes; -use serde::{Deserialize, Serialize}; -use flowy_error::{internal_error, FlowyError, FlowyResult}; - -use crate::entities::FieldType; - -/// TypeCellData is a generic CellData, you can parse the type_cell_data according to the field_type. -/// The `data` is encoded by JSON format. You can use `IntoCellData` to decode the opaque data to -/// concrete cell type. -/// TypeCellData -> IntoCellData -> T -/// -/// The `TypeCellData` is the same as the cell data that was saved to disk except it carries the -/// field_type. The field_type indicates the cell data original `FieldType`. The field_type will -/// be changed if the current Field's type switch from one to another. -/// -#[derive(Debug, Serialize, Deserialize)] -pub struct TypeCellData { - #[serde(rename = "data")] - pub cell_str: String, - pub field_type: FieldType, -} - -impl TypeCellData { - pub fn from_field_type(field_type: &FieldType) -> TypeCellData { - Self { - cell_str: "".to_string(), - field_type: *field_type, - } - } - - pub fn from_json_str(s: &str) -> FlowyResult { - let type_cell_data: TypeCellData = serde_json::from_str(s).map_err(|err| { - let msg = format!("Deserialize {} to type cell data failed.{}", s, err); - FlowyError::internal().with_context(msg) - })?; - Ok(type_cell_data) - } - - pub fn into_inner(self) -> String { - self.cell_str - } -} - -impl std::convert::TryFrom for TypeCellData { - type Error = FlowyError; - - fn try_from(value: String) -> Result { - TypeCellData::from_json_str(&value) - } -} - -impl ToString for TypeCellData { - fn to_string(&self) -> String { - self.cell_str.clone() - } -} - -impl TypeCellData { - pub fn new(cell_str: String, field_type: FieldType) -> Self { - TypeCellData { - cell_str, - field_type, - } - } - - pub fn to_json(&self) -> String { - serde_json::to_string(self).unwrap_or_else(|_| "".to_owned()) - } - - pub fn is_number(&self) -> bool { - self.field_type == FieldType::Number - } - - pub fn is_text(&self) -> bool { - self.field_type == FieldType::RichText - } - - pub fn is_checkbox(&self) -> bool { - self.field_type == FieldType::Checkbox - } - - pub fn is_date(&self) -> bool { - self.field_type == FieldType::DateTime - } - - pub fn is_single_select(&self) -> bool { - self.field_type == FieldType::SingleSelect - } - - pub fn is_multi_select(&self) -> bool { - self.field_type == FieldType::MultiSelect - } - - pub fn is_checklist(&self) -> bool { - self.field_type == FieldType::Checklist - } - - pub fn is_url(&self) -> bool { - self.field_type == FieldType::URL - } - - pub fn is_select_option(&self) -> bool { - self.field_type == FieldType::MultiSelect || self.field_type == FieldType::SingleSelect - } -} +use flowy_error::{internal_error, FlowyResult}; /// The data is encoded by protobuf or utf8. You should choose the corresponding decode struct to parse it. /// @@ -116,13 +13,8 @@ impl TypeCellData { #[derive(Default, Debug)] pub struct CellProtobufBlob(pub Bytes); -pub trait DecodedCellData { - type Object; - fn is_empty(&self) -> bool; -} - pub trait CellProtobufBlobParser { - type Object: DecodedCellData; + type Object; fn parser(bytes: &Bytes) -> FlowyResult; } 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 bf77ce7e7e1f1..dc1165326212e 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 @@ -3,8 +3,10 @@ use std::sync::Arc; use collab_database::database::MutexDatabase; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cell, Cells, CreateRowParams, Row, RowCell, RowDetail, RowId}; -use collab_database::views::{DatabaseLayout, DatabaseView, LayoutSetting, OrderObjectPosition}; +use collab_database::rows::{Cell, Cells, Row, RowCell, RowDetail, RowId}; +use collab_database::views::{ + DatabaseLayout, DatabaseView, FilterMap, LayoutSetting, OrderObjectPosition, +}; use futures::StreamExt; use lib_infra::box_any::BoxAny; use tokio::sync::{broadcast, RwLock}; @@ -26,14 +28,14 @@ use crate::services::database_view::{ }; use crate::services::field::{ default_type_option_data_from_type, select_type_option_from_field, transform_type_option, - type_option_data_from_pb, ChecklistCellChangeset, SelectOptionCellChangeset, SelectOptionIds, - TimestampCellData, TypeOptionCellDataHandler, TypeOptionCellExt, + type_option_data_from_pb, ChecklistCellChangeset, RelationTypeOption, SelectOptionCellChangeset, + SelectOptionIds, StrCellData, TimestampCellData, TypeOptionCellDataHandler, TypeOptionCellExt, }; 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, GroupChangesets, GroupSetting, RowChangeset}; +use crate::services::filter::{Filter, FilterChangeset}; +use crate::services::group::{default_group_setting, GroupChangeset, GroupSetting, RowChangeset}; use crate::services::share::csv::{CSVExport, CSVFormat}; use crate::services::sort::Sort; use crate::utils::cache::AnyTypeCache; @@ -208,22 +210,23 @@ impl DatabaseEditor { Ok(self.database.lock().delete_view(view_id)) } - pub async fn update_group(&self, view_id: &str, changesets: GroupChangesets) -> FlowyResult<()> { + pub async fn update_group( + &self, + view_id: &str, + changesets: Vec, + ) -> FlowyResult<()> { let view_editor = self.database_views.get_view_editor(view_id).await?; view_editor.v_update_group(changesets).await?; Ok(()) } - #[tracing::instrument(level = "trace", skip_all, err)] - pub async fn create_or_update_filter(&self, params: UpdateFilterParams) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; - view_editor.v_insert_filter(params).await?; - Ok(()) - } - - pub async fn delete_filter(&self, params: DeleteFilterPayloadPB) -> FlowyResult<()> { - let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; - view_editor.v_delete_filter(params).await?; + pub async fn modify_view_filters( + &self, + view_id: &str, + changeset: FilterChangeset, + ) -> FlowyResult<()> { + let view_editor = self.database_views.get_view_editor(view_id).await?; + view_editor.v_modify_filters(changeset).await?; Ok(()) } @@ -267,7 +270,8 @@ impl DatabaseEditor { pub async fn get_all_filters(&self, view_id: &str) -> RepeatedFilterPB { if let Ok(view_editor) = self.database_views.get_view_editor(view_id).await { - view_editor.v_get_all_filters().await.into() + let filters = view_editor.v_get_all_filters().await; + RepeatedFilterPB::from(&filters) } else { RepeatedFilterPB { items: vec![] } } @@ -357,6 +361,30 @@ impl DatabaseEditor { Ok(()) } + pub async fn clear_field(&self, view_id: &str, field_id: &str) -> FlowyResult<()> { + let field_type: FieldType = self + .get_field(field_id) + .map(|field| field.field_type.into()) + .unwrap_or_default(); + + if matches!( + field_type, + FieldType::LastEditedTime | FieldType::CreatedTime + ) { + return Err(FlowyError::new( + ErrorCode::Internal, + "Can not clear the field type of Last Edited Time or Created Time.", + )); + } + + let cells: Vec = self.get_cells_for_field(view_id, field_id).await; + for row_cell in cells { + self.clear_cell(view_id, row_cell.row_id, field_id).await?; + } + + Ok(()) + } + /// Update the field type option data. /// Do nothing if the [TypeOptionData] is empty. pub async fn update_field_type_option( @@ -441,26 +469,49 @@ impl DatabaseEditor { .duplicate_field(view_id, field_id, |field| format!("{} (copy)", field.name)); if let Some((index, duplicated_field)) = value { let _ = self - .notify_did_insert_database_field(duplicated_field, index) + .notify_did_insert_database_field(duplicated_field.clone(), index) .await; + + let new_field_id = duplicated_field.id.clone(); + let cells = self.get_cells_for_field(view_id, field_id).await; + for cell in cells { + if let Some(new_cell) = cell.cell.clone() { + self + .update_cell(view_id, cell.row_id, &new_field_id, new_cell) + .await?; + } + } } Ok(()) } - // consider returning a result. But most of the time, it should be fine to just ignore the error. - pub async fn duplicate_row(&self, view_id: &str, row_id: &RowId) { - let params = self.database.lock().duplicate_row(row_id); - match params { - None => warn!("Failed to duplicate row: {}", row_id), - Some(params) => { - let result = self.create_row(view_id, None, params).await; - if let Some(row_detail) = result.unwrap_or(None) { - for view in self.database_views.editors().await { - view.v_did_duplicate_row(&row_detail).await; - } - } - }, + pub async fn duplicate_row(&self, view_id: &str, row_id: &RowId) -> FlowyResult<()> { + let (row_detail, index) = { + let database = self.database.lock(); + + let params = database + .duplicate_row(row_id) + .ok_or_else(|| FlowyError::internal().with_context("error while copying row"))?; + + let (index, row_order) = database + .create_row_in_view(view_id, params) + .ok_or_else(|| { + FlowyError::internal().with_context("error while inserting duplicated row") + })?; + + tracing::trace!("duplicated row: {:?} at {}", row_order, index); + let row_detail = database.get_row_detail(&row_order.id); + + (row_detail, index) + }; + + if let Some(row_detail) = row_detail { + for view in self.database_views.editors().await { + view.v_did_create_row(&row_detail, index).await; + } } + + Ok(()) } pub async fn move_row( @@ -496,18 +547,21 @@ impl DatabaseEditor { Ok(()) } - pub async fn create_row( - &self, - view_id: &str, - group_id: Option, - mut params: CreateRowParams, - ) -> FlowyResult> { - for view in self.database_views.editors().await { - view.v_will_create_row(&mut params.cells, &group_id).await; - } - let result = self.database.lock().create_row_in_view(view_id, params); + pub async fn create_row(&self, params: CreateRowPayloadPB) -> FlowyResult> { + let view_editor = self.database_views.get_view_editor(¶ms.view_id).await?; + + let CreateRowParams { + collab_params, + open_after_create: _, + } = view_editor.v_will_create_row(params).await?; + + let result = self + .database + .lock() + .create_row_in_view(&view_editor.view_id, collab_params); + if let Some((index, row_order)) = result { - tracing::trace!("create row: {:?} at {}", row_order, index); + tracing::trace!("created row: {:?} at {}", row_order, index); 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 { @@ -584,13 +638,15 @@ impl DatabaseEditor { index: index as i32, }; let notified_changeset = DatabaseFieldChangesetPB { - view_id: params.view_id, + view_id: params.view_id.clone(), inserted_fields: vec![insert_field], deleted_fields: vec![delete_field], updated_fields: vec![], }; - self.notify_did_update_database(notified_changeset).await?; + send_notification(¶ms.view_id, DatabaseNotification::DidUpdateFields) + .payload(notified_changeset) + .send(); } Ok(()) @@ -749,6 +805,7 @@ impl DatabaseEditor { }?; (field, database.get_cell(field_id, &row_id).cell) }; + let new_cell = apply_cell_changeset(cell_changeset, cell, &field, Some(self.cell_cache.clone()))?; self.update_cell(view_id, row_id, field_id, new_cell).await @@ -772,6 +829,37 @@ impl DatabaseEditor { }); }); + self + .did_update_row(view_id, row_id, field_id, old_row) + .await; + + Ok(()) + } + + pub async fn clear_cell(&self, view_id: &str, row_id: RowId, field_id: &str) -> FlowyResult<()> { + // Get the old row before updating the cell. It would be better to get the old cell + let old_row = { self.get_row_detail(view_id, &row_id) }; + + self.database.lock().update_row(&row_id, |row_update| { + row_update.update_cells(|cell_update| { + cell_update.clear(field_id); + }); + }); + + self + .did_update_row(view_id, row_id, field_id, old_row) + .await; + + Ok(()) + } + + async fn did_update_row( + &self, + view_id: &str, + row_id: RowId, + field_id: &str, + old_row: Option, + ) { let option_row = self.get_row_detail(view_id, &row_id); if let Some(new_row_detail) = option_row { for view in self.database_views.editors().await { @@ -789,8 +877,6 @@ impl DatabaseEditor { self .notify_update_row(view_id, row_id, vec![changeset]) .await; - - Ok(()) } pub fn get_auto_updated_fields_changesets( @@ -1046,7 +1132,7 @@ impl DatabaseEditor { pub async fn group_by_field(&self, view_id: &str, field_id: &str) -> FlowyResult<()> { let view = self.database_views.get_view_editor(view_id).await?; - view.v_grouping_by_field(field_id).await?; + view.v_group_by_field(field_id).await?; Ok(()) } @@ -1228,6 +1314,60 @@ impl DatabaseEditor { Ok(()) } + pub async fn get_related_database_id(&self, field_id: &str) -> FlowyResult { + let mut field = self + .database + .lock() + .get_fields(Some(vec![field_id.to_string()])); + let field = field.pop().ok_or(FlowyError::internal())?; + + let type_option = field + .get_type_option::(FieldType::Relation) + .ok_or(FlowyError::record_not_found())?; + + Ok(type_option.database_id) + } + + pub async fn get_related_rows( + &self, + row_ids: Option<&Vec>, + ) -> FlowyResult> { + let primary_field = self.database.lock().fields.get_primary_field().unwrap(); + let handler = TypeOptionCellExt::new(&primary_field, Some(self.cell_cache.clone())) + .get_type_option_cell_data_handler(&FieldType::RichText) + .ok_or(FlowyError::internal())?; + + let row_data = { + let database = self.database.lock(); + let mut rows = database.get_database_rows(); + if let Some(row_ids) = row_ids { + rows.retain(|row| row_ids.contains(&row.id)); + } + rows + .iter() + .map(|row| { + let title = database + .get_cell(&primary_field.id, &row.id) + .cell + .and_then(|cell| { + handler + .get_cell_data(&cell, &FieldType::RichText, &primary_field) + .ok() + }) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(|| StrCellData("".to_string())); + + RelatedRowDataPB { + row_id: row.id.to_string(), + name: title.0, + } + }) + .collect::>() + }; + + Ok(row_data) + } + fn get_auto_updated_fields(&self, view_id: &str) -> Vec { self .database @@ -1314,9 +1454,9 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { to_fut(async move { view }) } - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>> { + fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut> { let fields = self.database.lock().get_fields_in_view(view_id, field_ids); - to_fut(async move { fields.into_iter().map(Arc::new).collect() }) + to_fut(async move { fields }) } fn get_field(&self, field_id: &str) -> Option { @@ -1499,13 +1639,12 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { .get_calculation::(view_id, field_id) } - fn get_all_filters(&self, view_id: &str) -> Vec> { + fn get_all_filters(&self, view_id: &str) -> Vec { self .database .lock() .get_all_filters(view_id) .into_iter() - .map(Arc::new) .collect() } @@ -1514,23 +1653,21 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { } fn insert_filter(&self, view_id: &str, filter: Filter) { - self.database.lock().insert_filter(view_id, filter); + self.database.lock().insert_filter(view_id, &filter); } - fn get_filter(&self, view_id: &str, filter_id: &str) -> Option { + fn save_filters(&self, view_id: &str, filters: &[Filter]) { self .database .lock() - .get_filter::(view_id, filter_id) + .save_filters::(view_id, filters); } - fn get_filter_by_field_id(&self, view_id: &str, field_id: &str) -> Option { + fn get_filter(&self, view_id: &str, filter_id: &str) -> Option { self .database .lock() - .get_all_filters::(view_id) - .into_iter() - .find(|filter| filter.field_id == field_id) + .get_filter::(view_id, filter_id) } fn get_layout_setting(&self, view_id: &str, layout_ty: &DatabaseLayout) -> Option { @@ -1565,7 +1702,7 @@ impl DatabaseViewOperation for DatabaseViewOperationImpl { field: &Field, field_type: &FieldType, ) -> Option> { - TypeOptionCellExt::new_with_cell_data_cache(field, Some(self.cell_cache.clone())) + TypeOptionCellExt::new(field, Some(self.cell_cache.clone())) .get_type_option_cell_data_handler(field_type) } diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs index 9a1cbecf98948..478aa2c5a44c3 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs @@ -1,12 +1,12 @@ #![allow(clippy::while_let_loop)] use crate::entities::{ CalculationChangesetNotificationPB, DatabaseViewSettingPB, FilterChangesetNotificationPB, - GroupChangesPB, GroupRowsNotificationPB, ReorderAllRowsPB, ReorderSingleRowPB, - RowsVisibilityChangePB, SortChangesetNotificationPB, + GroupChangesPB, GroupRowsNotificationPB, InsertedRowPB, ReorderAllRowsPB, ReorderSingleRowPB, + RowMetaPB, RowsChangePB, RowsVisibilityChangePB, SortChangesetNotificationPB, }; use crate::notification::{send_notification, DatabaseNotification}; use crate::services::filter::FilterResultNotification; -use crate::services::sort::{InsertSortedRowResult, ReorderAllRowsResult, ReorderSingleRowResult}; +use crate::services::sort::{InsertRowResult, ReorderAllRowsResult, ReorderSingleRowResult}; use async_stream::stream; use futures::stream::StreamExt; use tokio::sync::broadcast; @@ -16,7 +16,7 @@ pub enum DatabaseViewChanged { FilterNotification(FilterResultNotification), ReorderAllRowsNotification(ReorderAllRowsResult), ReorderSingleRowNotification(ReorderSingleRowResult), - InsertSortedRowNotification(InsertSortedRowResult), + InsertRowNotification(InsertRowResult), CalculationValueNotification(CalculationChangesetNotificationPB), } @@ -79,7 +79,17 @@ impl DatabaseViewChangedReceiverRunner { .payload(reorder_row) .send() }, - DatabaseViewChanged::InsertSortedRowNotification(_result) => {}, + DatabaseViewChanged::InsertRowNotification(result) => { + let inserted_row = InsertedRowPB { + row_meta: RowMetaPB::from(result.row), + index: Some(result.index as i32), + is_new: true, + }; + let changes = RowsChangePB::from_insert(inserted_row); + send_notification(&result.view_id, DatabaseNotification::DidUpdateViewRows) + .payload(changes) + .send(); + }, DatabaseViewChanged::CalculationValueNotification(notification) => send_notification( ¬ification.view_id, DatabaseNotification::DidUpdateCalculation, diff --git a/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs b/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs index f329e498a4787..32ddecc6674e2 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs @@ -66,4 +66,9 @@ impl CalculationsDelegate for DatabaseViewCalculationsDelegateImpl { fn remove_calculation(&self, view_id: &str, calculation_id: &str) { self.0.remove_calculation(view_id, calculation_id) } + + fn get_all_calculations(&self, view_id: &str) -> Fut>>> { + let calculations = Arc::new(self.0.get_all_calculations(view_id)); + to_fut(async move { calculations }) + } } 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 aefc1be4b1f45..6cef8ccc45f05 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,12 +2,11 @@ use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; -use collab_database::database::{ - gen_database_calculation_id, gen_database_filter_id, gen_database_sort_id, -}; -use collab_database::fields::{Field, TypeOptionData}; +use collab_database::database::{gen_database_calculation_id, gen_database_sort_id, gen_row_id}; +use collab_database::fields::Field; use collab_database::rows::{Cells, Row, RowDetail, RowId}; use collab_database::views::{DatabaseLayout, DatabaseView}; +use lib_infra::util::timestamp; use tokio::sync::{broadcast, RwLock}; use tracing::instrument; @@ -15,19 +14,19 @@ use flowy_error::{FlowyError, FlowyResult}; use lib_dispatch::prelude::af_spawn; use crate::entities::{ - CalendarEventPB, DatabaseLayoutMetaPB, DatabaseLayoutSettingPB, DeleteFilterPayloadPB, - DeleteSortPayloadPB, FieldType, FieldVisibility, GroupChangesPB, GroupPB, InsertedRowPB, - LayoutSettingChangeset, LayoutSettingParams, RemoveCalculationChangesetPB, ReorderSortPayloadPB, - RowMetaPB, RowsChangePB, SortChangesetNotificationPB, SortPB, UpdateCalculationChangesetPB, - UpdateFilterParams, UpdateSortPayloadPB, + CalendarEventPB, CreateRowParams, CreateRowPayloadPB, DatabaseLayoutMetaPB, + DatabaseLayoutSettingPB, DeleteSortPayloadPB, FieldType, FieldVisibility, GroupChangesPB, + GroupPB, LayoutSettingChangeset, LayoutSettingParams, RemoveCalculationChangesetPB, + ReorderSortPayloadPB, RowMetaPB, RowsChangePB, SortChangesetNotificationPB, SortPB, + UpdateCalculationChangesetPB, UpdateSortPayloadPB, }; use crate::notification::{send_notification, DatabaseNotification}; use crate::services::calculations::{Calculation, CalculationChangeset, CalculationsController}; -use crate::services::cell::CellCache; +use crate::services::cell::{CellBuilder, CellCache}; use crate::services::database::{database_view_setting_pb_from_view, DatabaseRowEvent, UpdatedRow}; 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, + get_cell_for_row, get_cells_for_field, new_group_controller, }; use crate::services::database_view::view_operation::DatabaseViewOperation; use crate::services::database_view::view_sort::make_sort_controller; @@ -37,10 +36,8 @@ use crate::services::database_view::{ DatabaseViewChangedNotifier, DatabaseViewChangedReceiverRunner, }; use crate::services::field_settings::FieldSettings; -use crate::services::filter::{ - Filter, FilterChangeset, FilterContext, FilterController, UpdatedFilter, -}; -use crate::services::group::{GroupChangesets, GroupController, MoveGroupRowContext, RowChangeset}; +use crate::services::filter::{Filter, FilterChangeset, FilterController}; +use crate::services::group::{GroupChangeset, GroupController, MoveGroupRowContext, RowChangeset}; use crate::services::setting::CalendarLayoutSetting; use crate::services::sort::{Sort, SortChangeset, SortController}; @@ -71,10 +68,6 @@ impl DatabaseViewEditor { ) -> FlowyResult { let (notifier, _) = broadcast::channel(100); 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( @@ -95,6 +88,17 @@ impl DatabaseViewEditor { ) .await; + // Group + let group_controller = Arc::new(RwLock::new( + new_group_controller( + view_id.clone(), + delegate.clone(), + filter_controller.clone(), + None, + ) + .await?, + )); + // Calculations let calculations_controller = make_calculations_controller(&view_id, delegate.clone(), notifier.clone()).await; @@ -120,17 +124,44 @@ impl DatabaseViewEditor { self.delegate.get_view(&self.view_id).await } - pub async fn v_will_create_row(&self, cells: &mut Cells, group_id: &Option) { - if group_id.is_none() { - return; + pub async fn v_will_create_row( + &self, + params: CreateRowPayloadPB, + ) -> FlowyResult { + let mut result = CreateRowParams { + collab_params: collab_database::rows::CreateRowParams { + id: gen_row_id(), + cells: Cells::new(), + height: 60, + visibility: true, + row_position: params.row_position.try_into()?, + timestamp: timestamp(), + }, + open_after_create: false, + }; + + // fill in cells from the frontend + let fields = self.delegate.get_fields(¶ms.view_id, None).await; + let mut cells = CellBuilder::with_cells(params.data, &fields).build(); + + // fill in cells according to group_id if supplied + if let Some(group_id) = params.group_id { + if let Some(controller) = self.group_controller.read().await.as_ref() { + let field = self + .delegate + .get_field(controller.get_grouping_field_id()) + .ok_or_else(|| FlowyError::internal().with_context("Failed to get grouping field"))?; + controller.will_create_row(&mut cells, &field, &group_id); + } } - let group_id = group_id.as_ref().unwrap(); - let _ = self - .mut_group_controller(|group_controller, field| { - group_controller.will_create_row(cells, &field, group_id); - Ok(()) - }) - .await; + + // fill in cells according to active filters + let filter_controller = self.filter_controller.clone(); + filter_controller.fill_cells(&mut cells).await; + + result.collab_params.cells = cells; + + Ok(result) } pub async fn v_did_update_row_meta(&self, row_id: &RowId, row_detail: &RowDetail) { @@ -144,31 +175,20 @@ impl DatabaseViewEditor { pub async fn v_did_create_row(&self, row_detail: &RowDetail, index: usize) { // Send the group notification if the current view has groups if let Some(controller) = self.group_controller.write().await.as_mut() { - let changesets = controller.did_create_row(row_detail, index); + let mut row_details = vec![Arc::new(row_detail.clone())]; + self.v_filter_rows(&mut row_details).await; - for changeset in changesets { - notify_did_update_group_rows(changeset).await; + if let Some(row_detail) = row_details.pop() { + let changesets = controller.did_create_row(&row_detail, index); + + for changeset in changesets { + notify_did_update_group_rows(changeset).await; + } } } - 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(); - self - .gen_did_create_row_view_tasks(row_detail.row.id.clone()) - .await; - } - - pub async fn v_did_duplicate_row(&self, row_detail: &RowDetail) { self - .calculations_controller - .did_receive_row_changed(row_detail.clone().row) + .gen_did_create_row_view_tasks(index, row_detail.clone()) .await; } @@ -222,34 +242,41 @@ impl DatabaseViewEditor { row_detail: &RowDetail, field_id: String, ) { - let result = self - .mut_group_controller(|group_controller, field| { - Ok(group_controller.did_update_group_row(old_row, row_detail, &field)) - }) - .await; - - if let Some(Ok(result)) = result { - let mut group_changes = GroupChangesPB { - view_id: self.view_id.clone(), - ..Default::default() - }; - if let Some(inserted_group) = result.inserted_group { - tracing::trace!("Create group after editing the row: {:?}", inserted_group); - group_changes.inserted_groups.push(inserted_group); - } - if let Some(delete_group) = result.deleted_group { - tracing::trace!("Delete group after editing the row: {:?}", delete_group); - group_changes.deleted_groups.push(delete_group.group_id); - } + if let Some(controller) = self.group_controller.write().await.as_mut() { + let field = self.delegate.get_field(controller.get_grouping_field_id()); + + if let Some(field) = field { + let mut row_details = vec![Arc::new(row_detail.clone())]; + self.v_filter_rows(&mut row_details).await; + + if let Some(row_detail) = row_details.pop() { + let result = controller.did_update_group_row(old_row, &row_detail, &field); + + if let Ok(result) = result { + let mut group_changes = GroupChangesPB { + view_id: self.view_id.clone(), + ..Default::default() + }; + if let Some(inserted_group) = result.inserted_group { + tracing::trace!("Create group after editing the row: {:?}", inserted_group); + group_changes.inserted_groups.push(inserted_group); + } + if let Some(delete_group) = result.deleted_group { + tracing::trace!("Delete group after editing the row: {:?}", delete_group); + group_changes.deleted_groups.push(delete_group.group_id); + } - if !group_changes.is_empty() { - notify_did_update_num_of_groups(&self.view_id, group_changes).await; - } + if !group_changes.is_empty() { + notify_did_update_num_of_groups(&self.view_id, group_changes).await; + } - for changeset in result.row_changesets { - if !changeset.is_empty() { - tracing::trace!("Group change after editing the row: {:?}", changeset); - notify_did_update_group_rows(changeset).await; + for changeset in result.row_changesets { + if !changeset.is_empty() { + tracing::trace!("Group change after editing the row: {:?}", changeset); + notify_did_update_group_rows(changeset).await; + } + } + } } } } @@ -359,7 +386,7 @@ impl DatabaseViewEditor { pub async fn is_grouping_field(&self, field_id: &str) -> bool { match self.group_controller.read().await.as_ref() { - Some(group_controller) => group_controller.field_id() == field_id, + Some(group_controller) => group_controller.get_grouping_field_id() == field_id, None => false, } } @@ -368,7 +395,7 @@ impl DatabaseViewEditor { pub async fn v_initialize_new_group(&self, field_id: &str) -> FlowyResult<()> { let is_grouping_field = self.is_grouping_field(field_id).await; if !is_grouping_field { - self.v_grouping_by_field(field_id).await?; + self.v_group_by_field(field_id).await?; if let Some(view) = self.delegate.get_view(&self.view_id).await { let setting = database_view_setting_pb_from_view(view); @@ -382,7 +409,7 @@ impl DatabaseViewEditor { let mut old_field: Option = 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()); + old_field = self.delegate.get_field(controller.get_grouping_field_id()); create_group_results } else { (None, None) @@ -415,7 +442,7 @@ impl DatabaseViewEditor { None => return Ok(RowsChangePB::default()), }; - let old_field = self.delegate.get_field(controller.field_id()); + let old_field = self.delegate.get_field(controller.get_grouping_field_id()); let (row_ids, type_option_data) = controller.delete_group(group_id)?; drop(group_controller); @@ -444,22 +471,24 @@ impl DatabaseViewEditor { Ok(changes) } - 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); + pub async fn v_update_group(&self, changeset: Vec) -> FlowyResult<()> { + let mut type_option_data = None; + let (old_field, updated_groups) = + if let Some(controller) = self.group_controller.write().await.as_mut() { + let old_field = self.delegate.get_field(controller.get_grouping_field_id()); + let (updated_groups, new_type_option) = controller.apply_group_changeset(&changeset)?; - (old_field, updated_groups) - } else { - (None, vec![]) - }; + if new_type_option.is_some() { + type_option_data = new_type_option; + } + + (old_field, updated_groups) + } else { + (None, vec![]) + }; if let Some(old_field) = old_field { - if !type_option_data.is_empty() { + if let Some(type_option_data) = type_option_data { self .delegate .update_field(type_option_data, old_field) @@ -618,71 +647,31 @@ impl DatabaseViewEditor { Ok(()) } - pub async fn v_get_all_filters(&self) -> Vec> { + pub async fn v_get_all_filters(&self) -> Vec { self.delegate.get_all_filters(&self.view_id) } - #[tracing::instrument(level = "trace", skip(self), err)] - pub async fn v_insert_filter(&self, params: UpdateFilterParams) -> FlowyResult<()> { - let is_exist = params.filter_id.is_some(); - let filter_id = match params.filter_id { - None => gen_database_filter_id(), - Some(filter_id) => filter_id, - }; - let filter = Filter { - id: filter_id.clone(), - field_id: params.field_id.clone(), - field_type: params.field_type, - condition: params.condition, - content: params.content, - }; - let filter_controller = self.filter_controller.clone(); - let changeset = if is_exist { - let old_filter = self.delegate.get_filter(&self.view_id, &filter.id); - - self.delegate.insert_filter(&self.view_id, filter.clone()); - filter_controller - .did_receive_changes(FilterChangeset::from_update(UpdatedFilter::new( - old_filter, filter, - ))) - .await - } else { - self.delegate.insert_filter(&self.view_id, filter.clone()); - filter_controller - .did_receive_changes(FilterChangeset::from_insert(filter)) - .await - }; - drop(filter_controller); - - if let Some(changeset) = changeset { - notify_did_update_filter(changeset).await; - } - Ok(()) + pub async fn v_get_filter(&self, filter_id: &str) -> Option { + self.delegate.get_filter(&self.view_id, filter_id) } #[tracing::instrument(level = "trace", skip(self), err)] - pub async fn v_delete_filter(&self, params: DeleteFilterPayloadPB) -> FlowyResult<()> { - let filter_context = FilterContext { - filter_id: params.filter_id.clone(), - field_id: params.field_id.clone(), - field_type: params.field_type, - }; - let changeset = self - .filter_controller - .did_receive_changes(FilterChangeset::from_delete(filter_context.clone())) - .await; + pub async fn v_modify_filters(&self, changeset: FilterChangeset) -> FlowyResult<()> { + let notification = self.filter_controller.apply_changeset(changeset).await; - self - .delegate - .delete_filter(&self.view_id, ¶ms.filter_id); - if changeset.is_some() { - notify_did_update_filter(changeset.unwrap()).await; + notify_did_update_filter(notification).await; + + let group_controller_read_guard = self.group_controller.read().await; + let grouping_field_id = group_controller_read_guard + .as_ref() + .map(|controller| controller.get_grouping_field_id().to_string()); + drop(group_controller_read_guard); + + if let Some(field_id) = grouping_field_id { + self.v_group_by_field(&field_id).await?; } - Ok(()) - } - pub async fn v_get_filter(&self, filter_id: &str) -> Option { - self.delegate.get_filter(&self.view_id, filter_id) + Ok(()) } /// Returns the current calendar settings @@ -769,6 +758,12 @@ impl DatabaseViewEditor { } pub async fn v_did_delete_field(&self, deleted_field_id: &str) { + let changeset = FilterChangeset::DeleteAllWithFieldId { + field_id: deleted_field_id.to_string(), + }; + let notification = self.filter_controller.apply_changeset(changeset).await; + notify_did_update_filter(notification).await; + let sorts = self.delegate.get_all_sorts(&self.view_id); if let Some(sort) = sorts.iter().find(|sort| sort.field_id == deleted_field_id) { @@ -809,11 +804,6 @@ impl DatabaseViewEditor { #[tracing::instrument(level = "trace", skip_all, err)] 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 @@ -823,68 +813,66 @@ impl DatabaseViewEditor { .did_update_field_type_option(&field) .await; - self - .mut_group_controller(|group_controller, _| { - group_controller.did_update_field_type_option(&field); - Ok(()) - }) - .await; - - if let Some(filter) = self - .delegate - .get_filter_by_field_id(&self.view_id, field_id) - { - let old = Filter { - field_type: FieldType::from(old_field.field_type), - ..filter.clone() + if old_field.field_type != field.field_type { + let changeset = FilterChangeset::DeleteAllWithFieldId { + field_id: field.id.clone(), }; - let updated_filter = UpdatedFilter::new(Some(old), filter); - let filter_changeset = FilterChangeset::from_update(updated_filter); - let filter_controller = self.filter_controller.clone(); - af_spawn(async move { - if let Some(notification) = filter_controller - .did_receive_changes(filter_changeset) - .await - { - notify_did_update_filter(notification).await; - } - }); + let notification = self.filter_controller.apply_changeset(changeset).await; + notify_did_update_filter(notification).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 self.is_grouping_field(field_id).await { + self.v_group_by_field(field_id).await?; + } + Ok(()) } /// 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<()> { + pub async fn v_group_by_field(&self, field_id: &str) -> FlowyResult<()> { if let Some(field) = self.delegate.get_field(field_id) { - let new_group_controller = new_group_controller_with_field( + tracing::trace!("create new group controller"); + + let new_group_controller = new_group_controller( self.view_id.clone(), self.delegate.clone(), - Arc::new(field), + self.filter_controller.clone(), + Some(field), ) .await?; - let new_groups = new_group_controller - .get_all_groups() - .into_iter() - .map(|group| GroupPB::from(group.clone())) - .collect(); + if let Some(controller) = &new_group_controller { + let new_groups = controller + .get_all_groups() + .into_iter() + .map(|group| GroupPB::from(group.clone())) + .collect(); - *self.group_controller.write().await = Some(new_group_controller); - let changeset = GroupChangesPB { - view_id: self.view_id.clone(), - initial_groups: new_groups, - ..Default::default() - }; + let changeset = GroupChangesPB { + view_id: self.view_id.clone(), + initial_groups: new_groups, + ..Default::default() + }; + tracing::trace!("notify did group by field1"); - debug_assert!(!changeset.is_empty()); - if !changeset.is_empty() { - send_notification(&changeset.view_id, DatabaseNotification::DidGroupByField) - .payload(changeset) - .send(); + debug_assert!(!changeset.is_empty()); + if !changeset.is_empty() { + send_notification(&changeset.view_id, DatabaseNotification::DidGroupByField) + .payload(changeset) + .send(); + } } + tracing::trace!("notify did group by field2"); + + *self.group_controller.write().await = new_group_controller; + + tracing::trace!("did write group_controller to cache"); } + Ok(()) } @@ -1005,8 +993,13 @@ impl DatabaseViewEditor { } // initialize the group controller if the current layout support grouping - *self.group_controller.write().await = - new_group_controller(self.view_id.clone(), self.delegate.clone()).await?; + *self.group_controller.write().await = new_group_controller( + self.view_id.clone(), + self.delegate.clone(), + self.filter_controller.clone(), + None, + ) + .await?; let payload = DatabaseLayoutMetaPB { view_id: self.view_id.clone(), @@ -1066,7 +1059,7 @@ impl DatabaseViewEditor { .read() .await .as_ref() - .map(|group| group.field_id().to_owned())?; + .map(|controller| controller.get_grouping_field_id().to_owned())?; 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 { @@ -1090,7 +1083,7 @@ impl DatabaseViewEditor { sort_controller .read() .await - .did_receive_row_changed(row_id) + .did_receive_row_changed(row_id.clone()) .await; } if let Some(calculations_controller) = weak_calculations_controller.upgrade() { @@ -1101,11 +1094,22 @@ impl DatabaseViewEditor { }); } - async fn gen_did_create_row_view_tasks(&self, row_id: RowId) { + async fn gen_did_create_row_view_tasks(&self, preliminary_index: usize, row_detail: RowDetail) { let weak_sort_controller = Arc::downgrade(&self.sort_controller); + let weak_calculations_controller = Arc::downgrade(&self.calculations_controller); af_spawn(async move { if let Some(sort_controller) = weak_sort_controller.upgrade() { - sort_controller.read().await.did_create_row(row_id).await; + sort_controller + .read() + .await + .did_create_row(preliminary_index, &row_detail) + .await; + } + + if let Some(calculations_controller) = weak_calculations_controller.upgrade() { + calculations_controller + .did_receive_row_changed(row_detail.row.clone()) + .await; } }); } 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 75f31212d9619..f710144e608c3 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 @@ -3,7 +3,7 @@ use std::sync::Arc; use collab_database::fields::Field; use collab_database::rows::{RowDetail, RowId}; -use lib_infra::future::{to_fut, Fut}; +use lib_infra::future::Fut; use crate::services::cell::CellCache; use crate::services::database_view::{ @@ -17,7 +17,6 @@ pub async fn make_filter_controller( notifier: DatabaseViewChangedNotifier, cell_cache: CellCache, ) -> Arc { - let filters = delegate.get_all_filters(view_id); let task_scheduler = delegate.get_task_scheduler(); let filter_delegate = DatabaseViewFilterDelegateImpl(delegate.clone()); @@ -27,7 +26,6 @@ pub async fn make_filter_controller( &handler_id, filter_delegate, task_scheduler.clone(), - filters, cell_cache, notifier, ) @@ -46,16 +44,11 @@ pub async fn make_filter_controller( struct DatabaseViewFilterDelegateImpl(Arc); impl FilterDelegate for DatabaseViewFilterDelegateImpl { - fn get_filter(&self, view_id: &str, filter_id: &str) -> Fut>> { - let filter = self.0.get_filter(view_id, filter_id).map(Arc::new); - to_fut(async move { filter }) - } - fn get_field(&self, field_id: &str) -> Option { self.0.get_field(field_id) } - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>> { + fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut> { self.0.get_fields(view_id, field_ids) } @@ -66,4 +59,12 @@ impl FilterDelegate for DatabaseViewFilterDelegateImpl { fn get_row(&self, view_id: &str, rows_id: &RowId) -> Fut)>> { self.0.get_row(view_id, rows_id) } + + fn get_all_filters(&self, view_id: &str) -> Vec { + self.0.get_all_filters(view_id) + } + + fn save_filters(&self, view_id: &str, filters: &[Filter]) { + self.0.save_filters(view_id, filters) + } } 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 963e8a45dd88e..9f7e3da4ff794 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,8 +1,7 @@ use std::sync::Arc; -use async_trait::async_trait; use collab_database::fields::Field; -use collab_database::rows::{Cell, RowId}; +use collab_database::rows::{RowDetail, RowId}; use flowy_error::FlowyResult; use lib_infra::future::{to_fut, Fut}; @@ -10,80 +9,61 @@ use lib_infra::future::{to_fut, Fut}; use crate::entities::FieldType; use crate::services::database_view::DatabaseViewOperation; use crate::services::field::RowSingleCellData; +use crate::services::filter::FilterController; use crate::services::group::{ - find_new_grouping_field, make_group_controller, GroupController, GroupSetting, - GroupSettingReader, GroupSettingWriter, GroupTypeOptionCellOperation, + make_group_controller, GroupContextDelegate, GroupController, GroupControllerDelegate, + GroupSetting, }; -pub async fn new_group_controller_with_field( - view_id: String, - delegate: Arc, - grouping_field: Arc, -) -> FlowyResult> { - 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, + filter_controller: Arc, + grouping_field: Option, ) -> FlowyResult>> { - let fields = delegate.get_fields(&view_id, None).await; - let setting_reader = GroupSettingReaderImpl(delegate.clone()); - - // Read the grouping field or find a new grouping field - let mut grouping_field = setting_reader - .get_group_setting(&view_id) - .await - .and_then(|setting| { - fields - .iter() - .find(|field| field.id == setting.field_id) - .cloned() - }); - - let layout = delegate.get_layout_for_view(&view_id); - // If the view is a board and the grouping field is empty, we need to find a new grouping field - if layout.is_board() && grouping_field.is_none() { - grouping_field = find_new_grouping_field(&fields, &layout); + if !delegate.get_layout_for_view(&view_id).is_board() { + return Ok(None); } - 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, - grouping_field, - rows, - setting_reader, - setting_writer, - type_option_writer, - ) - .await?, - )) - } else { - Ok(None) - } + let controller_delegate = GroupControllerDelegateImpl { + delegate: delegate.clone(), + filter_controller: filter_controller.clone(), + }; + + let grouping_field = match grouping_field { + Some(field) => Some(field), + None => { + let group_setting = controller_delegate.get_group_setting(&view_id).await; + + let fields = delegate.get_fields(&view_id, None).await; + + group_setting + .and_then(|setting| { + fields + .iter() + .find(|field| field.id == setting.field_id) + .cloned() + }) + .or_else(|| find_suitable_grouping_field(&fields)) + }, + }; + + let controller = match grouping_field { + Some(field) => Some(make_group_controller(&view_id, field, controller_delegate).await?), + None => None, + }; + + Ok(controller) } -pub(crate) struct GroupSettingReaderImpl(pub Arc); +pub(crate) struct GroupControllerDelegateImpl { + delegate: Arc, + filter_controller: Arc, +} -impl GroupSettingReader for GroupSettingReaderImpl { +impl GroupContextDelegate for GroupControllerDelegateImpl { fn get_group_setting(&self, view_id: &str) -> Fut>> { - let mut settings = self.0.get_group_setting(view_id); + let mut settings = self.delegate.get_group_setting(view_id); to_fut(async move { if settings.is_empty() { None @@ -96,9 +76,31 @@ impl GroupSettingReader for GroupSettingReaderImpl { fn get_configuration_cells(&self, view_id: &str, field_id: &str) -> Fut> { let field_id = field_id.to_owned(); let view_id = view_id.to_owned(); - let delegate = self.0.clone(); + let delegate = self.delegate.clone(); to_fut(async move { get_cells_for_field(delegate, &view_id, &field_id).await }) } + + fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut> { + self.delegate.insert_group_setting(view_id, group_setting); + to_fut(async move { Ok(()) }) + } +} + +impl GroupControllerDelegate for GroupControllerDelegateImpl { + fn get_field(&self, field_id: &str) -> Option { + self.delegate.get_field(field_id) + } + + fn get_all_rows(&self, view_id: &str) -> Fut>> { + let view_id = view_id.to_string(); + let delegate = self.delegate.clone(); + let filter_controller = self.filter_controller.clone(); + to_fut(async move { + let mut row_details = delegate.get_rows(&view_id).await; + filter_controller.filter_rows(&mut row_details).await; + row_details + }) + } } pub(crate) async fn get_cell_for_row( @@ -154,30 +156,14 @@ pub(crate) async fn get_cells_for_field( vec![] } -struct GroupSettingWriterImpl(Arc); -impl GroupSettingWriter for GroupSettingWriterImpl { - fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut> { - self.0.insert_group_setting(view_id, group_setting); - to_fut(async move { Ok(()) }) - } -} +fn find_suitable_grouping_field(fields: &[Field]) -> Option { + let groupable_field = fields + .iter() + .find(|field| FieldType::from(field.field_type).can_be_group()); -struct GroupTypeOptionCellWriterImpl(Arc); - -#[async_trait] -impl GroupTypeOptionCellOperation for GroupTypeOptionCellWriterImpl { - async fn get_cell(&self, _row_id: &RowId, _field_id: &str) -> FlowyResult> { - 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!() + if let Some(field) = groupable_field { + Some(field.clone()) + } else { + fields.iter().find(|field| field.is_primary).cloned() } } 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 index 40ad9778b58b7..e64d9b494e976 100644 --- 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 @@ -27,7 +27,7 @@ pub trait DatabaseViewOperation: Send + Sync + 'static { /// Get the view of the database with the view_id fn get_view(&self, view_id: &str) -> Fut>; /// If the field_ids is None, then it will return all the field revisions - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>>; + fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>; /// Returns the field with the field_id fn get_field(&self, field_id: &str) -> Option; @@ -91,15 +91,15 @@ pub trait DatabaseViewOperation: Send + Sync + 'static { fn remove_calculation(&self, view_id: &str, calculation_id: &str); - fn get_all_filters(&self, view_id: &str) -> Vec>; + fn get_all_filters(&self, view_id: &str) -> Vec; + + fn get_filter(&self, view_id: &str, filter_id: &str) -> Option; 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; - - fn get_filter_by_field_id(&self, view_id: &str, field_id: &str) -> Option; + fn save_filters(&self, view_id: &str, filters: &[Filter]); fn get_layout_setting(&self, view_id: &str, layout_ty: &DatabaseLayout) -> Option; 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 6587d9ea0e971..0397526b66ffc 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 @@ -70,11 +70,21 @@ impl SortDelegate for DatabaseViewSortDelegateImpl { }) } + fn filter_row(&self, row_detail: &RowDetail) -> Fut { + let filter_controller = self.filter_controller.clone(); + let row_detail = row_detail.clone(); + to_fut(async move { + let mut row_details = vec![Arc::new(row_detail)]; + filter_controller.filter_rows(&mut row_details).await; + !row_details.is_empty() + }) + } + fn get_field(&self, field_id: &str) -> Option { self.delegate.get_field(field_id) } - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>> { + fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut> { self.delegate.get_fields(view_id, field_ids) } } 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 005cb48443a15..ed949d7287fa1 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,17 +2,14 @@ use std::collections::HashMap; use std::sync::Arc; use collab_database::database::MutexDatabase; -use collab_database::rows::{RowDetail, RowId}; use nanoid::nanoid; use tokio::sync::{broadcast, RwLock}; 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::{DatabaseViewEditor, DatabaseViewOperation}; -use crate::services::group::RowChangeset; pub type RowEventSender = broadcast::Sender; pub type RowEventReceiver = broadcast::Receiver; @@ -59,30 +56,6 @@ impl DatabaseViews { .collect() } - /// It may generate a RowChangeset when the Row was moved from one group to another. - /// The return value, [RowChangeset], contains the changes made by the groups. - /// - pub async fn move_group_row( - &self, - view_id: &str, - row_detail: Arc, - to_group_id: String, - to_row_id: Option, - recv_row_changeset: impl FnOnce(RowChangeset) -> Fut<()>, - ) -> FlowyResult<()> { - let view_editor = self.get_view_editor(view_id).await?; - let mut row_changeset = RowChangeset::new(row_detail.row.id.clone()); - view_editor - .v_move_group_row(&row_detail, &mut row_changeset, &to_group_id, to_row_id) - .await; - - if !row_changeset.is_empty() { - recv_row_changeset(row_changeset).await; - } - - Ok(()) - } - pub async fn get_view_editor(&self, view_id: &str) -> FlowyResult> { debug_assert!(!view_id.is_empty()); if let Some(editor) = self.editor_by_view_id.read().await.get(view_id) { diff --git a/frontend/rust-lib/flowy-database2/src/services/field/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/mod.rs index 5df564a7daadc..72cc377c6051c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/mod.rs @@ -1,6 +1,6 @@ mod field_builder; mod field_operation; -mod type_options; +pub mod type_options; pub use field_builder::*; pub use field_operation::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs index 9a1e2812e1043..e2aa56de9457e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs @@ -1,4 +1,8 @@ +use collab_database::{fields::Field, rows::Cell}; + use crate::entities::{CheckboxCellDataPB, CheckboxFilterConditionPB, CheckboxFilterPB}; +use crate::services::cell::insert_checkbox_cell; +use crate::services::filter::PreFillCellsWithFilter; impl CheckboxFilterPB { pub fn is_visible(&self, cell_data: &CheckboxCellDataPB) -> bool { @@ -9,6 +13,20 @@ impl CheckboxFilterPB { } } +impl PreFillCellsWithFilter for CheckboxFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let is_checked = match self.condition { + CheckboxFilterConditionPB::IsChecked => Some(true), + CheckboxFilterConditionPB::IsUnChecked => None, + }; + + ( + is_checked.map(|is_checked| insert_checkbox_cell(is_checked, field)), + false, + ) + } +} + #[cfg(test)] mod tests { use crate::entities::{CheckboxCellDataPB, CheckboxFilterConditionPB, CheckboxFilterPB}; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs index 295dff0037a5a..53c86d400f28f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests { + use std::str::FromStr; + use collab_database::fields::Field; use crate::entities::CheckboxCellDataPB; use crate::entities::FieldType; use crate::services::cell::CellDataDecoder; - use crate::services::cell::FromCellString; use crate::services::field::type_options::checkbox_type_option::*; use crate::services::field::FieldBuilder; @@ -43,7 +44,7 @@ mod tests { assert_eq!( type_option .decode_cell( - &CheckboxCellDataPB::from_cell_str(input_str).unwrap().into(), + &CheckboxCellDataPB::from_str(input_str).unwrap().into(), field_type, field ) diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs index 327d426f39d4f..ec693d414dff0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs @@ -124,12 +124,8 @@ impl TypeOptionCellDataFilter for CheckboxTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_checkbox() { - return true; - } filter.is_visible(cell_data) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs index afab124d04e31..35de68136b206 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs @@ -7,7 +7,7 @@ use collab_database::rows::{new_cell_builder, Cell}; use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{CheckboxCellDataPB, FieldType}; -use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString}; +use crate::services::cell::CellProtobufBlobParser; use crate::services::field::{TypeOptionCellData, CELL_DATA}; pub const CHECK: &str = "Yes"; @@ -22,7 +22,7 @@ impl TypeOptionCellData for CheckboxCellDataPB { impl From<&Cell> for CheckboxCellDataPB { fn from(cell: &Cell) -> Self { let value = cell.get_str_value(CELL_DATA).unwrap_or_default(); - CheckboxCellDataPB::from_cell_str(&value).unwrap_or_default() + CheckboxCellDataPB::from_str(&value).unwrap_or_default() } } @@ -49,15 +49,6 @@ impl FromStr for CheckboxCellDataPB { } } -impl FromCellString for CheckboxCellDataPB { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - Self::from_str(s) - } -} - impl ToString for CheckboxCellDataPB { fn to_string(&self) -> String { if self.is_checked { @@ -68,14 +59,6 @@ impl ToString for CheckboxCellDataPB { } } -impl DecodedCellData for CheckboxCellDataPB { - type Object = CheckboxCellDataPB; - - fn is_empty(&self) -> bool { - false - } -} - pub struct CheckboxCellDataParser(); impl CellProtobufBlobParser for CheckboxCellDataParser { type Object = CheckboxCellDataPB; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs index 7a6f818f2ced6..8d080b2d07d13 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist.rs @@ -178,12 +178,8 @@ impl TypeOptionCellDataFilter for ChecklistTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_checklist() { - return true; - } let selected_options = cell_data.selected_options(); filter.is_visible(&cell_data.options, &selected_options) } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs index 84773b5cd3b65..91768a5cf3346 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs @@ -1,5 +1,9 @@ +use collab_database::fields::Field; +use collab_database::rows::Cell; + use crate::entities::{ChecklistFilterConditionPB, ChecklistFilterPB}; use crate::services::field::SelectOption; +use crate::services::filter::PreFillCellsWithFilter; impl ChecklistFilterPB { pub fn is_visible( @@ -37,3 +41,9 @@ impl ChecklistFilterPB { } } } + +impl PreFillCellsWithFilter for ChecklistFilterPB { + fn get_compliant_cell(&self, _field: &Field) -> (Option, bool) { + (None, true) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs index 1eed418c867f5..42a0300e18a64 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs @@ -1,8 +1,11 @@ use crate::entities::{DateFilterConditionPB, DateFilterPB}; +use crate::services::cell::insert_date_cell; +use crate::services::field::DateCellData; +use crate::services::filter::PreFillCellsWithFilter; -use chrono::{NaiveDate, NaiveDateTime}; - -use super::DateCellData; +use chrono::{Duration, NaiveDate, NaiveDateTime}; +use collab_database::fields::Field; +use collab_database::rows::Cell; impl DateFilterPB { /// Returns `None` if the DateFilterPB doesn't have the necessary data for @@ -95,6 +98,39 @@ impl DateFilterStrategy { } } +impl PreFillCellsWithFilter for DateFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let timestamp = match self.condition { + DateFilterConditionPB::DateIs + | DateFilterConditionPB::DateOnOrBefore + | DateFilterConditionPB::DateOnOrAfter => self.timestamp, + DateFilterConditionPB::DateBefore => self + .timestamp + .and_then(|timestamp| NaiveDateTime::from_timestamp_opt(timestamp, 0)) + .map(|date_time| { + let answer = date_time - Duration::days(1); + answer.timestamp() + }), + DateFilterConditionPB::DateAfter => self + .timestamp + .and_then(|timestamp| NaiveDateTime::from_timestamp_opt(timestamp, 0)) + .map(|date_time| { + let answer = date_time + Duration::days(1); + answer.timestamp() + }), + DateFilterConditionPB::DateWithIn => self.start, + _ => None, + }; + + let open_after_create = matches!(self.condition, DateFilterConditionPB::DateIsNotEmpty); + + ( + timestamp.map(|timestamp| insert_date_cell(timestamp, None, None, field)), + open_after_create, + ) + } +} + #[cfg(test)] mod tests { use crate::entities::{DateFilterConditionPB, DateFilterPB}; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs index 34badaa58f126..15acf6ad508d9 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs @@ -342,12 +342,8 @@ impl TypeOptionCellDataFilter for DateTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_date() { - return true; - } filter.is_visible(cell_data).unwrap_or(true) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs index 0bb1190768241..c2b0259aff742 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs @@ -11,7 +11,7 @@ use strum_macros::EnumIter; use flowy_error::{internal_error, FlowyResult}; use crate::entities::{DateCellDataPB, FieldType}; -use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString}; +use crate::services::cell::CellProtobufBlobParser; use crate::services::field::{TypeOptionCellData, CELL_DATA}; #[derive(Clone, Debug, Default)] @@ -196,16 +196,6 @@ impl<'de> serde::Deserialize<'de> for DateCellData { } } -impl FromCellString for DateCellData { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - let result: DateCellData = serde_json::from_str(s).unwrap(); - Ok(result) - } -} - impl ToString for DateCellData { fn to_string(&self) -> String { serde_json::to_string(self).unwrap() @@ -288,14 +278,6 @@ impl TimeFormat { } } -impl DecodedCellData for DateCellDataPB { - type Object = DateCellDataPB; - - fn is_empty(&self) -> bool { - self.date.is_empty() - } -} - pub struct DateCellDataParser(); impl CellProtobufBlobParser for DateCellDataParser { type Object = DateCellDataPB; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs index 075c5c41f2c4a..f4cd13d02041e 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs @@ -2,6 +2,7 @@ pub mod checkbox_type_option; pub mod checklist_type_option; pub mod date_type_option; pub mod number_type_option; +pub mod relation_type_option; pub mod selection_type_option; pub mod text_type_option; pub mod timestamp_type_option; @@ -14,6 +15,7 @@ pub use checkbox_type_option::*; pub use checklist_type_option::*; pub use date_type_option::*; pub use number_type_option::*; +pub use relation_type_option::*; pub use selection_type_option::*; pub use text_type_option::*; pub use timestamp_type_option::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs index 9832d0500920d..ba95dd88434e9 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs @@ -1,23 +1,30 @@ +use std::str::FromStr; + +use collab_database::fields::Field; +use collab_database::rows::Cell; +use rust_decimal::Decimal; + use crate::entities::{NumberFilterConditionPB, NumberFilterPB}; +use crate::services::cell::insert_text_cell; use crate::services::field::NumberCellFormat; -use rust_decimal::prelude::Zero; -use rust_decimal::Decimal; -use std::str::FromStr; +use crate::services::filter::PreFillCellsWithFilter; impl NumberFilterPB { pub fn is_visible(&self, cell_data: &NumberCellFormat) -> Option { - let expected_decimal = Decimal::from_str(&self.content).unwrap_or_else(|_| Decimal::zero()); + let expected_decimal = || Decimal::from_str(&self.content).ok(); let strategy = match self.condition { - NumberFilterConditionPB::Equal => NumberFilterStrategy::Equal(expected_decimal), - NumberFilterConditionPB::NotEqual => NumberFilterStrategy::NotEqual(expected_decimal), - NumberFilterConditionPB::GreaterThan => NumberFilterStrategy::GreaterThan(expected_decimal), - NumberFilterConditionPB::LessThan => NumberFilterStrategy::LessThan(expected_decimal), + NumberFilterConditionPB::Equal => NumberFilterStrategy::Equal(expected_decimal()?), + NumberFilterConditionPB::NotEqual => NumberFilterStrategy::NotEqual(expected_decimal()?), + NumberFilterConditionPB::GreaterThan => { + NumberFilterStrategy::GreaterThan(expected_decimal()?) + }, + NumberFilterConditionPB::LessThan => NumberFilterStrategy::LessThan(expected_decimal()?), NumberFilterConditionPB::GreaterThanOrEqualTo => { - NumberFilterStrategy::GreaterThanOrEqualTo(expected_decimal) + NumberFilterStrategy::GreaterThanOrEqualTo(expected_decimal()?) }, NumberFilterConditionPB::LessThanOrEqualTo => { - NumberFilterStrategy::LessThanOrEqualTo(expected_decimal) + NumberFilterStrategy::LessThanOrEqualTo(expected_decimal()?) }, NumberFilterConditionPB::NumberIsEmpty => NumberFilterStrategy::Empty, NumberFilterConditionPB::NumberIsNotEmpty => NumberFilterStrategy::NotEmpty, @@ -27,6 +34,39 @@ impl NumberFilterPB { } } +impl PreFillCellsWithFilter for NumberFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let expected_decimal = || Decimal::from_str(&self.content).ok(); + + let text = match self.condition { + NumberFilterConditionPB::Equal + | NumberFilterConditionPB::GreaterThanOrEqualTo + | NumberFilterConditionPB::LessThanOrEqualTo + if !self.content.is_empty() => + { + Some(self.content.clone()) + }, + NumberFilterConditionPB::GreaterThan if !self.content.is_empty() => { + expected_decimal().map(|value| { + let answer = value + Decimal::from_f32_retain(1.0).unwrap(); + answer.to_string() + }) + }, + NumberFilterConditionPB::LessThan if !self.content.is_empty() => { + expected_decimal().map(|value| { + let answer = value - Decimal::from_f32_retain(1.0).unwrap(); + answer.to_string() + }) + }, + _ => None, + }; + + let open_after_create = matches!(self.condition, NumberFilterConditionPB::NumberIsNotEmpty); + + // use `insert_text_cell` because self.content might not be a parsable i64. + (text.map(|s| insert_text_cell(s, field)), open_after_create) + } +} enum NumberFilterStrategy { Equal(Decimal), NotEqual(Decimal), diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs index 94da6b0c0b82e..b79ca661fc1d1 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs @@ -242,12 +242,8 @@ impl TypeOptionCellDataFilter for NumberTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_number() { - return true; - } match self.format_cell_data(cell_data) { Ok(cell_data) => filter.is_visible(&cell_data).unwrap_or(true), Err(_) => true, diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs index 59b069908fa69..5085bc3db3dfb 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs @@ -1,4 +1,4 @@ -use crate::services::cell::{CellBytesCustomParser, CellProtobufBlobParser, DecodedCellData}; +use crate::services::cell::{CellBytesCustomParser, CellProtobufBlobParser}; use crate::services::field::number_currency::Currency; use crate::services::field::{NumberFormat, EXTRACT_NUM_REGEX, START_WITH_DOT_NUM_REGEX}; use bytes::Bytes; @@ -108,14 +108,6 @@ impl ToString for NumberCellFormat { } } -impl DecodedCellData for NumberCellFormat { - type Object = NumberCellFormat; - - fn is_empty(&self) -> bool { - self.decimal.is_none() - } -} - pub struct NumberCellDataParser(); impl CellProtobufBlobParser for NumberCellDataParser { type Object = NumberCellFormat; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs new file mode 100644 index 0000000000000..4ae30a658914a --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs @@ -0,0 +1,5 @@ +mod relation; +mod relation_entities; + +pub use relation::*; +pub use relation_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs new file mode 100644 index 0000000000000..4bc7fd3d08153 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs @@ -0,0 +1,154 @@ +use std::cmp::Ordering; + +use collab::core::any_map::AnyMapExtension; +use collab_database::fields::{Field, TypeOptionData, TypeOptionDataBuilder}; +use collab_database::rows::Cell; +use flowy_error::FlowyResult; +use serde::{Deserialize, Serialize}; + +use crate::entities::{FieldType, RelationCellDataPB, RelationFilterPB}; +use crate::services::cell::{CellDataChangeset, CellDataDecoder}; +use crate::services::field::{ + default_order, TypeOption, TypeOptionCellDataCompare, TypeOptionCellDataFilter, + TypeOptionCellDataSerde, TypeOptionTransform, +}; +use crate::services::sort::SortCondition; + +use super::{RelationCellChangeset, RelationCellData}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RelationTypeOption { + pub database_id: String, +} + +impl From for RelationTypeOption { + fn from(value: TypeOptionData) -> Self { + let database_id = value.get_str_value("database_id").unwrap_or_default(); + Self { database_id } + } +} + +impl From for TypeOptionData { + fn from(value: RelationTypeOption) -> Self { + TypeOptionDataBuilder::new() + .insert_str_value("database_id", value.database_id) + .build() + } +} + +impl TypeOption for RelationTypeOption { + type CellData = RelationCellData; + type CellChangeset = RelationCellChangeset; + type CellProtobufType = RelationCellDataPB; + type CellFilter = RelationFilterPB; +} + +impl CellDataChangeset for RelationTypeOption { + fn apply_changeset( + &self, + changeset: RelationCellChangeset, + cell: Option, + ) -> FlowyResult<(Cell, RelationCellData)> { + if cell.is_none() { + let cell_data = RelationCellData { + row_ids: changeset.inserted_row_ids, + }; + + return Ok(((&cell_data).into(), cell_data)); + } + + let cell_data: RelationCellData = cell.unwrap().as_ref().into(); + let mut row_ids = cell_data.row_ids.clone(); + for inserted in changeset.inserted_row_ids.iter() { + if !row_ids.iter().any(|row_id| row_id == inserted) { + row_ids.push(inserted.clone()) + } + } + for removed_id in changeset.removed_row_ids.iter() { + if let Some(index) = row_ids.iter().position(|row_id| row_id == removed_id) { + row_ids.remove(index); + } + } + + let cell_data = RelationCellData { row_ids }; + + Ok(((&cell_data).into(), cell_data)) + } +} + +impl CellDataDecoder for RelationTypeOption { + fn decode_cell( + &self, + cell: &Cell, + decoded_field_type: &FieldType, + _field: &Field, + ) -> FlowyResult { + if !decoded_field_type.is_relation() { + return Ok(Default::default()); + } + + Ok(cell.into()) + } + + fn stringify_cell_data(&self, cell_data: RelationCellData) -> String { + cell_data.to_string() + } + + fn stringify_cell(&self, cell: &Cell) -> String { + let cell_data = RelationCellData::from(cell); + cell_data.to_string() + } + + fn numeric_cell(&self, _cell: &Cell) -> Option { + None + } +} + +impl TypeOptionCellDataCompare for RelationTypeOption { + fn apply_cmp( + &self, + _cell_data: &RelationCellData, + _other_cell_data: &RelationCellData, + _sort_condition: SortCondition, + ) -> Ordering { + default_order() + } +} + +impl TypeOptionCellDataFilter for RelationTypeOption { + fn apply_filter(&self, _filter: &RelationFilterPB, _cell_data: &RelationCellData) -> bool { + true + } +} + +impl TypeOptionTransform for RelationTypeOption { + fn transformable(&self) -> bool { + false + } + + fn transform_type_option( + &mut self, + _old_type_option_field_type: FieldType, + _old_type_option_data: TypeOptionData, + ) { + } + + fn transform_type_option_cell( + &self, + _cell: &Cell, + _transformed_field_type: &FieldType, + _field: &Field, + ) -> Option { + None + } +} + +impl TypeOptionCellDataSerde for RelationTypeOption { + fn protobuf_encode(&self, cell_data: RelationCellData) -> RelationCellDataPB { + cell_data.into() + } + + fn parse_cell(&self, cell: &Cell) -> FlowyResult { + Ok(cell.into()) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs new file mode 100644 index 0000000000000..97b18590af5dc --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use collab::preclude::Any; +use collab_database::rows::{new_cell_builder, Cell, RowId}; + +use crate::entities::FieldType; +use crate::services::field::{TypeOptionCellData, CELL_DATA}; + +#[derive(Debug, Clone, Default)] +pub struct RelationCellData { + pub row_ids: Vec, +} + +impl From<&Cell> for RelationCellData { + fn from(value: &Cell) -> Self { + let row_ids = match value.get(CELL_DATA) { + Some(Any::Array(array)) => array + .iter() + .flat_map(|item| { + if let Any::String(string) = item { + Some(RowId::from(string.clone().to_string())) + } else { + None + } + }) + .collect(), + _ => vec![], + }; + Self { row_ids } + } +} + +impl From<&RelationCellData> for Cell { + fn from(value: &RelationCellData) -> Self { + let data = Any::Array(Arc::from( + value + .row_ids + .clone() + .into_iter() + .map(|id| Any::String(Arc::from(id.to_string()))) + .collect::>(), + )); + new_cell_builder(FieldType::Relation) + .insert_any(CELL_DATA, data) + .build() + } +} + +impl From for RelationCellData { + fn from(s: String) -> Self { + if s.is_empty() { + return RelationCellData { row_ids: vec![] }; + } + + let ids = s + .split(", ") + .map(|id| id.to_string().into()) + .collect::>(); + + RelationCellData { row_ids: ids } + } +} + +impl TypeOptionCellData for RelationCellData { + fn is_cell_empty(&self) -> bool { + self.row_ids.is_empty() + } +} + +impl ToString for RelationCellData { + fn to_string(&self) -> String { + self + .row_ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + } +} + +#[derive(Debug, Clone, Default)] +pub struct RelationCellChangeset { + pub inserted_row_ids: Vec, + pub removed_row_ids: Vec, +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs index 61e5a5f31bca5..8ebd0d1db437a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs @@ -125,14 +125,10 @@ impl TypeOptionCellDataFilter for MultiSelectTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_multi_select() { - return true; - } let selected_options = self.get_selected_options(cell_data.clone()).select_options; - filter.is_visible(&selected_options, FieldType::MultiSelect) + filter.is_visible(&selected_options).unwrap_or(true) } } @@ -216,8 +212,6 @@ mod tests { debug_assert_eq!(multi_select.options.len(), 2); } - // #[test] - #[test] fn multi_select_insert_multi_option_test() { let google = SelectOption::new("Google"); diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs index 148cef5f750a8..a0e1ce096b1f4 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs @@ -1,106 +1,149 @@ -#![allow(clippy::needless_collect)] +use collab_database::fields::Field; +use collab_database::rows::Cell; -use crate::entities::{FieldType, SelectOptionConditionPB, SelectOptionFilterPB}; -use crate::services::field::SelectOption; +use crate::entities::{SelectOptionFilterConditionPB, SelectOptionFilterPB}; +use crate::services::cell::insert_select_option_cell; +use crate::services::field::{select_type_option_from_field, SelectOption}; +use crate::services::filter::PreFillCellsWithFilter; impl SelectOptionFilterPB { - pub fn is_visible(&self, selected_options: &[SelectOption], field_type: FieldType) -> bool { - let selected_option_ids: Vec<&String> = - selected_options.iter().map(|option| &option.id).collect(); - match self.condition { - SelectOptionConditionPB::OptionIs => match field_type { - FieldType::SingleSelect => { - if self.option_ids.is_empty() { - return true; - } + pub fn is_visible(&self, selected_options: &[SelectOption]) -> Option { + let selected_option_ids = selected_options + .iter() + .map(|option| &option.id) + .collect::>(); - if selected_options.is_empty() { - return false; - } + let get_non_empty_expected_options = + || (!self.option_ids.is_empty()).then(|| self.option_ids.clone()); - let required_options = self - .option_ids - .iter() - .filter(|id| selected_option_ids.contains(id)) - .collect::>(); - - !required_options.is_empty() - }, - FieldType::MultiSelect => { - if self.option_ids.is_empty() { - return true; - } + let strategy = match self.condition { + SelectOptionFilterConditionPB::OptionIs => { + SelectOptionFilterStrategy::Is(get_non_empty_expected_options()?) + }, + SelectOptionFilterConditionPB::OptionIsNot => { + SelectOptionFilterStrategy::IsNot(get_non_empty_expected_options()?) + }, + SelectOptionFilterConditionPB::OptionContains => { + SelectOptionFilterStrategy::Contains(get_non_empty_expected_options()?) + }, + SelectOptionFilterConditionPB::OptionDoesNotContain => { + SelectOptionFilterStrategy::DoesNotContain(get_non_empty_expected_options()?) + }, + SelectOptionFilterConditionPB::OptionIsEmpty => SelectOptionFilterStrategy::IsEmpty, + SelectOptionFilterConditionPB::OptionIsNotEmpty => SelectOptionFilterStrategy::IsNotEmpty, + }; + + Some(strategy.filter(&selected_option_ids)) + } +} + +enum SelectOptionFilterStrategy { + Is(Vec), + IsNot(Vec), + Contains(Vec), + DoesNotContain(Vec), + IsEmpty, + IsNotEmpty, +} - let required_options = self - .option_ids - .iter() - .filter(|id| selected_option_ids.contains(id)) - .collect::>(); +impl SelectOptionFilterStrategy { + fn filter(self, selected_option_ids: &[&String]) -> bool { + match self { + SelectOptionFilterStrategy::Is(option_ids) => { + if selected_option_ids.is_empty() { + return false; + } - !required_options.is_empty() - }, - _ => false, + selected_option_ids.len() == option_ids.len() + && selected_option_ids.iter().all(|id| option_ids.contains(id)) }, - SelectOptionConditionPB::OptionIsNot => match field_type { - FieldType::SingleSelect => { - if self.option_ids.is_empty() { - return true; - } + SelectOptionFilterStrategy::IsNot(option_ids) => { + if selected_option_ids.is_empty() { + return true; + } - if selected_options.is_empty() { - return false; - } + selected_option_ids.len() != option_ids.len() + || !selected_option_ids.iter().all(|id| option_ids.contains(id)) + }, + SelectOptionFilterStrategy::Contains(option_ids) => { + if selected_option_ids.is_empty() { + return false; + } + + let required_options = option_ids + .into_iter() + .filter(|id| selected_option_ids.contains(&id)) + .collect::>(); + + !required_options.is_empty() + }, + SelectOptionFilterStrategy::DoesNotContain(option_ids) => { + if selected_option_ids.is_empty() { + return true; + } + + let required_options = option_ids + .into_iter() + .filter(|id| selected_option_ids.contains(&id)) + .collect::>(); - let required_options = self - .option_ids - .iter() - .filter(|id| selected_option_ids.contains(id)) - .collect::>(); - - required_options.is_empty() - }, - FieldType::MultiSelect => { - let required_options = self - .option_ids - .iter() - .filter(|id| selected_option_ids.contains(id)) - .collect::>(); - - required_options.is_empty() - }, - _ => false, + required_options.is_empty() }, - SelectOptionConditionPB::OptionIsEmpty => selected_option_ids.is_empty(), - SelectOptionConditionPB::OptionIsNotEmpty => !selected_option_ids.is_empty(), + SelectOptionFilterStrategy::IsEmpty => selected_option_ids.is_empty(), + SelectOptionFilterStrategy::IsNotEmpty => !selected_option_ids.is_empty(), } } } +impl PreFillCellsWithFilter for SelectOptionFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let get_non_empty_expected_options = || { + if !self.option_ids.is_empty() { + Some(self.option_ids.clone()) + } else { + None + } + }; + + let option_ids = match self.condition { + SelectOptionFilterConditionPB::OptionIs => get_non_empty_expected_options(), + SelectOptionFilterConditionPB::OptionContains => { + get_non_empty_expected_options().map(|mut options| vec![options.swap_remove(0)]) + }, + SelectOptionFilterConditionPB::OptionIsNotEmpty => select_type_option_from_field(field) + .ok() + .map(|mut type_option| { + let options = type_option.mut_options(); + if options.is_empty() { + vec![] + } else { + vec![options.swap_remove(0).id] + } + }), + _ => None, + }; + + ( + option_ids.map(|ids| insert_select_option_cell(ids, field)), + false, + ) + } +} #[cfg(test)] mod tests { - #![allow(clippy::all)] - use crate::entities::{FieldType, SelectOptionConditionPB, SelectOptionFilterPB}; + use crate::entities::{SelectOptionFilterConditionPB, SelectOptionFilterPB}; use crate::services::field::SelectOption; #[test] fn select_option_filter_is_empty_test() { let option = SelectOption::new("A"); let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIsEmpty, + condition: SelectOptionFilterConditionPB::OptionIsEmpty, option_ids: vec![], }; - assert_eq!(filter.is_visible(&vec![], FieldType::SingleSelect), true); - assert_eq!( - filter.is_visible(&vec![option.clone()], FieldType::SingleSelect), - false, - ); - - assert_eq!(filter.is_visible(&vec![], FieldType::MultiSelect), true); - assert_eq!( - filter.is_visible(&vec![option], FieldType::MultiSelect), - false, - ); + assert_eq!(filter.is_visible(&[]), Some(true)); + assert_eq!(filter.is_visible(&[option.clone()]), Some(false)); } #[test] @@ -108,157 +151,227 @@ mod tests { let option_1 = SelectOption::new("A"); let option_2 = SelectOption::new("B"); let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIsNotEmpty, + condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, option_ids: vec![option_1.id.clone(), option_2.id.clone()], }; - assert_eq!( - filter.is_visible(&vec![option_1.clone()], FieldType::SingleSelect), - true - ); - assert_eq!(filter.is_visible(&vec![], FieldType::SingleSelect), false,); - - assert_eq!( - filter.is_visible(&vec![option_1.clone()], FieldType::MultiSelect), - true - ); - assert_eq!(filter.is_visible(&vec![], FieldType::MultiSelect), false,); + assert_eq!(filter.is_visible(&[]), Some(false)); + assert_eq!(filter.is_visible(&[option_1.clone()]), Some(true)); } #[test] - fn single_select_option_filter_is_not_test() { + fn select_option_filter_is_test() { let option_1 = SelectOption::new("A"); let option_2 = SelectOption::new("B"); let option_3 = SelectOption::new("C"); + + // no expected options let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIsNot, - option_ids: vec![option_1.id.clone(), option_2.id.clone()], + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: vec![], }; - - for (options, is_visible) in vec![ - (vec![option_2.clone()], false), - (vec![option_1.clone()], false), - (vec![option_3.clone()], true), - (vec![option_1.clone(), option_2.clone()], false), + for (options, is_visible) in [ + (vec![], None), + (vec![option_1.clone()], None), + (vec![option_1.clone(), option_2.clone()], None), ] { - assert_eq!( - filter.is_visible(&options, FieldType::SingleSelect), - is_visible - ); + assert_eq!(filter.is_visible(&options), is_visible); } - } - - #[test] - fn single_select_option_filter_is_test() { - let option_1 = SelectOption::new("A"); - let option_2 = SelectOption::new("B"); - let option_3 = SelectOption::new("c"); + // one expected option let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIs, + condition: SelectOptionFilterConditionPB::OptionIs, option_ids: vec![option_1.id.clone()], }; - for (options, is_visible) in vec![ - (vec![option_1.clone()], true), - (vec![option_2.clone()], false), - (vec![option_3.clone()], false), - (vec![option_1.clone(), option_2.clone()], true), + for (options, is_visible) in [ + (vec![], Some(false)), + (vec![option_1.clone()], Some(true)), + (vec![option_2.clone()], Some(false)), + (vec![option_3.clone()], Some(false)), + (vec![option_1.clone(), option_2.clone()], Some(false)), + ] { + assert_eq!(filter.is_visible(&options), is_visible); + } + + // multiple expected options + let filter = SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: vec![option_1.id.clone(), option_2.id.clone()], + }; + for (options, is_visible) in [ + (vec![], Some(false)), + (vec![option_1.clone()], Some(false)), + (vec![option_1.clone(), option_2.clone()], Some(true)), + ( + vec![option_1.clone(), option_2.clone(), option_3.clone()], + Some(false), + ), ] { - assert_eq!( - filter.is_visible(&options, FieldType::SingleSelect), - is_visible - ); + assert_eq!(filter.is_visible(&options), is_visible); } } #[test] - fn single_select_option_filter_is_test2() { + fn select_option_filter_is_not_test() { let option_1 = SelectOption::new("A"); let option_2 = SelectOption::new("B"); + let option_3 = SelectOption::new("C"); + // no expected options let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIs, + condition: SelectOptionFilterConditionPB::OptionIsNot, option_ids: vec![], }; - for (options, is_visible) in vec![ - (vec![option_1.clone()], true), - (vec![option_2.clone()], true), - (vec![option_1.clone(), option_2.clone()], true), + for (options, is_visible) in [ + (vec![], None), + (vec![option_1.clone()], None), + (vec![option_1.clone(), option_2.clone()], None), ] { - assert_eq!( - filter.is_visible(&options, FieldType::SingleSelect), - is_visible - ); + assert_eq!(filter.is_visible(&options), is_visible); } - } - #[test] - fn multi_select_option_filter_not_contains_test() { - let option_1 = SelectOption::new("A"); - let option_2 = SelectOption::new("B"); - let option_3 = SelectOption::new("C"); + // one expected option let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIsNot, - option_ids: vec![option_1.id.clone(), option_2.id.clone()], + condition: SelectOptionFilterConditionPB::OptionIsNot, + option_ids: vec![option_1.id.clone()], }; + for (options, is_visible) in [ + (vec![], Some(true)), + (vec![option_1.clone()], Some(false)), + (vec![option_2.clone()], Some(true)), + (vec![option_3.clone()], Some(true)), + (vec![option_1.clone(), option_2.clone()], Some(true)), + ] { + assert_eq!(filter.is_visible(&options), is_visible); + } - for (options, is_visible) in vec![ - (vec![option_1.clone(), option_2.clone()], false), - (vec![option_1.clone()], false), - (vec![option_2.clone()], false), - (vec![option_3.clone()], true), + // multiple expected options + let filter = SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIsNot, + option_ids: vec![option_1.id.clone(), option_2.id.clone()], + }; + for (options, is_visible) in [ + (vec![], Some(true)), + (vec![option_1.clone()], Some(true)), + (vec![option_1.clone(), option_2.clone()], Some(false)), ( vec![option_1.clone(), option_2.clone(), option_3.clone()], - false, + Some(true), ), - (vec![], true), ] { - assert_eq!( - filter.is_visible(&options, FieldType::MultiSelect), - is_visible - ); + assert_eq!(filter.is_visible(&options), is_visible); } } + #[test] - fn multi_select_option_filter_contains_test() { + fn select_option_filter_contains_test() { let option_1 = SelectOption::new("A"); let option_2 = SelectOption::new("B"); let option_3 = SelectOption::new("C"); + let option_4 = SelectOption::new("D"); + + // no expected options + let filter = SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionContains, + option_ids: vec![], + }; + for (options, is_visible) in [ + (vec![], None), + (vec![option_1.clone()], None), + (vec![option_1.clone(), option_2.clone()], None), + ] { + assert_eq!(filter.is_visible(&options), is_visible); + } + // one expected option let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIs, + condition: SelectOptionFilterConditionPB::OptionContains, + option_ids: vec![option_1.id.clone()], + }; + for (options, is_visible) in [ + (vec![], Some(false)), + (vec![option_1.clone()], Some(true)), + (vec![option_2.clone()], Some(false)), + (vec![option_1.clone(), option_2.clone()], Some(true)), + (vec![option_3.clone(), option_4.clone()], Some(false)), + ] { + assert_eq!(filter.is_visible(&options), is_visible); + } + + // multiple expected options + let filter = SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionContains, option_ids: vec![option_1.id.clone(), option_2.id.clone()], }; - for (options, is_visible) in vec![ + for (options, is_visible) in [ + (vec![], Some(false)), + (vec![option_1.clone()], Some(true)), + (vec![option_3.clone()], Some(false)), + (vec![option_1.clone(), option_2.clone()], Some(true)), + (vec![option_1.clone(), option_3.clone()], Some(true)), + (vec![option_3.clone(), option_4.clone()], Some(false)), ( - vec![option_1.clone(), option_2.clone(), option_3.clone()], - true, + vec![option_1.clone(), option_3.clone(), option_4.clone()], + Some(true), ), - (vec![option_2.clone(), option_1.clone()], true), - (vec![option_2.clone()], true), - (vec![option_1.clone(), option_3.clone()], true), - (vec![option_3.clone()], false), ] { - assert_eq!( - filter.is_visible(&options, FieldType::MultiSelect), - is_visible - ); + assert_eq!(filter.is_visible(&options), is_visible); } } #[test] - fn multi_select_option_filter_contains_test2() { + fn select_option_filter_does_not_contain_test() { let option_1 = SelectOption::new("A"); + let option_2 = SelectOption::new("B"); + let option_3 = SelectOption::new("C"); + let option_4 = SelectOption::new("D"); + // no expected options let filter = SelectOptionFilterPB { - condition: SelectOptionConditionPB::OptionIs, + condition: SelectOptionFilterConditionPB::OptionDoesNotContain, option_ids: vec![], }; - for (options, is_visible) in vec![(vec![option_1.clone()], true), (vec![], true)] { - assert_eq!( - filter.is_visible(&options, FieldType::MultiSelect), - is_visible - ); + for (options, is_visible) in [ + (vec![], None), + (vec![option_1.clone()], None), + (vec![option_1.clone(), option_2.clone()], None), + ] { + assert_eq!(filter.is_visible(&options), is_visible); + } + + // one expected option + let filter = SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionDoesNotContain, + option_ids: vec![option_1.id.clone()], + }; + for (options, is_visible) in [ + (vec![], Some(true)), + (vec![option_1.clone()], Some(false)), + (vec![option_2.clone()], Some(true)), + (vec![option_1.clone(), option_2.clone()], Some(false)), + (vec![option_3.clone(), option_4.clone()], Some(true)), + ] { + assert_eq!(filter.is_visible(&options), is_visible); + } + + // multiple expected options + let filter = SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionDoesNotContain, + option_ids: vec![option_1.id.clone(), option_2.id.clone()], + }; + for (options, is_visible) in [ + (vec![], Some(true)), + (vec![option_1.clone()], Some(false)), + (vec![option_3.clone()], Some(true)), + (vec![option_1.clone(), option_2.clone()], Some(false)), + (vec![option_1.clone(), option_3.clone()], Some(false)), + (vec![option_3.clone(), option_4.clone()], Some(true)), + ( + vec![option_1.clone(), option_3.clone(), option_4.clone()], + Some(false), + ), + ] { + assert_eq!(filter.is_visible(&options), is_visible); } } } 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 55fe2635bcbf8..c47738b7880b2 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 @@ -1,10 +1,11 @@ +use std::str::FromStr; + use collab::core::any_map::AnyMapExtension; use collab_database::rows::{new_cell_builder, Cell}; -use flowy_error::FlowyResult; +use flowy_error::FlowyError; use crate::entities::FieldType; -use crate::services::cell::{DecodedCellData, FromCellString}; use crate::services::field::{TypeOptionCellData, CELL_DATA}; pub const SELECTION_IDS_SEPARATOR: &str = ","; @@ -37,33 +38,25 @@ impl TypeOptionCellData for SelectOptionIds { } } -impl FromCellString for SelectOptionIds { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - Ok(Self::from(s.to_owned())) - } -} - impl From<&Cell> for SelectOptionIds { fn from(cell: &Cell) -> Self { let value = cell.get_str_value(CELL_DATA).unwrap_or_default(); - Self::from(value) + Self::from_str(&value).unwrap_or_default() } } -impl std::convert::From for SelectOptionIds { - fn from(s: String) -> Self { +impl FromStr for SelectOptionIds { + type Err = FlowyError; + + fn from_str(s: &str) -> Result { if s.is_empty() { - return Self(vec![]); + return Ok(Self(vec![])); } - let ids = s .split(SELECTION_IDS_SEPARATOR) .map(|id| id.to_string()) .collect::>(); - Self(ids) + Ok(Self(ids)) } } @@ -89,7 +82,7 @@ impl std::convert::From> for SelectOptionIds { fn from(s: Option) -> Self { match s { None => Self(vec![]), - Some(s) => Self::from(s), + Some(s) => Self::from_str(&s).unwrap_or_default(), } } } @@ -107,11 +100,3 @@ impl std::ops::DerefMut for SelectOptionIds { &mut self.0 } } - -impl DecodedCellData for SelectOptionIds { - type Object = SelectOptionIds; - - fn is_empty(&self) -> bool { - self.0.is_empty() - } -} 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 367284161b783..9ea8990ebb2fd 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 @@ -1,3 +1,5 @@ +use std::str::FromStr; + use bytes::Bytes; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::Cell; @@ -5,7 +7,7 @@ use collab_database::rows::Cell; use flowy_error::{internal_error, ErrorCode, FlowyResult}; use crate::entities::{CheckboxCellDataPB, FieldType, SelectOptionCellDataPB}; -use crate::services::cell::{CellDataDecoder, CellProtobufBlobParser, DecodedCellData}; +use crate::services::cell::{CellDataDecoder, CellProtobufBlobParser}; use crate::services::field::selection_type_option::type_option_transform::SelectOptionTypeOptionTransformHelper; use crate::services::field::{ make_selected_options, MultiSelectTypeOption, SelectOption, SelectOptionCellData, @@ -205,20 +207,12 @@ impl CellProtobufBlobParser for SelectOptionIdsParser { type Object = SelectOptionIds; fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { - Ok(s) => Ok(SelectOptionIds::from(s)), - Err(_) => Ok(SelectOptionIds::from("".to_owned())), + Ok(s) => SelectOptionIds::from_str(&s), + Err(_) => Ok(SelectOptionIds::default()), } } } -impl DecodedCellData for SelectOptionCellDataPB { - type Object = SelectOptionCellDataPB; - - fn is_empty(&self) -> bool { - self.select_options.is_empty() - } -} - pub struct SelectOptionCellDataParser(); impl CellProtobufBlobParser for SelectOptionCellDataParser { type Object = SelectOptionCellDataPB; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs index c047922b3a53f..fa0745133bbb0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs @@ -116,14 +116,10 @@ impl TypeOptionCellDataFilter for SingleSelectTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_single_select() { - return true; - } let selected_options = self.get_selected_options(cell_data.clone()).select_options; - filter.is_visible(&selected_options, FieldType::SingleSelect) + filter.is_visible(&selected_options).unwrap_or(true) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs index f684dcc56b4ab..8f090f580212d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs @@ -1,22 +1,46 @@ +use collab_database::{fields::Field, rows::Cell}; + use crate::entities::{TextFilterConditionPB, TextFilterPB}; +use crate::services::cell::insert_text_cell; +use crate::services::filter::PreFillCellsWithFilter; impl TextFilterPB { pub fn is_visible>(&self, cell_data: T) -> bool { let cell_data = cell_data.as_ref().to_lowercase(); let content = &self.content.to_lowercase(); match self.condition { - TextFilterConditionPB::Is => &cell_data == content, - TextFilterConditionPB::IsNot => &cell_data != content, - TextFilterConditionPB::Contains => cell_data.contains(content), - TextFilterConditionPB::DoesNotContain => !cell_data.contains(content), - TextFilterConditionPB::StartsWith => cell_data.starts_with(content), - TextFilterConditionPB::EndsWith => cell_data.ends_with(content), + TextFilterConditionPB::TextIs => &cell_data == content, + TextFilterConditionPB::TextIsNot => &cell_data != content, + TextFilterConditionPB::TextContains => cell_data.contains(content), + TextFilterConditionPB::TextDoesNotContain => !cell_data.contains(content), + TextFilterConditionPB::TextStartsWith => cell_data.starts_with(content), + TextFilterConditionPB::TextEndsWith => cell_data.ends_with(content), TextFilterConditionPB::TextIsEmpty => cell_data.is_empty(), TextFilterConditionPB::TextIsNotEmpty => !cell_data.is_empty(), } } } +impl PreFillCellsWithFilter for TextFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let text = match self.condition { + TextFilterConditionPB::TextIs + | TextFilterConditionPB::TextContains + | TextFilterConditionPB::TextStartsWith + | TextFilterConditionPB::TextEndsWith + if !self.content.is_empty() => + { + Some(self.content.clone()) + }, + _ => None, + }; + + let open_after_create = matches!(self.condition, TextFilterConditionPB::TextIsNotEmpty); + + (text.map(|s| insert_text_cell(s, field)), open_after_create) + } +} + #[cfg(test)] mod tests { #![allow(clippy::all)] @@ -25,7 +49,7 @@ mod tests { #[test] fn text_filter_equal_test() { let text_filter = TextFilterPB { - condition: TextFilterConditionPB::Is, + condition: TextFilterConditionPB::TextIs, content: "appflowy".to_owned(), }; @@ -37,7 +61,7 @@ mod tests { #[test] fn text_filter_start_with_test() { let text_filter = TextFilterPB { - condition: TextFilterConditionPB::StartsWith, + condition: TextFilterConditionPB::TextStartsWith, content: "appflowy".to_owned(), }; @@ -49,7 +73,7 @@ mod tests { #[test] fn text_filter_end_with_test() { let text_filter = TextFilterPB { - condition: TextFilterConditionPB::EndsWith, + condition: TextFilterConditionPB::TextEndsWith, content: "appflowy".to_owned(), }; @@ -70,7 +94,7 @@ mod tests { #[test] fn text_filter_contain_test() { let text_filter = TextFilterPB { - condition: TextFilterConditionPB::Contains, + condition: TextFilterConditionPB::TextContains, content: "appflowy".to_owned(), }; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs index 32e9096e87947..b18664e32e647 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs @@ -10,8 +10,7 @@ use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{FieldType, TextFilterPB}; use crate::services::cell::{ - stringify_cell_data, CellDataChangeset, CellDataDecoder, CellProtobufBlobParser, DecodedCellData, - FromCellString, + stringify_cell_data, CellDataChangeset, CellDataDecoder, CellProtobufBlobParser, }; use crate::services::field::type_options::util::ProtobufStr; use crate::services::field::{ @@ -73,6 +72,7 @@ impl TypeOptionTransform for RichTextTypeOption { || transformed_field_type.is_multi_select() || transformed_field_type.is_number() || transformed_field_type.is_url() + || transformed_field_type.is_checklist() { Some(StrCellData::from(stringify_cell_data( cell, @@ -144,13 +144,8 @@ impl TypeOptionCellDataFilter for RichTextTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_text() { - return false; - } - filter.is_visible(cell_data) } } @@ -190,29 +185,12 @@ impl std::ops::Deref for TextCellData { } } -impl FromCellString for TextCellData { - fn from_cell_str(s: &str) -> FlowyResult - where - Self: Sized, - { - Ok(TextCellData(s.to_owned())) - } -} - impl ToString for TextCellData { fn to_string(&self) -> String { self.0.clone() } } -impl DecodedCellData for TextCellData { - type Object = TextCellData; - - fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - pub struct TextCellDataParser(); impl CellProtobufBlobParser for TextCellDataParser { type Object = TextCellData; @@ -260,12 +238,6 @@ impl std::ops::DerefMut for StrCellData { } } -impl FromCellString for StrCellData { - fn from_cell_str(s: &str) -> FlowyResult { - Ok(Self(s.to_owned())) - } -} - impl std::convert::From for StrCellData { fn from(s: String) -> Self { Self(s) diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs index a5b78a6d89957..f3def99718b75 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs @@ -175,13 +175,9 @@ impl TypeOptionCellDataFilter for TimestampTypeOption { fn apply_filter( &self, _filter: &::CellFilter, - field_type: &FieldType, _cell_data: &::CellData, ) -> bool { - if !field_type.is_last_edited_time() && !field_type.is_created_time() { - return true; - } - false + true } } 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 4c47748c47230..0cb3d1ca6530b 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 @@ -10,30 +10,29 @@ use flowy_error::FlowyResult; use crate::entities::{ CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, - MultiSelectTypeOptionPB, NumberTypeOptionPB, RichTextTypeOptionPB, SingleSelectTypeOptionPB, - TimestampTypeOptionPB, URLTypeOptionPB, + MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB, + SingleSelectTypeOptionPB, TimestampTypeOptionPB, URLTypeOptionPB, }; use crate::services::cell::CellDataDecoder; use crate::services::field::checklist_type_option::ChecklistTypeOption; use crate::services::field::{ - CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RichTextTypeOption, - SingleSelectTypeOption, TimestampTypeOption, URLTypeOption, + CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption, + RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, URLTypeOption, }; -use crate::services::filter::FromFilterString; +use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; use crate::services::sort::SortCondition; -pub trait TypeOption { - /// `CellData` represents as the decoded model for current type option. Each of them impl the - /// `FromCellString` and `Default` trait. If the cell string can not be decoded into the specified - /// cell data type then the default value will be returned. - /// For example: +pub trait TypeOption: From + Into { + /// `CellData` represents the decoded model for the current type option. Each of them must + /// implement the From<&Cell> trait. If the `Cell` cannot be decoded into this type, the default + /// value will be returned. + /// + /// Note: Use `StrCellData` for any `TypeOption` whose cell data is simply `String`. /// /// - FieldType::Checkbox => CheckboxCellData /// - FieldType::Date => DateCellData /// - FieldType::URL => URLCellData /// - /// Uses `StrCellData` for any `TypeOption` if their cell data is pure `String`. - /// type CellData: for<'a> From<&'a Cell> + TypeOptionCellData + ToString @@ -59,7 +58,7 @@ pub trait TypeOption { type CellProtobufType: TryInto + Debug; /// Represents the filter configuration for this type option. - type CellFilter: FromFilterString + Send + Sync + 'static; + type CellFilter: ParseFilterData + PreFillCellsWithFilter + Clone + Send + Sync + 'static; } /// This trait providing serialization and deserialization methods for cell data. /// @@ -118,7 +117,7 @@ pub trait TypeOptionTransform: TypeOption { /// /// # Arguments /// - /// * `cell_str`: the cell string of the current field type + /// * `cell`: the cell in the current field type /// * `transformed_field_type`: the cell will be transformed to the is field type's cell data. /// current `TypeOption` field type. /// @@ -136,7 +135,6 @@ pub trait TypeOptionCellDataFilter: TypeOption + CellDataDecoder { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool; } @@ -202,6 +200,9 @@ pub fn type_option_data_from_pb>( FieldType::Checklist => { ChecklistTypeOptionPB::try_from(bytes).map(|pb| ChecklistTypeOption::from(pb).into()) }, + FieldType::Relation => { + RelationTypeOptionPB::try_from(bytes).map(|pb| RelationTypeOption::from(pb).into()) + }, } } @@ -257,6 +258,12 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) -> .try_into() .unwrap() }, + FieldType::Relation => { + let relation_type_option: RelationTypeOption = type_option.into(); + RelationTypeOptionPB::from(relation_type_option) + .try_into() + .unwrap() + }, } } @@ -276,5 +283,6 @@ pub fn default_type_option_data_from_type(field_type: FieldType) -> TypeOptionDa FieldType::Checkbox => CheckboxTypeOption::default().into(), FieldType::URL => URLTypeOption::default().into(), FieldType::Checklist => ChecklistTypeOption.into(), + FieldType::Relation => RelationTypeOption::default().into(), } } 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 f98bcd0cc09a4..0c2b8a73da876 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 @@ -9,14 +9,13 @@ use flowy_error::FlowyResult; use lib_infra::box_any::BoxAny; use crate::entities::FieldType; -use crate::services::cell::{ - CellCache, CellDataChangeset, CellDataDecoder, CellFilterCache, CellProtobufBlob, -}; +use crate::services::cell::{CellCache, CellDataChangeset, CellDataDecoder, CellProtobufBlob}; use crate::services::field::checklist_type_option::ChecklistTypeOption; use crate::services::field::{ - CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RichTextTypeOption, - SingleSelectTypeOption, TimestampTypeOption, TypeOption, TypeOptionCellDataCompare, - TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption, + CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption, + RichTextTypeOption, SingleSelectTypeOption, TimestampTypeOption, TypeOption, + TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, + TypeOptionTransform, URLTypeOption, }; use crate::services::sort::SortCondition; @@ -32,7 +31,7 @@ pub const CELL_DATA: &str = "data"; /// 2. there are no generic types parameters. /// pub trait TypeOptionCellDataHandler: Send + Sync + 'static { - fn handle_cell_str( + fn handle_cell_protobuf( &self, cell: &Cell, decoded_field_type: &FieldType, @@ -54,7 +53,7 @@ pub trait TypeOptionCellDataHandler: Send + Sync + 'static { sort_condition: SortCondition, ) -> Ordering; - fn handle_cell_filter(&self, field_type: &FieldType, field: &Field, cell: &Cell) -> bool; + fn handle_cell_filter(&self, field: &Field, cell: &Cell, filter: &BoxAny) -> bool; /// Format the cell to string using the passed-in [FieldType] and [Field]. /// The [Cell] is generic, so we need to know the [FieldType] and [Field] to format the cell. @@ -99,7 +98,6 @@ impl AsRef for CellDataCacheKey { struct TypeOptionCellDataHandlerImpl { inner: T, cell_data_cache: Option, - cell_filter_cache: Option, } impl TypeOptionCellDataHandlerImpl @@ -121,13 +119,11 @@ where pub fn new_with_boxed( inner: T, - cell_filter_cache: Option, cell_data_cache: Option, ) -> Box { Self { inner, cell_data_cache, - cell_filter_cache, } .into_boxed() } @@ -142,7 +138,7 @@ where cell: &Cell, decoded_field_type: &FieldType, field: &Field, - ) -> FlowyResult<::CellData> { + ) -> FlowyResult { let key = CellDataCacheKey::new(field, *decoded_field_type, cell); if let Some(cell_data_cache) = self.cell_data_cache.as_ref() { let read_guard = cell_data_cache.read(); @@ -172,12 +168,7 @@ where Ok(cell_data) } - fn set_decoded_cell_data( - &self, - cell: &Cell, - cell_data: ::CellData, - field: &Field, - ) { + fn set_decoded_cell_data(&self, cell: &Cell, cell_data: T::CellData, field: &Field) { if let Some(cell_data_cache) = self.cell_data_cache.as_ref() { let field_type = FieldType::from(field.field_type); let key = CellDataCacheKey::new(field, field_type, cell); @@ -200,16 +191,6 @@ impl std::ops::Deref for TypeOptionCellDataHandlerImpl { } } -impl TypeOption for TypeOptionCellDataHandlerImpl -where - T: TypeOption + Send + Sync, -{ - type CellData = T::CellData; - type CellChangeset = T::CellChangeset; - type CellProtobufType = T::CellProtobufType; - type CellFilter = T::CellFilter; -} - impl TypeOptionCellDataHandler for TypeOptionCellDataHandlerImpl where T: TypeOption @@ -223,7 +204,7 @@ where + Sync + 'static, { - fn handle_cell_str( + fn handle_cell_protobuf( &self, cell: &Cell, decoded_field_type: &FieldType, @@ -231,7 +212,7 @@ where ) -> FlowyResult { let cell_data = self .get_cell_data(cell, decoded_field_type, field_rev)? - .unbox_or_default::<::CellData>(); + .unbox_or_default::(); CellProtobufBlob::from(self.protobuf_encode(cell_data)) } @@ -242,7 +223,7 @@ where old_cell: Option, field: &Field, ) -> FlowyResult { - let changeset = cell_changeset.unbox_or_error::<::CellChangeset>()?; + let changeset = cell_changeset.unbox_or_error::()?; let (cell, cell_data) = self.apply_changeset(changeset, old_cell)?; self.set_decoded_cell_data(&cell, cell_data, field); Ok(cell) @@ -307,12 +288,12 @@ where } } - fn handle_cell_filter(&self, field_type: &FieldType, field: &Field, cell: &Cell) -> bool { + fn handle_cell_filter(&self, field: &Field, cell: &Cell, filter: &BoxAny) -> bool { let perform_filter = || { - let filter_cache = self.cell_filter_cache.as_ref()?.read(); - let cell_filter = filter_cache.get::<::CellFilter>(&field.id)?; - let cell_data = self.get_decoded_cell_data(cell, field_type, field).ok()?; - Some(self.apply_filter(cell_filter, field_type, &cell_data)) + let field_type = FieldType::from(field.field_type); + let cell_filter = filter.downcast_ref::()?; + let cell_data = self.get_decoded_cell_data(cell, &field_type, field).ok()?; + Some(self.apply_filter(cell_filter, &cell_data)) }; perform_filter().unwrap_or(true) @@ -361,28 +342,16 @@ where pub struct TypeOptionCellExt<'a> { field: &'a Field, cell_data_cache: Option, - cell_filter_cache: Option, } impl<'a> TypeOptionCellExt<'a> { - pub fn new_with_cell_data_cache(field: &'a Field, cell_data_cache: Option) -> Self { + pub fn new(field: &'a Field, cell_data_cache: Option) -> Self { Self { field, cell_data_cache, - cell_filter_cache: None, } } - pub fn new( - field: &'a Field, - cell_data_cache: Option, - cell_filter_cache: Option, - ) -> Self { - let mut this = Self::new_with_cell_data_cache(field, cell_data_cache); - this.cell_filter_cache = cell_filter_cache; - this - } - pub fn get_cells(&self) -> Vec { let field_type = FieldType::from(self.field.field_type); match self.get_type_option_cell_data_handler(&field_type) { @@ -402,93 +371,63 @@ impl<'a> TypeOptionCellExt<'a> { .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::Number => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::DateTime => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::LastEditedTime | FieldType::CreatedTime => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::SingleSelect => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::MultiSelect => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::Checkbox => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), FieldType::URL => { self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }) }, FieldType::Checklist => self .field .get_type_option::(field_type) .map(|type_option| { - TypeOptionCellDataHandlerImpl::new_with_boxed( - type_option, - self.cell_filter_cache.clone(), - self.cell_data_cache.clone(), - ) + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) + }), + FieldType::Relation => self + .field + .get_type_option::(field_type) + .map(|type_option| { + TypeOptionCellDataHandlerImpl::new_with_boxed(type_option, self.cell_data_cache.clone()) }), } } @@ -568,6 +507,9 @@ fn get_type_option_transform_handler( FieldType::Checklist => { Box::new(ChecklistTypeOption::from(type_option_data)) as Box }, + FieldType::Relation => { + Box::new(RelationTypeOption::from(type_option_data)) as Box + }, } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs index b236b31018530..447b5cbf56922 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs @@ -112,13 +112,8 @@ impl TypeOptionCellDataFilter for URLTypeOption { fn apply_filter( &self, filter: &::CellFilter, - field_type: &FieldType, cell_data: &::CellData, ) -> bool { - if !field_type.is_url() { - return true; - } - filter.is_visible(cell_data) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs index 442a6f062b3d4..e378990146e4c 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use flowy_error::{internal_error, FlowyResult}; use crate::entities::{FieldType, URLCellDataPB}; -use crate::services::cell::{CellProtobufBlobParser, DecodedCellData, FromCellString}; +use crate::services::cell::CellProtobufBlobParser; use crate::services::field::{TypeOptionCellData, CELL_DATA}; #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -60,14 +60,6 @@ impl From for URLCellDataPB { } } -impl DecodedCellData for URLCellDataPB { - type Object = URLCellDataPB; - - fn is_empty(&self) -> bool { - self.content.is_empty() - } -} - impl From for URLCellData { fn from(data: URLCellDataPB) -> Self { Self { @@ -79,15 +71,7 @@ impl From for URLCellData { impl AsRef for URLCellData { fn as_ref(&self) -> &str { - &self.url - } -} - -impl DecodedCellData for URLCellData { - type Object = URLCellData; - - fn is_empty(&self) -> bool { - self.data.is_empty() + &self.data } } @@ -100,12 +84,6 @@ impl CellProtobufBlobParser for URLCellDataParser { } } -impl FromCellString for URLCellData { - fn from_cell_str(s: &str) -> FlowyResult { - serde_json::from_str::(s).map_err(internal_error) - } -} - impl ToString for URLCellData { fn to_string(&self) -> String { self.to_json().unwrap() 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 983dfef1a887c..6f700ca7c0c0d 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/controller.rs @@ -2,8 +2,9 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; +use collab_database::database::gen_database_filter_id; use collab_database::fields::Field; -use collab_database::rows::{Cell, Row, RowDetail, RowId}; +use collab_database::rows::{Cell, Cells, Row, RowDetail, RowId}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; @@ -14,33 +15,31 @@ use lib_infra::priority_task::{QualityOfService, Task, TaskContent, TaskDispatch use crate::entities::filter_entities::*; use crate::entities::{FieldType, InsertedRowPB, RowMetaPB}; -use crate::services::cell::{CellCache, CellFilterCache}; +use crate::services::cell::CellCache; use crate::services::database_view::{DatabaseViewChanged, DatabaseViewChangedNotifier}; -use crate::services::field::*; -use crate::services::filter::{Filter, FilterChangeset, FilterResult, FilterResultNotification}; -use crate::utils::cache::AnyTypeCache; +use crate::services::field::TypeOptionCellExt; +use crate::services::filter::{Filter, FilterChangeset, FilterInner, FilterResultNotification}; pub trait FilterDelegate: Send + Sync + 'static { - fn get_filter(&self, view_id: &str, filter_id: &str) -> Fut>>; fn get_field(&self, field_id: &str) -> Option; - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>>; + fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>; fn get_rows(&self, view_id: &str) -> Fut>>; fn get_row(&self, view_id: &str, rows_id: &RowId) -> Fut)>>; + fn get_all_filters(&self, view_id: &str) -> Vec; + fn save_filters(&self, view_id: &str, filters: &[Filter]); } -pub trait FromFilterString { - fn from_filter(filter: &Filter) -> Self - where - Self: Sized; +pub trait PreFillCellsWithFilter { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool); } pub struct FilterController { view_id: String, handler_id: String, delegate: Box, - result_by_row_id: DashMap, + result_by_row_id: DashMap, cell_cache: CellCache, - cell_filter_cache: CellFilterCache, + filters: RwLock>, task_scheduler: Arc>, notifier: DatabaseViewChangedNotifier, } @@ -57,26 +56,56 @@ impl FilterController { handler_id: &str, delegate: T, task_scheduler: Arc>, - filters: Vec>, cell_cache: CellCache, notifier: DatabaseViewChangedNotifier, ) -> Self where T: FilterDelegate + 'static, { - let this = Self { + // ensure every filter is valid + let field_ids = delegate + .get_fields(view_id, None) + .await + .into_iter() + .map(|field| field.id) + .collect::>(); + + let mut need_save = false; + + let mut filters = delegate.get_all_filters(view_id); + let mut filtering_field_ids: HashMap> = HashMap::new(); + + for filter in filters.iter() { + filter.get_all_filtering_field_ids(&mut filtering_field_ids); + } + + let mut delete_filter_ids = vec![]; + + for (field_id, filter_ids) in &filtering_field_ids { + if !field_ids.contains(field_id) { + need_save = true; + delete_filter_ids.extend(filter_ids); + } + } + + for filter_id in delete_filter_ids { + Self::delete_filter(&mut filters, filter_id); + } + + if need_save { + delegate.save_filters(view_id, &filters); + } + + Self { view_id: view_id.to_string(), handler_id: handler_id.to_string(), delegate: Box::new(delegate), result_by_row_id: DashMap::default(), cell_cache, - // Cache by field_id - cell_filter_cache: AnyTypeCache::::new(), + filters: RwLock::new(filters), task_scheduler, notifier, - }; - this.refresh_filters(filters).await; - this + } } pub async fn close(&self) { @@ -100,7 +129,9 @@ impl FilterController { } pub async fn filter_rows(&self, rows: &mut Vec>) { - if self.cell_filter_cache.read().is_empty() { + let filters = self.filters.read().await; + + if filters.is_empty() { return; } let field_by_field_id = self.get_field_map().await; @@ -110,7 +141,7 @@ impl FilterController { &self.result_by_row_id, &field_by_field_id, &self.cell_cache, - &self.cell_filter_cache, + &filters, ); }); @@ -118,19 +149,175 @@ impl FilterController { self .result_by_row_id .get(&row_detail.row.id) - .map(|result| result.is_visible()) + .map(|result| *result) .unwrap_or(false) }); } - async fn get_field_map(&self) -> HashMap> { + pub async fn did_receive_row_changed(&self, row_id: RowId) { + if !self.filters.read().await.is_empty() { + self + .gen_task( + FilterEvent::RowDidChanged(row_id), + QualityOfService::UserInteractive, + ) + .await + } + } + + #[tracing::instrument(level = "trace", skip(self))] + pub async fn apply_changeset(&self, changeset: FilterChangeset) -> FilterChangesetNotificationPB { + let mut filters = self.filters.write().await; + + match changeset { + FilterChangeset::Insert { + parent_filter_id, + data, + } => { + let new_filter = Filter { + id: gen_database_filter_id(), + inner: data, + }; + match parent_filter_id { + Some(parent_filter_id) => { + if let Some(parent_filter) = filters + .iter_mut() + .find_map(|filter| filter.find_filter(&parent_filter_id)) + { + // TODO(RS): error handling for inserting filters + let _result = parent_filter.insert_filter(new_filter); + } + }, + None => { + filters.push(new_filter); + }, + } + }, + FilterChangeset::UpdateType { + filter_id, + filter_type, + } => { + for filter in filters.iter_mut() { + let filter = filter.find_filter(&filter_id); + if let Some(filter) = filter { + let result = filter.convert_to_and_or_filter_type(filter_type); + if result.is_ok() { + break; + } + } + } + }, + FilterChangeset::UpdateData { filter_id, data } => { + if let Some(filter) = filters + .iter_mut() + .find_map(|filter| filter.find_filter(&filter_id)) + { + // TODO(RS): error handling for updating filter data + let _result = filter.update_filter_data(data); + } + }, + FilterChangeset::Delete { + filter_id, + field_id: _, + } => Self::delete_filter(&mut filters, &filter_id), + FilterChangeset::DeleteAllWithFieldId { field_id } => { + let mut filter_ids = vec![]; + for filter in filters.iter() { + filter.find_all_filters_with_field_id(&field_id, &mut filter_ids); + } + for filter_id in filter_ids { + Self::delete_filter(&mut filters, &filter_id) + } + }, + } + + self.delegate.save_filters(&self.view_id, &filters); + self - .delegate - .get_fields(&self.view_id, None) - .await - .into_iter() - .map(|field| (field.id.clone(), field)) - .collect::>>() + .gen_task(FilterEvent::FilterDidChanged, QualityOfService::Background) + .await; + + FilterChangesetNotificationPB::from_filters(&self.view_id, &filters) + } + + pub async fn fill_cells(&self, cells: &mut Cells) -> bool { + let filters = self.filters.read().await; + + let mut open_after_create = false; + + let mut min_required_filters: Vec<&FilterInner> = vec![]; + for filter in filters.iter() { + filter.get_min_effective_filters(&mut min_required_filters); + } + + let field_map = self.get_field_map().await; + + while let Some(current_inner) = min_required_filters.pop() { + if let FilterInner::Data { + field_id, + field_type, + condition_and_content, + } = ¤t_inner + { + if min_required_filters.iter().any( + |inner| matches!(inner, FilterInner::Data { field_id: other_id, .. } if other_id == field_id), + ) { + min_required_filters.retain( + |inner| matches!(inner, FilterInner::Data { field_id: other_id, .. } if other_id != field_id), + ); + open_after_create = true; + continue; + } + + if let Some(field) = field_map.get(field_id) { + let (cell, flag) = match field_type { + FieldType::RichText | FieldType::URL => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, + FieldType::Number => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, + FieldType::DateTime => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, + FieldType::SingleSelect => { + let filter = condition_and_content + .cloned::() + .unwrap(); + filter.get_compliant_cell(field) + }, + FieldType::MultiSelect => { + let filter = condition_and_content + .cloned::() + .unwrap(); + filter.get_compliant_cell(field) + }, + FieldType::Checkbox => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, + FieldType::Checklist => { + let filter = condition_and_content.cloned::().unwrap(); + filter.get_compliant_cell(field) + }, + _ => (None, false), + }; + + if let Some(cell) = cell { + cells.insert(field_id.clone(), cell); + } + + if flag { + open_after_create = flag; + } + } + } + } + + open_after_create } #[tracing::instrument( @@ -143,22 +330,24 @@ impl FilterController { pub async fn process(&self, predicate: &str) -> FlowyResult<()> { let event_type = FilterEvent::from_str(predicate).unwrap(); match event_type { - FilterEvent::FilterDidChanged => self.filter_all_rows().await?, - FilterEvent::RowDidChanged(row_id) => self.filter_row(row_id).await?, + FilterEvent::FilterDidChanged => self.filter_all_rows_handler().await?, + FilterEvent::RowDidChanged(row_id) => self.filter_single_row_handler(row_id).await?, } Ok(()) } - async fn filter_row(&self, row_id: RowId) -> FlowyResult<()> { + async fn filter_single_row_handler(&self, row_id: RowId) -> FlowyResult<()> { + let filters = self.filters.read().await; + if let Some((_, row_detail)) = self.delegate.get_row(&self.view_id, &row_id).await { let field_by_field_id = self.get_field_map().await; let mut notification = FilterResultNotification::new(self.view_id.clone()); - if let Some((row_id, is_visible)) = filter_row( + if let Some(is_visible) = filter_row( &row_detail.row, &self.result_by_row_id, &field_by_field_id, &self.cell_cache, - &self.cell_filter_cache, + &filters, ) { if is_visible { if let Some((index, _row)) = self.delegate.get_row(&self.view_id, &row_id).await { @@ -178,7 +367,9 @@ impl FilterController { Ok(()) } - async fn filter_all_rows(&self) -> FlowyResult<()> { + async fn filter_all_rows_handler(&self) -> FlowyResult<()> { + let filters = self.filters.read().await; + let field_by_field_id = self.get_field_map().await; let mut visible_rows = vec![]; let mut invisible_rows = vec![]; @@ -190,18 +381,18 @@ impl FilterController { .into_iter() .enumerate() { - if let Some((row_id, is_visible)) = filter_row( + if let Some(is_visible) = filter_row( &row_detail.row, &self.result_by_row_id, &field_by_field_id, &self.cell_cache, - &self.cell_filter_cache, + &filters, ) { if is_visible { let row_meta = RowMetaPB::from(row_detail.as_ref()); visible_rows.push(InsertedRowPB::new(row_meta).with_index(index as i32)) } else { - invisible_rows.push(row_id); + invisible_rows.push(row_detail.row.id.clone()); } } } @@ -211,219 +402,142 @@ impl FilterController { invisible_rows, visible_rows, }; - tracing::Span::current().record("filter_result", format!("{:?}", ¬ification).as_str()); + tracing::trace!("filter result {:?}", filters); let _ = self .notifier .send(DatabaseViewChanged::FilterNotification(notification)); + Ok(()) } - pub async fn did_receive_row_changed(&self, row_id: RowId) { - if !self.cell_filter_cache.read().is_empty() { - self - .gen_task( - FilterEvent::RowDidChanged(row_id), - QualityOfService::UserInteractive, - ) - .await - } + async fn get_field_map(&self) -> HashMap { + self + .delegate + .get_fields(&self.view_id, None) + .await + .into_iter() + .map(|field| (field.id.clone(), field)) + .collect::>() } - #[tracing::instrument(level = "trace", skip(self))] - pub async fn did_receive_changes( - &self, - changeset: FilterChangeset, - ) -> Option { - let mut notification: Option = None; - - if let Some(filter_type) = &changeset.insert_filter { - if let Some(filter) = self.filter_from_filter_id(&filter_type.id).await { - notification = Some(FilterChangesetNotificationPB::from_insert( - &self.view_id, - vec![filter], - )); - } - if let Some(filter) = self - .delegate - .get_filter(&self.view_id, &filter_type.id) - .await - { - self.refresh_filters(vec![filter]).await; - } - } - - if let Some(updated_filter_type) = changeset.update_filter { - if let Some(old_filter_type) = updated_filter_type.old { - let new_filter = self - .filter_from_filter_id(&updated_filter_type.new.id) - .await; - let old_filter = self.filter_from_filter_id(&old_filter_type.id).await; - - // Get the filter id - let mut filter_id = old_filter.map(|filter| filter.id); - if filter_id.is_none() { - filter_id = new_filter.as_ref().map(|filter| filter.id.clone()); - } - - if let Some(filter_id) = filter_id { - // Update the corresponding filter in the cache - if let Some(filter) = self.delegate.get_filter(&self.view_id, &filter_id).await { - self.refresh_filters(vec![filter]).await; - } + fn delete_filter(filters: &mut Vec, filter_id: &str) { + let mut find_root_filter: Option = None; + let mut find_parent_of_non_root_filter: Option<&mut Filter> = None; - notification = Some(FilterChangesetNotificationPB::from_update( - &self.view_id, - vec![UpdatedFilter { - filter_id, - filter: new_filter, - }], - )); - } + for (position, filter) in filters.iter_mut().enumerate() { + if filter.id == filter_id { + find_root_filter = Some(position); + break; } - } - - if let Some(filter_context) = &changeset.delete_filter { - if let Some(filter) = self.filter_from_filter_id(&filter_context.filter_id).await { - notification = Some(FilterChangesetNotificationPB::from_delete( - &self.view_id, - vec![filter], - )); + if let Some(filter) = filter.find_parent_of_filter(filter_id) { + find_parent_of_non_root_filter = Some(filter); + break; } - self - .cell_filter_cache - .write() - .remove(&filter_context.field_id); } - self - .gen_task(FilterEvent::FilterDidChanged, QualityOfService::Background) - .await; - tracing::trace!("{:?}", notification); - notification - } - - async fn filter_from_filter_id(&self, filter_id: &str) -> Option { - self - .delegate - .get_filter(&self.view_id, filter_id) - .await - .map(|filter| FilterPB::from(filter.as_ref())) - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn refresh_filters(&self, filters: Vec>) { - for filter in filters { - let field_id = &filter.field_id; - tracing::trace!("Create filter with type: {:?}", filter.field_type); - match &filter.field_type { - FieldType::RichText => { - self - .cell_filter_cache - .write() - .insert(field_id, TextFilterPB::from_filter(filter.as_ref())); - }, - FieldType::Number => { - self - .cell_filter_cache - .write() - .insert(field_id, NumberFilterPB::from_filter(filter.as_ref())); - }, - FieldType::DateTime | FieldType::LastEditedTime | FieldType::CreatedTime => { - self - .cell_filter_cache - .write() - .insert(field_id, DateFilterPB::from_filter(filter.as_ref())); - }, - FieldType::SingleSelect | FieldType::MultiSelect => { - self - .cell_filter_cache - .write() - .insert(field_id, SelectOptionFilterPB::from_filter(filter.as_ref())); - }, - FieldType::Checkbox => { - self - .cell_filter_cache - .write() - .insert(field_id, CheckboxFilterPB::from_filter(filter.as_ref())); - }, - FieldType::URL => { - self - .cell_filter_cache - .write() - .insert(field_id, TextFilterPB::from_filter(filter.as_ref())); - }, - FieldType::Checklist => { - self - .cell_filter_cache - .write() - .insert(field_id, ChecklistFilterPB::from_filter(filter.as_ref())); - }, + if let Some(pos) = find_root_filter { + filters.remove(pos); + } else if let Some(filter) = find_parent_of_non_root_filter { + if let Err(err) = filter.delete_filter(filter_id) { + tracing::error!("error while deleting filter: {}", err); } } } } -/// Returns None if there is no change in this row after applying the filter +/// Returns `Some` if the visibility of the row changed after applying the filter and `None` +/// otherwise #[tracing::instrument(level = "trace", skip_all)] fn filter_row( row: &Row, - result_by_row_id: &DashMap, - field_by_field_id: &HashMap>, + result_by_row_id: &DashMap, + field_by_field_id: &HashMap, cell_data_cache: &CellCache, - cell_filter_cache: &CellFilterCache, -) -> Option<(RowId, bool)> { - // Create a filter result cache if it's not exist - let mut filter_result = result_by_row_id.entry(row.id.clone()).or_default(); - let old_is_visible = filter_result.is_visible(); - - // Iterate each cell of the row to check its visibility - for (field_id, field) in field_by_field_id { - if !cell_filter_cache.read().contains(field_id) { - filter_result.visible_by_field_id.remove(field_id); - continue; - } + filters: &Vec, +) -> Option { + // Create a filter result cache if it doesn't exist + let mut filter_result = result_by_row_id.entry(row.id.clone()).or_insert(true); + let old_is_visible = *filter_result; - let cell = row.cells.get(field_id).cloned(); - let field_type = FieldType::from(field.field_type); - // if the visibility of the cell_rew is changed, which means the visibility of the - // row is changed too. - if let Some(is_visible) = - filter_cell(&field_type, field, cell, cell_data_cache, cell_filter_cache) - { - filter_result - .visible_by_field_id - .insert(field_id.to_string(), is_visible); + let mut new_is_visible = true; + + for filter in filters { + if let Some(is_visible) = apply_filter(row, field_by_field_id, cell_data_cache, filter) { + new_is_visible = new_is_visible && is_visible; + + // short-circuit as soon as one filter tree returns false + if !new_is_visible { + break; + } } } - let is_visible = filter_result.is_visible(); - if old_is_visible != is_visible { - Some((row.id.clone(), is_visible)) + *filter_result = new_is_visible; + + if old_is_visible != new_is_visible { + Some(new_is_visible) } else { None } } -// Returns None if there is no change in this cell after applying the filter -// Returns Some if the visibility of the cell is changed - -#[tracing::instrument(level = "trace", skip_all, fields(cell_content))] -fn filter_cell( - field_type: &FieldType, - field: &Arc, - cell: Option, +/// Recursively applies a `Filter` to a `Row`'s cells. +fn apply_filter( + row: &Row, + field_by_field_id: &HashMap, cell_data_cache: &CellCache, - cell_filter_cache: &CellFilterCache, + filter: &Filter, ) -> Option { - let handler = TypeOptionCellExt::new( - field.as_ref(), - Some(cell_data_cache.clone()), - Some(cell_filter_cache.clone()), - ) - .get_type_option_cell_data_handler(field_type)?; - let is_visible = - handler.handle_cell_filter(field_type, field.as_ref(), &cell.unwrap_or_default()); - Some(is_visible) + match &filter.inner { + FilterInner::And { children } => { + if children.is_empty() { + return None; + } + for child_filter in children.iter() { + if let Some(false) = apply_filter(row, field_by_field_id, cell_data_cache, child_filter) { + return Some(false); + } + } + Some(true) + }, + FilterInner::Or { children } => { + if children.is_empty() { + return None; + } + for child_filter in children.iter() { + if let Some(true) = apply_filter(row, field_by_field_id, cell_data_cache, child_filter) { + return Some(true); + } + } + Some(false) + }, + FilterInner::Data { + field_id, + field_type, + condition_and_content, + } => { + let field = match field_by_field_id.get(field_id) { + Some(field) => field, + None => { + tracing::error!("cannot find field"); + return Some(false); + }, + }; + if *field_type != FieldType::from(field.field_type) { + tracing::error!("field type of filter doesn't match field type of field"); + return Some(false); + } + let cell = row.cells.get(field_id).cloned(); + let field_type = FieldType::from(field.field_type); + if let Some(handler) = TypeOptionCellExt::new(field, Some(cell_data_cache.clone())) + .get_type_option_cell_data_handler(&field_type) + { + Some(handler.handle_cell_filter(field, &cell.unwrap_or_default(), condition_and_content)) + } else { + Some(true) + } + }, + } } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -440,6 +554,7 @@ impl ToString for FilterEvent { impl FromStr for FilterEvent { type Err = serde_json::Error; + fn from_str(s: &str) -> Result { serde_json::from_str(s) } diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs index 27e220b0c277d..f12bc415d4bd0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs @@ -1,127 +1,452 @@ +use std::collections::HashMap; +use std::mem; + use anyhow::bail; use collab::core::any_map::AnyMapExtension; +use collab_database::database::gen_database_filter_id; use collab_database::rows::RowId; use collab_database::views::{FilterMap, FilterMapBuilder}; +use flowy_error::{FlowyError, FlowyResult}; +use lib_infra::box_any::BoxAny; + +use crate::entities::{ + CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType, + InsertedRowPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB, +}; +use crate::services::field::SelectOptionIds; -use crate::entities::{FieldType, FilterPB, InsertedRowPB}; +pub trait ParseFilterData { + fn parse(condition: u8, content: String) -> Self; +} -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct Filter { pub id: String, - pub field_id: String, - pub field_type: FieldType, - pub condition: i64, - pub content: String, + pub inner: FilterInner, } -const FILTER_ID: &str = "id"; -const FIELD_ID: &str = "field_id"; -const FIELD_TYPE: &str = "ty"; -const FILTER_CONDITION: &str = "condition"; -const FILTER_CONTENT: &str = "content"; +impl Filter { + /// Recursively determine whether there are any data filters in the filter tree. A tree that has + /// multiple AND/OR filters but no Data filters is considered "empty". + pub fn is_empty(&self) -> bool { + match &self.inner { + FilterInner::And { children } | FilterInner::Or { children } => children + .iter() + .map(|filter| filter.is_empty()) + .all(|is_empty| is_empty), + FilterInner::Data { .. } => false, + } + } -impl From for FilterMap { - fn from(data: Filter) -> Self { - FilterMapBuilder::new() - .insert_str_value(FILTER_ID, data.id) - .insert_str_value(FIELD_ID, data.field_id) - .insert_str_value(FILTER_CONTENT, data.content) - .insert_i64_value(FIELD_TYPE, data.field_type.into()) - .insert_i64_value(FILTER_CONDITION, data.condition) - .build() + /// Recursively find a filter based on `filter_id`. Returns `None` if the filter cannot be found. + pub fn find_filter(&mut self, filter_id: &str) -> Option<&mut Self> { + if self.id == filter_id { + return Some(self); + } + match &mut self.inner { + FilterInner::And { children } | FilterInner::Or { children } => { + for child_filter in children.iter_mut() { + let result = child_filter.find_filter(filter_id); + if result.is_some() { + return result; + } + } + None + }, + FilterInner::Data { .. } => None, + } } -} -impl TryFrom for Filter { - type Error = anyhow::Error; + /// Recursively find the parent of a filter whose id is `filter_id`. Returns `None` if the filter + /// cannot be found. + pub fn find_parent_of_filter(&mut self, filter_id: &str) -> Option<&mut Self> { + if self.id == filter_id { + return None; + } + match &mut self.inner { + FilterInner::And { children } | FilterInner::Or { children } => { + for child_filter in children.iter_mut() { + if child_filter.id == filter_id { + return Some(child_filter); + } + let result = child_filter.find_parent_of_filter(filter_id); + if result.is_some() { + return result; + } + } + None + }, + FilterInner::Data { .. } => None, + } + } - fn try_from(filter: FilterMap) -> Result { - match ( - filter.get_str_value(FILTER_ID), - filter.get_str_value(FIELD_ID), - ) { - (Some(id), Some(field_id)) => { - let condition = filter.get_i64_value(FILTER_CONDITION).unwrap_or(0); - let content = filter.get_str_value(FILTER_CONTENT).unwrap_or_default(); - let field_type = filter - .get_i64_value(FIELD_TYPE) - .map(FieldType::from) - .unwrap_or_default(); - Ok(Filter { - id, - field_id, - field_type, - condition, - content, - }) + /// Converts a filter from And/Or/Data to And/Or. If the current type of the filter is Data, + /// return the FilterInner after the conversion. + pub fn convert_to_and_or_filter_type( + &mut self, + filter_type: FilterType, + ) -> FlowyResult> { + match (&mut self.inner, filter_type) { + (FilterInner::And { children }, FilterType::Or) => { + self.inner = FilterInner::Or { + children: mem::take(children), + }; + Ok(None) + }, + (FilterInner::Or { children }, FilterType::And) => { + self.inner = FilterInner::And { + children: mem::take(children), + }; + Ok(None) + }, + (FilterInner::Data { .. }, FilterType::And) => { + let mut inner = FilterInner::And { children: vec![] }; + mem::swap(&mut self.inner, &mut inner); + Ok(Some(inner)) + }, + (FilterInner::Data { .. }, FilterType::Or) => { + let mut inner = FilterInner::Or { children: vec![] }; + mem::swap(&mut self.inner, &mut inner); + Ok(Some(inner)) + }, + (_, FilterType::Data) => { + // from And/Or to Data + Err(FlowyError::internal().with_context(format!( + "conversion from {:?} to FilterType::Data not supported", + FilterType::from(&self.inner) + ))) }, _ => { - bail!("Invalid filter data") + tracing::warn!("conversion to the same filter type"); + Ok(None) }, } } -} -#[derive(Debug)] -pub struct FilterChangeset { - pub(crate) insert_filter: Option, - pub(crate) update_filter: Option, - pub(crate) delete_filter: Option, -} -#[derive(Debug)] -pub struct UpdatedFilter { - pub old: Option, - pub new: Filter, -} + /// Insert a filter into the current filter in the filter tree. If the current filter + /// is an AND/OR filter, then the filter is appended to its children. Otherwise, the current + /// filter is converted to an AND filter, after which the current data filter and the new filter + /// are added to the AND filter's children. + pub fn insert_filter(&mut self, filter: Filter) -> FlowyResult<()> { + match &mut self.inner { + FilterInner::And { children } | FilterInner::Or { children } => { + children.push(filter); + }, + FilterInner::Data { .. } => { + // convert to FilterInner::And by default + let old_filter = self + .convert_to_and_or_filter_type(FilterType::And) + .and_then(|result| { + result.ok_or_else(|| FlowyError::internal().with_context("failed to convert filter")) + })?; + self.insert_filter(Filter { + id: gen_database_filter_id(), + inner: old_filter, + })?; + self.insert_filter(filter)?; + }, + } -impl UpdatedFilter { - pub fn new(old: Option, new: Filter) -> UpdatedFilter { - Self { old, new } + Ok(()) } -} -impl FilterChangeset { - pub fn from_insert(filter: Filter) -> Self { - Self { - insert_filter: Some(filter), - update_filter: None, - delete_filter: None, + /// Update the criteria of a data filter. Return an error if the current filter is an AND/OR + /// filter. + pub fn update_filter_data(&mut self, filter_data: FilterInner) -> FlowyResult<()> { + match &self.inner { + FilterInner::And { .. } | FilterInner::Or { .. } => Err(FlowyError::internal().with_context( + format!("unexpected filter type {:?}", FilterType::from(&self.inner)), + )), + _ => { + self.inner = filter_data; + Ok(()) + }, } } - pub fn from_update(filter: UpdatedFilter) -> Self { - Self { - insert_filter: None, - update_filter: Some(filter), - delete_filter: None, + /// Delete a filter based on `filter_id`. The current filter must be the parent of the filter + /// whose id is `filter_id`. Returns an error if the current filter is a Data filter (which + /// cannot have children), or the filter to be deleted cannot be found. + pub fn delete_filter(&mut self, filter_id: &str) -> FlowyResult<()> { + match &mut self.inner { + FilterInner::And { children } | FilterInner::Or { children } => children + .iter() + .position(|filter| filter.id == filter_id) + .map(|position| { + children.remove(position); + }) + .ok_or_else(|| { + FlowyError::internal() + .with_context(format!("filter with filter_id {:?} not found", filter_id)) + }), + FilterInner::Data { .. } => Err( + FlowyError::internal().with_context("unexpected parent filter type of FilterInner::Data"), + ), } } - pub fn from_delete(filter_context: FilterContext) -> Self { - Self { - insert_filter: None, - update_filter: None, - delete_filter: Some(filter_context), + + /// Recursively finds any Data filter whose `field_id` is equal to `matching_field_id`. Any found + /// filters' id is appended to the `ids` vector. + pub fn find_all_filters_with_field_id(&self, matching_field_id: &str, ids: &mut Vec) { + match &self.inner { + FilterInner::And { children } | FilterInner::Or { children } => { + for child_filter in children.iter() { + child_filter.find_all_filters_with_field_id(matching_field_id, ids); + } + }, + FilterInner::Data { field_id, .. } => { + if field_id == matching_field_id { + ids.push(self.id.clone()); + } + }, + } + } + + /// Recursively determine the smallest set of filters that loosely represents the filter tree. The + /// filters are appended to the `min_effective_filters` vector. The following rules are followed + /// when determining if a filter should get included. If the current filter is: + /// + /// 1. a Data filter, then it should be included. + /// 2. an AND filter, then all of its effective children should be + /// included. + /// 3. an OR filter, then only the first child should be included. + pub fn get_min_effective_filters<'a>(&'a self, min_effective_filters: &mut Vec<&'a FilterInner>) { + match &self.inner { + FilterInner::And { children } => { + for filter in children.iter() { + filter.get_min_effective_filters(min_effective_filters); + } + }, + FilterInner::Or { children } => { + if let Some(filter) = children.first() { + filter.get_min_effective_filters(min_effective_filters); + } + }, + FilterInner::Data { .. } => min_effective_filters.push(&self.inner), + } + } + + /// Recursively get all of the filtering field ids and the associated filter_ids + pub fn get_all_filtering_field_ids(&self, field_ids: &mut HashMap>) { + match &self.inner { + FilterInner::And { children } | FilterInner::Or { children } => { + for child in children.iter() { + child.get_all_filtering_field_ids(field_ids); + } + }, + FilterInner::Data { field_id, .. } => { + field_ids + .entry(field_id.clone()) + .and_modify(|filter_ids| filter_ids.push(self.id.clone())) + .or_insert_with(|| vec![self.id.clone()]); + }, } } } -#[derive(Debug, Clone)] -pub struct FilterContext { - pub filter_id: String, - pub field_id: String, - pub field_type: FieldType, +#[derive(Debug)] +pub enum FilterInner { + And { + children: Vec, + }, + Or { + children: Vec, + }, + Data { + field_id: String, + field_type: FieldType, + condition_and_content: BoxAny, + }, } -impl From<&FilterPB> for FilterContext { - fn from(filter: &FilterPB) -> Self { - Self { - filter_id: filter.id.clone(), - field_id: filter.field_id.clone(), - field_type: filter.field_type, +impl FilterInner { + pub fn new_data( + field_id: String, + field_type: FieldType, + condition: i64, + content: String, + ) -> Self { + let condition_and_content = match field_type { + FieldType::RichText | FieldType::URL => { + BoxAny::new(TextFilterPB::parse(condition as u8, content)) + }, + FieldType::Number => BoxAny::new(NumberFilterPB::parse(condition as u8, content)), + FieldType::DateTime | FieldType::CreatedTime | FieldType::LastEditedTime => { + BoxAny::new(DateFilterPB::parse(condition as u8, content)) + }, + FieldType::SingleSelect | FieldType::MultiSelect => { + BoxAny::new(SelectOptionFilterPB::parse(condition as u8, content)) + }, + FieldType::Checklist => BoxAny::new(ChecklistFilterPB::parse(condition as u8, content)), + FieldType::Checkbox => BoxAny::new(CheckboxFilterPB::parse(condition as u8, content)), + FieldType::Relation => BoxAny::new(RelationFilterPB::parse(condition as u8, content)), + }; + + FilterInner::Data { + field_id, + field_type, + condition_and_content, + } + } + + pub fn get_int_repr(&self) -> i64 { + match self { + FilterInner::And { .. } => FILTER_AND_INDEX, + FilterInner::Or { .. } => FILTER_OR_INDEX, + FilterInner::Data { .. } => FILTER_DATA_INDEX, } } } +const FILTER_ID: &str = "id"; +const FILTER_TYPE: &str = "filter_type"; +const FIELD_ID: &str = "field_id"; +const FIELD_TYPE: &str = "ty"; +const FILTER_CONDITION: &str = "condition"; +const FILTER_CONTENT: &str = "content"; +const FILTER_CHILDREN: &str = "children"; + +const FILTER_AND_INDEX: i64 = 0; +const FILTER_OR_INDEX: i64 = 1; +const FILTER_DATA_INDEX: i64 = 2; + +impl<'a> From<&'a Filter> for FilterMap { + fn from(filter: &'a Filter) -> Self { + let mut builder = FilterMapBuilder::new() + .insert_str_value(FILTER_ID, &filter.id) + .insert_i64_value(FILTER_TYPE, filter.inner.get_int_repr()); + + builder = match &filter.inner { + FilterInner::And { children } | FilterInner::Or { children } => { + builder.insert_maps(FILTER_CHILDREN, children.iter().collect::>()) + }, + FilterInner::Data { + field_id, + field_type, + condition_and_content, + } => { + let get_raw_condition_and_content = || -> Option<(u8, String)> { + let (condition, content) = match field_type { + FieldType::RichText | FieldType::URL => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, + FieldType::Number => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, + FieldType::DateTime | FieldType::LastEditedTime | FieldType::CreatedTime => { + let filter = condition_and_content.cloned::()?; + let content = DateFilterContent { + start: filter.start, + end: filter.end, + timestamp: filter.timestamp, + } + .to_string(); + (filter.condition as u8, content) + }, + FieldType::SingleSelect | FieldType::MultiSelect => { + let filter = condition_and_content.cloned::()?; + let content = SelectOptionIds::from(filter.option_ids).to_string(); + (filter.condition as u8, content) + }, + FieldType::Checkbox => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, "".to_string()) + }, + FieldType::Checklist => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, "".to_string()) + }, + FieldType::Relation => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, "".to_string()) + }, + }; + Some((condition, content)) + }; + + let (condition, content) = get_raw_condition_and_content().unwrap_or_else(|| { + tracing::error!("cannot deserialize filter condition and content filter properly"); + Default::default() + }); + + builder + .insert_str_value(FIELD_ID, field_id) + .insert_i64_value(FIELD_TYPE, field_type.into()) + .insert_i64_value(FILTER_CONDITION, condition as i64) + .insert_str_value(FILTER_CONTENT, content) + }, + }; + + builder.build() + } +} + +impl TryFrom for Filter { + type Error = anyhow::Error; + + fn try_from(filter_map: FilterMap) -> Result { + let filter_id = filter_map + .get_str_value(FILTER_ID) + .ok_or_else(|| anyhow::anyhow!("invalid filter data"))?; + let filter_type = filter_map + .get_i64_value(FILTER_TYPE) + .unwrap_or(FILTER_DATA_INDEX); + + let filter = Filter { + id: filter_id, + inner: match filter_type { + FILTER_AND_INDEX => FilterInner::And { + children: filter_map.try_get_array(FILTER_CHILDREN), + }, + FILTER_OR_INDEX => FilterInner::Or { + children: filter_map.try_get_array(FILTER_CHILDREN), + }, + FILTER_DATA_INDEX => { + let field_id = filter_map + .get_str_value(FIELD_ID) + .ok_or_else(|| anyhow::anyhow!("invalid filter data"))?; + let field_type = filter_map + .get_i64_value(FIELD_TYPE) + .map(FieldType::from) + .unwrap_or_default(); + let condition = filter_map.get_i64_value(FILTER_CONDITION).unwrap_or(0); + let content = filter_map.get_str_value(FILTER_CONTENT).unwrap_or_default(); + + FilterInner::new_data(field_id, field_type, condition, content) + }, + _ => bail!("Unsupported filter type"), + }, + }; + + Ok(filter) + } +} + +#[derive(Debug)] +pub enum FilterChangeset { + Insert { + parent_filter_id: Option, + data: FilterInner, + }, + UpdateType { + filter_id: String, + filter_type: FilterType, + }, + UpdateData { + filter_id: String, + data: FilterInner, + }, + Delete { + filter_id: String, + field_id: String, + }, + DeleteAllWithFieldId { + field_id: String, + }, +} + #[derive(Clone, Debug)] pub struct FilterResultNotification { pub view_id: String, diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/task.rs b/frontend/rust-lib/flowy-database2/src/services/filter/task.rs index dbf55776df419..03ed453f89902 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/task.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/task.rs @@ -1,7 +1,6 @@ use crate::services::filter::FilterController; use lib_infra::future::BoxResultFuture; use lib_infra::priority_task::{TaskContent, TaskHandler}; -use std::collections::HashMap; use std::sync::Arc; pub struct FilterTaskHandler { @@ -40,21 +39,3 @@ impl TaskHandler for FilterTaskHandler { }) } } -/// Refresh the filter according to the field id. -#[derive(Default)] -pub(crate) struct FilterResult { - pub(crate) visible_by_field_id: HashMap, -} - -impl FilterResult { - pub(crate) fn is_visible(&self) -> bool { - let mut is_visible = true; - for visible in self.visible_by_field_id.values() { - if !is_visible { - break; - } - is_visible = *visible; - } - is_visible - } -} 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 11bd169591628..b540fb5fa33e7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/action.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/action.rs @@ -1,16 +1,15 @@ -use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; -use collab_database::rows::{Cell, Row, RowDetail, RowId}; +use collab_database::rows::{Cell, Cells, Row, RowDetail, RowId}; use flowy_error::FlowyResult; use crate::entities::{GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; use crate::services::field::TypeOption; -use crate::services::group::{GroupChangesets, GroupData, MoveGroupRowContext}; +use crate::services::group::{GroupChangeset, GroupData, MoveGroupRowContext}; -/// Using polymorphism to provides the customs action for different group controller. -/// -/// For example, the `CheckboxGroupController` implements this trait to provide custom behavior. +/// [GroupCustomize] is implemented by parameterized `BaseGroupController`s to provide different +/// behaviors. This allows the BaseGroupController to call these actions indescriminantly using +/// polymorphism. /// pub trait GroupCustomize: Send + Sync { type GroupTypeOption: TypeOption; @@ -57,11 +56,7 @@ pub trait GroupCustomize: Send + Sync { ) -> (Option, Vec); /// Move row from one group to another - fn move_row( - &mut self, - cell_data: &::CellProtobufType, - context: MoveGroupRowContext, - ) -> Vec; + fn move_row(&mut self, context: MoveGroupRowContext) -> Vec; /// Returns None if there is no need to delete the group when corresponding row get removed fn delete_group_when_move_row( @@ -72,21 +67,38 @@ pub trait GroupCustomize: Send + Sync { None } - fn generate_new_group( + fn create_group( &mut self, _name: String, ) -> FlowyResult<(Option, Option)> { Ok((None, None)) } - fn delete_group_custom(&mut self, group_id: &str) -> FlowyResult>; + fn delete_group(&mut self, group_id: &str) -> FlowyResult>; + + fn update_type_option_when_update_group( + &mut self, + _changeset: &GroupChangeset, + _type_option: &Self::GroupTypeOption, + ) -> Option { + None + } + + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str); } -/// Defines the shared actions any group controller can perform. -#[async_trait] -pub trait GroupControllerOperation: Send + Sync { +/// The `GroupController` trait defines the behavior of the group controller when performing any +/// group-related tasks, such as managing rows within a group, transferring rows between groups, +/// manipulating groups themselves, and even pre-filling a row's cells before it is created. +/// +/// Depending on the type of the field that is being grouped, a parameterized `BaseGroupController` +/// or a `DefaultGroupController` may be the actual object that provides the functionality of +/// this trait. For example, a `Single-Select` group controller will be a `BaseGroupController`, +/// while a `URL` group controller will be a `DefaultGroupController`. +/// +pub trait GroupController: Send + Sync { /// Returns the id of field that is being used to group the rows - fn field_id(&self) -> &str; + fn get_grouping_field_id(&self) -> &str; /// Returns all of the groups currently managed by the controller fn get_all_groups(&self) -> Vec<&GroupData>; @@ -175,10 +187,13 @@ pub trait GroupControllerOperation: Send + Sync { /// in the field type option data. /// /// * `changesets`: list of changesets to be made to one or more groups - async fn apply_group_changeset( + fn apply_group_changeset( &mut self, - changesets: &GroupChangesets, - ) -> FlowyResult<(Vec, TypeOptionData)>; + changesets: &[GroupChangeset], + ) -> FlowyResult<(Vec, Option)>; + + /// Called before the row was created. + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str); } #[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 128dd3054328e..6134b7d265e26 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs @@ -1,11 +1,8 @@ -use std::collections::HashMap; 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; @@ -21,28 +18,15 @@ use crate::services::group::{ default_group_setting, GeneratedGroups, Group, GroupChangeset, GroupData, GroupSetting, }; -pub trait GroupSettingReader: Send + Sync + 'static { +pub trait GroupContextDelegate: Send + Sync + 'static { fn get_group_setting(&self, view_id: &str) -> Fut>>; + fn get_configuration_cells(&self, view_id: &str, field_id: &str) -> Fut>; -} -pub trait GroupSettingWriter: Send + Sync + 'static { fn save_configuration(&self, view_id: &str, group_setting: GroupSetting) -> Fut>; } -#[async_trait] -pub trait GroupTypeOptionCellOperation: Send + Sync + 'static { - async fn get_cell(&self, row_id: &RowId, field_id: &str) -> FlowyResult>; - async fn update_cell( - &self, - view_id: &str, - row_id: &RowId, - field_id: &str, - cell: Cell, - ) -> FlowyResult<()>; -} - -impl std::fmt::Display for GroupContext { +impl std::fmt::Display for GroupControllerContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { self.group_by_id.iter().for_each(|(_, group)| { let _ = f.write_fmt(format_args!( @@ -56,12 +40,12 @@ impl std::fmt::Display for GroupContext { } } -/// A [GroupContext] represents as the groups memory cache -/// Each [GenericGroupController] has its own [GroupContext], the `context` has its own configuration +/// A [GroupControllerContext] represents as the groups memory cache +/// Each [GenericGroupController] has its own [GroupControllerContext], the `context` has its own configuration /// that is restored from the disk. /// /// The `context` contains a list of [GroupData]s and the grouping [Field] -pub struct GroupContext { +pub struct GroupControllerContext { pub view_id: String, /// The group configuration restored from the disk. /// @@ -70,39 +54,32 @@ pub struct GroupContext { configuration_phantom: PhantomData, - /// The grouping field - field: Arc, + /// The grouping field id + field_id: String, /// Cache all the groups. Cache the group by its id. /// We use the id of the [Field] as the [No Status] group id. group_by_id: IndexMap, - /// A reader that implement the [GroupSettingReader] trait - /// - reader: Arc, - - /// A writer that implement the [GroupSettingWriter] trait is used to save the - /// configuration to disk - /// - writer: Arc, + /// delegate that reads and writes data to and from disk + delegate: Arc, } -impl GroupContext +impl GroupControllerContext where C: Serialize + DeserializeOwned, { #[tracing::instrument(level = "trace", skip_all, err)] pub async fn new( view_id: String, - field: Arc, - reader: Arc, - writer: Arc, + field: Field, + delegate: Arc, ) -> FlowyResult { - event!(tracing::Level::TRACE, "GroupContext::new"); - let setting = match reader.get_group_setting(&view_id).await { + event!(tracing::Level::TRACE, "GroupControllerContext::new"); + let setting = match delegate.get_group_setting(&view_id).await { None => { let default_configuration = default_group_setting(&field); - writer + delegate .save_configuration(&view_id, default_configuration.clone()) .await?; Arc::new(default_configuration) @@ -112,10 +89,9 @@ where Ok(Self { view_id, - field, + field_id: field.id, group_by_id: IndexMap::new(), - reader, - writer, + delegate, setting, configuration_phantom: PhantomData, }) @@ -126,11 +102,11 @@ where /// 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) + self.group_by_id.get(&self.field_id) } pub(crate) fn get_mut_no_status_group(&mut self) -> Option<&mut GroupData> { - self.group_by_id.get_mut(&self.field.id) + self.group_by_id.get_mut(&self.field_id) } pub(crate) fn groups(&self) -> Vec<&GroupData> { @@ -155,7 +131,7 @@ where /// Iterate mut the groups without `No status` group pub(crate) fn iter_mut_status_groups(&mut self, mut each: impl FnMut(&mut GroupData)) { self.group_by_id.iter_mut().for_each(|(_, group)| { - if group.id != self.field.id { + if group.id != self.field_id { each(group); } }); @@ -168,13 +144,7 @@ where } #[tracing::instrument(level = "trace", skip(self), err)] pub(crate) fn add_new_group(&mut self, group: Group) -> FlowyResult { - let group_data = GroupData::new( - group.id.clone(), - self.field.id.clone(), - group.name.clone(), - group.id.clone(), - group.visible, - ); + let group_data = GroupData::new(group.id.clone(), self.field_id.clone(), group.visible); self.group_by_id.insert(group.id.clone(), group_data); let (index, group_data) = self.get_group(&group.id).unwrap(); let insert_group = InsertedGroupPB { @@ -232,7 +202,7 @@ where configuration .groups .iter() - .map(|group| group.name.clone()) + .map(|group| group.id.clone()) .collect::>() .join(",") ); @@ -268,22 +238,12 @@ where ) -> FlowyResult> { let GeneratedGroups { no_status_group, - group_configs, + groups, } = generated_groups; - let mut new_groups = vec![]; - let mut filter_content_map = HashMap::new(); - group_configs.into_iter().for_each(|generate_group| { - filter_content_map.insert( - generate_group.group.id.clone(), - generate_group.filter_content, - ); - new_groups.push(generate_group.group); - }); - let mut old_groups = self.setting.groups.clone(); // clear all the groups if grouping by a new field - if self.setting.field_id != self.field.id { + if self.setting.field_id != self.field_id { old_groups.clear(); } @@ -292,7 +252,7 @@ where mut all_groups, new_groups, deleted_groups, - } = merge_groups(no_status_group, old_groups, new_groups); + } = merge_groups(no_status_group, old_groups, groups); let deleted_group_ids = deleted_groups .into_iter() @@ -321,12 +281,10 @@ where Some(pos) => { let old_group = configuration.groups.get_mut(pos).unwrap(); // Take the old group setting - group.visible = old_group.visible; - if !is_changed { - is_changed = is_group_changed(group, old_group); + if group.visible != old_group.visible { + is_changed = true; } - // Consider the the name of the `group_rev` as the newest. - old_group.name = group.name.clone(); + group.visible = old_group.visible; }, } } @@ -335,31 +293,14 @@ where // Update the memory cache of the groups all_groups.into_iter().for_each(|group| { - let filter_content = filter_content_map - .get(&group.id) - .cloned() - .unwrap_or_else(|| "".to_owned()); - let group = GroupData::new( - group.id, - self.field.id.clone(), - group.name, - filter_content, - group.visible, - ); + let group = GroupData::new(group.id, self.field_id.clone(), group.visible); self.group_by_id.insert(group.id.clone(), group); }); let initial_groups = new_groups .into_iter() .flat_map(|group_rev| { - let filter_content = filter_content_map.get(&group_rev.id)?; - let group = GroupData::new( - group_rev.id, - self.field.id.clone(), - group_rev.name, - filter_content.clone(), - group_rev.visible, - ); + let group = GroupData::new(group_rev.id, self.field_id.clone(), group_rev.visible); Some(GroupPB::from(group)) }) .collect(); @@ -385,14 +326,10 @@ where if let Some(visible) = group_changeset.visible { group.visible = visible; } - if let Some(name) = &group_changeset.name { - group.name = name.clone(); - } })?; if let Some(group) = update_group { if let Some(group_data) = self.group_by_id.get_mut(&group.id) { - group_data.name = group.name.clone(); group_data.is_visible = group.visible; }; } @@ -401,8 +338,8 @@ where pub(crate) async fn get_all_cells(&self) -> Vec { self - .reader - .get_configuration_cells(&self.view_id, &self.field.id) + .delegate + .get_configuration_cells(&self.view_id, &self.field_id) .await } @@ -423,10 +360,10 @@ where let is_changed = mut_configuration_fn(configuration); if is_changed { let configuration = (*self.setting).clone(); - let writer = self.writer.clone(); + let delegate = self.delegate.clone(); let view_id = self.view_id.clone(); af_spawn(async move { - match writer.save_configuration(&view_id, configuration).await { + match delegate.save_configuration(&view_id, configuration).await { Ok(_) => {}, Err(e) => { tracing::error!("Save group configuration failed: {}", e); @@ -504,13 +441,6 @@ fn merge_groups( merge_result } -fn is_group_changed(new: &Group, old: &Group) -> bool { - if new.name != old.name { - return true; - } - false -} - struct MergeGroupResult { // Contains the new groups and the updated groups all_groups: Vec, @@ -545,13 +475,13 @@ mod tests { exp_deleted_groups: Vec<&'a str>, } - let new_group = |name: &str| Group::new(name.to_string(), name.to_string()); + let new_group = |name: &str| Group::new(name.to_string()); let groups_from_strings = |strings: Vec<&str>| strings.iter().map(|s| new_group(s)).collect::>(); let group_stringify = |groups: Vec| { groups .iter() - .map(|group| group.name.clone()) + .map(|group| group.id.clone()) .collect::>() .join(",") }; 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 acc240a9e3b5a..a918e7f7c2262 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/controller.rs @@ -1,14 +1,14 @@ use std::marker::PhantomData; use std::sync::Arc; -use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{Cells, Row, RowDetail, RowId}; use futures::executor::block_on; +use lib_infra::future::Fut; use serde::de::DeserializeOwned; use serde::Serialize; -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; use crate::entities::{ FieldType, GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, @@ -17,67 +17,45 @@ use crate::entities::{ 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, + DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupController, GroupCustomize, }; -use crate::services::group::configuration::GroupContext; +use crate::services::group::configuration::GroupControllerContext; use crate::services::group::entities::GroupData; -use crate::services::group::{GroupChangeset, GroupChangesets, GroupsBuilder, MoveGroupRowContext}; +use crate::services::group::{GroupChangeset, GroupsBuilder, MoveGroupRowContext}; -// use collab_database::views::Group; +pub trait GroupControllerDelegate: Send + Sync + 'static { + fn get_field(&self, field_id: &str) -> Option; -/// The [GroupController] trait defines the group actions, including create/delete/move items -/// For example, the group will insert a item if the one of the new [RowRevision]'s [CellRevision]s -/// content match the group filter. -/// -/// Different [FieldType] has a different controller that implements the [GroupController] trait. -/// If the [FieldType] doesn't implement its group controller, then the [DefaultGroupController] will -/// be used. -/// -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: &Field); - - /// Called before the row was created. - fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str); -} - -#[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 { - None - } + fn get_all_rows(&self, view_id: &str) -> Fut>>; } -/// C: represents the group configuration that impl [GroupConfigurationSerde] -/// 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 { +/// [BaseGroupController] is a generic group controller that provides customized implementations +/// of the `GroupController` trait for different field types. +/// +/// - `C`: represents the group configuration that impl [GroupConfigurationSerde] +/// - `G`: group generator, [GroupsBuilder] +/// - `P`: parser that impl [CellProtobufBlobParser] for the CellBytes +/// +/// See also: [DefaultGroupController] which contains the most basic implementation of +/// `GroupController` that only has one group. +pub struct BaseGroupController { pub grouping_field_id: String, - pub type_option: T, - pub context: GroupContext, + pub context: GroupControllerContext, group_builder_phantom: PhantomData, cell_parser_phantom: PhantomData

, - pub operation_interceptor: I, + pub delegate: Arc, } -impl BaseGroupController +impl BaseGroupController where C: Serialize + DeserializeOwned, - T: TypeOption + From + Send + Sync, - G: GroupsBuilder, GroupTypeOption = T>, - I: GroupOperationInterceptor + Send + Sync, + T: TypeOption + Send + Sync, + G: GroupsBuilder, GroupTypeOption = T>, { pub async fn new( - grouping_field: &Arc, - mut configuration: GroupContext, - operation_interceptor: I, + grouping_field: &Field, + mut configuration: GroupControllerContext, + delegate: Arc, ) -> FlowyResult { let field_type = FieldType::from(grouping_field.field_type); let type_option = grouping_field @@ -90,14 +68,20 @@ where Ok(Self { grouping_field_id: grouping_field.id.clone(), - type_option, context: configuration, group_builder_phantom: PhantomData, cell_parser_phantom: PhantomData, - operation_interceptor, + delegate, }) } + pub fn get_grouping_field_type_option(&self) -> Option { + self + .delegate + .get_field(&self.grouping_field_id) + .and_then(|field| field.get_type_option::(FieldType::from(field.field_type))) + } + fn update_no_status_group( &mut self, row_detail: &RowDetail, @@ -170,17 +154,15 @@ where } } -#[async_trait] -impl GroupControllerOperation for BaseGroupController +impl GroupController for BaseGroupController where P: CellProtobufBlobParser::CellProtobufType>, C: Serialize + DeserializeOwned + Sync + Send, - T: TypeOption + From + Send + Sync, - G: GroupsBuilder, GroupTypeOption = T>, - I: GroupOperationInterceptor + Send + Sync, + T: TypeOption + Send + Sync, + G: GroupsBuilder, GroupTypeOption = T>, Self: GroupCustomize, { - fn field_id(&self) -> &str { + fn get_grouping_field_id(&self) -> &str { &self.grouping_field_id } @@ -205,7 +187,7 @@ where let mut grouped_rows: Vec = vec![]; let cell_data = ::CellData::from(&cell); for group in self.context.groups() { - if self.can_group(&group.filter_content, &cell_data) { + if self.can_group(&group.id, &cell_data) { grouped_rows.push(GroupedRow { row_detail: (*row_detail).clone(), group_id: group.id.clone(), @@ -237,7 +219,7 @@ where &mut self, name: String, ) -> FlowyResult<(Option, Option)> { - self.generate_new_group(name) + ::create_group(self, name) } fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()> { @@ -249,24 +231,25 @@ where row_detail: &RowDetail, index: usize, ) -> Vec { + let mut changesets: Vec = vec![]; + 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 = vec![]; if let Some(cell) = cell { let cell_data = ::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) { + if self.can_group(&group.id, &cell_data) { suitable_group_ids.push(group.id.clone()); let changeset = GroupRowsNotificationPB::insert( group.id.clone(), vec![InsertedRowPB { - row_meta: row_detail.into(), + row_meta: (*row_detail).clone().into(), index: Some(index as i32), is_new: true, }], @@ -277,15 +260,15 @@ where 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()); + 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()); + no_status_group.add_row((*row_detail).clone()); let changeset = GroupRowsNotificationPB::insert( no_status_group.id.clone(), vec![InsertedRowPB { - row_meta: row_detail.into(), + row_meta: (*row_detail).clone().into(), index: Some(index as i32), is_new: true, }], @@ -303,18 +286,12 @@ where row_detail: &RowDetail, field: &Field, ) -> FlowyResult { - // let cell_data = row_rev.cells.get(&self.field_id).and_then(|cell_rev| { - // let cell_data: Option

= get_type_cell_data(cell_rev, field_rev, None); - // cell_data - // }); let mut result = DidUpdateGroupRowResult { inserted_group: None, deleted_group: None, row_changesets: vec![], }; - if let Some(cell_data) = get_cell_data_from_row::

(Some(&row_detail.row), field) { - let _old_row = old_row_detail.as_ref(); let old_cell_data = get_cell_data_from_row::

(old_row_detail.as_ref().map(|detail| &detail.row), field); if let Ok((insert, delete)) = self.create_or_delete_group_when_cell_changed( @@ -385,7 +362,7 @@ where let cell_bytes = get_cell_protobuf(&cell, context.field, None); let cell_data = cell_bytes.parser::

()?; result.deleted_group = self.delete_group_when_move_row(&context.row_detail.row, &cell_data); - result.row_changesets = self.move_row(&cell_data, context); + result.row_changesets = self.move_row(context); } else { tracing::warn!("Unexpected moving group row, changes should not be empty"); } @@ -397,7 +374,7 @@ where } fn delete_group(&mut self, group_id: &str) -> FlowyResult<(Vec, Option)> { - let group = if group_id != self.field_id() { + let group = if group_id != self.get_grouping_field_id() { self.get_group(group_id) } else { None @@ -410,32 +387,39 @@ where .iter() .map(|row| row.row.id.clone()) .collect(); - let type_option_data = self.delete_group_custom(group_id)?; + let type_option_data = ::delete_group(self, group_id)?; Ok((row_ids, type_option_data)) }, None => Ok((vec![], None)), } } - async fn apply_group_changeset( + fn apply_group_changeset( &mut self, - changeset: &GroupChangesets, - ) -> FlowyResult<(Vec, TypeOptionData)> { - for group_changeset in changeset.changesets.iter() { + changeset: &[GroupChangeset], + ) -> FlowyResult<(Vec, Option)> { + // update group visibility + for group_changeset in changeset.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 + + // update group name + let type_option = self.get_grouping_field_type_option().ok_or_else(|| { + FlowyError::internal().with_context("Failed to get grouping field type option") + })?; + + let mut updated_type_option = None; + + for group_changeset in changeset.iter() { + if let Some(type_option) = + self.update_type_option_when_update_group(group_changeset, &type_option) { - type_option_data.extend(new_type_option_data); + updated_type_option = Some(type_option); + break; } } + let updated_groups = changeset - .changesets .iter() .filter_map(|changeset| { self @@ -443,7 +427,15 @@ where .map(|(_, group)| GroupPB::from(group)) }) .collect::>(); - Ok((updated_groups, type_option_data)) + + Ok(( + updated_groups, + updated_type_option.map(|type_option| type_option.into()), + )) + } + + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { + ::will_create_row(self, cells, field, group_id); } } 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 e6e9fdeabda3c..a3057b24a0b47 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 @@ -10,11 +10,10 @@ use crate::services::field::{ CheckboxCellDataParser, CheckboxTypeOption, TypeOption, CHECK, UNCHECK, }; use crate::services::group::action::GroupCustomize; -use crate::services::group::configuration::GroupContext; -use crate::services::group::controller::{BaseGroupController, GroupController}; +use crate::services::group::configuration::GroupControllerContext; +use crate::services::group::controller::BaseGroupController; use crate::services::group::{ - move_group_row, GeneratedGroupConfig, GeneratedGroups, Group, GroupOperationInterceptor, - GroupsBuilder, MoveGroupRowContext, + move_group_row, GeneratedGroups, Group, GroupsBuilder, MoveGroupRowContext, }; #[derive(Default, Serialize, Deserialize)] @@ -22,16 +21,10 @@ pub struct CheckboxGroupConfiguration { pub hide_empty: bool, } -pub type CheckboxGroupController = BaseGroupController< - CheckboxGroupConfiguration, - CheckboxTypeOption, - CheckboxGroupBuilder, - CheckboxCellDataParser, - CheckboxGroupOperationInterceptorImpl, ->; - -pub type CheckboxGroupContext = GroupContext; +pub type CheckboxGroupController = + BaseGroupController; +pub type CheckboxGroupControllerContext = GroupControllerContext; impl GroupCustomize for CheckboxGroupController { type GroupTypeOption = CheckboxTypeOption; fn placeholder_cell(&self) -> Option { @@ -126,11 +119,7 @@ impl GroupCustomize for CheckboxGroupController { (None, changesets) } - fn move_row( - &mut self, - _cell_data: &::CellProtobufType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; self.context.iter_mut_groups(|group| { if let Some(changeset) = move_group_row(group, &mut context) { @@ -140,17 +129,11 @@ impl GroupCustomize for CheckboxGroupController { group_changeset } - fn delete_group_custom(&mut self, _group_id: &str) -> FlowyResult> { + fn delete_group(&mut self, _group_id: &str) -> FlowyResult> { Ok(None) } -} - -impl GroupController for CheckboxGroupController { - 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 will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { None => tracing::warn!("Can not find the group: {}", group_id), Some((_, group)) => { @@ -165,7 +148,7 @@ impl GroupController for CheckboxGroupController { pub struct CheckboxGroupBuilder(); #[async_trait] impl GroupsBuilder for CheckboxGroupBuilder { - type Context = CheckboxGroupContext; + type Context = CheckboxGroupControllerContext; type GroupTypeOption = CheckboxTypeOption; async fn build( @@ -173,26 +156,12 @@ impl GroupsBuilder for CheckboxGroupBuilder { _context: &Self::Context, _type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { - let check_group = GeneratedGroupConfig { - group: Group::new(CHECK.to_string(), "".to_string()), - filter_content: CHECK.to_string(), - }; - - let uncheck_group = GeneratedGroupConfig { - group: Group::new(UNCHECK.to_string(), "".to_string()), - filter_content: UNCHECK.to_string(), - }; + let check_group = Group::new(CHECK.to_string()); + let uncheck_group = Group::new(UNCHECK.to_string()); GeneratedGroups { no_status_group: None, - group_configs: vec![check_group, uncheck_group], + groups: vec![check_group, uncheck_group], } } } - -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 1d947d66e37ac..8a2827a1078cd 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,5 @@ -use std::format; - use async_trait::async_trait; -use chrono::{DateTime, Datelike, Days, Duration, Local, NaiveDate, NaiveDateTime}; +use chrono::{DateTime, Datelike, Days, Duration, Local, NaiveDateTime}; use collab_database::database::timestamp; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail}; @@ -16,28 +14,24 @@ use crate::entities::{ use crate::services::cell::insert_date_cell; 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}; +use crate::services::group::configuration::GroupControllerContext; +use crate::services::group::controller::BaseGroupController; use crate::services::group::{ - make_no_status_group, move_group_row, GeneratedGroupConfig, GeneratedGroups, Group, - GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext, + make_no_status_group, move_group_row, GeneratedGroups, Group, GroupsBuilder, MoveGroupRowContext, }; -pub trait GroupConfigurationContentSerde: Sized + Send + Sync { - fn from_json(s: &str) -> Result; - fn to_json(&self) -> Result; -} - #[derive(Default, Serialize, Deserialize)] pub struct DateGroupConfiguration { pub hide_empty: bool, pub condition: DateCondition, } -impl GroupConfigurationContentSerde for DateGroupConfiguration { +impl DateGroupConfiguration { fn from_json(s: &str) -> Result { serde_json::from_str(s) } + + #[allow(dead_code)] fn to_json(&self) -> Result { serde_json::to_string(self) } @@ -54,15 +48,10 @@ pub enum DateCondition { Year = 4, } -pub type DateGroupController = BaseGroupController< - DateGroupConfiguration, - DateTypeOption, - DateGroupBuilder, - DateCellDataParser, - DateGroupOperationInterceptorImpl, ->; +pub type DateGroupController = + BaseGroupController; -pub type DateGroupContext = GroupContext; +pub type DateGroupControllerContext = GroupControllerContext; impl GroupCustomize for DateGroupController { type GroupTypeOption = DateTypeOption; @@ -80,7 +69,7 @@ impl GroupCustomize for DateGroupController { content: &str, cell_data: &::CellData, ) -> bool { - content == group_id(cell_data, &self.context.get_setting_content()) + content == get_date_group_id(cell_data, &self.context.get_setting_content()) } fn create_or_delete_group_when_cell_changed( @@ -93,7 +82,7 @@ impl GroupCustomize for DateGroupController { let mut inserted_group = None; if self .context - .get_group(&group_id(&_cell_data.into(), &setting_content)) + .get_group(&get_date_group_id(&_cell_data.into(), &setting_content)) .is_none() { let group = make_group_from_date_cell(&_cell_data.into(), &setting_content); @@ -106,7 +95,7 @@ impl GroupCustomize for DateGroupController { let deleted_group = match _old_cell_data.and_then(|old_cell_data| { self .context - .get_group(&group_id(&old_cell_data.into(), &setting_content)) + .get_group(&get_date_group_id(&old_cell_data.into(), &setting_content)) }) { None => None, Some((_, group)) => { @@ -138,7 +127,7 @@ impl GroupCustomize for DateGroupController { 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(), &setting_content) { + if group.id == get_date_group_id(&cell_data.into(), &setting_content) { if !group.contains_row(&row_detail.row.id) { changeset .inserted_rows @@ -180,7 +169,7 @@ impl GroupCustomize for DateGroupController { let setting_content = self.context.get_setting_content(); let deleted_group = match self .context - .get_group(&group_id(cell_data, &setting_content)) + .get_group(&get_date_group_id(cell_data, &setting_content)) { Some((_, group)) if group.rows.len() == 1 => Some(group.clone()), _ => None, @@ -194,11 +183,7 @@ impl GroupCustomize for DateGroupController { (deleted_group, changesets) } - fn move_row( - &mut self, - _cell_data: &::CellProtobufType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; self.context.iter_mut_groups(|group| { if let Some(changeset) = move_group_row(group, &mut context) { @@ -211,13 +196,13 @@ impl GroupCustomize for DateGroupController { fn delete_group_when_move_row( &mut self, _row: &Row, - _cell_data: &::CellProtobufType, + cell_data: &::CellProtobufType, ) -> Option { 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(), &setting_content)) + .get_group(&get_date_group_id(&cell_data.into(), &setting_content)) { if group.rows.len() == 1 { deleted_group = Some(GroupPB::from(group.clone())); @@ -229,16 +214,12 @@ impl GroupCustomize for DateGroupController { deleted_group } - fn delete_group_custom(&mut self, group_id: &str) -> FlowyResult> { + fn delete_group(&mut self, group_id: &str) -> FlowyResult> { self.context.delete_group(group_id)?; Ok(None) } -} - -impl GroupController for DateGroupController { - fn did_update_field_type_option(&mut self, _field: &Field) {} - fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { None => tracing::warn!("Can not find the group: {}", group_id), Some((_, _)) => { @@ -253,7 +234,7 @@ impl GroupController for DateGroupController { pub struct DateGroupBuilder(); #[async_trait] impl GroupsBuilder for DateGroupBuilder { - type Context = DateGroupContext; + type Context = DateGroupControllerContext; type GroupTypeOption = DateTypeOption; async fn build( @@ -265,39 +246,31 @@ impl GroupsBuilder for DateGroupBuilder { let cells = context.get_all_cells().await; // Generate the groups - let mut group_configs: Vec = cells + let mut groups: Vec = cells .into_iter() .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, &context.get_setting_content()); - GeneratedGroupConfig { - filter_content: group.id.clone(), - group, - } - }) + .map(|cell| make_group_from_date_cell(&cell, &context.get_setting_content())) .collect(); - group_configs.sort_by(|a, b| a.filter_content.cmp(&b.filter_content)); + groups.sort_by(|a, b| a.id.cmp(&b.id)); let no_status_group = Some(make_no_status_group(field)); + GeneratedGroups { no_status_group, - group_configs, + groups, } } } fn make_group_from_date_cell(cell_data: &DateCellData, setting_content: &str) -> Group { - let group_id = group_id(cell_data, setting_content); - Group::new( - group_id.clone(), - group_name_from_id(&group_id, setting_content), - ) + let group_id = get_date_group_id(cell_data, setting_content); + Group::new(group_id) } const GROUP_ID_DATE_FORMAT: &str = "%Y/%m/%d"; -fn group_id(cell_data: &DateCellData, setting_content: &str) -> String { +fn get_date_group_id(cell_data: &DateCellData, setting_content: &str) -> String { let config = DateGroupConfiguration::from_json(setting_content).unwrap_or_default(); let date_time = date_time_from_timestamp(cell_data.timestamp); @@ -354,63 +327,6 @@ fn group_id(cell_data: &DateCellData, setting_content: &str) -> String { date.to_string() } -fn group_name_from_id(group_id: &str, setting_content: &str) -> String { - let config = DateGroupConfiguration::from_json(setting_content).unwrap_or_default(); - let date = NaiveDate::parse_from_str(group_id, GROUP_ID_DATE_FORMAT).unwrap(); - - let tmp; - match config.condition { - DateCondition::Day => { - tmp = format!("{} {}, {}", date.format("%b"), date.day(), date.year(),); - tmp - }, - DateCondition::Week => { - let begin_of_week = date - .checked_sub_days(Days::new(date.weekday().num_days_from_monday() as u64)) - .unwrap() - .format("%d"); - let end_of_week = date - .checked_add_days(Days::new(6 - date.weekday().num_days_from_monday() as u64)) - .unwrap() - .format("%d"); - - tmp = format!( - "Week of {} {}-{} {}", - date.format("%b"), - begin_of_week, - end_of_week, - date.year() - ); - tmp - }, - DateCondition::Month => { - tmp = format!("{} {}", date.format("%b"), date.year(),); - tmp - }, - DateCondition::Year => date.year().to_string(), - DateCondition::Relative => { - let now = date_time_from_timestamp(Some(timestamp())); - - let diff = date.signed_duration_since(now.date_naive()); - let result = match diff.num_days() { - 0 => "Today", - -1 => "Yesterday", - 1 => "Tomorrow", - -7 => "Last 7 days", - 2 => "Next 7 days", - -30 => "Last 30 days", - 8 => "Next 30 days", - _ => { - tmp = format!("{} {}", date.format("%b"), date.year(),); - &tmp - }, - }; - - result.to_string() - }, - } -} - fn date_time_from_timestamp(timestamp: Option) -> DateTime { match timestamp { Some(timestamp) => { @@ -423,24 +339,14 @@ fn date_time_from_timestamp(timestamp: Option) -> DateTime { } } -pub struct DateGroupOperationInterceptorImpl {} - -#[async_trait] -impl GroupOperationInterceptor for DateGroupOperationInterceptorImpl { - type GroupTypeOption = DateTypeOption; -} - #[cfg(test)] mod tests { - use std::vec; - use chrono::{offset, Days, Duration, NaiveDateTime}; - use crate::services::{ - field::{date_type_option::DateTypeOption, DateCellData}, - group::controller_impls::date_controller::{ - group_id, group_name_from_id, GROUP_ID_DATE_FORMAT, - }, + use crate::services::field::date_type_option::DateTypeOption; + use crate::services::field::DateCellData; + use crate::services::group::controller_impls::date_controller::{ + get_date_group_id, GROUP_ID_DATE_FORMAT, }; #[test] @@ -449,7 +355,6 @@ mod tests { cell_data: DateCellData, setting_content: String, exp_group_id: String, - exp_group_name: String, } let mar_14_2022 = NaiveDateTime::from_timestamp_opt(1647251762, 0).unwrap(); @@ -471,7 +376,6 @@ mod tests { cell_data: mar_14_2022_cd.clone(), setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(), exp_group_id: "2022/03/01".to_string(), - exp_group_name: "Mar 2022".to_string(), }, GroupIDTest { cell_data: DateCellData { @@ -481,7 +385,6 @@ mod tests { }, setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(), exp_group_id: today.format(GROUP_ID_DATE_FORMAT).to_string(), - exp_group_name: "Today".to_string(), }, GroupIDTest { cell_data: DateCellData { @@ -495,13 +398,11 @@ mod tests { .unwrap() .format(GROUP_ID_DATE_FORMAT) .to_string(), - exp_group_name: "Last 7 days".to_string(), }, GroupIDTest { cell_data: mar_14_2022_cd.clone(), setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(), exp_group_id: "2022/03/14".to_string(), - exp_group_name: "Mar 14, 2022".to_string(), }, GroupIDTest { cell_data: DateCellData { @@ -516,19 +417,16 @@ mod tests { }, setting_content: r#"{"condition": 2, "hide_empty": false}"#.to_string(), exp_group_id: "2022/03/14".to_string(), - exp_group_name: "Week of Mar 14-20 2022".to_string(), }, GroupIDTest { cell_data: mar_14_2022_cd.clone(), setting_content: r#"{"condition": 3, "hide_empty": false}"#.to_string(), exp_group_id: "2022/03/01".to_string(), - exp_group_name: "Mar 2022".to_string(), }, GroupIDTest { cell_data: mar_14_2022_cd, setting_content: r#"{"condition": 4, "hide_empty": false}"#.to_string(), exp_group_id: "2022/01/01".to_string(), - exp_group_name: "2022".to_string(), }, GroupIDTest { cell_data: DateCellData { @@ -538,7 +436,6 @@ mod tests { }, setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(), exp_group_id: "2023/06/02".to_string(), - exp_group_name: "".to_string(), }, GroupIDTest { cell_data: DateCellData { @@ -548,18 +445,12 @@ mod tests { }, setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(), exp_group_id: "2023/06/03".to_string(), - exp_group_name: "".to_string(), }, ]; for (i, test) in tests.iter().enumerate() { - let group_id = group_id(&test.cell_data, &test.setting_content); + let group_id = get_date_group_id(&test.cell_data, &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, &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 021615b359632..bcfd48bc0985c 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,6 +1,5 @@ use std::sync::Arc; -use async_trait::async_trait; use collab_database::fields::{Field, TypeOptionData}; use collab_database::rows::{Cells, Row, RowDetail, RowId}; @@ -10,9 +9,11 @@ use crate::entities::{ GroupChangesPB, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB, }; use crate::services::group::action::{ - DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupControllerOperation, + DidMoveGroupRowResult, DidUpdateGroupRowResult, GroupController, +}; +use crate::services::group::{ + GroupChangeset, GroupControllerDelegate, GroupData, 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 @@ -21,29 +22,24 @@ use crate::services::group::{GroupChangesets, GroupController, GroupData, MoveGr pub struct DefaultGroupController { pub field_id: String, pub group: GroupData, + pub delegate: Arc, } const DEFAULT_GROUP_CONTROLLER: &str = "DefaultGroupController"; impl DefaultGroupController { - pub fn new(field: &Arc) -> Self { - let group = GroupData::new( - DEFAULT_GROUP_CONTROLLER.to_owned(), - field.id.clone(), - "".to_owned(), - "".to_owned(), - true, - ); + pub fn new(field: &Field, delegate: Arc) -> Self { + let group = GroupData::new(DEFAULT_GROUP_CONTROLLER.to_owned(), field.id.clone(), true); Self { field_id: field.id.clone(), group, + delegate, } } } -#[async_trait] -impl GroupControllerOperation for DefaultGroupController { - fn field_id(&self) -> &str { +impl GroupController for DefaultGroupController { + fn get_grouping_field_id(&self) -> &str { &self.field_id } @@ -78,12 +74,12 @@ impl GroupControllerOperation for DefaultGroupController { row_detail: &RowDetail, index: usize, ) -> Vec { - self.group.add_row(row_detail.clone()); + self.group.add_row((*row_detail).clone()); vec![GroupRowsNotificationPB::insert( self.group.id.clone(), vec![InsertedRowPB { - row_meta: row_detail.into(), + row_meta: (*row_detail).clone().into(), index: Some(index as i32), is_new: true, }], @@ -133,18 +129,12 @@ impl GroupControllerOperation for DefaultGroupController { Ok((vec![], None)) } - async fn apply_group_changeset( + fn apply_group_changeset( &mut self, - _changeset: &GroupChangesets, - ) -> FlowyResult<(Vec, TypeOptionData)> { - Ok((Vec::new(), TypeOptionData::default())) - } -} - -impl GroupController for DefaultGroupController { - fn did_update_field_type_option(&mut self, _field: &Field) { - // Do nothing + _changeset: &[GroupChangeset], + ) -> FlowyResult<(Vec, Option)> { + Ok((Vec::new(), None)) } - fn will_create_row(&mut self, _cells: &mut Cells, _field: &Field, _group_id: &str) {} + fn will_create_row(&self, _cells: &mut Cells, _field: &Field, _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 dfc7ce8ce984c..cae19109f6b15 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,7 +1,7 @@ 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 flowy_error::{FlowyError, FlowyResult}; use serde::{Deserialize, Serialize}; use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; @@ -11,11 +11,11 @@ use crate::services::field::{ TypeOption, }; use crate::services::group::action::GroupCustomize; -use crate::services::group::controller::{BaseGroupController, GroupController}; +use crate::services::group::controller::BaseGroupController; 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, Group, GroupChangeset, GroupContext, - GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext, + move_group_row, remove_select_option_row, GeneratedGroups, Group, GroupChangeset, + GroupControllerContext, GroupsBuilder, MoveGroupRowContext, }; #[derive(Default, Serialize, Deserialize)] @@ -23,14 +23,12 @@ pub struct MultiSelectGroupConfiguration { pub hide_empty: bool, } -pub type MultiSelectOptionGroupContext = GroupContext; +pub type MultiSelectGroupControllerContext = GroupControllerContext; // MultiSelect pub type MultiSelectGroupController = BaseGroupController< MultiSelectGroupConfiguration, - MultiSelectTypeOption, MultiSelectGroupBuilder, SelectOptionCellDataParser, - MultiSelectGroupOperationInterceptorImpl, >; impl GroupCustomize for MultiSelectGroupController { @@ -80,11 +78,7 @@ impl GroupCustomize for MultiSelectGroupController { (None, changesets) } - fn move_row( - &mut self, - _cell_data: &::CellProtobufType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; self.context.iter_mut_groups(|group| { if let Some(changeset) = move_group_row(group, &mut context) { @@ -94,44 +88,69 @@ impl GroupCustomize for MultiSelectGroupController { group_changeset } - fn generate_new_group( + fn create_group( &mut self, name: String, ) -> FlowyResult<(Option, Option)> { - let mut new_type_option = self.type_option.clone(); - let new_select_option = self.type_option.create_option(&name); + let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + FlowyError::internal().with_context("Failed to get grouping field type option") + })?; + let new_select_option = new_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 new_group = Group::new(new_select_option.id); let inserted_group_pb = self.context.add_new_group(new_group)?; Ok((Some(new_type_option.into()), Some(inserted_group_pb))) } - fn delete_group_custom(&mut self, group_id: &str) -> FlowyResult> { - if let Some(option_index) = self - .type_option + fn delete_group(&mut self, group_id: &str) -> FlowyResult> { + let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + FlowyError::internal().with_context("Failed to get grouping field type option") + })?; + if let Some(option_index) = new_type_option .options .iter() .position(|option| option.id == group_id) { // Remove the option if the group is found - let mut new_type_option = self.type_option.clone(); new_type_option.options.remove(option_index); Ok(Some(new_type_option.into())) } else { Ok(None) } } -} -impl GroupController for MultiSelectGroupController { - fn did_update_field_type_option(&mut self, _field: &Field) {} + fn update_type_option_when_update_group( + &mut self, + changeset: &GroupChangeset, + type_option: &Self::GroupTypeOption, + ) -> Option { + if let Some(name) = &changeset.name { + let mut new_type_option = type_option.clone(); - fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { + 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); + + Some(new_type_option) + } else { + None + } + } + + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { None => tracing::warn!("Can not find the group: {}", group_id), - Some((_, group)) => { + Some((_index, group)) => { let cell = insert_select_option_cell(vec![group.id.clone()], field); cells.insert(field.id.clone(), cell); }, @@ -142,7 +161,7 @@ impl GroupController for MultiSelectGroupController { pub struct MultiSelectGroupBuilder; #[async_trait] impl GroupsBuilder for MultiSelectGroupBuilder { - type Context = MultiSelectOptionGroupContext; + type Context = MultiSelectGroupControllerContext; type GroupTypeOption = MultiSelectTypeOption; async fn build( @@ -150,43 +169,11 @@ impl GroupsBuilder for MultiSelectGroupBuilder { _context: &Self::Context, type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { - let group_configs = generate_select_option_groups(&field.id, &type_option.options); + let groups = generate_select_option_groups(&field.id, &type_option.options); + GeneratedGroups { no_status_group: Some(make_no_status_group(field)), - group_configs, + groups, } } } - -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 { - 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 6986ad0e83ae9..d26ef50b709fd 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,7 +1,7 @@ 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 flowy_error::{FlowyError, FlowyResult}; use serde::{Deserialize, Serialize}; use crate::entities::{FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB}; @@ -11,12 +11,11 @@ use crate::services::field::{ TypeOption, }; use crate::services::group::action::GroupCustomize; -use crate::services::group::controller::{BaseGroupController, GroupController}; +use crate::services::group::controller::BaseGroupController; 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, Group, GroupChangeset, GroupContext, - GroupOperationInterceptor, GroupsBuilder, MoveGroupRowContext, + make_no_status_group, GeneratedGroups, Group, GroupChangeset, GroupControllerContext, + GroupsBuilder, MoveGroupRowContext, }; #[derive(Default, Serialize, Deserialize)] @@ -24,19 +23,19 @@ pub struct SingleSelectGroupConfiguration { pub hide_empty: bool, } -pub type SingleSelectOptionGroupContext = GroupContext; +pub type SingleSelectGroupControllerContext = + GroupControllerContext; // SingleSelect pub type SingleSelectGroupController = BaseGroupController< SingleSelectGroupConfiguration, - SingleSelectTypeOption, SingleSelectGroupBuilder, SelectOptionCellDataParser, - SingleSelectGroupOperationInterceptorImpl, >; impl GroupCustomize for SingleSelectGroupController { type GroupTypeOption = SingleSelectTypeOption; + fn can_group( &self, content: &str, @@ -81,11 +80,7 @@ impl GroupCustomize for SingleSelectGroupController { (None, changesets) } - fn move_row( - &mut self, - _cell_data: &::CellProtobufType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; self.context.iter_mut_groups(|group| { if let Some(changeset) = move_group_row(group, &mut context) { @@ -95,46 +90,69 @@ impl GroupCustomize for SingleSelectGroupController { group_changeset } - fn generate_new_group( + fn create_group( &mut self, name: String, ) -> FlowyResult<(Option, Option)> { - let mut new_type_option = self.type_option.clone(); - let new_select_option = self.type_option.create_option(&name); + let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + FlowyError::internal().with_context("Failed to get grouping field type option") + })?; + let new_select_option = new_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 new_group = Group::new(new_select_option.id); let inserted_group_pb = self.context.add_new_group(new_group)?; Ok((Some(new_type_option.into()), Some(inserted_group_pb))) } - fn delete_group_custom(&mut self, group_id: &str) -> FlowyResult> { - if let Some(option_index) = self - .type_option + fn delete_group(&mut self, group_id: &str) -> FlowyResult> { + let mut new_type_option = self.get_grouping_field_type_option().ok_or_else(|| { + FlowyError::internal().with_context("Failed to get grouping field type option") + })?; + if let Some(option_index) = new_type_option .options .iter() .position(|option| option.id == group_id) { // Remove the option if the group is found - let mut new_type_option = self.type_option.clone(); new_type_option.options.remove(option_index); Ok(Some(new_type_option.into())) } else { - // Return None if no matching group is found Ok(None) } } -} -impl GroupController for SingleSelectGroupController { - fn did_update_field_type_option(&mut self, _field: &Field) {} + fn update_type_option_when_update_group( + &mut self, + changeset: &GroupChangeset, + type_option: &Self::GroupTypeOption, + ) -> Option { + 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); + + Some(new_type_option) + } else { + None + } + } - 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); - match group { - None => {}, - Some(group) => { + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { + match self.context.get_group(group_id) { + None => tracing::warn!("Can not find the group: {}", group_id), + Some((_index, group)) => { let cell = insert_select_option_cell(vec![group.id.clone()], field); cells.insert(field.id.clone(), cell); }, @@ -145,51 +163,18 @@ impl GroupController for SingleSelectGroupController { pub struct SingleSelectGroupBuilder(); #[async_trait] impl GroupsBuilder for SingleSelectGroupBuilder { - type Context = SingleSelectOptionGroupContext; + type Context = SingleSelectGroupControllerContext; type GroupTypeOption = SingleSelectTypeOption; async fn build( field: &Field, _context: &Self::Context, type_option: &Self::GroupTypeOption, ) -> GeneratedGroups { - let group_configs = generate_select_option_groups(&field.id, &type_option.options); + let groups = generate_select_option_groups(&field.id, &type_option.options); GeneratedGroups { no_status_group: Some(make_no_status_group(field)), - group_configs, + groups, } } } - -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 { - 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 b8a144b594ffc..01bd4cdc0d7f0 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 @@ -9,7 +9,7 @@ use crate::services::cell::{ insert_checkbox_cell, insert_date_cell, insert_select_option_cell, insert_url_cell, }; use crate::services::field::{SelectOption, SelectOptionIds, CHECK}; -use crate::services::group::{GeneratedGroupConfig, Group, GroupData, MoveGroupRowContext}; +use crate::services::group::{Group, GroupData, MoveGroupRowContext}; pub fn add_or_remove_select_option_row( group: &mut GroupData, @@ -186,16 +186,10 @@ pub fn make_inserted_cell(group_id: &str, field: &Field) -> Option { } } -pub fn generate_select_option_groups( - _field_id: &str, - options: &[SelectOption], -) -> Vec { +pub fn generate_select_option_groups(_field_id: &str, options: &[SelectOption]) -> Vec { let groups = options .iter() - .map(|option| GeneratedGroupConfig { - group: Group::new(option.id.clone(), option.name.clone()), - filter_content: option.id.clone(), - }) + .map(|option| Group::new(option.id.clone())) .collect(); groups 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 a687acce5d1af..195bae405c8ee 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,3 @@ -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}; @@ -13,11 +11,10 @@ use crate::entities::{ use crate::services::cell::insert_url_cell; 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}; +use crate::services::group::configuration::GroupControllerContext; +use crate::services::group::controller::BaseGroupController; use crate::services::group::{ - make_no_status_group, move_group_row, GeneratedGroupConfig, GeneratedGroups, Group, - GroupOperationInterceptor, GroupTypeOptionCellOperation, GroupsBuilder, MoveGroupRowContext, + make_no_status_group, move_group_row, GeneratedGroups, Group, GroupsBuilder, MoveGroupRowContext, }; #[derive(Default, Serialize, Deserialize)] @@ -25,15 +22,10 @@ pub struct URLGroupConfiguration { pub hide_empty: bool, } -pub type URLGroupController = BaseGroupController< - URLGroupConfiguration, - URLTypeOption, - URLGroupGenerator, - URLCellDataParser, - URLGroupOperationInterceptorImpl, ->; +pub type URLGroupController = + BaseGroupController; -pub type URLGroupContext = GroupContext; +pub type URLGroupControllerContext = GroupControllerContext; impl GroupCustomize for URLGroupController { type GroupTypeOption = URLTypeOption; @@ -64,7 +56,7 @@ impl GroupCustomize for URLGroupController { let mut inserted_group = None; if self.context.get_group(&_cell_data.url).is_none() { let cell_data: URLCellData = _cell_data.clone().into(); - let group = make_group_from_url_cell(&cell_data); + let group = Group::new(cell_data.data); let mut new_group = self.context.add_new_group(group)?; new_group.group.rows.push(RowMetaPB::from(_row_detail)); inserted_group = Some(new_group); @@ -155,11 +147,7 @@ impl GroupCustomize for URLGroupController { (deleted_group, changesets) } - fn move_row( - &mut self, - _cell_data: &::CellProtobufType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; self.context.iter_mut_groups(|group| { if let Some(changeset) = move_group_row(group, &mut context) { @@ -168,13 +156,14 @@ impl GroupCustomize for URLGroupController { }); group_changeset } + fn delete_group_when_move_row( &mut self, _row: &Row, - _cell_data: &::CellProtobufType, + cell_data: &::CellProtobufType, ) -> Option { let mut deleted_group = None; - if let Some((_, group)) = self.context.get_group(&_cell_data.content) { + if let Some((_index, group)) = self.context.get_group(&cell_data.content) { if group.rows.len() == 1 { deleted_group = Some(GroupPB::from(group.clone())); } @@ -185,16 +174,12 @@ impl GroupCustomize for URLGroupController { deleted_group } - fn delete_group_custom(&mut self, group_id: &str) -> FlowyResult> { + fn delete_group(&mut self, group_id: &str) -> FlowyResult> { self.context.delete_group(group_id)?; Ok(None) } -} - -impl GroupController for URLGroupController { - fn did_update_field_type_option(&mut self, _field: &Field) {} - fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) { + fn will_create_row(&self, cells: &mut Cells, field: &Field, group_id: &str) { match self.context.get_group(group_id) { None => tracing::warn!("Can not find the group: {}", group_id), Some((_, group)) => { @@ -208,7 +193,7 @@ impl GroupController for URLGroupController { pub struct URLGroupGenerator(); #[async_trait] impl GroupsBuilder for URLGroupGenerator { - type Context = URLGroupContext; + type Context = URLGroupControllerContext; type GroupTypeOption = URLTypeOption; async fn build( @@ -220,36 +205,18 @@ impl GroupsBuilder for URLGroupGenerator { let cells = context.get_all_cells().await; // Generate the groups - let group_configs = cells + let groups = cells .into_iter() .flat_map(|value| value.into_url_field_cell_data()) .filter(|cell| !cell.data.is_empty()) - .map(|cell| GeneratedGroupConfig { - group: make_group_from_url_cell(&cell), - filter_content: cell.data, - }) + .map(|cell| Group::new(cell.data.clone())) .collect(); let no_status_group = Some(make_no_status_group(field)); + GeneratedGroups { no_status_group, - group_configs, + groups, } } } - -fn make_group_from_url_cell(cell: &URLCellData) -> Group { - let group_id = cell.data.clone(); - let group_name = cell.data.clone(); - Group::new(group_id, group_name) -} - -pub struct URLGroupOperationInterceptorImpl { - #[allow(dead_code)] - pub(crate) cell_writer: Arc, -} - -#[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 253c12bac931d..12692fd8129a6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/entities.rs @@ -14,16 +14,6 @@ pub struct GroupSetting { pub content: String, } -pub struct GroupChangesets { - pub changesets: Vec, -} - -impl From> for GroupChangesets { - fn from(changesets: Vec) -> Self { - Self { changesets } - } -} - #[derive(Clone, Default, Debug)] pub struct GroupChangeset { pub group_id: String, @@ -92,7 +82,6 @@ impl From for GroupSettingMap { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Group { pub id: String, - pub name: String, #[serde(default = "GROUP_VISIBILITY")] pub visible: bool, } @@ -104,9 +93,8 @@ impl TryFrom for Group { match value.get_str_value("id") { None => bail!("Invalid group data"), Some(id) => { - let name = value.get_str_value("name").unwrap_or_default(); let visible = value.get_bool_value("visible").unwrap_or_default(); - Ok(Self { id, name, visible }) + Ok(Self { id, visible }) }, } } @@ -116,7 +104,6 @@ impl From for GroupMap { fn from(group: Group) -> Self { GroupMapBuilder::new() .insert_str_value("id", group.id) - .insert_str_value("name", group.name) .insert_bool_value("visible", group.visible) .build() } @@ -125,12 +112,8 @@ impl From for GroupMap { const GROUP_VISIBILITY: fn() -> bool = || true; impl Group { - pub fn new(id: String, name: String) -> Self { - Self { - id, - name, - visible: true, - } + pub fn new(id: String) -> Self { + Self { id, visible: true } } } @@ -138,32 +121,20 @@ impl Group { pub struct GroupData { pub id: String, pub field_id: String, - pub name: String, pub is_default: bool, pub is_visible: bool, pub(crate) rows: Vec, - - /// [filter_content] is used to determine which group the cell belongs to. - pub filter_content: String, } impl GroupData { - pub fn new( - id: String, - field_id: String, - name: String, - filter_content: String, - is_visible: bool, - ) -> Self { + pub fn new(id: String, field_id: String, is_visible: bool) -> Self { let is_default = id == field_id; Self { id, field_id, is_default, 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 c221c7fdaf1e7..8eb677ed26a7f 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 @@ -4,21 +4,17 @@ use std::sync::Arc; use async_trait::async_trait; use collab_database::fields::Field; 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, CheckboxGroupOperationInterceptorImpl, - DateGroupContext, DateGroupController, DateGroupOperationInterceptorImpl, DefaultGroupController, - Group, GroupController, GroupSetting, GroupSettingReader, GroupSettingWriter, - GroupTypeOptionCellOperation, MultiSelectGroupController, - MultiSelectGroupOperationInterceptorImpl, MultiSelectOptionGroupContext, - SingleSelectGroupController, SingleSelectGroupOperationInterceptorImpl, - SingleSelectOptionGroupContext, URLGroupContext, URLGroupController, - URLGroupOperationInterceptorImpl, + CheckboxGroupController, CheckboxGroupControllerContext, DateGroupController, + DateGroupControllerContext, DefaultGroupController, Group, GroupContextDelegate, GroupController, + GroupControllerDelegate, GroupSetting, MultiSelectGroupController, + MultiSelectGroupControllerContext, SingleSelectGroupController, + SingleSelectGroupControllerContext, URLGroupController, URLGroupControllerContext, }; /// The [GroupsBuilder] trait is used to generate the groups for different [FieldType] @@ -36,12 +32,7 @@ pub trait GroupsBuilder: Send + Sync + 'static { pub struct GeneratedGroups { pub no_status_group: Option, - pub group_configs: Vec, -} - -pub struct GeneratedGroupConfig { - pub group: Group, - pub filter_content: String, + pub groups: Vec, } pub struct MoveGroupRowContext<'a> { @@ -94,136 +85,95 @@ impl RowChangeset { fields(grouping_field_id=%grouping_field.id, grouping_field_type) err )] -pub async fn make_group_controller( - view_id: String, - grouping_field: Arc, - row_details: Vec>, - setting_reader: R, - setting_writer: W, - type_option_cell_writer: TW, +pub async fn make_group_controller( + view_id: &str, + grouping_field: Field, + delegate: D, ) -> FlowyResult> where - R: GroupSettingReader, - W: GroupSettingWriter, - TW: GroupTypeOptionCellOperation, + D: GroupContextDelegate + GroupControllerDelegate, { let grouping_field_type = FieldType::from(grouping_field.field_type); tracing::Span::current().record("grouping_field", &grouping_field_type.default_name()); let mut group_controller: Box; - 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); + let delegate = Arc::new(delegate); match grouping_field_type { FieldType::SingleSelect => { - let configuration = SingleSelectOptionGroupContext::new( - view_id, + let configuration = SingleSelectGroupControllerContext::new( + view_id.to_string(), grouping_field.clone(), - configuration_reader, - configuration_writer, + delegate.clone(), ) .await?; - let operation_interceptor = SingleSelectGroupOperationInterceptorImpl; let controller = - SingleSelectGroupController::new(&grouping_field, configuration, operation_interceptor) - .await?; + SingleSelectGroupController::new(&grouping_field, configuration, delegate.clone()).await?; group_controller = Box::new(controller); }, FieldType::MultiSelect => { - let configuration = MultiSelectOptionGroupContext::new( - view_id, + let configuration = MultiSelectGroupControllerContext::new( + view_id.to_string(), grouping_field.clone(), - configuration_reader, - configuration_writer, + delegate.clone(), ) .await?; - let operation_interceptor = MultiSelectGroupOperationInterceptorImpl; let controller = - MultiSelectGroupController::new(&grouping_field, configuration, operation_interceptor) - .await?; + MultiSelectGroupController::new(&grouping_field, configuration, delegate.clone()).await?; group_controller = Box::new(controller); }, FieldType::Checkbox => { - let configuration = CheckboxGroupContext::new( - view_id, + let configuration = CheckboxGroupControllerContext::new( + view_id.to_string(), grouping_field.clone(), - configuration_reader, - configuration_writer, + delegate.clone(), ) .await?; - let operation_interceptor = CheckboxGroupOperationInterceptorImpl {}; let controller = - CheckboxGroupController::new(&grouping_field, configuration, operation_interceptor).await?; + CheckboxGroupController::new(&grouping_field, configuration, delegate.clone()).await?; group_controller = Box::new(controller); }, FieldType::URL => { - let configuration = URLGroupContext::new( - view_id, + let configuration = URLGroupControllerContext::new( + view_id.to_string(), grouping_field.clone(), - configuration_reader, - configuration_writer, + delegate.clone(), ) .await?; - let operation_interceptor = URLGroupOperationInterceptorImpl { - cell_writer: type_option_cell_writer, - }; let controller = - URLGroupController::new(&grouping_field, configuration, operation_interceptor).await?; + URLGroupController::new(&grouping_field, configuration, delegate.clone()).await?; group_controller = Box::new(controller); }, FieldType::DateTime => { - let configuration = DateGroupContext::new( - view_id, + let configuration = DateGroupControllerContext::new( + view_id.to_string(), grouping_field.clone(), - configuration_reader, - configuration_writer, + delegate.clone(), ) .await?; - let operation_interceptor = DateGroupOperationInterceptorImpl {}; let controller = - DateGroupController::new(&grouping_field, configuration, operation_interceptor).await?; + DateGroupController::new(&grouping_field, configuration, delegate.clone()).await?; group_controller = Box::new(controller); }, _ => { - group_controller = Box::new(DefaultGroupController::new(&grouping_field)); + group_controller = Box::new(DefaultGroupController::new( + &grouping_field, + delegate.clone(), + )); }, } // Separates the rows into different groups + let row_details = delegate.get_all_rows(view_id).await; + let rows = row_details .iter() .map(|row| row.as_ref()) - .collect::>(); + .collect::>(); + group_controller.fill_groups(rows.as_slice(), &grouping_field)?; - Ok(group_controller) -} -#[tracing::instrument(level = "debug", skip_all)] -pub fn find_new_grouping_field( - fields: &[Arc], - _layout: &DatabaseLayout, -) -> Option> { - let mut groupable_field_revs = fields - .iter() - .flat_map(|field_rev| { - let field_type = FieldType::from(field_rev.field_type); - match field_type.can_be_group() { - true => Some(field_rev.clone()), - false => None, - } - }) - .collect::>>(); - - if groupable_field_revs.is_empty() { - // If there is not groupable fields then we use the primary field. - fields - .iter() - .find(|field_rev| field_rev.is_primary) - .cloned() - } else { - Some(groupable_field_revs.remove(0)) - } + Ok(group_controller) } /// Returns a `default` group configuration for the [Field] @@ -240,7 +190,6 @@ pub fn default_group_setting(field: &Field) -> GroupSetting { pub fn make_no_status_group(field: &Field) -> Group { Group { id: field.id.clone(), - name: format!("No {}", field.name), visible: true, } } 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 c9f9e91b655a8..c2ac8300b4a05 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/mod.rs @@ -5,6 +5,7 @@ mod controller_impls; mod entities; mod group_builder; +pub(crate) use action::GroupController; pub(crate) use configuration::*; pub(crate) use controller::*; pub(crate) use controller_impls::*; 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 0c968dd35364f..4a9f09e63ce5f 100644 --- a/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs +++ b/frontend/rust-lib/flowy-database2/src/services/sort/controller.rs @@ -21,16 +21,16 @@ use crate::services::field::{ default_order, TimestampCellData, TimestampCellDataWrapper, TypeOptionCellExt, }; use crate::services::sort::{ - InsertSortedRowResult, ReorderAllRowsResult, ReorderSingleRowResult, Sort, SortChangeset, - SortCondition, + InsertRowResult, ReorderAllRowsResult, ReorderSingleRowResult, Sort, SortChangeset, SortCondition, }; pub trait SortDelegate: Send + Sync { fn get_sort(&self, view_id: &str, sort_id: &str) -> Fut>>; /// Returns all the rows after applying grid's filter fn get_rows(&self, view_id: &str) -> Fut>>; + fn filter_row(&self, row_detail: &RowDetail) -> Fut; fn get_field(&self, field_id: &str) -> Option; - fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>>; + fn get_fields(&self, view_id: &str, field_ids: Option>) -> Fut>; } pub struct SortController { @@ -94,14 +94,27 @@ impl SortController { } } - pub async fn did_create_row(&self, row_id: RowId) { + pub async fn did_create_row(&self, preliminary_index: usize, row_detail: &RowDetail) { + if !self.delegate.filter_row(row_detail).await { + return; + } + if !self.sorts.is_empty() { self .gen_task( - SortEvent::NewRowInserted(row_id), + SortEvent::NewRowInserted(row_detail.clone()), QualityOfService::Background, ) .await; + } else { + let result = InsertRowResult { + view_id: self.view_id.clone(), + row: row_detail.clone(), + index: preliminary_index, + }; + let _ = self + .notifier + .send(DatabaseViewChanged::InsertRowNotification(result)); } } @@ -117,6 +130,7 @@ impl SortController { pub async fn process(&mut self, predicate: &str) -> FlowyResult<()> { let event_type = SortEvent::from_str(predicate).unwrap(); let mut row_details = self.delegate.get_rows(&self.view_id).await; + match event_type { SortEvent::SortDidChanged | SortEvent::DeleteAllSorts => { self.sort_rows(&mut row_details).await; @@ -161,22 +175,20 @@ impl SortController { _ => tracing::trace!("The row index cache is outdated"), } }, - SortEvent::NewRowInserted(row_id) => { + SortEvent::NewRowInserted(row_detail) => { self.sort_rows(&mut row_details).await; - let row_index = self.row_index_cache.get(&row_id).cloned(); + let row_index = self.row_index_cache.get(&row_detail.row.id).cloned(); match row_index { Some(row_index) => { - let notification = InsertSortedRowResult { - row_id: row_id.clone(), + let notification = InsertRowResult { view_id: self.view_id.clone(), + row: row_detail.clone(), index: row_index, }; - self.row_index_cache.insert(row_id, row_index); + self.row_index_cache.insert(row_detail.row.id, row_index); let _ = self .notifier - .send(DatabaseViewChanged::InsertSortedRowNotification( - notification, - )); + .send(DatabaseViewChanged::InsertRowNotification(notification)); }, _ => tracing::trace!("The row index cache is outdated"), } @@ -290,7 +302,7 @@ fn cmp_row( left: &Row, right: &Row, sort: &Arc, - fields: &[Arc], + fields: &[Field], cell_data_cache: &CellCache, ) -> Ordering { match fields @@ -335,18 +347,16 @@ fn cmp_row( fn cmp_cell( left_cell: Option<&Cell>, right_cell: Option<&Cell>, - field: &Arc, + field: &Field, field_type: FieldType, cell_data_cache: &CellCache, sort_condition: SortCondition, ) -> Ordering { - match TypeOptionCellExt::new_with_cell_data_cache(field.as_ref(), Some(cell_data_cache.clone())) + match TypeOptionCellExt::new(field, Some(cell_data_cache.clone())) .get_type_option_cell_data_handler(&field_type) { None => default_order(), - Some(handler) => { - handler.handle_cell_compare(left_cell, right_cell, field.as_ref(), sort_condition) - }, + Some(handler) => handler.handle_cell_compare(left_cell, right_cell, field, sort_condition), } } @@ -354,7 +364,7 @@ fn cmp_cell( enum SortEvent { SortDidChanged, RowDidChanged(RowId), - NewRowInserted(RowId), + NewRowInserted(RowDetail), DeleteAllSorts, } diff --git a/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs b/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs index 66bfea4f3f875..9f9d37d4fb853 100644 --- a/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/sort/entities.rs @@ -2,7 +2,7 @@ use std::cmp::Ordering; use anyhow::bail; use collab::core::any_map::AnyMapExtension; -use collab_database::rows::RowId; +use collab_database::rows::{RowDetail, RowId}; use collab_database::views::{SortMap, SortMapBuilder}; #[derive(Debug, Clone)] @@ -107,9 +107,9 @@ pub struct ReorderSingleRowResult { } #[derive(Clone)] -pub struct InsertSortedRowResult { +pub struct InsertRowResult { pub view_id: String, - pub row_id: RowId, + pub row: RowDetail, pub index: usize, } diff --git a/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs index c253422242001..72b62b55dffe2 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs @@ -1,7 +1,6 @@ -use collab_database::database::gen_row_id; use collab_database::rows::RowId; -use lib_infra::util::timestamp; +use flowy_database2::entities::CreateRowPayloadPB; use crate::database::database_editor::DatabaseEditorTest; @@ -30,17 +29,11 @@ impl DatabaseRowTest { pub async fn run_script(&mut self, script: RowScript) { match script { RowScript::CreateEmptyRow => { - let params = collab_database::rows::CreateRowParams { - id: gen_row_id(), - timestamp: timestamp(), + let params = CreateRowPayloadPB { + view_id: self.view_id.clone(), ..Default::default() }; - let row_detail = self - .editor - .create_row(&self.view_id, None, params) - .await - .unwrap() - .unwrap(); + let row_detail = self.editor.create_row(params).await.unwrap().unwrap(); self .row_by_row_id .insert(row_detail.row.id.to_string(), row_detail.into()); diff --git a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs index 3a31446374014..2049e5815329e 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs @@ -3,7 +3,8 @@ use std::time::Duration; use flowy_database2::entities::FieldType; use flowy_database2::services::field::{ ChecklistCellChangeset, DateCellChangeset, DateCellData, MultiSelectTypeOption, - SelectOptionCellChangeset, SingleSelectTypeOption, StrCellData, URLCellData, + RelationCellChangeset, SelectOptionCellChangeset, SingleSelectTypeOption, StrCellData, + URLCellData, }; use lib_infra::box_any::BoxAny; @@ -52,6 +53,10 @@ async fn grid_cell_update() { }), FieldType::Checkbox => BoxAny::new("1".to_string()), FieldType::URL => BoxAny::new("1".to_string()), + FieldType::Relation => BoxAny::new(RelationCellChangeset { + inserted_row_ids: vec!["abcdefabcdef".to_string().into()], + ..Default::default() + }), _ => BoxAny::new("".to_string()), }; 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 8e4af7073f65d..cccaba68fe5b9 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/database_editor.rs @@ -155,12 +155,13 @@ impl DatabaseEditorTest { type_option.options } - pub fn get_single_select_type_option(&self, field_id: &str) -> SingleSelectTypeOption { + pub fn get_single_select_type_option(&self, field_id: &str) -> Vec { let field_type = FieldType::SingleSelect; let field = self.get_field(field_id, field_type); - field + let type_option = field .get_type_option::(field_type) - .unwrap() + .unwrap(); + type_option.options } #[allow(dead_code)] diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs index 03b42a69b7979..3ec982f461e76 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs @@ -1,7 +1,7 @@ use collab_database::database::gen_option_id; use flowy_database2::entities::{FieldChangesetParams, FieldType}; -use flowy_database2::services::field::{SelectOption, CHECK, UNCHECK}; +use flowy_database2::services::field::{SelectOption, SingleSelectTypeOption, CHECK, UNCHECK}; use crate::database::field_test::script::DatabaseFieldTest; use crate::database::field_test::script::FieldScript::*; @@ -104,16 +104,16 @@ async fn grid_switch_from_select_option_to_checkbox_test() { let field = test.get_first_field(FieldType::SingleSelect); // Update the type option data of single select option - let mut single_select_type_option = test.get_single_select_type_option(&field.id); - single_select_type_option.options.clear(); + let mut options = test.get_single_select_type_option(&field.id); + options.clear(); // Add a new option with name CHECK - single_select_type_option.options.push(SelectOption { + options.push(SelectOption { id: gen_option_id(), name: CHECK.to_string(), color: Default::default(), }); // Add a new option with name UNCHECK - single_select_type_option.options.push(SelectOption { + options.push(SelectOption { id: gen_option_id(), name: UNCHECK.to_string(), color: Default::default(), @@ -122,7 +122,11 @@ async fn grid_switch_from_select_option_to_checkbox_test() { let scripts = vec![ UpdateTypeOption { field_id: field.id.clone(), - type_option: single_select_type_option.into(), + type_option: SingleSelectTypeOption { + options, + disable_color: false, + } + .into(), }, SwitchToField { field_id: field.id.clone(), @@ -159,16 +163,10 @@ async fn grid_switch_from_checkbox_to_select_option_test() { ]; test.run_scripts(scripts).await; - let single_select_type_option = test.get_single_select_type_option(&checkbox_field.id); - assert_eq!(single_select_type_option.options.len(), 2); - assert!(single_select_type_option - .options - .iter() - .any(|option| option.name == UNCHECK)); - assert!(single_select_type_option - .options - .iter() - .any(|option| option.name == CHECK)); + let options = test.get_single_select_type_option(&checkbox_field.id); + assert_eq!(options.len(), 2); + assert!(options.iter().any(|option| option.name == UNCHECK)); + assert!(options.iter().any(|option| option.name == CHECK)); } // Test when switching the current field from Multi-select to Text test @@ -206,7 +204,7 @@ async fn grid_switch_from_multi_select_to_text_test() { // Test when switching the current field from Checkbox to Text test // input: // check -> "Yes" -// unchecked -> "" +// unchecked -> "No" #[tokio::test] async fn grid_switch_from_checkbox_to_text_test() { let mut test = DatabaseFieldTest::new().await; @@ -290,3 +288,24 @@ async fn grid_switch_from_number_to_text_test() { test.run_scripts(scripts).await; } + +/// Test when switching the current field from Checklist to Text test +#[tokio::test] +async fn grid_switch_from_checklist_to_text_test() { + let mut test = DatabaseFieldTest::new().await; + let field_rev = test.get_first_field(FieldType::Checklist); + + let scripts = vec![ + SwitchToField { + field_id: field_rev.id.clone(), + new_field_type: FieldType::RichText, + }, + AssertCellContent { + field_id: field_rev.id.clone(), + row_index: 0, + from_field_type: FieldType::Checklist, + expected_content: "First thing".to_string(), + }, + ]; + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs new file mode 100644 index 0000000000000..107e588fed217 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs @@ -0,0 +1,314 @@ +use bytes::Bytes; +use flowy_database2::entities::{ + CheckboxFilterConditionPB, CheckboxFilterPB, DateFilterConditionPB, DateFilterPB, FieldType, + FilterDataPB, FilterPB, FilterType, NumberFilterConditionPB, NumberFilterPB, +}; +use lib_infra::box_any::BoxAny; +use protobuf::ProtobufError; +use std::convert::TryInto; + +use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged, FilterScript::*}; + +/// Create a single advanced filter: +/// +/// 1. Add an OR filter +/// 2. Add a Checkbox and an AND filter to its children +/// 3. Add a DateTime and a Number filter to the AND filter's children +/// +#[tokio::test] +async fn create_advanced_filter_test() { + let mut test = DatabaseFilterTest::new().await; + + let create_checkbox_filter = || -> CheckboxFilterPB { + CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsChecked, + } + }; + + let create_date_filter = || -> DateFilterPB { + DateFilterPB { + condition: DateFilterConditionPB::DateAfter, + timestamp: Some(1651366800), + ..Default::default() + } + }; + + let create_number_filter = || -> NumberFilterPB { + NumberFilterPB { + condition: NumberFilterConditionPB::NumberIsNotEmpty, + content: "".to_string(), + } + }; + + let scripts = vec![ + CreateOrFilter { + parent_filter_id: None, + changed: None, + }, + Wait { millisecond: 100 }, + AssertFilters { + expected: vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![], + data: None, + }], + }, + ]; + test.run_scripts(scripts).await; + // OR + + let or_filter = test.get_filter(FilterType::Or, None).await.unwrap(); + + let checkbox_filter_bytes: Result = create_checkbox_filter().try_into(); + let checkbox_filter_bytes = checkbox_filter_bytes.unwrap().to_vec(); + + let scripts = vec![ + CreateDataFilter { + parent_filter_id: Some(or_filter.id.clone()), + field_type: FieldType::Checkbox, + data: BoxAny::new(create_checkbox_filter()), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: 4, + }), + }, + CreateAndFilter { + parent_filter_id: Some(or_filter.id), + changed: None, + }, + Wait { millisecond: 100 }, + AssertFilters { + expected: vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Checkbox, + data: checkbox_filter_bytes.clone(), + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::And, + children: vec![], + data: None, + }, + ], + data: None, + }], + }, + AssertNumberOfVisibleRows { expected: 3 }, + ]; + test.run_scripts(scripts).await; + // IS_CHECK OR AND + + let and_filter = test.get_filter(FilterType::And, None).await.unwrap(); + + let date_filter_bytes: Result = create_date_filter().try_into(); + let date_filter_bytes = date_filter_bytes.unwrap().to_vec(); + let number_filter_bytes: Result = create_number_filter().try_into(); + let number_filter_bytes = number_filter_bytes.unwrap().to_vec(); + + let scripts = vec![ + CreateDataFilter { + parent_filter_id: Some(and_filter.id.clone()), + field_type: FieldType::DateTime, + data: BoxAny::new(create_date_filter()), + changed: None, + }, + CreateDataFilter { + parent_filter_id: Some(and_filter.id), + field_type: FieldType::Number, + data: BoxAny::new(create_number_filter()), + changed: None, + }, + Wait { millisecond: 100 }, + AssertFilters { + expected: vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Checkbox, + data: checkbox_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::And, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::DateTime, + data: date_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Number, + data: number_filter_bytes, + }), + }, + ], + data: None, + }, + ], + data: None, + }], + }, + AssertNumberOfVisibleRows { expected: 4 }, + ]; + test.run_scripts(scripts).await; + // IS_CHECK OR (DATE > 1651366800 AND NUMBER NOT EMPTY) +} + +/// Create the same advanced filter single advanced filter: +/// +/// 1. Add an OR filter +/// 2. Add a Checkbox and a DateTime filter to its children +/// 3. Add a Number filter to the DateTime filter's children +/// +#[tokio::test] +async fn create_advanced_filter_with_conversion_test() { + let mut test = DatabaseFilterTest::new().await; + + let create_checkbox_filter = || -> CheckboxFilterPB { + CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsChecked, + } + }; + + let create_date_filter = || -> DateFilterPB { + DateFilterPB { + condition: DateFilterConditionPB::DateAfter, + timestamp: Some(1651366800), + ..Default::default() + } + }; + + let create_number_filter = || -> NumberFilterPB { + NumberFilterPB { + condition: NumberFilterConditionPB::NumberIsNotEmpty, + content: "".to_string(), + } + }; + + let scripts = vec![CreateOrFilter { + parent_filter_id: None, + changed: None, + }]; + test.run_scripts(scripts).await; + // IS_CHECK OR DATE > 1651366800 + + let or_filter = test.get_filter(FilterType::Or, None).await.unwrap(); + + let scripts = vec![ + CreateDataFilter { + parent_filter_id: Some(or_filter.id.clone()), + field_type: FieldType::Checkbox, + data: BoxAny::new(create_checkbox_filter()), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: 4, + }), + }, + CreateDataFilter { + parent_filter_id: Some(or_filter.id.clone()), + field_type: FieldType::DateTime, + data: BoxAny::new(create_date_filter()), + changed: None, + }, + ]; + test.run_scripts(scripts).await; + // OR + + let date_filter = test + .get_filter(FilterType::Data, Some(FieldType::DateTime)) + .await + .unwrap(); + + let checkbox_filter_bytes: Result = create_checkbox_filter().try_into(); + let checkbox_filter_bytes = checkbox_filter_bytes.unwrap().to_vec(); + let date_filter_bytes: Result = create_date_filter().try_into(); + let date_filter_bytes = date_filter_bytes.unwrap().to_vec(); + let number_filter_bytes: Result = create_number_filter().try_into(); + let number_filter_bytes = number_filter_bytes.unwrap().to_vec(); + + let scripts = vec![ + CreateDataFilter { + parent_filter_id: Some(date_filter.id), + field_type: FieldType::Number, + data: BoxAny::new(create_number_filter()), + changed: None, + }, + Wait { millisecond: 100 }, + AssertFilters { + expected: vec![FilterPB { + id: "".to_string(), + filter_type: FilterType::Or, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Checkbox, + data: checkbox_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::And, + children: vec![ + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::DateTime, + data: date_filter_bytes, + }), + }, + FilterPB { + id: "".to_string(), + filter_type: FilterType::Data, + children: vec![], + data: Some(FilterDataPB { + field_id: "".to_string(), + field_type: FieldType::Number, + data: number_filter_bytes, + }), + }, + ], + data: None, + }, + ], + data: None, + }], + }, + AssertNumberOfVisibleRows { expected: 4 }, + ]; + test.run_scripts(scripts).await; + // IS_CHECK OR (DATE > 1651366800 AND NUMBER NOT EMPTY) +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs index dd30c75df617f..881a1cebf9893 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs @@ -1,4 +1,5 @@ -use flowy_database2::entities::CheckboxFilterConditionPB; +use flowy_database2::entities::{CheckboxFilterConditionPB, CheckboxFilterPB, FieldType}; +use lib_infra::box_any::BoxAny; use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; @@ -6,16 +7,24 @@ use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged} #[tokio::test] async fn grid_filter_checkbox_is_check_test() { let mut test = DatabaseFilterTest::new().await; + let expected = 3; let row_count = test.row_details.len(); - // The initial number of unchecked is 3 - // The initial number of checked is 2 - let scripts = vec![CreateCheckboxFilter { - condition: CheckboxFilterConditionPB::IsChecked, - changed: Some(FilterRowChanged { - showing_num_of_rows: 0, - hiding_num_of_rows: row_count - 3, - }), - }]; + // The initial number of checked is 3 + // The initial number of unchecked is 4 + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Checkbox, + data: BoxAny::new(CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsChecked, + }), + changed: Some(FilterRowChanged { + showing_num_of_rows: 0, + hiding_num_of_rows: row_count - expected, + }), + }, + AssertNumberOfVisibleRows { expected }, + ]; test.run_scripts(scripts).await; } @@ -25,8 +34,12 @@ async fn grid_filter_checkbox_is_uncheck_test() { let expected = 4; let row_count = test.row_details.len(); let scripts = vec![ - CreateCheckboxFilter { - condition: CheckboxFilterConditionPB::IsUnChecked, + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Checkbox, + data: BoxAny::new(CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsUnChecked, + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs index b6bbfc88f6d07..3da9cab5a26de 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs @@ -1,5 +1,6 @@ -use flowy_database2::entities::{ChecklistFilterConditionPB, FieldType}; +use flowy_database2::entities::{ChecklistFilterConditionPB, ChecklistFilterPB, FieldType}; use flowy_database2::services::field::checklist_type_option::ChecklistCellData; +use lib_infra::box_any::BoxAny; use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; @@ -16,8 +17,12 @@ async fn grid_filter_checklist_is_incomplete_test() { row_id: test.row_details[0].row.id.clone(), selected_option_ids: option_ids, }, - CreateChecklistFilter { - condition: ChecklistFilterConditionPB::IsIncomplete, + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Checklist, + data: BoxAny::new(ChecklistFilterPB { + condition: ChecklistFilterConditionPB::IsIncomplete, + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -39,8 +44,12 @@ async fn grid_filter_checklist_is_complete_test() { row_id: test.row_details[0].row.id.clone(), selected_option_ids: option_ids, }, - CreateChecklistFilter { - condition: ChecklistFilterConditionPB::IsComplete, + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Checklist, + data: BoxAny::new(ChecklistFilterPB { + condition: ChecklistFilterConditionPB::IsComplete, + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs index 86caf2d8faa3e..34964b9720828 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs @@ -1,4 +1,5 @@ -use flowy_database2::entities::DateFilterConditionPB; +use flowy_database2::entities::{DateFilterConditionPB, DateFilterPB, FieldType}; +use lib_infra::box_any::BoxAny; use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; @@ -9,11 +10,15 @@ async fn grid_filter_date_is_test() { let row_count = test.row_details.len(); let expected = 3; let scripts = vec![ - CreateDateFilter { - condition: DateFilterConditionPB::DateIs, - start: None, - end: None, - timestamp: Some(1647251762), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::DateTime, + data: BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateIs, + start: None, + end: None, + timestamp: Some(1647251762), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -30,11 +35,15 @@ async fn grid_filter_date_after_test() { let row_count = test.row_details.len(); let expected = 3; let scripts = vec![ - CreateDateFilter { - condition: DateFilterConditionPB::DateAfter, - start: None, - end: None, - timestamp: Some(1647251762), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::DateTime, + data: BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateAfter, + start: None, + end: None, + timestamp: Some(1647251762), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -51,11 +60,15 @@ async fn grid_filter_date_on_or_after_test() { let row_count = test.row_details.len(); let expected = 3; let scripts = vec![ - CreateDateFilter { - condition: DateFilterConditionPB::DateOnOrAfter, - start: None, - end: None, - timestamp: Some(1668359085), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::DateTime, + data: BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateOnOrAfter, + start: None, + end: None, + timestamp: Some(1668359085), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -72,11 +85,15 @@ async fn grid_filter_date_on_or_before_test() { let row_count = test.row_details.len(); let expected = 4; let scripts = vec![ - CreateDateFilter { - condition: DateFilterConditionPB::DateOnOrBefore, - start: None, - end: None, - timestamp: Some(1668359085), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::DateTime, + data: BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateOnOrBefore, + start: None, + end: None, + timestamp: Some(1668359085), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -93,11 +110,15 @@ async fn grid_filter_date_within_test() { let row_count = test.row_details.len(); let expected = 5; let scripts = vec![ - CreateDateFilter { - condition: DateFilterConditionPB::DateWithIn, - start: Some(1647251762), - end: Some(1668704685), - timestamp: None, + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::DateTime, + data: BoxAny::new(DateFilterPB { + condition: DateFilterConditionPB::DateWithIn, + start: Some(1647251762), + end: Some(1668704685), + timestamp: None, + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs index 160bf3427f28d..bf5d1513c9acb 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs @@ -1,3 +1,4 @@ +mod advanced_filter_test; mod checkbox_filter_test; mod checklist_filter_test; mod date_filter_test; diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs index c6cdef1db2bfc..e041ba1b4c3b2 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs @@ -1,4 +1,5 @@ -use flowy_database2::entities::NumberFilterConditionPB; +use flowy_database2::entities::{FieldType, NumberFilterConditionPB, NumberFilterPB}; +use lib_infra::box_any::BoxAny; use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; @@ -9,9 +10,13 @@ async fn grid_filter_number_is_equal_test() { let row_count = test.row_details.len(); let expected = 1; let scripts = vec![ - CreateNumberFilter { - condition: NumberFilterConditionPB::Equal, - content: "1".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Number, + data: BoxAny::new(NumberFilterPB { + condition: NumberFilterConditionPB::Equal, + content: "1".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -28,9 +33,13 @@ async fn grid_filter_number_is_less_than_test() { let row_count = test.row_details.len(); let expected = 2; let scripts = vec![ - CreateNumberFilter { - condition: NumberFilterConditionPB::LessThan, - content: "3".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Number, + data: BoxAny::new(NumberFilterPB { + condition: NumberFilterConditionPB::LessThan, + content: "3".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -48,9 +57,13 @@ async fn grid_filter_number_is_less_than_test2() { let row_count = test.row_details.len(); let expected = 2; let scripts = vec![ - CreateNumberFilter { - condition: NumberFilterConditionPB::LessThan, - content: "$3".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Number, + data: BoxAny::new(NumberFilterPB { + condition: NumberFilterConditionPB::LessThan, + content: "$3".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -67,9 +80,13 @@ async fn grid_filter_number_is_less_than_or_equal_test() { let row_count = test.row_details.len(); let expected = 3; let scripts = vec![ - CreateNumberFilter { - condition: NumberFilterConditionPB::LessThanOrEqualTo, - content: "3".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Number, + data: BoxAny::new(NumberFilterPB { + condition: NumberFilterConditionPB::LessThanOrEqualTo, + content: "3".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -86,9 +103,13 @@ async fn grid_filter_number_is_empty_test() { let row_count = test.row_details.len(); let expected = 2; let scripts = vec![ - CreateNumberFilter { - condition: NumberFilterConditionPB::NumberIsEmpty, - content: "".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Number, + data: BoxAny::new(NumberFilterPB { + condition: NumberFilterConditionPB::NumberIsEmpty, + content: "".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -105,9 +126,13 @@ async fn grid_filter_number_is_not_empty_test() { let row_count = test.row_details.len(); let expected = 5; let scripts = vec![ - CreateNumberFilter { - condition: NumberFilterConditionPB::NumberIsNotEmpty, - content: "".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::Number, + data: BoxAny::new(NumberFilterPB { + condition: NumberFilterConditionPB::NumberIsNotEmpty, + content: "".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, 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 1518398719930..f2b58070e7655 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 @@ -3,14 +3,12 @@ use std::time::Duration; use collab_database::rows::RowId; -use flowy_database2::services::filter::FilterContext; +use flowy_database2::services::filter::{FilterChangeset, FilterInner}; +use lib_infra::box_any::BoxAny; use tokio::sync::broadcast::Receiver; use flowy_database2::entities::{ - CheckboxFilterConditionPB, CheckboxFilterPB, ChecklistFilterConditionPB, ChecklistFilterPB, - DatabaseViewSettingPB, DateFilterConditionPB, DateFilterPB, DeleteFilterPayloadPB, FieldType, - FilterPB, NumberFilterConditionPB, NumberFilterPB, SelectOptionConditionPB, SelectOptionFilterPB, - TextFilterConditionPB, TextFilterPB, UpdateFilterParams, UpdateFilterPayloadPB, + DatabaseViewSettingPB, FieldType, FilterPB, FilterType, TextFilterConditionPB, TextFilterPB, }; use flowy_database2::services::database_view::DatabaseViewChanged; use lib_dispatch::prelude::af_spawn; @@ -37,12 +35,10 @@ pub enum FilterScript { option_id: String, changed: Option, }, - InsertFilter { - payload: UpdateFilterPayloadPB, - }, - CreateTextFilter { - condition: TextFilterConditionPB, - content: String, + CreateDataFilter { + parent_filter_id: Option, + field_type: FieldType, + data: BoxAny, changed: Option, }, UpdateTextFilter { @@ -51,50 +47,36 @@ pub enum FilterScript { content: String, changed: Option, }, - CreateNumberFilter { - condition: NumberFilterConditionPB, - content: String, - changed: Option, - }, - CreateCheckboxFilter { - condition: CheckboxFilterConditionPB, + CreateAndFilter { + parent_filter_id: Option, changed: Option, }, - CreateDateFilter { - condition: DateFilterConditionPB, - start: Option, - end: Option, - timestamp: Option, + CreateOrFilter { + parent_filter_id: Option, changed: Option, }, - CreateMultiSelectFilter { - condition: SelectOptionConditionPB, - option_ids: Vec, - }, - CreateSingleSelectFilter { - condition: SelectOptionConditionPB, - option_ids: Vec, - changed: Option, - }, - CreateChecklistFilter { - condition: ChecklistFilterConditionPB, - changed: Option, - }, - AssertFilterCount { - count: i32, - }, DeleteFilter { - filter_context: FilterContext, + filter_id: String, + field_id: String, changed: Option, }, - AssertFilterContent { - filter_id: String, - condition: i64, - content: String, + // CreateSimpleAdvancedFilter, + // CreateComplexAdvancedFilter, + AssertFilterCount { + count: usize, }, AssertNumberOfVisibleRows { expected: usize, }, + AssertFilters { + /// 1. assert that the filter type is correct + /// 2. if the filter is data, assert that the field_type, condition and content are correct + /// (no field_id) + /// 3. if the filter is and/or, assert that each child is correct as well. + expected: Vec, + }, + // AssertSimpleAdvancedFilter, + // AssertComplexAdvancedFilterResult, #[allow(dead_code)] AssertGridSetting { expected_setting: DatabaseViewSettingPB, @@ -118,14 +100,54 @@ impl DatabaseFilterTest { } } - pub fn view_id(&self) -> String { - self.view_id.clone() - } - pub async fn get_all_filters(&self) -> Vec { self.editor.get_all_filters(&self.view_id).await.items } + pub async fn get_filter( + &self, + filter_type: FilterType, + field_type: Option, + ) -> Option { + let filters = self.inner.editor.get_all_filters(&self.view_id).await; + + for filter in filters.items.iter() { + let result = Self::find_filter(filter, filter_type, field_type); + if result.is_some() { + return result; + } + } + + None + } + + fn find_filter( + filter: &FilterPB, + filter_type: FilterType, + field_type: Option, + ) -> Option { + match &filter.filter_type { + FilterType::And | FilterType::Or if filter.filter_type == filter_type => Some(filter.clone()), + FilterType::And | FilterType::Or => { + for child_filter in filter.children.iter() { + if let Some(result) = Self::find_filter(child_filter, filter_type, field_type) { + return Some(result); + } + } + None + }, + FilterType::Data + if filter.filter_type == filter_type + && field_type.map_or(false, |field_type| { + field_type == filter.data.clone().unwrap().field_type + }) => + { + Some(filter.clone()) + }, + _ => None, + } + } + pub async fn run_scripts(&mut self, scripts: Vec) { for script in scripts { self.run_script(script).await; @@ -139,13 +161,7 @@ impl DatabaseFilterTest { text, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; self.assert_future_changed(changed).await; self.update_text_cell(row_id, &text).await.unwrap(); }, @@ -163,46 +179,35 @@ impl DatabaseFilterTest { option_id, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; self.assert_future_changed(changed).await; self .update_single_select_cell(row_id, &option_id) .await .unwrap(); }, - FilterScript::InsertFilter { payload } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); - self.insert_filter(payload).await; - }, - FilterScript::CreateTextFilter { - condition, - content, + FilterScript::CreateDataFilter { + parent_filter_id, + field_type, + data, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; self.assert_future_changed(changed).await; - let field = self.get_first_field(FieldType::RichText); - let text_filter = TextFilterPB { condition, content }; - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, text_filter); - self.insert_filter(payload).await; + let field = self.get_first_field(field_type); + let params = FilterChangeset::Insert { + parent_filter_id, + data: FilterInner::Data { + field_id: field.id, + field_type, + condition_and_content: data, + }, + }; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); }, FilterScript::UpdateTextFilter { filter, @@ -210,172 +215,76 @@ impl DatabaseFilterTest { content, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; + self.assert_future_changed(changed).await; - let params = UpdateFilterParams { - view_id: self.view_id(), - field_id: filter.field_id, - filter_id: Some(filter.id), - field_type: filter.field_type, - condition: condition as i64, - content, + let current_filter = filter.data.unwrap(); + let params = FilterChangeset::UpdateData { + filter_id: filter.id, + data: FilterInner::Data { + field_id: current_filter.field_id, + field_type: current_filter.field_type, + condition_and_content: BoxAny::new(TextFilterPB { condition, content }), + }, }; - self.editor.create_or_update_filter(params).await.unwrap(); - }, - FilterScript::CreateNumberFilter { - condition, - content, - changed, - } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); - self.assert_future_changed(changed).await; - let field = self.get_first_field(FieldType::Number); - let number_filter = NumberFilterPB { condition, content }; - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, number_filter); - self.insert_filter(payload).await; - }, - FilterScript::CreateCheckboxFilter { condition, changed } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); - self.assert_future_changed(changed).await; - let field = self.get_first_field(FieldType::Checkbox); - let checkbox_filter = CheckboxFilterPB { condition }; - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, checkbox_filter); - self.insert_filter(payload).await; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); }, - FilterScript::CreateDateFilter { - condition, - start, - end, - timestamp, + FilterScript::CreateAndFilter { + parent_filter_id, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; self.assert_future_changed(changed).await; - let field = self.get_first_field(FieldType::DateTime); - let date_filter = DateFilterPB { - condition, - start, - end, - timestamp, + let params = FilterChangeset::Insert { + parent_filter_id, + data: FilterInner::And { children: vec![] }, }; - - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, date_filter); - self.insert_filter(payload).await; - }, - FilterScript::CreateMultiSelectFilter { - condition, - option_ids, - } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); - let field = self.get_first_field(FieldType::MultiSelect); - let filter = SelectOptionFilterPB { - condition, - option_ids, - }; - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, filter); - self.insert_filter(payload).await; + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); }, - FilterScript::CreateSingleSelectFilter { - condition, - option_ids, + FilterScript::CreateOrFilter { + parent_filter_id, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; self.assert_future_changed(changed).await; - let field = self.get_first_field(FieldType::SingleSelect); - let filter = SelectOptionFilterPB { - condition, - option_ids, + let params = FilterChangeset::Insert { + parent_filter_id, + data: FilterInner::Or { children: vec![] }, }; - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, filter); - self.insert_filter(payload).await; - }, - FilterScript::CreateChecklistFilter { condition, changed } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); - self.assert_future_changed(changed).await; - let field = self.get_first_field(FieldType::Checklist); - let filter = ChecklistFilterPB { condition }; - let payload = UpdateFilterPayloadPB::new(&self.view_id(), &field, filter); - self.insert_filter(payload).await; - }, - FilterScript::AssertFilterCount { count } => { - let filters = self.editor.get_all_filters(&self.view_id).await.items; - assert_eq!(count as usize, filters.len()); - }, - FilterScript::AssertFilterContent { - filter_id, - condition, - content, - } => { - let filter = self + self .editor - .get_filter(&self.view_id, &filter_id) + .modify_view_filters(&self.view_id, params) .await .unwrap(); - assert_eq!(&filter.content, &content); - assert_eq!(filter.condition, condition); + }, + FilterScript::AssertFilterCount { count } => { + let filters = self.editor.get_all_filters(&self.view_id).await.items; + assert_eq!(count, filters.len()); }, FilterScript::DeleteFilter { - filter_context, + filter_id, + field_id, changed, } => { - self.recv = Some( - self - .editor - .subscribe_view_changed(&self.view_id()) - .await - .unwrap(), - ); + self.subscribe_view_changed().await; self.assert_future_changed(changed).await; - let params = DeleteFilterPayloadPB { - filter_id: filter_context.filter_id, - view_id: self.view_id(), - field_id: filter_context.field_id, - field_type: filter_context.field_type, + let params = FilterChangeset::Delete { + filter_id, + field_id, }; - self.editor.delete_filter(params).await.unwrap(); + self + .editor + .modify_view_filters(&self.view_id, params) + .await + .unwrap(); }, FilterScript::AssertGridSetting { expected_setting } => { let setting = self @@ -385,6 +294,12 @@ impl DatabaseFilterTest { .unwrap(); assert_eq!(expected_setting, setting); }, + FilterScript::AssertFilters { expected } => { + let actual = self.get_all_filters().await; + for (actual_filter, expected_filter) in actual.iter().zip(expected.iter()) { + Self::assert_filter(actual_filter, expected_filter); + } + }, FilterScript::AssertNumberOfVisibleRows { expected } => { let grid = self.editor.get_database_data(&self.view_id).await.unwrap(); assert_eq!(grid.rows.len(), expected); @@ -395,6 +310,16 @@ impl DatabaseFilterTest { } } + async fn subscribe_view_changed(&mut self) { + self.recv = Some( + self + .editor + .subscribe_view_changed(&self.view_id) + .await + .unwrap(), + ); + } + async fn assert_future_changed(&mut self, change: Option) { if change.is_none() { return; @@ -424,9 +349,24 @@ impl DatabaseFilterTest { }); } - async fn insert_filter(&self, payload: UpdateFilterPayloadPB) { - let params: UpdateFilterParams = payload.try_into().unwrap(); - self.editor.create_or_update_filter(params).await.unwrap(); + fn assert_filter(actual: &FilterPB, expected: &FilterPB) { + assert_eq!(actual.filter_type, expected.filter_type); + assert_eq!(actual.children.is_empty(), expected.children.is_empty()); + assert_eq!(actual.data.is_some(), expected.data.is_some()); + + match actual.filter_type { + FilterType::Data => { + let actual_data = actual.data.clone().unwrap(); + let expected_data = expected.data.clone().unwrap(); + assert_eq!(actual_data.field_type, expected_data.field_type); + assert_eq!(actual_data.data, expected_data.data); + }, + FilterType::And | FilterType::Or => { + for (actual_child, expected_child) in actual.children.iter().zip(expected.children.iter()) { + Self::assert_filter(actual_child, expected_child); + } + }, + } } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs index 16c848ea12d7f..eb808d0bc30b6 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs @@ -1,4 +1,5 @@ -use flowy_database2::entities::{FieldType, SelectOptionConditionPB}; +use flowy_database2::entities::{FieldType, SelectOptionFilterConditionPB, SelectOptionFilterPB}; +use lib_infra::box_any::BoxAny; use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged}; @@ -7,9 +8,14 @@ use crate::database::filter_test::script::{DatabaseFilterTest, FilterRowChanged} async fn grid_filter_multi_select_is_empty_test() { let mut test = DatabaseFilterTest::new().await; let scripts = vec![ - CreateMultiSelectFilter { - condition: SelectOptionConditionPB::OptionIsEmpty, - option_ids: vec![], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::MultiSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIsEmpty, + option_ids: vec![], + }), + changed: None, }, AssertNumberOfVisibleRows { expected: 2 }, ]; @@ -20,9 +26,14 @@ async fn grid_filter_multi_select_is_empty_test() { async fn grid_filter_multi_select_is_not_empty_test() { let mut test = DatabaseFilterTest::new().await; let scripts = vec![ - CreateMultiSelectFilter { - condition: SelectOptionConditionPB::OptionIsNotEmpty, - option_ids: vec![], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::MultiSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, + option_ids: vec![], + }), + changed: None, }, AssertNumberOfVisibleRows { expected: 5 }, ]; @@ -35,11 +46,16 @@ async fn grid_filter_multi_select_is_test() { let field = test.get_first_field(FieldType::MultiSelect); let mut options = test.get_multi_select_type_option(&field.id); let scripts = vec![ - CreateMultiSelectFilter { - condition: SelectOptionConditionPB::OptionIs, - option_ids: vec![options.remove(0).id, options.remove(0).id], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::MultiSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: vec![options.remove(0).id, options.remove(0).id], + }), + changed: None, }, - AssertNumberOfVisibleRows { expected: 5 }, + AssertNumberOfVisibleRows { expected: 1 }, ]; test.run_scripts(scripts).await; } @@ -50,11 +66,16 @@ async fn grid_filter_multi_select_is_test2() { let field = test.get_first_field(FieldType::MultiSelect); let mut options = test.get_multi_select_type_option(&field.id); let scripts = vec![ - CreateMultiSelectFilter { - condition: SelectOptionConditionPB::OptionIs, - option_ids: vec![options.remove(1).id], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::MultiSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: vec![options.remove(1).id], + }), + changed: None, }, - AssertNumberOfVisibleRows { expected: 4 }, + AssertNumberOfVisibleRows { expected: 1 }, ]; test.run_scripts(scripts).await; } @@ -65,9 +86,13 @@ async fn grid_filter_single_select_is_empty_test() { let expected = 3; let row_count = test.row_details.len(); let scripts = vec![ - CreateSingleSelectFilter { - condition: SelectOptionConditionPB::OptionIsEmpty, - option_ids: vec![], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::SingleSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIsEmpty, + option_ids: vec![], + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -82,13 +107,17 @@ async fn grid_filter_single_select_is_empty_test() { async fn grid_filter_single_select_is_test() { let mut test = DatabaseFilterTest::new().await; let field = test.get_first_field(FieldType::SingleSelect); - let mut options = test.get_single_select_type_option(&field.id).options; + let mut options = test.get_single_select_type_option(&field.id); let expected = 2; let row_count = test.row_details.len(); let scripts = vec![ - CreateSingleSelectFilter { - condition: SelectOptionConditionPB::OptionIs, - option_ids: vec![options.remove(0).id], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::SingleSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: vec![options.remove(0).id], + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - expected, @@ -104,14 +133,18 @@ async fn grid_filter_single_select_is_test2() { let mut test = DatabaseFilterTest::new().await; let field = test.get_first_field(FieldType::SingleSelect); let row_details = test.get_rows().await; - let mut options = test.get_single_select_type_option(&field.id).options; + let mut options = test.get_single_select_type_option(&field.id); let option = options.remove(0); let row_count = test.row_details.len(); let scripts = vec![ - CreateSingleSelectFilter { - condition: SelectOptionConditionPB::OptionIs, - option_ids: vec![option.id.clone()], + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::SingleSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: vec![option.id.clone()], + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: row_count - 2, @@ -136,3 +169,43 @@ async fn grid_filter_single_select_is_test2() { ]; test.run_scripts(scripts).await; } + +#[tokio::test] +async fn grid_filter_multi_select_contains_test() { + let mut test = DatabaseFilterTest::new().await; + let field = test.get_first_field(FieldType::MultiSelect); + let mut options = test.get_multi_select_type_option(&field.id); + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::MultiSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionContains, + option_ids: vec![options.remove(0).id, options.remove(0).id], + }), + changed: None, + }, + AssertNumberOfVisibleRows { expected: 5 }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn grid_filter_multi_select_contains_test2() { + let mut test = DatabaseFilterTest::new().await; + let field = test.get_first_field(FieldType::MultiSelect); + let mut options = test.get_multi_select_type_option(&field.id); + let scripts = vec![ + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::MultiSelect, + data: BoxAny::new(SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionContains, + option_ids: vec![options.remove(1).id], + }), + changed: None, + }, + AssertNumberOfVisibleRows { expected: 4 }, + ]; + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs index 3c4940d261561..600f4342faf44 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs @@ -1,7 +1,5 @@ -use flowy_database2::entities::{ - FieldType, TextFilterConditionPB, TextFilterPB, UpdateFilterPayloadPB, -}; -use flowy_database2::services::filter::FilterContext; +use flowy_database2::entities::{FieldType, TextFilterConditionPB, TextFilterPB}; +use lib_infra::box_any::BoxAny; use crate::database::filter_test::script::FilterScript::*; use crate::database::filter_test::script::*; @@ -10,9 +8,13 @@ use crate::database::filter_test::script::*; async fn grid_filter_text_is_empty_test() { let mut test = DatabaseFilterTest::new().await; let scripts = vec![ - CreateTextFilter { - condition: TextFilterConditionPB::TextIsEmpty, - content: "".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextIsEmpty, + content: "".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 5, @@ -28,9 +30,13 @@ async fn grid_filter_text_is_not_empty_test() { let mut test = DatabaseFilterTest::new().await; // Only one row's text of the initial rows is "" let scripts = vec![ - CreateTextFilter { - condition: TextFilterConditionPB::TextIsNotEmpty, - content: "".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextIsNotEmpty, + content: "".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 1, @@ -44,7 +50,8 @@ async fn grid_filter_text_is_not_empty_test() { test .run_scripts(vec![ DeleteFilter { - filter_context: FilterContext::from(&filter), + filter_id: filter.id, + field_id: filter.data.unwrap().field_id, changed: Some(FilterRowChanged { showing_num_of_rows: 1, hiding_num_of_rows: 0, @@ -59,9 +66,13 @@ async fn grid_filter_text_is_not_empty_test() { async fn grid_filter_is_text_test() { let mut test = DatabaseFilterTest::new().await; // Only one row's text of the initial rows is "A" - let scripts = vec![CreateTextFilter { - condition: TextFilterConditionPB::Is, - content: "A".to_string(), + let scripts = vec![CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextIs, + content: "A".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 5, @@ -73,9 +84,13 @@ async fn grid_filter_is_text_test() { #[tokio::test] async fn grid_filter_contain_text_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![CreateTextFilter { - condition: TextFilterConditionPB::Contains, - content: "A".to_string(), + let scripts = vec![CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "A".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 2, @@ -90,9 +105,13 @@ async fn grid_filter_contain_text_test2() { let row_detail = test.row_details.clone(); let scripts = vec![ - CreateTextFilter { - condition: TextFilterConditionPB::Contains, - content: "A".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "A".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 2, @@ -114,9 +133,13 @@ async fn grid_filter_contain_text_test2() { async fn grid_filter_does_not_contain_text_test() { let mut test = DatabaseFilterTest::new().await; // None of the initial rows contains the text "AB" - let scripts = vec![CreateTextFilter { - condition: TextFilterConditionPB::DoesNotContain, - content: "AB".to_string(), + let scripts = vec![CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextDoesNotContain, + content: "AB".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 0, @@ -128,9 +151,13 @@ async fn grid_filter_does_not_contain_text_test() { #[tokio::test] async fn grid_filter_start_with_text_test() { let mut test = DatabaseFilterTest::new().await; - let scripts = vec![CreateTextFilter { - condition: TextFilterConditionPB::StartsWith, - content: "A".to_string(), + let scripts = vec![CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextStartsWith, + content: "A".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 3, @@ -143,9 +170,13 @@ async fn grid_filter_start_with_text_test() { async fn grid_filter_ends_with_text_test() { let mut test = DatabaseFilterTest::new().await; let scripts = vec![ - CreateTextFilter { - condition: TextFilterConditionPB::EndsWith, - content: "A".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextEndsWith, + content: "A".to_string(), + }), changed: None, }, AssertNumberOfVisibleRows { expected: 2 }, @@ -157,9 +188,13 @@ async fn grid_filter_ends_with_text_test() { async fn grid_update_text_filter_test() { let mut test = DatabaseFilterTest::new().await; let scripts = vec![ - CreateTextFilter { - condition: TextFilterConditionPB::EndsWith, - content: "A".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextEndsWith, + content: "A".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 4, @@ -175,7 +210,7 @@ async fn grid_update_text_filter_test() { let scripts = vec![ UpdateTextFilter { filter, - condition: TextFilterConditionPB::Is, + condition: TextFilterConditionPB::TextIs, content: "A".to_string(), changed: Some(FilterRowChanged { showing_num_of_rows: 0, @@ -190,14 +225,16 @@ async fn grid_update_text_filter_test() { #[tokio::test] async fn grid_filter_delete_test() { let mut test = DatabaseFilterTest::new().await; - let field = test.get_first_field(FieldType::RichText).clone(); - let text_filter = TextFilterPB { - condition: TextFilterConditionPB::TextIsEmpty, - content: "".to_string(), - }; - let payload = UpdateFilterPayloadPB::new(&test.view_id(), &field, text_filter); let scripts = vec![ - InsertFilter { payload }, + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + changed: None, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextIsEmpty, + content: "".to_string(), + }), + }, AssertFilterCount { count: 1 }, AssertNumberOfVisibleRows { expected: 1 }, ]; @@ -207,7 +244,8 @@ async fn grid_filter_delete_test() { test .run_scripts(vec![ DeleteFilter { - filter_context: FilterContext::from(&filter), + filter_id: filter.id, + field_id: filter.data.unwrap().field_id, changed: None, }, AssertFilterCount { count: 0 }, @@ -221,9 +259,13 @@ async fn grid_filter_update_empty_text_cell_test() { let mut test = DatabaseFilterTest::new().await; let row_details = test.row_details.clone(); let scripts = vec![ - CreateTextFilter { - condition: TextFilterConditionPB::TextIsEmpty, - content: "".to_string(), + CreateDataFilter { + parent_filter_id: None, + field_type: FieldType::RichText, + data: BoxAny::new(TextFilterPB { + condition: TextFilterConditionPB::TextIsEmpty, + content: "".to_string(), + }), changed: Some(FilterRowChanged { showing_num_of_rows: 0, hiding_num_of_rows: 5, diff --git a/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs b/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs index 5ee71618683fd..418dafa0f7f00 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs @@ -3,12 +3,8 @@ use std::vec; use chrono::NaiveDateTime; use chrono::{offset, Duration}; -use collab_database::database::gen_row_id; -use collab_database::rows::CreateRowParams; -use collab_database::views::OrderObjectPosition; -use flowy_database2::entities::FieldType; -use flowy_database2::services::cell::CellBuilder; +use flowy_database2::entities::{CreateRowPayloadPB, FieldType}; use flowy_database2::services::field::DateCellData; use crate::database::group_test::script::DatabaseGroupTest; @@ -26,19 +22,17 @@ async fn group_by_date_test() { .unwrap() .timestamp() .to_string(); + let mut cells = HashMap::new(); cells.insert(date_field.id.clone(), timestamp); - let cells = CellBuilder::with_cells(cells, &[date_field.clone()]).build(); - let params = CreateRowParams { - id: gen_row_id(), - cells, - height: 60, - visibility: true, - row_position: OrderObjectPosition::default(), - timestamp: 0, + let params = CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: cells, + ..Default::default() }; - let res = test.editor.create_row(&test.view_id, None, params).await; + + let res = test.editor.create_row(params).await; assert!(res.is_ok()); } @@ -69,56 +63,50 @@ async fn group_by_date_test() { row_count: 0, }, // Added via `make_test_board` - AssertGroupIDName { + AssertGroupId { group_index: 1, group_id: "2022/03/01".to_string(), - group_name: "Mar 2022".to_string(), }, AssertGroupRowCount { group_index: 1, row_count: 3, }, // Added via `make_test_board` - AssertGroupIDName { + AssertGroupId { group_index: 2, group_id: "2022/11/01".to_string(), - group_name: "Nov 2022".to_string(), }, AssertGroupRowCount { group_index: 2, row_count: 2, }, - AssertGroupIDName { + AssertGroupId { group_index: 3, group_id: last_30_days, - group_name: "Last 30 days".to_string(), }, AssertGroupRowCount { group_index: 3, row_count: 1, }, - AssertGroupIDName { + AssertGroupId { group_index: 4, group_id: last_day, - group_name: "Yesterday".to_string(), }, AssertGroupRowCount { group_index: 4, row_count: 2, }, - AssertGroupIDName { + AssertGroupId { group_index: 5, group_id: today.format("%Y/%m/%d").to_string(), - group_name: "Today".to_string(), }, AssertGroupRowCount { group_index: 5, row_count: 1, }, - AssertGroupIDName { + AssertGroupId { group_index: 6, group_id: next_7_days, - group_name: "Next 7 days".to_string(), }, AssertGroupRowCount { group_index: 6, @@ -186,10 +174,9 @@ async fn change_date_on_moving_row_to_another_group() { group_index: 2, row_count: 3, }, - AssertGroupIDName { + AssertGroupId { group_index: 2, group_id: "2022/11/01".to_string(), - group_name: "Nov 2022".to_string(), }, ]; test.run_scripts(scripts).await; 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 e1d24b29da207..48f47b01e01de 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 @@ -1,8 +1,7 @@ -use collab_database::database::gen_row_id; use collab_database::fields::Field; -use collab_database::rows::{CreateRowParams, RowId}; +use collab_database::rows::RowId; -use flowy_database2::entities::{FieldType, GroupPB, RowMetaPB}; +use flowy_database2::entities::{CreateRowPayloadPB, FieldType, GroupPB, RowMetaPB}; use flowy_database2::services::cell::{ delete_select_option_cell, insert_date_cell, insert_select_option_cell, insert_url_cell, }; @@ -10,7 +9,6 @@ use flowy_database2::services::field::{ edit_single_select_type_option, SelectOption, SelectTypeOptionSharedAction, SingleSelectTypeOption, }; -use lib_infra::util::timestamp; use crate::database::database_editor::DatabaseEditorTest; @@ -62,10 +60,9 @@ pub enum GroupScript { GroupByField { field_id: String, }, - AssertGroupIDName { + AssertGroupId { group_index: usize, group_id: String, - group_name: String, }, CreateGroup { name: String, @@ -138,16 +135,13 @@ impl DatabaseGroupTest { }, GroupScript::CreateRow { group_index } => { let group = self.group_at_index(group_index).await; - let params = CreateRowParams { - id: gen_row_id(), - timestamp: timestamp(), - ..Default::default() + let params = CreateRowPayloadPB { + view_id: self.view_id.clone(), + row_position: Default::default(), + group_id: Some(group.group_id), + data: Default::default(), }; - let _ = self - .editor - .create_row(&self.view_id, Some(group.group_id.clone()), params) - .await - .unwrap(); + let _ = self.editor.create_row(params).await.unwrap(); }, GroupScript::DeleteRow { group_index, @@ -246,7 +240,6 @@ impl DatabaseGroupTest { } => { let group = self.group_at_index(group_index).await; assert_eq!(group.group_id, group_pb.group_id); - assert_eq!(group.group_name, group_pb.group_name); }, GroupScript::UpdateSingleSelectSelectOption { inserted_options } => { self @@ -264,14 +257,12 @@ impl DatabaseGroupTest { .await .unwrap(); }, - GroupScript::AssertGroupIDName { + GroupScript::AssertGroupId { group_index, group_id, - group_name, } => { let group = self.group_at_index(group_index).await; assert_eq!(group_id, group.group_id, "group index: {}", group_index); - assert_eq!(group_name, group.group_name, "group index: {}", group_index); }, GroupScript::CreateGroup { name } => self .editor 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 61e4d0e6aa543..33e2b1563c398 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 @@ -457,13 +457,17 @@ async fn group_insert_single_select_option_test() { let scripts = vec![ AssertGroupCount(4), UpdateSingleSelectSelectOption { - inserted_options: vec![SelectOption::new(new_option_name)], + inserted_options: vec![SelectOption { + id: new_option_name.to_string(), + name: new_option_name.to_string(), + color: Default::default(), + }], }, AssertGroupCount(5), ]; test.run_scripts(scripts).await; let new_group = test.group_at_index(4).await; - assert_eq!(new_group.group_name, new_option_name); + assert_eq!(new_group.group_id, new_option_name); } #[tokio::test] @@ -499,6 +503,4 @@ async fn group_manual_create_new_group() { 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/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index f08335c8a3fb3..318e6579d83b3 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 @@ -5,8 +5,8 @@ use strum::IntoEnumIterator; use flowy_database2::entities::FieldType; use flowy_database2::services::field::checklist_type_option::ChecklistTypeOption; use flowy_database2::services::field::{ - DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, SelectOption, SelectOptionColor, - SingleSelectTypeOption, TimeFormat, TimestampTypeOption, + DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, RelationTypeOption, + SelectOption, SelectOptionColor, SingleSelectTypeOption, TimeFormat, TimestampTypeOption, }; use flowy_database2::services::field_settings::default_field_settings_for_fields; use flowy_database2::services::setting::BoardLayoutSetting; @@ -126,6 +126,16 @@ pub fn make_test_board() -> DatabaseData { .build(); fields.push(checklist_field); }, + FieldType::Relation => { + let type_option = RelationTypeOption { + database_id: "".to_string(), + }; + let relation_field = FieldBuilder::new(field_type, type_option) + .name("Related") + .visibility(true) + .build(); + fields.push(relation_field); + }, } } @@ -227,7 +237,6 @@ pub fn make_test_board() -> DatabaseData { FieldType::SingleSelect => { row_builder.insert_single_select_cell(|mut options| options.remove(2)) }, - FieldType::Checkbox => row_builder.insert_checkbox_cell("false"), _ => "".to_owned(), }; 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 4fd1f5fb8fd3f..01362d75b47f3 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 @@ -3,10 +3,10 @@ use collab_database::views::{DatabaseLayout, DatabaseView}; use strum::IntoEnumIterator; use flowy_database2::entities::FieldType; -use flowy_database2::services::field::checklist_type_option::ChecklistTypeOption; use flowy_database2::services::field::{ - DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, NumberFormat, NumberTypeOption, - SelectOption, SelectOptionColor, SingleSelectTypeOption, TimeFormat, TimestampTypeOption, + ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, + NumberFormat, NumberTypeOption, RelationTypeOption, SelectOption, SelectOptionColor, + SingleSelectTypeOption, TimeFormat, TimestampTypeOption, }; use flowy_database2::services::field_settings::default_field_settings_for_fields; @@ -117,17 +117,23 @@ pub fn make_test_grid() -> DatabaseData { fields.push(url); }, FieldType::Checklist => { - // let option1 = SelectOption::with_color(FIRST_THING, SelectOptionColor::Purple); - // let option2 = SelectOption::with_color(SECOND_THING, SelectOptionColor::Orange); - // let option3 = SelectOption::with_color(THIRD_THING, SelectOptionColor::Yellow); let type_option = ChecklistTypeOption; - // type_option.options.extend(vec![option1, option2, option3]); let checklist_field = FieldBuilder::new(field_type, type_option) .name("TODO") .visibility(true) .build(); fields.push(checklist_field); }, + FieldType::Relation => { + let type_option = RelationTypeOption { + database_id: "".to_string(), + }; + let relation_field = FieldBuilder::new(field_type, type_option) + .name("Related") + .visibility(true) + .build(); + fields.push(relation_field); + }, } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/mod.rs b/frontend/rust-lib/flowy-database2/tests/database/mod.rs index 5333d54c337fe..f1614f5493147 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mod.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mod.rs @@ -7,7 +7,7 @@ mod field_test; mod filter_test; mod group_test; mod layout_test; -mod sort_test; - mod mock_data; +mod pre_fill_cell_test; mod share_test; +mod sort_test; diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/mod.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/mod.rs new file mode 100644 index 0000000000000..0e76b61079e04 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/mod.rs @@ -0,0 +1,3 @@ +mod pre_fill_row_according_to_filter_test; +mod pre_fill_row_with_payload_test; +mod script; diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs new file mode 100644 index 0000000000000..1000df6f4bc99 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs @@ -0,0 +1,433 @@ +use flowy_database2::entities::{ + CheckboxFilterConditionPB, CheckboxFilterPB, DateFilterConditionPB, DateFilterPB, FieldType, + FilterDataPB, SelectOptionFilterConditionPB, SelectOptionFilterPB, TextFilterConditionPB, + TextFilterPB, +}; +use flowy_database2::services::field::SELECTION_IDS_SEPARATOR; + +use crate::database::pre_fill_cell_test::script::{ + DatabasePreFillRowCellTest, PreFillRowCellTestScript::*, +}; + +// This suite of tests cover creating an empty row into a database that has +// active filters. Where appropriate, the row's cell data will be pre-filled +// into the row's cells before creating it in collab. + +#[tokio::test] +async fn according_to_text_contains_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + + let scripts = vec![ + InsertFilter { + filter: FilterDataPB { + field_id: text_field.id.clone(), + field_type: FieldType::RichText, + data: TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "sample".to_string(), + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + CreateEmptyRow, + Wait { milliseconds: 100 }, + ]; + + test.run_scripts(scripts).await; + + let scripts = vec![ + AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len() - 1, + exists: true, + }, + AssertCellContent { + field_id: text_field.id, + row_index: test.row_details.len() - 1, + from_field_type: FieldType::RichText, + expected_content: "sample".to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_empty_text_contains_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + + let scripts = vec![ + InsertFilter { + filter: FilterDataPB { + field_id: text_field.id.clone(), + field_type: FieldType::RichText, + data: TextFilterPB { + condition: TextFilterConditionPB::TextContains, + content: "".to_string(), + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + CreateEmptyRow, + Wait { milliseconds: 100 }, + ]; + + test.run_scripts(scripts).await; + + let scripts = vec![AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len() - 1, + exists: false, + }]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_text_is_not_empty_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: text_field.id.clone(), + field_type: FieldType::RichText, + data: TextFilterPB { + condition: TextFilterConditionPB::TextIsNotEmpty, + content: "".to_string(), + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(6), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(6), + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_checkbox_is_unchecked_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let checkbox_field = test.get_first_field(FieldType::Checkbox); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: checkbox_field.id.clone(), + field_type: FieldType::Checkbox, + data: CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsUnChecked, + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(4), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(5), + ]; + + test.run_scripts(scripts).await; + + let scripts = vec![AssertCellExistence { + field_id: checkbox_field.id.clone(), + row_index: 4, + exists: false, + }]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_checkbox_is_checked_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let checkbox_field = test.get_first_field(FieldType::Checkbox); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: checkbox_field.id.clone(), + field_type: FieldType::Checkbox, + data: CheckboxFilterPB { + condition: CheckboxFilterConditionPB::IsChecked, + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(3), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(4), + ]; + + test.run_scripts(scripts).await; + + let scripts = vec![ + AssertCellExistence { + field_id: checkbox_field.id.clone(), + row_index: 3, + exists: true, + }, + AssertCellContent { + field_id: checkbox_field.id, + row_index: 3, + from_field_type: FieldType::Checkbox, + expected_content: "Yes".to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_date_time_is_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let datetime_field = test.get_first_field(FieldType::DateTime); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: datetime_field.id.clone(), + field_type: FieldType::DateTime, + data: DateFilterPB { + condition: DateFilterConditionPB::DateIs, + timestamp: Some(1710510086), + ..Default::default() + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(0), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(1), + ]; + + test.run_scripts(scripts).await; + + let scripts = vec![ + AssertCellExistence { + field_id: datetime_field.id.clone(), + row_index: 0, + exists: true, + }, + AssertCellContent { + field_id: datetime_field.id, + row_index: 0, + from_field_type: FieldType::DateTime, + expected_content: "2024/03/15".to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_invalid_date_time_is_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let datetime_field = test.get_first_field(FieldType::DateTime); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: datetime_field.id.clone(), + field_type: FieldType::DateTime, + data: DateFilterPB { + condition: DateFilterConditionPB::DateIs, + timestamp: None, + ..Default::default() + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(7), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(8), + AssertCellExistence { + field_id: datetime_field.id.clone(), + row_index: test.row_details.len(), + exists: false, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_select_option_is_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let multi_select_field = test.get_first_field(FieldType::MultiSelect); + let options = test.get_multi_select_type_option(&multi_select_field.id); + + let filtering_options = vec![options[1].clone(), options[2].clone()]; + let ids = filtering_options + .iter() + .map(|option| option.id.clone()) + .collect(); + let stringified_expected = filtering_options + .iter() + .map(|option| option.name.clone()) + .collect::>() + .join(SELECTION_IDS_SEPARATOR); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: multi_select_field.id.clone(), + field_type: FieldType::MultiSelect, + data: SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIs, + option_ids: ids, + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(1), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(2), + AssertCellExistence { + field_id: multi_select_field.id.clone(), + row_index: 1, + exists: true, + }, + AssertCellContent { + field_id: multi_select_field.id, + row_index: 1, + from_field_type: FieldType::MultiSelect, + expected_content: stringified_expected, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_select_option_contains_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let multi_select_field = test.get_first_field(FieldType::MultiSelect); + let options = test.get_multi_select_type_option(&multi_select_field.id); + + let filtering_options = vec![options[1].clone(), options[2].clone()]; + let ids = filtering_options + .iter() + .map(|option| option.id.clone()) + .collect(); + let stringified_expected = filtering_options.first().unwrap().name.clone(); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: multi_select_field.id.clone(), + field_type: FieldType::MultiSelect, + data: SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionContains, + option_ids: ids, + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(5), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(6), + AssertCellExistence { + field_id: multi_select_field.id.clone(), + row_index: 5, + exists: true, + }, + AssertCellContent { + field_id: multi_select_field.id, + row_index: 5, + from_field_type: FieldType::MultiSelect, + expected_content: stringified_expected, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn according_to_select_option_is_not_empty_filter_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let multi_select_field = test.get_first_field(FieldType::MultiSelect); + let options = test.get_multi_select_type_option(&multi_select_field.id); + + let stringified_expected = options.first().unwrap().name.clone(); + + let scripts = vec![ + AssertRowCount(7), + InsertFilter { + filter: FilterDataPB { + field_id: multi_select_field.id.clone(), + field_type: FieldType::MultiSelect, + data: SelectOptionFilterPB { + condition: SelectOptionFilterConditionPB::OptionIsNotEmpty, + ..Default::default() + } + .try_into() + .unwrap(), + }, + }, + Wait { milliseconds: 100 }, + AssertRowCount(5), + CreateEmptyRow, + Wait { milliseconds: 100 }, + AssertRowCount(6), + AssertCellExistence { + field_id: multi_select_field.id.clone(), + row_index: 5, + exists: true, + }, + AssertCellContent { + field_id: multi_select_field.id, + row_index: 5, + from_field_type: FieldType::MultiSelect, + expected_content: stringified_expected, + }, + ]; + + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs new file mode 100644 index 0000000000000..b1b42d6479a7f --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs @@ -0,0 +1,422 @@ +use std::collections::HashMap; + +use flowy_database2::entities::{CreateRowPayloadPB, FieldType}; +use flowy_database2::services::field::{DateCellData, SELECTION_IDS_SEPARATOR}; + +use crate::database::pre_fill_cell_test::script::{ + DatabasePreFillRowCellTest, PreFillRowCellTestScript::*, +}; + +// This suite of tests cover creating a row using `CreateRowPayloadPB` that passes +// in some cell data in its `data` field of `HashMap` which is a +// map of `field_id` to its corresponding cell data as a String. If valid, the cell +// data will be pre-filled into the row's cells before creating it in collab. + +#[tokio::test] +async fn row_data_payload_with_empty_hashmap_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::new(), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + exists: false, + }, + AssertCellContent { + field_id: text_field.id, + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: "".to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_unknown_field_id_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + let malformed_field_id = "this_field_id_will_never_exist"; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([( + malformed_field_id.to_string(), + "sample cell data".to_string(), + )]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + exists: false, + }, + AssertCellContent { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: "".to_string(), + }, + AssertCellExistence { + field_id: malformed_field_id.to_string(), + row_index: test.row_details.len(), + exists: false, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_empty_string_text_data_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + let cell_data = ""; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(text_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: text_field.id, + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: cell_data.to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_text_data_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + let cell_data = "sample cell data"; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(text_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: cell_data.to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_multi_text_data_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let text_field = test.get_first_field(FieldType::RichText); + let number_field = test.get_first_field(FieldType::Number); + let url_field = test.get_first_field(FieldType::URL); + + let text_cell_data = "sample cell data"; + let number_cell_data = "1234"; + let url_cell_data = "appflowy.io"; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([ + (text_field.id.clone(), text_cell_data.to_string()), + (number_field.id.clone(), number_cell_data.to_string()), + (url_field.id.clone(), url_cell_data.to_string()), + ]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: text_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: text_field.id, + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: text_cell_data.to_string(), + }, + AssertCellExistence { + field_id: number_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: number_field.id, + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: "$1,234".to_string(), + }, + AssertCellExistence { + field_id: url_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: url_field.id, + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: url_cell_data.to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_date_time_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let date_field = test.get_first_field(FieldType::DateTime); + let cell_data = "1710510086"; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(date_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: date_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: date_field.id.clone(), + row_index: test.row_details.len(), + from_field_type: FieldType::RichText, + expected_content: "2024/03/15".to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_invalid_date_time_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let date_field = test.get_first_field(FieldType::DateTime); + let cell_data = DateCellData { + timestamp: Some(1710510086), + ..Default::default() + } + .to_string(); + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(date_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: date_field.id.clone(), + row_index: test.row_details.len(), + exists: false, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_checkbox_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let checkbox_field = test.get_first_field(FieldType::Checkbox); + let cell_data = "Yes"; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(checkbox_field.id.clone(), cell_data.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: checkbox_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: checkbox_field.id.clone(), + row_index: test.row_details.len(), + from_field_type: FieldType::Checkbox, + expected_content: cell_data.to_string(), + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_select_option_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let multi_select_field = test.get_first_field(FieldType::MultiSelect); + let options = test.get_multi_select_type_option(&multi_select_field.id); + + let ids = options + .iter() + .map(|option| option.id.clone()) + .collect::>() + .join(SELECTION_IDS_SEPARATOR); + + let stringified_cell_data = options + .iter() + .map(|option| option.name.clone()) + .collect::>() + .join(SELECTION_IDS_SEPARATOR); + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(multi_select_field.id.clone(), ids)]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: multi_select_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertCellContent { + field_id: multi_select_field.id.clone(), + row_index: test.row_details.len(), + from_field_type: FieldType::MultiSelect, + expected_content: stringified_cell_data, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_invalid_select_option_id_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let multi_select_field = test.get_first_field(FieldType::MultiSelect); + let mut options = test.get_multi_select_type_option(&multi_select_field.id); + + let first_id = options.swap_remove(0).id; + let ids = [first_id.clone(), "nonsense".to_string()].join(SELECTION_IDS_SEPARATOR); + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(multi_select_field.id.clone(), ids.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: multi_select_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertSelectOptionCellStrict { + field_id: multi_select_field.id.clone(), + row_index: test.row_details.len(), + expected_content: first_id, + }, + ]; + + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn row_data_payload_with_too_many_select_option_test() { + let mut test = DatabasePreFillRowCellTest::new().await; + + let single_select_field = test.get_first_field(FieldType::SingleSelect); + let mut options = test.get_single_select_type_option(&single_select_field.id); + + let ids = options + .iter() + .map(|option| option.id.clone()) + .collect::>() + .join(SELECTION_IDS_SEPARATOR); + + let stringified_cell_data = options.swap_remove(0).id; + + let scripts = vec![ + CreateRowWithPayload { + payload: CreateRowPayloadPB { + view_id: test.view_id.clone(), + data: HashMap::from([(single_select_field.id.clone(), ids.to_string())]), + ..Default::default() + }, + }, + Wait { milliseconds: 100 }, + AssertCellExistence { + field_id: single_select_field.id.clone(), + row_index: test.row_details.len(), + exists: true, + }, + AssertSelectOptionCellStrict { + field_id: single_select_field.id.clone(), + row_index: test.row_details.len(), + expected_content: stringified_cell_data, + }, + ]; + + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs new file mode 100644 index 0000000000000..e78732ec5116c --- /dev/null +++ b/frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs @@ -0,0 +1,164 @@ +use std::ops::{Deref, DerefMut}; +use std::time::Duration; + +use flowy_database2::entities::{CreateRowPayloadPB, FieldType, FilterDataPB, InsertFilterPB}; +use flowy_database2::services::cell::stringify_cell_data; +use flowy_database2::services::field::{SelectOptionIds, SELECTION_IDS_SEPARATOR}; + +use crate::database::database_editor::DatabaseEditorTest; + +pub enum PreFillRowCellTestScript { + CreateEmptyRow, + CreateRowWithPayload { + payload: CreateRowPayloadPB, + }, + InsertFilter { + filter: FilterDataPB, + }, + AssertRowCount(usize), + AssertCellExistence { + field_id: String, + row_index: usize, + exists: bool, + }, + AssertCellContent { + field_id: String, + row_index: usize, + from_field_type: FieldType, + expected_content: String, + }, + AssertSelectOptionCellStrict { + field_id: String, + row_index: usize, + expected_content: String, + }, + Wait { + milliseconds: u64, + }, +} + +pub struct DatabasePreFillRowCellTest { + inner: DatabaseEditorTest, +} + +impl DatabasePreFillRowCellTest { + pub async fn new() -> Self { + let editor_test = DatabaseEditorTest::new_grid().await; + Self { inner: editor_test } + } + + pub async fn run_scripts(&mut self, scripts: Vec) { + for script in scripts { + self.run_script(script).await; + } + } + + pub async fn run_script(&mut self, script: PreFillRowCellTestScript) { + match script { + PreFillRowCellTestScript::CreateEmptyRow => { + let params = CreateRowPayloadPB { + view_id: self.view_id.clone(), + ..Default::default() + }; + let row_detail = self.editor.create_row(params).await.unwrap().unwrap(); + self + .row_by_row_id + .insert(row_detail.row.id.to_string(), row_detail.into()); + self.row_details = self.get_rows().await; + }, + PreFillRowCellTestScript::CreateRowWithPayload { payload } => { + let row_detail = self.editor.create_row(payload).await.unwrap().unwrap(); + self + .row_by_row_id + .insert(row_detail.row.id.to_string(), row_detail.into()); + self.row_details = self.get_rows().await; + }, + PreFillRowCellTestScript::InsertFilter { filter } => self + .editor + .modify_view_filters( + &self.view_id, + InsertFilterPB { + parent_filter_id: None, + data: filter, + } + .try_into() + .unwrap(), + ) + .await + .unwrap(), + PreFillRowCellTestScript::AssertRowCount(expected_row_count) => { + let rows = self.editor.get_rows(&self.view_id).await.unwrap(); + assert_eq!(expected_row_count, rows.len()); + }, + PreFillRowCellTestScript::AssertCellExistence { + field_id, + row_index, + exists, + } => { + let rows = self.editor.get_rows(&self.view_id).await.unwrap(); + let row_detail = rows.get(row_index).unwrap(); + + let cell = row_detail.row.cells.get(&field_id).cloned(); + + assert_eq!(exists, cell.is_some()); + }, + PreFillRowCellTestScript::AssertCellContent { + field_id, + row_index, + from_field_type, + expected_content, + } => { + let field = self.editor.get_field(&field_id).unwrap(); + let field_type = FieldType::from(field.field_type); + + let rows = self.editor.get_rows(&self.view_id).await.unwrap(); + let row_detail = rows.get(row_index).unwrap(); + + let cell = row_detail + .row + .cells + .get(&field_id) + .cloned() + .unwrap_or_default(); + let content = stringify_cell_data(&cell, &from_field_type, &field_type, &field); + assert_eq!(content, expected_content); + }, + PreFillRowCellTestScript::AssertSelectOptionCellStrict { + field_id, + row_index, + expected_content, + } => { + let rows = self.editor.get_rows(&self.view_id).await.unwrap(); + let row_detail = rows.get(row_index).unwrap(); + + let cell = row_detail + .row + .cells + .get(&field_id) + .cloned() + .unwrap_or_default(); + + let content = SelectOptionIds::from(&cell).join(SELECTION_IDS_SEPARATOR); + + assert_eq!(content, expected_content); + }, + PreFillRowCellTestScript::Wait { milliseconds } => { + tokio::time::sleep(Duration::from_millis(milliseconds)).await; + }, + } + } +} + +impl Deref for DatabasePreFillRowCellTest { + type Target = DatabaseEditorTest; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for DatabasePreFillRowCellTest { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs index 8101e85748cfc..34ef732f39df8 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs @@ -27,14 +27,14 @@ async fn export_csv_test() { let test = DatabaseEditorTest::new_grid().await; let database = test.editor.clone(); let s = database.export_csv(CSVFormat::Original).await.unwrap(); - let expected = r#"Name,Price,Time,Status,Platform,is urgent,link,TODO,Last Modified,Created At -A,$1,2022/03/14,,"Google,Facebook",Yes,AppFlowy website - https://www.appflowy.io,First thing,, -,$2,2022/03/14,,"Google,Twitter",Yes,,"Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed",, -C,$3,2022/03/14,Completed,"Facebook,Google,Twitter",No,,,, -DA,$14,2022/11/17,Completed,,No,,Task 1,, -AE,,2022/11/13,Planned,"Facebook,Twitter",No,,,, -AE,$5,2022/12/25,Planned,Facebook,Yes,,"Sprint,Sprint some more,Rest",, -CB,,,,,,,,, + let expected = r#"Name,Price,Time,Status,Platform,is urgent,link,TODO,Last Modified,Created At,Related +A,$1,2022/03/14,,"Google,Facebook",Yes,AppFlowy website - https://www.appflowy.io,First thing,,, +,$2,2022/03/14,,"Google,Twitter",Yes,,"Have breakfast,Have lunch,Take a nap,Have dinner,Shower and head to bed",,, +C,$3,2022/03/14,Completed,"Facebook,Google,Twitter",No,,,,, +DA,$14,2022/11/17,Completed,,No,,Task 1,,, +AE,,2022/11/13,Planned,"Facebook,Twitter",No,,,,, +AE,$5,2022/12/25,Planned,Facebook,Yes,,"Sprint,Sprint some more,Rest",,, +CB,,,,,,,,,, "#; println!("{}", s); assert_eq!(s, expected); @@ -99,6 +99,7 @@ async fn export_and_then_import_meta_csv_test() { FieldType::Checklist => {}, FieldType::LastEditedTime => {}, FieldType::CreatedTime => {}, + FieldType::Relation => {}, } } else { panic!( @@ -180,6 +181,7 @@ async fn history_database_import_test() { FieldType::Checklist => {}, FieldType::LastEditedTime => {}, FieldType::CreatedTime => {}, + FieldType::Relation => {}, } } else { panic!( diff --git a/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs b/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs index 90c0c5f17bc84..cfa9859075cec 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs @@ -8,7 +8,7 @@ use futures::stream::StreamExt; use tokio::sync::broadcast::Receiver; use flowy_database2::entities::{ - DeleteSortPayloadPB, FieldType, ReorderSortPayloadPB, UpdateSortPayloadPB, + CreateRowPayloadPB, DeleteSortPayloadPB, FieldType, ReorderSortPayloadPB, UpdateSortPayloadPB, }; use flowy_database2::services::cell::stringify_cell_data; use flowy_database2::services::database_view::DatabaseViewChanged; @@ -155,15 +155,10 @@ impl DatabaseSortTest { ); self .editor - .create_row( - &self.view_id, - None, - collab_database::rows::CreateRowParams { - id: collab_database::database::gen_row_id(), - timestamp: collab_database::database::timestamp(), - ..Default::default() - }, - ) + .create_row(CreateRowPayloadPB { + view_id: self.view_id.clone(), + ..Default::default() + }) .await .unwrap(); }, @@ -217,7 +212,7 @@ async fn assert_sort_changed( old_row_orders.insert(changed.new_index, old); assert_eq!(old_row_orders, new_row_orders); }, - DatabaseViewChanged::InsertSortedRowNotification(_changed) => {}, + DatabaseViewChanged::InsertRowNotification(_changed) => {}, _ => {}, } }) diff --git a/frontend/rust-lib/flowy-document-pub/src/cloud.rs b/frontend/rust-lib/flowy-document-pub/src/cloud.rs index 7ff9cd6a36ffe..2f4da1bd37960 100644 --- a/frontend/rust-lib/flowy-document-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-document-pub/src/cloud.rs @@ -1,5 +1,4 @@ use anyhow::Error; -use collab::core::collab::CollabDocState; pub use collab_document::blocks::DocumentData; use flowy_error::FlowyError; @@ -13,7 +12,7 @@ pub trait DocumentCloudService: Send + Sync + 'static { &self, document_id: &str, workspace_id: &str, - ) -> FutureResult; + ) -> FutureResult, FlowyError>; fn get_document_snapshots( &self, diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index 8fc07c1597ea6..9c6b318706b4f 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -445,14 +445,27 @@ pub struct DocumentSnapshotStatePB { #[derive(Debug, Default, ProtoBuf)] pub struct DocumentSyncStatePB { #[pb(index = 1)] - pub is_syncing: bool, + pub value: DocumentSyncState, +} + +#[derive(Debug, Default, ProtoBuf_Enum, PartialEq, Eq, Clone, Copy)] +pub enum DocumentSyncState { + #[default] + InitSyncBegin = 0, + InitSyncEnd = 1, + Syncing = 2, + SyncFinished = 3, } impl From for DocumentSyncStatePB { fn from(value: SyncState) -> Self { - Self { - is_syncing: value.is_syncing(), - } + let value = match value { + SyncState::InitSyncBegin => DocumentSyncState::InitSyncBegin, + SyncState::InitSyncEnd => DocumentSyncState::InitSyncEnd, + SyncState::Syncing => DocumentSyncState::Syncing, + SyncState::SyncFinished => DocumentSyncState::SyncFinished, + }; + Self { value } } } diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 78cc0c493a432..309d65e03e553 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -2,7 +2,7 @@ use std::num::NonZeroUsize; use std::sync::Arc; use std::sync::Weak; -use collab::core::collab::{CollabDocState, MutexCollab}; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab::core::collab_plugin::EncodedCollab; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; @@ -16,8 +16,6 @@ use lru::LruCache; use parking_lot::Mutex; use tokio::io::AsyncWriteExt; use tracing::error; -use tracing::info; -use tracing::warn; use tracing::{event, instrument}; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; @@ -122,33 +120,40 @@ impl DocumentManager { .doc_state .to_vec(); let collab = self - .collab_for_document(uid, doc_id, doc_state, false) + .collab_for_document(uid, doc_id, DocStateSource::FromDocState(doc_state), false) .await?; collab.lock().flush(); Ok(()) } } - /// Return the document - #[tracing::instrument(level = "debug", skip(self), err)] + /// Returns Document for given object id + /// If the document does not exist in local disk, try get the doc state from the cloud. + /// If the document exists, open the document and cache it + #[tracing::instrument(level = "info", skip(self), err)] pub async fn get_document(&self, doc_id: &str) -> FlowyResult> { if let Some(doc) = self.documents.lock().get(doc_id).cloned() { return Ok(doc); } - let mut doc_state = vec![]; + let mut doc_state = DocStateSource::FromDisk; + // If the document does not exist in local disk, try get the doc state from the cloud. This happens + // When user_device_a create a document and user_device_b open the document. if !self.is_doc_exist(doc_id).await? { - // Try to get the document from the cloud service - doc_state = self - .cloud_service - .get_document_doc_state(doc_id, &self.user_service.workspace_id()?) - .await?; - event!( - tracing::Level::DEBUG, - "get document from cloud service: {}, size:{}", - doc_id, - doc_state.len() + doc_state = DocStateSource::FromDocState( + self + .cloud_service + .get_document_doc_state(doc_id, &self.user_service.workspace_id()?) + .await?, ); + + // the doc_state should not be empty if remote return the doc state without error. + if doc_state.is_empty() { + return Err(FlowyError::new( + ErrorCode::RecordNotFound, + format!("document {} not found", doc_id), + )); + } } let uid = self.user_service.user_id()?; @@ -156,28 +161,38 @@ impl DocumentManager { let collab = self .collab_for_document(uid, doc_id, doc_state, true) .await?; - let document = Arc::new(MutexDocument::open(doc_id, collab)?); - - // save the document to the memory and read it from the memory if we open the same document again. - // and we don't want to subscribe to the document changes if we open the same document again. - self - .documents - .lock() - .put(doc_id.to_string(), document.clone()); - Ok(document) + + match MutexDocument::open(doc_id, collab) { + Ok(document) => { + let document = Arc::new(document); + self + .documents + .lock() + .put(doc_id.to_string(), document.clone()); + Ok(document) + }, + Err(err) => { + if err.is_invalid_data() { + if let Some(db) = self.user_service.collab_db(uid)?.upgrade() { + db.delete_doc(uid, doc_id).await?; + } + } + return Err(err); + }, + } } pub async fn get_document_data(&self, doc_id: &str) -> FlowyResult { - let mut updates = vec![]; + let mut doc_state = vec![]; if !self.is_doc_exist(doc_id).await? { - updates = self + doc_state = self .cloud_service .get_document_doc_state(doc_id, &self.user_service.workspace_id()?) .await?; } let uid = self.user_service.user_id()?; let collab = self - .collab_for_document(uid, doc_id, updates, false) + .collab_for_document(uid, doc_id, DocStateSource::FromDocState(doc_state), false) .await?; Document::open(collab)? .get_document_data() @@ -224,18 +239,6 @@ impl DocumentManager { }) .collect::>(); - // let snapshots = self - // .cloud_service - // .get_document_snapshots(document_id, limit, &workspace_id) - // .await? - // .into_iter() - // .map(|snapshot| DocumentSnapshotPB { - // snapshot_id: snapshot.snapshot_id, - // snapshot_desc: "".to_string(), - // created_at: snapshot.created_at, - // }) - // .collect::>(); - Ok(metas) } @@ -281,7 +284,7 @@ impl DocumentManager { #[cfg(not(target_arch = "wasm32"))] { if tokio::fs::metadata(&local_file_path).await.is_ok() { - warn!("file already exist in user local disk: {}", local_file_path); + tracing::warn!("file already exist in user local disk: {}", local_file_path); return Ok(()); } @@ -295,7 +298,7 @@ impl DocumentManager { .await?; let n = file.write(&object_value.raw).await?; - info!("downloaded {} bytes to file: {}", n, local_file_path); + tracing::info!("downloaded {} bytes to file: {}", n, local_file_path); } Ok(()) } @@ -323,22 +326,19 @@ impl DocumentManager { &self, uid: i64, doc_id: &str, - doc_state: CollabDocState, + doc_state: DocStateSource, sync_enable: bool, ) -> FlowyResult> { let db = self.user_service.collab_db(uid)?; - let collab = self - .collab_builder - .build_with_config( - uid, - doc_id, - CollabType::Document, - db, - doc_state, - CollabPersistenceConfig::default().snapshot_per_update(1000), - CollabBuilderConfig::default().sync_enable(sync_enable), - ) - .await?; + let collab = self.collab_builder.build_with_config( + uid, + doc_id, + CollabType::Document, + db, + doc_state, + CollabPersistenceConfig::default().snapshot_per_update(1000), + CollabBuilderConfig::default().sync_enable(sync_enable), + )?; Ok(collab) } @@ -382,6 +382,7 @@ async fn doc_state_from_document_data( CollabOrigin::Empty, doc_id, vec![], + false, ))); let _ = Document::create_with_data(collab.clone(), data).map_err(internal_error)?; Ok::<_, FlowyError>(collab.encode_collab_v1()) diff --git a/frontend/rust-lib/flowy-document/src/parser/constant.rs b/frontend/rust-lib/flowy-document/src/parser/constant.rs index 27e817114d377..20edb9387144e 100644 --- a/frontend/rust-lib/flowy-document/src/parser/constant.rs +++ b/frontend/rust-lib/flowy-document/src/parser/constant.rs @@ -107,7 +107,6 @@ pub const TEXT_DECORATION: &str = "text-decoration"; pub const BACKGROUND_COLOR: &str = "background-color"; pub const TRANSPARENT: &str = "transparent"; -pub const DEFAULT_FONT_COLOR: &str = "rgb(0, 0, 0)"; pub const COLOR: &str = "color"; pub const LINE_THROUGH: &str = "line-through"; diff --git a/frontend/rust-lib/flowy-document/src/parser/external/utils.rs b/frontend/rust-lib/flowy-document/src/parser/external/utils.rs index 257f81e772e02..1e31792f2f38f 100644 --- a/frontend/rust-lib/flowy-document/src/parser/external/utils.rs +++ b/frontend/rust-lib/flowy-document/src/parser/external/utils.rs @@ -428,10 +428,6 @@ fn get_attributes_with_style(style: &str) -> HashMap { attributes.insert(BG_COLOR.to_string(), Value::String(value.to_string())); }, COLOR => { - if value.eq(DEFAULT_FONT_COLOR) { - continue; - } - attributes.insert(FONT_COLOR.to_string(), Value::String(value.to_string())); }, _ => {}, diff --git a/frontend/rust-lib/flowy-document/tests/assets/json/simple.json b/frontend/rust-lib/flowy-document/tests/assets/json/simple.json index 9a27d97913f78..2ab6a3275ed05 100644 --- a/frontend/rust-lib/flowy-document/tests/assets/json/simple.json +++ b/frontend/rust-lib/flowy-document/tests/assets/json/simple.json @@ -2,7 +2,10 @@ "type": "page", "data": { "delta": [{ - "insert": "This is a paragraph" + "insert": "This is a paragraph", + "attributes": { + "font_color": "rgb(0, 0, 0)" + } }] }, "children": [] diff --git a/frontend/rust-lib/flowy-document/tests/document/util.rs b/frontend/rust-lib/flowy-document/tests/document/util.rs index 860d3b7a40b65..795841877278c 100644 --- a/frontend/rust-lib/flowy-document/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document/tests/document/util.rs @@ -2,7 +2,6 @@ use std::ops::Deref; use std::sync::Arc; use anyhow::Error; -use collab::core::collab::CollabDocState; use collab::preclude::CollabPlugin; use collab_document::blocks::DocumentData; use collab_document::document_data::default_document_data; @@ -24,7 +23,7 @@ use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_storage::ObjectStorageService; use lib_infra::async_trait::async_trait; -use lib_infra::future::{to_fut, Fut, FutureResult}; +use lib_infra::future::FutureResult; pub struct DocumentTest { inner: DocumentManager, @@ -135,7 +134,7 @@ impl DocumentCloudService for LocalTestDocumentCloudServiceImpl { &self, document_id: &str, _workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, FlowyError> { let document_id = document_id.to_string(); FutureResult::new(async move { Err(FlowyError::new( @@ -197,8 +196,8 @@ impl CollabCloudPluginProvider for DefaultCollabStorageProvider { CollabPluginProviderType::Local } - fn get_plugins(&self, _context: CollabPluginProviderContext) -> Fut>> { - to_fut(async move { vec![] }) + fn get_plugins(&self, _context: CollabPluginProviderContext) -> Vec> { + vec![] } fn is_sync_enabled(&self) -> bool { diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index 659e35442766c..adb03672a0bf7 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -30,6 +30,7 @@ url = { version = "2.2", optional = true } collab-database = { version = "0.1.0", optional = true } collab-document = { version = "0.1.0", optional = true } collab-plugins = { version = "0.1.0", optional = true } +collab-folder = { version = "0.1.0", optional = true } client-api = { version = "0.1.0", optional = true } [features] @@ -38,6 +39,7 @@ impl_from_serde = [] impl_from_reqwest = ["reqwest"] impl_from_collab_persistence = ["collab-plugins"] impl_from_collab_document = ["collab-document", "impl_from_reqwest", "collab-plugins"] +impl_from_collab_folder = ["collab-folder"] impl_from_collab_database= ["collab-database"] impl_from_url = ["url"] diff --git a/frontend/rust-lib/flowy-error/src/code.rs b/frontend/rust-lib/flowy-error/src/code.rs index 8cc78a4ca0634..4ddd316a8e20d 100644 --- a/frontend/rust-lib/flowy-error/src/code.rs +++ b/frontend/rust-lib/flowy-error/src/code.rs @@ -259,6 +259,12 @@ pub enum ErrorCode { #[error("Cloud request payload too large")] CloudRequestPayloadTooLarge = 90, + + #[error("Workspace limit exceeded")] + WorkspaceLimitExceeded = 91, + + #[error("Workspace member limit exceeded")] + WorkspaceMemberLimitExceeded = 92, } impl ErrorCode { diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index 96a735c2e4689..47151ed8ec416 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -64,6 +64,10 @@ impl FlowyError { self.code == ErrorCode::UserUnauthorized || self.code == ErrorCode::RecordNotFound } + pub fn is_invalid_data(&self) -> bool { + self.code == ErrorCode::InvalidParams + } + pub fn is_local_version_not_support(&self) -> bool { self.code == ErrorCode::LocalVersionNotSupport } 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 c45bfb16c1dc3..3c38bc4005261 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/cloud.rs @@ -21,6 +21,11 @@ impl From for FlowyError { AppErrorCode::NotEnoughPermissions => ErrorCode::NotEnoughPermissions, AppErrorCode::NetworkError => ErrorCode::HttpError, AppErrorCode::PayloadTooLarge => ErrorCode::CloudRequestPayloadTooLarge, + AppErrorCode::UserUnAuthorized => match &*error.message { + "Workspace Limit Exceeded" => ErrorCode::WorkspaceLimitExceeded, + "Workspace Member Limit Exceeded" => ErrorCode::WorkspaceMemberLimitExceeded, + _ => ErrorCode::UserUnauthorized, + }, _ => ErrorCode::Internal, }; diff --git a/frontend/rust-lib/flowy-error/src/impl_from/collab.rs b/frontend/rust-lib/flowy-error/src/impl_from/collab.rs index 3af53b1ffe1eb..400be07661331 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/collab.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/collab.rs @@ -15,6 +15,22 @@ impl From for FlowyError { #[cfg(feature = "impl_from_collab_document")] impl From for FlowyError { fn from(error: DocumentError) -> Self { - FlowyError::internal().with_context(error) + match error { + DocumentError::NoRequiredData => FlowyError::invalid_data().with_context(error), + _ => FlowyError::internal().with_context(error), + } + } +} + +#[cfg(feature = "impl_from_collab_folder")] +use collab_folder::error::FolderError; + +#[cfg(feature = "impl_from_collab_folder")] +impl From for FlowyError { + fn from(error: FolderError) -> Self { + match error { + FolderError::NoRequiredData(_) => FlowyError::invalid_data().with_context(error), + _ => FlowyError::internal().with_context(error), + } } } diff --git a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs index 316795ca590c3..cee216a217845 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/cloud.rs @@ -1,5 +1,4 @@ pub use anyhow::Error; -use collab::core::collab::CollabDocState; use collab_entity::CollabType; pub use collab_folder::{Folder, FolderData, Workspace}; use uuid::Uuid; @@ -36,7 +35,7 @@ pub trait FolderCloudService: Send + Sync + 'static { uid: i64, collab_type: CollabType, object_id: &str, - ) -> FutureResult; + ) -> FutureResult, Error>; fn batch_create_folder_collab_objects( &self, diff --git a/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs b/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs index 7a7c7ca0300bf..26c536839803f 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/folder_builder.rs @@ -59,6 +59,7 @@ impl ViewBuilder { layout: ViewLayout::Document, child_views: vec![], is_favorite: false, + icon: None, } } diff --git a/frontend/rust-lib/flowy-folder/Cargo.toml b/frontend/rust-lib/flowy-folder/Cargo.toml index 757e9bfff7221..5a4ee05ec3a9a 100644 --- a/frontend/rust-lib/flowy-folder/Cargo.toml +++ b/frontend/rust-lib/flowy-folder/Cargo.toml @@ -18,7 +18,7 @@ flowy-notification = { workspace = true } parking_lot.workspace = true unicode-segmentation = "1.10" tracing.workspace = true -flowy-error = { path = "../flowy-error", features = ["impl_from_dispatch_error"]} +flowy-error = { path = "../flowy-error", features = ["impl_from_dispatch_error", "impl_from_collab_folder"]} lib-dispatch = { workspace = true } bytes.workspace = true lib-infra = { workspace = true } diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 7b756163a655f..65f785d8ff875 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -59,14 +59,14 @@ pub struct ViewPB { pub is_favorite: bool, } -pub fn view_pb_without_child_views(view: Arc) -> ViewPB { +pub fn view_pb_without_child_views(view: View) -> ViewPB { ViewPB { - id: view.id.clone(), - parent_view_id: view.parent_view_id.clone(), - name: view.name.clone(), + id: view.id, + parent_view_id: view.parent_view_id, + name: view.name, create_time: view.created_at, child_views: Default::default(), - layout: view.layout.clone().into(), + layout: view.layout.into(), icon: view.icon.clone().map(|icon| icon.into()), is_favorite: view.is_favorite, } @@ -81,7 +81,7 @@ pub fn view_pb_with_child_views(view: Arc, child_views: Vec>) -> create_time: view.created_at, child_views: child_views .into_iter() - .map(view_pb_without_child_views) + .map(|view| view_pb_without_child_views(view.as_ref().clone())) .collect(), layout: view.layout.clone().into(), icon: view.icon.clone().map(|icon| icon.into()), @@ -118,6 +118,15 @@ impl std::convert::From for ViewLayoutPB { } } +#[derive(Eq, PartialEq, Debug, Default, ProtoBuf, Clone)] +pub struct SectionViewsPB { + #[pb(index = 1)] + pub section: ViewSectionPB, + + #[pb(index = 2)] + pub views: Vec, +} + #[derive(Eq, PartialEq, Debug, Default, ProtoBuf, Clone)] pub struct RepeatedViewPB { #[pb(index = 1)] @@ -181,6 +190,20 @@ pub struct CreateViewPayloadPB { // If the index is None or the index is out of range, the view will be appended to the end of the parent view. #[pb(index = 9, one_of)] pub index: Option, + + // The section of the view. + // Only the view in public section will be shown in the shared workspace view list. + // The view in private section will only be shown in the user's private view list. + #[pb(index = 10, one_of)] + pub section: Option, +} + +#[derive(Eq, PartialEq, Hash, Debug, ProtoBuf_Enum, Clone, Default)] +pub enum ViewSectionPB { + #[default] + // only support public and private section now. + Private = 0, + Public = 1, } /// The orphan view is meant to be a view that is not attached to any parent view. By default, this @@ -218,6 +241,8 @@ pub struct CreateViewParams { // The index of the view in the parent view. // If the index is None or the index is out of range, the view will be appended to the end of the parent view. pub index: Option, + // The section of the view. + pub section: Option, } impl TryInto for CreateViewPayloadPB { @@ -238,6 +263,7 @@ impl TryInto for CreateViewPayloadPB { meta: self.meta, set_as_current: self.set_as_current, index: self.index, + section: self.section, }) } } @@ -259,6 +285,7 @@ impl TryInto for CreateOrphanViewPayloadPB { meta: Default::default(), set_as_current: false, index: None, + section: None, }) } } @@ -384,6 +411,12 @@ pub struct MoveNestedViewPayloadPB { #[pb(index = 3, one_of)] pub prev_view_id: Option, + + #[pb(index = 4, one_of)] + pub from_section: Option, + + #[pb(index = 5, one_of)] + pub to_section: Option, } pub struct MoveViewParams { @@ -405,10 +438,13 @@ impl TryInto for MoveViewPayloadPB { } } +#[derive(Debug)] pub struct MoveNestedViewParams { pub view_id: String, pub new_parent_id: String, pub prev_view_id: Option, + pub from_section: Option, + pub to_section: Option, } impl TryInto for MoveNestedViewPayloadPB { @@ -422,6 +458,8 @@ impl TryInto for MoveNestedViewPayloadPB { view_id, new_parent_id, prev_view_id, + from_section: self.from_section, + to_section: self.to_section, }) } } diff --git a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs index 6ce3328da6954..21ff046226888 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/workspace.rs @@ -97,6 +97,42 @@ pub struct WorkspaceIdPB { pub value: String, } +#[derive(Clone, Debug)] +pub struct WorkspaceIdParams { + pub value: String, +} + +impl TryInto for WorkspaceIdPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + Ok(WorkspaceIdParams { + value: WorkspaceIdentify::parse(self.value)?.0, + }) + } +} + +#[derive(Clone, ProtoBuf, Default, Debug)] +pub struct GetWorkspaceViewPB { + #[pb(index = 1)] + pub value: String, +} + +#[derive(Clone, Debug)] +pub struct GetWorkspaceViewParams { + pub value: String, +} + +impl TryInto for GetWorkspaceViewPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + Ok(GetWorkspaceViewParams { + value: WorkspaceIdentify::parse(self.value)?.0, + }) + } +} + #[derive(Default, ProtoBuf, Debug, Clone)] pub struct WorkspaceSettingPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-folder/src/event_handler.rs b/frontend/rust-lib/flowy-folder/src/event_handler.rs index a5412decc6abd..6e307ac0fa74b 100644 --- a/frontend/rust-lib/flowy-folder/src/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/event_handler.rs @@ -28,7 +28,7 @@ pub(crate) async fn create_workspace_handler( .get_views_belong_to(&workspace.id) .await? .into_iter() - .map(view_pb_without_child_views) + .map(|view| view_pb_without_child_views(view.as_ref().clone())) .collect::>(); data_result_ok(WorkspacePB { id: workspace.id, @@ -48,10 +48,34 @@ pub(crate) async fn get_all_workspace_handler( #[tracing::instrument(level = "debug", skip(folder), err)] pub(crate) async fn get_workspace_views_handler( + data: AFPluginData, folder: AFPluginState>, ) -> DataResult { let folder = upgrade_folder(folder)?; - let child_views = folder.get_current_workspace_views().await?; + let params: GetWorkspaceViewParams = data.into_inner().try_into()?; + let child_views = folder.get_workspace_public_views(¶ms.value).await?; + let repeated_view: RepeatedViewPB = child_views.into(); + data_result_ok(repeated_view) +} + +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn get_current_workspace_views_handler( + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let child_views = folder.get_current_workspace_public_views().await?; + let repeated_view: RepeatedViewPB = child_views.into(); + data_result_ok(repeated_view) +} + +#[tracing::instrument(level = "debug", skip(folder), err)] +pub(crate) async fn read_private_views_handler( + data: AFPluginData, + folder: AFPluginState>, +) -> DataResult { + let folder = upgrade_folder(folder)?; + let params: GetWorkspaceViewParams = data.into_inner().try_into()?; + let child_views = folder.get_workspace_private_views(¶ms.value).await?; let repeated_view: RepeatedViewPB = child_views.into(); data_result_ok(repeated_view) } @@ -85,7 +109,7 @@ pub(crate) async fn create_view_handler( if set_as_current { let _ = folder.set_current_view(&view.id).await; } - data_result_ok(view_pb_without_child_views(Arc::new(view))) + data_result_ok(view_pb_without_child_views(view)) } pub(crate) async fn create_orphan_view_handler( @@ -99,11 +123,11 @@ pub(crate) async fn create_orphan_view_handler( if set_as_current { let _ = folder.set_current_view(&view.id).await; } - data_result_ok(view_pb_without_child_views(Arc::new(view))) + data_result_ok(view_pb_without_child_views(view)) } #[tracing::instrument(level = "debug", skip(data, folder), err)] -pub(crate) async fn read_view_handler( +pub(crate) async fn get_view_handler( data: AFPluginData, folder: AFPluginState>, ) -> DataResult { @@ -212,9 +236,7 @@ pub(crate) async fn move_nested_view_handler( ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; let params: MoveNestedViewParams = data.into_inner().try_into()?; - folder - .move_nested_view(params.view_id, params.new_parent_id, params.prev_view_id) - .await?; + folder.move_nested_view(params).await?; Ok(()) } @@ -264,7 +286,7 @@ pub(crate) async fn read_trash_handler( folder: AFPluginState>, ) -> DataResult { let folder = upgrade_folder(folder)?; - let trash = folder.get_all_trash().await; + let trash = folder.get_my_trash_info().await; data_result_ok(trash.into()) } @@ -301,11 +323,11 @@ pub(crate) async fn restore_all_trash_handler( } #[tracing::instrument(level = "debug", skip(folder), err)] -pub(crate) async fn delete_all_trash_handler( +pub(crate) async fn delete_my_trash_handler( folder: AFPluginState>, ) -> Result<(), FlowyError> { let folder = upgrade_folder(folder)?; - folder.delete_all_trash().await; + folder.delete_my_trash().await; Ok(()) } @@ -313,11 +335,12 @@ pub(crate) async fn delete_all_trash_handler( pub(crate) async fn import_data_handler( data: AFPluginData, folder: AFPluginState>, -) -> Result<(), FlowyError> { +) -> DataResult { let folder = upgrade_folder(folder)?; let params: ImportParams = data.into_inner().try_into()?; - folder.import(params).await?; - Ok(()) + let view = folder.import(params).await?; + let view_pb = view_pb_without_child_views(view); + data_result_ok(view_pb) } #[tracing::instrument(level = "debug", skip(folder), err)] diff --git a/frontend/rust-lib/flowy-folder/src/event_map.rs b/frontend/rust-lib/flowy-folder/src/event_map.rs index 6a4d1faa20758..51005929a488b 100644 --- a/frontend/rust-lib/flowy-folder/src/event_map.rs +++ b/frontend/rust-lib/flowy-folder/src/event_map.rs @@ -11,13 +11,13 @@ use crate::manager::FolderManager; pub fn init(folder: Weak) -> AFPlugin { AFPlugin::new().name("Flowy-Folder").state(folder) // Workspace - .event(FolderEvent::CreateWorkspace, create_workspace_handler) + .event(FolderEvent::CreateFolderWorkspace, create_workspace_handler) .event(FolderEvent::GetCurrentWorkspaceSetting, read_current_workspace_setting_handler) .event(FolderEvent::ReadCurrentWorkspace, read_current_workspace_handler) .event(FolderEvent::ReadWorkspaceViews, get_workspace_views_handler) .event(FolderEvent::CreateView, create_view_handler) .event(FolderEvent::CreateOrphanView, create_orphan_view_handler) - .event(FolderEvent::GetView, read_view_handler) + .event(FolderEvent::GetView, get_view_handler) .event(FolderEvent::UpdateView, update_view_handler) .event(FolderEvent::DeleteView, delete_view_handler) .event(FolderEvent::DuplicateView, duplicate_view_handler) @@ -29,7 +29,7 @@ pub fn init(folder: Weak) -> AFPlugin { .event(FolderEvent::RestoreTrashItem, putback_trash_handler) .event(FolderEvent::PermanentlyDeleteTrashItem, delete_trash_handler) .event(FolderEvent::RecoverAllTrashItems, restore_all_trash_handler) - .event(FolderEvent::PermanentlyDeleteAllTrashItem, delete_all_trash_handler) + .event(FolderEvent::PermanentlyDeleteAllTrashItem, delete_my_trash_handler) .event(FolderEvent::ImportData, import_data_handler) .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler) .event(FolderEvent::UpdateViewIcon, update_view_icon_handler) @@ -38,6 +38,8 @@ pub fn init(folder: Weak) -> AFPlugin { .event(FolderEvent::ToggleFavorite, toggle_favorites_handler) .event(FolderEvent::UpdateRecentViews, update_recent_views_handler) .event(FolderEvent::ReloadWorkspace, reload_workspace_handler) + .event(FolderEvent::ReadPrivateViews, read_private_views_handler) + .event(FolderEvent::ReadCurrentWorkspaceViews, get_current_workspace_views_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -45,7 +47,7 @@ pub fn init(folder: Weak) -> AFPlugin { pub enum FolderEvent { /// Create a new workspace #[event(input = "CreateWorkspacePayloadPB", output = "WorkspacePB")] - CreateWorkspace = 0, + CreateFolderWorkspace = 0, /// Read the current opening workspace. Currently, we only support one workspace #[event(output = "WorkspaceSettingPB")] @@ -59,9 +61,9 @@ pub enum FolderEvent { #[event(input = "WorkspaceIdPB")] DeleteWorkspace = 3, - /// Return a list of views of the current workspace. + /// Return a list of views of the specified workspace. /// Only the first level of child views are included. - #[event(input = "WorkspaceIdPB", output = "RepeatedViewPB")] + #[event(input = "GetWorkspaceViewPB", output = "RepeatedViewPB")] ReadWorkspaceViews = 5, /// Create a new view in the corresponding app @@ -124,7 +126,7 @@ pub enum FolderEvent { #[event()] PermanentlyDeleteAllTrashItem = 27, - #[event(input = "ImportPB")] + #[event(input = "ImportPB", output = "ViewPB")] ImportData = 30, #[event(input = "WorkspaceIdPB", output = "RepeatedFolderSnapshotPB")] @@ -156,4 +158,12 @@ pub enum FolderEvent { #[event()] ReloadWorkspace = 38, + + #[event(input = "GetWorkspaceViewPB", output = "RepeatedViewPB")] + ReadPrivateViews = 39, + + /// Return a list of views of the current workspace. + /// Only the first level of child views are included. + #[event(output = "RepeatedViewPB")] + ReadCurrentWorkspaceViews = 40, } diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index a759217282ff0..310bcf582ec0d 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -1,30 +1,12 @@ -use std::fmt::{Display, Formatter}; -use std::ops::Deref; -use std::sync::{Arc, Weak}; - -use collab::core::collab::{CollabDocState, MutexCollab}; -use collab_entity::CollabType; -use collab_folder::{ - Folder, FolderData, Section, SectionItem, TrashInfo, View, ViewLayout, ViewUpdate, Workspace, -}; -use parking_lot::{Mutex, RwLock}; -use tracing::{error, info, instrument}; - -use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; -use collab_integrate::{CollabKVDB, CollabPersistenceConfig}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; -use flowy_folder_pub::cloud::{gen_view_id, FolderCloudService}; -use flowy_folder_pub::folder_builder::ParentChildViews; -use lib_infra::conditional_send_sync_trait; - use crate::entities::icon::UpdateViewIconParams; use crate::entities::{ view_pb_with_child_views, view_pb_without_child_views, CreateViewParams, CreateWorkspaceParams, - DeletedViewPB, FolderSnapshotPB, RepeatedTrashPB, RepeatedViewIdPB, RepeatedViewPB, - UpdateViewParams, ViewPB, WorkspacePB, WorkspaceSettingPB, + DeletedViewPB, FolderSnapshotPB, MoveNestedViewParams, RepeatedTrashPB, RepeatedViewIdPB, + RepeatedViewPB, UpdateViewParams, ViewPB, ViewSectionPB, WorkspacePB, WorkspaceSettingPB, }; use crate::manager_observer::{ - notify_child_views_changed, notify_parent_view_did_change, ChildViewChangeReason, + notify_child_views_changed, notify_did_update_workspace, notify_parent_view_did_change, + ChildViewChangeReason, }; use crate::notification::{ send_notification, send_workspace_setting_notification, FolderNotification, @@ -34,6 +16,24 @@ use crate::util::{ folder_not_init_error, insert_parent_child_views, workspace_data_not_sync_error, }; use crate::view_operation::{create_view, FolderOperationHandler, FolderOperationHandlers}; +use collab::core::collab::{DocStateSource, MutexCollab}; +use collab_entity::CollabType; +use collab_folder::error::FolderError; +use collab_folder::{ + Folder, FolderData, FolderNotify, Section, SectionItem, TrashInfo, UserId, View, ViewLayout, + ViewUpdate, Workspace, +}; +use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabBuilderConfig}; +use collab_integrate::{CollabKVDB, CollabPersistenceConfig}; +use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_folder_pub::cloud::{gen_view_id, FolderCloudService}; +use flowy_folder_pub::folder_builder::ParentChildViews; +use lib_infra::conditional_send_sync_trait; +use parking_lot::{Mutex, RwLock}; +use std::fmt::{Display, Formatter}; +use std::ops::Deref; +use std::sync::{Arc, Weak}; +use tracing::{error, info, instrument}; conditional_send_sync_trait! { "[crate::manager::FolderUser] represents the user for folder."; @@ -46,7 +46,7 @@ conditional_send_sync_trait! { pub struct FolderManager { pub(crate) workspace_id: RwLock>, pub(crate) mutex_folder: Arc, - collab_builder: Arc, + pub(crate) collab_builder: Arc, pub(crate) user: Arc, pub(crate) operation_handlers: FolderOperationHandlers, pub cloud_service: Arc, @@ -109,7 +109,7 @@ impl FolderManager { }, |folder| { let workspace_pb_from_workspace = |workspace: Workspace, folder: &Folder| { - let views = get_workspace_view_pbs(&workspace.id, folder); + let views = get_workspace_public_view_pbs(&workspace.id, folder); let workspace: WorkspacePB = (workspace, views).into(); Ok::(workspace) }; @@ -124,7 +124,7 @@ impl FolderManager { /// Return a list of views of the current workspace. /// Only the first level of child views are included. - pub async fn get_current_workspace_views(&self) -> FlowyResult> { + pub async fn get_current_workspace_public_views(&self) -> FlowyResult> { let workspace_id = self .mutex_folder .lock() @@ -132,42 +132,85 @@ impl FolderManager { .map(|folder| folder.get_workspace_id()); if let Some(workspace_id) = workspace_id { - self.get_workspace_views(&workspace_id).await + self.get_workspace_public_views(&workspace_id).await } else { tracing::warn!("Can't get current workspace views"); Ok(vec![]) } } - pub async fn get_workspace_views(&self, workspace_id: &str) -> FlowyResult> { + pub async fn get_workspace_public_views(&self, workspace_id: &str) -> FlowyResult> { + let views = self.with_folder(Vec::new, |folder| { + get_workspace_public_view_pbs(workspace_id, folder) + }); + + Ok(views) + } + + pub async fn get_workspace_private_views(&self, workspace_id: &str) -> FlowyResult> { let views = self.with_folder(Vec::new, |folder| { - get_workspace_view_pbs(workspace_id, folder) + get_workspace_private_view_pbs(workspace_id, folder) }); Ok(views) } - pub(crate) async fn collab_for_folder( + pub(crate) async fn make_folder>>( + &self, + uid: i64, + workspace_id: &str, + collab_db: Weak, + doc_state: DocStateSource, + folder_notifier: T, + ) -> Result { + let folder_notifier = folder_notifier.into(); + let collab = self.collab_builder.build_with_config( + uid, + workspace_id, + CollabType::Folder, + collab_db, + doc_state, + CollabPersistenceConfig::new() + .enable_snapshot(true) + .snapshot_per_update(50), + CollabBuilderConfig::default().sync_enable(true), + )?; + let (should_clear, err) = match Folder::open(UserId::from(uid), collab, folder_notifier) { + Ok(folder) => { + return Ok(folder); + }, + Err(err) => (matches!(err, FolderError::NoRequiredData(_)), err), + }; + + // If opening the folder fails due to missing required data (indicated by a `FolderError::NoRequiredData`), + // the function logs an informational message and attempts to clear the folder data by deleting its + // document from the collaborative database. It then returns the encountered error. + if should_clear { + info!("Clear the folder data and try to open the folder again"); + if let Some(db) = self.user.collab_db(uid).ok().and_then(|a| a.upgrade()) { + let _ = db.delete_doc(uid, workspace_id).await; + } + } + Err(err.into()) + } + + pub(crate) async fn create_empty_collab( &self, uid: i64, workspace_id: &str, collab_db: Weak, - collab_doc_state: CollabDocState, ) -> Result, FlowyError> { - let collab = self - .collab_builder - .build_with_config( - uid, - workspace_id, - CollabType::Folder, - collab_db, - collab_doc_state, - CollabPersistenceConfig::new() - .enable_snapshot(true) - .snapshot_per_update(50), - CollabBuilderConfig::default().sync_enable(true), - ) - .await?; + let collab = self.collab_builder.build_with_config( + uid, + workspace_id, + CollabType::Folder, + collab_db, + DocStateSource::FromDocState(vec![]), + CollabPersistenceConfig::new() + .enable_snapshot(true) + .snapshot_per_update(50), + CollabBuilderConfig::default().sync_enable(true), + )?; Ok(collab) } @@ -329,7 +372,7 @@ impl FolderManager { .views .get_views_belong_to(&workspace.id) .into_iter() - .map(view_pb_without_child_views) + .map(|view| view_pb_without_child_views(view.as_ref().clone())) .collect::>(); WorkspacePB { @@ -407,11 +450,16 @@ impl FolderManager { } let index = params.index; + let section = params.section.clone().unwrap_or(ViewSectionPB::Public); + let is_private = section == ViewSectionPB::Private; let view = create_view(self.user.user_id()?, params, view_layout); self.with_folder( || (), |folder| { folder.insert_view(view.clone(), index); + if is_private { + folder.add_private_view_ids(vec![view.id.clone()]); + } }, ); @@ -461,7 +509,7 @@ impl FolderManager { let folder = self.mutex_folder.lock(); let folder = folder.as_ref().ok_or_else(folder_not_init_error)?; let trash_ids = folder - .get_all_trash() + .get_all_trash_sections() .into_iter() .map(|trash| trash.id) .collect::>(); @@ -501,7 +549,7 @@ impl FolderManager { |folder| { if let Some(view) = folder.views.get_view(view_id) { self.unfavorite_view_and_decendants(view.clone(), folder); - folder.add_trash(vec![view_id.to_string()]); + folder.add_trash_view_ids(vec![view_id.to_string()]); // notify the parent view that the view is moved to trash send_notification(view_id, FolderNotification::DidMoveViewToTrash) .payload(DeletedViewPB { @@ -511,7 +559,7 @@ impl FolderManager { .send(); notify_child_views_changed( - view_pb_without_child_views(view), + view_pb_without_child_views(view.as_ref().clone()), ChildViewChangeReason::Delete, ); } @@ -528,11 +576,11 @@ impl FolderManager { let favorite_descendant_views: Vec = all_descendant_views .iter() .filter(|view| view.is_favorite) - .map(|view| view_pb_without_child_views(view.clone())) + .map(|view| view_pb_without_child_views(view.as_ref().clone())) .collect(); if !favorite_descendant_views.is_empty() { - folder.delete_favorites( + folder.delete_favorite_view_ids( favorite_descendant_views .iter() .map(|v| v.id.clone()) @@ -564,18 +612,26 @@ impl FolderManager { /// * `prev_view_id` - An `Option` that holds the id of the view after which the `view_id` should be positioned. /// #[tracing::instrument(level = "trace", skip(self), err)] - pub async fn move_nested_view( - &self, - view_id: String, - new_parent_id: String, - prev_view_id: Option, - ) -> FlowyResult<()> { + pub async fn move_nested_view(&self, params: MoveNestedViewParams) -> FlowyResult<()> { + let view_id = params.view_id; + let new_parent_id = params.new_parent_id; + let prev_view_id = params.prev_view_id; + let from_section = params.from_section; + let to_section = params.to_section; let view = self.get_view_pb(&view_id).await?; let old_parent_id = view.parent_view_id; self.with_folder( || (), |folder| { folder.move_nested_view(&view_id, &new_parent_id, prev_view_id); + + if from_section != to_section { + if to_section == Some(ViewSectionPB::Private) { + folder.add_private_view_ids(vec![view_id.clone()]); + } else { + folder.delete_private_view_ids(vec![view_id.clone()]); + } + } }, ); notify_parent_view_did_change( @@ -688,6 +744,16 @@ impl FolderManager { None }; + let is_private = self.with_folder( + || false, + |folder| folder.is_view_in_section(Section::Private, &view.id), + ); + let section = if is_private { + ViewSectionPB::Private + } else { + ViewSectionPB::Public + }; + let duplicate_params = CreateViewParams { parent_view_id: view.parent_view_id.clone(), name: format!("{} (copy)", &view.name), @@ -698,6 +764,7 @@ impl FolderManager { meta: Default::default(), set_as_current: true, index, + section: Some(section), }; self.create_view_with_params(duplicate_params).await?; @@ -733,9 +800,9 @@ impl FolderManager { |folder| { if let Some(old_view) = folder.views.get_view(view_id) { if old_view.is_favorite { - folder.delete_favorites(vec![view_id.to_string()]); + folder.delete_favorite_view_ids(vec![view_id.to_string()]); } else { - folder.add_favorites(vec![view_id.to_string()]); + folder.add_favorite_view_ids(vec![view_id.to_string()]); } } }, @@ -810,8 +877,8 @@ impl FolderManager { } #[tracing::instrument(level = "trace", skip(self))] - pub(crate) async fn get_all_trash(&self) -> Vec { - self.with_folder(Vec::new, |folder| folder.get_all_trash()) + pub(crate) async fn get_my_trash_info(&self) -> Vec { + self.with_folder(Vec::new, |folder| folder.get_my_trash_info()) } #[tracing::instrument(level = "trace", skip(self))] @@ -819,7 +886,7 @@ impl FolderManager { self.with_folder( || (), |folder| { - folder.remote_all_trash(); + folder.remove_all_my_trash_sections(); }, ); send_notification("trash", FolderNotification::DidUpdateTrash) @@ -832,15 +899,15 @@ impl FolderManager { self.with_folder( || (), |folder| { - folder.delete_trash(vec![trash_id.to_string()]); + folder.delete_trash_view_ids(vec![trash_id.to_string()]); }, ); } /// 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(Vec::new, |folder| folder.get_all_trash()); + pub(crate) async fn delete_my_trash(&self) { + let deleted_trash = self.with_folder(Vec::new, |folder| folder.get_my_trash_info()); for trash in deleted_trash { let _ = self.delete_trash(&trash.id).await; } @@ -858,7 +925,7 @@ impl FolderManager { self.with_folder( || (), |folder| { - folder.delete_trash(vec![view_id.to_string()]); + folder.delete_trash_view_ids(vec![view_id.to_string()]); folder.views.delete_views(vec![view_id]); }, ); @@ -909,6 +976,7 @@ impl FolderManager { meta: Default::default(), set_as_current: false, index: None, + section: None, }; let view = create_view(self.user.user_id()?, params, import_data.view_layout); @@ -947,7 +1015,15 @@ impl FolderManager { send_notification(&view_pb.id, FolderNotification::DidUpdateView) .payload(view_pb) .send(); + + if let Ok(workspace_id) = self.get_current_workspace_id().await { + let folder = &self.mutex_folder.lock(); + if let Some(folder) = folder.as_ref() { + notify_did_update_workspace(&workspace_id, folder); + } + } } + Ok(()) } @@ -1039,14 +1115,14 @@ impl FolderManager { fn get_sections(&self, section_type: Section) -> Vec { self.with_folder(Vec::new, |folder| { let trash_ids = folder - .get_all_trash() + .get_all_trash_sections() .into_iter() .map(|trash| trash.id) .collect::>(); let mut views = match section_type { - Section::Favorite => folder.get_all_favorites(), - Section::Recent => folder.get_all_recent_sections(), + Section::Favorite => folder.get_my_favorite_sections(), + Section::Recent => folder.get_my_recent_sections(), _ => vec![], }; @@ -1057,16 +1133,61 @@ impl FolderManager { } } -/// Return the views that belong to the workspace. The views are filtered by the trash. -pub(crate) fn get_workspace_view_pbs(_workspace_id: &str, folder: &Folder) -> Vec { - let items = folder.get_all_trash(); - let trash_ids = items +/// Return the views that belong to the workspace. The views are filtered by the trash and all the private views. +pub(crate) fn get_workspace_public_view_pbs(_workspace_id: &str, folder: &Folder) -> Vec { + // get the trash ids + let trash_ids = folder + .get_all_trash_sections() .into_iter() .map(|trash| trash.id) .collect::>(); + // get the private view ids + let private_view_ids = folder + .get_all_private_sections() + .into_iter() + .map(|view| view.id) + .collect::>(); + let mut views = folder.get_workspace_views(); - views.retain(|view| !trash_ids.contains(&view.id)); + + // filter the views that are in the trash and all the private views + views.retain(|view| !trash_ids.contains(&view.id) && !private_view_ids.contains(&view.id)); + + views + .into_iter() + .map(|view| { + // Get child views + let child_views = folder + .views + .get_views_belong_to(&view.id) + .into_iter() + .collect(); + view_pb_with_child_views(view, child_views) + }) + .collect() +} + +/// Get the current private views of the user. +pub(crate) fn get_workspace_private_view_pbs(_workspace_id: &str, folder: &Folder) -> Vec { + // get the trash ids + let trash_ids = folder + .get_all_trash_sections() + .into_iter() + .map(|trash| trash.id) + .collect::>(); + + // get the private view ids + let private_view_ids = folder + .get_my_private_sections() + .into_iter() + .map(|view| view.id) + .collect::>(); + + let mut views = folder.get_workspace_views(); + + // filter the views that are in the trash and not in the private view ids + views.retain(|view| !trash_ids.contains(&view.id) && private_view_ids.contains(&view.id)); views .into_iter() @@ -1098,7 +1219,7 @@ 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 - Cloud(CollabDocState), + Cloud(Vec), /// If the user is new, we use the [DefaultFolderBuilder] to create the default folder. FolderData(FolderData), } diff --git a/frontend/rust-lib/flowy-folder/src/manager_init.rs b/frontend/rust-lib/flowy-folder/src/manager_init.rs index 5d3475a10d0f9..b3dbf9836408c 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_init.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_init.rs @@ -1,7 +1,12 @@ use collab_entity::CollabType; + use collab_folder::{Folder, FolderNotify, UserId}; + use collab_integrate::CollabKVDB; + use flowy_error::{FlowyError, FlowyResult}; + +use collab::core::collab::DocStateSource; use std::sync::{Arc, Weak}; use tracing::{event, Level}; @@ -48,8 +53,15 @@ impl FolderManager { let is_exist = self.is_workspace_exist_in_local(uid, &workspace_id).await; // 1. if the folder exists, open it from local disk if is_exist { + event!(Level::INFO, "Init folder from local disk"); self - .open_local_folder(uid, &workspace_id, collab_db, folder_notifier) + .make_folder( + uid, + &workspace_id, + collab_db, + DocStateSource::FromDisk, + folder_notifier, + ) .await? } else if create_if_not_exist { // 2. if the folder doesn't exist and create_if_not_exist is true, create a default folder @@ -66,30 +78,46 @@ impl FolderManager { .get_folder_doc_state(&workspace_id, uid, CollabType::Folder, &workspace_id) .await?; - let collab = self - .collab_for_folder(uid, &workspace_id, collab_db.clone(), doc_state) - .await?; - Folder::open(UserId::from(uid), collab, Some(folder_notifier.clone()))? + self + .make_folder( + uid, + &workspace_id, + collab_db.clone(), + DocStateSource::FromDocState(doc_state), + folder_notifier.clone(), + ) + .await? } }, FolderInitDataSource::Cloud(doc_state) => { if doc_state.is_empty() { event!(Level::ERROR, "remote folder data is empty, open from local"); self - .open_local_folder(uid, &workspace_id, collab_db, folder_notifier) + .make_folder( + uid, + &workspace_id, + collab_db, + DocStateSource::FromDisk, + folder_notifier, + ) .await? } else { - event!(Level::INFO, "Restore folder with remote data"); - let collab = self - .collab_for_folder(uid, &workspace_id, collab_db.clone(), doc_state) - .await?; - Folder::open(UserId::from(uid), collab, Some(folder_notifier.clone()))? + event!(Level::INFO, "Restore folder from remote data"); + self + .make_folder( + uid, + &workspace_id, + collab_db.clone(), + DocStateSource::FromDocState(doc_state), + folder_notifier.clone(), + ) + .await? } }, 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![]) + .create_empty_collab(uid, &workspace_id, collab_db) .await?; Folder::create( UserId::from(uid), @@ -135,7 +163,7 @@ impl FolderManager { 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![]) + .create_empty_collab(uid, workspace_id, collab_db) .await?; Ok(Folder::create( UserId::from(uid), @@ -144,19 +172,4 @@ impl FolderManager { folder_data, )) } - - async fn open_local_folder( - &self, - uid: i64, - workspace_id: &str, - collab_db: Weak, - folder_notifier: FolderNotify, - ) -> Result { - event!(Level::INFO, "Init folder from local disk"); - let collab = self - .collab_for_folder(uid, workspace_id, collab_db, vec![]) - .await?; - let folder = Folder::open(UserId::from(uid), collab, Some(folder_notifier))?; - Ok(folder) - } } diff --git a/frontend/rust-lib/flowy-folder/src/manager_observer.rs b/frontend/rust-lib/flowy-folder/src/manager_observer.rs index e37f20f31b42f..964e0efe0aac8 100644 --- a/frontend/rust-lib/flowy-folder/src/manager_observer.rs +++ b/frontend/rust-lib/flowy-folder/src/manager_observer.rs @@ -14,9 +14,9 @@ use lib_dispatch::prelude::af_spawn; use crate::entities::{ view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, FolderSnapshotStatePB, - FolderSyncStatePB, RepeatedTrashPB, RepeatedViewPB, ViewPB, + FolderSyncStatePB, RepeatedTrashPB, RepeatedViewPB, SectionViewsPB, ViewPB, ViewSectionPB, }; -use crate::manager::{get_workspace_view_pbs, MutexFolder}; +use crate::manager::{get_workspace_private_view_pbs, get_workspace_public_view_pbs, MutexFolder}; use crate::notification::{send_notification, FolderNotification}; /// Listen on the [ViewChange] after create/delete/update events happened @@ -32,7 +32,7 @@ pub(crate) fn subscribe_folder_view_changed( match value { ViewChange::DidCreateView { view } => { notify_child_views_changed( - view_pb_without_child_views(Arc::new(view.clone())), + view_pb_without_child_views(view.clone()), ChildViewChangeReason::Create, ); notify_parent_view_did_change(folder.clone(), vec![view.parent_view_id]); @@ -40,7 +40,7 @@ pub(crate) fn subscribe_folder_view_changed( ViewChange::DidDeleteView { views } => { for view in views { notify_child_views_changed( - view_pb_without_child_views(view), + view_pb_without_child_views(view.as_ref().clone()), ChildViewChangeReason::Delete, ); } @@ -48,7 +48,7 @@ pub(crate) fn subscribe_folder_view_changed( ViewChange::DidUpdate { view } => { notify_view_did_change(view.clone()); notify_child_views_changed( - view_pb_without_child_views(Arc::new(view.clone())), + view_pb_without_child_views(view.clone()), ChildViewChangeReason::Update, ); notify_parent_view_did_change(folder.clone(), vec![view.parent_view_id.clone()]); @@ -125,7 +125,7 @@ pub(crate) fn subscribe_folder_trash_changed( unique_ids.insert(view.parent_view_id.clone()); } - let repeated_trash: RepeatedTrashPB = folder.get_all_trash().into(); + let repeated_trash: RepeatedTrashPB = folder.get_my_trash_info().into(); send_notification("trash", FolderNotification::DidUpdateTrash) .payload(repeated_trash) .send(); @@ -150,7 +150,7 @@ pub(crate) fn notify_parent_view_did_change>( let folder = folder.as_ref()?; let workspace_id = folder.get_workspace_id(); let trash_ids = folder - .get_all_trash() + .get_all_trash_sections() .into_iter() .map(|trash| trash.id) .collect::>(); @@ -161,7 +161,8 @@ pub(crate) fn notify_parent_view_did_change>( // if the view's parent id equal to workspace id. Then it will fetch the current // workspace views. Because the the workspace is not a view stored in the views map. if parent_view_id == workspace_id { - notify_did_update_workspace(&workspace_id, folder) + notify_did_update_workspace(&workspace_id, folder); + notify_did_update_section_views(&workspace_id, folder); } else { // Parent view can contain a list of child views. Currently, only get the first level // child views. @@ -181,8 +182,35 @@ pub(crate) fn notify_parent_view_did_change>( None } +pub(crate) fn notify_did_update_section_views(workspace_id: &str, folder: &Folder) { + let public_views = get_workspace_public_view_pbs(workspace_id, folder); + let private_views = get_workspace_private_view_pbs(workspace_id, folder); + tracing::trace!( + "Did update section views: public len = {}, private len = {}", + public_views.len(), + private_views.len() + ); + + // TODO(Lucas.xu) - Only notify the section changed, not the public/private both. + // Notify the public views + send_notification(workspace_id, FolderNotification::DidUpdateSectionViews) + .payload(SectionViewsPB { + section: ViewSectionPB::Public, + views: public_views, + }) + .send(); + + // Notify the private views + send_notification(workspace_id, FolderNotification::DidUpdateSectionViews) + .payload(SectionViewsPB { + section: ViewSectionPB::Private, + views: private_views, + }) + .send(); +} + pub(crate) fn notify_did_update_workspace(workspace_id: &str, folder: &Folder) { - let repeated_view: RepeatedViewPB = get_workspace_view_pbs(workspace_id, folder).into(); + let repeated_view: RepeatedViewPB = get_workspace_public_view_pbs(workspace_id, folder).into(); tracing::trace!("Did update workspace views: {:?}", repeated_view); send_notification(workspace_id, FolderNotification::DidUpdateWorkspaceViews) .payload(repeated_view) @@ -190,8 +218,9 @@ pub(crate) fn notify_did_update_workspace(workspace_id: &str, folder: &Folder) { } fn notify_view_did_change(view: View) -> Option<()> { - let view_pb = view_pb_without_child_views(Arc::new(view.clone())); - send_notification(&view.id, FolderNotification::DidUpdateView) + let view_id = view.id.clone(); + let view_pb = view_pb_without_child_views(view); + send_notification(&view_id, FolderNotification::DidUpdateView) .payload(view_pb) .send(); None diff --git a/frontend/rust-lib/flowy-folder/src/notification.rs b/frontend/rust-lib/flowy-folder/src/notification.rs index df83edf46b945..c57450a5d6d4a 100644 --- a/frontend/rust-lib/flowy-folder/src/notification.rs +++ b/frontend/rust-lib/flowy-folder/src/notification.rs @@ -35,6 +35,9 @@ pub enum FolderNotification { DidUnfavoriteView = 37, DidUpdateRecentViews = 38, + + /// Trigger when the ROOT views (the first level) in section are updated + DidUpdateSectionViews = 39, } impl std::convert::From for i32 { @@ -60,6 +63,8 @@ impl std::convert::From for FolderNotification { 17 => FolderNotification::DidUpdateFolderSyncUpdate, 36 => FolderNotification::DidFavoriteView, 37 => FolderNotification::DidUnfavoriteView, + 38 => FolderNotification::DidUpdateRecentViews, + 39 => FolderNotification::DidUpdateSectionViews, _ => FolderNotification::Unknown, } } diff --git a/frontend/rust-lib/flowy-folder/src/test_helper.rs b/frontend/rust-lib/flowy-folder/src/test_helper.rs index b63448bc94814..50e4b290ff43f 100644 --- a/frontend/rust-lib/flowy-folder/src/test_helper.rs +++ b/frontend/rust-lib/flowy-folder/src/test_helper.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use flowy_folder_pub::cloud::gen_view_id; -use crate::entities::{CreateViewParams, ViewLayoutPB}; +use crate::entities::{CreateViewParams, ViewLayoutPB, ViewSectionPB}; use crate::manager::FolderManager; #[cfg(feature = "test_helper")] @@ -47,6 +47,7 @@ impl FolderManager { meta: ext, set_as_current: true, index: None, + section: Some(ViewSectionPB::Public), }; self.create_view_with_params(params).await.unwrap(); view_id diff --git a/frontend/rust-lib/flowy-folder/src/user_default.rs b/frontend/rust-lib/flowy-folder/src/user_default.rs index be2e4c3cf4eaf..0e2e3f4bc3dce 100644 --- a/frontend/rust-lib/flowy-folder/src/user_default.rs +++ b/frontend/rust-lib/flowy-folder/src/user_default.rs @@ -54,6 +54,7 @@ impl DefaultFolderBuilder { favorites: Default::default(), recent: Default::default(), trash: Default::default(), + private: Default::default(), } } } diff --git a/frontend/rust-lib/flowy-server/Cargo.toml b/frontend/rust-lib/flowy-server/Cargo.toml index 4cfc90ae7515f..1ee765f25fbe2 100644 --- a/frontend/rust-lib/flowy-server/Cargo.toml +++ b/frontend/rust-lib/flowy-server/Cargo.toml @@ -48,6 +48,7 @@ tokio-stream = { workspace = true, features = ["sync"] } client-api = { version = "0.1.0", features = ["collab-sync", "test_util"] } lib-dispatch = { workspace = true } yrs = "0.17.1" +rand = "0.8.5" [dev-dependencies] uuid.workspace = true 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 ad5c7ce5cf01f..c369a260ea856 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,7 +2,7 @@ use anyhow::Error; use client_api::entity::QueryCollabResult::{Failed, Success}; use client_api::entity::{QueryCollab, QueryCollabParams}; use client_api::error::ErrorCode::RecordNotFound; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::core::collab_plugin::EncodedCollab; use collab_entity::CollabType; use tracing::error; @@ -23,7 +23,7 @@ where object_id: &str, collab_type: CollabType, workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { let workspace_id = workspace_id.to_string(); let object_id = object_id.to_string(); let try_get_client = self.0.try_get_client(); @@ -73,7 +73,10 @@ where .flat_map(|(object_id, result)| match result { Success { encode_collab_v1 } => { match EncodedCollab::decode_from_bytes(&encode_collab_v1) { - Ok(encode) => Some((object_id, encode.doc_state.to_vec())), + Ok(encode) => Some(( + object_id, + DocStateSource::FromDocState(encode.doc_state.to_vec()), + )), Err(err) => { error!("Failed to decode collab: {}", err); 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 2712d272d34dd..7c5904ab1d4c1 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 @@ -1,6 +1,6 @@ use anyhow::Error; use client_api::entity::{QueryCollab, QueryCollabParams}; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::core::origin::CollabOrigin; use collab_document::document::Document; use collab_entity::CollabType; @@ -21,7 +21,7 @@ where &self, document_id: &str, workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, FlowyError> { let workspace_id = workspace_id.to_string(); let try_get_client = self.0.try_get_client(); let document_id = document_id.to_string(); @@ -74,8 +74,12 @@ where .map_err(FlowyError::from)? .doc_state .to_vec(); - let document = - Document::from_doc_state(CollabOrigin::Empty, doc_state, &document_id, vec![])?; + let document = Document::from_doc_state( + CollabOrigin::Empty, + DocStateSource::FromDocState(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 dcc1b8aa3a4a8..4706babfb2533 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 @@ -2,7 +2,7 @@ use anyhow::Error; use client_api::entity::{ workspace_dto::CreateWorkspaceParam, CollabParams, QueryCollab, QueryCollabParams, }; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::core::origin::CollabOrigin; use collab_entity::CollabType; use collab_folder::RepeatedViewIdentifier; @@ -96,8 +96,13 @@ where .map_err(FlowyError::from)? .doc_state .to_vec(); - let folder = - Folder::from_collab_doc_state(uid, CollabOrigin::Empty, doc_state, &workspace_id, vec![])?; + let folder = Folder::from_collab_doc_state( + uid, + CollabOrigin::Empty, + DocStateSource::FromDocState(doc_state), + &workspace_id, + vec![], + )?; Ok(folder.get_folder_data()) }) } @@ -116,7 +121,7 @@ where _uid: i64, collab_type: CollabType, object_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { let object_id = object_id.to_string(); let workspace_id = workspace_id.to_string(); let try_get_client = self.0.try_get_client(); 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 73717798f02ba..4e900385cbfad 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 @@ -1,13 +1,12 @@ use std::collections::HashMap; use std::sync::Arc; -use anyhow::{anyhow, Error}; +use anyhow::anyhow; use client_api::entity::workspace_dto::{ - CreateWorkspaceMember, CreateWorkspaceParam, WorkspaceMemberChangeset, + CreateWorkspaceMember, CreateWorkspaceParam, PatchWorkspaceParam, WorkspaceMemberChangeset, }; use client_api::entity::{AFRole, AFWorkspace, AuthProvider, CollabParams, CreateCollabParams}; use client_api::{Client, ClientConfiguration}; -use collab::core::collab::CollabDocState; use collab_entity::CollabObject; use parking_lot::RwLock; @@ -16,6 +15,7 @@ use flowy_user_pub::cloud::{UserCloudService, UserCollabParams, UserUpdate, User use flowy_user_pub::entities::*; use lib_infra::box_any::BoxAny; use lib_infra::future::FutureResult; +use uuid::Uuid; use crate::af_cloud::define::USER_SIGN_IN_URL; use crate::af_cloud::impls::user::dto::{ @@ -176,7 +176,7 @@ where &self, user_email: String, workspace_id: String, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { let try_get_client = self.server.try_get_client(); FutureResult::new(async move { try_get_client? @@ -196,7 +196,7 @@ where &self, user_email: String, workspace_id: String, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { let try_get_client = self.server.try_get_client(); FutureResult::new(async move { try_get_client? @@ -211,7 +211,7 @@ where user_email: String, workspace_id: String, role: Role, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let changeset = WorkspaceMemberChangeset::new(user_email).with_role(to_af_role(role)); @@ -225,7 +225,7 @@ where fn get_workspace_members( &self, workspace_id: String, - ) -> FutureResult, Error> { + ) -> FutureResult, FlowyError> { let try_get_client = self.server.try_get_client(); FutureResult::new(async move { let members = try_get_client? @@ -238,7 +238,7 @@ where }) } - fn get_user_awareness_doc_state(&self, _uid: i64) -> FutureResult { + fn get_user_awareness_doc_state(&self, _uid: i64) -> FutureResult, FlowyError> { FutureResult::new(async { Ok(vec![]) }) } @@ -246,7 +246,7 @@ where self.user_change_recv.write().take() } - fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), Error> { + fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } @@ -276,7 +276,7 @@ where &self, workspace_id: &str, objects: Vec, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { let workspace_id = workspace_id.to_string(); let try_get_client = self.server.try_get_client(); FutureResult::new(async move { @@ -320,6 +320,32 @@ where Ok(()) }) } + + fn patch_workspace( + &self, + workspace_id: &str, + new_workspace_name: Option<&str>, + new_workspace_icon: Option<&str>, + ) -> FutureResult<(), FlowyError> { + let try_get_client = self.server.try_get_client(); + let owned_workspace_id = workspace_id.to_owned(); + let owned_workspace_name = new_workspace_name.map(|s| s.to_owned()); + let owned_workspace_icon = new_workspace_icon.map(|s| s.to_owned()); + FutureResult::new(async move { + let workspace_id: Uuid = owned_workspace_id + .parse() + .map_err(|_| ErrorCode::InvalidParams)?; + let client = try_get_client?; + client + .patch_workspace(PatchWorkspaceParam { + workspace_id, + workspace_name: owned_workspace_name, + workspace_icon: owned_workspace_icon, + }) + .await?; + Ok(()) + }) + } } async fn get_admin_client(client: &Arc) -> FlowyResult { @@ -333,7 +359,7 @@ async fn get_admin_client(client: &Arc) -> FlowyResult { client.gotrue_url(), &client.device_id, ClientConfiguration::default(), - &client.client_id, + &client.client_version.to_string(), ); admin_client .sign_in_password(&admin_email, &admin_password) @@ -382,6 +408,7 @@ fn to_user_workspace(af_workspace: AFWorkspace) -> UserWorkspace { name: af_workspace.workspace_name, created_at: af_workspace.created_at, workspace_database_object_id: af_workspace.database_storage_id.to_string(), + icon: af_workspace.icon, } } @@ -393,7 +420,7 @@ fn to_user_workspaces(workspaces: Vec) -> Result Ok(result) } -fn oauth_params_from_box_any(any: BoxAny) -> Result { +fn oauth_params_from_box_any(any: BoxAny) -> Result { let map: HashMap = any.unbox_or_error()?; let sign_in_url = map .get(USER_SIGN_IN_URL) 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 e3b1d3c9f2e4c..6cb8d8697c7d7 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/server.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/server.rs @@ -1,8 +1,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; use anyhow::Error; -use client_api::collab_sync::collab_msg::CollabMessage; +use client_api::collab_sync::collab_msg::ServerCollabMessage; use client_api::entity::UserMessage; use client_api::notify::{TokenState, TokenStateReceiver}; use client_api::ws::{ @@ -10,6 +11,7 @@ use client_api::ws::{ }; use client_api::{Client, ClientConfiguration}; use flowy_storage::ObjectStorageService; +use rand::Rng; use tokio::sync::watch; use tokio_stream::wrappers::WatchStream; use tracing::{error, event, info, warn}; @@ -23,7 +25,6 @@ use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_user_pub::cloud::{UserCloudService, UserUpdate}; use flowy_user_pub::entities::UserTokenState; use lib_dispatch::prelude::af_spawn; -use lib_infra::future::FutureResult; use crate::af_cloud::impls::{ AFCloudDatabaseCloudServiceImpl, AFCloudDocumentCloudServiceImpl, AFCloudFileStorageServiceImpl, @@ -48,7 +49,7 @@ impl AppFlowyCloudServer { config: AFCloudConfiguration, enable_sync: bool, mut device_id: String, - app_version: &str, + client_version: &str, ) -> Self { // The device id can't be empty, so we generate a new one if it is. if device_id.is_empty() { @@ -64,7 +65,7 @@ impl AppFlowyCloudServer { ClientConfiguration::default() .with_compression_buffer_size(10240) .with_compression_quality(8), - app_version, + client_version, ); let token_state_rx = api_client.subscribe_token_state(); let enable_sync = Arc::new(AtomicBool::new(enable_sync)); @@ -74,13 +75,7 @@ impl AppFlowyCloudServer { let ws_client = Arc::new(ws_client); let api_client = Arc::new(api_client); - spawn_ws_conn( - &device_id, - token_state_rx, - &ws_client, - &api_client, - &enable_sync, - ); + spawn_ws_conn(token_state_rx, &ws_client, &api_client, &enable_sync); Self { config, client: api_client, @@ -200,30 +195,18 @@ impl AppFlowyServer for AppFlowyCloudServer { fn collab_ws_channel( &self, _object_id: &str, - ) -> FutureResult< + ) -> Result< Option<( - Arc>, + Arc>, WSConnectStateReceiver, bool, )>, Error, > { - if self.enable_sync.load(Ordering::SeqCst) { - 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_collab(object_id).ok(); - let connect_state_recv = ws_client.subscribe_connect_state(); - Ok(channel.map(|c| (c, connect_state_recv, ws_client.is_connected()))) - }, - } - }) - } else { - FutureResult::new(async { Ok(None) }) - } + let object_id = _object_id.to_string(); + let channel = self.ws_client.subscribe_collab(object_id).ok(); + let connect_state_recv = self.ws_client.subscribe_connect_state(); + Ok(channel.map(|c| (c, connect_state_recv, self.ws_client.is_connected()))) } fn file_storage(&self) -> Option> { @@ -239,13 +222,11 @@ impl AppFlowyServer for AppFlowyCloudServer { /// This function listens to the `token_state_rx` channel for token state updates. Depending on the /// received state, it either refreshes the WebSocket connection or disconnects from it. fn spawn_ws_conn( - device_id: &String, mut token_state_rx: TokenStateReceiver, ws_client: &Arc, api_client: &Arc, enable_sync: &Arc, ) { - let cloned_device_id = device_id.to_owned(); let weak_ws_client = Arc::downgrade(ws_client); let weak_api_client = Arc::downgrade(api_client); let enable_sync = enable_sync.clone(); @@ -256,17 +237,11 @@ fn spawn_ws_conn( while let Ok(state) = state_recv.recv().await { info!("[websocket] state: {:?}", state); match state { - ConnectState::PingTimeout | ConnectState::Closed => { + ConnectState::PingTimeout | ConnectState::Lost => { // Try to reconnect if the connection is timed out. if let Some(api_client) = weak_api_client.upgrade() { if enable_sync.load(Ordering::SeqCst) { - match api_client.ws_url(&cloned_device_id).await { - Ok(ws_addr) => { - event!(tracing::Level::INFO, "🟢reconnecting websocket"); - let _ = ws_client.connect(ws_addr, &cloned_device_id).await; - }, - Err(err) => error!("Failed to get ws url: {}, connect state:{:?}", err, state), - } + attempt_reconnect(&ws_client, &api_client, 2).await; } } }, @@ -283,7 +258,6 @@ fn spawn_ws_conn( } }); - let device_id = device_id.to_owned(); let weak_ws_client = Arc::downgrade(ws_client); let weak_api_client = Arc::downgrade(api_client); af_spawn(async move { @@ -294,9 +268,9 @@ fn spawn_ws_conn( if let (Some(api_client), Some(ws_client)) = (weak_api_client.upgrade(), weak_ws_client.upgrade()) { - match api_client.ws_url(&device_id).await { - Ok(ws_addr) => { - let _ = ws_client.connect(ws_addr, &device_id).await; + match api_client.ws_connect_info().await { + Ok(conn_info) => { + let _ = ws_client.connect(api_client.ws_addr(), conn_info).await; }, Err(err) => error!("Failed to get ws url: {}", err), } @@ -313,6 +287,28 @@ fn spawn_ws_conn( }); } +async fn attempt_reconnect( + ws_client: &Arc, + api_client: &Arc, + minimum_delay: u64, +) { + // Introduce randomness in the reconnection attempts to avoid thundering herd problem + let delay_seconds = rand::thread_rng().gen_range(minimum_delay..8); + tokio::time::sleep(Duration::from_secs(delay_seconds)).await; + event!( + tracing::Level::INFO, + "🟢 Attempting to reconnect websocket." + ); + match api_client.ws_connect_info().await { + Ok(conn_info) => { + if let Err(e) = ws_client.connect(api_client.ws_addr(), conn_info).await { + error!("Failed to reconnect websocket: {}", e); + } + }, + Err(err) => error!("Failed to get websocket URL: {}", err), + } +} + pub trait AFServer: Send + Sync + 'static { fn get_client(&self) -> Option>; fn try_get_client(&self) -> Result, Error>; diff --git a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs index 9092c967a9f74..14b2c32aba094 100644 --- a/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs +++ b/frontend/rust-lib/flowy-server/src/local_server/impls/database.rs @@ -1,5 +1,4 @@ use anyhow::Error; -use collab::core::collab::CollabDocState; use collab_entity::CollabType; use flowy_database_pub::cloud::{CollabDocStateByOid, DatabaseCloudService, DatabaseSnapshot}; @@ -13,7 +12,7 @@ impl DatabaseCloudService for LocalServerDatabaseCloudServiceImpl { _object_id: &str, _collab_type: CollabType, _workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { FutureResult::new(async move { Ok(vec![]) }) } 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 e22d36bc0467f..bc712d03d0662 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,5 +1,4 @@ use anyhow::Error; -use collab::core::collab::CollabDocState; use flowy_document_pub::cloud::*; use flowy_error::{ErrorCode, FlowyError}; @@ -12,7 +11,7 @@ impl DocumentCloudService for LocalServerDocumentCloudServiceImpl { &self, document_id: &str, _workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, FlowyError> { let document_id = document_id.to_string(); FutureResult::new(async move { Err(FlowyError::new( 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 4920df3c5132b..ea0ee027b965c 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 @@ -1,7 +1,6 @@ use std::sync::Arc; use anyhow::{anyhow, Error}; -use collab::core::collab::CollabDocState; use collab_entity::CollabType; use flowy_folder_pub::cloud::{ @@ -59,7 +58,7 @@ impl FolderCloudService for LocalServerFolderCloudServiceImpl { _uid: i64, _collab_type: CollabType, _object_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { FutureResult::new(async { Err(anyhow!( "Local server doesn't support get collab doc state from remote" 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 feb63ddc383f8..62bd938c1dcee 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 @@ -1,7 +1,5 @@ use std::sync::Arc; -use anyhow::{anyhow, Error}; -use collab::core::collab::CollabDocState; use collab_entity::CollabObject; use lazy_static::lazy_static; use parking_lot::Mutex; @@ -150,11 +148,11 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { FutureResult::new(async { Ok(vec![]) }) } - fn get_user_awareness_doc_state(&self, _uid: i64) -> FutureResult { + fn get_user_awareness_doc_state(&self, _uid: i64) -> FutureResult, FlowyError> { FutureResult::new(async { Ok(vec![]) }) } - fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), Error> { + fn reset_workspace(&self, _collab_object: CollabObject) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } @@ -171,15 +169,20 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { &self, _workspace_id: &str, _objects: Vec, - ) -> FutureResult<(), Error> { - FutureResult::new(async { Err(anyhow!("local server doesn't support create collab object")) }) + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async { + Err( + FlowyError::local_version_not_support() + .with_context("local server doesn't support batch create collab object"), + ) + }) } fn create_workspace(&self, _workspace_name: &str) -> FutureResult { FutureResult::new(async { Err( FlowyError::local_version_not_support() - .with_context("local server doesn't support mulitple workspaces"), + .with_context("local server doesn't support multiple workspaces"), ) }) } @@ -188,7 +191,21 @@ impl UserCloudService for LocalServerUserAuthServiceImpl { FutureResult::new(async { Err( FlowyError::local_version_not_support() - .with_context("local server doesn't support mulitple workspaces"), + .with_context("local server doesn't support multiple workspaces"), + ) + }) + } + + fn patch_workspace( + &self, + _workspace_id: &str, + _new_workspace_name: Option<&str>, + _new_workspace_icon: Option<&str>, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async { + Err( + FlowyError::local_version_not_support() + .with_context("local server doesn't support multiple workspaces"), ) }) } @@ -200,5 +217,6 @@ fn make_user_workspace() -> UserWorkspace { name: "My Workspace".to_string(), created_at: Default::default(), workspace_database_object_id: uuid::Uuid::new_v4().to_string(), + icon: "".to_string(), } } diff --git a/frontend/rust-lib/flowy-server/src/server.rs b/frontend/rust-lib/flowy-server/src/server.rs index 483cd4f8daa40..5459d8735b910 100644 --- a/frontend/rust-lib/flowy-server/src/server.rs +++ b/frontend/rust-lib/flowy-server/src/server.rs @@ -5,7 +5,7 @@ use flowy_storage::ObjectStorageService; use std::sync::Arc; use anyhow::Error; -use client_api::collab_sync::collab_msg::CollabMessage; +use client_api::collab_sync::collab_msg::ServerCollabMessage; use parking_lot::RwLock; use tokio_stream::wrappers::WatchStream; #[cfg(feature = "enable_supabase")] @@ -16,7 +16,6 @@ use flowy_document_pub::cloud::DocumentCloudService; use flowy_folder_pub::cloud::FolderCloudService; use flowy_user_pub::cloud::UserCloudService; use flowy_user_pub::entities::UserTokenState; -use lib_infra::future::FutureResult; pub trait AppFlowyEncryption: Send + Sync + 'static { fn get_secret(&self) -> Option; @@ -116,22 +115,22 @@ pub trait AppFlowyServer: Send + Sync + 'static { } fn get_ws_state(&self) -> ConnectState { - ConnectState::Closed + ConnectState::Lost } #[allow(clippy::type_complexity)] fn collab_ws_channel( &self, _object_id: &str, - ) -> FutureResult< + ) -> Result< Option<( - Arc>, + Arc>, WSConnectStateReceiver, bool, )>, anyhow::Error, > { - FutureResult::new(async { Ok(None) }) + Ok(None) } fn file_storage(&self) -> Option>; diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs b/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs index 3138e72f860bd..a27a6221f1594 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/collab_storage.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Weak}; use anyhow::Error; use chrono::{DateTime, Utc}; use client_api::collab_sync::collab_msg::MsgId; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::preclude::merge_updates_v1; use collab_entity::CollabObject; use collab_plugins::cloud_storage::{ @@ -62,7 +62,7 @@ where true } - async fn get_doc_state(&self, object: &CollabObject) -> Result { + async fn get_doc_state(&self, object: &CollabObject) -> Result { let postgrest = self.server.try_get_weak_postgrest()?; let action = FetchObjectUpdateAction::new( object.object_id.clone(), @@ -70,7 +70,7 @@ where postgrest, ); let doc_state = action.run().await?; - Ok(doc_state) + Ok(DocStateSource::FromDocState(doc_state)) } async fn get_snapshots(&self, object_id: &str, limit: usize) -> Vec { 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 afd6a2cac8da1..b5e3689e191ca 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/database.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/database.rs @@ -1,5 +1,4 @@ use anyhow::Error; -use collab::core::collab::CollabDocState; use collab_entity::CollabType; use tokio::sync::oneshot::channel; @@ -31,7 +30,7 @@ where object_id: &str, collab_type: CollabType, _workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let object_id = object_id.to_string(); let (tx, rx) = channel(); 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 869421ea75b72..2d2738f391fb2 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/document.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/document.rs @@ -1,5 +1,5 @@ use anyhow::Error; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::core::origin::CollabOrigin; use collab_document::blocks::DocumentData; use collab_document::document::Document; @@ -33,7 +33,7 @@ where &self, document_id: &str, workspace_id: &str, - ) -> FutureResult { + ) -> FutureResult, FlowyError> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let document_id = document_id.to_string(); let (tx, rx) = channel(); @@ -94,8 +94,12 @@ where let action = FetchObjectUpdateAction::new(document_id.clone(), CollabType::Document, postgrest); let doc_state = action.run_with_fix_interval(5, 10).await?; - let document = - Document::from_doc_state(CollabOrigin::Empty, doc_state, &document_id, vec![])?; + let document = Document::from_doc_state( + CollabOrigin::Empty, + DocStateSource::FromDocState(doc_state), + &document_id, + vec![], + )?; Ok(document.get_document_data().ok()) } .await, 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 81c19a015dad9..04b20fc7ed360 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/folder.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use anyhow::{anyhow, Error}; use chrono::{DateTime, Utc}; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab::core::origin::CollabOrigin; use collab_entity::CollabType; use serde_json::Value; @@ -102,8 +102,13 @@ where let doc_state = merge_updates_v1(&updates) .map_err(|err| anyhow::anyhow!("merge updates failed: {:?}", err))?; - let folder = - Folder::from_collab_doc_state(uid, CollabOrigin::Empty, doc_state, &workspace_id, vec![])?; + let folder = Folder::from_collab_doc_state( + uid, + CollabOrigin::Empty, + DocStateSource::FromDocState(doc_state), + &workspace_id, + vec![], + )?; Ok(folder.get_folder_data()) }) } @@ -137,7 +142,7 @@ where _uid: i64, collab_type: CollabType, object_id: &str, - ) -> FutureResult { + ) -> FutureResult, Error> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let object_id = object_id.to_string(); let (tx, rx) = channel(); diff --git a/frontend/rust-lib/flowy-server/src/supabase/api/request.rs b/frontend/rust-lib/flowy-server/src/supabase/api/request.rs index 4dab453ddd28b..5601b4a20f5a2 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/request.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/request.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::Error; use chrono::{DateTime, Utc}; -use collab::core::collab::CollabDocState; +use collab::core::collab::DocStateSource; use collab_entity::{CollabObject, CollabType}; use collab_plugins::cloud_storage::RemoteCollabSnapshot; use serde_json::Value; @@ -60,7 +60,7 @@ impl FetchObjectUpdateAction { impl Action for FetchObjectUpdateAction { type Future = Pin> + Send>>; - type Item = CollabDocState; + type Item = Vec; type Error = anyhow::Error; fn run(&mut self) -> Self::Future { @@ -284,7 +284,7 @@ pub async fn batch_get_updates_from_server( match parser_updates_form_json(record.clone(), &postgrest.secret()) { Ok(items) => { if items.is_empty() { - updates_by_oid.insert(oid.to_string(), vec![]); + updates_by_oid.insert(oid.to_string(), DocStateSource::FromDocState(vec![])); } else { let updates = items .iter() @@ -293,7 +293,7 @@ pub async fn batch_get_updates_from_server( let doc_state = merge_updates_v1(&updates) .map_err(|err| anyhow::anyhow!("merge updates failed: {:?}", err))?; - updates_by_oid.insert(oid.to_string(), doc_state); + updates_by_oid.insert(oid.to_string(), DocStateSource::FromDocState(doc_state)); } }, Err(e) => { 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 1307787c73ab7..34490e3f89d6d 100644 --- a/frontend/rust-lib/flowy-server/src/supabase/api/user.rs +++ b/frontend/rust-lib/flowy-server/src/supabase/api/user.rs @@ -5,8 +5,8 @@ use std::pin::Pin; use std::sync::{Arc, Weak}; use std::time::Duration; -use anyhow::{anyhow, Error}; -use collab::core::collab::{CollabDocState, MutexCollab}; +use anyhow::Error; +use collab::core::collab::MutexCollab; use collab::core::origin::CollabOrigin; use collab_entity::{CollabObject, CollabType}; use parking_lot::RwLock; @@ -16,7 +16,7 @@ use tokio_retry::strategy::FixedInterval; use tokio_retry::{Action, RetryIf}; use uuid::Uuid; -use flowy_error::FlowyError; +use flowy_error::{internal_error, FlowyError}; use flowy_folder_pub::cloud::{Folder, FolderData, Workspace}; use flowy_user_pub::cloud::*; use flowy_user_pub::entities::*; @@ -248,7 +248,8 @@ where Ok(user_workspaces) }) } - fn get_user_awareness_doc_state(&self, uid: i64) -> FutureResult { + + fn get_user_awareness_doc_state(&self, uid: i64) -> FutureResult, FlowyError> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let awareness_id = uid.to_string(); let (tx, rx) = channel(); @@ -263,7 +264,10 @@ where .await, ) }); - FutureResult::new(async { rx.await? }) + FutureResult::new(async { + let doc_state = rx.await.map_err(internal_error)?; + doc_state.map_err(internal_error) + }) } fn receive_realtime_event(&self, json: Value) { @@ -286,7 +290,7 @@ where self.user_update_rx.write().take() } - fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), Error> { + fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), FlowyError> { let try_get_postgrest = self.server.try_get_weak_postgrest(); let (tx, rx) = channel(); let init_update = default_workspace_doc_state(&collab_object); @@ -347,11 +351,12 @@ where &self, _workspace_id: &str, _objects: Vec, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { FutureResult::new(async { - Err(anyhow!( - "supabase server doesn't support batch create collab" - )) + Err( + FlowyError::local_version_not_support() + .with_context("supabase server doesn't support batch create collab"), + ) }) } @@ -372,6 +377,20 @@ where ) }) } + + fn patch_workspace( + &self, + _workspace_id: &str, + _new_workspace_name: Option<&str>, + _new_workspace_icon: Option<&str>, + ) -> FutureResult<(), FlowyError> { + FutureResult::new(async { + Err( + FlowyError::local_version_not_support() + .with_context("supabase server doesn't support mulitple workspaces"), + ) + }) + } } pub struct CreateCollabAction { @@ -655,6 +674,7 @@ fn default_workspace_doc_state(collab_object: &CollabObject) -> Vec { CollabOrigin::Empty, &collab_object.object_id, vec![], + false, )); 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)); 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 8377eb3dd943c..1a39d704a3e81 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 @@ -30,7 +30,7 @@ pub fn af_cloud_server(config: AFCloudConfiguration) -> Arc config, true, fake_device_id, - "flowy-server-test", + "0.5.1", )) } @@ -51,7 +51,7 @@ pub async fn generate_sign_in_url(user_email: &str, config: &AFCloudConfiguratio client.gotrue_url(), "fake_device_id", ClientConfiguration::default(), - &client.client_id, + &client.client_version.to_string(), ); admin_client .sign_in_password(&admin_email, &admin_password) diff --git a/frontend/rust-lib/flowy-server/tests/supabase_test/database_test.rs b/frontend/rust-lib/flowy-server/tests/supabase_test/database_test.rs index da17de9c65e65..4eabe8c5c07f2 100644 --- a/frontend/rust-lib/flowy-server/tests/supabase_test/database_test.rs +++ b/frontend/rust-lib/flowy-server/tests/supabase_test/database_test.rs @@ -1,3 +1,4 @@ +use collab::core::collab::DocStateSource; use collab_entity::{CollabObject, CollabType}; use uuid::Uuid; @@ -50,7 +51,12 @@ async fn supabase_create_database_test() { .unwrap(); assert_eq!(updates_by_oid.len(), 3); - for (_, update) in updates_by_oid { - assert_eq!(update.len(), 2); + for (_, source) in updates_by_oid { + match source { + DocStateSource::FromDisk => panic!("should not be from disk"), + DocStateSource::FromDocState(doc_state) => { + assert_eq!(doc_state.len(), 2); + }, + } } } 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 4732f5fa94dff..466b7283593cf 100644 --- a/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs +++ b/frontend/rust-lib/flowy-server/tests/supabase_test/util.rs @@ -2,7 +2,7 @@ use flowy_storage::ObjectStorageService; use std::collections::HashMap; use std::sync::Arc; -use collab::core::collab::MutexCollab; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab::core::origin::CollabOrigin; use collab_plugins::cloud_storage::RemoteCollabStorage; use uuid::Uuid; @@ -122,7 +122,14 @@ pub async fn print_encryption_folder_snapshot( .pop() .unwrap(); let collab = Arc::new( - MutexCollab::new_with_doc_state(CollabOrigin::Empty, folder_id, snapshot.blob, vec![]).unwrap(), + MutexCollab::new_with_doc_state( + CollabOrigin::Empty, + folder_id, + DocStateSource::FromDocState(snapshot.blob), + vec![], + false, + ) + .unwrap(), ); let folder_data = Folder::open(uid, collab, None) .unwrap() diff --git a/frontend/rust-lib/flowy-sqlite/migrations/.gitkeep b/frontend/rust-lib/flowy-sqlite/migrations/.gitkeep deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/down.sql new file mode 100644 index 0000000000000..6adb7719f8079 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE user_workspace_table DROP COLUMN icon TEXT; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/up.sql new file mode 100644 index 0000000000000..61dfcf40b8932 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE user_workspace_table ADD COLUMN icon TEXT NOT NULL DEFAULT ''; diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index a5ccad50f2d5d..37c2ff8bbd01a 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -43,6 +43,7 @@ diesel::table! { uid -> BigInt, created_at -> BigInt, database_storage_id -> Text, + icon -> Text, } } diff --git a/frontend/rust-lib/flowy-user-pub/src/cloud.rs b/frontend/rust-lib/flowy-user-pub/src/cloud.rs index 27cc2233f09af..928e1ce7f082e 100644 --- a/frontend/rust-lib/flowy-user-pub/src/cloud.rs +++ b/frontend/rust-lib/flowy-user-pub/src/cloud.rs @@ -1,7 +1,5 @@ -use anyhow::Error; -use collab::core::collab::CollabDocState; use collab_entity::{CollabObject, CollabType}; -use flowy_error::{ErrorCode, FlowyError}; +use flowy_error::{internal_error, ErrorCode, FlowyError}; use lib_infra::box_any::BoxAny; use lib_infra::conditional_send_sync_trait; use lib_infra::future::FutureResult; @@ -171,6 +169,14 @@ pub trait UserCloudService: Send + Sync + 'static { /// Returns the new workspace if successful fn create_workspace(&self, workspace_name: &str) -> FutureResult; + // Updates the workspace name and icon + fn patch_workspace( + &self, + workspace_id: &str, + new_workspace_name: Option<&str>, + new_workspace_icon: Option<&str>, + ) -> FutureResult<(), FlowyError>; + /// Deletes a workspace owned by the user. fn delete_workspace(&self, workspace_id: &str) -> FutureResult<(), FlowyError>; @@ -178,7 +184,7 @@ pub trait UserCloudService: Send + Sync + 'static { &self, user_email: String, workspace_id: String, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } @@ -186,7 +192,7 @@ pub trait UserCloudService: Send + Sync + 'static { &self, user_email: String, workspace_id: String, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } @@ -195,18 +201,18 @@ pub trait UserCloudService: Send + Sync + 'static { user_email: String, workspace_id: String, role: Role, - ) -> FutureResult<(), Error> { + ) -> FutureResult<(), FlowyError> { FutureResult::new(async { Ok(()) }) } fn get_workspace_members( &self, workspace_id: String, - ) -> FutureResult, Error> { + ) -> FutureResult, FlowyError> { FutureResult::new(async { Ok(vec![]) }) } - fn get_user_awareness_doc_state(&self, uid: i64) -> FutureResult; + fn get_user_awareness_doc_state(&self, uid: i64) -> FutureResult, FlowyError>; fn receive_realtime_event(&self, _json: Value) {} @@ -214,7 +220,7 @@ pub trait UserCloudService: Send + Sync + 'static { None } - fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), Error>; + fn reset_workspace(&self, collab_object: CollabObject) -> FutureResult<(), FlowyError>; fn create_collab_object( &self, @@ -227,7 +233,7 @@ pub trait UserCloudService: Send + Sync + 'static { &self, workspace_id: &str, objects: Vec, - ) -> FutureResult<(), Error>; + ) -> FutureResult<(), FlowyError>; } pub type UserUpdateReceiver = tokio::sync::mpsc::Receiver; @@ -240,13 +246,12 @@ pub struct UserUpdate { pub encryption_sign: String, } -pub fn uuid_from_map(map: &HashMap) -> Result { +pub fn uuid_from_map(map: &HashMap) -> Result { let uuid = map .get("uuid") .ok_or_else(|| FlowyError::new(ErrorCode::MissingAuthField, "Missing uuid field"))? .as_str(); - let uuid = Uuid::from_str(uuid)?; - Ok(uuid) + Uuid::from_str(uuid).map_err(internal_error) } #[derive(Debug)] diff --git a/frontend/rust-lib/flowy-user-pub/src/entities.rs b/frontend/rust-lib/flowy-user-pub/src/entities.rs index 9728c0cd09ec1..ec338fd4f4889 100644 --- a/frontend/rust-lib/flowy-user-pub/src/entities.rs +++ b/frontend/rust-lib/flowy-user-pub/src/entities.rs @@ -140,6 +140,8 @@ pub struct UserWorkspace { /// The database storage id is used indexing all the database views in current workspace. #[serde(rename = "database_storage_id")] pub workspace_database_object_id: String, + #[serde(default)] + pub icon: String, } impl UserWorkspace { @@ -149,6 +151,7 @@ impl UserWorkspace { name: "".to_string(), created_at: Utc::now(), workspace_database_object_id: Uuid::new_v4().to_string(), + icon: "".to_string(), } } } diff --git a/frontend/rust-lib/flowy-user-pub/src/session.rs b/frontend/rust-lib/flowy-user-pub/src/session.rs index 2b742690b41ac..f4d45aff7073b 100644 --- a/frontend/rust-lib/flowy-user-pub/src/session.rs +++ b/frontend/rust-lib/flowy-user-pub/src/session.rs @@ -63,6 +63,7 @@ impl<'de> Visitor<'de> for SessionVisitor { created_at: Utc::now(), // For historical reasons, the database_storage_id is constructed by the user_id. workspace_database_object_id: STANDARD.encode(format!("{}:user:database", user_id)), + icon: "".to_owned(), }) } } diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 71163380a2a9e..ec423d9bda722 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -9,7 +9,7 @@ edition = "2018" flowy-derive.workspace = true flowy-sqlite = { workspace = true, optional = true } flowy-encrypt = { workspace = true } -flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "impl_from_sqlite"] } +flowy-error = { workspace = true, features = ["impl_from_dispatch_error", "impl_from_sqlite", "impl_from_collab_folder", "impl_from_collab_persistence"] } flowy-folder-pub = { workspace = true } lib-infra = { workspace = true } flowy-notification = { workspace = true } diff --git a/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs b/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs index 18a5de7ff075d..4e5fc0cb81487 100644 --- a/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs +++ b/frontend/rust-lib/flowy-user/src/anon_user/migrate_anon_user_collab.rs @@ -3,14 +3,14 @@ use std::ops::{Deref, DerefMut}; use std::sync::Arc; use anyhow::anyhow; -use collab::core::collab::MutexCollab; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab::core::origin::{CollabClient, CollabOrigin}; use collab::preclude::Collab; use collab_database::database::{ is_database_collab, mut_database_views_with_collab, reset_inline_view_id, }; use collab_database::rows::{database_row_document_id_from_row_id, mut_row_with_collab, RowId}; -use collab_database::user::DatabaseViewTrackerList; +use collab_database::user::DatabaseMetaList; use collab_folder::{Folder, UserId}; use collab_plugins::local_storage::kv::KVTransactionDB; use parking_lot::{Mutex, RwLock}; @@ -151,6 +151,7 @@ where &old_user.session.user_workspace.workspace_database_object_id, "phantom", vec![], + false, ); database_with_views_collab.with_origin_transact_mut(|txn| { old_collab_r_txn.load_doc_with_txn( @@ -163,9 +164,9 @@ where let new_uid = new_user_session.user_id; let new_object_id = &new_user_session.user_workspace.workspace_database_object_id; - let array = DatabaseViewTrackerList::from_collab(&database_with_views_collab); - for database_view_tracker in array.get_all_database_tracker() { - array.update_database(&database_view_tracker.database_id, |update| { + let array = DatabaseMetaList::from_collab(&database_with_views_collab); + for database_meta in array.get_all_database_meta() { + array.update_database(&database_meta.database_id, |update| { let new_linked_views = update .linked_views .iter() @@ -214,7 +215,7 @@ where let new_uid = new_user_session.user_id; let new_workspace_id = &new_user_session.user_workspace.id; - let old_folder_collab = Collab::new(old_uid, old_workspace_id, "phantom", vec![]); + let old_folder_collab = Collab::new(old_uid, old_workspace_id, "phantom", vec![], false); old_folder_collab.with_origin_transact_mut(|txn| { old_collab_r_txn.load_doc_with_txn(old_uid, old_workspace_id, txn) })?; @@ -304,8 +305,14 @@ where } let origin = CollabOrigin::Client(CollabClient::new(new_uid, "phantom")); - let new_folder_collab = Collab::new_with_doc_state(origin, new_workspace_id, vec![], vec![]) - .map_err(|err| PersistenceError::Internal(err.into()))?; + let new_folder_collab = Collab::new_with_doc_state( + origin, + new_workspace_id, + DocStateSource::FromDisk, + vec![], + false, + ) + .map_err(|err| PersistenceError::Internal(err.into()))?; let mutex_collab = Arc::new(MutexCollab::from_collab(new_folder_collab)); let new_user_id = UserId::from(new_uid); info!("migrated folder: {:?}", folder_data); @@ -450,7 +457,13 @@ where { let mut collab_by_oid = HashMap::new(); for object_id in object_ids { - let collab = Collab::new(old_user.session.user_id, object_id, "phantom", vec![]); + let collab = Collab::new( + old_user.session.user_id, + object_id, + "phantom", + vec![], + false, + ); match collab.with_origin_transact_mut(|txn| { old_collab_r_txn.load_doc_with_txn(old_user.session.user_id, &object_id, txn) }) { diff --git a/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs b/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs index 6387ec7ba5fc8..c5ac91f6b3247 100644 --- a/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs +++ b/frontend/rust-lib/flowy-user/src/anon_user/sync_supabase_user_collab.rs @@ -8,7 +8,7 @@ use collab::core::collab::MutexCollab; use collab::preclude::Collab; use collab_database::database::get_database_row_ids; use collab_database::rows::database_row_document_id_from_row_id; -use collab_database::user::{get_all_database_view_trackers, DatabaseViewTracker}; +use collab_database::user::{get_all_database_meta, DatabaseMeta}; use collab_entity::{CollabObject, CollabType}; use collab_folder::{Folder, View, ViewLayout}; use collab_plugins::local_storage::kv::KVTransactionDB; @@ -75,7 +75,7 @@ pub async fn sync_supabase_user_data_to_cloud( fn sync_view( uid: i64, folder: Arc, - database_records: Vec>, + database_metas: Vec>, workspace_id: String, device_id: String, view: Arc, @@ -84,7 +84,7 @@ fn sync_view( ) -> Pin> + Send + Sync>> { Box::pin(async move { let collab_type = collab_type_from_view_layout(&view.layout); - let object_id = object_id_from_view(&view, &database_records)?; + let object_id = object_id_from_view(&view, &database_metas)?; tracing::debug!( "sync view: {:?}:{} with object_id: {}", view.layout, @@ -180,7 +180,7 @@ fn sync_view( if let Err(err) = Box::pin(sync_view( uid, folder.clone(), - database_records.clone(), + database_metas.clone(), workspace_id.clone(), device_id.to_string(), child_view, @@ -207,7 +207,7 @@ fn get_collab_doc_state( collab_object: &CollabObject, collab_db: &Arc, ) -> Result, PersistenceError> { - let collab = Collab::new(uid, &collab_object.object_id, "phantom", vec![]); + let collab = Collab::new(uid, &collab_object.object_id, "phantom", vec![], false); let _ = collab.with_origin_transact_mut(|txn| { collab_db .read_txn() @@ -226,7 +226,7 @@ fn get_database_doc_state( collab_object: &CollabObject, collab_db: &Arc, ) -> Result<(Vec, Vec), PersistenceError> { - let collab = Collab::new(uid, &collab_object.object_id, "phantom", vec![]); + let collab = Collab::new(uid, &collab_object.object_id, "phantom", vec![], false); let _ = collab.with_origin_transact_mut(|txn| { collab_db .read_txn() @@ -250,7 +250,7 @@ async fn sync_folder( user_service: Arc, ) -> Result { let (folder, update) = { - let collab = Collab::new(uid, workspace_id, "phantom", vec![]); + let collab = Collab::new(uid, workspace_id, "phantom", vec![], false); // Use the temporary result to short the lifetime of the TransactionMut collab.with_origin_transact_mut(|txn| { collab_db @@ -297,7 +297,7 @@ async fn sync_database_views( database_views_aggregate_id: &str, collab_db: &Arc, user_service: Arc, -) -> Vec> { +) -> Vec> { let collab_object = CollabObject::new( uid, database_views_aggregate_id.to_string(), @@ -308,7 +308,7 @@ async fn sync_database_views( // Use the temporary result to short the lifetime of the TransactionMut let result = { - let collab = Collab::new(uid, database_views_aggregate_id, "phantom", vec![]); + let collab = Collab::new(uid, database_views_aggregate_id, "phantom", vec![], false); collab .with_origin_transact_mut(|txn| { collab_db @@ -317,7 +317,7 @@ async fn sync_database_views( }) .map(|_| { ( - get_all_database_view_trackers(&collab), + get_all_database_meta(&collab), collab.encode_collab_v1().doc_state, ) }) @@ -357,7 +357,7 @@ fn collab_type_from_view_layout(view_layout: &ViewLayout) -> CollabType { fn object_id_from_view( view: &Arc, - database_records: &[Arc], + database_records: &[Arc], ) -> Result { if view.layout.is_database() { match database_records 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 030c5b1179d57..80dcfd1b7f08c 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -225,6 +225,12 @@ pub struct UserWorkspacePB { #[pb(index = 2)] pub name: String, + + #[pb(index = 3)] + pub created_at_timestamp: i64, + + #[pb(index = 4)] + pub icon: String, } impl From for UserWorkspacePB { @@ -232,6 +238,8 @@ impl From for UserWorkspacePB { Self { workspace_id: value.id, name: value.name, + created_at_timestamp: value.created_at.timestamp(), + icon: value.icon, } } } diff --git a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs index 95f0d83e6cfc0..6aa421bdbc97d 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs @@ -68,6 +68,10 @@ pub struct AppearanceSettingsPB { #[pb(index = 12)] #[serde(default)] pub document_setting: DocumentSettingsPB, + + #[pb(index = 13)] + #[serde(default)] + pub enable_rtl_toolbar_items: bool, } const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT; @@ -129,6 +133,7 @@ pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono"; const APPEARANCE_RESET_AS_DEFAULT: bool = true; const APPEARANCE_DEFAULT_IS_MENU_COLLAPSED: bool = false; const APPEARANCE_DEFAULT_MENU_OFFSET: f64 = 0.0; +const APPEARANCE_DEFAULT_ENABLE_RTL_TOOLBAR_ITEMS: bool = false; impl std::default::Default for AppearanceSettingsPB { fn default() -> Self { @@ -144,6 +149,7 @@ impl std::default::Default for AppearanceSettingsPB { menu_offset: APPEARANCE_DEFAULT_MENU_OFFSET, layout_direction: LayoutDirectionPB::default(), text_direction: TextDirectionPB::default(), + enable_rtl_toolbar_items: APPEARANCE_DEFAULT_ENABLE_RTL_TOOLBAR_ITEMS, document_setting: DocumentSettingsPB::default(), } } diff --git a/frontend/rust-lib/flowy-user/src/entities/workspace.rs b/frontend/rust-lib/flowy-user/src/entities/workspace.rs index daef940819cd8..c98e256547fc1 100644 --- a/frontend/rust-lib/flowy-user/src/entities/workspace.rs +++ b/frontend/rust-lib/flowy-user/src/entities/workspace.rs @@ -116,3 +116,24 @@ pub struct CreateWorkspacePB { #[validate(custom = "required_not_empty_str")] pub name: String, } + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct RenameWorkspacePB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, + + #[pb(index = 2)] + #[validate(custom = "required_not_empty_str")] + pub new_name: String, +} + +#[derive(ProtoBuf, Default, Clone, Validate)] +pub struct ChangeWorkspaceIconPB { + #[pb(index = 1)] + #[validate(custom = "required_not_empty_str")] + pub workspace_id: String, + + #[pb(index = 2)] + pub new_icon: String, +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 40cba9c28216d..bff1ef891b9fd 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -683,3 +683,29 @@ pub async fn delete_workspace_handler( manager.delete_workspace(&workspace_id).await?; Ok(()) } + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn rename_workspace_handler( + rename_workspace_param: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let params = rename_workspace_param.try_into_inner()?; + let manager = upgrade_manager(manager)?; + manager + .patch_workspace(¶ms.workspace_id, Some(¶ms.new_name), None) + .await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn change_workspace_icon_handler( + change_workspace_icon_param: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let params = change_workspace_icon_param.try_into_inner()?; + let manager = upgrade_manager(manager)?; + manager + .patch_workspace(¶ms.workspace_id, None, Some(¶ms.new_icon)) + .await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 6dbeb877b2081..611fd9bad1fab 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -62,6 +62,8 @@ pub fn init(user_manager: Weak) -> AFPlugin { .event(UserEvent::GetAllWorkspace, get_all_workspace_handler) .event(UserEvent::CreateWorkspace, create_workspace_handler) .event(UserEvent::DeleteWorkspace, delete_workspace_handler) + .event(UserEvent::RenameWorkspace, rename_workspace_handler) + .event(UserEvent::ChangeWorkspaceIcon, change_workspace_icon_handler) } #[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)] @@ -180,26 +182,32 @@ pub enum UserEvent { #[event(output = "NotificationSettingsPB")] GetNotificationSettings = 36, - #[event(output = "AddWorkspaceMemberPB")] + #[event(input = "AddWorkspaceMemberPB")] AddWorkspaceMember = 37, - #[event(output = "RemoveWorkspaceMemberPB")] + #[event(input = "RemoveWorkspaceMemberPB")] RemoveWorkspaceMember = 38, - #[event(output = "UpdateWorkspaceMemberPB")] + #[event(input = "UpdateWorkspaceMemberPB")] UpdateWorkspaceMember = 39, - #[event(output = "QueryWorkspacePB")] + #[event(input = "QueryWorkspacePB", output = "RepeatedWorkspaceMemberPB")] GetWorkspaceMember = 40, #[event(input = "ImportAppFlowyDataPB")] ImportAppFlowyDataFolder = 41, - #[event(output = "CreateWorkspacePB")] + #[event(input = "CreateWorkspacePB", output = "UserWorkspacePB")] CreateWorkspace = 42, #[event(input = "UserWorkspaceIdPB")] DeleteWorkspace = 43, + + #[event(input = "RenameWorkspacePB")] + RenameWorkspace = 44, + + #[event(input = "ChangeWorkspaceIconPB")] + ChangeWorkspaceIcon = 45, } pub trait UserStatusCallback: Send + Sync + 'static { 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 index 94c4a6104d944..41a84b03d676e 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs @@ -9,7 +9,7 @@ use collab_plugins::local_storage::kv::KVTransactionDB; use tracing::{event, instrument}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; -use flowy_error::{internal_error, FlowyError, FlowyResult}; +use flowy_error::{FlowyError, FlowyResult}; use flowy_user_pub::entities::Authenticator; use crate::migrations::migration::UserDataMigration; @@ -37,33 +37,32 @@ impl UserDataMigration for HistoricalEmptyDocumentMigration { if !matches!(authenticator, Authenticator::Local) { return Ok(()); } - collab_db - .with_write_txn(|write_txn| { - let origin = CollabOrigin::Client(CollabClient::new(session.user_id, "phantom")); - let folder_collab = - match load_collab(session.user_id, write_txn, &session.user_workspace.id) { - Ok(fc) => fc, - Err(_) => return Ok(()), - }; + collab_db.with_write_txn(|write_txn| { + let origin = CollabOrigin::Client(CollabClient::new(session.user_id, "phantom")); + let folder_collab = match load_collab(session.user_id, write_txn, &session.user_workspace.id) + { + Ok(fc) => fc, + Err(_) => return Ok(()), + }; - let folder = Folder::open(session.user_id, folder_collab, None)?; - let migration_views = folder.get_workspace_views(); + let folder = Folder::open(session.user_id, folder_collab, None) + .map_err(|err| PersistenceError::Internal(err.into()))?; + let migration_views = folder.get_workspace_views(); - // For historical reasons, the first level documents are empty. So migrate them by inserting - // the default document data. - for view in migration_views { - if migrate_empty_document(write_txn, &origin, &view, session.user_id).is_err() { - event!( - tracing::Level::ERROR, - "Failed to migrate document {}", - view.id - ); - } + // For historical reasons, the first level documents are empty. So migrate them by inserting + // the default document data. + for view in migration_views { + if migrate_empty_document(write_txn, &origin, &view, session.user_id).is_err() { + event!( + tracing::Level::ERROR, + "Failed to migrate document {}", + view.id + ); } + } - Ok(()) - }) - .map_err(internal_error)?; + Ok(()) + })?; Ok(()) } @@ -81,7 +80,7 @@ where { // If the document is not exist, we don't need to migrate it. if load_collab(user_id, write_txn, &view.id).is_err() { - let collab = Arc::new(MutexCollab::new(origin.clone(), &view.id, vec![])); + let collab = Arc::new(MutexCollab::new(origin.clone(), &view.id, vec![], false)); let document = Document::create_with_data(collab, default_document_data())?; let encode = document.get_collab().encode_collab_v1(); write_txn.flush_doc_with(user_id, &view.id, &encode.doc_state, &encode.state_vector)?; diff --git a/frontend/rust-lib/flowy-user/src/migrations/util.rs b/frontend/rust-lib/flowy-user/src/migrations/util.rs index f135cbbc96ef1..8249ac341d00c 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/util.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/util.rs @@ -15,7 +15,7 @@ where R: CollabKVAction<'a>, PersistenceError: From, { - let collab = Collab::new(uid, object_id, "phantom", vec![]); + let collab = Collab::new(uid, object_id, "phantom", vec![], false); collab.with_origin_transact_mut(|txn| collab_r_txn.load_doc_with_txn(uid, &object_id, txn))?; 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 index 2f3e7aca3c1af..3c4273e980b36 100644 --- 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 @@ -1,11 +1,11 @@ use std::sync::Arc; use collab_folder::Folder; -use collab_plugins::local_storage::kv::KVTransactionDB; +use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; -use flowy_error::{internal_error, FlowyResult}; +use flowy_error::FlowyResult; use flowy_user_pub::entities::Authenticator; use crate::migrations::migration::UserDataMigration; @@ -29,33 +29,32 @@ impl UserDataMigration for FavoriteV1AndWorkspaceArrayMigration { collab_db: &Arc, _authenticator: &Authenticator, ) -> FlowyResult<()> { - collab_db - .with_write_txn(|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::>(); - - 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, - )?; + collab_db.with_write_txn(|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) + .map_err(|err| PersistenceError::Internal(err.into()))?; + folder.migrate_workspace_to_view(); + + let favorite_view_ids = folder + .get_favorite_v1() + .into_iter() + .map(|fav| fav.id) + .collect::>(); + + if !favorite_view_ids.is_empty() { + folder.add_favorite_view_ids(favorite_view_ids); } - Ok(()) - }) - .map_err(internal_error)?; + + let encode = folder.encode_collab_v1(); + write_txn.flush_doc_with( + session.user_id, + &session.user_workspace.id, + &encode.doc_state, + &encode.state_vector, + )?; + } + Ok(()) + })?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs index 8e3e65c73b8d3..a8cdbaed89a21 100644 --- a/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs +++ b/frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs @@ -1,11 +1,11 @@ use std::sync::Arc; use collab_folder::Folder; -use collab_plugins::local_storage::kv::KVTransactionDB; +use collab_plugins::local_storage::kv::{KVTransactionDB, PersistenceError}; use tracing::instrument; use collab_integrate::{CollabKVAction, CollabKVDB}; -use flowy_error::{internal_error, FlowyResult}; +use flowy_error::FlowyResult; use flowy_user_pub::entities::Authenticator; use crate::migrations::migration::UserDataMigration; @@ -27,31 +27,30 @@ impl UserDataMigration for WorkspaceTrashMapToSectionMigration { collab_db: &Arc, _authenticator: &Authenticator, ) -> FlowyResult<()> { - collab_db - .with_write_txn(|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)?; - let trash_ids = folder - .get_trash_v1() - .into_iter() - .map(|fav| fav.id) - .collect::>(); - - if !trash_ids.is_empty() { - folder.add_trash(trash_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, - )?; + collab_db.with_write_txn(|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) + .map_err(|err| PersistenceError::Internal(err.into()))?; + let trash_ids = folder + .get_trash_v1() + .into_iter() + .map(|fav| fav.id) + .collect::>(); + + if !trash_ids.is_empty() { + folder.add_trash_view_ids(trash_ids); } - Ok(()) - }) - .map_err(internal_error)?; + + let encode = folder.encode_collab_v1(); + write_txn.flush_doc_with( + session.user_id, + &session.user_workspace.id, + &encode.doc_state, + &encode.state_vector, + )?; + } + Ok(()) + })?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs index 6783a24b04c4e..35eda7c58a3a6 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs @@ -5,7 +5,7 @@ use crate::services::entities::UserPaths; use crate::services::sqlite_sql::user_sql::select_user_profile; use crate::user_manager::run_collab_data_migration; use anyhow::anyhow; -use collab::core::collab::{CollabDocState, MutexCollab}; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab::core::origin::CollabOrigin; use collab::core::transaction::DocTransactionExtension; use collab::preclude::updates::decoder::Decode; @@ -14,7 +14,7 @@ use collab_database::database::{ is_database_collab, mut_database_views_with_collab, reset_inline_view_id, }; use collab_database::rows::{database_row_document_id_from_row_id, mut_row_with_collab, RowId}; -use collab_database::user::DatabaseViewTrackerList; +use collab_database::user::DatabaseMetaList; use collab_document::document_data::default_document_collab_data; use collab_entity::CollabType; use collab_folder::{Folder, UserId, View, ViewIdentifier, ViewLayout}; @@ -271,6 +271,7 @@ where &other_session.user_workspace.workspace_database_object_id, "phantom", vec![], + false, ); database_view_tracker_collab.with_origin_transact_mut(|txn| { other_collab_read_txn.load_doc_with_txn( @@ -280,11 +281,11 @@ where ) })?; - let array = DatabaseViewTrackerList::from_collab(&database_view_tracker_collab); - for database_view_tracker in array.get_all_database_tracker() { + let array = DatabaseMetaList::from_collab(&database_view_tracker_collab); + for database_meta in array.get_all_database_meta() { database_view_ids_by_database_id.insert( - old_to_new_id_map.renew_id(&database_view_tracker.database_id), - database_view_tracker + old_to_new_id_map.renew_id(&database_meta.database_id), + database_meta .linked_views .into_iter() .map(|view_id| old_to_new_id_map.renew_id(&view_id)) @@ -446,7 +447,7 @@ where } fn import_collab_object_with_doc_state<'a, W>( - doc_state: CollabDocState, + doc_state: Vec, new_uid: i64, new_object_id: &str, w_txn: &'a W, @@ -455,7 +456,13 @@ where W: CollabKVAction<'a>, PersistenceError: From, { - let collab = Collab::new_with_doc_state(CollabOrigin::Empty, new_object_id, doc_state, vec![])?; + let collab = Collab::new_with_doc_state( + CollabOrigin::Empty, + new_object_id, + DocStateSource::FromDocState(doc_state), + vec![], + false, + )?; write_collab_object(&collab, new_uid, new_object_id, w_txn); Ok(()) } @@ -475,6 +482,7 @@ where &other_session.user_workspace.id, "phantom", vec![], + false, ); other_folder_collab.with_origin_transact_mut(|txn| { other_collab_read_txn.load_doc_with_txn( diff --git a/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs b/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs index d12854f5fb384..b45cc87fa93e3 100644 --- a/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs +++ b/frontend/rust-lib/flowy-user/src/services/data_import/importer.rs @@ -32,7 +32,7 @@ where { let mut collab_by_oid = HashMap::new(); for object_id in object_ids { - let collab = Collab::new(uid, object_id, "phantom", vec![]); + let collab = Collab::new(uid, object_id, "phantom", vec![], false); match collab .with_origin_transact_mut(|txn| collab_read_txn.load_doc_with_txn(uid, &object_id, txn)) { diff --git a/frontend/rust-lib/flowy-user/src/services/db.rs b/frontend/rust-lib/flowy-user/src/services/db.rs index 853365c04ac06..3305fca41a895 100644 --- a/frontend/rust-lib/flowy-user/src/services/db.rs +++ b/frontend/rust-lib/flowy-user/src/services/db.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::{collections::HashMap, fs, io, sync::Arc, time::Duration}; -use chrono::Local; +use chrono::{Days, Local}; use collab_integrate::{CollabKVAction, CollabKVDB, PersistenceError}; use collab_plugins::local_storage::kv::KVTransactionDB; use flowy_error::FlowyError; @@ -323,40 +323,46 @@ impl CollabDBZipBackup { fn clean_old_backups(&self) -> io::Result<()> { let mut backups = Vec::new(); - let threshold_date = Local::now() - chrono::Duration::days(10); - - // Collect all backup files - for entry in fs::read_dir(&self.history_folder)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("zip") { - let filename = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default(); - let date_str = filename.split('_').last().unwrap_or(""); - backups.push((date_str.to_string(), path)); - } - } + let now = Local::now(); + match now.checked_sub_days(Days::new(10)) { + None => { + error!("Failed to calculate threshold date"); + }, + Some(threshold_date) => { + // Collect all backup files + for entry in fs::read_dir(&self.history_folder)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("zip") { + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let date_str = filename.split('_').last().unwrap_or(""); + backups.push((date_str.to_string(), path)); + } + } - // Sort backups by date (oldest first) - backups.sort_by(|a, b| a.0.cmp(&b.0)); - - // Remove backups older than 10 days - let threshold_str = threshold_date.format(zip_time_format()).to_string(); - - info!("Current backup: {:?}", backups.len()); - // If there are more than 10 backups, remove the oldest ones - while backups.len() > 10 { - if let Some((date_str, path)) = backups.first() { - if date_str < &threshold_str { - info!("Remove old backup file: {:?}", path); - fs::remove_file(path)?; - backups.remove(0); - } else { - break; + // Sort backups by date (oldest first) + backups.sort_by(|a, b| a.0.cmp(&b.0)); + + // Remove backups older than 10 days + let threshold_str = threshold_date.format(zip_time_format()).to_string(); + + info!("Current backup: {:?}", backups.len()); + // If there are more than 10 backups, remove the oldest ones + while backups.len() > 10 { + if let Some((date_str, path)) = backups.first() { + if date_str < &threshold_str { + info!("Remove old backup file: {:?}", path); + fs::remove_file(path)?; + backups.remove(0); + } else { + break; + } + } } - } + }, } Ok(()) diff --git a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs index e335949448128..529865234ba88 100644 --- a/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs +++ b/frontend/rust-lib/flowy-user/src/services/sqlite_sql/workspace_sql.rs @@ -15,6 +15,7 @@ pub struct UserWorkspaceTable { pub uid: i64, pub created_at: i64, pub database_storage_id: String, + pub icon: String, } pub fn get_user_workspace_op(workspace_id: &str, mut conn: DBConnection) -> Option { @@ -90,6 +91,7 @@ impl TryFrom<(i64, &UserWorkspace)> for UserWorkspaceTable { uid: value.0, created_at: value.1.created_at.timestamp(), database_storage_id: value.1.workspace_database_object_id.clone(), + icon: value.1.icon.clone(), }) } } @@ -104,6 +106,7 @@ impl From for UserWorkspace { .single() .unwrap_or_default(), workspace_database_object_id: value.database_storage_id, + icon: value.icon, } } } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index af75c0d39527b..73b57161ef912 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -695,6 +695,7 @@ impl UserManager { save_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; event!(tracing::Level::INFO, "Save new user profile to disk"); + self.authenticate_user.set_session(Some(session.clone()))?; self .save_user(uid, (user_profile, authenticator.clone()).into()) diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs index f7fc49803e7b0..3c1249304b553 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Weak}; use anyhow::Context; -use collab::core::collab::{CollabDocState, MutexCollab}; +use collab::core::collab::{DocStateSource, MutexCollab}; use collab_entity::reminder::Reminder; use collab_entity::CollabType; use collab_integrate::collab_builder::CollabBuilderConfig; @@ -164,7 +164,7 @@ impl UserManager { &self, session: &Session, collab_db: Weak, - raw_data: CollabDocState, + doc_state: Vec, ) -> Result, FlowyError> { let collab_builder = self.collab_builder.upgrade().ok_or(FlowyError::new( ErrorCode::Internal, @@ -176,7 +176,7 @@ impl UserManager { session.user_id, &user_awareness_id.to_string(), CollabType::UserAwareness, - raw_data, + DocStateSource::FromDocState(doc_state), collab_db, CollabBuilderConfig::default().sync_enable(true), ) diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index aa75a6f91253f..231f0299ba455 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use collab_entity::{CollabObject, CollabType}; use collab_integrate::CollabKVDB; -use tracing::{error, info, instrument}; +use tracing::{error, info, instrument, warn}; use flowy_error::{FlowyError, FlowyResult}; use flowy_folder_pub::entities::{AppFlowyData, ImportData}; @@ -167,12 +167,50 @@ impl UserManager { Ok(new_workspace) } + pub async fn patch_workspace( + &self, + workspace_id: &str, + new_workspace_name: Option<&str>, + new_workspace_icon: Option<&str>, + ) -> FlowyResult<()> { + self + .cloud_services + .get_user_service()? + .patch_workspace(workspace_id, new_workspace_name, new_workspace_icon) + .await?; + + // save the icon and name to sqlite db + let uid = self.user_id()?; + let conn = self.db_connection(uid)?; + let mut user_workspace = match self.get_user_workspace(uid, workspace_id) { + Some(user_workspace) => user_workspace, + None => { + return Err(FlowyError::record_not_found().with_context(format!( + "Expected to find user workspace with id: {}, but not found", + workspace_id + ))); + }, + }; + + if let Some(new_workspace_name) = new_workspace_name { + user_workspace.name = new_workspace_name.to_string(); + } + if let Some(new_workspace_icon) = new_workspace_icon { + user_workspace.icon = new_workspace_icon.to_string(); + } + + save_user_workspaces(uid, conn, &[user_workspace]) + } + pub async fn delete_workspace(&self, workspace_id: &str) -> FlowyResult<()> { self .cloud_services .get_user_service()? .delete_workspace(workspace_id) .await?; + let uid = self.user_id()?; + let conn = self.db_connection(uid)?; + delete_user_workspaces(conn, workspace_id)?; Ok(()) } @@ -294,6 +332,7 @@ pub fn save_user_workspaces( user_workspace_table::name.eq(&user_workspace.name), user_workspace_table::created_at.eq(&user_workspace.created_at), user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), + user_workspace_table::icon.eq(&user_workspace.icon), )) .execute(conn) .and_then(|rows| { @@ -310,3 +349,16 @@ pub fn save_user_workspaces( Ok::<(), FlowyError>(()) }) } + +pub fn delete_user_workspaces(mut conn: DBConnection, workspace_id: &str) -> FlowyResult<()> { + let n = conn.immediate_transaction(|conn| { + let rows_affected: usize = + diesel::delete(user_workspace_table::table.filter(user_workspace_table::id.eq(workspace_id))) + .execute(conn)?; + Ok::(rows_affected) + })?; + if n != 1 { + warn!("expected to delete 1 row, but deleted {} rows", n); + } + Ok(()) +} diff --git a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs index 32f6968c4c8a2..dc1d158282330 100644 --- a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs +++ b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs @@ -11,7 +11,7 @@ use crate::module::AFPluginStateMap; use crate::runtime::AFPluginRuntime; use crate::{ errors::{DispatchError, Error, InternalError}, - module::{as_plugin_map, AFPlugin, AFPluginMap, AFPluginRequest}, + module::{plugin_map_or_crash, AFPlugin, AFPluginMap, AFPluginRequest}, response::AFPluginEventResponse, service::{AFPluginServiceFactory, Service}, }; @@ -87,7 +87,7 @@ impl AFPluginDispatcher { pub fn new(runtime: Arc, plugins: Vec) -> AFPluginDispatcher { tracing::trace!("{}", plugin_info(&plugins)); AFPluginDispatcher { - plugins: as_plugin_map(plugins), + plugins: plugin_map_or_crash(plugins), runtime, } } diff --git a/frontend/rust-lib/lib-dispatch/src/module/module.rs b/frontend/rust-lib/lib-dispatch/src/module/module.rs index 0eb162b515269..a5b2df234af8b 100644 --- a/frontend/rust-lib/lib-dispatch/src/module/module.rs +++ b/frontend/rust-lib/lib-dispatch/src/module/module.rs @@ -27,12 +27,16 @@ use crate::{ }; pub type AFPluginMap = Arc>>; -pub(crate) fn as_plugin_map(plugins: Vec) -> AFPluginMap { - let mut plugin_map = HashMap::new(); +pub(crate) fn plugin_map_or_crash(plugins: Vec) -> AFPluginMap { + let mut plugin_map: HashMap> = HashMap::new(); plugins.into_iter().for_each(|m| { let events = m.events(); let plugins = Arc::new(m); events.into_iter().for_each(|e| { + if plugin_map.contains_key(&e) { + let plugin_name = plugin_map.get(&e).map(|p| &p.name); + panic!("⚠️⚠️⚠️Error: {:?} is already defined in {:?}", &e, plugin_name,); + } plugin_map.insert(e, plugins.clone()); }); }); @@ -40,7 +44,7 @@ pub(crate) fn as_plugin_map(plugins: Vec) -> AFPluginMap { } #[derive(PartialEq, Eq, Hash, Debug, Clone)] -pub struct AFPluginEvent(pub String); +pub struct AFPluginEvent(String); impl std::convert::From for AFPluginEvent { fn from(t: T) -> Self { diff --git a/frontend/rust-lib/lib-infra/src/box_any.rs b/frontend/rust-lib/lib-infra/src/box_any.rs index 1822cd1a23c6f..c471e14735750 100644 --- a/frontend/rust-lib/lib-infra/src/box_any.rs +++ b/frontend/rust-lib/lib-infra/src/box_any.rs @@ -2,6 +2,7 @@ use std::any::Any; use anyhow::Result; +#[derive(Debug)] pub struct BoxAny(Box); impl BoxAny { @@ -12,6 +13,13 @@ impl BoxAny { Self(Box::new(value)) } + pub fn cloned(&self) -> Option + where + T: Clone + 'static, + { + self.0.downcast_ref::().cloned() + } + pub fn unbox_or_default(self) -> T where T: Default + 'static, diff --git a/frontend/rust-lib/lib-log/src/layer.rs b/frontend/rust-lib/lib-log/src/layer.rs index b8db7aeb54ca0..45f8e99001730 100644 --- a/frontend/rust-lib/lib-log/src/layer.rs +++ b/frontend/rust-lib/lib-log/src/layer.rs @@ -5,6 +5,7 @@ use serde_json::Value; use tracing::{Event, Id, Subscriber}; use tracing_bunyan_formatter::JsonStorage; use tracing_core::metadata::Level; +use tracing_core::span::Attributes; use tracing_subscriber::{fmt::MakeWriter, layer::Context, registry::SpanRef, Layer}; const LEVEL: &str = "level"; @@ -31,7 +32,7 @@ where pub fn new(make_writer: W) -> Self { Self { make_writer, - with_target: false, + with_target: true, phantom: std::marker::PhantomData, } } @@ -63,8 +64,8 @@ where map_serializer.serialize_entry("target", &span.metadata().target())?; } - map_serializer.serialize_entry("line", &span.metadata().line())?; - map_serializer.serialize_entry("file", &span.metadata().file())?; + // map_serializer.serialize_entry("line", &span.metadata().line())?; + // map_serializer.serialize_entry("file", &span.metadata().file())?; let extensions = span.extensions(); if let Some(visitor) = extensions.get::() { @@ -102,9 +103,9 @@ pub enum Type { impl fmt::Display for Type { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let repr = match self { - Type::EnterSpan => "Start", - Type::ExitSpan => "End", - Type::Event => "Event", + Type::EnterSpan => "START", + Type::ExitSpan => "END", + Type::Event => "EVENT", }; write!(f, "{}", repr) } @@ -113,19 +114,12 @@ impl fmt::Display for Type { fn format_span_context<'b, S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>>( span: &SpanRef<'b, S>, ty: Type, - context: &Context<'_, S>, + _: &Context<'_, S>, ) -> String { - match context.lookup_current() { - None => { - if matches!(ty, Type::EnterSpan) { - format!("[🟢 {} - {}]", span.metadata().name().to_uppercase(), ty) - } else { - format!("[🔵 {} - {}]", span.metadata().name().to_uppercase(), ty) - } - }, - Some(_) => { - format!("[{} - {}]", span.metadata().name().to_uppercase(), ty) - }, + if matches!(ty, Type::EnterSpan) { + format!("[🟢 {} - {}]", span.metadata().name().to_uppercase(), ty) + } else { + format!("[{} - {}]", span.metadata().name().to_uppercase(), ty) } } @@ -227,6 +221,13 @@ where } } + fn on_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 4e67b5c627056..01536fc37b838 100644 --- a/frontend/rust-lib/lib-log/src/lib.rs +++ b/frontend/rust-lib/lib-log/src/lib.rs @@ -7,7 +7,7 @@ use tracing_appender::rolling::Rotation; use tracing_appender::{non_blocking::WorkerGuard, rolling::RollingFileAppender}; use tracing_bunyan_formatter::JsonStorageLayer; use tracing_subscriber::fmt::format::Writer; -use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter}; +use tracing_subscriber::{layer::SubscriberExt, EnvFilter}; use crate::layer::FlowyFormattingLayer; @@ -48,22 +48,20 @@ impl Builder { pub fn build(self) -> Result<(), String> { let env_filter = EnvFilter::new(self.env_filter); - let std_out_layer = fmt::layer().with_writer(std::io::stdout).pretty(); + // let std_out_layer = std::fmt::layer().with_writer(std::io::stdout).pretty(); let (non_blocking, guard) = tracing_appender::non_blocking(self.file_appender); let file_layer = FlowyFormattingLayer::new(non_blocking); let subscriber = tracing_subscriber::fmt() .with_timer(CustomTime) .with_ansi(true) - .with_target(false) .with_max_level(tracing::Level::TRACE) .with_thread_ids(false) .pretty() .with_env_filter(env_filter) .finish() .with(JsonStorageLayer) - .with(file_layer) - .with(std_out_layer); + .with(file_layer); set_global_default(subscriber).map_err(|e| format!("{:?}", e))?; diff --git a/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh b/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh index f20f1b72c388e..30538def964ac 100755 --- a/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh +++ b/frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh @@ -1,13 +1,5 @@ #!/bin/bash -no_pub_get=false - -while getopts 's' flag; do - case "${flag}" in - s) no_pub_get=true ;; - esac -done - echo "Generating flowy icon files" # Store the current working directory diff --git a/frontend/scripts/code_generation/freezed/generate_freezed.sh b/frontend/scripts/code_generation/freezed/generate_freezed.sh index 1cf5f0fe497eb..24c90650d2569 100755 --- a/frontend/scripts/code_generation/freezed/generate_freezed.sh +++ b/frontend/scripts/code_generation/freezed/generate_freezed.sh @@ -1,13 +1,5 @@ #!/bin/bash -no_pub_get=false - -while getopts 's' flag; do - case "${flag}" in - s) no_pub_get=true ;; - esac -done - # Store the current working directory original_dir=$(pwd) @@ -19,9 +11,7 @@ cd ../../../appflowy_flutter # Navigate to the appflowy_flutter directory and generate files echo "Generating files for appflowy_flutter" -if [ "$no_pub_get" = false ]; then - flutter packages pub get >/dev/null 2>&1 -fi +flutter packages pub get >/dev/null 2>&1 dart run build_runner build -d echo "Done generating files for appflowy_flutter" @@ -36,9 +26,7 @@ for d in */; do if [ -f "pubspec.yaml" ]; then echo "Generating freezed files in $d..." echo "Please wait while we clean the project and fetch the dependencies." - if [ "$no_pub_get" = false ]; then - flutter packages pub get >/dev/null 2>&1 - fi + flutter packages pub get >/dev/null 2>&1 dart run build_runner build -d echo "Done running build command in $d" else diff --git a/frontend/scripts/code_generation/language_files/generate_language_files.sh b/frontend/scripts/code_generation/language_files/generate_language_files.sh index 41abe4217a67e..8aa403d1f25c4 100755 --- a/frontend/scripts/code_generation/language_files/generate_language_files.sh +++ b/frontend/scripts/code_generation/language_files/generate_language_files.sh @@ -1,13 +1,5 @@ #!/bin/bash -no_pub_get=false - -while getopts 's' flag; do - case "${flag}" in - s) no_pub_get=true ;; - esac -done - echo "Generating language files" # Store the current working directory @@ -24,6 +16,9 @@ rm -rf assets/translations/ mkdir -p assets/translations/ cp -f ../resources/translations/*.json assets/translations/ +# the ci alwayas return a 'null check operator used on a null value' error. +# so we force to exec the below command to avoid the error. +# https://github.com/dart-lang/pub/issues/3314 flutter pub get flutter packages pub get diff --git a/frontend/scripts/docker-buildfiles/Dockerfile b/frontend/scripts/docker-buildfiles/Dockerfile index e9f73d3d0c867..0624ec053b52c 100644 --- a/frontend/scripts/docker-buildfiles/Dockerfile +++ b/frontend/scripts/docker-buildfiles/Dockerfile @@ -21,10 +21,10 @@ WORKDIR /home/$user RUN sudo pacman -S --needed --noconfirm curl tar RUN curl -sSfL \ --output yay.tar.gz \ - https://github.com/Jguer/yay/releases/download/v12.0.2/yay_12.0.2_x86_64.tar.gz && \ + https://github.com/Jguer/yay/releases/download/v12.3.3/yay_12.3.3_x86_64.tar.gz && \ tar -xf yay.tar.gz && \ - sudo mv yay_12.0.2_x86_64/yay /bin && \ - rm -rf yay_12.0.2_x86_64 && \ + sudo mv yay_12.3.3_x86_64/yay /bin && \ + rm -rf yay_12.3.3_x86_64 && \ yay --version # Install Rust diff --git a/frontend/scripts/makefile/flutter.toml b/frontend/scripts/makefile/flutter.toml index 6e6c8957c58c0..4203ce678d457 100644 --- a/frontend/scripts/makefile/flutter.toml +++ b/frontend/scripts/makefile/flutter.toml @@ -82,10 +82,10 @@ run_task = { name = [ script_runner = "@shell" [tasks.appflowy-android-dev-ci] -dependencies = ["appflowy-core-dev-android"] +dependencies = ["appflowy-core-dev-android-ci"] run_task = { name = [ "code_generation", - "flutter-build-android", + "flutter-build-android-ci", ] } script_runner = "@shell" diff --git a/frontend/scripts/makefile/mobile.toml b/frontend/scripts/makefile/mobile.toml index 62214d7541f7a..8e89e4c2eddb0 100644 --- a/frontend/scripts/makefile/mobile.toml +++ b/frontend/scripts/makefile/mobile.toml @@ -27,11 +27,11 @@ script = [ """ cd rust-lib/ rustup show - if [ "${BUILD_FLAG}" == "debug" ]; then - echo "🚀 🚀 🚀 Building for debug" + if [ "${BUILD_FLAG}" = "debug" ]; then + echo "🚀 🚀 🚀 Building iOS SDK for debug" cargo lipo --targets ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi else - echo "🚀 🚀 🚀 Building for release" + echo "🚀 🚀 🚀 Building iOS SDK for release" cargo lipo --release --targets ${RUST_COMPILE_TARGET} --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi fi cd ../ @@ -49,25 +49,48 @@ run_task = { name = [ "restore-crate-type", ] } +# only use in CI job +[tasks.appflowy-core-dev-android-ci] +category = "Build" +dependencies = ["env_check", "set-app-version"] +run_task = { name = [ + "setup-crate-type", + "sdk-build-android-ci", + "post-mobile-android", + "restore-crate-type", +] } + [tasks.sdk-build-android] dependencies = ["set-app-version"] private = true script = [ """ cd rust-lib/ - rustup show if [ "${BUILD_FLAG}" = "debug" ]; then - echo "🚀 🚀 🚀 Building for debug" - cargo ndk -t arm64-v8a -t x86_64 -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi + echo "🚀 🚀 🚀 Building Android SDK for debug" + cargo ndk -t arm64-v8a -t armeabi-v7a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi else - echo "🚀 🚀 🚀 Building for release" - cargo ndk -t arm64-v8a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi --release + echo "🚀 🚀 🚀 Building Android SDK for release" + cargo ndk -t arm64-v8a -t armeabi-v7a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi --release fi cd ../ """, ] script_runner = "@shell" +# only use in CI job +[tasks.sdk-build-android-ci] +dependencies = ["set-app-version"] +private = true +script = [ + """ + cd rust-lib/ + cargo ndk -t arm64-v8a -t armeabi-v7a -o ./jniLibs build --features "${FLUTTER_DESKTOP_FEATURES}" --package=dart-ffi + cd ../ + """, +] +script_runner = "@shell" + [tasks.post-mobile-ios] private = true script = [ @@ -76,6 +99,9 @@ script = [ dart_ffi_dir= set ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/appflowy_flutter/packages/appflowy_backend/${TARGET_OS} lib = set lib${LIB_NAME}.${LIB_EXT} + ls -a ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/target/${RUST_COMPILE_TARGET}/${BUILD_FLAG} + + echo "💻 💻 💻 Copying ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/target/${RUST_COMPILE_TARGET}/${BUILD_FLAG}/${lib} to ${dart_ffi_dir}/${lib}" rm -f ${dart_ffi_dir}/${lib} cp ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/target/${RUST_COMPILE_TARGET}/${BUILD_FLAG}/${lib} \ diff --git a/project.inlang.json b/project.inlang.json index 2870eb1c71305..bacd225238ead 100644 --- a/project.inlang.json +++ b/project.inlang.json @@ -5,9 +5,11 @@ "en", "ar-SA", "ca-ES", + "cs-CZ", "de-DE", "es-VE", "eu-ES", + "el-GR", "fa", "fr-CA", "fr-FR", diff --git a/project.inlang/settings.json b/project.inlang/settings.json index ce10fe3f7b659..20f6e900769de 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -5,6 +5,7 @@ "en", "ar-SA", "ca-ES", + "cs-CZ", "de-DE", "es-VE", "eu-ES", @@ -40,4 +41,4 @@ "@:" ] } -} +} \ No newline at end of file