mirror of
https://github.com/enso-org/enso.git
synced 2024-09-19 09:07:28 +03:00
Merge branch 'develop' into wip/akirathan/10157-lang-server-module
# Conflicts: # build.sbt # engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala # engine/runtime-instrument-repl-debugger/src/main/java/org/enso/interpreter/instrument/repl/debugger/ReplDebuggerInstrument.java # project/JPMSPlugin.scala # project/JPMSUtils.scala
This commit is contained in:
commit
595028cf40
45
.github/workflows/engine-benchmark.yml
vendored
45
.github/workflows/engine-benchmark.yml
vendored
@ -58,51 +58,6 @@ jobs:
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
timeout-minutes: 240
|
||||
benchmark-engine-oracle-graal-vm:
|
||||
name: Benchmark Engine (Oracle GraalVM)
|
||||
runs-on:
|
||||
- benchmark
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
uses: jetli/wasm-pack-action@v0.4.0
|
||||
with:
|
||||
version: v0.10.2
|
||||
- name: Expose Artifact API and context information.
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n "
|
||||
- name: Checking out the repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: recursive
|
||||
- name: Build Script Setup
|
||||
run: ./run --help || (git clean -ffdx && ./run --help)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- if: (always())
|
||||
name: Clean before
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: ./run backend benchmark runtime
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- if: failure() && runner.os == 'Windows'
|
||||
name: List files if failed (Windows)
|
||||
run: Get-ChildItem -Force -Recurse
|
||||
- if: failure() && runner.os != 'Windows'
|
||||
name: List files if failed (non-Windows)
|
||||
run: ls -lAR
|
||||
- if: (always())
|
||||
name: Clean after
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: Oracle GraalVM
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
ENSO_BUILD_MINIMAL_RUN: ${{ true == inputs.just-check }}
|
||||
ENSO_BUILD_SKIP_VERSION_CHECK: "true"
|
||||
|
64
.github/workflows/engine-nightly.yml
vendored
64
.github/workflows/engine-nightly.yml
vendored
@ -13,8 +13,8 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
jobs:
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-x86_64:
|
||||
name: Engine (GraalVM CE) (linux, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-amd64:
|
||||
name: Engine (GraalVM CE) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -104,8 +104,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-x86_64:
|
||||
name: Engine (GraalVM CE) (macos, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-amd64:
|
||||
name: Engine (GraalVM CE) (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -148,8 +148,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-x86_64:
|
||||
name: Engine (GraalVM CE) (windows, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-amd64:
|
||||
name: Engine (GraalVM CE) (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -193,8 +193,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
enso-build-ci-gen-job-ci-check-backend-oracle-graal-vm-linux-x86_64:
|
||||
name: Engine (Oracle GraalVM) (linux, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-oracle-graal-vm-linux-amd64:
|
||||
name: Engine (Oracle GraalVM) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -238,8 +238,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: Oracle GraalVM
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (linux, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-amd64:
|
||||
name: JVM Tests (GraalVM CE) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -275,7 +275,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (GraalVM CE, linux, x86_64)
|
||||
name: Engine Tests Report (GraalVM CE, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -351,8 +351,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (macos, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-amd64:
|
||||
name: JVM Tests (GraalVM CE) (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -387,7 +387,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (GraalVM CE, macos, x86_64)
|
||||
name: Engine Tests Report (GraalVM CE, macos, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -406,8 +406,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (windows, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-amd64:
|
||||
name: JVM Tests (GraalVM CE) (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -443,7 +443,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (GraalVM CE, windows, x86_64)
|
||||
name: Engine Tests Report (GraalVM CE, windows, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -462,8 +462,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-jvm-tests-oracle-graal-vm-linux-x86_64:
|
||||
name: JVM Tests (Oracle GraalVM) (linux, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-oracle-graal-vm-linux-amd64:
|
||||
name: JVM Tests (Oracle GraalVM) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -499,7 +499,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (Oracle GraalVM, linux, x86_64)
|
||||
name: Engine Tests Report (Oracle GraalVM, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -518,8 +518,8 @@ jobs:
|
||||
GRAAL_EDITION: Oracle GraalVM
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (linux, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-amd64:
|
||||
name: Standard Library Tests (GraalVM CE) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -558,7 +558,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (GraalVM CE, linux, x86_64)
|
||||
name: Standard Library Tests Report (GraalVM CE, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -637,8 +637,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (macos, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-amd64:
|
||||
name: Standard Library Tests (GraalVM CE) (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -676,7 +676,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (GraalVM CE, macos, x86_64)
|
||||
name: Standard Library Tests Report (GraalVM CE, macos, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -695,8 +695,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (windows, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-amd64:
|
||||
name: Standard Library Tests (GraalVM CE) (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -735,7 +735,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (GraalVM CE, windows, x86_64)
|
||||
name: Standard Library Tests Report (GraalVM CE, windows, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -754,8 +754,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-oracle-graal-vm-linux-x86_64:
|
||||
name: Standard Library Tests (Oracle GraalVM) (linux, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-oracle-graal-vm-linux-amd64:
|
||||
name: Standard Library Tests (Oracle GraalVM) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -794,7 +794,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (Oracle GraalVM, linux, x86_64)
|
||||
name: Standard Library Tests Report (Oracle GraalVM, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
|
6
.github/workflows/extra-nightly-tests.yml
vendored
6
.github/workflows/extra-nightly-tests.yml
vendored
@ -13,8 +13,8 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
jobs:
|
||||
enso-build-ci-gen-job-snowflake-tests-linux-x86_64:
|
||||
name: Snowflake Tests (linux, x86_64)
|
||||
enso-build-ci-gen-job-snowflake-tests-linux-amd64:
|
||||
name: Snowflake Tests (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Extra Library Tests Report (GraalVM CE, linux, x86_64)
|
||||
name: Extra Library Tests Report (GraalVM CE, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
|
18
.github/workflows/gui-tests.yml
vendored
18
.github/workflows/gui-tests.yml
vendored
@ -15,7 +15,7 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
jobs:
|
||||
enso-build-ci-gen-job-cancel-workflow-linux-x86_64:
|
||||
enso-build-ci-gen-job-cancel-workflow-linux-amd64:
|
||||
name: Cancel Previous Runs
|
||||
if: github.ref != 'refs/heads/develop'
|
||||
runs-on:
|
||||
@ -27,8 +27,8 @@ jobs:
|
||||
access_token: ${{ github.token }}
|
||||
permissions:
|
||||
actions: write
|
||||
enso-build-ci-gen-job-gui-check-linux-x86_64:
|
||||
name: GUI tests (linux, x86_64)
|
||||
enso-build-ci-gen-job-gui-check-linux-amd64:
|
||||
name: GUI tests (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -70,8 +70,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-lint-linux-x86_64:
|
||||
name: Lint (linux, x86_64)
|
||||
enso-build-ci-gen-job-lint-linux-amd64:
|
||||
name: Lint (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -113,8 +113,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-native-test-linux-x86_64:
|
||||
name: Native Rust tests (linux, x86_64)
|
||||
enso-build-ci-gen-job-native-test-linux-amd64:
|
||||
name: Native Rust tests (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -156,8 +156,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-wasm-test-linux-x86_64:
|
||||
name: WASM tests (linux, x86_64)
|
||||
enso-build-ci-gen-job-wasm-test-linux-amd64:
|
||||
name: WASM tests (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
|
44
.github/workflows/gui.yml
vendored
44
.github/workflows/gui.yml
vendored
@ -15,8 +15,8 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
jobs:
|
||||
enso-build-ci-gen-job-build-backend-linux-x86_64:
|
||||
name: Build Backend (linux, x86_64)
|
||||
enso-build-ci-gen-job-build-backend-linux-amd64:
|
||||
name: Build Backend (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -58,8 +58,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-build-backend-macos-x86_64:
|
||||
name: Build Backend (macos, x86_64)
|
||||
enso-build-ci-gen-job-build-backend-macos-amd64:
|
||||
name: Build Backend (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -100,8 +100,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-build-backend-windows-x86_64:
|
||||
name: Build Backend (windows, x86_64)
|
||||
enso-build-ci-gen-job-build-backend-windows-amd64:
|
||||
name: Build Backend (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -143,7 +143,7 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-cancel-workflow-linux-x86_64:
|
||||
enso-build-ci-gen-job-cancel-workflow-linux-amd64:
|
||||
name: Cancel Previous Runs
|
||||
if: github.ref != 'refs/heads/develop'
|
||||
runs-on:
|
||||
@ -155,8 +155,8 @@ jobs:
|
||||
access_token: ${{ github.token }}
|
||||
permissions:
|
||||
actions: write
|
||||
enso-build-ci-gen-job-gui-build-linux-x86_64:
|
||||
name: GUI build (linux, x86_64)
|
||||
enso-build-ci-gen-job-gui-build-linux-amd64:
|
||||
name: GUI build (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -210,8 +210,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-gui-build-macos-x86_64:
|
||||
name: GUI build (macos, x86_64)
|
||||
enso-build-ci-gen-job-gui-build-macos-amd64:
|
||||
name: GUI build (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -264,8 +264,8 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-gui-build-windows-x86_64:
|
||||
name: GUI build (windows, x86_64)
|
||||
enso-build-ci-gen-job-gui-build-windows-amd64:
|
||||
name: GUI build (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -319,10 +319,10 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-package-ide-linux-x86_64:
|
||||
name: Package New IDE (linux, x86_64)
|
||||
enso-build-ci-gen-job-package-ide-linux-amd64:
|
||||
name: Package New IDE (linux, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-job-build-backend-linux-x86_64
|
||||
- enso-build-ci-gen-job-build-backend-linux-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -376,10 +376,10 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-package-ide-macos-x86_64:
|
||||
name: Package New IDE (macos, x86_64)
|
||||
enso-build-ci-gen-job-package-ide-macos-amd64:
|
||||
name: Package New IDE (macos, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-job-build-backend-macos-x86_64
|
||||
- enso-build-ci-gen-job-build-backend-macos-amd64
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -439,10 +439,10 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-package-ide-windows-x86_64:
|
||||
name: Package New IDE (windows, x86_64)
|
||||
enso-build-ci-gen-job-package-ide-windows-amd64:
|
||||
name: Package New IDE (windows, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-job-build-backend-windows-x86_64
|
||||
- enso-build-ci-gen-job-build-backend-windows-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
|
8
.github/workflows/promote.yml
vendored
8
.github/workflows/promote.yml
vendored
@ -21,8 +21,8 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
jobs:
|
||||
enso-build-ci-gen-promote-release-job-linux-x86_64:
|
||||
name: Promote release (linux, x86_64)
|
||||
enso-build-ci-gen-promote-release-job-linux-amd64:
|
||||
name: Promote release (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -72,10 +72,10 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
needs:
|
||||
- enso-build-ci-gen-promote-release-job-linux-x86_64
|
||||
- enso-build-ci-gen-promote-release-job-linux-amd64
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
version: ${{ needs.enso-build-ci-gen-promote-release-job-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
version: ${{ needs.enso-build-ci-gen-promote-release-job-linux-amd64.outputs.ENSO_VERSION }}
|
||||
secrets: inherit
|
||||
env:
|
||||
ENSO_BUILD_SKIP_VERSION_CHECK: "true"
|
||||
|
110
.github/workflows/release.yml
vendored
110
.github/workflows/release.yml
vendored
@ -16,7 +16,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
jobs:
|
||||
enso-build-ci-gen-draft-release-linux-x86_64:
|
||||
enso-build-ci-gen-draft-release-linux-amd64:
|
||||
name: Create a release draft.
|
||||
runs-on:
|
||||
- self-hosted
|
||||
@ -47,11 +47,11 @@ jobs:
|
||||
outputs:
|
||||
ENSO_RELEASE_ID: ${{ steps.prepare.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ steps.prepare.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-deploy-runtime-linux-x86_64:
|
||||
name: Upload Runtime to ECR (linux, x86_64)
|
||||
enso-build-ci-gen-job-deploy-runtime-linux-amd64:
|
||||
name: Upload Runtime to ECR (linux, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-job-upload-backend-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
- enso-build-ci-gen-job-upload-backend-linux-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -98,12 +98,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-linux-x86_64:
|
||||
name: Upload Backend (linux, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-linux-amd64:
|
||||
name: Upload Backend (linux, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -146,12 +146,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-macos-aarch64:
|
||||
name: Upload Backend (macos, aarch64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- macOS
|
||||
@ -195,12 +195,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-macos-x86_64:
|
||||
name: Upload Backend (macos, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-macos-amd64:
|
||||
name: Upload Backend (macos, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -242,12 +242,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-windows-x86_64:
|
||||
name: Upload Backend (windows, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-job-upload-backend-windows-amd64:
|
||||
name: Upload Backend (windows, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -290,17 +290,17 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-publish-release-linux-x86_64:
|
||||
name: Publish release (linux, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-publish-release-linux-amd64:
|
||||
name: Publish release (linux, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-job-deploy-runtime-linux-x86_64
|
||||
- enso-build-ci-gen-upload-ide-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
- enso-build-ci-gen-job-deploy-runtime-linux-amd64
|
||||
- enso-build-ci-gen-upload-ide-linux-amd64
|
||||
- enso-build-ci-gen-upload-ide-macos-aarch64
|
||||
- enso-build-ci-gen-upload-ide-macos-x86_64
|
||||
- enso-build-ci-gen-upload-ide-windows-x86_64
|
||||
- enso-build-ci-gen-upload-ide-macos-amd64
|
||||
- enso-build-ci-gen-upload-ide-windows-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -346,13 +346,13 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_S3_ACCESS_KEY_ID }}
|
||||
AWS_REGION: us-west-1
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_S3_SECRET_ACCESS_KEY }}
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-linux-x86_64:
|
||||
name: Build IDE (linux, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-linux-amd64:
|
||||
name: Build IDE (linux, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-job-upload-backend-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
- enso-build-ci-gen-job-upload-backend-linux-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -407,12 +407,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-macos-aarch64:
|
||||
name: Build IDE (macos, aarch64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
- enso-build-ci-gen-job-upload-backend-macos-aarch64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
@ -476,13 +476,13 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-macos-x86_64:
|
||||
name: Build IDE (macos, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-macos-amd64:
|
||||
name: Build IDE (macos, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-job-upload-backend-macos-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
- enso-build-ci-gen-job-upload-backend-macos-amd64
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -543,13 +543,13 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-windows-x86_64:
|
||||
name: Build IDE (windows, x86_64)
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
enso-build-ci-gen-upload-ide-windows-amd64:
|
||||
name: Build IDE (windows, amd64)
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-job-upload-backend-windows-x86_64
|
||||
- enso-build-ci-gen-draft-release-linux-amd64
|
||||
- enso-build-ci-gen-job-upload-backend-windows-amd64
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -606,8 +606,8 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-x86_64.outputs.ENSO_VERSION }}
|
||||
ENSO_RELEASE_ID: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_RELEASE_ID }}
|
||||
ENSO_VERSION: ${{ needs.enso-build-ci-gen-draft-release-linux-amd64.outputs.ENSO_VERSION }}
|
||||
env:
|
||||
ENSO_BUILD_SKIP_VERSION_CHECK: "true"
|
||||
ENSO_EDITION: ${{ inputs.version }}
|
||||
|
54
.github/workflows/scala-new.yml
vendored
54
.github/workflows/scala-new.yml
vendored
@ -15,7 +15,7 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
jobs:
|
||||
enso-build-ci-gen-job-cancel-workflow-linux-x86_64:
|
||||
enso-build-ci-gen-job-cancel-workflow-linux-amd64:
|
||||
name: Cancel Previous Runs
|
||||
if: github.ref != 'refs/heads/develop'
|
||||
runs-on:
|
||||
@ -27,8 +27,8 @@ jobs:
|
||||
access_token: ${{ github.token }}
|
||||
permissions:
|
||||
actions: write
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-x86_64:
|
||||
name: Engine (GraalVM CE) (linux, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-linux-amd64:
|
||||
name: Engine (GraalVM CE) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -72,8 +72,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-x86_64:
|
||||
name: Engine (GraalVM CE) (macos, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-amd64:
|
||||
name: Engine (GraalVM CE) (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -116,8 +116,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-x86_64:
|
||||
name: Engine (GraalVM CE) (windows, x86_64)
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-windows-amd64:
|
||||
name: Engine (GraalVM CE) (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -161,8 +161,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (linux, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-linux-amd64:
|
||||
name: JVM Tests (GraalVM CE) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -198,7 +198,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (GraalVM CE, linux, x86_64)
|
||||
name: Engine Tests Report (GraalVM CE, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -217,8 +217,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (macos, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-amd64:
|
||||
name: JVM Tests (GraalVM CE) (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -253,7 +253,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (GraalVM CE, macos, x86_64)
|
||||
name: Engine Tests Report (GraalVM CE, macos, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -272,8 +272,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (windows, x86_64)
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-windows-amd64:
|
||||
name: JVM Tests (GraalVM CE) (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -309,7 +309,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Engine Tests Report (GraalVM CE, windows, x86_64)
|
||||
name: Engine Tests Report (GraalVM CE, windows, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -328,8 +328,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (linux, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-linux-amd64:
|
||||
name: Standard Library Tests (GraalVM CE) (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
@ -368,7 +368,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (GraalVM CE, linux, x86_64)
|
||||
name: Standard Library Tests Report (GraalVM CE, linux, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -387,8 +387,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (macos, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-amd64:
|
||||
name: Standard Library Tests (GraalVM CE) (macos, amd64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
steps:
|
||||
@ -426,7 +426,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (GraalVM CE, macos, x86_64)
|
||||
name: Standard Library Tests Report (GraalVM CE, macos, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -445,8 +445,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (windows, x86_64)
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-windows-amd64:
|
||||
name: Standard Library Tests (GraalVM CE) (windows, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Windows
|
||||
@ -485,7 +485,7 @@ jobs:
|
||||
uses: dorny/test-reporter@v1
|
||||
with:
|
||||
max-annotations: 50
|
||||
name: Standard Library Tests Report (GraalVM CE, windows, x86_64)
|
||||
name: Standard Library Tests Report (GraalVM CE, windows, amd64)
|
||||
path: ${{ env.ENSO_TEST_JUNIT_DIR }}/*/*.xml
|
||||
path-replace-backslashes: true
|
||||
reporter: java-junit
|
||||
@ -504,8 +504,8 @@ jobs:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
permissions:
|
||||
checks: write
|
||||
enso-build-ci-gen-job-verify-license-packages-linux-x86_64:
|
||||
name: Verify License Packages (linux, x86_64)
|
||||
enso-build-ci-gen-job-verify-license-packages-linux-amd64:
|
||||
name: Verify License Packages (linux, amd64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- Linux
|
||||
|
45
.github/workflows/std-libs-benchmark.yml
vendored
45
.github/workflows/std-libs-benchmark.yml
vendored
@ -58,51 +58,6 @@ jobs:
|
||||
env:
|
||||
GRAAL_EDITION: GraalVM CE
|
||||
timeout-minutes: 240
|
||||
benchmark-standard-libraries-oracle-graal-vm:
|
||||
name: Benchmark Standard Libraries (Oracle GraalVM)
|
||||
runs-on:
|
||||
- benchmark
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
uses: jetli/wasm-pack-action@v0.4.0
|
||||
with:
|
||||
version: v0.10.2
|
||||
- name: Expose Artifact API and context information.
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: "\n core.exportVariable(\"ACTIONS_RUNTIME_TOKEN\", process.env[\"ACTIONS_RUNTIME_TOKEN\"])\n core.exportVariable(\"ACTIONS_RUNTIME_URL\", process.env[\"ACTIONS_RUNTIME_URL\"])\n core.exportVariable(\"GITHUB_RETENTION_DAYS\", process.env[\"GITHUB_RETENTION_DAYS\"])\n console.log(context)\n "
|
||||
- name: Checking out the repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: false
|
||||
submodules: recursive
|
||||
- name: Build Script Setup
|
||||
run: ./run --help || (git clean -ffdx && ./run --help)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- if: (always())
|
||||
name: Clean before
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: ./run backend benchmark enso-jmh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- if: failure() && runner.os == 'Windows'
|
||||
name: List files if failed (Windows)
|
||||
run: Get-ChildItem -Force -Recurse
|
||||
- if: failure() && runner.os != 'Windows'
|
||||
name: List files if failed (non-Windows)
|
||||
run: ls -lAR
|
||||
- if: (always())
|
||||
name: Clean after
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
GRAAL_EDITION: Oracle GraalVM
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
ENSO_BUILD_MINIMAL_RUN: ${{ true == inputs.just-check }}
|
||||
ENSO_BUILD_SKIP_VERSION_CHECK: "true"
|
||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@ -3,8 +3,21 @@
|
||||
#### Enso IDE
|
||||
|
||||
- [Table Editor Widget][10774] displayed in `Table.new` component.
|
||||
- [New design of Component Browser][10814] - the component list is under the
|
||||
input and shown only in the initial "component browsing" mode - in this mode
|
||||
the entire input is a filtering pattern (it is not interpreted as parts of
|
||||
code). After picking any suggestion with Tab or new button the mode is
|
||||
switched to "code editing", where visualization preview is displayed instead.
|
||||
- [Drilldown for XML][10824]
|
||||
- [Fixed issue where switching edited widget with <kbd>tab</kbd> key did not
|
||||
updated actual code][10857]
|
||||
- [Added fullscreen modes to documentation editor and code editor][10876]
|
||||
|
||||
[10774]: https://github.com/enso-org/enso/pull/10774
|
||||
[10814]: https://github.com/enso-org/enso/pull/10814
|
||||
[10824]: https://github.com/enso-org/enso/pull/10824
|
||||
[10857]: https://github.com/enso-org/enso/pull/10857
|
||||
[10876]: https://github.com/enso-org/enso/pull/10876
|
||||
|
||||
#### Enso Standard Library
|
||||
|
||||
@ -17,6 +30,10 @@
|
||||
- [Mixed Decimal/Float arithmetic now throws an error; mixed comparisons now
|
||||
attach warnings.][10725]
|
||||
- [Support for creating Atoms in expressions.][10820]
|
||||
- [IO.print without new line][10858]
|
||||
- [Add `Text.to_decimal`.][10874]
|
||||
- [Added .floor, .ceil, .trunc to the in-memory `Decimal` column.][10887]
|
||||
- [Added vectorized .round to the in-memory `Decimal` column.][10912]
|
||||
|
||||
[10614]: https://github.com/enso-org/enso/pull/10614
|
||||
[10660]: https://github.com/enso-org/enso/pull/10660
|
||||
@ -24,8 +41,18 @@
|
||||
[10733]: https://github.com/enso-org/enso/pull/10733
|
||||
[10725]: https://github.com/enso-org/enso/pull/10725
|
||||
[10820]: https://github.com/enso-org/enso/pull/10820
|
||||
[10858]: https://github.com/enso-org/enso/pull/10858
|
||||
[10874]: https://github.com/enso-org/enso/pull/10874
|
||||
[10887]: https://github.com/enso-org/enso/pull/10887
|
||||
[10912]: https://github.com/enso-org/enso/pull/10912
|
||||
|
||||
# Enso 2023.3
|
||||
#### Enso Language & Runtime
|
||||
|
||||
- [Print out warnings associated with local variables][10842]
|
||||
|
||||
[10842]: https://github.com/enso-org/enso/pull/10842
|
||||
|
||||
# Enso 2024.3
|
||||
|
||||
#### Enso Language & Runtime
|
||||
|
||||
|
4
Cargo.lock
generated
4
Cargo.lock
generated
@ -1205,6 +1205,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap 4.5.15",
|
||||
"convert_case",
|
||||
"derive-where",
|
||||
"derive_more",
|
||||
"dirs",
|
||||
@ -1216,7 +1217,6 @@ dependencies = [
|
||||
"futures",
|
||||
"glob",
|
||||
"handlebars",
|
||||
"heck 0.4.1",
|
||||
"ide-ci",
|
||||
"mime",
|
||||
"new_mime_guess",
|
||||
@ -2186,6 +2186,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap 4.5.15",
|
||||
"convert_case",
|
||||
"data-encoding",
|
||||
"dependency_runner",
|
||||
"derive-where",
|
||||
@ -2200,7 +2201,6 @@ dependencies = [
|
||||
"futures-util",
|
||||
"glob",
|
||||
"headers",
|
||||
"heck 0.4.1",
|
||||
"http-serde",
|
||||
"indicatif",
|
||||
"itertools",
|
||||
|
@ -92,7 +92,7 @@ blocks_in_conditions = "allow" # Until the issue is fixed: https://github.com/ru
|
||||
# We are tryingto maintain minimum set of dependencies. Before adding a new dependency, consult it
|
||||
# with the core development team. Thank you!
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
clap = { version = "4.5.4", features = ["derive", "env", "wrap_help", "string"] }
|
||||
clap = { version = "4.5.15", features = ["derive", "env", "wrap_help", "string"] }
|
||||
derive-where = "1.2.7"
|
||||
directories = { version = "5.0.1" }
|
||||
dirs = { version = "5.0.1" }
|
||||
|
48
app/.vscode/launch.json
vendored
48
app/.vscode/launch.json
vendored
@ -5,88 +5,88 @@
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-dashboard", "dev"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "dev"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["dev:gui"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (Storybook)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "story:dev"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (Build)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "build:cloud"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["build:gui"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (E2E UI)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:e2e:debug"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (E2E UI)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:e2e", "--", "--ui"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (All tests)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (E2E tests)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:e2e"],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (Unit tests)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:unit"],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:unit"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (All tests)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "test"]
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (E2E tests)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:e2e"],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (Unit tests)",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:unit", "--", "run"],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:unit", "--", "run"],
|
||||
"outputCapture": "std"
|
||||
}
|
||||
]
|
||||
|
@ -65,12 +65,12 @@ export function locateNewLabelModalColorButtons(page: test.Page) {
|
||||
|
||||
/** Find a "name" input for an "upsert secret" modal (if any) on the current page. */
|
||||
export function locateSecretNameInput(page: test.Page) {
|
||||
return locateUpsertSecretModal(page).getByPlaceholder('Enter the name of the secret')
|
||||
return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder)
|
||||
}
|
||||
|
||||
/** Find a "value" input for an "upsert secret" modal (if any) on the current page. */
|
||||
export function locateSecretValueInput(page: test.Page) {
|
||||
return locateUpsertSecretModal(page).getByPlaceholder('Enter the value of the secret')
|
||||
return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder)
|
||||
}
|
||||
|
||||
/** Find a search bar input (if any) on the current page. */
|
||||
@ -100,16 +100,6 @@ export function locateSetUsernameButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username')
|
||||
}
|
||||
|
||||
/** Find a "delete" button (if any) on the current page. */
|
||||
export function locateDeleteButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Delete' }).getByText('Delete')
|
||||
}
|
||||
|
||||
/** Find a button to delete something (if any) on the current page. */
|
||||
export function locateDeleteIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Delete')
|
||||
}
|
||||
|
||||
/** Find a "create" button (if any) on the current page. */
|
||||
export function locateCreateButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Create' }).getByText('Create')
|
||||
@ -765,7 +755,7 @@ export async function login(
|
||||
await locateLoginButton(page).click()
|
||||
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
|
||||
if (first) {
|
||||
await passTermsAndConditionsDialog({ page, setupAPI })
|
||||
await passAgreementsDialog({ page, setupAPI })
|
||||
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
@ -840,11 +830,12 @@ async function mockDate({ page }: MockParams) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Pass the Terms and conditions dialog. */
|
||||
export async function passTermsAndConditionsDialog({ page }: MockParams) {
|
||||
/** Pass the Agreements dialog. */
|
||||
export async function passAgreementsDialog({ page }: MockParams) {
|
||||
await test.test.step('Accept Terms and Conditions', async () => {
|
||||
await page.waitForSelector('#terms-of-service-modal')
|
||||
await page.getByRole('checkbox').click()
|
||||
await page.waitForSelector('#agreements-modal')
|
||||
await page.getByRole('checkbox').and(page.getByTestId('terms-of-service-checkbox')).click()
|
||||
await page.getByRole('checkbox').and(page.getByTestId('privacy-policy-checkbox')).click()
|
||||
await page.getByRole('button', { name: 'Accept' }).click()
|
||||
})
|
||||
}
|
||||
|
@ -174,7 +174,7 @@ export default class DrivePageActions extends PageActions {
|
||||
await test.expect(locateAssetRows(page)).toHaveCount(0)
|
||||
const nonAssetRows = locateNonAssetRows(page)
|
||||
await test.expect(nonAssetRows).toHaveCount(1)
|
||||
await test.expect(nonAssetRows).toHaveText(/You have no files/)
|
||||
await test.expect(nonAssetRows).toHaveText(/This folder is empty/)
|
||||
})
|
||||
},
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
|
@ -1,6 +1,7 @@
|
||||
/** @file Actions for a "new Data Link" modal. */
|
||||
import type * as test from 'playwright/test'
|
||||
|
||||
import { TEXT } from '../actions'
|
||||
import type * as baseActions from './BaseActions'
|
||||
import BaseActions from './BaseActions'
|
||||
import DrivePageActions from './DrivePageActions'
|
||||
@ -11,7 +12,7 @@ import DrivePageActions from './DrivePageActions'
|
||||
|
||||
/** Locate the "new data link" modal. */
|
||||
function locateNewDataLinkModal(page: test.Page) {
|
||||
return page.getByRole('heading').and(page.getByText('Create Datalink')).locator('..')
|
||||
return page.getByRole('dialog').filter({ has: page.getByText('Create Datalink') })
|
||||
}
|
||||
|
||||
// ===============================
|
||||
@ -30,7 +31,7 @@ export default class NewDataLinkModalActions extends BaseActions {
|
||||
/** Interact with the "name" input - for example, to set the name using `.fill("")`. */
|
||||
withNameInput(callback: baseActions.LocatorCallback) {
|
||||
return this.step('Interact with "name" input', async (page) => {
|
||||
const locator = locateNewDataLinkModal(page).getByLabel('Name')
|
||||
const locator = locateNewDataLinkModal(page).getByPlaceholder(TEXT.datalinkNamePlaceholder)
|
||||
await callback(locator)
|
||||
})
|
||||
}
|
||||
|
@ -83,6 +83,14 @@ export default class RegisterPageActions extends BaseActions {
|
||||
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
|
||||
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
|
||||
await this.page.getByPlaceholder(TEXT.confirmPasswordPlaceholder).fill(confirmPassword)
|
||||
await this.page
|
||||
.getByRole('checkbox')
|
||||
.and(this.page.getByTestId('terms-of-service-checkbox'))
|
||||
.click()
|
||||
await this.page
|
||||
.getByRole('checkbox')
|
||||
.and(this.page.getByTestId('privacy-policy-checkbox'))
|
||||
.click()
|
||||
await this.page
|
||||
.getByRole('button', { name: TEXT.register, exact: true })
|
||||
.getByText(TEXT.register)
|
||||
|
@ -80,7 +80,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
isEnabled: true,
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
plan: backend.Plan.solo,
|
||||
isOrganizationAdmin: true,
|
||||
}
|
||||
const defaultOrganization: backend.OrganizationInfo = {
|
||||
@ -530,6 +530,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
return
|
||||
}
|
||||
})
|
||||
await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => {
|
||||
await route.fulfill({ json: [] })
|
||||
})
|
||||
await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({
|
||||
versions: [
|
||||
{
|
||||
@ -928,6 +931,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
goOnline: () => {
|
||||
isOnline = true
|
||||
},
|
||||
setPlan: (plan: backend.Plan) => {
|
||||
if (currentUser) {
|
||||
object.unsafeMutable(currentUser).plan = plan
|
||||
}
|
||||
},
|
||||
currentUser: () => currentUser,
|
||||
setCurrentUser: (user: backend.User | null) => {
|
||||
currentUser = user
|
||||
|
@ -65,7 +65,7 @@ test.test('asset panel contents', ({ page }) =>
|
||||
})
|
||||
.login()
|
||||
.do(async (thePage) => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
await actions.passAgreementsDialog({ page: thePage })
|
||||
})
|
||||
.driveTable.clickRow(0)
|
||||
.toggleAssetPanel()
|
||||
|
@ -1,51 +1,64 @@
|
||||
/** @file Test the labels sidebar panel. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import {
|
||||
locateCreateButton,
|
||||
locateLabelsPanel,
|
||||
locateLabelsPanelLabels,
|
||||
locateModalBackground,
|
||||
locateNewLabelButton,
|
||||
locateNewLabelModal,
|
||||
locateNewLabelModalColorButtons,
|
||||
locateNewLabelModalNameInput,
|
||||
mockAllAndLogin,
|
||||
TEXT,
|
||||
} from './actions'
|
||||
|
||||
test.test.beforeEach(({ page }) => actions.mockAllAndLogin({ page }))
|
||||
test.test.beforeEach(({ page }) => mockAllAndLogin({ page }))
|
||||
|
||||
test.test('labels', async ({ page }) => {
|
||||
// Empty labels panel
|
||||
await test.expect(actions.locateLabelsPanel(page)).toBeVisible()
|
||||
await test.expect(locateLabelsPanel(page)).toBeVisible()
|
||||
|
||||
// "Create label" modal
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
await test.expect(actions.locateNewLabelModal(page)).toBeVisible()
|
||||
await locateNewLabelButton(page).click()
|
||||
await test.expect(locateNewLabelModal(page)).toBeVisible()
|
||||
await page.press('body', 'Escape')
|
||||
await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible()
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
await actions.locateModalBackground(page).click()
|
||||
await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible()
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
await test.expect(locateNewLabelModal(page)).not.toBeVisible()
|
||||
await locateNewLabelButton(page).click()
|
||||
await locateModalBackground(page).click()
|
||||
await test.expect(locateNewLabelModal(page)).not.toBeVisible()
|
||||
await locateNewLabelButton(page).click()
|
||||
|
||||
// "Create label" modal with name set
|
||||
await actions.locateNewLabelModalNameInput(page).fill('New Label')
|
||||
await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/)
|
||||
await locateNewLabelModalNameInput(page).fill('New Label')
|
||||
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
|
||||
|
||||
await page.press('body', 'Escape')
|
||||
|
||||
// "Create label" modal with color set
|
||||
// The exact number is allowed to vary; but to click the fourth color, there must be at least
|
||||
// four colors.
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
test.expect(await actions.locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
|
||||
await locateNewLabelButton(page).click()
|
||||
test.expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
|
||||
// `force: true` is required because the `label` needs to handle the click event, not the
|
||||
// `button`.
|
||||
await actions.locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
|
||||
await test.expect(actions.locateNewLabelModal(page)).toBeVisible()
|
||||
await locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
|
||||
await test.expect(locateNewLabelModal(page)).toBeVisible()
|
||||
|
||||
// "Create label" modal with name and color set
|
||||
await actions.locateNewLabelModalNameInput(page).fill('New Label')
|
||||
await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/)
|
||||
await locateNewLabelModalNameInput(page).fill('New Label')
|
||||
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
|
||||
|
||||
// Labels panel with one entry
|
||||
await actions.locateCreateButton(actions.locateNewLabelModal(page)).click()
|
||||
await test.expect(actions.locateLabelsPanel(page)).toBeVisible()
|
||||
await locateCreateButton(locateNewLabelModal(page)).click()
|
||||
await test.expect(locateLabelsPanel(page)).toBeVisible()
|
||||
|
||||
// Empty labels panel again, after deleting the only entry
|
||||
await actions.locateLabelsPanelLabels(page).first().hover()
|
||||
await actions.locateDeleteIcon(actions.locateLabelsPanel(page)).first().click()
|
||||
await actions.locateDeleteButton(page).click()
|
||||
test.expect(await actions.locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
|
||||
await locateLabelsPanelLabels(page).first().hover()
|
||||
|
||||
const labelsPanel = locateLabelsPanel(page)
|
||||
await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click()
|
||||
await page.getByRole('button', { name: 'Delete' }).getByText('Delete').click()
|
||||
test.expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
@ -12,7 +12,7 @@ test.test('login and logout', ({ page }) =>
|
||||
.mockAll({ page })
|
||||
.login()
|
||||
.do(async (thePage) => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
await actions.passAgreementsDialog({ page: thePage })
|
||||
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
|
||||
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
|
||||
})
|
||||
|
@ -4,7 +4,7 @@ import * as test from '@playwright/test'
|
||||
import {
|
||||
INVALID_PASSWORD,
|
||||
mockAll,
|
||||
passTermsAndConditionsDialog,
|
||||
passAgreementsDialog,
|
||||
TEXT,
|
||||
VALID_EMAIL,
|
||||
VALID_PASSWORD,
|
||||
@ -26,7 +26,7 @@ test.test('login screen', ({ page }) =>
|
||||
// Technically it should not be allowed, but
|
||||
.login(VALID_EMAIL, INVALID_PASSWORD)
|
||||
.do(async (thePage) => {
|
||||
await passTermsAndConditionsDialog({ page: thePage })
|
||||
await passAgreementsDialog({ page: thePage })
|
||||
})
|
||||
.withDriveView(async (driveView) => {
|
||||
await test.expect(driveView).toBeVisible()
|
||||
|
@ -1,15 +1,21 @@
|
||||
/** @file Test the user settings tab. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('members settings', async ({ page }) => {
|
||||
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
|
||||
const api = await actions.mockAllAndLoginAndExposeAPI({
|
||||
page,
|
||||
setupAPI: (theApi) => {
|
||||
theApi.setPlan(backend.Plan.enterprise)
|
||||
// Setup
|
||||
theApi.setCurrentOrganization(theApi.defaultOrganization)
|
||||
},
|
||||
})
|
||||
const localActions = actions.settings.members
|
||||
|
||||
// Setup
|
||||
api.setCurrentOrganization(api.defaultOrganization)
|
||||
|
||||
await localActions.go(page)
|
||||
await test
|
||||
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
|
||||
@ -17,7 +23,6 @@ test.test('members settings', async ({ page }) => {
|
||||
|
||||
const otherUserName = 'second.user_'
|
||||
const otherUser = api.addUser(otherUserName)
|
||||
// await actions.closeModal(page)
|
||||
await actions.relog({ page })
|
||||
await localActions.go(page)
|
||||
await test
|
||||
@ -25,7 +30,6 @@ test.test('members settings', async ({ page }) => {
|
||||
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
|
||||
|
||||
api.deleteUser(otherUser.userId)
|
||||
// await actions.closeModal(page)
|
||||
await actions.relog({ page })
|
||||
await localActions.go(page)
|
||||
await test
|
||||
|
@ -31,8 +31,8 @@ test.test('organization settings', async ({ page }) => {
|
||||
await test.test.step('Unset name (should fail)', async () => {
|
||||
await nameInput.fill('')
|
||||
await nameInput.press('Enter')
|
||||
await test.expect(nameInput).toHaveValue('')
|
||||
test.expect(api.currentOrganization()?.name).toBe(newName)
|
||||
await test.expect(nameInput).toHaveValue(newName)
|
||||
})
|
||||
|
||||
const invalidEmail = 'invalid@email'
|
||||
|
@ -31,6 +31,7 @@
|
||||
"@aws-amplify/auth": "5.6.5",
|
||||
"@aws-amplify/core": "5.8.5",
|
||||
"@hookform/resolvers": "^3.4.0",
|
||||
"@internationalized/date": "^3.5.5",
|
||||
"@monaco-editor/react": "4.6.0",
|
||||
"@sentry/react": "^7.74.0",
|
||||
"@stripe/react-stripe-js": "^2.7.1",
|
||||
@ -40,17 +41,18 @@
|
||||
"ajv": "^8.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"enso-common": "workspace:*",
|
||||
"framer-motion": "11.3.0",
|
||||
"is-network-error": "^1.0.1",
|
||||
"monaco-editor": "0.48.0",
|
||||
"react": "^18.3.1",
|
||||
"react-aria": "^3.33.0",
|
||||
"react-aria-components": "^1.2.0",
|
||||
"react-aria": "^3.34.3",
|
||||
"react-aria-components": "^1.3.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "4.0.13",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-router": "^6.23.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-stately": "^3.31.0",
|
||||
"react-stately": "^3.32.2",
|
||||
"react-toastify": "^9.1.3",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwind-variants": "0.2.1",
|
||||
@ -58,8 +60,7 @@
|
||||
"ts-results": "^3.3.0",
|
||||
"validator": "^13.12.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.4",
|
||||
"framer-motion": "11.3.0"
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fast-check/vitest": "^0.0.8",
|
||||
|
@ -47,7 +47,7 @@ import * as appUtils from '#/appUtils'
|
||||
import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
|
||||
import DriveProvider from '#/providers/DriveProvider'
|
||||
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
|
||||
import { useHttpClient } from '#/providers/HttpClientProvider'
|
||||
@ -80,8 +80,8 @@ import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
import AboutModal from '#/modals/AboutModal'
|
||||
import { AgreementsModal } from '#/modals/AgreementsModal'
|
||||
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
|
||||
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
|
||||
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import ProjectManager, * as projectManager from '#/services/ProjectManager'
|
||||
@ -93,6 +93,8 @@ import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import { useInitAuthService } from '#/authentication/service'
|
||||
import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
|
||||
import { Path } from '#/utilities/path'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
@ -102,6 +104,7 @@ declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly inputBindings: Readonly<Record<string, readonly string[]>>
|
||||
readonly localRootDirectory: string
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +121,8 @@ LocalStorage.registerKey('inputBindings', {
|
||||
),
|
||||
})
|
||||
|
||||
LocalStorage.registerKey('localRootDirectory', { schema: z.string() })
|
||||
|
||||
// ======================
|
||||
// === getMainPageUrl ===
|
||||
// ======================
|
||||
@ -347,6 +352,12 @@ function AppRouter(props: AppRouterProps) {
|
||||
}
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
|
||||
// Subscribe to `localStorage` updates to trigger a rerender when the terms of service
|
||||
// or privacy policy have been accepted.
|
||||
localStorageProvider.useLocalStorageState('termsOfService')
|
||||
localStorageProvider.useLocalStorageState('privacyPolicy')
|
||||
|
||||
const authService = useInitAuthService(props)
|
||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||
const refreshUserSession =
|
||||
@ -420,24 +431,26 @@ function AppRouter(props: AppRouterProps) {
|
||||
{/* Protected pages are visible to authenticated users. */}
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route element={<authProvider.ProtectedLayout />}>
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<AgreementsModal />}>
|
||||
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
|
||||
<router.Route
|
||||
path={appUtils.DASHBOARD_PATH}
|
||||
element={shouldShowDashboard && <Dashboard {...props} />}
|
||||
/>
|
||||
<router.Route element={<InvitedToOrganizationModal />}>
|
||||
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
|
||||
<router.Route
|
||||
path={appUtils.DASHBOARD_PATH}
|
||||
element={shouldShowDashboard && <Dashboard {...props} />}
|
||||
/>
|
||||
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribe.Subscribe />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribe.Subscribe />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
@ -455,7 +468,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<AgreementsModal />}>
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route path={appUtils.SETUP_PATH} element={<setup.Setup />} />
|
||||
</router.Route>
|
||||
@ -499,6 +512,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
* due to modals being in `TheModal`. */}
|
||||
<DriveProvider>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<LocalBackendPathSynchronizer />
|
||||
<VersionChecker />
|
||||
{routes}
|
||||
{detect.IS_DEV_MODE && (
|
||||
@ -516,3 +530,21 @@ function AppRouter(props: AppRouterProps) {
|
||||
</DevtoolsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// === LocalBackendPathSynchronizer ===
|
||||
// ====================================
|
||||
|
||||
/** Keep `localBackend.rootPath` in sync with the saved root path state. */
|
||||
function LocalBackendPathSynchronizer() {
|
||||
const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory')
|
||||
const localBackend = useLocalBackend()
|
||||
if (localBackend) {
|
||||
if (localRootDirectory != null) {
|
||||
localBackend.rootPath = Path(localRootDirectory)
|
||||
} else {
|
||||
localBackend.resetRootPath()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
@ -37,6 +37,10 @@ export const ALL_PATHS_REGEX = new RegExp(
|
||||
// === Constants related to URLs ===
|
||||
|
||||
export const SEARCH_PARAMS_PREFIX = 'cloud-ide_'
|
||||
/** Return the email address for contacting support. */
|
||||
export const SUPPORT_EMAIL = 'cloud@enso.org'
|
||||
/** Return the `mailto:` URL for contacting support. */
|
||||
export const SUPPORT_EMAIL_URL = `mailto:${SUPPORT_EMAIL}`
|
||||
|
||||
/**
|
||||
* Build a Subscription URL for a given plan.
|
||||
|
@ -1,8 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M2.08579 3.75735L6.32843 7.99999L2.08579 12.2426L3.5 13.6568L7.7426 9.41421L9.1569 7.99999L7.7426 6.58578L3.5 2.34314L2.08579 3.75735Z"
|
||||
fill="black" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M7.08579 3.75735L11.32843 7.99999L7.08579 12.2426L8.5 13.6568L12.7426 9.41421L14.1569 7.99999L12.7426 6.58578L8.5 2.34314L7.08579 3.75735Z"
|
||||
fill="black" />
|
||||
</svg>
|
Before Width: | Height: | Size: 548 B |
3
app/dashboard/src/assets/minus2.svg
Normal file
3
app/dashboard/src/assets/minus2.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="m2 6.5h12v3h-12" fill="black" />
|
||||
</svg>
|
After Width: | Height: | Size: 185 B |
4
app/dashboard/src/assets/person.svg
Normal file
4
app/dashboard/src/assets/person.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 11C3 9.34315 4.34315 8 6 8H10C11.6569 8 13 9.34315 13 11V14C13 14.5523 12.5523 15 12 15H4C3.44772 15 3 14.5523 3 14V11Z" fill="black"/>
|
||||
<circle cx="8" cy="4" r="3" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 294 B |
@ -1,4 +1,3 @@
|
||||
<svg width="6" height="6" viewBox="0 0 6 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0H2V2L0 2V4H2V6H4V4H6V2L4 2V0Z" fill="black"
|
||||
fill-opacity="0.3" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0H2V2L0 2V4H2V6H4V4H6V2L4 2V0Z" fill="black" />
|
||||
</svg>
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 202 B |
@ -1,25 +1,26 @@
|
||||
/** @file Alert component. */
|
||||
import * as React from 'react'
|
||||
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
export const ALERT_STYLES = twv.tv({
|
||||
export const ALERT_STYLES = tv({
|
||||
base: 'flex flex-col items-stretch',
|
||||
variants: {
|
||||
fullWidth: { true: 'w-full' },
|
||||
variant: {
|
||||
custom: '',
|
||||
outline: 'border border-2 bg-transparent border-primary/30 text-primary',
|
||||
neutral: 'border border-2 bg-gray-100 border-gray-800 text-primary',
|
||||
error: 'border border-2 bg-red-100 border-danger text-primary',
|
||||
info: 'border border-2 bg-blue-100 border-blue-800 text-blue-800',
|
||||
success: 'border border-2 bg-green-100 border-green-800 text-green-800',
|
||||
warning: 'border border-2 bg-yellow-100 border-yellow-800 text-yellow-800',
|
||||
outline: 'border border-0.5 bg-transparent border-primary/20 text-primary',
|
||||
neutral: 'border border-0.5 bg-gray-100 border-gray-800 text-primary',
|
||||
error: 'border border-0.5 bg-red-100 border-danger text-primary',
|
||||
info: 'border border-0.5 bg-blue-100 border-blue-800 text-blue-800',
|
||||
success: 'border border-0.5 bg-green-100 border-green-800 text-green-800',
|
||||
warning: 'border border-0.5 bg-yellow-100 border-yellow-800 text-yellow-800',
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
@ -51,14 +52,14 @@ export const ALERT_STYLES = twv.tv({
|
||||
|
||||
/** Props for an {@link Alert}. */
|
||||
export interface AlertProps
|
||||
extends React.PropsWithChildren,
|
||||
twv.VariantProps<typeof ALERT_STYLES>,
|
||||
React.HTMLAttributes<HTMLDivElement> {}
|
||||
extends PropsWithChildren,
|
||||
VariantProps<typeof ALERT_STYLES>,
|
||||
HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/** Alert component. */
|
||||
export const Alert = React.forwardRef(function Alert(
|
||||
export const Alert = forwardRef(function Alert(
|
||||
props: AlertProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props
|
||||
|
||||
@ -70,7 +71,7 @@ export const Alert = React.forwardRef(function Alert(
|
||||
return (
|
||||
<div
|
||||
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
|
||||
ref={mergeRefs.mergeRefs(ref, (e) => {
|
||||
ref={mergeRefs(ref, (e) => {
|
||||
if (variant === 'error') {
|
||||
e?.focus()
|
||||
}
|
||||
|
@ -10,7 +10,8 @@ import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import * as text from '../Text'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { TEXT_STYLE } from '../Text'
|
||||
|
||||
// ==============
|
||||
// === Button ===
|
||||
@ -88,7 +89,9 @@ export const BUTTON_STYLES = twv.tv({
|
||||
'transition-[opacity,outline-offset,background,border-color] duration-150 ease-in-out',
|
||||
],
|
||||
variants: {
|
||||
isDisabled: { true: 'disabled:opacity-50 disabled:cursor-not-allowed' },
|
||||
isDisabled: {
|
||||
true: 'disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'focus:outline-none focus-visible:outline-2 focus-visible:outline-black focus-visible:outline-offset-[-2px]',
|
||||
},
|
||||
@ -104,7 +107,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
custom: { base: '', extraClickZone: '', icon: 'h-full' },
|
||||
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
|
||||
large: {
|
||||
base: text.TEXT_STYLE({
|
||||
base: TEXT_STYLE({
|
||||
variant: 'body',
|
||||
color: 'custom',
|
||||
weight: 'semibold',
|
||||
@ -115,7 +118,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
extraClickZone: 'after:inset-[-6px]',
|
||||
},
|
||||
medium: {
|
||||
base: text.TEXT_STYLE({
|
||||
base: TEXT_STYLE({
|
||||
variant: 'body',
|
||||
color: 'custom',
|
||||
weight: 'semibold',
|
||||
@ -126,7 +129,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
extraClickZone: 'after:inset-[-8px]',
|
||||
},
|
||||
small: {
|
||||
base: text.TEXT_STYLE({
|
||||
base: TEXT_STYLE({
|
||||
variant: 'body',
|
||||
color: 'custom',
|
||||
weight: 'medium',
|
||||
@ -137,7 +140,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
extraClickZone: 'after:inset-[-10px]',
|
||||
},
|
||||
xsmall: {
|
||||
base: text.TEXT_STYLE({
|
||||
base: TEXT_STYLE({
|
||||
variant: 'body',
|
||||
color: 'custom',
|
||||
weight: 'medium',
|
||||
@ -149,7 +152,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
extraClickZone: 'after:inset-[-12px]',
|
||||
},
|
||||
xxsmall: {
|
||||
base: text.TEXT_STYLE({
|
||||
base: TEXT_STYLE({
|
||||
variant: 'body',
|
||||
color: 'custom',
|
||||
className: 'flex px-[3px] pt-[0.5px] pb-[2.5px] leading-[16px]',
|
||||
@ -164,7 +167,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
},
|
||||
iconOnly: {
|
||||
true: {
|
||||
base: text.TEXT_STYLE({
|
||||
base: TEXT_STYLE({
|
||||
disableLineHeightCompensation: true,
|
||||
className: 'border-0 outline-offset-[5px]',
|
||||
}),
|
||||
@ -189,8 +192,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
icon: 'h-[1.25cap] w-[1.25cap] mt-[0.25cap]',
|
||||
},
|
||||
primary: 'bg-primary text-white hover:bg-primary/70',
|
||||
tertiary: 'bg-accent text-white hover:bg-accent-dark',
|
||||
cancel: 'bg-white/50 hover:bg-white',
|
||||
accent: 'bg-accent text-white hover:bg-accent-dark',
|
||||
delete:
|
||||
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
|
||||
icon: {
|
||||
@ -201,9 +203,11 @@ export const BUTTON_STYLES = twv.tv({
|
||||
},
|
||||
ghost:
|
||||
'text-primary hover:text-primary/80 hover:bg-white focus-visible:text-primary/80 focus-visible:bg-white',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'ghost-fading':
|
||||
'text-primary opacity-80 hover:opacity-100 hover:bg-white focus-visible:bg-white',
|
||||
submit: 'bg-invite text-white opacity-80 hover:opacity-100',
|
||||
outline: 'border-primary/40 text-primary hover:border-primary hover:bg-primary/5',
|
||||
bar: 'border-primary/20 hover:bg-primary/5',
|
||||
outline: 'border-primary/20 text-primary hover:border-primary hover:bg-primary/5',
|
||||
},
|
||||
iconPosition: {
|
||||
start: { content: '' },
|
||||
@ -214,7 +218,8 @@ export const BUTTON_STYLES = twv.tv({
|
||||
},
|
||||
extraClickZone: {
|
||||
true: {
|
||||
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
|
||||
extraClickZone:
|
||||
'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed',
|
||||
},
|
||||
false: {
|
||||
extraClickZone: 'after:inset-0',
|
||||
@ -240,7 +245,8 @@ export const BUTTON_STYLES = twv.tv({
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
|
||||
extraClickZone:
|
||||
'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed',
|
||||
wrapper: 'relative block',
|
||||
loader: 'absolute inset-0 flex items-center justify-center',
|
||||
content: 'flex items-center gap-[0.5em]',
|
||||
@ -278,7 +284,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
})
|
||||
|
||||
/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
|
||||
export const Button = React.forwardRef(function Button(
|
||||
export const Button = forwardRef(function Button(
|
||||
props: ButtonProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
@ -331,7 +337,7 @@ export const Button = React.forwardRef(function Button(
|
||||
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
|
||||
|
||||
const isLoading = loading || implicitlyLoading
|
||||
const isDisabled = props.isDisabled == null ? isLoading : props.isDisabled
|
||||
const isDisabled = props.isDisabled ?? isLoading
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const delay = 350
|
||||
@ -382,7 +388,7 @@ export const Button = React.forwardRef(function Button(
|
||||
icon: iconClasses,
|
||||
text: textClasses,
|
||||
} = BUTTON_STYLES({
|
||||
isDisabled: isDisabled,
|
||||
isDisabled,
|
||||
isActive,
|
||||
loading: isLoading,
|
||||
fullWidth,
|
||||
@ -443,7 +449,11 @@ export const Button = React.forwardRef(function Button(
|
||||
isDisabled,
|
||||
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
|
||||
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
|
||||
onPressEnd: handlePress,
|
||||
onPressEnd: (e) => {
|
||||
if (!isDisabled) {
|
||||
handlePress(e)
|
||||
}
|
||||
},
|
||||
className: aria.composeRenderProps(className, (classNames, states) =>
|
||||
base({ className: classNames, ...states }),
|
||||
),
|
||||
|
@ -51,11 +51,14 @@ const STYLES = twv.tv({
|
||||
|
||||
/** Props for a {@link ButtonGroup}. */
|
||||
interface ButtonGroupProps extends React.PropsWithChildren, twv.VariantProps<typeof STYLES> {
|
||||
readonly className?: string
|
||||
readonly className?: string | undefined
|
||||
}
|
||||
|
||||
/** A group of buttons. */
|
||||
export function ButtonGroup(props: ButtonGroupProps) {
|
||||
export const ButtonGroup = React.forwardRef(function ButtonGroup(
|
||||
props: ButtonGroupProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
@ -69,6 +72,7 @@ export function ButtonGroup(props: ButtonGroupProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={STYLES({
|
||||
gap,
|
||||
wrap,
|
||||
@ -82,4 +86,4 @@ export function ButtonGroup(props: ButtonGroupProps) {
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
@ -57,6 +57,12 @@ const DIALOG_STYLES = twv.tv({
|
||||
header: 'px-4 pt-[5px] pb-1.5 min-h-12',
|
||||
},
|
||||
},
|
||||
fitContent: {
|
||||
true: {
|
||||
base: 'min-w-max',
|
||||
content: 'min-w-max',
|
||||
},
|
||||
},
|
||||
hideCloseButton: { true: { closeButton: 'hidden' } },
|
||||
closeButton: {
|
||||
normal: { base: '', closeButton: '' },
|
||||
@ -127,6 +133,7 @@ export function Dialog(props: DialogProps) {
|
||||
testId = 'dialog',
|
||||
size,
|
||||
rounded,
|
||||
fitContent,
|
||||
...ariaDialogProps
|
||||
} = props
|
||||
|
||||
@ -148,7 +155,7 @@ export function Dialog(props: DialogProps) {
|
||||
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
|
||||
const root = portal.useStrictPortalContext()
|
||||
|
||||
const dialogSlots = DIALOG_STYLES({
|
||||
const styles = DIALOG_STYLES({
|
||||
className,
|
||||
type,
|
||||
rounded,
|
||||
@ -156,6 +163,7 @@ export function Dialog(props: DialogProps) {
|
||||
closeButton,
|
||||
scrolledToTop: isScrolledToTop,
|
||||
size,
|
||||
fitContent,
|
||||
})
|
||||
|
||||
utlities.useInteractOutside({
|
||||
@ -216,17 +224,15 @@ export function Dialog(props: DialogProps) {
|
||||
element.dataset.testId = testId
|
||||
}
|
||||
})}
|
||||
className={dialogSlots.base()}
|
||||
className={styles.base()}
|
||||
{...ariaDialogProps}
|
||||
>
|
||||
{(opts) => {
|
||||
return (
|
||||
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
|
||||
<aria.Header
|
||||
className={dialogSlots.header({ scrolledToTop: isScrolledToTop })}
|
||||
>
|
||||
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
|
||||
<ariaComponents.CloseButton
|
||||
className={dialogSlots.closeButton()}
|
||||
className={styles.closeButton()}
|
||||
onPress={opts.close}
|
||||
/>
|
||||
|
||||
@ -234,7 +240,7 @@ export function Dialog(props: DialogProps) {
|
||||
<ariaComponents.Text.Heading
|
||||
slot="title"
|
||||
level={2}
|
||||
className={dialogSlots.heading()}
|
||||
className={styles.heading()}
|
||||
weight="semibold"
|
||||
>
|
||||
{title}
|
||||
@ -248,7 +254,7 @@ export function Dialog(props: DialogProps) {
|
||||
handleScroll(ref.scrollTop)
|
||||
}
|
||||
}}
|
||||
className={dialogSlots.content()}
|
||||
className={styles.content()}
|
||||
onScroll={(event) => {
|
||||
handleScroll(event.currentTarget.scrollTop)
|
||||
}}
|
||||
|
@ -40,6 +40,7 @@ export const POPOVER_STYLES = twv.tv({
|
||||
true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150',
|
||||
},
|
||||
size: {
|
||||
auto: { base: 'w-[unset]', dialog: 'p-2.5' },
|
||||
xsmall: { base: 'max-w-xs', dialog: 'p-2.5' },
|
||||
small: { base: 'max-w-sm', dialog: 'p-3.5' },
|
||||
medium: { base: 'max-w-md', dialog: 'p-3.5' },
|
||||
|
@ -13,6 +13,7 @@ import * as aria from '#/components/aria'
|
||||
|
||||
import * as errorUtils from '#/utilities/error'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import type { Mutable } from 'enso-common/src/utilities/data/object'
|
||||
import * as dialog from '../Dialog'
|
||||
import * as components from './components'
|
||||
@ -35,14 +36,12 @@ function mapValueOnEvent(value: unknown) {
|
||||
* Provides better error handling and form state management and better UX out of the box. */
|
||||
// There is no way to avoid type casting here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Form = React.forwardRef(function Form<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: types.FormProps<Schema, TFieldValues, TTransformedValues>,
|
||||
export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
||||
props: types.FormProps<Schema>,
|
||||
ref: React.Ref<HTMLFormElement>,
|
||||
) {
|
||||
/** Input values for this form. */
|
||||
type FieldValues = components.FieldValues<Schema>
|
||||
const formId = React.useId()
|
||||
|
||||
const {
|
||||
@ -78,6 +77,7 @@ export const Form = React.forwardRef(function Form<
|
||||
schema,
|
||||
...formOptions,
|
||||
},
|
||||
defaultValues,
|
||||
)
|
||||
|
||||
const dialogContext = dialog.useDialogContext()
|
||||
@ -90,7 +90,7 @@ export const Form = React.forwardRef(function Form<
|
||||
// the result, and the variables(form fields).
|
||||
// In general, prefer using object literals for the mutation key.
|
||||
mutationKey: ['Form submission', `testId: ${testId}`, `id: ${id}`],
|
||||
mutationFn: async (fieldValues: TFieldValues) => {
|
||||
mutationFn: async (fieldValues: FieldValues) => {
|
||||
try {
|
||||
await onSubmit?.(fieldValues, innerForm)
|
||||
|
||||
@ -153,33 +153,32 @@ export const Form = React.forwardRef(function Form<
|
||||
control,
|
||||
} = innerForm
|
||||
|
||||
const formStateRenderProps: types.FormStateRenderProps<Schema, TFieldValues, TTransformedValues> =
|
||||
{
|
||||
formState,
|
||||
register: (name, options) => {
|
||||
const registered = register(name, options)
|
||||
const formStateRenderProps: types.FormStateRenderProps<Schema> = {
|
||||
formState,
|
||||
register: (name, options) => {
|
||||
const registered = register(name, options)
|
||||
|
||||
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
|
||||
...registered,
|
||||
isDisabled: registered.disabled ?? false,
|
||||
isRequired: registered.required ?? false,
|
||||
isInvalid: Boolean(formState.errors[name]),
|
||||
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
|
||||
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
|
||||
}
|
||||
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
|
||||
...registered,
|
||||
isDisabled: registered.disabled ?? false,
|
||||
isRequired: registered.required ?? false,
|
||||
isInvalid: Boolean(formState.errors[name]),
|
||||
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
|
||||
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
unregister,
|
||||
setError,
|
||||
clearErrors,
|
||||
getValues,
|
||||
setValue,
|
||||
setFocus,
|
||||
reset,
|
||||
control,
|
||||
form: innerForm,
|
||||
}
|
||||
return result
|
||||
},
|
||||
unregister,
|
||||
setError,
|
||||
clearErrors,
|
||||
getValues,
|
||||
setValue,
|
||||
setFocus,
|
||||
reset,
|
||||
control,
|
||||
form: innerForm,
|
||||
}
|
||||
|
||||
const base = styles.FORM_STYLES({
|
||||
className: typeof className === 'function' ? className(formStateRenderProps) : className,
|
||||
@ -192,7 +191,7 @@ export const Form = React.forwardRef(function Form<
|
||||
const message = error?.message ?? getText('arbitraryFormErrorMessage')
|
||||
return [key, message]
|
||||
}),
|
||||
) as Record<keyof TFieldValues, string>
|
||||
) as Record<keyof FieldValues, string>
|
||||
|
||||
return (
|
||||
<form
|
||||
@ -235,14 +234,8 @@ export const Form = React.forwardRef(function Form<
|
||||
| 'useFormSchema'
|
||||
>
|
||||
> &
|
||||
(<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: React.RefAttributes<HTMLFormElement> &
|
||||
types.FormProps<Schema, TFieldValues, TTransformedValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(<Schema extends components.TSchema>(
|
||||
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
|
||||
) => React.JSX.Element)
|
||||
|
||||
Form.schema = components.schema
|
||||
|
@ -3,13 +3,14 @@
|
||||
*
|
||||
* Field component
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { type ExtractFunction, tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||
import type { Path } from 'react-hook-form'
|
||||
import * as text from '../../Text'
|
||||
import type * as types from './types'
|
||||
import * as formContext from './useFormContext'
|
||||
@ -17,11 +18,12 @@ import * as formContext from './useFormContext'
|
||||
/**
|
||||
* Props for Field component
|
||||
*/
|
||||
export interface FieldComponentProps extends VariantProps<typeof FIELD_STYLES>, types.FieldProps {
|
||||
export interface FieldComponentProps<Schema extends types.TSchema>
|
||||
extends VariantProps<typeof FIELD_STYLES>,
|
||||
types.FieldProps {
|
||||
readonly 'data-testid'?: string | undefined
|
||||
readonly name: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly form?: types.FormInstance<any, any, any>
|
||||
readonly name: Path<types.FieldValues<Schema>>
|
||||
readonly form?: types.FormInstance<Schema>
|
||||
readonly isInvalid?: boolean | undefined
|
||||
readonly className?: string | undefined
|
||||
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
|
||||
@ -62,12 +64,14 @@ export const FIELD_STYLES = tv({
|
||||
/**
|
||||
* Field component
|
||||
*/
|
||||
export const Field = React.forwardRef(function Field(
|
||||
props: FieldComponentProps,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Field = forwardRef(function Field<Schema extends types.TSchema>(
|
||||
props: FieldComponentProps<Schema>,
|
||||
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
||||
) {
|
||||
const {
|
||||
form = formContext.useFormContext(),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
form = formContext.useFormContext() as unknown as types.FormInstance<Schema>,
|
||||
isInvalid,
|
||||
children,
|
||||
className,
|
||||
|
@ -17,8 +17,9 @@ import * as formContext from './useFormContext'
|
||||
* Props for the FormError component.
|
||||
*/
|
||||
export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'children'> {
|
||||
// We do not need to know the form fields.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly form?: types.FormInstance<any, any>
|
||||
readonly form?: types.FormInstance<any>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -20,9 +20,9 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
|
||||
*
|
||||
* This field is helpful when you need to use the submit button outside of the form.
|
||||
*/
|
||||
// For this component, we don't need to know the form fields
|
||||
// We do not need to know the form fields.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly form?: types.FormInstance<any, any>
|
||||
readonly form?: types.FormInstance<any>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,9 +24,9 @@ interface SubmitButtonBaseProps {
|
||||
*
|
||||
* This field is helpful when you need to use the submit button outside of the form.
|
||||
*/
|
||||
// For this component, we don't need to know the form fields
|
||||
// We do not need to know the form fields.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly form?: types.FormInstance<any, any>
|
||||
readonly form?: types.FormInstance<any>
|
||||
/**
|
||||
* Prop that allows to close the parent dialog without submitting the form.
|
||||
*
|
||||
|
@ -9,33 +9,34 @@ import type * as z from 'zod'
|
||||
|
||||
import type * as schemaModule from './schema'
|
||||
|
||||
/**
|
||||
* Field values type.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
/** The type of the inputs to the form, used for UI inputs. */
|
||||
export type FieldValues<Schema extends TSchema | undefined> =
|
||||
Schema extends TSchema ? z.infer<Schema> : reactHookForm.FieldValues
|
||||
Schema extends TSchema ? z.input<Schema> : reactHookForm.FieldValues
|
||||
|
||||
/** The type of the outputs of the form, used for the callback. */
|
||||
export type TransformedValues<Schema extends TSchema | undefined> =
|
||||
Schema extends TSchema ? z.output<Schema> : reactHookForm.FieldValues
|
||||
|
||||
/**
|
||||
* Field path type.
|
||||
* @alias reactHookForm.FieldPath
|
||||
*/
|
||||
export type FieldPath<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
> = reactHookForm.FieldPath<TFieldValues>
|
||||
export type FieldPath<Schema extends TSchema> = reactHookForm.FieldPath<FieldValues<Schema>>
|
||||
|
||||
/**
|
||||
* Schema type
|
||||
*/
|
||||
export type TSchema = z.AnyZodObject | z.ZodEffects<z.AnyZodObject>
|
||||
export type TSchema =
|
||||
| z.AnyZodObject
|
||||
| z.ZodEffects<z.AnyZodObject>
|
||||
| z.ZodEffects<z.ZodEffects<z.AnyZodObject>>
|
||||
|
||||
/**
|
||||
* Props for the useForm hook.
|
||||
*/
|
||||
export interface UseFormProps<Schema extends TSchema, TFieldValues extends FieldValues<Schema>>
|
||||
export interface UseFormProps<Schema extends TSchema>
|
||||
extends Omit<
|
||||
reactHookForm.UseFormProps<TFieldValues>,
|
||||
reactHookForm.UseFormProps<FieldValues<Schema>>,
|
||||
'handleSubmit' | 'resetOptions' | 'resolver'
|
||||
> {
|
||||
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
|
||||
@ -45,30 +46,20 @@ export interface UseFormProps<Schema extends TSchema, TFieldValues extends Field
|
||||
* Return type of the useForm hook.
|
||||
* @alias reactHookForm.UseFormReturn
|
||||
*/
|
||||
export interface UseFormReturn<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TTransformedValues extends Record<string, unknown> | undefined = undefined,
|
||||
> extends reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues> {}
|
||||
export interface UseFormReturn<Schema extends TSchema>
|
||||
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {}
|
||||
|
||||
/**
|
||||
* Form state type.
|
||||
* @alias reactHookForm.FormState
|
||||
*/
|
||||
export type FormState<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
> = reactHookForm.FormState<TFieldValues>
|
||||
export type FormState<Schema extends TSchema> = reactHookForm.FormState<FieldValues<Schema>>
|
||||
|
||||
/**
|
||||
* Form instance type
|
||||
* @alias UseFormReturn
|
||||
*/
|
||||
export type FormInstance<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema> = FieldValues<Schema>,
|
||||
TTransformedValues extends Record<string, unknown> | undefined = undefined,
|
||||
> = UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
export type FormInstance<Schema extends TSchema> = UseFormReturn<Schema>
|
||||
|
||||
/**
|
||||
* Form type interface that check if FieldValues type is compatible with the value type from component
|
||||
@ -76,21 +67,20 @@ export type FormInstance<
|
||||
export interface FormWithValueValidation<
|
||||
BaseValueType,
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
// It is not ideal to have this as a parameter as it can be edited, but this is the simplest way
|
||||
// to avoid distributive conditional types to affect the error message. We want distributivity
|
||||
// to happen, just not for the error message itself.
|
||||
ErrorType = [
|
||||
'Type mismatch: Expected',
|
||||
TFieldValues[TFieldName],
|
||||
FieldValues<Schema>[TFieldName],
|
||||
'got',
|
||||
BaseValueType,
|
||||
'instead.',
|
||||
],
|
||||
> {
|
||||
readonly form?:
|
||||
| (BaseValueType extends TFieldValues[TFieldName] ?
|
||||
FormInstance<Schema, TFieldValues, TTransformedValues>
|
||||
: ErrorType)
|
||||
| (BaseValueType extends FieldValues<Schema>[TFieldName] ? FormInstance<Schema> : ErrorType)
|
||||
| undefined
|
||||
}
|
||||
|
||||
|
@ -14,21 +14,11 @@ import * as formContext from './useFormContext'
|
||||
export interface UseFieldOptions<
|
||||
BaseValueType,
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
TFieldName extends types.FieldPath<Schema, TFieldValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
> extends types.FormWithValueValidation<
|
||||
BaseValueType,
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
> {
|
||||
TFieldName extends types.FieldPath<Schema>,
|
||||
> extends types.FormWithValueValidation<BaseValueType, Schema, TFieldName> {
|
||||
readonly name: TFieldName
|
||||
readonly isDisabled?: boolean | undefined
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly defaultValue?: TFieldValues[TFieldName] | undefined
|
||||
readonly defaultValue?: types.FieldValues<Schema>[TFieldName] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@ -37,18 +27,15 @@ export interface UseFieldOptions<
|
||||
export function useField<
|
||||
BaseValueType,
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
TFieldName extends types.FieldPath<Schema, TFieldValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
>(options: UseFieldOptions<BaseValueType, Schema, TFieldValues, TFieldName, TTransformedValues>) {
|
||||
TFieldName extends types.FieldPath<Schema>,
|
||||
>(options: UseFieldOptions<BaseValueType, Schema, TFieldName>) {
|
||||
const { form = formContext.useFormContext(), name, defaultValue, isDisabled = false } = options
|
||||
|
||||
// This is safe, because the form is always passed either via the options or via the context.
|
||||
// The assertion is needed because we use additional type validation for form instance and throw
|
||||
// ts error if form does not pass the validation.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const formInstance = form as types.FormInstance<Schema, TFieldValues, TTransformedValues>
|
||||
const formInstance = form as types.FormInstance<Schema>
|
||||
|
||||
const { field, fieldState, formState } = reactHookForm.useController({
|
||||
name,
|
||||
|
@ -26,15 +26,12 @@ import type * as types from './types'
|
||||
* But be careful, You should not switch between the two types of arguments.
|
||||
* Otherwise you'll be fired
|
||||
*/
|
||||
export function useForm<
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema> = types.FieldValues<Schema>,
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
optionsOrFormInstance:
|
||||
| types.UseFormProps<Schema, TFieldValues>
|
||||
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>,
|
||||
): types.UseFormReturn<Schema, TFieldValues, TTransformedValues> {
|
||||
export function useForm<Schema extends types.TSchema>(
|
||||
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
|
||||
defaultValues?:
|
||||
| reactHookForm.DefaultValues<types.FieldValues<Schema>>
|
||||
| ((payload?: unknown) => Promise<types.FieldValues<Schema>>),
|
||||
): types.UseFormReturn<Schema> {
|
||||
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
|
||||
|
||||
const argsType = getArgsType(optionsOrFormInstance)
|
||||
@ -47,31 +44,45 @@ export function useForm<
|
||||
`,
|
||||
)
|
||||
|
||||
if ('formState' in optionsOrFormInstance) {
|
||||
return optionsOrFormInstance
|
||||
} else {
|
||||
const { schema, ...options } = optionsOrFormInstance
|
||||
const form =
|
||||
'formState' in optionsOrFormInstance ? optionsOrFormInstance : (
|
||||
(() => {
|
||||
const { schema, ...options } = optionsOrFormInstance
|
||||
|
||||
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
||||
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
||||
|
||||
return reactHookForm.useForm<TFieldValues, unknown, TTransformedValues>({
|
||||
...options,
|
||||
resolver: zodResolver.zodResolver(computedSchema, { async: true }),
|
||||
})
|
||||
}
|
||||
return reactHookForm.useForm<
|
||||
types.FieldValues<Schema>,
|
||||
unknown,
|
||||
types.TransformedValues<Schema>
|
||||
>({
|
||||
...options,
|
||||
resolver: zodResolver.zodResolver(computedSchema, { async: true }),
|
||||
})
|
||||
})()
|
||||
)
|
||||
|
||||
const initialDefaultValues = React.useRef(defaultValues)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Expose default values to controlled inputs like `Selector` and `MultiSelector`.
|
||||
// Using `defaultValues` is not sufficient as the value needs to be manually set at least once.
|
||||
const defaults = initialDefaultValues.current
|
||||
if (defaults) {
|
||||
if (typeof defaults !== 'function') {
|
||||
form.reset(defaults)
|
||||
}
|
||||
}
|
||||
}, [form])
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of arguments passed to the useForm hook
|
||||
*/
|
||||
function getArgsType<
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
args:
|
||||
| types.UseFormProps<Schema, TFieldValues>
|
||||
| types.UseFormReturn<Schema, TFieldValues, TTransformedValues>,
|
||||
function getArgsType<Schema extends types.TSchema>(
|
||||
args: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
|
||||
) {
|
||||
return 'formState' in args ? 'formInstance' : 'formOptions'
|
||||
}
|
||||
|
@ -15,26 +15,14 @@ export type * from './components'
|
||||
/**
|
||||
* Props for the Form component
|
||||
*/
|
||||
export type FormProps<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> = BaseFormProps<Schema, TFieldValues, TTransformedValues> &
|
||||
(
|
||||
| FormPropsWithOptions<Schema, TFieldValues>
|
||||
| FormPropsWithParentForm<Schema, TFieldValues, TTransformedValues>
|
||||
)
|
||||
export type FormProps<Schema extends components.TSchema> = BaseFormProps<Schema> &
|
||||
(FormPropsWithOptions<Schema> | FormPropsWithParentForm<Schema>)
|
||||
|
||||
/**
|
||||
* Base props for the Form component.
|
||||
*/
|
||||
interface BaseFormProps<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> extends Omit<
|
||||
interface BaseFormProps<Schema extends components.TSchema>
|
||||
extends Omit<
|
||||
React.HTMLProps<HTMLFormElement>,
|
||||
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
|
||||
>,
|
||||
@ -47,26 +35,18 @@ interface BaseFormProps<
|
||||
* Otherwise Typescript fails to infer the correct type for the form values.
|
||||
* This is a known limitation and we are working on a solution.
|
||||
*/
|
||||
readonly defaultValues?: components.UseFormProps<Schema, TFieldValues>['defaultValues']
|
||||
readonly defaultValues?: components.UseFormProps<Schema>['defaultValues']
|
||||
readonly onSubmit?: (
|
||||
values: TFieldValues,
|
||||
form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>,
|
||||
values: components.TransformedValues<Schema>,
|
||||
form: components.UseFormReturn<Schema>,
|
||||
) => unknown
|
||||
readonly style?:
|
||||
| React.CSSProperties
|
||||
| ((
|
||||
props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>,
|
||||
) => React.CSSProperties)
|
||||
readonly children:
|
||||
| React.ReactNode
|
||||
| ((props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>) => React.ReactNode)
|
||||
readonly formRef?: React.MutableRefObject<
|
||||
components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
>
|
||||
| ((props: FormStateRenderProps<Schema>) => React.CSSProperties)
|
||||
readonly children: React.ReactNode | ((props: FormStateRenderProps<Schema>) => React.ReactNode)
|
||||
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
|
||||
|
||||
readonly className?:
|
||||
| string
|
||||
| ((props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>) => string)
|
||||
readonly className?: string | ((props: FormStateRenderProps<Schema>) => string)
|
||||
|
||||
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
|
||||
readonly onSubmitSuccess?: () => Promise<void> | void
|
||||
@ -86,13 +66,8 @@ interface BaseFormProps<
|
||||
* Props for the Form component with parent form
|
||||
* or if form is passed as a prop.
|
||||
*/
|
||||
interface FormPropsWithParentForm<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> {
|
||||
readonly form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
interface FormPropsWithParentForm<Schema extends components.TSchema> {
|
||||
readonly form: components.UseFormReturn<Schema>
|
||||
readonly schema?: never
|
||||
readonly formOptions?: never
|
||||
}
|
||||
@ -101,42 +76,28 @@ interface FormPropsWithParentForm<
|
||||
* Props for the Form component with schema and form options.
|
||||
* Creates a new form instance. This is the default way to use the form.
|
||||
*/
|
||||
interface FormPropsWithOptions<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
> {
|
||||
interface FormPropsWithOptions<Schema extends components.TSchema> {
|
||||
readonly schema: Schema | ((schema: typeof components.schema) => Schema)
|
||||
readonly form?: never
|
||||
readonly formOptions?: Omit<components.UseFormProps<Schema, TFieldValues>, 'resolver' | 'schema'>
|
||||
readonly formOptions?: Omit<components.UseFormProps<Schema>, 'resolver' | 'schema'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register function for a form field.
|
||||
*/
|
||||
export type UseFormRegister<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
> = <
|
||||
TFieldName extends components.FieldPath<Schema, TFieldValues> = components.FieldPath<
|
||||
Schema,
|
||||
TFieldValues
|
||||
>,
|
||||
export type UseFormRegister<Schema extends components.TSchema> = <
|
||||
TFieldName extends components.FieldPath<Schema> = components.FieldPath<Schema>,
|
||||
>(
|
||||
name: TFieldName,
|
||||
options?: reactHookForm.RegisterOptions<TFieldValues, TFieldName>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
) => UseFormRegisterReturn<Schema, TFieldValues, TFieldName>
|
||||
options?: reactHookForm.RegisterOptions<components.FieldValues<Schema>, TFieldName>,
|
||||
) => UseFormRegisterReturn<Schema, TFieldName>
|
||||
|
||||
/**
|
||||
* UseFormRegister return type.
|
||||
*/
|
||||
export interface UseFormRegisterReturn<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
TFieldName extends components.FieldPath<Schema, TFieldValues> = components.FieldPath<
|
||||
Schema,
|
||||
TFieldValues
|
||||
>,
|
||||
TFieldName extends components.FieldPath<Schema> = components.FieldPath<Schema>,
|
||||
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onBlur' | 'onChange'> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
readonly onChange: <Value>(value: Value) => Promise<boolean | void> | void
|
||||
@ -150,13 +111,8 @@ export interface UseFormRegisterReturn<
|
||||
/**
|
||||
* Form Render Props.
|
||||
*/
|
||||
export type FormStateRenderProps<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> = Pick<
|
||||
components.FormInstance<Schema, TFieldValues, TTransformedValues>,
|
||||
export type FormStateRenderProps<Schema extends components.TSchema> = Pick<
|
||||
components.FormInstance<Schema>,
|
||||
| 'clearErrors'
|
||||
| 'control'
|
||||
| 'formState'
|
||||
@ -166,17 +122,11 @@ export type FormStateRenderProps<
|
||||
| 'setFocus'
|
||||
| 'setValue'
|
||||
| 'unregister'
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
> & {
|
||||
/**
|
||||
* The form register function.
|
||||
* Adds a field to the form state.
|
||||
*/
|
||||
readonly register: UseFormRegister<Schema, TFieldValues>
|
||||
/**
|
||||
* Form Instance
|
||||
*/
|
||||
readonly form: components.FormInstance<Schema, TFieldValues, TTransformedValues>
|
||||
/** The form register function. Adds a field to the form state. */
|
||||
readonly register: UseFormRegister<Schema>
|
||||
/** The form instance. */
|
||||
readonly form: components.FormInstance<Schema>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,20 +136,13 @@ export type FormStateRenderProps<
|
||||
interface FormFieldProps<
|
||||
BaseValueType,
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
TFieldName extends components.FieldPath<Schema, TFieldValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> extends components.FormWithValueValidation<
|
||||
BaseValueType,
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
> {
|
||||
TFieldName extends components.FieldPath<Schema>,
|
||||
> extends components.FormWithValueValidation<BaseValueType, Schema, TFieldName> {
|
||||
readonly name: TFieldName
|
||||
readonly value?: BaseValueType extends TFieldValues[TFieldName] ? TFieldValues[TFieldName] : never
|
||||
readonly defaultValue?: TFieldValues[TFieldName]
|
||||
readonly value?: BaseValueType extends components.FieldValues<Schema>[TFieldName] ?
|
||||
components.FieldValues<Schema>[TFieldName]
|
||||
: never
|
||||
readonly defaultValue?: components.FieldValues<Schema>[TFieldName] | undefined
|
||||
readonly isDisabled?: boolean
|
||||
readonly isRequired?: boolean
|
||||
readonly isInvalid?: boolean
|
||||
@ -212,14 +155,11 @@ export type FieldStateProps<
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
BaseProps extends { value?: unknown },
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
TFieldName extends components.FieldPath<Schema, TFieldValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
> = FormFieldProps<BaseProps['value'], Schema, TFieldValues, TFieldName, TTransformedValues> & {
|
||||
TFieldName extends components.FieldPath<Schema>,
|
||||
> = FormFieldProps<BaseProps['value'], Schema, TFieldName> & {
|
||||
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
|
||||
[K in keyof Omit<
|
||||
BaseProps,
|
||||
keyof FormFieldProps<BaseProps['value'], Schema, TFieldValues, TFieldName, TTransformedValues>
|
||||
keyof FormFieldProps<BaseProps['value'], Schema, TFieldName>
|
||||
>]: BaseProps[K]
|
||||
}
|
||||
|
@ -0,0 +1,219 @@
|
||||
/** @file A date picker. */
|
||||
import { useContext, type ForwardedRef } from 'react'
|
||||
|
||||
import type { DateSegment as DateSegmentType } from 'react-stately'
|
||||
|
||||
import CrossIcon from '#/assets/cross.svg'
|
||||
import ArrowIcon from '#/assets/folder_arrow.svg'
|
||||
import {
|
||||
DatePicker as AriaDatePicker,
|
||||
Calendar,
|
||||
CalendarCell,
|
||||
CalendarGrid,
|
||||
CalendarGridBody,
|
||||
CalendarGridHeader,
|
||||
CalendarHeaderCell,
|
||||
DateInput,
|
||||
DatePickerStateContext,
|
||||
DateSegment,
|
||||
Dialog,
|
||||
Group,
|
||||
Heading,
|
||||
Label,
|
||||
type DatePickerProps as AriaDatePickerProps,
|
||||
type DateValue,
|
||||
} from '#/components/aria'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Popover,
|
||||
Text,
|
||||
type FieldComponentProps,
|
||||
type FieldPath,
|
||||
type FieldProps,
|
||||
type FieldStateProps,
|
||||
type FieldValues,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
const DATE_PICKER_STYLES = tv({
|
||||
base: '',
|
||||
variants: {
|
||||
size: {
|
||||
small: {
|
||||
inputGroup: 'h-6 px-2',
|
||||
},
|
||||
medium: {
|
||||
inputGroup: 'h-8 px-4',
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
inputGroup: 'flex items-center gap-2 rounded-full border-0.5 border-primary/20',
|
||||
dateInput: 'flex justify-center grow',
|
||||
dateSegment: 'rounded placeholder-shown:text-primary/30 focus:bg-primary/10 px-[0.5px]',
|
||||
resetButton: '',
|
||||
calendarPopover: 'w-0',
|
||||
calendarDialog: 'text-primary text-xs',
|
||||
calendarContainer: '',
|
||||
calendarHeader: 'flex items-center mb-2',
|
||||
calendarHeading: 'grow text-center',
|
||||
calendarGrid: '',
|
||||
calendarGridHeader: 'flex',
|
||||
calendarGridHeaderCell: '',
|
||||
calendarGridBody: '',
|
||||
calendarGridCell:
|
||||
'text-center px-1 rounded border border-transparent hover:bg-primary/10 outside-visible-range:text-primary/30 disabled:text-primary/30 selected:border-primary/40',
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
})
|
||||
|
||||
/** Props for a {@link DatePicker}. */
|
||||
export interface DatePickerProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
|
||||
extends FieldStateProps<
|
||||
Omit<
|
||||
AriaDatePickerProps<Extract<FieldValues<Schema>[TFieldName], DateValue>>,
|
||||
'children' | 'className' | 'style'
|
||||
>,
|
||||
Schema,
|
||||
TFieldName
|
||||
>,
|
||||
FieldProps,
|
||||
Pick<FieldComponentProps<Schema>, 'className' | 'style'>,
|
||||
VariantProps<typeof DATE_PICKER_STYLES> {
|
||||
readonly noResetButton?: boolean
|
||||
readonly noCalendarHeader?: boolean
|
||||
readonly segments?: Partial<Record<DateSegmentType['type'], boolean>>
|
||||
}
|
||||
|
||||
/** A date picker. */
|
||||
export const DatePicker = forwardRef(function DatePicker<
|
||||
Schema extends TSchema,
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
>(props: DatePickerProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
|
||||
const {
|
||||
noResetButton = false,
|
||||
noCalendarHeader = false,
|
||||
segments = {},
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
label,
|
||||
isRequired,
|
||||
className,
|
||||
size,
|
||||
} = props
|
||||
|
||||
const { fieldState, formInstance } = Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const styles = DATE_PICKER_STYLES({ size })
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
form={formInstance}
|
||||
name={name}
|
||||
fullWidth
|
||||
label={label}
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
isRequired={isRequired}
|
||||
isInvalid={fieldState.invalid}
|
||||
aria-details={props['aria-details']}
|
||||
ref={ref}
|
||||
style={props.style}
|
||||
>
|
||||
<Controller
|
||||
control={formInstance.control}
|
||||
name={name}
|
||||
render={(renderProps) => {
|
||||
return (
|
||||
<AriaDatePicker className={styles.base({ className })} {...renderProps.field}>
|
||||
<Label />
|
||||
<Group className={styles.inputGroup()}>
|
||||
<Button variant="icon" icon={ArrowIcon} className="rotate-90" />
|
||||
<DateInput className={styles.dateInput()}>
|
||||
{(segment) =>
|
||||
segments[segment.type] === false ?
|
||||
<></>
|
||||
: <DateSegment segment={segment} className={styles.dateSegment()} />
|
||||
}
|
||||
</DateInput>
|
||||
{!noResetButton && <DatePickerResetButton className={styles.resetButton()} />}
|
||||
</Group>
|
||||
{props.description != null && <Text slot="description" />}
|
||||
<Popover size="auto" className={styles.calendarPopover()}>
|
||||
<Dialog className={styles.calendarDialog()}>
|
||||
<Calendar className={styles.calendarContainer()}>
|
||||
<header className={styles.calendarHeader()}>
|
||||
<Button
|
||||
variant="icon"
|
||||
slot="previous"
|
||||
icon={ArrowIcon}
|
||||
className="rotate-180"
|
||||
/>
|
||||
<Heading className={styles.calendarHeading()} />
|
||||
<Button variant="icon" slot="next" icon={ArrowIcon} />
|
||||
</header>
|
||||
<CalendarGrid className={styles.calendarGrid()}>
|
||||
{noCalendarHeader ?
|
||||
<></>
|
||||
: <CalendarGridHeader className={styles.calendarGridHeader()}>
|
||||
{() => <CalendarHeaderCell className={styles.calendarGridHeaderCell()} />}
|
||||
</CalendarGridHeader>
|
||||
}
|
||||
<CalendarGridBody className={styles.calendarGridBody()}>
|
||||
{(date) => (
|
||||
<CalendarCell date={date} className={styles.calendarGridCell()} />
|
||||
)}
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
<Text slot="errorMessage" />
|
||||
</Calendar>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</AriaDatePicker>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Form.Field>
|
||||
)
|
||||
})
|
||||
|
||||
/** Props for a {@link DatePickerResetButton}. */
|
||||
interface DatePickerResetButtonProps {
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
/** A reset button for a {@link DatePicker}. */
|
||||
function DatePickerResetButton(props: DatePickerResetButtonProps) {
|
||||
const { className } = props
|
||||
const state = useContext(DatePickerStateContext)
|
||||
const { getText } = useText()
|
||||
|
||||
return (
|
||||
<Button
|
||||
// Do not inherit default Button behavior from DatePicker.
|
||||
slot={null}
|
||||
variant="icon"
|
||||
aria-label={getText('reset')}
|
||||
icon={CrossIcon}
|
||||
className={className ?? ''}
|
||||
onPress={() => {
|
||||
state.setValue(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
/** @file Barrel file for the DatePicker component. */
|
||||
export * from './DatePicker'
|
@ -0,0 +1,257 @@
|
||||
/** @file A styled dropdown. */
|
||||
import { useEffect, useMemo, useRef, useState, type ForwardedRef, type ReactNode } from 'react'
|
||||
|
||||
import CheckMarkIcon from '#/assets/check_mark.svg'
|
||||
import FolderArrowIcon from '#/assets/folder_arrow.svg'
|
||||
import {
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
mergeProps,
|
||||
useFocusVisible,
|
||||
useFocusWithin,
|
||||
} from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
|
||||
const DROPDOWN_STYLES = tv({
|
||||
base: 'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
|
||||
variants: {
|
||||
isFocused: {
|
||||
true: {
|
||||
container: 'z-1',
|
||||
options: 'before:h-full before:shadow-soft',
|
||||
optionsContainer: 'grid-rows-1fr',
|
||||
input: 'z-1',
|
||||
},
|
||||
false: {
|
||||
container: 'overflow-hidden',
|
||||
options: 'before:h-6 group-hover:before:bg-hover-bg',
|
||||
optionsContainer: 'grid-rows-0fr',
|
||||
},
|
||||
},
|
||||
isReadOnly: {
|
||||
true: {
|
||||
input: 'read-only',
|
||||
},
|
||||
},
|
||||
multiple: {
|
||||
true: {
|
||||
optionsItem: 'hover:font-semibold',
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
container: 'absolute left-0 h-full w-full min-w-max',
|
||||
options:
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors',
|
||||
optionsSpacing: 'padding relative h-6',
|
||||
optionsContainer:
|
||||
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
|
||||
optionsList: 'overflow-hidden',
|
||||
optionsItem:
|
||||
'flex h-6 items-center gap-dropdown-arrow rounded-input px-input-x transition-colors focus:cursor-default focus:bg-frame focus:font-bold focus:focus-ring not-focus:hover:bg-hover-bg not-selected:hover:bg-hover-bg',
|
||||
input: 'relative flex h-6 items-center gap-dropdown-arrow px-input-x',
|
||||
inputDisplay: 'grow select-none',
|
||||
hiddenOptions: 'flex h-0 flex-col overflow-hidden',
|
||||
hiddenOption: 'flex gap-dropdown-arrow px-input-x font-bold',
|
||||
},
|
||||
})
|
||||
|
||||
// ================
|
||||
// === Dropdown ===
|
||||
// ================
|
||||
|
||||
/** Props for a list item child. */
|
||||
interface InternalChildProps<T> {
|
||||
readonly item: T
|
||||
}
|
||||
|
||||
/** Props for the display of the currently selected item, when the dropdown supports multiple children. */
|
||||
interface InternalChildrenProps<T> {
|
||||
readonly items: readonly T[]
|
||||
/** This is the value passed as {@link DropdownProps.children}. */
|
||||
readonly children: (props: InternalChildProps<T>) => ReactNode
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown} shared between all variants. */
|
||||
interface InternalBaseDropdownProps<T> extends InternalChildrenProps<T> {
|
||||
readonly readOnly?: boolean
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown}, when `multiple` is `false` or absent. */
|
||||
interface InternalSingleDropdownProps<T> extends InternalBaseDropdownProps<T> {
|
||||
readonly multiple?: false
|
||||
readonly selectedIndex: number | null
|
||||
readonly onChange: (item: T, index: number) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown}, when `multiple` is `true`. */
|
||||
interface InternalMultipleDropdownProps<T> extends InternalBaseDropdownProps<T> {
|
||||
readonly multiple: true
|
||||
readonly selectedIndices: readonly number[]
|
||||
readonly renderMultiple: (props: InternalChildrenProps<T>) => ReactNode
|
||||
readonly onChange: (items: readonly T[], indices: readonly number[]) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown}. */
|
||||
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
|
||||
|
||||
/** A styled dropdown. */
|
||||
export const Dropdown = forwardRef(function Dropdown<T>(
|
||||
props: DropdownProps<T>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const { readOnly = false, className, items, children: Child } = props
|
||||
const listBoxItems = useMemo(() => items.map((item, i) => ({ item, i })), [items])
|
||||
const [tempSelectedIndex, setTempSelectedIndex] = useState<number | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isFocusWithin, setIsFocusWithin] = useState(false)
|
||||
const [isMouseFocused, setIsMouseFocused] = useState(false)
|
||||
const { isFocusVisible } = useFocusVisible()
|
||||
const isFocusedRef = useSyncRef(isFocusWithin)
|
||||
const isSelfMouseDownRef = useRef(false)
|
||||
const delayedIsFocused = useRef(false)
|
||||
const { focusWithinProps } = useFocusWithin({
|
||||
onFocusWithinChange: setIsFocusWithin,
|
||||
})
|
||||
const multiple = props.multiple === true
|
||||
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
|
||||
const selectedIndices =
|
||||
'selectedIndices' in props ? props.selectedIndices
|
||||
: selectedIndex != null ? [selectedIndex]
|
||||
: []
|
||||
const selectedItems = selectedIndices.flatMap((index) => {
|
||||
const item = items[index]
|
||||
return item != null ? [item] : []
|
||||
})
|
||||
const visuallySelectedIndex = tempSelectedIndex ?? selectedIndex
|
||||
const visuallySelectedItem = visuallySelectedIndex == null ? null : items[visuallySelectedIndex]
|
||||
|
||||
const isFocused = isFocusVisible ? isFocusWithin : isMouseFocused
|
||||
const styles = DROPDOWN_STYLES({ isFocused, isReadOnly: readOnly })
|
||||
|
||||
useEffect(() => {
|
||||
setTempSelectedIndex(selectedIndex)
|
||||
}, [selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
const onDocumentMouseDown = () => {
|
||||
if (!isSelfMouseDownRef.current) {
|
||||
setIsMouseFocused(false)
|
||||
if (document.activeElement === rootRef.current) {
|
||||
rootRef.current?.blur()
|
||||
}
|
||||
isSelfMouseDownRef.current = false
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
}
|
||||
}, [isFocusedRef])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = requestAnimationFrame(() => {
|
||||
delayedIsFocused.current = isFocused
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(handle)
|
||||
}
|
||||
}, [isFocused])
|
||||
|
||||
return (
|
||||
<FocusRing placement="outset">
|
||||
<div
|
||||
ref={mergeRefs(ref, rootRef)}
|
||||
onMouseDown={() => {
|
||||
isSelfMouseDownRef.current = true
|
||||
// `isFocused` cannot be used as `isFocusWithin` is set to `false` immediately before
|
||||
// this event handler is called.
|
||||
setIsMouseFocused(!delayedIsFocused.current)
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className={styles.base({ className })}
|
||||
{...mergeProps<React.JSX.IntrinsicElements['div']>()(focusWithinProps, {
|
||||
onBlur: (event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsMouseFocused(false)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={styles.container()}>
|
||||
<div className={styles.options()}>
|
||||
{/* Spacing. */}
|
||||
<div className={styles.optionsSpacing()} />
|
||||
<div className={styles.optionsContainer()}>
|
||||
<ListBox
|
||||
selectionMode={multiple ? 'multiple' : 'single'}
|
||||
selectionBehavior={multiple ? 'toggle' : 'replace'}
|
||||
items={listBoxItems}
|
||||
dependencies={[selectedIndices]}
|
||||
className={styles.optionsList()}
|
||||
onSelectionChange={(keys) => {
|
||||
if (multiple) {
|
||||
const indices = Array.from(keys, (i) => Number(i))
|
||||
props.onChange(
|
||||
indices.flatMap((i) => {
|
||||
const item = items[i]
|
||||
return item === undefined ? [] : [item]
|
||||
}),
|
||||
indices,
|
||||
)
|
||||
} else {
|
||||
const [key] = keys
|
||||
if (key != null) {
|
||||
const i = Number(key)
|
||||
const item = items[i]
|
||||
if (item !== undefined) {
|
||||
props.onChange(item, i)
|
||||
setIsMouseFocused(false)
|
||||
rootRef.current?.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ item, i }) => (
|
||||
<ListBoxItem key={i} id={i} className={styles.optionsItem()}>
|
||||
<SvgMask
|
||||
src={CheckMarkIcon}
|
||||
className={selectedIndices.includes(i) ? '' : 'invisible'}
|
||||
/>
|
||||
<Child item={item} />
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.input()}>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-90" />
|
||||
<div className={styles.inputDisplay()}>
|
||||
{visuallySelectedItem != null ?
|
||||
<Child item={visuallySelectedItem} />
|
||||
: multiple && <props.renderMultiple items={selectedItems}>{Child}</props.renderMultiple>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden, but required to exist for the width of the parent element to be correct.
|
||||
* Classes that do not affect width have been removed. */}
|
||||
<div className={styles.hiddenOptions()}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={styles.hiddenOption()}>
|
||||
<SvgMask src={CheckMarkIcon} />
|
||||
<Child item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FocusRing>
|
||||
)
|
||||
})
|
@ -0,0 +1,2 @@
|
||||
/** @file Barrel file for the Dropdown component. */
|
||||
export * from './Dropdown'
|
@ -3,63 +3,60 @@
|
||||
*
|
||||
* Basic input component. Input component is a component that is used to get user input in a text field.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import {
|
||||
useRef,
|
||||
type CSSProperties,
|
||||
type ForwardedRef,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from 'react'
|
||||
|
||||
import type * as twv from 'tailwind-variants'
|
||||
import type { VariantProps } from 'tailwind-variants'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
|
||||
import {
|
||||
Form,
|
||||
Text,
|
||||
type FieldComponentProps,
|
||||
type FieldPath,
|
||||
type FieldProps,
|
||||
type FieldStateProps,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import type { ExtractFunction } from '#/utilities/tailwindVariants'
|
||||
import { omit } from 'enso-common/src/utilities/data/object'
|
||||
import { INPUT_STYLES } from '../variants'
|
||||
|
||||
/**
|
||||
* Props for the Input component.
|
||||
*/
|
||||
export interface InputProps<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
> extends ariaComponents.FieldStateProps<
|
||||
Omit<aria.InputProps, 'children' | 'size'>,
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
>,
|
||||
ariaComponents.FieldProps,
|
||||
Omit<twv.VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
|
||||
export interface InputProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
|
||||
extends FieldStateProps<Omit<aria.InputProps, 'children' | 'size'>, Schema, TFieldName>,
|
||||
FieldProps,
|
||||
Omit<VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
|
||||
readonly 'data-testid'?: string | undefined
|
||||
readonly className?: string
|
||||
readonly style?: React.CSSProperties
|
||||
readonly inputRef?: React.Ref<HTMLInputElement>
|
||||
readonly addonStart?: React.ReactNode
|
||||
readonly addonEnd?: React.ReactNode
|
||||
readonly style?: CSSProperties
|
||||
readonly inputRef?: Ref<HTMLInputElement>
|
||||
readonly addonStart?: ReactNode
|
||||
readonly addonEnd?: ReactNode
|
||||
readonly placeholder?: string
|
||||
/** The icon to display in the input. */
|
||||
readonly icon?: React.ReactElement | string | null
|
||||
readonly icon?: ReactElement | string | null
|
||||
readonly variants?: ExtractFunction<typeof INPUT_STYLES> | undefined
|
||||
readonly fieldVariants?: ariaComponents.FieldComponentProps['variants']
|
||||
readonly fieldVariants?: FieldComponentProps<Schema>['variants']
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic input component. Input component is a component that is used to get user input in a text field.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Input = React.forwardRef(function Input<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
||||
) {
|
||||
export const Input = forwardRef(function Input<
|
||||
Schema extends TSchema,
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
>(props: InputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
|
||||
const {
|
||||
name,
|
||||
isDisabled = false,
|
||||
@ -83,9 +80,9 @@ export const Input = React.forwardRef(function Input<
|
||||
...inputProps
|
||||
} = props
|
||||
|
||||
const privateInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const privateInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { fieldState, formInstance } = ariaComponents.Form.useField({
|
||||
const { fieldState, formInstance } = Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
@ -127,7 +124,7 @@ export const Input = React.forwardRef(function Input<
|
||||
})
|
||||
|
||||
return (
|
||||
<ariaComponents.Form.Field
|
||||
<Form.Field
|
||||
data-testid={props['data-testid']}
|
||||
form={formInstance}
|
||||
name={name}
|
||||
@ -156,11 +153,11 @@ export const Input = React.forwardRef(function Input<
|
||||
|
||||
<div className={classes.inputContainer()}>
|
||||
<aria.Input
|
||||
ref={mergeRefs.mergeRefs(inputRef, privateInputRef, fieldRef)}
|
||||
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
|
||||
{...aria.mergeProps<aria.InputProps>()(
|
||||
{ className: classes.textArea(), type, name, min, max, isRequired, isDisabled },
|
||||
{ className: classes.textArea(), type, name, min, max },
|
||||
inputProps,
|
||||
omit(field, 'required', 'disabled'),
|
||||
field,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@ -169,19 +166,11 @@ export const Input = React.forwardRef(function Input<
|
||||
</div>
|
||||
|
||||
{description != null && (
|
||||
<ariaComponents.Text slot="description" className={classes.description()}>
|
||||
<Text slot="description" className={classes.description()}>
|
||||
{description}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</ariaComponents.Form.Field>
|
||||
</Form.Field>
|
||||
)
|
||||
}) as <
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: InputProps<Schema, TFieldValues, TFieldName, TTransformedValues> &
|
||||
React.RefAttributes<HTMLInputElement>,
|
||||
) => React.ReactElement
|
||||
})
|
||||
|
@ -0,0 +1,187 @@
|
||||
/** @file A horizontal selector supporting multiple input. */
|
||||
import { useRef, type CSSProperties, type ForwardedRef, type Ref } from 'react'
|
||||
|
||||
import type { VariantProps } from 'tailwind-variants'
|
||||
|
||||
import {
|
||||
FieldError,
|
||||
ListBox,
|
||||
mergeProps,
|
||||
type ListBoxItemProps,
|
||||
type ListBoxProps,
|
||||
} from '#/components/aria'
|
||||
import {
|
||||
Form,
|
||||
type FieldPath,
|
||||
type FieldProps,
|
||||
type FieldStateProps,
|
||||
type FieldValues,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { MultiSelectorOption } from './MultiSelectorOption'
|
||||
|
||||
/** * Props for the MultiSelector component. */
|
||||
export interface MultiSelectorProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
|
||||
extends FieldStateProps<
|
||||
Omit<ListBoxItemProps, 'children' | 'value'> & { value: FieldValues<Schema>[TFieldName] },
|
||||
Schema,
|
||||
TFieldName
|
||||
>,
|
||||
FieldProps,
|
||||
Omit<VariantProps<typeof MULTI_SELECTOR_STYLES>, 'disabled' | 'invalid'> {
|
||||
readonly items: readonly Extract<FieldValues<Schema>[TFieldName], readonly unknown[]>[number][]
|
||||
readonly itemToString?: (
|
||||
item: Extract<FieldValues<Schema>[TFieldName], readonly unknown[]>[number],
|
||||
) => string
|
||||
readonly columns?: number
|
||||
readonly className?: string
|
||||
readonly style?: CSSProperties
|
||||
readonly inputRef?: Ref<HTMLDivElement>
|
||||
readonly placeholder?: string
|
||||
}
|
||||
|
||||
export const MULTI_SELECTOR_STYLES = tv({
|
||||
base: 'block w-full bg-transparent transition-[border-color,outline] duration-200',
|
||||
variants: {
|
||||
disabled: {
|
||||
true: { base: 'cursor-default opacity-50', textArea: 'cursor-default' },
|
||||
false: { base: 'cursor-text', textArea: 'cursor-text' },
|
||||
},
|
||||
readOnly: { true: 'cursor-default' },
|
||||
size: {
|
||||
medium: { base: '' },
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
small: 'rounded-sm',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
xlarge: 'rounded-xl',
|
||||
xxlarge: 'rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
variant: {
|
||||
outline: {
|
||||
base: 'border-[0.5px] border-primary/20',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: 'xxlarge',
|
||||
variant: 'outline',
|
||||
},
|
||||
slots: {
|
||||
listBox: 'grid',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* A horizontal multi-selector.
|
||||
*/
|
||||
export const MultiSelector = forwardRef(function MultiSelector<
|
||||
Schema extends TSchema,
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
>(props: MultiSelectorProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
|
||||
const {
|
||||
name,
|
||||
items,
|
||||
itemToString = String,
|
||||
isDisabled = false,
|
||||
columns,
|
||||
form,
|
||||
defaultValue,
|
||||
inputRef,
|
||||
label,
|
||||
size,
|
||||
rounded,
|
||||
isRequired = false,
|
||||
...inputProps
|
||||
} = props
|
||||
|
||||
const privateInputRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { fieldState, formInstance } = Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const classes = MULTI_SELECTOR_STYLES({
|
||||
size,
|
||||
rounded,
|
||||
readOnly: inputProps.readOnly,
|
||||
disabled: isDisabled || formInstance.formState.isSubmitting,
|
||||
})
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
form={formInstance}
|
||||
name={name}
|
||||
fullWidth
|
||||
label={label}
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
isRequired={isRequired}
|
||||
isInvalid={fieldState.invalid}
|
||||
aria-details={props['aria-details']}
|
||||
ref={ref}
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
>
|
||||
<div
|
||||
className={classes.base()}
|
||||
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<Controller
|
||||
control={formInstance.control}
|
||||
name={name}
|
||||
render={(renderProps) => {
|
||||
const { ref: fieldRef, value, onChange, ...field } = renderProps.field
|
||||
return (
|
||||
<ListBox
|
||||
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
|
||||
orientation="horizontal"
|
||||
selectionMode="multiple"
|
||||
{...(inputProps.id != null && { id: String(inputProps.id) })}
|
||||
{...mergeProps<ListBoxProps<FieldValues<Schema>[TFieldName]>>()(
|
||||
{
|
||||
className: classes.listBox(),
|
||||
style: { gridTemplateColumns: `repeat(${columns ?? items.length}, 1fr)` },
|
||||
},
|
||||
unsafeRemoveUndefined(omit(inputProps, 'id')),
|
||||
field,
|
||||
)}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')}
|
||||
// This is SAFE, as there is a constraint on `items` that prevents using keys
|
||||
// that do not correspond to array values.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||
defaultSelectedKeys={value?.map((item: FieldValues<Schema>[TFieldName]) =>
|
||||
items.indexOf(item),
|
||||
)}
|
||||
onSelectionChange={(selection) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
onChange([...selection].map((key) => items[Number(key)]))
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<MultiSelectorOption key={i} id={i} value={{ item }} label={itemToString(item)} />
|
||||
))}
|
||||
</ListBox>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FieldError />
|
||||
</Form.Field>
|
||||
)
|
||||
})
|
@ -0,0 +1,69 @@
|
||||
/** @file An option in a selector. */
|
||||
import { ListBoxItem, type ListBoxItemProps } from '#/components/aria'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
import * as React from 'react'
|
||||
import type { VariantProps } from 'tailwind-variants'
|
||||
import { TEXT_STYLE } from '../../Text'
|
||||
|
||||
/** Props for a {@link MultiSelectorOption}. */
|
||||
export interface MultiSelectorOptionProps
|
||||
extends ListBoxItemProps,
|
||||
VariantProps<typeof MULTI_SELECTOR_OPTION_STYLES> {
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
export const MULTI_SELECTOR_OPTION_STYLES = tv({
|
||||
base: TEXT_STYLE({
|
||||
className:
|
||||
'flex flex-1 items-center justify-center min-h-8 relative overflow-clip cursor-pointer transition-[background-color,color,outline-offset] duration-200',
|
||||
variant: 'body',
|
||||
}),
|
||||
variants: {
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
small: 'rounded-sm',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
xlarge: 'rounded-xl',
|
||||
xxlarge: 'rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
size: {
|
||||
medium: { base: 'px-[11px] pb-1.5 pt-2' },
|
||||
small: { base: 'px-[11px] pb-0.5 pt-1' },
|
||||
},
|
||||
variant: {
|
||||
primary:
|
||||
'selected:bg-primary selected:text-white hover:bg-primary/5 pressed:bg-primary/10 outline outline-2 outline-transparent outline-offset-[-2px] focus-visible:outline-primary focus-visible:outline-offset-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: 'xxxlarge',
|
||||
variant: 'primary',
|
||||
},
|
||||
})
|
||||
|
||||
export const MultiSelectorOption = forwardRef(function MultiSelectorOption(
|
||||
props: MultiSelectorOptionProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const { label, ...radioProps } = props
|
||||
const { className } = props
|
||||
|
||||
return (
|
||||
<ListBoxItem
|
||||
ref={ref}
|
||||
{...radioProps}
|
||||
className={(renderProps) =>
|
||||
MULTI_SELECTOR_OPTION_STYLES({
|
||||
className: typeof className === 'function' ? className(renderProps) : className,
|
||||
})
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</ListBoxItem>
|
||||
)
|
||||
})
|
@ -0,0 +1,2 @@
|
||||
/** @file Barrel file for the MultiSelector component. */
|
||||
export * from './MultiSelector'
|
@ -17,20 +17,13 @@ import {
|
||||
// ================
|
||||
|
||||
/** Props for a {@link Password}. */
|
||||
export interface PasswordProps<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends Path<TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
> extends Omit<InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>, 'type'> {}
|
||||
export interface PasswordProps<Schema extends TSchema, TFieldName extends Path<FieldValues<Schema>>>
|
||||
extends Omit<InputProps<Schema, TFieldName>, 'type'> {}
|
||||
|
||||
/** A component wrapping {@link Input} with the ability to show and hide password. */
|
||||
export function Password<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends Path<TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
>(props: PasswordProps<Schema, TFieldValues, TFieldName, TTransformedValues>) {
|
||||
export function Password<Schema extends TSchema, TFieldName extends Path<FieldValues<Schema>>>(
|
||||
props: PasswordProps<Schema, TFieldName>,
|
||||
) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
return (
|
||||
|
@ -1,19 +1,24 @@
|
||||
/**
|
||||
* @file A resizable input that uses a content-editable div.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { useRef, type ClipboardEvent, type ForwardedRef, type HTMLAttributes } from 'react'
|
||||
|
||||
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
|
||||
import {
|
||||
Form,
|
||||
Text,
|
||||
type FieldPath,
|
||||
type FieldProps,
|
||||
type FieldStateProps,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||
import { INPUT_STYLES } from '../variants'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import * as variants from '../variants'
|
||||
|
||||
const CONTENT_EDITABLE_STYLES = twv.tv({
|
||||
extend: variants.INPUT_STYLES,
|
||||
const CONTENT_EDITABLE_STYLES = tv({
|
||||
extend: INPUT_STYLES,
|
||||
base: '',
|
||||
slots: { placeholder: 'opacity-50 absolute inset-0 pointer-events-none' },
|
||||
})
|
||||
@ -22,19 +27,11 @@ const CONTENT_EDITABLE_STYLES = twv.tv({
|
||||
* Props for a {@link ResizableContentEditableInput}.
|
||||
*/
|
||||
export interface ResizableContentEditableInputProps<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
> extends ariaComponents.FieldStateProps<
|
||||
React.HTMLAttributes<HTMLDivElement> & { value: string },
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
>,
|
||||
Omit<ariaComponents.FieldProps, 'variant'>,
|
||||
Omit<twv.VariantProps<typeof variants.INPUT_STYLES>, 'disabled' | 'invalid'> {
|
||||
Schema extends TSchema,
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
> extends FieldStateProps<HTMLAttributes<HTMLDivElement> & { value: string }, Schema, TFieldName>,
|
||||
Omit<FieldProps, 'variant'>,
|
||||
Omit<VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
|
||||
/**
|
||||
* onChange is called when the content of the input changes.
|
||||
* There is no way to prevent the change, so the value is always the new value.
|
||||
@ -49,117 +46,98 @@ export interface ResizableContentEditableInputProps<
|
||||
* A resizable input that uses a content-editable div.
|
||||
* This component might be useful for a text input that needs to have highlighted content inside of it.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const ResizableContentEditableInput = React.forwardRef(
|
||||
function ResizableContentEditableInput<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema> = ariaComponents.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: ResizableContentEditableInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const {
|
||||
placeholder = '',
|
||||
description = null,
|
||||
name,
|
||||
isDisabled = false,
|
||||
form,
|
||||
defaultValue,
|
||||
size,
|
||||
rounded,
|
||||
variant,
|
||||
...textFieldProps
|
||||
} = props
|
||||
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const onPaste = eventCallbackHooks.useEventCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
// Prevent pasting styled text.
|
||||
event.preventDefault()
|
||||
// sanitize the pasted text
|
||||
// replace all < with < to prevent XSS
|
||||
const text = event.clipboardData
|
||||
.getData('text/plain')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
document.execCommand('insertHTML', false, text)
|
||||
},
|
||||
)
|
||||
|
||||
const { field, fieldState, formInstance } = ariaComponents.Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const {
|
||||
base,
|
||||
description: descriptionClass,
|
||||
inputContainer,
|
||||
textArea,
|
||||
placeholder: placeholderClass,
|
||||
} = CONTENT_EDITABLE_STYLES({
|
||||
variant,
|
||||
invalid: fieldState.invalid,
|
||||
disabled: isDisabled || formInstance.formState.isSubmitting,
|
||||
rounded,
|
||||
size,
|
||||
})
|
||||
|
||||
return (
|
||||
<ariaComponents.Form.Field form={formInstance} name={name} fullWidth {...textFieldProps}>
|
||||
<div
|
||||
className={base()}
|
||||
onClick={() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<div className={inputContainer()}>
|
||||
<div
|
||||
className={textArea()}
|
||||
ref={mergeRefs.mergeRefs(inputRef, ref, field.ref)}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
aria-autocomplete="none"
|
||||
onPaste={onPaste}
|
||||
onBlur={field.onBlur}
|
||||
onInput={(event) => {
|
||||
field.onChange(event.currentTarget.textContent ?? '')
|
||||
}}
|
||||
/>
|
||||
|
||||
<ariaComponents.Text
|
||||
className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}
|
||||
>
|
||||
{placeholder}
|
||||
</ariaComponents.Text>
|
||||
</div>
|
||||
|
||||
{description != null && (
|
||||
<ariaComponents.Text slot="description" className={descriptionClass()}>
|
||||
{description}
|
||||
</ariaComponents.Text>
|
||||
)}
|
||||
</div>
|
||||
</ariaComponents.Form.Field>
|
||||
)
|
||||
},
|
||||
) as <
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema> = ariaComponents.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
export const ResizableContentEditableInput = forwardRef(function ResizableContentEditableInput<
|
||||
Schema extends TSchema,
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
>(
|
||||
props: React.RefAttributes<HTMLDivElement> &
|
||||
ResizableContentEditableInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
) => React.JSX.Element
|
||||
props: ResizableContentEditableInputProps<Schema, TFieldName>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const {
|
||||
placeholder = '',
|
||||
description = null,
|
||||
name,
|
||||
isDisabled = false,
|
||||
form,
|
||||
defaultValue,
|
||||
size,
|
||||
rounded,
|
||||
variant,
|
||||
...textFieldProps
|
||||
} = props
|
||||
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onPaste = useEventCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
||||
// Prevent pasting styled text.
|
||||
event.preventDefault()
|
||||
// sanitize the pasted text
|
||||
// replace all < with < to prevent XSS
|
||||
const text = event.clipboardData
|
||||
.getData('text/plain')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
document.execCommand('insertHTML', false, text)
|
||||
})
|
||||
|
||||
const { field, fieldState, formInstance } = Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const {
|
||||
base,
|
||||
description: descriptionClass,
|
||||
inputContainer,
|
||||
textArea,
|
||||
placeholder: placeholderClass,
|
||||
} = CONTENT_EDITABLE_STYLES({
|
||||
variant,
|
||||
invalid: fieldState.invalid,
|
||||
disabled: isDisabled || formInstance.formState.isSubmitting,
|
||||
rounded,
|
||||
size,
|
||||
})
|
||||
|
||||
return (
|
||||
<Form.Field form={formInstance} name={name} fullWidth {...textFieldProps}>
|
||||
<div
|
||||
className={base()}
|
||||
onClick={() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<div className={inputContainer()}>
|
||||
<div
|
||||
className={textArea()}
|
||||
ref={mergeRefs(inputRef, ref, field.ref)}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
aria-autocomplete="none"
|
||||
onPaste={onPaste}
|
||||
onBlur={field.onBlur}
|
||||
onInput={(event) => {
|
||||
field.onChange(event.currentTarget.textContent ?? '')
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}>
|
||||
{placeholder}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{description != null && (
|
||||
<Text slot="description" className={descriptionClass()}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Form.Field>
|
||||
)
|
||||
})
|
||||
|
@ -9,6 +9,7 @@ import * as aria from '#/components/aria'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as variants from '../variants'
|
||||
|
||||
/**
|
||||
@ -22,7 +23,7 @@ export interface ResizableInputProps extends aria.TextFieldProps {
|
||||
/**
|
||||
* A resizable input field.
|
||||
*/
|
||||
export const ResizableInput = React.forwardRef(function ResizableInput(
|
||||
export const ResizableInput = forwardRef(function ResizableInput(
|
||||
props: ResizableInputProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>,
|
||||
) {
|
||||
|
@ -12,36 +12,29 @@ import {
|
||||
Form,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
|
||||
import RadioGroup from '#/components/styled/RadioGroup'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { SelectorOption } from './SelectorOption'
|
||||
|
||||
/** * Props for the Selector component. */
|
||||
export interface SelectorProps<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
> extends FieldStateProps<
|
||||
Omit<RadioGroupProps, 'children' | 'value'> & { value: TFieldValues[TFieldName] },
|
||||
export interface SelectorProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
|
||||
extends FieldStateProps<
|
||||
Omit<RadioGroupProps, 'children' | 'value'> & { value: FieldValues<Schema>[TFieldName] },
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
TFieldName
|
||||
>,
|
||||
FieldProps,
|
||||
Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'> {
|
||||
readonly items: readonly TFieldValues[TFieldName][]
|
||||
readonly itemToString?: (item: TFieldValues[TFieldName]) => string
|
||||
readonly items: readonly FieldValues<Schema>[TFieldName][]
|
||||
readonly children?: (item: FieldValues<Schema>[TFieldName]) => string
|
||||
readonly columns?: number
|
||||
readonly className?: string
|
||||
readonly style?: React.CSSProperties
|
||||
readonly inputRef?: React.Ref<HTMLDivElement>
|
||||
readonly placeholder?: string
|
||||
readonly readOnly?: boolean
|
||||
}
|
||||
|
||||
export const SELECTOR_STYLES = tv({
|
||||
@ -84,21 +77,16 @@ export const SELECTOR_STYLES = tv({
|
||||
/**
|
||||
* A horizontal selector.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Selector = React.forwardRef(function Selector<
|
||||
export const Selector = forwardRef(function Selector<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: SelectorProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
||||
) {
|
||||
TFieldName extends FieldPath<Schema>,
|
||||
>(props: SelectorProps<Schema, TFieldName>, ref: React.ForwardedRef<HTMLFieldSetElement>) {
|
||||
const {
|
||||
name,
|
||||
items,
|
||||
itemToString = String,
|
||||
children = String,
|
||||
isDisabled = false,
|
||||
columns,
|
||||
form,
|
||||
defaultValue,
|
||||
inputRef,
|
||||
@ -115,7 +103,7 @@ export const Selector = React.forwardRef(function Selector<
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
...(defaultValue != null ? { defaultValue } : {}),
|
||||
})
|
||||
|
||||
const classes = SELECTOR_STYLES({
|
||||
@ -154,7 +142,14 @@ export const Selector = React.forwardRef(function Selector<
|
||||
<RadioGroup
|
||||
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
|
||||
{...mergeProps<RadioGroupProps>()(
|
||||
{ className: classes.radioGroup(), name, isRequired, isDisabled },
|
||||
{
|
||||
className: classes.radioGroup(),
|
||||
name,
|
||||
isRequired,
|
||||
isDisabled,
|
||||
style:
|
||||
columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {},
|
||||
},
|
||||
inputProps,
|
||||
field,
|
||||
)}
|
||||
@ -167,7 +162,7 @@ export const Selector = React.forwardRef(function Selector<
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<SelectorOption value={String(i)} label={itemToString(item)} />
|
||||
<SelectorOption key={i} value={String(i)} label={children(item)} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
@ -176,12 +171,4 @@ export const Selector = React.forwardRef(function Selector<
|
||||
</div>
|
||||
</Form.Field>
|
||||
)
|
||||
}) as <
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: React.RefAttributes<HTMLDivElement> &
|
||||
SelectorProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
) => React.ReactElement
|
||||
})
|
||||
|
@ -1,5 +1,6 @@
|
||||
/** @file An option in a selector. */
|
||||
import { Radio, type RadioProps } from '#/components/aria'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
import * as React from 'react'
|
||||
import type { VariantProps } from 'tailwind-variants'
|
||||
@ -45,7 +46,7 @@ export const SELECTOR_OPTION_STYLES = tv({
|
||||
},
|
||||
})
|
||||
|
||||
export const SelectorOption = React.forwardRef(function SelectorOption(
|
||||
export const SelectorOption = forwardRef(function SelectorOption(
|
||||
props: SelectorOptionProps,
|
||||
ref: React.ForwardedRef<HTMLLabelElement>,
|
||||
) {
|
||||
|
@ -4,7 +4,10 @@
|
||||
* Barrel export file for Inputs
|
||||
*/
|
||||
|
||||
export * from './DatePicker'
|
||||
export * from './Dropdown'
|
||||
export * from './Input'
|
||||
export * from './MultiSelector'
|
||||
export * from './Password'
|
||||
export * from './ResizableInput'
|
||||
export * from './Selector'
|
||||
|
@ -11,6 +11,7 @@ import * as aria from '#/components/aria'
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as text from '../Text'
|
||||
import * as radioGroup from './RadioGroup'
|
||||
import * as radioGroupContext from './RadioGroupContext'
|
||||
@ -56,7 +57,7 @@ export interface RadioProps extends aria.RadioProps {
|
||||
* A radio button.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Radio = React.forwardRef(function Radio(
|
||||
export const Radio = forwardRef(function Radio(
|
||||
props: RadioProps,
|
||||
ref: React.ForwardedRef<HTMLLabelElement>,
|
||||
) {
|
||||
|
@ -10,6 +10,7 @@ import * as aria from '#/components/aria'
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as formComponent from '../Form'
|
||||
import * as radioGroupContext from './RadioGroupContext'
|
||||
|
||||
@ -18,15 +19,11 @@ import * as radioGroupContext from './RadioGroupContext'
|
||||
*/
|
||||
export interface RadioGroupProps<
|
||||
Schema extends formComponent.TSchema,
|
||||
TFieldValues extends formComponent.FieldValues<Schema>,
|
||||
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
|
||||
TFieldName extends formComponent.FieldPath<Schema>,
|
||||
> extends formComponent.FieldStateProps<
|
||||
Omit<aria.AriaRadioGroupProps, 'description' | 'label'>,
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
TFieldName
|
||||
>,
|
||||
twv.VariantProps<typeof RADIO_GROUP_STYLES>,
|
||||
formComponent.FieldProps {
|
||||
@ -43,15 +40,10 @@ export const RADIO_GROUP_STYLES = twv.tv({
|
||||
* A radio group component.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const RadioGroup = React.forwardRef(function RadioGroup<
|
||||
export const RadioGroup = forwardRef(function RadioGroup<
|
||||
Schema extends formComponent.TSchema,
|
||||
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
|
||||
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
|
||||
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
TFieldName extends formComponent.FieldPath<Schema>,
|
||||
>(props: RadioGroupProps<Schema, TFieldName>, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const {
|
||||
children,
|
||||
isRequired = false,
|
||||
@ -109,13 +101,4 @@ export const RadioGroup = React.forwardRef(function RadioGroup<
|
||||
</radioGroupContext.RadioGroupProvider>
|
||||
</aria.RadioGroup>
|
||||
)
|
||||
}) as <
|
||||
Schema extends formComponent.TSchema,
|
||||
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
|
||||
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues> &
|
||||
React.RefAttributes<HTMLFormElement>,
|
||||
) => React.JSX.Element
|
||||
})
|
||||
|
@ -8,6 +8,7 @@ import * as aria from '#/components/aria'
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as textProvider from './TextProvider'
|
||||
import * as visualTooltip from './useVisualTooltip'
|
||||
|
||||
@ -17,6 +18,7 @@ import * as visualTooltip from './useVisualTooltip'
|
||||
export interface TextProps
|
||||
extends Omit<aria.TextProps, 'color'>,
|
||||
twv.VariantProps<typeof TEXT_STYLE> {
|
||||
readonly elementType?: keyof HTMLElementTagNameMap
|
||||
readonly lineClamp?: number
|
||||
readonly tooltip?: React.ReactElement | string | false | null
|
||||
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
|
||||
@ -116,10 +118,7 @@ export const TEXT_STYLE = twv.tv({
|
||||
* Text component that supports truncation and show a tooltip on hover when text is truncated
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Text = React.forwardRef(function Text(
|
||||
props: TextProps,
|
||||
ref: React.Ref<HTMLSpanElement>,
|
||||
) {
|
||||
export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HTMLSpanElement>) {
|
||||
const {
|
||||
className,
|
||||
variant,
|
||||
@ -185,8 +184,8 @@ export const Text = React.forwardRef(function Text(
|
||||
|
||||
return (
|
||||
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
|
||||
{/* @ts-expect-error We suppose that elementType is a valid HTML element */}
|
||||
<ElementType
|
||||
// @ts-expect-error This is caused by the type-safe `elementType` type.
|
||||
ref={mergeRefs.mergeRefs(ref, textElementRef)}
|
||||
className={textClasses}
|
||||
{...aria.mergeProps<React.HTMLAttributes<HTMLElement>>()(
|
||||
@ -207,7 +206,7 @@ export const Text = React.forwardRef(function Text(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Heading: React.FC<HeadingProps>
|
||||
Heading: typeof Heading
|
||||
}
|
||||
|
||||
/**
|
||||
@ -221,10 +220,12 @@ export interface HeadingProps extends Omit<TextProps, 'elementType'> {
|
||||
/**
|
||||
* Heading component
|
||||
*/
|
||||
Text.Heading = React.forwardRef(function Heading(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const Heading = forwardRef(function Heading(
|
||||
props: HeadingProps,
|
||||
ref: React.Ref<HTMLHeadingElement>,
|
||||
) {
|
||||
const { level = 1, ...textProps } = props
|
||||
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
|
||||
})
|
||||
Text.Heading = Heading
|
||||
|
@ -35,6 +35,12 @@ export interface VisualTooltipProps
|
||||
readonly testId?: string
|
||||
}
|
||||
|
||||
/** The return value of the {@link useVisualTooltip} hook. */
|
||||
export interface VisualTooltipReturn {
|
||||
readonly targetProps: aria.DOMAttributes<aria.FocusableElement> & { readonly id: string }
|
||||
readonly tooltip: JSX.Element | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The display strategy for the tooltip.
|
||||
*/
|
||||
@ -50,7 +56,7 @@ const DEFAULT_DELAY = 250
|
||||
* Common use case is to show a tooltip when the content of an element is overflowing,
|
||||
* Or show a description of the element when hovered over.
|
||||
*/
|
||||
export function useVisualTooltip(props: VisualTooltipProps) {
|
||||
export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn {
|
||||
const {
|
||||
children,
|
||||
targetRef,
|
||||
@ -137,11 +143,15 @@ export function useVisualTooltip(props: VisualTooltipProps) {
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'aria-hidden': true,
|
||||
// Note that this is a `@ts-expect-error` so that an update to the outdated type
|
||||
// definitions will notify that this `@ts-expect-error` can be safely removed.
|
||||
// @ts-expect-error This is a new DOM property.
|
||||
popover: '',
|
||||
role: 'presentation',
|
||||
'data-testid': testId,
|
||||
// Remove z-index from the overlay style
|
||||
// because it's not needed(we show latest element on top) and can cause issues with stacking context
|
||||
// Remove z-index from the overlay style because it is not needed.
|
||||
// We show the latest element on top, and z-index can cause issues with
|
||||
// the stacking context.
|
||||
style: { zIndex: '' },
|
||||
},
|
||||
)}
|
||||
|
@ -5,6 +5,7 @@
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
/**
|
||||
@ -17,9 +18,11 @@ export const VISUALLY_HIDDEN_STYLES = twv.tv({ base: 'sr-only' })
|
||||
/**
|
||||
* A component visually hides its children from the screen, but keeps them accessible to screen readers.
|
||||
*/
|
||||
export const VisuallyHidden = React.forwardRef<HTMLSpanElement, VisuallyHiddenProps>(
|
||||
function VisuallyHidden(props, ref) {
|
||||
const { className } = props
|
||||
return <span ref={ref} className={VISUALLY_HIDDEN_STYLES({ className })} {...props} />
|
||||
},
|
||||
)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const VisuallyHidden = forwardRef(function VisuallyHidden(
|
||||
props: VisuallyHiddenProps,
|
||||
ref: React.ForwardedRef<HTMLSpanElement>,
|
||||
) {
|
||||
const { className } = props
|
||||
return <span ref={ref} className={VISUALLY_HIDDEN_STYLES({ className })} {...props} />
|
||||
})
|
||||
|
@ -32,7 +32,7 @@ interface InternalBaseAutocompleteProps<T> {
|
||||
/** This may change as the user types in the input. */
|
||||
readonly items: readonly T[]
|
||||
readonly itemToKey: (item: T) => string
|
||||
readonly itemToString: (item: T) => string
|
||||
readonly children: (item: T) => string
|
||||
readonly itemsToString?: (items: T[]) => string
|
||||
readonly matches: (item: T, text: string) => boolean
|
||||
readonly text?: string | null
|
||||
@ -80,7 +80,7 @@ export type AutocompleteProps<T> = (
|
||||
/** A select menu with a dropdown. */
|
||||
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
|
||||
const { text, setText, autoFocus = false, items, itemToKey, itemToString, itemsToString } = props
|
||||
const { text, setText, autoFocus = false, items, itemToKey, children, itemsToString } = props
|
||||
const { matches } = props
|
||||
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
||||
@ -233,7 +233,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
{itemsToString?.(values) ?? (values[0] != null ? children(values[0]) : ZWSP)}
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
@ -275,7 +275,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
}}
|
||||
>
|
||||
<Text truncate="1" className="w-full" tooltipPlacement="left">
|
||||
{itemToString(item)}
|
||||
{children(item)}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
|
@ -12,6 +12,7 @@ import RadioGroup from '#/components/styled/RadioGroup'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
/** Props for a {@link ColorPickerItem}. */
|
||||
@ -55,6 +56,9 @@ export interface ColorPickerProps extends Readonly<aria.RadioGroupProps> {
|
||||
readonly setColor: (color: backend.LChColor) => void
|
||||
}
|
||||
|
||||
/** A color picker to select from a predetermined list of colors. */
|
||||
export default forwardRef(ColorPicker)
|
||||
|
||||
/** A color picker to select from a predetermined list of colors. */
|
||||
function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const { pickerClassName = '', children, setColor, ...radioGroupProps } = props
|
||||
@ -79,6 +83,3 @@ function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivEle
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/** A color picker to select from a predetermined list of colors. */
|
||||
export default React.forwardRef(ColorPicker)
|
||||
|
@ -5,6 +5,7 @@ import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// =================
|
||||
@ -28,6 +29,8 @@ export interface ContextMenusProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
|
||||
}
|
||||
|
||||
export default forwardRef(ContextMenus)
|
||||
|
||||
/** A context menu that opens at the current mouse position. */
|
||||
function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const { hidden = false, children, event } = props
|
||||
@ -55,5 +58,3 @@ function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivE
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export default React.forwardRef(ContextMenus)
|
||||
|
@ -1,113 +0,0 @@
|
||||
/** @file Styled input element. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The delay to wait before checking input validity. */
|
||||
const DEBOUNCE_MS = 1000
|
||||
|
||||
// =======================
|
||||
// === ControlledInput ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link ControlledInput}. */
|
||||
export interface ControlledInputProps extends Readonly<aria.InputProps> {
|
||||
readonly value: string
|
||||
readonly error?: string
|
||||
readonly validate?: boolean
|
||||
readonly setValue: (value: string) => void
|
||||
readonly shouldReportValidityRef?: React.MutableRefObject<boolean>
|
||||
}
|
||||
|
||||
/** A component for authentication from inputs, with preset styles. */
|
||||
export default function ControlledInput(props: ControlledInputProps) {
|
||||
const {
|
||||
setValue,
|
||||
error,
|
||||
validate = false,
|
||||
shouldReportValidityRef,
|
||||
onKeyDown,
|
||||
onChange,
|
||||
onBlur,
|
||||
...inputProps
|
||||
} = props
|
||||
const [reportTimeoutHandle, setReportTimeoutHandle] = React.useState<number | null>(null)
|
||||
const [hasReportedValidity, setHasReportedValidity] = React.useState(false)
|
||||
const [wasJustBlurred, setWasJustBlurred] = React.useState(false)
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
|
||||
return (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
{...aria.mergeProps<aria.InputProps>()(inputProps, focusChildProps, {
|
||||
className:
|
||||
'w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100',
|
||||
onKeyDown: (event) => {
|
||||
if (!event.isPropagationStopped()) {
|
||||
onKeyDown?.(event)
|
||||
}
|
||||
},
|
||||
onChange: (event) => {
|
||||
onChange?.(event)
|
||||
setValue(event.target.value)
|
||||
setWasJustBlurred(false)
|
||||
if (validate) {
|
||||
if (reportTimeoutHandle != null) {
|
||||
window.clearTimeout(reportTimeoutHandle)
|
||||
}
|
||||
const currentTarget = event.currentTarget
|
||||
if (error != null) {
|
||||
currentTarget.setCustomValidity('')
|
||||
currentTarget.setCustomValidity(
|
||||
currentTarget.checkValidity() || shouldReportValidityRef?.current === false ?
|
||||
''
|
||||
: error,
|
||||
)
|
||||
}
|
||||
if (hasReportedValidity) {
|
||||
if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) {
|
||||
setHasReportedValidity(false)
|
||||
}
|
||||
} else {
|
||||
setReportTimeoutHandle(
|
||||
window.setTimeout(() => {
|
||||
if (
|
||||
shouldReportValidityRef?.current !== false &&
|
||||
!currentTarget.reportValidity()
|
||||
) {
|
||||
setHasReportedValidity(true)
|
||||
}
|
||||
}, DEBOUNCE_MS),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBlur:
|
||||
validate ?
|
||||
(event) => {
|
||||
onBlur?.(event)
|
||||
if (wasJustBlurred) {
|
||||
setHasReportedValidity(false)
|
||||
} else {
|
||||
const currentTarget = event.currentTarget
|
||||
if (shouldReportValidityRef?.current !== false) {
|
||||
if (!currentTarget.reportValidity()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
setWasJustBlurred(true)
|
||||
}
|
||||
}
|
||||
: onBlur,
|
||||
})}
|
||||
/>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
@ -1,262 +0,0 @@
|
||||
/** @file An input that outputs a {@link Date}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CrossIcon from '#/assets/cross.svg'
|
||||
import FolderArrowIcon from '#/assets/folder_arrow.svg'
|
||||
import FolderArrowDoubleIcon from '#/assets/folder_arrow_double.svg'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The number of days in a week. */
|
||||
const DAYS_IN_WEEK = 7
|
||||
/** The month index of the last month i the year (December). */
|
||||
const LAST_MONTH_INDEX = 11
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Information required to render a specific day. */
|
||||
interface DayInfo {
|
||||
readonly monthOffset: number
|
||||
readonly date: number
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === DateInputProps ===
|
||||
// ======================
|
||||
|
||||
/** Props for a {@link DateInput}. */
|
||||
export interface DateInputProps {
|
||||
readonly date: Date | null
|
||||
readonly onInput: (date: Date | null) => void
|
||||
}
|
||||
|
||||
/** An input that outputs a {@link Date}. */
|
||||
export default function DateInput(props: DateInputProps) {
|
||||
const { date, onInput } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
const year = date?.getFullYear() ?? new Date().getFullYear()
|
||||
const monthIndex = date?.getMonth() ?? new Date().getMonth()
|
||||
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
|
||||
const [selectedYear, setSelectedYear] = React.useState(year)
|
||||
const [selectedMonthIndex, setSelectedMonthIndex] = React.useState(monthIndex)
|
||||
const month = React.useMemo<readonly (readonly DayInfo[])[]>(() => {
|
||||
const currentDay = new Date(selectedYear, selectedMonthIndex, 1)
|
||||
const dayOfWeek = (currentDay.getDay() + DAYS_IN_WEEK - 1) % DAYS_IN_WEEK
|
||||
currentDay.setDate(currentDay.getDate() - dayOfWeek)
|
||||
const result: DayInfo[][] = []
|
||||
let lastMonthIndex = currentDay.getMonth()
|
||||
let monthOffset = currentDay.getMonth() === selectedMonthIndex ? 0 : -1
|
||||
do {
|
||||
const week: DayInfo[] = []
|
||||
for (let i = 0; i < DAYS_IN_WEEK; i += 1) {
|
||||
week.push({ monthOffset, date: currentDay.getDate() })
|
||||
currentDay.setDate(currentDay.getDate() + 1)
|
||||
if (currentDay.getMonth() !== lastMonthIndex) {
|
||||
monthOffset += 1
|
||||
lastMonthIndex = currentDay.getMonth()
|
||||
}
|
||||
}
|
||||
result.push(week)
|
||||
// This MUST be a `do ... while ...` loop since the first day may not be in the same month.
|
||||
} while (currentDay.getMonth() === selectedMonthIndex)
|
||||
return result
|
||||
}, [selectedYear, selectedMonthIndex])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedYear(year)
|
||||
setSelectedMonthIndex(monthIndex)
|
||||
}, [year, monthIndex])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onClick = () => {
|
||||
setIsPickerVisible(false)
|
||||
}
|
||||
document.addEventListener('click', onClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<FocusRing>
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
className: tailwindMerge.twMerge(
|
||||
'flex h-text w-date-picker items-center rounded-full border border-primary/10 px-date-input transition-colors hover:[&:not(:has(button:hover))]:bg-hover-bg',
|
||||
date == null && 'placeholder',
|
||||
),
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
setIsPickerVisible(!isPickerVisible)
|
||||
},
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === 'Space') {
|
||||
event.stopPropagation()
|
||||
setIsPickerVisible(!isPickerVisible)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className="flex grow flex-col items-center">
|
||||
{date != null ? dateTime.formatDate(date) : getText('noDateSelected')}
|
||||
</div>
|
||||
{date != null && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
onInput(null)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>
|
||||
</FocusRing>
|
||||
{isPickerVisible && (
|
||||
<div className="absolute left-1/2 top-text-h z-1 mt-date-input-gap">
|
||||
<div
|
||||
className={ariaComponents.DIALOG_BACKGROUND({
|
||||
className:
|
||||
'relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft',
|
||||
})}
|
||||
>
|
||||
<div className="relative mb-date-input-gap">
|
||||
<div className="flex items-center">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
setSelectedYear(selectedYear - 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
if (selectedMonthIndex === 0) {
|
||||
setSelectedYear(selectedYear - 1)
|
||||
setSelectedMonthIndex(LAST_MONTH_INDEX)
|
||||
} else {
|
||||
setSelectedMonthIndex(selectedMonthIndex - 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-180" />
|
||||
</ariaComponents.Button>
|
||||
<aria.Text className="grow text-center">
|
||||
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
|
||||
</aria.Text>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
if (selectedMonthIndex === LAST_MONTH_INDEX) {
|
||||
setSelectedYear(selectedYear + 1)
|
||||
setSelectedMonthIndex(0)
|
||||
} else {
|
||||
setSelectedMonthIndex(selectedMonthIndex + 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} />
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
setSelectedYear(selectedYear + 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} />
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="relative w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-tight min-w-date-cell p">{getText('mondayAbbr')}</th>
|
||||
<th className="text-tight min-w-date-cell p">{getText('tuesdayAbbr')}</th>
|
||||
<th className="text-tight min-w-date-cell p">{getText('wednesdayAbbr')}</th>
|
||||
<th className="text-tight min-w-date-cell p">{getText('thursdayAbbr')}</th>
|
||||
<th className="text-tight min-w-date-cell p">{getText('fridayAbbr')}</th>
|
||||
<th className="text-tight min-w-date-cell p">{getText('saturdayAbbr')}</th>
|
||||
<th className="text-tight min-w-date-cell p">{getText('sundayAbbr')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{month.map((week, i) => (
|
||||
<tr key={i}>
|
||||
{week.map((day, j) => {
|
||||
const currentDate = new Date(
|
||||
selectedYear,
|
||||
selectedMonthIndex + day.monthOffset,
|
||||
day.date,
|
||||
)
|
||||
const isSelectedDate =
|
||||
date != null &&
|
||||
currentDate.getFullYear() === year &&
|
||||
currentDate.getMonth() === monthIndex &&
|
||||
currentDate.getDate() === date.getDate()
|
||||
return (
|
||||
<td key={j} className="text-tight p">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isSelectedDate}
|
||||
className={tailwindMerge.twMerge(
|
||||
'w-full rounded-small-rectangle-button text-center hover:bg-primary/10 disabled:bg-frame disabled:font-bold',
|
||||
day.monthOffset !== 0 && 'opacity-unimportant',
|
||||
)}
|
||||
onPress={() => {
|
||||
setIsPickerVisible(false)
|
||||
onInput(currentDate)
|
||||
}}
|
||||
>
|
||||
{day.date}
|
||||
</ariaComponents.Button>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -23,11 +23,24 @@ import {
|
||||
} from '#/providers/EnsoDevtoolsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Switch } from '#/components/aria'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Separator,
|
||||
Text,
|
||||
} from '#/components/AriaComponents'
|
||||
import Portal from '#/components/Portal'
|
||||
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
import * as backend from '#/services/Backend'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
|
||||
|
||||
/**
|
||||
* Configuration for a paywall feature.
|
||||
@ -64,6 +77,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
const { authQueryKey, session } = authProvider.useAuth()
|
||||
const enableVersionChecker = useEnableVersionChecker()
|
||||
const setEnableVersionChecker = useSetEnableVersionChecker()
|
||||
const { localStorage } = useLocalStorage()
|
||||
|
||||
const [features, setFeatures] = React.useState<
|
||||
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
|
||||
@ -92,8 +106,8 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
{children}
|
||||
|
||||
<Portal>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
icon={DevtoolsLogo}
|
||||
aria-label={getText('paywallDevtoolsButtonLabel')}
|
||||
variant="icon"
|
||||
@ -103,27 +117,25 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
data-ignore-click-outside
|
||||
/>
|
||||
|
||||
<ariaComponents.Popover>
|
||||
<ariaComponents.Text.Heading disableLineHeightCompensation>
|
||||
<Popover>
|
||||
<Text.Heading disableLineHeightCompensation>
|
||||
{getText('paywallDevtoolsPopoverHeading')}
|
||||
</ariaComponents.Text.Heading>
|
||||
</Text.Heading>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
{session?.type === UserSessionType.full && (
|
||||
<>
|
||||
<ariaComponents.Text variant="subtitle">
|
||||
{getText('paywallDevtoolsPlanSelectSubtitle')}
|
||||
</ariaComponents.Text>
|
||||
<Text variant="subtitle">{getText('paywallDevtoolsPlanSelectSubtitle')}</Text>
|
||||
|
||||
<ariaComponents.Form
|
||||
<Form
|
||||
gap="small"
|
||||
schema={(schema) => schema.object({ plan: schema.string() })}
|
||||
defaultValues={{ plan: session.user.plan ?? 'free' }}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<ariaComponents.RadioGroup
|
||||
<RadioGroup
|
||||
form={form}
|
||||
name="plan"
|
||||
onChange={(value) => {
|
||||
@ -133,16 +145,13 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ariaComponents.Radio label={getText('free')} value={'free'} />
|
||||
<ariaComponents.Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<ariaComponents.Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<ariaComponents.Radio
|
||||
label={getText('enterprise')}
|
||||
value={backend.Plan.enterprise}
|
||||
/>
|
||||
</ariaComponents.RadioGroup>
|
||||
<Radio label={getText('free')} value={'free'} />
|
||||
<Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
|
||||
</RadioGroup>
|
||||
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() =>
|
||||
@ -152,27 +161,27 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
}
|
||||
>
|
||||
{getText('reset')}
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
</Form>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<ariaComponents.Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
Open setup page
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('productionOnlyFeatures')}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
<div className="flex flex-col">
|
||||
<aria.Switch
|
||||
<Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={enableVersionChecker ?? !IS_DEV_MODE}
|
||||
onChange={setEnableVersionChecker}
|
||||
@ -181,21 +190,45 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
|
||||
</div>
|
||||
|
||||
<ariaComponents.Text className="flex-1">
|
||||
{getText('enableVersionChecker')}
|
||||
</ariaComponents.Text>
|
||||
</aria.Switch>
|
||||
<Text className="flex-1">{getText('enableVersionChecker')}</Text>
|
||||
</Switch>
|
||||
|
||||
<ariaComponents.Text variant="body" color="disabled">
|
||||
<Text variant="body" color="disabled">
|
||||
{getText('enableVersionCheckerDescription')}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('localStorage')}
|
||||
</Text>
|
||||
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
||||
<div className="flex gap-1">
|
||||
<ButtonGroup className="grow-0">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
localStorage.delete(key)
|
||||
}}
|
||||
>
|
||||
{getText('delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Text variant="body">
|
||||
{key
|
||||
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
||||
.replace(/^./, (m) => m.toUpperCase())}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('paywallDevtoolsPaywallFeaturesToggles')}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(features).map(([feature, configuration]) => {
|
||||
@ -205,7 +238,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
|
||||
return (
|
||||
<div key={feature} className="flex flex-col">
|
||||
<aria.Switch
|
||||
<Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={configuration.isForceEnabled ?? true}
|
||||
onChange={(value) => {
|
||||
@ -218,18 +251,18 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
|
||||
</div>
|
||||
|
||||
<ariaComponents.Text className="flex-1">{getText(label)}</ariaComponents.Text>
|
||||
</aria.Switch>
|
||||
<Text className="flex-1">{getText(label)}</Text>
|
||||
</Switch>
|
||||
|
||||
<ariaComponents.Text variant="body" color="disabled">
|
||||
<Text variant="body" color="disabled">
|
||||
{getText(descriptionTextId)}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ariaComponents.Popover>
|
||||
</ariaComponents.DialogTrigger>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</Portal>
|
||||
</PaywallDevtoolsContext.Provider>
|
||||
)
|
||||
|
@ -1,334 +0,0 @@
|
||||
/** @file A styled dropdown. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CheckMarkIcon from '#/assets/check_mark.svg'
|
||||
import FolderArrowIcon from '#/assets/folder_arrow.svg'
|
||||
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// ================
|
||||
// === Dropdown ===
|
||||
// ================
|
||||
|
||||
/** Props for a list item child. */
|
||||
interface InternalChildProps<T> {
|
||||
readonly item: T
|
||||
}
|
||||
|
||||
/** Props for the display of the currently selected item, when the dropdown supports multiple children. */
|
||||
interface InternalChildrenProps<T> {
|
||||
readonly items: T[]
|
||||
/** This is the value passed as {@link DropdownProps.render}. */
|
||||
readonly render: (props: InternalChildProps<T>) => React.ReactNode
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown} shared between all variants. */
|
||||
interface InternalBaseDropdownProps<T> {
|
||||
readonly readOnly?: boolean
|
||||
readonly className?: string
|
||||
readonly items: readonly T[]
|
||||
readonly render: (props: InternalChildProps<T>) => React.ReactNode
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown}, when `multiple` is `false` or absent. */
|
||||
interface InternalSingleDropdownProps<T> extends InternalBaseDropdownProps<T> {
|
||||
readonly multiple?: false
|
||||
readonly selectedIndex: number | null
|
||||
readonly onClick: (item: T, index: number) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown}, when `multiple` is `true`. */
|
||||
interface InternalMultipleDropdownProps<T> extends InternalBaseDropdownProps<T> {
|
||||
readonly multiple: true
|
||||
readonly selectedIndices: readonly number[]
|
||||
readonly renderMultiple: (props: InternalChildrenProps<T>) => React.ReactNode
|
||||
readonly onClick: (items: readonly T[], indices: readonly number[]) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link Dropdown}. */
|
||||
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
|
||||
|
||||
/** A styled dropdown. */
|
||||
function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const { readOnly = false, className, items, render: Child } = props
|
||||
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
||||
const [tempSelectedIndex, setTempSelectedIndex] = React.useState<number | null>(null)
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const justFocusedRef = React.useRef(false)
|
||||
const justBlurredRef = React.useRef(false)
|
||||
const isMouseDown = React.useRef(false)
|
||||
const multiple = props.multiple === true
|
||||
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
|
||||
const selectedIndices = 'selectedIndices' in props ? props.selectedIndices : []
|
||||
const selectedItems = selectedIndices.flatMap((index) => {
|
||||
const item = items[index]
|
||||
return item != null ? [item] : []
|
||||
})
|
||||
const visuallySelectedIndex = tempSelectedIndex ?? selectedIndex
|
||||
const visuallySelectedItem = visuallySelectedIndex == null ? null : items[visuallySelectedIndex]
|
||||
|
||||
React.useEffect(() => {
|
||||
setTempSelectedIndex(selectedIndex)
|
||||
}, [selectedIndex])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDropdownVisible) {
|
||||
rootRef.current?.blur()
|
||||
}
|
||||
}, [isDropdownVisible])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDocumentClick = () => {
|
||||
setIsDropdownVisible(false)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
event.stopPropagation()
|
||||
setIsDropdownVisible(false)
|
||||
break
|
||||
}
|
||||
case 'Enter':
|
||||
case 'Tab': {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') {
|
||||
setIsDropdownVisible(true)
|
||||
}
|
||||
if (tempSelectedIndex != null) {
|
||||
const item = items[tempSelectedIndex]
|
||||
if (item != null) {
|
||||
if (multiple) {
|
||||
const newIndices =
|
||||
selectedIndices.includes(tempSelectedIndex) ?
|
||||
selectedIndices.filter((index) => index !== tempSelectedIndex)
|
||||
: [...selectedIndices, tempSelectedIndex]
|
||||
props.onClick(
|
||||
newIndices.flatMap((index) => {
|
||||
const otherItem = items[index]
|
||||
return otherItem != null ? [otherItem] : []
|
||||
}),
|
||||
newIndices,
|
||||
)
|
||||
} else {
|
||||
props.onClick(item, tempSelectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isDropdownVisible && (event.key !== 'Enter' || !multiple)) {
|
||||
setIsDropdownVisible(false)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (!isDropdownVisible) break
|
||||
event.preventDefault()
|
||||
setTempSelectedIndex(
|
||||
(
|
||||
tempSelectedIndex == null ||
|
||||
tempSelectedIndex === 0 ||
|
||||
tempSelectedIndex >= items.length
|
||||
) ?
|
||||
items.length - 1
|
||||
: tempSelectedIndex - 1,
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
if (!isDropdownVisible) break
|
||||
event.preventDefault()
|
||||
setTempSelectedIndex(
|
||||
tempSelectedIndex == null || tempSelectedIndex >= items.length - 1 ?
|
||||
0
|
||||
: tempSelectedIndex + 1,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusRing placement="outset">
|
||||
<div
|
||||
ref={(element) => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(element)
|
||||
} else if (ref != null) {
|
||||
ref.current = element
|
||||
}
|
||||
rootRef.current = element
|
||||
}}
|
||||
tabIndex={0}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
|
||||
className,
|
||||
)}
|
||||
onFocus={(event) => {
|
||||
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(true)
|
||||
justFocusedRef.current = true
|
||||
}
|
||||
justBlurredRef.current = false
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(false)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={() => {
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'absolute left-0 h-full w-full min-w-max',
|
||||
isDropdownVisible ? 'z-1' : 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors',
|
||||
isDropdownVisible ?
|
||||
'before:h-full before:shadow-soft'
|
||||
: 'before:h-text group-hover:before:bg-hover-bg',
|
||||
)}
|
||||
>
|
||||
{/* Spacing. */}
|
||||
<div
|
||||
className="padding relative h-text"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
setIsDropdownVisible(!isDropdownVisible)
|
||||
}
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
|
||||
isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors',
|
||||
multiple && 'hover:font-semibold',
|
||||
i === visuallySelectedIndex ?
|
||||
'cursor-default bg-frame font-bold focus-ring'
|
||||
: 'hover:bg-hover-bg',
|
||||
)}
|
||||
key={i}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
isMouseDown.current = true
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
isMouseDown.current = false
|
||||
}}
|
||||
onClick={() => {
|
||||
if (i !== visuallySelectedIndex) {
|
||||
if (multiple) {
|
||||
const newIndices =
|
||||
selectedIndices.includes(i) ?
|
||||
selectedIndices.filter((index) => index !== i)
|
||||
: [...selectedIndices, i]
|
||||
props.onClick(
|
||||
newIndices.flatMap((index) => {
|
||||
const otherItem = items[index]
|
||||
return otherItem != null ? [otherItem] : []
|
||||
}),
|
||||
newIndices,
|
||||
)
|
||||
rootRef.current?.focus()
|
||||
} else {
|
||||
setIsDropdownVisible(false)
|
||||
props.onClick(item, i)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isMouseDown.current) {
|
||||
// This is from keyboard navigation.
|
||||
if (multiple) {
|
||||
props.onClick([item], [i])
|
||||
} else {
|
||||
props.onClick(item, i)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={CheckMarkIcon}
|
||||
className={selectedIndices.includes(i) ? '' : 'invisible'}
|
||||
/>
|
||||
<Child item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative flex h-text items-center gap-dropdown-arrow px-input-x',
|
||||
isDropdownVisible && 'z-1',
|
||||
readOnly && 'read-only',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
setIsDropdownVisible(!isDropdownVisible)
|
||||
}
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-90" />
|
||||
<div className="grow">
|
||||
{visuallySelectedItem != null ?
|
||||
<Child item={visuallySelectedItem} />
|
||||
: multiple && <props.renderMultiple items={selectedItems} render={Child} />}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden, but required to exist for the width of the parent element to be correct.
|
||||
* Classes that do not affect width have been removed. */}
|
||||
<div className="flex h flex-col overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex gap-dropdown-arrow px-input-x font-bold">
|
||||
<SvgMask src={CheckMarkIcon} />
|
||||
<Child item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
||||
/** A styled dropdown. */
|
||||
// This is REQUIRED, as `React.forwardRef` does not preserve types of generic functions.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export default React.forwardRef(Dropdown) as <T>(
|
||||
props: DropdownProps<T> & React.RefAttributes<HTMLDivElement>,
|
||||
) => React.JSX.Element
|
@ -102,7 +102,7 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
|
||||
|
||||
{detect.IS_DEV_MODE && stack != null && (
|
||||
<ariaComponents.Alert
|
||||
className="mx-auto mt-4 max-w-screen-lg overflow-x-auto"
|
||||
className="mx-auto mt-4 max-h-[80vh] max-w-screen-lg overflow-auto"
|
||||
variant="neutral"
|
||||
>
|
||||
<ariaComponents.Text
|
||||
|
@ -1,41 +0,0 @@
|
||||
/** @file A styled input that includes an icon. */
|
||||
import * as React from 'react'
|
||||
|
||||
import EyeIcon from '#/assets/eye.svg'
|
||||
import EyeCrossedIcon from '#/assets/eye_crossed.svg'
|
||||
|
||||
import type * as controlledInput from '#/components/ControlledInput'
|
||||
import ControlledInput from '#/components/ControlledInput'
|
||||
import SvgIcon from '#/components/SvgIcon'
|
||||
|
||||
// =============
|
||||
// === Input ===
|
||||
// =============
|
||||
|
||||
/** Props for a {@link Input}. */
|
||||
export interface InputProps extends controlledInput.ControlledInputProps {
|
||||
readonly allowShowingPassword?: boolean
|
||||
readonly icon: string
|
||||
}
|
||||
|
||||
/** A styled input that includes an icon. */
|
||||
export default function Input(props: InputProps) {
|
||||
const { allowShowingPassword = false, icon, type, ...passthrough } = props
|
||||
const [isShowingPassword, setIsShowingPassword] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<SvgIcon src={icon} />
|
||||
<ControlledInput {...passthrough} type={isShowingPassword ? 'text' : type} />
|
||||
{type === 'password' && allowShowingPassword && (
|
||||
<SvgIcon
|
||||
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||
className="left-[unset] right-0 cursor-pointer rounded-full"
|
||||
onClick={() => {
|
||||
setIsShowingPassword((show) => !show)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,21 +1,17 @@
|
||||
/** @file A dynamic wizard for creating an arbitrary type of Datalink. */
|
||||
import * as React from 'react'
|
||||
import { type Dispatch, Fragment, type JSX, type SetStateAction, useState } from 'react'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Input, Text } from '#/components/aria'
|
||||
import { Button, Dropdown } from '#/components/AriaComponents'
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Dropdown from '#/components/Dropdown'
|
||||
import Checkbox from '#/components/styled/Checkbox'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
import { useBackendQuery } from '#/hooks/backendHooks'
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { constantValueOfSchema, getSchemaName, lookupDef } from '#/utilities/jsonSchema'
|
||||
import { asObject, singletonObjectOrNull } from '#/utilities/object'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
// =======================
|
||||
// === JSONSchemaInput ===
|
||||
@ -30,21 +26,21 @@ export interface JSONSchemaInputProps {
|
||||
readonly path: string
|
||||
readonly getValidator: (path: string) => (value: unknown) => boolean
|
||||
readonly value: NonNullable<unknown> | null
|
||||
readonly setValue: React.Dispatch<React.SetStateAction<NonNullable<unknown> | null>>
|
||||
readonly onChange: Dispatch<SetStateAction<NonNullable<unknown> | null>>
|
||||
}
|
||||
|
||||
/** A dynamic wizard for creating an arbitrary type of Datalink. */
|
||||
export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { dropdownTitle, readOnly = false, defs, schema, path, getValidator } = props
|
||||
const { value, setValue } = props
|
||||
const { value, onChange } = props
|
||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||
// but it is more convenient to avoid having plugin infrastructure.
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const { getText } = textProvider.useText()
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const { getText } = useText()
|
||||
const [autocompleteText, setAutocompleteText] = useState(() =>
|
||||
typeof value === 'string' ? value : null,
|
||||
)
|
||||
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
|
||||
const [selectedChildIndex, setSelectedChildIndex] = useState<number | null>(null)
|
||||
const isSecret =
|
||||
'type' in schema &&
|
||||
schema.type === 'string' &&
|
||||
@ -58,7 +54,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
// This value cannot change.
|
||||
return null
|
||||
} else {
|
||||
const children: React.JSX.Element[] = []
|
||||
const children: JSX.Element[] = []
|
||||
if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
@ -66,107 +62,93 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const isValid = typeof value === 'string' && value !== ''
|
||||
children.push(
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
className={twMerge(
|
||||
'w-60 rounded-default border-0.5',
|
||||
isValid ? 'border-primary/20' : 'border-red-700/60',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
>
|
||||
<Autocomplete
|
||||
items={autocompleteItems ?? []}
|
||||
itemToKey={(item) => item}
|
||||
itemToString={(item) => item}
|
||||
placeholder={getText('enterSecretPath')}
|
||||
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
||||
values={isValid ? [value] : []}
|
||||
setValues={(values) => {
|
||||
setValue(values[0] ?? '')
|
||||
onChange(values[0] ?? '')
|
||||
}}
|
||||
text={autocompleteText}
|
||||
setText={setAutocompleteText}
|
||||
/>
|
||||
>
|
||||
{(item) => item}
|
||||
</Autocomplete>
|
||||
</div>,
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
<FocusArea direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
type="text"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterText')}
|
||||
onChange={(event) => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
setValue(newValue)
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
</FocusRing>
|
||||
)}
|
||||
</FocusArea>,
|
||||
<FocusRing>
|
||||
<Input
|
||||
type="text"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={twMerge(
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterText')}
|
||||
onChange={(event) => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
onChange(newValue)
|
||||
}}
|
||||
/>
|
||||
</FocusRing>,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'number': {
|
||||
children.push(
|
||||
<FocusArea direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterNumber')}
|
||||
onChange={(event) => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
</FocusRing>
|
||||
)}
|
||||
</FocusArea>,
|
||||
<FocusRing>
|
||||
<Input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={twMerge(
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterNumber')}
|
||||
onChange={(event) => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
if (Number.isFinite(newValue)) {
|
||||
onChange(newValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FocusRing>,
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'integer': {
|
||||
children.push(
|
||||
<FocusArea direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child min-6- text40 w-80 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterInteger')}
|
||||
onChange={(event) => {
|
||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||
setValue(newValue)
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
</FocusRing>
|
||||
)}
|
||||
</FocusArea>,
|
||||
<FocusRing>
|
||||
<Input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={twMerge(
|
||||
'focus-child min-6- text40 w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterInteger')}
|
||||
onChange={(event) => {
|
||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||
onChange(newValue)
|
||||
}}
|
||||
/>
|
||||
</FocusRing>,
|
||||
)
|
||||
break
|
||||
}
|
||||
@ -175,79 +157,68 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
<Checkbox
|
||||
isReadOnly={readOnly}
|
||||
isSelected={typeof value === 'boolean' && value}
|
||||
onChange={setValue}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
const propertiesObject =
|
||||
'properties' in schema ? object.asObject(schema.properties) ?? {} : {}
|
||||
const propertiesObject = 'properties' in schema ? asObject(schema.properties) ?? {} : {}
|
||||
const requiredProperties =
|
||||
'required' in schema && Array.isArray(schema.required) ? schema.required : []
|
||||
const propertyDefinitions = Object.entries(propertiesObject).flatMap(
|
||||
(kv: [string, unknown]) => {
|
||||
const [k, v] = kv
|
||||
return object
|
||||
.singletonObjectOrNull(v)
|
||||
.map((childSchema) => ({ key: k, schema: childSchema }))
|
||||
return singletonObjectOrNull(v).map((childSchema) => ({
|
||||
key: k,
|
||||
schema: childSchema,
|
||||
}))
|
||||
},
|
||||
)
|
||||
if (jsonSchema.constantValue(defs, schema).length !== 1) {
|
||||
if (constantValueOfSchema(defs, schema).length !== 1) {
|
||||
children.push(
|
||||
<div className="grid items-center gap-json-schema rounded-default border-0.5 border-primary/20 p-json-schema-object-input">
|
||||
{propertyDefinitions.map((definition) => {
|
||||
const { key, schema: childSchema } = definition
|
||||
const isOptional = !requiredProperties.includes(key)
|
||||
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
|
||||
const isPresent = value != null && key in value
|
||||
return constantValueOfSchema(defs, childSchema).length === 1 ?
|
||||
null
|
||||
: <>
|
||||
<FocusArea active={isOptional} direction="horizontal">
|
||||
{(innerProps) => {
|
||||
const isPresent = value != null && key in value
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isOptional}
|
||||
isActive={!isOptional || isPresent}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text col-start-1 inline-block whitespace-nowrap rounded-full px-button-x text-left',
|
||||
isOptional && 'hover:bg-hover-bg',
|
||||
)}
|
||||
onPress={() => {
|
||||
if (isOptional) {
|
||||
setValue((oldValue) => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: jsonSchema.constantValue(
|
||||
defs,
|
||||
childSchema,
|
||||
true,
|
||||
)[0],
|
||||
}
|
||||
}
|
||||
})
|
||||
: <Fragment key={key}>
|
||||
<Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isOptional}
|
||||
isActive={!isOptional || isPresent}
|
||||
className={twMerge(
|
||||
'col-start-1 inline-block whitespace-nowrap rounded-full px-button-x',
|
||||
isOptional && 'hover:bg-hover-bg',
|
||||
)}
|
||||
onPress={() => {
|
||||
if (isOptional) {
|
||||
onChange((oldValue) => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: constantValueOfSchema(defs, childSchema, true)[0],
|
||||
}
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
</FocusArea>
|
||||
{value != null && key in value && (
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</Button>
|
||||
{isPresent && (
|
||||
<div className="col-start-2">
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
@ -258,8 +229,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={(newValue) => {
|
||||
setValue((oldValue) => {
|
||||
onChange={(newValue) => {
|
||||
onChange((oldValue) => {
|
||||
if (typeof newValue === 'function') {
|
||||
const unsafeValue: unknown = newValue(
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
@ -288,7 +259,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
})}
|
||||
</div>,
|
||||
)
|
||||
@ -298,7 +269,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
}
|
||||
}
|
||||
if ('$ref' in schema && typeof schema.$ref === 'string') {
|
||||
const referencedSchema = jsonSchema.lookupDef(defs, schema)
|
||||
const referencedSchema = lookupDef(defs, schema)
|
||||
if (referencedSchema != null) {
|
||||
children.push(
|
||||
<JSONSchemaInput
|
||||
@ -311,12 +282,12 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
}
|
||||
}
|
||||
if ('anyOf' in schema && Array.isArray(schema.anyOf)) {
|
||||
const childSchemas = schema.anyOf.flatMap(object.singletonObjectOrNull)
|
||||
const childSchemas = schema.anyOf.flatMap(singletonObjectOrNull)
|
||||
const selectedChildSchema =
|
||||
selectedChildIndex == null ? null : childSchemas[selectedChildIndex]
|
||||
const selectedChildPath = `${path}/anyOf/${selectedChildIndex ?? 0}`
|
||||
const childValue =
|
||||
selectedChildSchema == null ? [] : jsonSchema.constantValue(defs, selectedChildSchema)
|
||||
selectedChildSchema == null ? [] : constantValueOfSchema(defs, selectedChildSchema)
|
||||
if (
|
||||
value != null &&
|
||||
(selectedChildSchema == null || getValidator(selectedChildPath)(value) !== true)
|
||||
@ -330,32 +301,23 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
}
|
||||
}
|
||||
const dropdown = (
|
||||
<FocusArea direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<Dropdown
|
||||
readOnly={readOnly}
|
||||
items={childSchemas}
|
||||
selectedIndex={selectedChildIndex}
|
||||
render={(childProps) => (
|
||||
<aria.Text>{jsonSchema.getSchemaName(defs, childProps.item)}</aria.Text>
|
||||
)}
|
||||
className="self-start"
|
||||
onClick={(childSchema, index) => {
|
||||
setSelectedChildIndex(index)
|
||||
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
|
||||
setValue(newConstantValue[0] ?? null)
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
)}
|
||||
</FocusArea>
|
||||
<Dropdown
|
||||
readOnly={readOnly}
|
||||
items={childSchemas}
|
||||
selectedIndex={selectedChildIndex}
|
||||
className="self-start"
|
||||
onChange={(childSchema, index) => {
|
||||
setSelectedChildIndex(index)
|
||||
const newConstantValue = constantValueOfSchema(defs, childSchema, true)
|
||||
onChange(newConstantValue[0] ?? null)
|
||||
}}
|
||||
>
|
||||
{({ item }) => <Text slot="label">{getSchemaName(defs, item)}</Text>}
|
||||
</Dropdown>
|
||||
)
|
||||
children.push(
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex flex-col gap-json-schema',
|
||||
childValue.length === 0 && 'w-full',
|
||||
)}
|
||||
className={twMerge('flex flex-col gap-json-schema', childValue.length === 0 && 'w-full')}
|
||||
>
|
||||
{dropdownTitle != null ?
|
||||
<div className="flex h-row items-center">
|
||||
@ -372,14 +334,14 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
path={selectedChildPath}
|
||||
getValidator={getValidator}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
if ('allOf' in schema && Array.isArray(schema.allOf)) {
|
||||
const childSchemas = schema.allOf.flatMap(object.singletonObjectOrNull)
|
||||
const childSchemas = schema.allOf.flatMap(singletonObjectOrNull)
|
||||
const newChildren = childSchemas.map((childSchema, i) => (
|
||||
<JSONSchemaInput
|
||||
key={i}
|
||||
@ -389,7 +351,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
path={`${path}/allOf/${i}`}
|
||||
getValidator={getValidator}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))
|
||||
children.push(...newChildren)
|
||||
|
@ -7,6 +7,8 @@ import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import { useFocusChild } from '#/hooks/focusHooks'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
// ============
|
||||
@ -15,25 +17,28 @@ import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/** Props for a {@link Link}. */
|
||||
export interface LinkProps {
|
||||
readonly onPress?: () => void
|
||||
readonly openInBrowser?: boolean
|
||||
readonly to: string
|
||||
readonly icon: string
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export default React.forwardRef(Link)
|
||||
export default forwardRef(Link)
|
||||
|
||||
/** A styled colored link with an icon. */
|
||||
function Link(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
|
||||
const { openInBrowser = false, to, icon, text } = props
|
||||
const { openInBrowser = false, to, icon, text, onPress } = props
|
||||
const { getText } = useText()
|
||||
const { className: focusChildClassName, ...focusChildProps } = useFocusChild()
|
||||
const linkRef = React.useRef<HTMLAnchorElement>(null)
|
||||
|
||||
return (
|
||||
<FocusRing>
|
||||
<aria.Link
|
||||
ref={ref}
|
||||
ref={mergeRefs(linkRef, ref)}
|
||||
href={to}
|
||||
{...(openInBrowser && { target: '_blank' })}
|
||||
rel="noopener noreferrer"
|
||||
className={twMerge(
|
||||
'flex items-center gap-auth-link rounded-full px-auth-link-x py-auth-link-y text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700',
|
||||
@ -43,6 +48,7 @@ function Link(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
|
||||
if (openInBrowser) {
|
||||
toastify.toast.success(getText('openedLinkInBrowser'))
|
||||
}
|
||||
onPress?.()
|
||||
}}
|
||||
{...focusChildProps}
|
||||
>
|
||||
|
@ -14,21 +14,35 @@ import SvgMask from '#/components/SvgMask'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const INFO_ICON = (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]" aria-hidden>
|
||||
!
|
||||
</ariaComponents.Text>
|
||||
)
|
||||
|
||||
const STATUS_ICON_MAP: Readonly<Record<Status, StatusIcon>> = {
|
||||
loading: {
|
||||
icon: <loader.Loader minHeight="h8" />,
|
||||
colorClassName: 'text-primary',
|
||||
bgClassName: 'bg-transparent',
|
||||
},
|
||||
info: {
|
||||
icon: INFO_ICON,
|
||||
colorClassName: 'text-primary',
|
||||
bgClassName: 'bg-primary/30',
|
||||
},
|
||||
error: { icon: Error, colorClassName: 'text-red-500', bgClassName: 'bg-red-500' },
|
||||
success: { icon: Success, colorClassName: 'text-green-500', bgClassName: 'bg-green' },
|
||||
info: {
|
||||
icon: (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]" aria-hidden>
|
||||
!
|
||||
</ariaComponents.Text>
|
||||
),
|
||||
// pending is the same as loading. Used for mutations.
|
||||
pending: {
|
||||
icon: <loader.Loader minHeight="h8" />,
|
||||
colorClassName: 'text-primary',
|
||||
bgClassName: 'bg-transparent',
|
||||
},
|
||||
// idle is the same as info. Used for mutations.
|
||||
idle: {
|
||||
icon: INFO_ICON,
|
||||
colorClassName: 'text-primary',
|
||||
bgClassName: 'bg-primary/30',
|
||||
},
|
||||
@ -60,7 +74,7 @@ const RESULT_STYLES = twv.tv({
|
||||
// ==============
|
||||
|
||||
/** Possible statuses for a result. */
|
||||
export type Status = 'error' | 'info' | 'loading' | 'success'
|
||||
export type Status = 'error' | 'idle' | 'info' | 'loading' | 'pending' | 'success'
|
||||
|
||||
// ==================
|
||||
// === StatusIcon ===
|
||||
|
@ -80,11 +80,11 @@ export interface StepperProps {
|
||||
}
|
||||
|
||||
const STEPPER_STYLES = tvw.tv({
|
||||
base: 'flex flex-col items-center w-full',
|
||||
base: 'flex flex-col items-center w-full gap-4',
|
||||
slots: {
|
||||
steps: 'flex items-center justify-between w-full',
|
||||
step: 'flex-1 last:flex-none',
|
||||
content: 'relative w-full mt-4',
|
||||
content: 'relative w-full',
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -18,7 +18,13 @@ type Direction = 'back-none' | 'back' | 'forward-none' | 'forward' | 'initial'
|
||||
* Props for {@link useStepperState}
|
||||
*/
|
||||
export interface StepperStateProps {
|
||||
/**
|
||||
* The default step to start on (0-indexed)
|
||||
*/
|
||||
readonly defaultStep?: number
|
||||
/**
|
||||
* The number of steps in the stepper (amount of steps is 1-indexed)
|
||||
*/
|
||||
readonly steps: number
|
||||
readonly onStepChange?: (step: number, direction: 'back' | 'forward') => void
|
||||
readonly onCompleted?: () => void
|
||||
|
@ -1,7 +1,9 @@
|
||||
/** @file Barrel re-export of `react-aria` and `react-aria-components`. */
|
||||
import type { Mutable } from 'enso-common/src/utilities/data/object'
|
||||
import * as aria from 'react-aria'
|
||||
|
||||
export type * from '@react-types/shared'
|
||||
// @ts-expect-error The conflicting exports are props types ONLY.
|
||||
export * from 'react-aria'
|
||||
// @ts-expect-error The conflicting exports are props types ONLY.
|
||||
export * from 'react-aria-components'
|
||||
@ -18,6 +20,9 @@ export { useTooltipTriggerState, type OverlayTriggerState } from 'react-stately'
|
||||
*
|
||||
* The constraint is defaulted to `never` to make an explicit constraint mandatory. */
|
||||
export function mergeProps<Constraint extends object = never>() {
|
||||
return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) =>
|
||||
aria.mergeProps(...args)
|
||||
return <const T extends readonly (Partial<Constraint> | null | undefined)[]>(
|
||||
...args: T & { [K in keyof T]: Pick<T[K], keyof Constraint & keyof T[K]> }
|
||||
// This is SAFE, as `args` is an intersection of `T` and another type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
) => aria.mergeProps<Mutable<T>>(...(args as T))
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { useStore } from 'zustand'
|
||||
|
||||
import BlankIcon from '#/assets/blank.svg'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
@ -23,7 +23,6 @@ import AssetListEventType from '#/events/AssetListEventType'
|
||||
import AssetContextMenu from '#/layouts/AssetContextMenu'
|
||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||
@ -36,9 +35,9 @@ import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import { isCloudCategory } from '#/layouts/CategorySwitcher/Category'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as download from '#/utilities/download'
|
||||
@ -47,6 +46,7 @@ import * as eventModule from '#/utilities/event'
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as path from '#/utilities/path'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
@ -98,7 +98,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
|
||||
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
|
||||
const { visibilities } = state
|
||||
const { visibilities, category } = state
|
||||
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const driveStore = useDriveStore()
|
||||
@ -115,12 +115,14 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected,
|
||||
)
|
||||
const draggableProps = dragAndDropHooks.useDraggable()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const { data: users } = useBackendQuery(backend, 'listUsers', [])
|
||||
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
|
||||
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
||||
const rootRef = React.useRef<HTMLElement | null>(null)
|
||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||
@ -131,7 +133,11 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
||||
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
|
||||
)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const nodeParentKeysRef = React.useRef<{
|
||||
readonly nodeMap: WeakRef<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
|
||||
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
|
||||
} | null>(null)
|
||||
const isCloud = isCloudCategory(category)
|
||||
const outerVisibility = visibilities.get(item.key)
|
||||
const visibility =
|
||||
outerVisibility == null || outerVisibility === Visibility.visible ?
|
||||
@ -163,7 +169,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
// This is SAFE, as `isOpened` is only true for projects.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
...createGetProjectDetailsQuery.createPassiveListener(item.item.id as backendModule.ProjectId),
|
||||
select: (data) => data.state.type,
|
||||
select: (data) => data?.state.type,
|
||||
enabled: item.type === backendModule.AssetType.project,
|
||||
})
|
||||
|
||||
@ -205,7 +211,13 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
object.merge(oldAsset, {
|
||||
title: oldAsset.title + ' (copy)',
|
||||
labels: [],
|
||||
permissions: permissions.tryGetSingletonOwnerPermission(user),
|
||||
permissions: permissions.tryCreateOwnerPermission(
|
||||
`${item.path} (copy)`,
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
),
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
}),
|
||||
)
|
||||
@ -232,14 +244,19 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
},
|
||||
[
|
||||
user,
|
||||
rootDirectoryId,
|
||||
asset,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
copyAsset,
|
||||
nodeMap,
|
||||
setAsset,
|
||||
rootDirectoryId,
|
||||
copyAsset,
|
||||
asset.id,
|
||||
asset.title,
|
||||
nodeMap,
|
||||
item.path,
|
||||
item.key,
|
||||
category,
|
||||
user,
|
||||
users,
|
||||
userGroups,
|
||||
toastAndLog,
|
||||
dispatchAssetListEvent,
|
||||
],
|
||||
)
|
||||
@ -259,7 +276,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
let newId = asset.id
|
||||
if (!isCloud) {
|
||||
const oldPath = localBackend.extractTypeAndId(asset.id).id
|
||||
const newPath = projectManager.joinPath(newParentPath, fileInfo.fileName(oldPath))
|
||||
const newPath = path.joinPath(newParentPath, fileInfo.getFileName(oldPath))
|
||||
switch (asset.type) {
|
||||
case backendModule.AssetType.file: {
|
||||
newId = localBackend.newFileId(newPath)
|
||||
@ -425,7 +442,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (state.category === Category.trash) {
|
||||
if (state.category.type === 'trash') {
|
||||
switch (event.type) {
|
||||
case AssetEventType.deleteForever: {
|
||||
if (event.ids.has(item.key)) {
|
||||
@ -712,15 +729,33 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const directoryKey =
|
||||
item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
if (
|
||||
(payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)) ||
|
||||
event.dataTransfer.types.includes('Files')
|
||||
) {
|
||||
const isPayloadMatch =
|
||||
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
|
||||
const canPaste = (() => {
|
||||
if (!isPayloadMatch) {
|
||||
return true
|
||||
} else {
|
||||
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
|
||||
const parentKeys = new Map(
|
||||
Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [
|
||||
id,
|
||||
otherAsset.directoryKey,
|
||||
]),
|
||||
)
|
||||
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
|
||||
}
|
||||
return !payload.some((payloadItem) => {
|
||||
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
|
||||
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
|
||||
return !parent ? true : (
|
||||
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
|
||||
)
|
||||
})
|
||||
}
|
||||
})()
|
||||
if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
if (
|
||||
item.item.type === backendModule.AssetType.directory &&
|
||||
state.category !== Category.trash
|
||||
) {
|
||||
if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') {
|
||||
setIsDraggedOver(true)
|
||||
}
|
||||
}
|
||||
@ -837,7 +872,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (state.category === Category.trash) {
|
||||
if (state.category.type === 'trash') {
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
props.onDragOver?.(event)
|
||||
@ -864,7 +899,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
props.onDragLeave?.(event)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (state.category !== Category.trash) {
|
||||
if (state.category.type !== 'trash') {
|
||||
props.onDrop?.(event)
|
||||
clearDragState()
|
||||
const [directoryKey, directoryId, directoryTitle] =
|
||||
@ -921,7 +956,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
state={state}
|
||||
rowState={rowState}
|
||||
setRowState={setRowState}
|
||||
isEditable={state.category !== Category.trash}
|
||||
isEditable={state.category.type !== 'trash'}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
|
@ -7,7 +7,11 @@ import * as datalinkValidator from '#/data/datalinkValidator'
|
||||
import type * as jsonSchemaInput from '#/components/JSONSchemaInput'
|
||||
import JSONSchemaInput from '#/components/JSONSchemaInput'
|
||||
|
||||
import { FieldError } from '#/components/aria'
|
||||
import type { FieldValues, FormInstance, TSchema } from '#/components/AriaComponents'
|
||||
import { useFormContext } from '#/components/AriaComponents/Form/components/useFormContext'
|
||||
import * as error from '#/utilities/error'
|
||||
import { Controller, type FieldPath } from 'react-hook-form'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -45,3 +49,33 @@ export default function DatalinkInput(props: DatalinkInputProps) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Props for a {@link DatalinkFormInput}. */
|
||||
export interface DatalinkFormInputProps<Schema extends TSchema>
|
||||
extends Omit<DatalinkInputProps, 'onChange' | 'value'> {
|
||||
readonly form?: FormInstance<Schema>
|
||||
readonly name: FieldPath<FieldValues<Schema>>
|
||||
}
|
||||
|
||||
/** A dynamic wizard for creating an arbitrary type of Datalink. */
|
||||
export function DatalinkFormInput<Schema extends TSchema>(props: DatalinkFormInputProps<Schema>) {
|
||||
const fallbackForm = useFormContext()
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const { form = fallbackForm as unknown as FormInstance<Schema>, name, ...inputProps } = props
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => {
|
||||
const { value, onChange } = field
|
||||
return (
|
||||
<>
|
||||
<DatalinkInput {...inputProps} value={value} onChange={onChange} />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
/** @file The icon and name of a {@link backendModule.DirectoryAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import FolderIcon from '#/assets/folder.svg'
|
||||
|
@ -42,7 +42,7 @@ export interface PermissionProps {
|
||||
readonly backend: Backend
|
||||
readonly asset: Pick<backendModule.Asset, 'id' | 'permissions' | 'type'>
|
||||
|
||||
readonly self: backendModule.UserPermission
|
||||
readonly self: backendModule.AssetPermission
|
||||
readonly isOnlyOwner: boolean
|
||||
readonly permission: backendModule.AssetPermission
|
||||
readonly setPermission: (userPermissions: backendModule.AssetPermission) => void
|
||||
@ -57,7 +57,7 @@ export default function Permission(props: PermissionProps) {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [permission, setPermission] = React.useState(initialPermission)
|
||||
const permissionId = backendModule.getAssetPermissionId(permission)
|
||||
const isDisabled = isOnlyOwner && permissionId === self.user.userId
|
||||
const isDisabled = isOnlyOwner && backendModule.getAssetPermissionId(self) === permissionId
|
||||
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
||||
|
||||
const createPermission = useMutation(
|
||||
|
@ -71,16 +71,12 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const closeProject = projectHooks.useCloseProject()
|
||||
const openProjectTab = projectHooks.useOpenEditor()
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const {
|
||||
data: projectState,
|
||||
isLoading,
|
||||
isError,
|
||||
} = reactQuery.useQuery({
|
||||
const { data: projectState, isError } = reactQuery.useQuery({
|
||||
...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id),
|
||||
select: (data) => data.state,
|
||||
select: (data) => data?.state,
|
||||
enabled: isOpened,
|
||||
})
|
||||
const status = projectState?.type
|
||||
@ -95,12 +91,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
// Project is closed, show open button
|
||||
if (!isOpened) {
|
||||
return backendModule.ProjectState.closed
|
||||
} else if (!isLoading && status == null) {
|
||||
// Project is opened, but not yet queried.
|
||||
return backendModule.ProjectState.openInProgress
|
||||
} else if (isLoading) {
|
||||
return backendModule.ProjectState.openInProgress
|
||||
} else if (status == null) {
|
||||
// Project is opened, but not yet queried.
|
||||
return backendModule.ProjectState.openInProgress
|
||||
} else if (status === backendModule.ProjectState.closed) {
|
||||
// Project is opened locally, but not on the backend yet.
|
||||
@ -113,8 +105,6 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const spinnerState = (() => {
|
||||
if (!isOpened) {
|
||||
return spinner.SpinnerState.initial
|
||||
} else if (isLoading) {
|
||||
return spinner.SpinnerState.loadingSlow
|
||||
} else if (isError) {
|
||||
return spinner.SpinnerState.initial
|
||||
} else if (status == null) {
|
||||
|
@ -63,7 +63,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const { backend, nodeMap } = state
|
||||
const client = useQueryClient()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
|
@ -10,8 +10,6 @@ import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
@ -37,20 +35,18 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
const { backend, category, setQuery } = state
|
||||
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const labels = backendHooks.useListTags(backend)
|
||||
const { data: labels } = backendHooks.useBackendQuery(backend, 'listTags', [])
|
||||
const labelsByName = React.useMemo(() => {
|
||||
return new Map(labels?.map((label) => [label.value, label]))
|
||||
}, [labels])
|
||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
|
||||
)
|
||||
const self = permissions.tryFindSelfPermission(user, asset.permissions)
|
||||
const managesThisAsset =
|
||||
category !== Category.trash &&
|
||||
category.type !== 'trash' &&
|
||||
(self?.permission === permissions.PermissionAction.own ||
|
||||
self?.permission === permissions.PermissionAction.admin)
|
||||
const setAsset = React.useCallback(
|
||||
|
@ -11,7 +11,6 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
@ -45,21 +44,16 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
const { item, setItem, state, isReadonly = false } = props
|
||||
const { category, setQuery } = state
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
|
||||
)
|
||||
const self = permissions.tryFindSelfPermission(user, asset.permissions)
|
||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const managesThisAsset =
|
||||
!isReadonly &&
|
||||
category !== Category.trash &&
|
||||
category.type !== 'trash' &&
|
||||
(self?.permission === permissions.PermissionAction.own ||
|
||||
self?.permission === permissions.PermissionAction.admin)
|
||||
const setAsset = React.useCallback(
|
||||
|
@ -1,33 +1,28 @@
|
||||
/** @file A heading for the "Accessed by projects" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import AccessedByProjectsIcon from '#/assets/accessed_by_projects.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
/** A heading for the "Accessed by projects" column. */
|
||||
export default function AccessedByProjectsColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
|
||||
return (
|
||||
<div className="flex h-table-row w-full items-center gap-icon-with-text">
|
||||
<Button
|
||||
active
|
||||
image={AccessedByProjectsIcon}
|
||||
className="size-4"
|
||||
alt={getText('accessedByProjectsColumnHide')}
|
||||
variant="icon"
|
||||
icon={AccessedByProjectsIcon}
|
||||
aria-label={getText('accessedByProjectsColumnHide')}
|
||||
tooltip={false}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.accessedByProjects)
|
||||
hideColumn(Column.accessedByProjects)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text-header">{getText('accessedByProjectsColumnName')}</aria.Text>
|
||||
<Text className="text-sm font-semibold">{getText('accessedByProjectsColumnName')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,33 +1,28 @@
|
||||
/** @file A heading for the "Accessed data" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import AccessedDataIcon from '#/assets/accessed_data.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
/** A heading for the "Accessed data" column. */
|
||||
export default function AccessedDataColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
|
||||
return (
|
||||
<div className="flex h-table-row w-full items-center gap-icon-with-text">
|
||||
<Button
|
||||
active
|
||||
image={AccessedDataIcon}
|
||||
className="size-4"
|
||||
alt={getText('accessedDataColumnHide')}
|
||||
variant="icon"
|
||||
icon={AccessedDataIcon}
|
||||
aria-label={getText('accessedDataColumnHide')}
|
||||
tooltip={false}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.accessedData)
|
||||
hideColumn(Column.accessedData)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text-header">{getText('accessedDataColumnName')}</aria.Text>
|
||||
<Text className="text-sm font-semibold">{getText('accessedDataColumnName')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,33 +1,28 @@
|
||||
/** @file A heading for the "Docs" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import DocsIcon from '#/assets/docs.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
/** A heading for the "Docs" column. */
|
||||
export default function DocsColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
export default function DocsColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
|
||||
return (
|
||||
<div className="flex h-table-row w-full items-center gap-icon-with-text">
|
||||
<Button
|
||||
active
|
||||
image={DocsIcon}
|
||||
className="size-4"
|
||||
alt={getText('docsColumnHide')}
|
||||
variant="icon"
|
||||
icon={DocsIcon}
|
||||
aria-label={getText('docsColumnHide')}
|
||||
tooltip={false}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.docs)
|
||||
hideColumn(Column.docs)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text-header">{getText('docsColumnName')}</aria.Text>
|
||||
<Text className="text-sm font-semibold">{getText('docsColumnName')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,33 +1,28 @@
|
||||
/** @file A heading for the "Labels" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import TagIcon from '#/assets/tag.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
/** A heading for the "Labels" column. */
|
||||
export default function LabelsColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
export default function LabelsColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
|
||||
return (
|
||||
<div className="flex h-table-row w-full items-center gap-icon-with-text">
|
||||
<Button
|
||||
active
|
||||
image={TagIcon}
|
||||
className="size-4"
|
||||
alt={getText('labelsColumnHide')}
|
||||
variant="icon"
|
||||
icon={TagIcon}
|
||||
aria-label={getText('labelsColumnHide')}
|
||||
tooltip={false}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.labels)
|
||||
hideColumn(Column.labels)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text-header">{getText('labelsColumnName')}</aria.Text>
|
||||
<Text className="fond-semibold text-sm">{getText('labelsColumnName')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,29 +1,21 @@
|
||||
/** @file A heading for the "Modified" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import SortAscendingIcon from '#/assets/sort_ascending.svg'
|
||||
import TimeIcon from '#/assets/time.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { Text } from '#/components/aria'
|
||||
import { Button } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
/** A heading for the "Modified" column. */
|
||||
export default function ModifiedColumnHeading(
|
||||
props: column.AssetColumnHeadingProps,
|
||||
): React.JSX.Element {
|
||||
export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { sortInfo, setSortInfo, hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const isSortActive = sortInfo?.field === columnUtils.Column.modified
|
||||
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
||||
const { getText } = useText()
|
||||
const isSortActive = sortInfo?.field === Column.modified
|
||||
const isDescending = sortInfo?.direction === SortDirection.descending
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -36,41 +28,39 @@ export default function ModifiedColumnHeading(
|
||||
className="group flex h-table-row w-full cursor-pointer items-center gap-icon-with-text"
|
||||
>
|
||||
<Button
|
||||
active
|
||||
image={TimeIcon}
|
||||
className="size-4"
|
||||
alt={getText('modifiedColumnHide')}
|
||||
variant="icon"
|
||||
icon={TimeIcon}
|
||||
aria-label={getText('modifiedColumnHide')}
|
||||
tooltip={false}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.modified)
|
||||
hideColumn(Column.modified)
|
||||
}}
|
||||
/>
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="flex grow justify-start gap-icon-with-text"
|
||||
onPress={() => {
|
||||
const nextDirection =
|
||||
isSortActive ?
|
||||
sorting.nextSortDirection(sortInfo.direction)
|
||||
: sorting.SortDirection.ascending
|
||||
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
|
||||
if (nextDirection == null) {
|
||||
setSortInfo(null)
|
||||
} else {
|
||||
setSortInfo({ field: columnUtils.Column.modified, direction: nextDirection })
|
||||
setSortInfo({ field: Column.modified, direction: nextDirection })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text-header">{getText('modifiedColumnName')}</aria.Text>
|
||||
<Text className="text-header">{getText('modifiedColumnName')}</Text>
|
||||
<img
|
||||
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
||||
src={SortAscendingIcon}
|
||||
className={tailwindMerge.twMerge(
|
||||
className={twMerge(
|
||||
'transition-all duration-arrow',
|
||||
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
|
||||
isDescending && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,30 +1,22 @@
|
||||
/** @file A heading for the "Name" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import SortAscendingIcon from '#/assets/sort_ascending.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
/** A heading for the "Name" column. */
|
||||
export default function NameColumnHeading(
|
||||
props: column.AssetColumnHeadingProps,
|
||||
): React.JSX.Element {
|
||||
export default function NameColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { sortInfo, setSortInfo } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const isSortActive = sortInfo?.field === columnUtils.Column.name
|
||||
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
||||
const { getText } = useText()
|
||||
const isSortActive = sortInfo?.field === Column.name
|
||||
const isDescending = sortInfo?.direction === SortDirection.descending
|
||||
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
aria-label={
|
||||
@ -36,26 +28,24 @@ export default function NameColumnHeading(
|
||||
className="group flex h-table-row w-full items-center justify-start gap-icon-with-text px-name-column-x"
|
||||
onPress={() => {
|
||||
const nextDirection =
|
||||
isSortActive ?
|
||||
sorting.nextSortDirection(sortInfo.direction)
|
||||
: sorting.SortDirection.ascending
|
||||
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
|
||||
if (nextDirection == null) {
|
||||
setSortInfo(null)
|
||||
} else {
|
||||
setSortInfo({ field: columnUtils.Column.name, direction: nextDirection })
|
||||
setSortInfo({ field: Column.name, direction: nextDirection })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text-header">{getText('nameColumnName')}</aria.Text>
|
||||
<Text className="text-sm font-semibold">{getText('nameColumnName')}</Text>
|
||||
<img
|
||||
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
||||
src={SortAscendingIcon}
|
||||
className={tailwindMerge.twMerge(
|
||||
className={twMerge(
|
||||
'transition-all duration-arrow',
|
||||
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
|
||||
isDescending && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
@ -1,53 +1,42 @@
|
||||
/** @file A heading for the "Shared with" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import PeopleIcon from '#/assets/people.svg'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import { PaywallDialogButton } from '#/components/Paywall'
|
||||
import { usePaywall } from '#/hooks/billing'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
/** A heading for the "Shared with" column. */
|
||||
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) {
|
||||
const { state } = props
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = useFullUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
return (
|
||||
<div className="flex h-table-row w-full items-center gap-icon-with-text">
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
variant="icon"
|
||||
size="medium"
|
||||
icon={PeopleIcon}
|
||||
aria-label={getText('sharedWithColumnHide')}
|
||||
tooltip={false}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.sharedWith)
|
||||
hideColumn(Column.sharedWith)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
|
||||
<Text className="text-sm font-semibold">{getText('sharedWithColumnName')}</Text>
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallDialogButton
|
||||
feature="share"
|
||||
variant="icon"
|
||||
children={false}
|
||||
size="medium"
|
||||
/>
|
||||
<PaywallDialogButton feature="share" variant="icon" children={false} size="medium" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -8,6 +8,7 @@ import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// ==============
|
||||
@ -39,6 +40,8 @@ export interface ButtonProps {
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
export default forwardRef(Button)
|
||||
|
||||
/** A styled button. */
|
||||
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const {
|
||||
@ -64,13 +67,17 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
||||
const button = (
|
||||
<FocusRing placement="after">
|
||||
<aria.Button
|
||||
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
|
||||
ref,
|
||||
className: tailwindMerge.twMerge(
|
||||
'relative after:pointer-events-none after:absolute after:inset after:rounded-button-focus-ring transition-colors hover:enabled:bg-primary/10 rounded-button-focus-ring -m-1 p-1',
|
||||
buttonClassName,
|
||||
),
|
||||
})}
|
||||
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
|
||||
buttonProps,
|
||||
focusChildProps,
|
||||
{
|
||||
ref,
|
||||
className: tailwindMerge.twMerge(
|
||||
'relative after:pointer-events-none after:absolute after:inset after:rounded-button-focus-ring transition-colors hover:enabled:bg-primary/10 rounded-button-focus-ring -m-1 p-1',
|
||||
buttonClassName,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
@ -103,5 +110,3 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
||||
</ariaComponents.TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(Button)
|
||||
|
@ -62,7 +62,11 @@ function FocusRoot(props: FocusRootProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
...(active ? { onKeyDown: navigator2D.onKeyDown.bind(navigator2D) } : {}),
|
||||
...(active && {
|
||||
onKeyDown: (event) => {
|
||||
navigator2D.onKeyDown(event)
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[active, children, navigator2D],
|
||||
)
|
||||
|
@ -1,27 +1,32 @@
|
||||
/** @file An input that handles focus movement. */
|
||||
import * as React from 'react'
|
||||
import type { ForwardedRef, RefAttributes } from 'react'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import {
|
||||
Input as AriaInput,
|
||||
mergeProps,
|
||||
type InputProps as AriaInputProps,
|
||||
} from '#/components/aria'
|
||||
import { useHandleFocusMove } from '#/hooks/focusHooks'
|
||||
import { useFocusDirection } from '#/providers/FocusDirectionProvider'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
|
||||
// =============
|
||||
// === Input ===
|
||||
// =============
|
||||
|
||||
/** Props for a {@link Input}. */
|
||||
export interface InputProps extends Readonly<aria.InputProps> {}
|
||||
export interface InputProps extends Readonly<AriaInputProps> {}
|
||||
|
||||
export default forwardRef(Input)
|
||||
|
||||
/** An input that handles focus movement. */
|
||||
function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
const focusDirection = focusDirectionProvider.useFocusDirection()
|
||||
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
|
||||
function Input(props: InputProps, ref: ForwardedRef<HTMLInputElement>) {
|
||||
const focusDirection = useFocusDirection()
|
||||
const handleFocusMove = useHandleFocusMove(focusDirection)
|
||||
|
||||
return (
|
||||
<aria.Input
|
||||
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(props, {
|
||||
<AriaInput
|
||||
{...mergeProps<AriaInputProps & RefAttributes<HTMLInputElement>>()(props, {
|
||||
ref,
|
||||
className: 'focus-child',
|
||||
onKeyDown: handleFocusMove,
|
||||
@ -29,5 +34,3 @@ function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(Input)
|
||||
|
@ -19,6 +19,7 @@ import * as React from 'react'
|
||||
import * as reactStately from 'react-stately'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
|
||||
/** Options for {@link useRenderProps}. */
|
||||
interface RenderPropsHookOptions<T> extends aria.DOMProps, aria.AriaLabelingProps {
|
||||
@ -101,6 +102,9 @@ function useSlot(): [React.RefCallback<Element>, boolean] {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const UNDEFINED = undefined
|
||||
|
||||
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
|
||||
export default forwardRef(RadioGroup)
|
||||
|
||||
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
|
||||
function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
;[props, ref] = aria.useContextProps(props, ref, aria.RadioGroupContext)
|
||||
@ -171,6 +175,3 @@ function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDiv
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
|
||||
export default React.forwardRef(RadioGroup)
|
||||
|
@ -11,6 +11,7 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
import * as aria from '#/components/aria'
|
||||
import Button from '#/components/styled/Button'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
// =====================
|
||||
@ -27,6 +28,8 @@ export interface SettingsInputProps {
|
||||
readonly onSubmit?: (event: React.SyntheticEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export default forwardRef(SettingsInput)
|
||||
|
||||
/** A styled input specific to settings pages. */
|
||||
function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
const { isDisabled = false, type, placeholder, autoComplete, onChange, onSubmit } = props
|
||||
@ -98,5 +101,3 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(SettingsInput)
|
||||
|
@ -1,7 +1,13 @@
|
||||
/** @file A styled button representing a tab on a sidebar. */
|
||||
import * as React from 'react'
|
||||
import { Button, type ButtonProps } from '#/components/AriaComponents'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
|
||||
import * as ariaComponent from '#/components/AriaComponents'
|
||||
const SIDEBAR_TAB_BUTTON_STYLES = tv({
|
||||
base: 'font-medium',
|
||||
variants: {
|
||||
isActive: { true: 'bg-white opacity-100' },
|
||||
},
|
||||
})
|
||||
|
||||
// ========================
|
||||
// === SidebarTabButton ===
|
||||
@ -13,28 +19,30 @@ export interface SidebarTabButtonProps {
|
||||
readonly isDisabled?: boolean
|
||||
readonly autoFocus?: boolean
|
||||
/** When `true`, the button is not faded out even when not hovered. */
|
||||
readonly active?: boolean
|
||||
readonly isActive?: boolean
|
||||
readonly icon: string
|
||||
readonly label: string
|
||||
readonly onPress: ariaComponent.ButtonProps['onPress']
|
||||
readonly onPress: ButtonProps['onPress']
|
||||
}
|
||||
|
||||
/** A styled button representing a tab on a sidebar. */
|
||||
export default function SidebarTabButton(props: SidebarTabButtonProps) {
|
||||
const { isDisabled = false, active = false, icon, label, onPress } = props
|
||||
const { isDisabled = false, isActive = false, icon, label, onPress } = props
|
||||
|
||||
const styles = SIDEBAR_TAB_BUTTON_STYLES({ isActive })
|
||||
|
||||
return (
|
||||
<ariaComponent.Button
|
||||
onPress={onPress}
|
||||
<Button
|
||||
icon={icon}
|
||||
variant="ghost"
|
||||
variant="ghost-fading"
|
||||
loaderPosition="icon"
|
||||
size="medium"
|
||||
isDisabled={isDisabled}
|
||||
rounded="full"
|
||||
className={active ? 'bg-white opacity-100' : ''}
|
||||
className={styles}
|
||||
onPress={onPress}
|
||||
>
|
||||
{label}
|
||||
</ariaComponent.Button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user