diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2a5a19f702aa..de2e21c8cbc7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,7 +30,7 @@ Cargo.toml /build.sbt @4e6 @jaroslavtulach @hubertp @Akirathan /distribution/ @4e6 @jdunkerley @radeusgd @GregoryTravis @AdRiley @marthasharkey /engine/ @4e6 @jaroslavtulach @hubertp @Akirathan -/project/ @4e6 @jaroslavtulach @hubertp +/project/ @4e6 @jaroslavtulach @hubertp @Akirathan /tools/ @4e6 @jaroslavtulach @radeusgd @hubertp # Enso Libraries diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 1357173a3141..a5cd19cdc646 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -20,7 +20,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/engine-benchmark.yml b/.github/workflows/engine-benchmark.yml index dc808eca5aa5..66d65fb7a37a 100644 --- a/.github/workflows/engine-benchmark.yml +++ b/.github/workflows/engine-benchmark.yml @@ -22,7 +22,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/engine-nightly.yml b/.github/workflows/engine-nightly.yml index 5997a095d712..bf58c73dc75e 100644 --- a/.github/workflows/engine-nightly.yml +++ b/.github/workflows/engine-nightly.yml @@ -23,7 +23,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -69,7 +69,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -113,7 +113,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -158,7 +158,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -203,7 +203,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -248,7 +248,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -305,7 +305,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -360,7 +360,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -416,7 +416,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -472,7 +472,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -528,7 +528,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -588,7 +588,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -646,7 +646,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -705,7 +705,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -764,7 +764,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/extra-nightly-tests.yml b/.github/workflows/extra-nightly-tests.yml index e0696b442e77..2b8a471fc19b 100644 --- a/.github/workflows/extra-nightly-tests.yml +++ b/.github/workflows/extra-nightly-tests.yml @@ -23,7 +23,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -84,7 +84,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/gui-tests.yml b/.github/workflows/gui-tests.yml index b12694f05afa..8e6ce1d61755 100644 --- a/.github/workflows/gui-tests.yml +++ b/.github/workflows/gui-tests.yml @@ -37,7 +37,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -80,7 +80,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -123,7 +123,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -166,7 +166,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/gui.yml b/.github/workflows/gui.yml index 2ec74a1d5353..c9c7c862f636 100644 --- a/.github/workflows/gui.yml +++ b/.github/workflows/gui.yml @@ -25,7 +25,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -67,7 +67,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -110,7 +110,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -165,7 +165,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -219,7 +219,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -274,7 +274,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -331,7 +331,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -350,7 +350,7 @@ jobs: run: ./run git-clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false + - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false --gui-sign-artifacts false env: ENSO_CLOUD_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} ENSO_CLOUD_CHAT_URL: ${{ vars.ENSO_CLOUD_CHAT_URL }} @@ -387,7 +387,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -406,7 +406,7 @@ jobs: run: ./run git-clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false + - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false --gui-sign-artifacts false env: APPLEID: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} APPLEIDPASS: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} @@ -451,7 +451,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -470,7 +470,7 @@ jobs: run: ./run git-clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false + - run: ./run ide build --backend-source current-ci-run --gui-upload-artifact false --gui-sign-artifacts false env: ENSO_CLOUD_API_URL: ${{ vars.ENSO_CLOUD_API_URL }} ENSO_CLOUD_CHAT_URL: ${{ vars.ENSO_CLOUD_CHAT_URL }} diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 5fbbfbd21a1b..c6cd484cfb98 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -31,7 +31,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9336bc045592..49ae23e4bd15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -60,7 +60,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -112,7 +112,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -161,7 +161,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -208,7 +208,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -256,7 +256,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -309,7 +309,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -361,7 +361,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -423,7 +423,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -490,7 +490,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -558,7 +558,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/scala-new.yml b/.github/workflows/scala-new.yml index ef44a17fb088..d0694d9c1ad4 100644 --- a/.github/workflows/scala-new.yml +++ b/.github/workflows/scala-new.yml @@ -37,7 +37,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -81,7 +81,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -126,7 +126,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -171,7 +171,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -226,7 +226,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -282,7 +282,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -338,7 +338,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -396,7 +396,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -455,7 +455,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: @@ -514,7 +514,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/.github/workflows/std-libs-benchmark.yml b/.github/workflows/std-libs-benchmark.yml index dbac651715c2..1c94146c09db 100644 --- a/.github/workflows/std-libs-benchmark.yml +++ b/.github/workflows/std-libs-benchmark.yml @@ -22,7 +22,7 @@ jobs: name: Installing wasm-pack uses: jetli/wasm-pack-action@v0.4.0 with: - version: v0.10.2 + version: v0.12.1 - name: Expose Artifact API and context information. uses: actions/github-script@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f4df9ef33e..7b3c37979e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ #### Enso IDE +- [Rows and Columns may be now removed in Table Input Widget][11151]. The option + is available in right-click context menu. + +[11151]: https://github.com/enso-org/enso/pull/11151 + +# Enso 2024.4 + +#### 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 @@ -14,6 +23,14 @@ updated actual code][10857] - [Added fullscreen modes to documentation editor and code editor][10876] - [Fixed issue with node name assignment when uploading multiple files.][10979] +- [Cloud file browser inserts `enso:` paths][11001] +- [Fixed issue where drag'n'dropped files were not uploaded in cloud + projects.][11014] +- [Fixed files associations not properly registered on Windows][11030] +- [Fixed "rename project" button being broken after not changing project + name][11103] +- [Numbers starting with dot (`.5`) are accepted in Numeric Widget][11108] +- [Add support for interacting with graph editor using touch devices.][11056] [10774]: https://github.com/enso-org/enso/pull/10774 [10814]: https://github.com/enso-org/enso/pull/10814 @@ -21,6 +38,12 @@ [10857]: https://github.com/enso-org/enso/pull/10857 [10876]: https://github.com/enso-org/enso/pull/10876 [10979]: https://github.com/enso-org/enso/pull/10979 +[11001]: https://github.com/enso-org/enso/pull/11001 +[11014]: https://github.com/enso-org/enso/pull/11014 +[11030]: https://github.com/enso-org/enso/pull/11030 +[11103]: https://github.com/enso-org/enso/pull/11103 +[11108]: https://github.com/enso-org/enso/pull/11108 +[11056]: https://github.com/enso-org/enso/pull/11056 #### Enso Standard Library diff --git a/app/dashboard/e2e/actions.ts b/app/dashboard/e2e/actions.ts index aaf4830d39cb..96b415d85a3d 100644 --- a/app/dashboard/e2e/actions.ts +++ b/app/dashboard/e2e/actions.ts @@ -347,12 +347,6 @@ export function locateCollapsibleDirectories(page: test.Page) { return locateAssetRows(page).filter({ has: page.locator('[aria-label=Collapse]') }) } -/** Find a "confirm delete" modal (if any) on the current page. */ -export function locateConfirmDeleteModal(page: test.Page) { - // This has no identifying features. - return page.getByTestId('confirm-delete-modal') -} - /** Find a "new label" modal (if any) on the current page. */ export function locateNewLabelModal(page: test.Page) { // This has no identifying features. @@ -365,12 +359,6 @@ export function locateUpsertSecretModal(page: test.Page) { return page.getByTestId('upsert-secret-modal') } -/** Find a "new user group" modal (if any) on the current page. */ -export function locateNewUserGroupModal(page: test.Page) { - // This has no identifying features. - return page.getByTestId('new-user-group-modal') -} - /** Find a user menu (if any) on the current page. */ export function locateUserMenu(page: test.Page) { return page.getByAltText('User Settings').locator('visible=true') diff --git a/app/dashboard/e2e/actions/DrivePageActions.ts b/app/dashboard/e2e/actions/DrivePageActions.ts index f6062dd512cb..50f13cad484a 100644 --- a/app/dashboard/e2e/actions/DrivePageActions.ts +++ b/app/dashboard/e2e/actions/DrivePageActions.ts @@ -62,7 +62,7 @@ export default class DrivePageActions extends PageActions { cloud() { return self.step('Go to "Cloud" category', (page) => page - .getByRole('button', { name: TEXT.cloudCategory }) + .getByRole('button', { name: TEXT.cloudCategory, exact: true }) .getByText(TEXT.cloudCategory) .click(), ) @@ -71,7 +71,7 @@ export default class DrivePageActions extends PageActions { local() { return self.step('Go to "Local" category', (page) => page - .getByRole('button', { name: TEXT.localCategory }) + .getByRole('button', { name: TEXT.localCategory, exact: true }) .getByText(TEXT.localCategory) .click(), ) @@ -80,7 +80,7 @@ export default class DrivePageActions extends PageActions { recent() { return self.step('Go to "Recent" category', (page) => page - .getByRole('button', { name: TEXT.recentCategory }) + .getByRole('button', { name: TEXT.recentCategory, exact: true }) .getByText(TEXT.recentCategory) .click(), ) @@ -88,10 +88,7 @@ export default class DrivePageActions extends PageActions { /** Switch to the "trash" category. */ trash() { return self.step('Go to "Trash" category', (page) => - page - .getByRole('button', { name: TEXT.trashCategory }) - .getByText(TEXT.trashCategory) - .click(), + page.getByRole('button', { name: TEXT.trashCategory, exact: true }).click(), ) }, } @@ -241,7 +238,7 @@ export default class DrivePageActions extends PageActions { /** Create a new empty project. */ newEmptyProject() { return this.step('Create empty project', (page) => - page.getByText(TEXT.newEmptyProject).click(), + page.getByText(TEXT.newEmptyProject, { exact: true }).click(), ).into(EditorPageActions) } @@ -252,9 +249,12 @@ export default class DrivePageActions extends PageActions { /** Create a new folder using the icon in the Drive Bar. */ createFolder() { - return this.step('Create folder', (page) => - page.getByRole('button', { name: TEXT.newFolder, exact: true }).click(), - ) + return this.step('Create folder', async (page) => { + await page.getByRole('button', { name: TEXT.newFolder, exact: true }).click() + // eslint-disable-next-line no-restricted-properties + await test.expect(page.locator('input:focus')).toBeVisible() + await page.keyboard.press('Escape') + }) } /** Upload a file using the icon in the Drive Bar. */ diff --git a/app/dashboard/e2e/actions/contextMenuActions.ts b/app/dashboard/e2e/actions/contextMenuActions.ts index e87bd8f50e6c..47de5ea5d854 100644 --- a/app/dashboard/e2e/actions/contextMenuActions.ts +++ b/app/dashboard/e2e/actions/contextMenuActions.ts @@ -1,4 +1,5 @@ /** @file Actions for the context menu. */ +import { TEXT } from '../actions' import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' import EditorPageActions from './EditorPageActions' @@ -13,7 +14,8 @@ export interface ContextMenuActions { readonly uploadToCloud: () => T readonly rename: () => T readonly snapshot: () => T - readonly moveToTrash: () => T + readonly moveNonFolderToTrash: () => T + readonly moveFolderToTrash: () => T readonly moveAllToTrash: () => T readonly restoreFromTrash: () => T readonly restoreAllFromTrash: () => T @@ -43,97 +45,153 @@ export function contextMenuActions( return { open: () => step('Open (context menu)', (page) => - page.getByRole('button', { name: 'Open' }).getByText('Open').click(), + page.getByRole('button', { name: TEXT.openShortcut }).getByText(TEXT.openShortcut).click(), ), uploadToCloud: () => step('Upload to cloud (context menu)', (page) => - page.getByRole('button', { name: 'Upload To Cloud' }).getByText('Upload To Cloud').click(), + page + .getByRole('button', { name: TEXT.uploadToCloudShortcut }) + .getByText(TEXT.uploadToCloudShortcut) + .click(), ), rename: () => step('Rename (context menu)', (page) => - page.getByRole('button', { name: 'Rename' }).getByText('Rename').click(), + page + .getByRole('button', { name: TEXT.renameShortcut }) + .getByText(TEXT.renameShortcut) + .click(), ), snapshot: () => step('Snapshot (context menu)', (page) => - page.getByRole('button', { name: 'Snapshot' }).getByText('Snapshot').click(), + page + .getByRole('button', { name: TEXT.snapshotShortcut }) + .getByText(TEXT.snapshotShortcut) + .click(), ), - moveToTrash: () => + moveNonFolderToTrash: () => step('Move to trash (context menu)', (page) => - page.getByRole('button', { name: 'Move To Trash' }).getByText('Move To Trash').click(), + page + .getByRole('button', { name: TEXT.moveToTrashShortcut }) + .getByText(TEXT.moveToTrashShortcut) + .click(), ), + moveFolderToTrash: () => + step('Move folder to trash (context menu)', async (page) => { + await page + .getByRole('button', { name: TEXT.moveToTrashShortcut }) + .getByText(TEXT.moveToTrashShortcut) + .click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + }), moveAllToTrash: () => step('Move all to trash (context menu)', (page) => page - .getByRole('button', { name: 'Move All To Trash' }) - .getByText('Move All To Trash') + .getByRole('button', { name: TEXT.moveAllToTrashShortcut }) + .getByText(TEXT.moveAllToTrashShortcut) .click(), ), restoreFromTrash: () => step('Restore from trash (context menu)', (page) => page - .getByRole('button', { name: 'Restore From Trash' }) - .getByText('Restore From Trash') + .getByRole('button', { name: TEXT.restoreFromTrashShortcut }) + .getByText(TEXT.restoreFromTrashShortcut) .click(), ), restoreAllFromTrash: () => step('Restore all from trash (context menu)', (page) => page - .getByRole('button', { name: 'Restore All From Trash' }) - .getByText('Restore All From Trash') + .getByRole('button', { name: TEXT.restoreAllFromTrashShortcut }) + .getByText(TEXT.restoreAllFromTrashShortcut) .click(), ), share: () => step('Share (context menu)', (page) => - page.getByRole('button', { name: 'Share' }).getByText('Share').click(), + page + .getByRole('button', { name: TEXT.shareShortcut }) + .getByText(TEXT.shareShortcut) + .click(), ), label: () => step('Label (context menu)', (page) => - page.getByRole('button', { name: 'Label' }).getByText('Label').click(), + page + .getByRole('button', { name: TEXT.labelShortcut }) + .getByText(TEXT.labelShortcut) + .click(), ), duplicate: () => step('Duplicate (context menu)', (page) => - page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click(), + page + .getByRole('button', { name: TEXT.duplicateShortcut }) + .getByText(TEXT.duplicateShortcut) + .click(), ), duplicateProject: () => step('Duplicate project (context menu)', (page) => - page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click(), + page + .getByRole('button', { name: TEXT.duplicateShortcut }) + .getByText(TEXT.duplicateShortcut) + .click(), ).into(EditorPageActions), copy: () => step('Copy (context menu)', (page) => - page.getByRole('button', { name: 'Copy' }).getByText('Copy', { exact: true }).click(), + page + .getByRole('button', { name: TEXT.copyShortcut }) + .getByText(TEXT.copyShortcut, { exact: true }) + .click(), ), cut: () => step('Cut (context menu)', (page) => - page.getByRole('button', { name: 'Cut' }).getByText('Cut').click(), + page.getByRole('button', { name: TEXT.cutShortcut }).getByText(TEXT.cutShortcut).click(), ), paste: () => step('Paste (context menu)', (page) => - page.getByRole('button', { name: 'Paste' }).getByText('Paste').click(), + page + .getByRole('button', { name: TEXT.pasteShortcut }) + .getByText(TEXT.pasteShortcut) + .click(), ), copyAsPath: () => step('Copy as path (context menu)', (page) => - page.getByRole('button', { name: 'Copy As Path' }).getByText('Copy As Path').click(), + page + .getByRole('button', { name: TEXT.copyAsPathShortcut }) + .getByText(TEXT.copyAsPathShortcut) + .click(), ), download: () => step('Download (context menu)', (page) => - page.getByRole('button', { name: 'Download' }).getByText('Download').click(), + page + .getByRole('button', { name: TEXT.downloadShortcut }) + .getByText(TEXT.downloadShortcut) + .click(), ), // TODO: Specify the files in parameters. uploadFiles: () => step('Upload files (context menu)', (page) => - page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files').click(), + page + .getByRole('button', { name: TEXT.uploadFilesShortcut }) + .getByText(TEXT.uploadFilesShortcut) + .click(), ), newFolder: () => step('New folder (context menu)', (page) => - page.getByRole('button', { name: 'New Folder' }).getByText('New Folder').click(), + page + .getByRole('button', { name: TEXT.newFolderShortcut }) + .getByText(TEXT.newFolderShortcut) + .click(), ), newSecret: () => step('New secret (context menu)', (page) => - page.getByRole('button', { name: 'New Secret' }).getByText('New Secret').click(), + page + .getByRole('button', { name: TEXT.newSecretShortcut }) + .getByText(TEXT.newSecretShortcut) + .click(), ), newDataLink: () => step('New Data Link (context menu)', (page) => - page.getByRole('button', { name: 'New Data Link' }).getByText('New Data Link').click(), + page + .getByRole('button', { name: TEXT.newDatalinkShortcut }) + .getByText(TEXT.newDatalinkShortcut) + .click(), ), } } diff --git a/app/dashboard/e2e/api.ts b/app/dashboard/e2e/api.ts index a9ccef283cf8..d527bad76ae6 100644 --- a/app/dashboard/e2e/api.ts +++ b/app/dashboard/e2e/api.ts @@ -704,7 +704,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { new URL(request.url()).searchParams.entries(), ) as never - const file = addFile(searchParams.file_name) + const file = addFile( + searchParams.file_name, + searchParams.parent_directory_id == null ? + {} + : { parentId: searchParams.parent_directory_id }, + ) return { path: '', id: file.id, project: null } satisfies backend.FileInfo }) @@ -900,8 +905,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const body: backend.CreateDirectoryRequestBody = request.postDataJSON() const title = body.title const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) - const parentId = - body.parentId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + const parentId = body.parentId ?? defaultDirectoryId const json: backend.CreatedDirectory = { title, id, parentId } addDirectory(title, { description: null, diff --git a/app/dashboard/e2e/delete.spec.ts b/app/dashboard/e2e/delete.spec.ts index d445f9a186e3..67eec35feaeb 100644 --- a/app/dashboard/e2e/delete.spec.ts +++ b/app/dashboard/e2e/delete.spec.ts @@ -1,17 +1,16 @@ /** @file Test copying, moving, cutting and pasting. */ import * as test from '@playwright/test' -import * as actions from './actions' +import { mockAllAndLogin, TEXT } from './actions' test.test('delete and restore', ({ page }) => - actions - .mockAllAndLogin({ page }) + mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(1) }) .driveTable.rightClickRow(0) - .contextMenu.moveToTrash() + .contextMenu.moveFolderToTrash() .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { @@ -27,14 +26,16 @@ test.test('delete and restore', ({ page }) => ) test.test('delete and restore (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) + mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(1) }) .driveTable.clickRow(0) .press('Delete') + .do(async (thePage) => { + await thePage.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + }) .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { diff --git a/app/dashboard/e2e/driveView.spec.ts b/app/dashboard/e2e/driveView.spec.ts index bdb80e765f8a..7b65bf794977 100644 --- a/app/dashboard/e2e/driveView.spec.ts +++ b/app/dashboard/e2e/driveView.spec.ts @@ -37,7 +37,7 @@ test.test('drive view', ({ page }) => }) // Project context menu .driveTable.rightClickRow(0) - .contextMenu.moveToTrash() + .contextMenu.moveNonFolderToTrash() .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(1) }), diff --git a/app/dashboard/e2e/editAssetName.spec.ts b/app/dashboard/e2e/editAssetName.spec.ts index 42cf6ce06213..09eaa8ec09a5 100644 --- a/app/dashboard/e2e/editAssetName.spec.ts +++ b/app/dashboard/e2e/editAssetName.spec.ts @@ -7,12 +7,12 @@ test.test.beforeEach(({ page }) => actions.mockAllAndLogin({ page })) test.test('edit name', async ({ page }) => { const assetRows = actions.locateAssetRows(page) - const mod = await actions.modModifier(page) const row = assetRows.nth(0) const newName = 'foo bar baz' await actions.locateNewFolderIcon(page).click() - await actions.locateAssetRowName(row).click({ modifiers: [mod] }) + await actions.locateAssetRowName(row).click() + await actions.locateAssetRowName(row).click() await actions.locateAssetRowName(row).fill(newName) await actions.locateEditingTick(row).click() await test.expect(row).toHaveText(new RegExp('^' + newName)) @@ -33,13 +33,14 @@ test.test('edit name (keyboard)', async ({ page }) => { test.test('cancel editing name', async ({ page }) => { const assetRows = actions.locateAssetRows(page) - const mod = await actions.modModifier(page) const row = assetRows.nth(0) const newName = 'foo bar baz' await actions.locateNewFolderIcon(page).click() const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click({ modifiers: [mod] }) + await actions.locateAssetRowName(row).click() + await actions.locateAssetRowName(row).click() + await actions.locateAssetRowName(row).fill(newName) await actions.locateEditingCross(row).click() await test.expect(row).toHaveText(new RegExp('^' + oldName)) @@ -61,12 +62,12 @@ test.test('cancel editing name (keyboard)', async ({ page }) => { test.test('change to blank name', async ({ page }) => { const assetRows = actions.locateAssetRows(page) - const mod = await actions.modModifier(page) const row = assetRows.nth(0) await actions.locateNewFolderIcon(page).click() const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click({ modifiers: [mod] }) + await actions.locateAssetRowName(row).click() + await actions.locateAssetRowName(row).click() await actions.locateAssetRowName(row).fill('') await test.expect(actions.locateEditingTick(row)).not.toBeVisible() await actions.locateEditingCross(row).click() diff --git a/app/dashboard/e2e/labelsPanel.spec.ts b/app/dashboard/e2e/labelsPanel.spec.ts index 24350aa7907b..1249f05d7fb7 100644 --- a/app/dashboard/e2e/labelsPanel.spec.ts +++ b/app/dashboard/e2e/labelsPanel.spec.ts @@ -59,6 +59,6 @@ test.test('labels', async ({ page }) => { const labelsPanel = locateLabelsPanel(page) await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() - await page.getByRole('button', { name: 'Delete' }).getByText('Delete').click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() test.expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) }) diff --git a/app/dashboard/package.json b/app/dashboard/package.json index 03d89cebc9e4..3e0073e87135 100644 --- a/app/dashboard/package.json +++ b/app/dashboard/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@aws-amplify/auth": "5.6.5", + "amazon-cognito-identity-js": "6.3.6", "@aws-amplify/core": "5.8.5", "@hookform/resolvers": "^3.4.0", "@internationalized/date": "^3.5.5", @@ -60,7 +61,9 @@ "ts-results": "^3.3.0", "validator": "^13.12.0", "zod": "^3.23.8", - "zustand": "^4.5.4" + "zustand": "^4.5.4", + "input-otp": "1.2.4", + "qrcode.react": "3.1.0" }, "devDependencies": { "@fast-check/vitest": "^0.0.8", diff --git a/app/dashboard/src/App.tsx b/app/dashboard/src/App.tsx index 1c72ad17f972..bf09c66b140a 100644 --- a/app/dashboard/src/App.tsx +++ b/app/dashboard/src/App.tsx @@ -346,6 +346,7 @@ function AppRouter(props: AppRouterProps) { }, } }, [localStorage, inputBindingsRaw]) + const mainPageUrl = getMainPageUrl() // Subscribe to `localStorage` updates to trigger a rerender when the terms of service @@ -354,10 +355,10 @@ function AppRouter(props: AppRouterProps) { localStorageProvider.useLocalStorageState('privacyPolicy') const authService = useInitAuthService(props) - const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null - const refreshUserSession = - authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null - const registerAuthEventListener = authService?.registerAuthEventListener ?? null + + const userSession = authService.cognito.userSession.bind(authService.cognito) + const refreshUserSession = authService.cognito.refreshUserSession.bind(authService.cognito) + const registerAuthEventListener = authService.registerAuthEventListener React.useEffect(() => { if ('menuApi' in window) { @@ -490,7 +491,7 @@ function AppRouter(props: AppRouterProps) { + + + \ No newline at end of file diff --git a/app/dashboard/src/assets/joining.png b/app/dashboard/src/assets/joining.png new file mode 100644 index 000000000000..7b9cfad33bdc Binary files /dev/null and b/app/dashboard/src/assets/joining.png differ diff --git a/app/dashboard/src/assets/kmeans.png b/app/dashboard/src/assets/kmeans.png new file mode 100644 index 000000000000..aaef5a2cbdf5 Binary files /dev/null and b/app/dashboard/src/assets/kmeans.png differ diff --git a/app/dashboard/src/assets/monthSales.png b/app/dashboard/src/assets/monthSales.png new file mode 100644 index 000000000000..7c8f4d221979 Binary files /dev/null and b/app/dashboard/src/assets/monthSales.png differ diff --git a/app/dashboard/src/assets/nasdaq.png b/app/dashboard/src/assets/nasdaq.png new file mode 100644 index 000000000000..36cfaadf3ed4 Binary files /dev/null and b/app/dashboard/src/assets/nasdaq.png differ diff --git a/app/dashboard/src/assets/shield_break.svg b/app/dashboard/src/assets/shield_break.svg new file mode 100644 index 000000000000..dbdaf22f2e52 --- /dev/null +++ b/app/dashboard/src/assets/shield_break.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/dashboard/src/assets/shield_check.svg b/app/dashboard/src/assets/shield_check.svg new file mode 100644 index 000000000000..dcc2e7d24ae7 --- /dev/null +++ b/app/dashboard/src/assets/shield_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/dashboard/src/assets/shield_crossed.svg b/app/dashboard/src/assets/shield_crossed.svg new file mode 100644 index 000000000000..03270c29cb7c --- /dev/null +++ b/app/dashboard/src/assets/shield_crossed.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/dashboard/src/assets/un_fa.svg b/app/dashboard/src/assets/un_fa.svg new file mode 100644 index 000000000000..73b74efe8466 --- /dev/null +++ b/app/dashboard/src/assets/un_fa.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/dashboard/src/assets/weather.png b/app/dashboard/src/assets/weather.png new file mode 100644 index 000000000000..9286d7183d5e Binary files /dev/null and b/app/dashboard/src/assets/weather.png differ diff --git a/app/dashboard/src/authentication/cognito.mock.ts b/app/dashboard/src/authentication/cognito.mock.ts index 9f379103c9b1..f293b78d9b35 100644 --- a/app/dashboard/src/authentication/cognito.mock.ts +++ b/app/dashboard/src/authentication/cognito.mock.ts @@ -283,6 +283,13 @@ export class Cognito { async refreshUserSession() { return Promise.resolve(results.Ok(null)) } + + /** + * Returns MFA preference for the current user. + */ + async getMFAPreference() { + return Promise.resolve(results.Ok('NOMFA')) + } } // =================== diff --git a/app/dashboard/src/authentication/cognito.ts b/app/dashboard/src/authentication/cognito.ts index 989e61dedd92..595c6355e1d9 100644 --- a/app/dashboard/src/authentication/cognito.ts +++ b/app/dashboard/src/authentication/cognito.ts @@ -30,7 +30,7 @@ * `kind` field provides a unique string that can be used to brand the error in place of the * `internalCode`, when rethrowing the error. */ import * as amplify from '@aws-amplify/auth' -import type * as cognito from 'amazon-cognito-identity-js' +import * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' import * as detect from 'enso-common/src/detect' @@ -70,6 +70,18 @@ interface UserAttributes { } /* eslint-enable @typescript-eslint/naming-convention */ +/** + * The type of multi-factor authentication (MFA) that the user has set up. + */ +export type MfaType = 'NOMFA' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'TOTP' + +/** + * The type of challenge that the user is currently facing after signing in. + * + * The `NO_CHALLENGE` value is used when the user is not currently facing any challenge. + */ +export type UserSessionChallenge = cognito.ChallengeName | 'NO_CHALLENGE' + /** User information returned from {@link amplify.Auth.currentUserInfo}. */ interface UserInfo { readonly username: string @@ -214,6 +226,16 @@ export class Cognito { return userInfo.attributes['custom:organizationId'] ?? null } + /** + * Gets user email from cognito + */ + async email() { + // This `any` comes from a third-party API and cannot be avoided. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const userInfo: UserInfo = await amplify.Auth.currentUserInfo() + return userInfo.attributes.email + } + /** Sign up with username and password. * * Does not rely on federated identity providers (e.g., Google or GitHub). */ @@ -268,7 +290,20 @@ export class Cognito { * Does not rely on external identity providers (e.g., Google or GitHub). */ async signInWithPassword(username: string, password: string) { const result = await results.Result.wrapAsync(async () => { - await amplify.Auth.signIn(username, password) + // This `any` comes from a third-party API and cannot be avoided. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const maybeUser = await amplify.Auth.signIn(username, password) + + if (maybeUser instanceof cognito.CognitoUser) { + return maybeUser + } else { + // eslint-disable-next-line no-restricted-properties + console.error( + 'Unknown result from signIn, expected CognitoUser, got ' + typeof maybeUser, + JSON.stringify(maybeUser), + ) + throw new Error('Unknown response from the server, please try again later ') + } }) return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow) @@ -363,6 +398,112 @@ export class Cognito { } } + /** + * Start the TOTP setup process. Returns the secret and the URL to scan the QR code. + */ + async setupTOTP() { + const email = await this.email() + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + + const result = ( + await results.Result.wrapAsync(() => amplify.Auth.setupTOTP(cognitoUser)) + ).map((data) => { + const str = 'otpauth://totp/AWSCognito:' + email + '?secret=' + data + '&issuer=' + 'Enso' + + return { secret: data, url: str } as const + }) + + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Verify the TOTP token during the setup process. + * Use it *only* during the setup process. + */ + async verifyTotpSetup(totpToken: string) { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + const result = await results.Result.wrapAsync(async () => { + await amplify.Auth.verifyTotpToken(cognitoUser, totpToken) + }) + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Set the user's preferred MFA method. + */ + async updateMFAPreference(mfaMethod: MfaType) { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + const result = await results.Result.wrapAsync( + async () => await amplify.Auth.setPreferredMFA(cognitoUser, mfaMethod), + ) + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Get the user's preferred MFA method. + */ + async getMFAPreference() { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + const result = await results.Result.wrapAsync(async () => { + // eslint-disable-next-line no-restricted-syntax + return (await amplify.Auth.getPreferredMFA(cognitoUser)) as MfaType + }) + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Verify the TOTP token. + * Returns the user session if the token is valid. + */ + async verifyTotpToken(totpToken: string) { + const cognitoUserResult = await currentAuthenticatedUser() + + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + + return ( + await results.Result.wrapAsync(() => amplify.Auth.verifyTotpToken(cognitoUser, totpToken)) + ).mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Confirm the sign in with the MFA token. + */ + async confirmSignIn( + user: amplify.CognitoUser, + confirmationCode: string, + mfaType: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA', + ) { + const result = await results.Result.wrapAsync(() => + amplify.Auth.confirmSignIn(user, confirmationCode, mfaType), + ) + + return result.mapErr(intoAmplifyErrorOrThrow) + } + /** We want to signal to Amplify to fire a "custom state change" event when the user is * redirected back to the application after signing in via an external identity provider. This * is done so we get a chance to fix the location history. The location history is the history @@ -707,3 +848,4 @@ async function currentAuthenticatedUser() { ) return result.mapErr(intoAmplifyErrorOrThrow) } +export { CognitoUser } from '@aws-amplify/auth' diff --git a/app/dashboard/src/authentication/service.ts b/app/dashboard/src/authentication/service.ts index a72f2b3d83d5..e4caeafab38e 100644 --- a/app/dashboard/src/authentication/service.ts +++ b/app/dashboard/src/authentication/service.ts @@ -116,20 +116,17 @@ export interface AuthService { * * This hook should only be called in a single place, as it performs global configuration of the * Amplify library. */ -export function useInitAuthService(authConfig: AuthConfig): AuthService | null { +export function useInitAuthService(authConfig: AuthConfig): AuthService { const { supportsDeepLinks } = authConfig + const logger = useLogger() const navigate = useNavigate() + return React.useMemo(() => { const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate) - const cognito = - amplifyConfig == null ? null : ( - new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig) - ) + const cognito = new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig) - return cognito == null ? null : ( - { cognito, registerAuthEventListener: listen.registerAuthEventListener } - ) + return { cognito, registerAuthEventListener: listen.registerAuthEventListener } }, [logger, navigate, supportsDeepLinks]) } @@ -138,7 +135,7 @@ function loadAmplifyConfig( logger: Logger, supportsDeepLinks: boolean, navigate: (url: string) => void, -): AmplifyConfig | null { +): AmplifyConfig { let urlOpener: ((url: string) => void) | null = null let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null = null @@ -175,25 +172,18 @@ function loadAmplifyConfig( /** Load the platform-specific Amplify configuration. */ const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl - return ( - process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID == null || - process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID == null || - process.env.ENSO_CLOUD_COGNITO_DOMAIN == null || - process.env.ENSO_CLOUD_COGNITO_REGION == null - ) ? - null - : { - userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID, - userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID, - domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN, - region: process.env.ENSO_CLOUD_COGNITO_REGION, - redirectSignIn: signInOutRedirect, - redirectSignOut: signInOutRedirect, - scope: ['email', 'openid', 'aws.cognito.signin.user.admin'], - responseType: 'code', - urlOpener, - saveAccessToken, - } + return { + userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID, + userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID, + domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN, + region: process.env.ENSO_CLOUD_COGNITO_REGION, + redirectSignIn: signInOutRedirect, + redirectSignOut: signInOutRedirect, + scope: ['email', 'openid', 'aws.cognito.signin.user.admin'], + responseType: 'code', + urlOpener, + saveAccessToken, + } } /** Set the callback that will be invoked when a deep link to the application is opened. diff --git a/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx b/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx index 000b705fe40a..40c06b173656 100644 --- a/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx +++ b/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx @@ -1,6 +1,8 @@ /** @file Alert component. */ import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react' +import SvgMask from '#/components/SvgMask' + import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' @@ -9,7 +11,7 @@ import { tv, type VariantProps } from '#/utilities/tailwindVariants' // ================= export const ALERT_STYLES = tv({ - base: 'flex flex-col items-stretch', + base: 'flex items-stretch gap-2', variants: { fullWidth: { true: 'w-full' }, variant: { @@ -37,6 +39,11 @@ export const ALERT_STYLES = tv({ large: 'px-4 pt-2 pb-2', }, }, + slots: { + iconContainer: 'flex items-center justify-center w-6 h-6', + children: 'flex flex-col items-stretch', + icon: 'flex items-center justify-center w-6 h-6 mr-2', + }, defaultVariants: { fullWidth: true, variant: 'error', @@ -53,7 +60,12 @@ export const ALERT_STYLES = tv({ export interface AlertProps extends PropsWithChildren, VariantProps, - HTMLAttributes {} + HTMLAttributes { + /** + * The icon to display in the Alert + */ + readonly icon?: React.ReactElement | string | null | undefined +} /** Alert component. */ // eslint-disable-next-line no-restricted-syntax @@ -61,20 +73,45 @@ export const Alert = forwardRef(function Alert( props: AlertProps, ref: ForwardedRef, ) { - const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props + const { + children, + className, + variant, + size, + rounded, + fullWidth, + icon, + variants = ALERT_STYLES, + ...containerProps + } = props if (variant === 'error') { containerProps.tabIndex = -1 containerProps.role = 'alert' } + const classes = variants({ + variant, + size, + rounded, + fullWidth, + }) + return ( -
- {children} +
+ {icon != null && + (() => { + if (typeof icon === 'string') { + // eslint-disable-next-line no-restricted-syntax + return ( +
+ +
+ ) + } + return
{icon}
+ })()} +
{children}
) }) diff --git a/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx b/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx index c8859460edbc..710cfba95713 100644 --- a/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx +++ b/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx @@ -72,6 +72,7 @@ export const CheckboxGroup = forwardRef( return ( { const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name] diff --git a/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx b/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx index 434744036892..59fd6464f267 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx @@ -9,13 +9,14 @@ import * as portal from '#/components/Portal' import * as suspense from '#/components/Suspense' import * as mergeRefs from '#/utilities/mergeRefs' -import * as twv from '#/utilities/tailwindVariants' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' import type * as types from './types' import * as utlities from './utilities' -import * as variants from './variants' +import { DIALOG_BACKGROUND } from './variants' // ================= // === Constants === @@ -25,10 +26,10 @@ import * as variants from './variants' */ export interface DialogProps extends types.DialogProps, - Omit, 'scrolledToTop'> {} + Omit, 'scrolledToTop'> {} -const OVERLAY_STYLES = twv.tv({ - base: 'fixed inset-0 isolate flex items-center justify-center bg-black/[25%]', +const OVERLAY_STYLES = tv({ + base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20', variants: { isEntering: { true: 'animate-in fade-in duration-200 ease-out' }, isExiting: { true: 'animate-out fade-out duration-200 ease-in' }, @@ -36,17 +37,25 @@ const OVERLAY_STYLES = twv.tv({ }, }) -const MODAL_STYLES = twv.tv({ - base: 'fixed inset-0 flex items-center justify-center text-center text-xs text-primary p-4', +const MODAL_STYLES = tv({ + base: 'fixed inset-0 flex items-center justify-center text-xs text-primary', variants: { - isEntering: { true: 'animate-in slide-in-from-top-1 ease-out duration-200' }, - isExiting: { true: 'animate-out slide-out-to-top-1 ease-in duration-200' }, + isEntering: { true: 'animate-in ease-out duration-200' }, + isExiting: { true: 'animate-out ease-in duration-200' }, + type: { modal: '', fullscreen: 'p-3.5' }, }, + compoundVariants: [ + { type: 'modal', isEntering: true, class: 'slide-in-from-top-1' }, + { type: 'modal', isExiting: true, class: 'slide-out-to-top-1' }, + { type: 'fullscreen', isEntering: true, class: 'zoom-in-[1.015]' }, + { type: 'fullscreen', isExiting: true, class: 'zoom-out-[1.015]' }, + ], }) -const DIALOG_STYLES = twv.tv({ - extend: variants.DIALOG_STYLES, - base: 'w-full overflow-y-auto', +const DIALOG_STYLES = tv({ + base: DIALOG_BACKGROUND({ + className: 'w-full max-w-full flex flex-col text-left align-middle shadow-xl', + }), variants: { type: { modal: { @@ -74,6 +83,16 @@ const DIALOG_STYLES = twv.tv({ content: 'isolate', }, }, + rounded: { + none: { base: '' }, + small: { base: 'rounded-sm' }, + medium: { base: 'rounded-md' }, + large: { base: 'rounded-lg' }, + xlarge: { base: 'rounded-xl' }, + xxlarge: { base: 'rounded-2xl', content: 'scroll-offset-edge-2xl' }, + xxxlarge: { base: 'rounded-3xl', content: 'scroll-offset-edge-4xl' }, + xxxxlarge: { base: 'rounded-4xl', content: 'scroll-offset-edge-6xl' }, + }, /** * The size of the dialog. * Only applies to the `modal` type. @@ -100,10 +119,10 @@ const DIALOG_STYLES = twv.tv({ }, slots: { header: - 'sticky top-0 grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 transition-[border-color] duration-150', + 'sticky z-1 top-0 grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 transition-[border-color] duration-150', closeButton: 'col-start-1 col-end-1 mr-auto', heading: 'col-start-2 col-end-2 my-0 text-center', - content: 'relative flex-auto', + content: 'relative flex-auto overflow-y-auto max-h-[inherit]', }, compoundVariants: [ { type: 'modal', size: 'small', class: 'max-w-sm' }, @@ -120,6 +139,7 @@ const DIALOG_STYLES = twv.tv({ hideCloseButton: false, size: 'medium', padding: 'medium', + rounded: 'xxlarge', }, }) @@ -144,8 +164,9 @@ export function Dialog(props: DialogProps) { testId = 'dialog', size, rounded, - padding, + padding = type === 'modal' ? 'medium' : 'xlarge', fitContent, + variants = DIALOG_STYLES, ...ariaDialogProps } = props @@ -155,11 +176,13 @@ export function Dialog(props: DialogProps) { * Handles the scroll event on the dialog content. */ const handleScroll = (scrollTop: number) => { - if (scrollTop > 0) { - setIsScrolledToTop(false) - } else { - setIsScrolledToTop(true) - } + React.startTransition(() => { + if (scrollTop > 0) { + setIsScrolledToTop(false) + } else { + setIsScrolledToTop(true) + } + }) } const dialogId = aria.useId() @@ -169,7 +192,7 @@ export function Dialog(props: DialogProps) { const overlayState = React.useRef(null) const root = portal.useStrictPortalContext() - const styles = DIALOG_STYLES({ + const styles = variants({ className, type, rounded, @@ -200,11 +223,7 @@ export function Dialog(props: DialogProps) { return ( - OVERLAY_STYLES({ - isEntering, - isExiting, - blockInteractions: !isDismissable, - }) + OVERLAY_STYLES({ isEntering, isExiting, blockInteractions: !isDismissable }) } isDismissable={isDismissable} isKeyboardDismissDisabled={isKeyboardDismissDisabled} @@ -218,7 +237,7 @@ export function Dialog(props: DialogProps) { return ( MODAL_STYLES({ type, isEntering, isExiting })} isDismissable={isDismissable} isKeyboardDismissDisabled={isKeyboardDismissDisabled} UNSTABLE_portalContainer={root} @@ -252,25 +271,23 @@ export function Dialog(props: DialogProps) { {(opts) => { return ( - {((!hideCloseButton && closeButton !== 'floating') || title != null) && ( - - + + - {title != null && ( - - {title} - - )} - - )} + {title != null && ( + + {title} + + )} +
{ diff --git a/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx b/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx index f69c25dfae82..2b099e9b80e9 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx +++ b/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx @@ -5,7 +5,8 @@ import * as modalProvider from '#/providers/ModalProvider' import * as aria from '#/components/aria' -import { AnimatePresence, motion } from 'framer-motion' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useOverlayTriggerState } from 'react-stately' const PLACEHOLDER =
@@ -13,7 +14,9 @@ const PLACEHOLDER =
* Props passed to the render function of a {@link DialogTrigger}. */ export interface DialogTriggerRenderProps { - readonly isOpened: boolean + readonly isOpen: boolean + readonly close: () => void + readonly open: () => void } /** * Props for a {@link DialogTrigger}. @@ -26,55 +29,54 @@ export interface DialogTriggerProps extends Omit React.ReactElement), ] + readonly onOpen?: () => void + readonly onClose?: () => void } /** A DialogTrigger opens a dialog when a trigger element is pressed. */ export function DialogTrigger(props: DialogTriggerProps) { - const { children, onOpenChange, ...triggerProps } = props + const { children, onOpenChange, onOpen = () => {}, onClose = () => {} } = props + + const state = useOverlayTriggerState(props) - const [isOpened, setIsOpened] = React.useState(false) const { setModal, unsetModal } = modalProvider.useSetModal() - const onOpenChangeInternal = React.useCallback( - (opened: boolean) => { - if (opened) { - // We're using a placeholder here just to let the rest of the code know that the modal - // is open. - setModal(PLACEHOLDER) - } else { - unsetModal() - } - - setIsOpened(opened) - onOpenChange?.(opened) - }, - [setModal, unsetModal, onOpenChange], - ) + const onOpenStableCallback = useEventCallback(onOpen) + const onCloseStableCallback = useEventCallback(onClose) - const renderProps = { - isOpened, - } satisfies DialogTriggerRenderProps + const onOpenChangeInternal = useEventCallback((opened: boolean) => { + if (opened) { + // We're using a placeholder here just to let the rest of the code know that the modal + // is open. + setModal(PLACEHOLDER) + } else { + unsetModal() + onCloseStableCallback() + } + + state.setOpen(opened) + onOpenChange?.(opened) + }) + + React.useEffect(() => { + if (state.isOpen) { + onOpenStableCallback() + } + }, [state.isOpen, onOpenStableCallback]) const [trigger, dialog] = children + const renderProps = { + isOpen: state.isOpen, + close: state.close.bind(state), + open: state.open.bind(state), + } satisfies DialogTriggerRenderProps + return ( - + {trigger} - {/* We're using AnimatePresence here to animate the dialog in and out. */} - - {isOpened && ( - - {typeof dialog === 'function' ? dialog(renderProps) : dialog} - - )} - + {typeof dialog === 'function' ? dialog(renderProps) : dialog} ) } diff --git a/app/dashboard/src/components/AriaComponents/Dialog/variants.ts b/app/dashboard/src/components/AriaComponents/Dialog/variants.ts index b803e20e6787..c8f7ea6bb3be 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/variants.ts +++ b/app/dashboard/src/components/AriaComponents/Dialog/variants.ts @@ -14,19 +14,4 @@ export const DIALOG_BACKGROUND = twv.tv({ export const DIALOG_STYLES = twv.tv({ extend: DIALOG_BACKGROUND, base: 'flex flex-col text-left align-middle shadow-xl', - variants: { - rounded: { - none: '', - small: 'rounded-sm', - medium: 'rounded-md', - large: 'rounded-lg', - xlarge: 'rounded-xl', - xxlarge: 'rounded-2xl', - xxxlarge: 'rounded-3xl', - xxxxlarge: 'rounded-4xl', - }, - }, - defaultVariants: { - rounded: 'xxlarge', - }, }) diff --git a/app/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/dashboard/src/components/AriaComponents/Form/Form.tsx index ef2ebfb91e4c..babc31264157 100644 --- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -1,18 +1,11 @@ /** @file Form component. */ import * as React from 'react' -import * as sentry from '@sentry/react' -import * as reactQuery from '@tanstack/react-query' -import * as reactHookForm from 'react-hook-form' - -import * as offlineHooks from '#/hooks/offlineHooks' - import * as textProvider from '#/providers/TextProvider' import * as aria from '#/components/aria' -import * as errorUtils from '#/utilities/error' - +import { useEventCallback } from '#/hooks/eventCallbackHooks' import { forwardRef } from '#/utilities/react' import * as dialog from '../Dialog' import * as components from './components' @@ -24,27 +17,25 @@ import type * as types from './types' * 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 = forwardRef(function Form( - props: types.FormProps, - ref: React.Ref, -) { +export const Form = forwardRef(function Form< + Schema extends components.TSchema, + SubmitResult = void, +>(props: types.FormProps, ref: React.Ref) { /** Input values for this form. */ type FieldValues = components.FieldValues const formId = React.useId() const { children, - onSubmit, formRef, form, - formOptions = {}, + formOptions, className, style, onSubmitted = () => {}, onSubmitSuccess = () => {}, onSubmitFailed = () => {}, id = formId, - testId, schema, defaultValues, gap, @@ -55,78 +46,47 @@ export const Form = forwardRef(function Form( const { getText } = textProvider.useText() - if (defaultValues) { - formOptions.defaultValues = defaultValues - } - - const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions }) - - React.useImperativeHandle(formRef, () => innerForm, [innerForm]) - const dialogContext = dialog.useDialogContext() - const formMutation = reactQuery.useMutation({ - // We use template literals to make the mutation key more readable in the devtools - // This mutation exists only for debug purposes - React Query dev tools record the mutation, - // 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: FieldValues) => { - try { - await onSubmit?.(fieldValues, innerForm) - - if (method === 'dialog') { - dialogContext?.close() - } - } catch (error) { - const isJSError = errorUtils.isJSError(error) - - if (isJSError) { - sentry.captureException(error, { - contexts: { form: { values: fieldValues } }, - }) - } - - const message = - isJSError ? - getText('arbitraryFormErrorMessage') - : errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage')) - - innerForm.setError('root.submit', { message }) - - // We need to throw the error to make the mutation fail - // eslint-disable-next-line no-restricted-syntax - throw error - } - }, - onError: onSubmitFailed, - onSuccess: onSubmitSuccess, - onSettled: onSubmitted, - }) + const onSubmit = useEventCallback( + async (fieldValues: types.FieldValues, formInstance: types.UseFormReturn) => { + // This is SAFE because we're passing the result transparently, and it's typed outside + // eslint-disable-next-line no-restricted-syntax + const result = (await props.onSubmit?.(fieldValues, formInstance)) as SubmitResult - // There is no way to avoid type casting here - // eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument - const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any) + if (method === 'dialog') { + dialogContext?.close() + } - const { isOffline } = offlineHooks.useOffline() + return result + }, + ) - offlineHooks.useOfflineChange( - (offline) => { - if (offline) { - innerForm.setError('root.offline', { message: getText('unavailableOffline') }) - } else { - innerForm.clearErrors('root.offline') - } + const testId = props['testId'] ?? props['data-testid'] ?? 'form' + + const innerForm = components.useForm( + form ?? { + ...formOptions, + ...(defaultValues ? { defaultValues } : {}), + schema, + canSubmitOffline, + onSubmit, + onSubmitFailed, + onSubmitSuccess, + onSubmitted, + shouldFocusError: true, + debugName: `Form ${testId} id: ${id}`, }, - { isDisabled: canSubmitOffline }, ) + React.useImperativeHandle(formRef, () => innerForm, [innerForm]) + const base = styles.FORM_STYLES({ className: typeof className === 'function' ? className(innerForm) : className, gap, }) - const { formState, setError } = innerForm + const { formState } = innerForm // eslint-disable-next-line no-restricted-syntax const errors = Object.fromEntries( @@ -136,39 +96,30 @@ export const Form = forwardRef(function Form( }), ) as Record + const values = components.useWatch({ control: innerForm.control }) + return ( - <> -
{ - event.preventDefault() - event.stopPropagation() - - if (isOffline && !canSubmitOffline) { - setError('root.offline', { message: getText('unavailableOffline') }) - } else { - void formOnSubmit(event) - } - }} - className={base} - style={typeof style === 'function' ? style(innerForm) : style} - noValidate - data-testid={testId} - {...formProps} - > - - - {typeof children === 'function' ? - children({ ...innerForm, form: innerForm }) - : children} - - -
- +
+ + + {typeof children === 'function' ? + children({ ...innerForm, form: innerForm, values }) + : children} + + +
) -}) as unknown as (( - props: React.RefAttributes & types.FormProps, +}) as unknown as (( + props: React.RefAttributes & types.FormProps, ) => React.JSX.Element) & { /* eslint-disable @typescript-eslint/naming-convention */ schema: typeof components.schema @@ -183,7 +134,9 @@ export const Form = forwardRef(function Form( FIELD_STYLES: typeof components.FIELD_STYLES useFormContext: typeof components.useFormContext useOptionalFormContext: typeof components.useOptionalFormContext - useWatch: typeof reactHookForm.useWatch + useWatch: typeof components.useWatch + useFieldRegister: typeof components.useFieldRegister + useFieldState: typeof components.useFieldState /* eslint-enable @typescript-eslint/naming-convention */ } @@ -198,5 +151,7 @@ Form.useFormContext = components.useFormContext Form.useOptionalFormContext = components.useOptionalFormContext Form.Field = components.Field Form.Controller = components.Controller -Form.useWatch = reactHookForm.useWatch +Form.useWatch = components.useWatch Form.FIELD_STYLES = components.FIELD_STYLES +Form.useFieldRegister = components.useFieldRegister +Form.useFieldState = components.useFieldState diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx index eeeb958e4403..f2a52394d47f 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx @@ -11,8 +11,8 @@ import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import type { Path } from 'react-hook-form' import * as text from '../../Text' +import { Form } from '../Form' import type * as types from './types' -import * as formContext from './useFormContext' /** * Props for Field component @@ -44,6 +44,7 @@ export interface FieldChildrenRenderProps { readonly isDirty: boolean readonly isTouched: boolean readonly isValidating: boolean + readonly hasError: boolean readonly error?: string | undefined } @@ -73,36 +74,29 @@ export const Field = forwardRef(function Field( ref: React.ForwardedRef, ) { const { - // eslint-disable-next-line no-restricted-syntax - form = formContext.useFormContext() as unknown as types.FormInstance, - isInvalid, children, className, label, description, fullWidth, error, - name, isHidden, + isInvalid = false, isRequired = false, variants = FIELD_STYLES, } = props - const fieldState = form.getFieldState(name) - const labelId = React.useId() const descriptionId = React.useId() const errorId = React.useId() - const invalid = isInvalid === true || fieldState.invalid + const fieldState = Form.useFieldState(props) - const classes = variants({ - fullWidth, - isInvalid: invalid, - isHidden, - }) + const invalid = isInvalid || fieldState.hasError + + const classes = variants({ fullWidth, isInvalid: invalid, isHidden }) - const hasError = (error ?? fieldState.error?.message) != null + const hasError = (error ?? fieldState.error) != null return (
( isDirty: fieldState.isDirty, isTouched: fieldState.isTouched, isValidating: fieldState.isValidating, - error: fieldState.error?.message, + hasError: fieldState.hasError, + error: fieldState.error, }) : children}
@@ -152,7 +147,7 @@ export const Field = forwardRef(function Field( {hasError && ( - {error ?? fieldState.error?.message} + {error ?? fieldState.error} )} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx b/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx index 77f845153a64..9bc171964037 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx @@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider' import * as reactAriaComponents from '#/components/AriaComponents' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Props for the FormError component. @@ -26,14 +26,9 @@ export interface FormErrorProps extends Omit { + readonly form: types.UseFormReturn +} + +// at this moment, we don't know the type of the form context +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const FormContext = createContext | null>(null) + +/** + * Provides the form instance to the component tree. + */ +export function FormProvider( + props: FormContextType & PropsWithChildren, +) { + const { children, form } = props + + return ( + // eslint-disable-next-line no-restricted-syntax,@typescript-eslint/no-explicit-any + }}> + {children} + + ) +} + +/** + * Returns the form instance from the context. + */ +export function useFormContext( + form?: FormInstanceValidated | undefined, +): FormInstance { + if (form != null && 'control' in form) { + return form + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks + const ctx = useContext(FormContext) + + invariant(ctx, 'FormContext not found') + + // This is safe, as it's we pass the value transparently and it's typed outside + // eslint-disable-next-line no-restricted-syntax + return ctx.form as unknown as types.UseFormReturn + } +} + +/** + * Returns the form instance from the context, or null if the context is not available. + */ +export function useOptionalFormContext< + Form extends FormInstanceValidated | undefined, + Schema extends types.TSchema, +>(form?: Form): Form extends undefined ? FormInstance | null : FormInstance { + try { + return useFormContext(form) + } catch { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return null! + } +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx index 05d10e6e80e8..67f91a82bede 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx @@ -7,8 +7,9 @@ import * as React from 'react' import * as ariaComponents from '#/components/AriaComponents' +import { useText } from '#/providers/TextProvider' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Props for the Reset component. @@ -29,14 +30,16 @@ export interface ResetProps extends Omit * Reset button for forms. */ export function Reset(props: ResetProps): React.JSX.Element { + const { getText } = useText() const { - form = formContext.useFormContext(), - variant = 'cancel', + variant = 'ghost-fading', size = 'medium', testId = 'form-reset-button', + children = getText('reset'), ...buttonProps } = props - const { formState } = form + + const { formState } = formContext.useFormContext(props.form) return ( ) } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx index a6505d85d9c5..b87b2f1ffe1a 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx @@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Additional props for the Submit component. @@ -48,22 +48,23 @@ export type SubmitProps = Omit & * Manages the form state and displays a loading spinner when the form is submitting. */ export function Submit(props: SubmitProps): React.JSX.Element { + const { getText } = textProvider.useText() + const { - form = formContext.useFormContext(), - variant = 'submit', size = 'medium', - testId = 'form-submit-button', formnovalidate = false, loading = false, - children, + children = formnovalidate ? getText('cancel') : getText('submit'), + variant = formnovalidate ? 'ghost-fading' : 'submit', + testId = formnovalidate ? 'form-cancel-button' : 'form-submit-button', ...buttonProps } = props - const { getText } = textProvider.useText() const dialogContext = ariaComponents.useDialogContext() + const form = formContext.useFormContext(props.form) const { formState } = form - const isLoading = loading || formState.isSubmitting + const isLoading = formnovalidate ? false : loading || formState.isSubmitting const type = formnovalidate || isLoading ? 'button' : 'submit' return ( @@ -82,7 +83,7 @@ export function Submit(props: SubmitProps): React.JSX.Element { } }} > - {children ?? getText('submit')} + {children} ) } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/index.ts b/app/dashboard/src/components/AriaComponents/Form/components/index.ts index 3bd2ded7df08..2ccfc60f65ab 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/index.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/index.ts @@ -3,14 +3,16 @@ * * Barrel file for form components. */ -export { Controller } from 'react-hook-form' +export { Controller, useWatch } from 'react-hook-form' export * from './Field' export * from './FormError' +export * from './FormProvider' export * from './Reset' export * from './schema' export * from './Submit' export * from './types' export * from './useField' +export * from './useFieldRegister' +export * from './useFieldState' export * from './useForm' -export * from './useFormContext' export * from './useFormSchema' diff --git a/app/dashboard/src/components/AriaComponents/Form/components/types.ts b/app/dashboard/src/components/AriaComponents/Form/components/types.ts index 13ebc22299df..f840595faeb0 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/types.ts @@ -7,6 +7,7 @@ import type * as React from 'react' import type * as reactHookForm from 'react-hook-form' import type * as z from 'zod' +import type { FormEvent } from 'react' import type * as schemaModule from './schema' /** The type of the inputs to the form, used for UI inputs. */ @@ -31,15 +32,61 @@ export type TSchema = | z.ZodEffects | z.ZodEffects> +/** + * OnSubmitCallbacks type. + */ +export interface OnSubmitCallbacks { + readonly onSubmit?: + | (( + values: FieldValues, + form: UseFormReturn, + ) => Promise | SubmitResult) + | undefined + + readonly onSubmitFailed?: + | (( + error: unknown, + values: FieldValues, + form: UseFormReturn, + ) => Promise | void) + | undefined + readonly onSubmitSuccess?: + | (( + data: SubmitResult, + values: FieldValues, + form: UseFormReturn, + ) => Promise | void) + | undefined + readonly onSubmitted?: + | (( + data: SubmitResult | undefined, + error: unknown, + values: FieldValues, + form: UseFormReturn, + ) => Promise | void) + | undefined +} + /** * Props for the useForm hook. */ -export interface UseFormProps +export interface UseFormProps extends Omit< - reactHookForm.UseFormProps>, - 'handleSubmit' | 'resetOptions' | 'resolver' - > { + reactHookForm.UseFormProps>, + 'handleSubmit' | 'resetOptions' | 'resolver' + >, + OnSubmitCallbacks { readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema) + /** + * Whether the form can submit offline. + * @default false + */ + readonly canSubmitOffline?: boolean + + /** + * Debug name for the form. Use it to identify the form in the tanstack query devtools. + */ + readonly debugName?: string } /** @@ -50,7 +97,6 @@ export type UseFormRegister = < >( name: TFieldName, options?: reactHookForm.RegisterOptions, TFieldName>, - // eslint-disable-next-line no-restricted-syntax ) => UseFormRegisterReturn /** @@ -64,9 +110,12 @@ export interface UseFormRegisterReturn< readonly onChange: (value: Value) => Promise // eslint-disable-next-line @typescript-eslint/no-invalid-void-type readonly onBlur: (value: Value) => Promise - readonly isDisabled?: boolean - readonly isRequired?: boolean - readonly isInvalid?: boolean + readonly isDisabled: boolean + readonly isRequired: boolean + readonly isInvalid: boolean + readonly disabled: boolean + readonly required: boolean + readonly invalid: boolean } /** @@ -74,8 +123,14 @@ export interface UseFormRegisterReturn< * @alias reactHookForm.UseFormReturn */ export interface UseFormReturn - extends reactHookForm.UseFormReturn, unknown, TransformedValues> { + extends Omit< + reactHookForm.UseFormReturn, unknown, TransformedValues>, + 'onSubmit' | 'resetOptions' | 'resolver' + > { readonly register: UseFormRegister + readonly submit: (event?: FormEvent | null | undefined) => Promise + readonly schema: Schema + readonly setFormError: (error: string) => void } /** @@ -113,6 +168,16 @@ export interface FormWithValueValidation< | undefined } +/** + * Form instance type that has been validated. + * Cast validatable form type to FormInstance + */ +export type FormInstanceValidated< + Schema extends TSchema, + // We use any here because we want to bypass the type check for Error type as it won't be a case here + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/ban-types +> = FormInstance | (any[] & {}) + /** * Props for the Field component. */ @@ -148,3 +213,36 @@ export interface FieldProps { // eslint-disable-next-line @typescript-eslint/naming-convention 'aria-details'?: string | undefined } +/** + * Base Props for a Form Field. + * @private + */ +export interface FormFieldProps< + BaseValueType, + Schema extends TSchema, + TFieldName extends FieldPath, +> extends FormWithValueValidation { + readonly name: TFieldName + readonly value?: BaseValueType extends FieldValues ? FieldValues[TFieldName] + : never + readonly defaultValue?: FieldValues[TFieldName] | undefined + readonly isDisabled?: boolean | undefined + readonly isRequired?: boolean | undefined + readonly isInvalid?: boolean | undefined +} + +/** + * Field State Props + */ +export type FieldStateProps< + // eslint-disable-next-line no-restricted-syntax + BaseProps extends { value?: unknown }, + Schema extends TSchema, + TFieldName extends FieldPath, +> = FormFieldProps & { + // to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps + [K in keyof Omit< + BaseProps, + keyof FormFieldProps + >]: BaseProps[K] +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts index f3bfb69d17c1..a6c8e7ced12f 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts @@ -5,8 +5,8 @@ */ import * as reactHookForm from 'react-hook-form' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Options for {@link useField} hook. @@ -29,24 +29,16 @@ export function useField< Schema extends types.TSchema, TFieldName extends types.FieldPath, >(options: UseFieldOptions) { - const { form = formContext.useFormContext(), name, defaultValue, isDisabled = false } = options + const { 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 + const formInstance = formContext.useFormContext(options.form) const { field, fieldState, formState } = reactHookForm.useController({ name, disabled: isDisabled, + control: formInstance.control, ...(defaultValue != null ? { defaultValue } : {}), }) - return { - field, - fieldState, - formState, - formInstance, - } as const + return { field, fieldState, formState, formInstance } as const } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts b/app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts new file mode 100644 index 000000000000..13413e2e812d --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts @@ -0,0 +1,99 @@ +/** + * @file + * + * Form field registration hook. + * Use this hook to register a field in the form. + */ +import { useFormContext } from './FormProvider' +import type { + FieldPath, + FieldValues, + FormFieldProps, + FormInstanceValidated, + TSchema, +} from './types' + +/** + * Options for the useFieldRegister hook. + */ +export type UseFieldRegisterOptions< + BaseValueType extends { value?: unknown }, + Schema extends TSchema, + TFieldName extends FieldPath, +> = Omit, 'form'> & { + name: TFieldName + form?: FormInstanceValidated | undefined + defaultValue?: FieldValues[TFieldName] | undefined + min?: number | string | undefined + max?: number | string | undefined + minLength?: number | undefined + maxLength?: number | undefined + setValueAs?: ((value: unknown) => unknown) | undefined +} + +/** + * Registers a field in the form. + */ +export function useFieldRegister< + BaseValueType extends { value?: unknown }, + Schema extends TSchema, + TFieldName extends FieldPath, +>(options: UseFieldRegisterOptions) { + const { name, min, max, minLength, maxLength, isRequired, isDisabled, form, setValueAs } = options + + const formInstance = useFormContext(form) + + const extractedValidationDetails = unsafe__extractValidationDetailsFromSchema( + formInstance.schema, + name, + ) + + const fieldProps = formInstance.register(name, { + disabled: isDisabled ?? false, + required: isRequired ?? extractedValidationDetails?.required ?? false, + ...(setValueAs != null ? { setValueAs } : {}), + ...(extractedValidationDetails?.min != null ? { min: extractedValidationDetails.min } : {}), + ...(extractedValidationDetails?.max != null ? { min: extractedValidationDetails.max } : {}), + ...(min != null ? { min } : {}), + ...(max != null ? { max } : {}), + ...(minLength != null ? { minLength } : {}), + ...(maxLength != null ? { maxLength } : {}), + }) + + return { fieldProps, formInstance } as const +} +/** + * Tried to extract validation details from the schema. + */ +// This name is intentional to highlight that this function is unsafe and should be used with caution. +// eslint-disable-next-line @typescript-eslint/naming-convention +function unsafe__extractValidationDetailsFromSchema< + Schema extends TSchema, + TFieldName extends FieldPath, +>(schema: Schema, name: TFieldName) { + try { + if ('shape' in schema) { + if (name in schema.shape) { + // THIS is 100% unsafe, so we need to be very careful here + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const fieldShape = schema.shape[name] + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + const min: number | null = fieldShape.minLength + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + const max: number | null = fieldShape.maxLength + const required = min != null && min > 0 + + // eslint-disable-next-line no-restricted-syntax + return { required, min, max } as const + } + + // eslint-disable-next-line no-restricted-syntax + return null + } + // eslint-disable-next-line no-restricted-syntax + return null + } catch { + // eslint-disable-next-line no-restricted-syntax + return null + } +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts b/app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts new file mode 100644 index 000000000000..e1e2e74b2227 --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts @@ -0,0 +1,47 @@ +/** + * @file + * + * Hook to get the state of a field. + */ +import { useFormState } from 'react-hook-form' +import { useFormContext } from './FormProvider' +import type { FieldPath, FormInstanceValidated, TSchema } from './types' + +/** + * Options for the `useFieldState` hook. + */ +export interface UseFieldStateOptions< + Schema extends TSchema, + TFieldName extends FieldPath, +> { + readonly name: TFieldName + readonly form?: FormInstanceValidated | undefined +} + +/** + * Hook to get the state of a field. + */ +export function useFieldState>( + options: UseFieldStateOptions, +) { + const { name } = options + + const form = useFormContext(options.form) + + const { errors, dirtyFields, isValidating, touchedFields } = useFormState({ + control: form.control, + name, + }) + + const isDirty = name in dirtyFields + const isTouched = name in touchedFields + const error = errors[name]?.message?.toString() + + return { + error, + isDirty, + isTouched, + isValidating, + hasError: error != null, + } as const +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts index 5197a3b9f2cc..f1f55e94e8af 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts @@ -3,13 +3,18 @@ * * A hook that returns a form instance. */ +import * as sentry from '@sentry/react' import * as React from 'react' import * as zodResolver from '@hookform/resolvers/zod' import * as reactHookForm from 'react-hook-form' import invariant from 'tiny-invariant' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useOffline, useOfflineChange } from '#/hooks/offlineHooks' import { useText } from '#/providers/TextProvider' +import * as errorUtils from '#/utilities/error' +import { useMutation } from '@tanstack/react-query' import * as schemaModule from './schema' import type * as types from './types' @@ -39,64 +44,73 @@ function mapValueOnEvent(value: unknown) { * But be careful, You should not switch between the two types of arguments. * Otherwise you'll be fired */ -export function useForm( - optionsOrFormInstance: types.UseFormProps | types.UseFormReturn, +export function useForm( + optionsOrFormInstance: types.UseFormProps | types.UseFormReturn, ): types.UseFormReturn { const { getText } = useText() - const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance)) + const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance)) const argsType = getArgsType(optionsOrFormInstance) invariant( - initialTypePassed.current === argsType, + initialTypePassed === argsType, ` Found a switch between form options and form instance. This is not allowed. Please use either form options or form instance and stick to it.\n\n - Initially passed: ${initialTypePassed.current}, Currently passed: ${argsType}. + Initially passed: ${initialTypePassed}, Currently passed: ${argsType}. `, ) if ('formState' in optionsOrFormInstance) { return optionsOrFormInstance } else { - const { schema, ...options } = optionsOrFormInstance + const { + schema, + onSubmit, + canSubmitOffline = false, + onSubmitFailed, + onSubmitted, + onSubmitSuccess, + debugName, + ...options + } = optionsOrFormInstance const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema - const formInstance = reactHookForm.useForm< - types.FieldValues, - unknown, - types.TransformedValues - >({ + const formInstance = reactHookForm.useForm({ ...options, - resolver: zodResolver.zodResolver(computedSchema, { - async: true, - errorMap: (issue) => { - switch (issue.code) { - case 'too_small': - if (issue.minimum === 0) { + resolver: zodResolver.zodResolver( + computedSchema, + { + async: true, + errorMap: (issue) => { + switch (issue.code) { + case 'too_small': + if (issue.minimum === 0) { + return { + message: getText('arbitraryFieldRequired'), + } + } else { + return { + message: getText('arbitraryFieldTooSmall', issue.minimum.toString()), + } + } + case 'too_big': return { - message: getText('arbitraryFieldRequired'), + message: getText('arbitraryFieldTooLarge', issue.maximum.toString()), } - } else { + case 'invalid_type': return { - message: getText('arbitraryFieldTooSmall', issue.minimum.toString()), + message: getText('arbitraryFieldInvalid'), } - } - case 'too_big': - return { - message: getText('arbitraryFieldTooLarge', issue.maximum.toString()), - } - case 'invalid_type': - return { - message: getText('arbitraryFieldInvalid'), - } - default: - return { - message: getText('arbitraryFieldInvalid'), - } - } + default: + return { + message: getText('arbitraryFieldInvalid'), + } + } + }, }, - }), + { mode: 'async' }, + ), }) const register: types.UseFormRegister = (name, opts) => { @@ -110,9 +124,12 @@ export function useForm( const result: types.UseFormRegisterReturn = { ...registered, - ...(registered.disabled != null ? { isDisabled: registered.disabled } : {}), - ...(registered.required != null ? { isRequired: registered.required } : {}), + disabled: registered.disabled ?? false, + isDisabled: registered.disabled ?? false, + invalid: !!formInstance.formState.errors[name], isInvalid: !!formInstance.formState.errors[name], + required: registered.required ?? false, + isRequired: registered.required ?? false, onChange, onBlur, } @@ -120,19 +137,106 @@ export function useForm( return result } - return { + // eslint-disable-next-line react-hooks/rules-of-hooks + const formMutation = useMutation({ + // We use template literals to make the mutation key more readable in the devtools + // This mutation exists only for debug purposes - React Query dev tools record the mutation, + // the result, and the variables(form fields). + // In general, prefer using object literals for the mutation key. + mutationKey: ['Form submission', `debugName: ${debugName}`], + mutationFn: async (fieldValues: types.FieldValues) => { + try { + // This is safe, because we transparently passing the result of the onSubmit function, + // and the type of the result is the same as the type of the SubmitResult. + // eslint-disable-next-line no-restricted-syntax + return (await onSubmit?.(fieldValues, form)) as SubmitResult + } catch (error) { + const isJSError = errorUtils.isJSError(error) + + if (isJSError) { + sentry.captureException(error, { + contexts: { form: { values: fieldValues } }, + }) + } + + const message = + isJSError ? + getText('arbitraryFormErrorMessage') + : errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage')) + + setFormError(message) + // We need to throw the error to make the mutation fail + // eslint-disable-next-line no-restricted-syntax + throw error + } + }, + onError: (error, values) => onSubmitFailed?.(error, values, form), + onSuccess: (data, values) => onSubmitSuccess?.(data, values, form), + onSettled: (data, error, values) => onSubmitted?.(data, error, values, form), + }) + + // There is no way to avoid type casting here + // eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument + const formOnSubmit = formInstance.handleSubmit(formMutation.mutateAsync as any) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const { isOffline } = useOffline() + + // eslint-disable-next-line react-hooks/rules-of-hooks + useOfflineChange( + (offline) => { + if (offline) { + formInstance.setError('root.offline', { message: getText('unavailableOffline') }) + } else { + formInstance.clearErrors('root.offline') + } + }, + { isDisabled: canSubmitOffline }, + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const submit = useEventCallback( + (event: React.FormEvent | null | undefined) => { + event?.preventDefault() + event?.stopPropagation() + + if (isOffline && !canSubmitOffline) { + formInstance.setError('root.offline', { message: getText('unavailableOffline') }) + return Promise.resolve() + } else { + if (event) { + return formOnSubmit(event) + } else { + return formOnSubmit() + } + } + }, + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const setFormError = useEventCallback((error: string) => { + formInstance.setError('root.submit', { message: error }) + }) + + const form: types.UseFormReturn = { ...formInstance, + submit, control: { ...formInstance.control, register }, register, - } satisfies types.UseFormReturn + schema: computedSchema, + setFormError, + handleSubmit: formInstance.handleSubmit, + } + + return form } } /** * Get the type of arguments passed to the useForm hook */ -function getArgsType( - args: types.UseFormProps | types.UseFormReturn, +function getArgsType( + args: types.UseFormProps, ) { - return 'formState' in args ? 'formInstance' : 'formOptions' + return 'formState' in args ? ('formInstance' as const) : ('formOptions' as const) } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx b/app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx deleted file mode 100644 index 490f7ebe738d..000000000000 --- a/app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @file - * - * This file is a wrapper around the react-hook-form useFormContext hook. - */ -import * as reactHookForm from 'react-hook-form' - -/** - * Returns the form instance from the context. - */ -export function useFormContext() { - return reactHookForm.useFormContext() -} - -/** - * Returns the form instance from the context, or null if the context is not available. - */ -export function useOptionalFormContext() { - try { - return useFormContext() - } catch { - return null - } -} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx b/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx index ef5f2b47b63a..ede8698dfe12 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx @@ -3,8 +3,8 @@ import * as React from 'react' import * as callbackEventHooks from '#/hooks/eventCallbackHooks' -import * as schemaComponent from '#/components/AriaComponents/Form/components/schema' -import type * as types from '#/components/AriaComponents/Form/components/types' +import * as schemaComponent from './schema' +import type * as types from './types' // ===================== // === useFormSchema === diff --git a/app/dashboard/src/components/AriaComponents/Form/types.ts b/app/dashboard/src/components/AriaComponents/Form/types.ts index 28fe075b093e..30b767e3a02c 100644 --- a/app/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/types.ts @@ -7,6 +7,8 @@ import type * as React from 'react' import type * as reactHookForm from 'react-hook-form' +import type { DeepPartialSkipArrayKey } from 'react-hook-form' +import type { TestIdProps } from '../types' import type * as components from './components' import type * as styles from './styles' @@ -15,8 +17,11 @@ export type * from './components' /** * Props for the Form component */ -export type FormProps = BaseFormProps & - (FormPropsWithOptions | FormPropsWithParentForm) +export type FormProps< + Schema extends components.TSchema, + SubmitResult = void, +> = BaseFormProps & + (FormPropsWithOptions | FormPropsWithParentForm) /** * Base props for the Form component. @@ -26,20 +31,8 @@ interface BaseFormProps React.HTMLProps, 'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style' >, - styles.FormStyleProps { - /** - * The default values for the form fields - * - * __Note:__ Even though this is optional, - * it is recommended to provide default values and specify all fields defined in the schema. - * 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['defaultValues'] - readonly onSubmit?: ( - values: components.TransformedValues, - form: components.UseFormReturn, - ) => unknown + Omit, + TestIdProps { readonly style?: | React.CSSProperties | ((props: components.UseFormReturn) => React.CSSProperties) @@ -48,17 +41,13 @@ interface BaseFormProps | (( props: components.UseFormReturn & { readonly form: components.UseFormReturn + readonly values: DeepPartialSkipArrayKey> }, ) => React.ReactNode) readonly formRef?: React.MutableRefObject> readonly className?: string | ((props: components.UseFormReturn) => string) - readonly onSubmitFailed?: (error: unknown) => Promise | void - readonly onSubmitSuccess?: () => Promise | void - readonly onSubmitted?: () => Promise | void - - readonly testId?: string /** * When set to `dialog`, form submission will close the parent dialog on successful submission. */ @@ -76,16 +65,33 @@ interface FormPropsWithParentForm { readonly form: components.UseFormReturn readonly schema?: never readonly formOptions?: never + readonly defaultValues?: never + readonly onSubmit?: never + readonly onSubmitSuccess?: never + readonly onSubmitFailed?: never + readonly onSubmitted?: never } /** * 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 { +interface FormPropsWithOptions + extends components.OnSubmitCallbacks { readonly schema: Schema | ((schema: typeof components.schema) => Schema) + readonly formOptions?: Omit< + components.UseFormProps, + 'defaultValues' | 'onSubmit' | 'onSubmitFailed' | 'onSubmitSuccess' | 'onSubmitted' | 'schema' + > + /** + * The default values for the form fields + * + * __Note:__ Even though this is optional, + * it is recommended to provide default values and specify all fields defined in the schema. + * Otherwise Typescript fails to infer the correct type for the form values. + */ + readonly defaultValues?: components.UseFormProps['defaultValues'] readonly form?: never - readonly formOptions?: Omit, 'resolver' | 'schema'> } /** @@ -134,38 +140,3 @@ export type FormStateRenderProps = Pick< /** The form instance. */ readonly form: components.FormInstance } - -/** - * Base Props for a Form Field. - * @private - */ -interface FormFieldProps< - BaseValueType, - Schema extends components.TSchema, - TFieldName extends components.FieldPath, -> extends components.FormWithValueValidation { - readonly name: TFieldName - readonly value?: BaseValueType extends components.FieldValues[TFieldName] ? - components.FieldValues[TFieldName] - : never - readonly defaultValue?: components.FieldValues[TFieldName] | undefined - readonly isDisabled?: boolean - readonly isRequired?: boolean - readonly isInvalid?: boolean -} - -/** - * Field State Props - */ -export type FieldStateProps< - // eslint-disable-next-line no-restricted-syntax - BaseProps extends { value?: unknown }, - Schema extends components.TSchema, - TFieldName extends components.FieldPath, -> = FormFieldProps & { - // to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps - [K in keyof Omit< - BaseProps, - keyof FormFieldProps - >]: BaseProps[K] -} diff --git a/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx b/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx index dccfc52462d5..5acaddcd7705 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx @@ -37,8 +37,8 @@ import { } 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' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' const DATE_PICKER_STYLES = tv({ base: '', @@ -109,6 +109,7 @@ export const DatePicker = forwardRef(function DatePicker< isRequired, className, size, + variants = DATE_PICKER_STYLES, } = props const { fieldState, formInstance } = Form.useField({ @@ -118,7 +119,7 @@ export const DatePicker = forwardRef(function DatePicker< defaultValue, }) - const styles = DATE_PICKER_STYLES({ size }) + const styles = variants({ size }) return ( - { diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx index 683a2b530238..c3e40a96826e 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx @@ -16,6 +16,7 @@ import { useSyncRef } from '#/hooks/syncRefHooks' import { mergeRefs } from '#/utilities/mergeRefs' import { forwardRef } from '#/utilities/react' import { tv } from '#/utilities/tailwindVariants' +import { DIALOG_BACKGROUND } from '../../Dialog' const DROPDOWN_STYLES = tv({ base: 'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy', @@ -47,10 +48,12 @@ const DROPDOWN_STYLES = tv({ 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', + 'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 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', + optionsContainer: DIALOG_BACKGROUND({ + className: + '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', diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx index bd747805a004..f88667e5ab7f 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx @@ -27,9 +27,11 @@ import { type TSchema, } from '#/components/AriaComponents' import SvgMask from '#/components/SvgMask' +import { useAutoFocus } from '#/hooks/autoFocusHooks' 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' /** @@ -62,24 +64,19 @@ export const Input = forwardRef(function Input< >(props: InputProps, ref: ForwardedRef) { const { name, - isDisabled = false, - form, - defaultValue, description, inputRef, addonStart, addonEnd, - label, size, rounded, - isRequired = false, - min, - max, icon, type = 'text', variant, variants = INPUT_STYLES, fieldVariants, + form, + autoFocus = false, ...inputProps } = props @@ -87,32 +84,14 @@ export const Input = forwardRef(function Input< const privateInputRef = useRef(null) - const { fieldState, formInstance } = Form.useField({ - name, - isDisabled, + const { fieldProps, formInstance } = Form.useFieldRegister< + Omit, + Schema, + TFieldName + >({ + ...props, form, - defaultValue, - }) - - const classes = variants({ - variant, - size, - rounded, - invalid: fieldState.invalid, - readOnly: inputProps.readOnly, - disabled: isDisabled || formInstance.formState.isSubmitting, - }) - - const { ref: fieldRef, ...field } = formInstance.register(name, { - disabled: isDisabled, - required: isRequired, - ...(inputProps.onBlur && { onBlur: inputProps.onBlur }), - ...(inputProps.onChange && { onChange: inputProps.onChange }), - ...(inputProps.minLength != null ? { minLength: inputProps.minLength } : {}), - ...(inputProps.maxLength != null ? { maxLength: inputProps.maxLength } : {}), - ...(min != null ? { min } : {}), - ...(max != null ? { max } : {}), - setValueAs: (value) => { + setValueAs: (value: unknown) => { if (typeof value === 'string') { if (type === 'number') { return Number(value) @@ -128,24 +107,28 @@ export const Input = forwardRef(function Input< }, }) + const classes = variants({ + variant, + size, + rounded, + invalid: fieldProps.isInvalid, + readOnly: inputProps.readOnly, + disabled: fieldProps.disabled || formInstance.formState.isSubmitting, + }) + + useAutoFocus({ ref: privateInputRef, disabled: !autoFocus }) + return ( >()(inputProps, omit(fieldProps), { + isHidden: props.hidden, + fullWidth: true, + variants: fieldVariants, + form: formInstance, + })} ref={ref} - style={props.style} - className={props.className} - variants={fieldVariants} + name={props.name} + data-testid={testId} >
()( - { className: classes.textArea(), type, name, min, max }, inputProps, - field, + { className: classes.textArea(), type, name }, + omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'), )} + ref={mergeRefs(inputRef, privateInputRef, fieldProps.ref)} />
diff --git a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx index bf46f380d76c..82711703cb5a 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx @@ -22,7 +22,6 @@ 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. */ @@ -141,7 +140,7 @@ export const MultiSelector = forwardRef(function MultiSelector< className={classes.base()} onClick={() => privateInputRef.current?.focus({ preventScroll: true })} > - { diff --git a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx index 76aa7fe4e5a9..b84fac5db031 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx @@ -1,9 +1,9 @@ /** @file An option in a selector. */ import { ListBoxItem, type ListBoxItemProps } from '#/components/aria' import { forwardRef } from '#/utilities/react' +import type { VariantProps } from '#/utilities/tailwindVariants' 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}. */ diff --git a/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx new file mode 100644 index 000000000000..7024e6fc088a --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx @@ -0,0 +1,218 @@ +/** + * @file + */ +import { mergeProps } from '#/components/aria' +import { mergeRefs } from '#/utilities/mergeRefs' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' +import { omit } from 'enso-common/src/utilities/data/object' +import type { OTPInputProps } from 'input-otp' +import { OTPInput as BaseOTPInput, type SlotProps as OTPInputSlotProps } from 'input-otp' +import type { ForwardedRef, Ref } from 'react' +import { forwardRef, useRef } from 'react' +import type { + FieldComponentProps, + FieldPath, + FieldProps, + FieldStateProps, + FieldVariantProps, + TSchema, +} from '../../Form' +import { Form } from '../../Form' +import { Separator } from '../../Separator' +import { TEXT_STYLE } from '../../Text' +import type { TestIdProps } from '../../types' + +/** + * Props for an {@link OTPInput}. + */ +export interface OtpInputProps> + extends FieldStateProps, Schema, TFieldName>, + FieldProps, + FieldVariantProps, + Omit, 'disabled' | 'invalid'>, + TestIdProps { + readonly inputRef?: Ref + readonly maxLength: number + readonly className?: string + /** + * Whether to submit the form when the OTP is filled. + * @default true + */ + readonly submitOnComplete?: boolean + /** + * Callback when the OTP is filled. + */ + readonly onComplete?: () => void +} + +const STYLES = tv({ + base: 'group flex overflow-hidden p-1 w-[calc(100%+8px)] -m-1 flex-1', + slots: { + slotsContainer: 'flex items-center justify-center flex-1 w-full gap-1', + }, +}) + +const SLOT_STYLES = tv({ + base: [ + 'flex-1 h-10 min-w-8 flex items-center justify-center', + 'border border-primary rounded-xl', + 'outline outline-1 outline-transparent -outline-offset-2', + 'transition-[outline-offset] duration-200', + ], + variants: { + isActive: { true: 'relative outline-offset-0 outline-2 outline-primary' }, + isInvalid: { true: { base: 'border-danger', char: 'text-danger' } }, + }, + slots: { + char: TEXT_STYLE({ + variant: 'body', + weight: 'bold', + color: 'current', + }), + fakeCaret: + 'absolute pointer-events-none inset-0 flex items-center justify-center animate-caret-blink before:w-px before:h-5 before:bg-primary', + }, + compoundVariants: [ + { + isActive: true, + isInvalid: true, + class: { base: 'outline-danger' }, + }, + ], +}) + +/** + * Accessible one-time password component with copy paste functionality. + */ +export const OTPInput = forwardRef(function OTPInput< + Schema extends TSchema, + TFieldName extends FieldPath, +>(props: OtpInputProps, ref: ForwardedRef) { + const { + maxLength, + variants = STYLES, + className, + name, + fieldVariants, + inputRef, + submitOnComplete = true, + onComplete, + form, + ...inputProps + } = props + + const innerOtpInputRef = useRef(null) + const classes = variants({ className }) + + const { fieldProps, formInstance } = Form.useFieldRegister({ + ...props, + form, + }) + + return ( + >()(inputProps, omit(fieldProps), { + isHidden: props.hidden, + fullWidth: true, + variants: fieldVariants, + form: formInstance, + })} + ref={ref} + name={props.name} + > + ()( + inputProps, + omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'), + { + name, + maxLength, + noScriptCSSFallback: null, + containerClassName: classes.base(), + onClick: () => { + if (innerOtpInputRef.current) { + // Check if the input is not already focused + if (document.activeElement !== innerOtpInputRef.current) { + innerOtpInputRef.current.focus() + } + } + }, + onComplete: () => { + onComplete?.() + + if (submitOnComplete) { + void formInstance.trigger(name).then(() => formInstance.submit()) + } + }, + }, + )} + ref={mergeRefs(fieldProps.ref, inputRef, innerOtpInputRef)} + render={({ slots }) => { + const sections = (() => { + const items = [] + const remainingSlots = slots.length % 3 + + const sectionsCount = Math.floor(slots.length / 3) + (remainingSlots > 0 ? 1 : 0) + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + if (slots.length < 6) { + items.push(slots) + } else { + for (let i = 0; i < sectionsCount; i++) { + const section = slots.slice(i * 3, (i + 1) * 3) + items.push(section) + } + } + + return items + })() + + return ( +
+ {sections.map((section, idx) => ( + <> +
+ {section.map((slot, key) => ( + + ))} +
+ + {idx < sections.length - 1 && ( + + )} + + ))} +
+ ) + }} + /> +
+ ) +}) + +/** + * Props for a single {@link Slot}. + */ +interface SlotProps extends Omit, VariantProps {} + +/** + * Slot is a component that represents a single char in the OTP input. + * @internal + */ +function Slot(props: SlotProps) { + const { char, isActive, hasFakeCaret, variants = SLOT_STYLES, isInvalid } = props + const classes = variants({ isActive, isInvalid }) + + return ( +
+ {char != null &&
{char}
} + {hasFakeCaret &&
} +
+ ) +} diff --git a/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts new file mode 100644 index 000000000000..f623bee2a16d --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts @@ -0,0 +1,6 @@ +/** + * @file + * + * Barrel export file for OTPInput + */ +export * from './OTPInput' diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx index f8411352819c..f5e81b5f0a6d 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx @@ -7,6 +7,7 @@ import EyeCrossedIcon from '#/assets/eye_crossed.svg' import { Button, Input, + type FieldPath, type FieldValues, type InputProps, type TSchema, @@ -17,7 +18,7 @@ import { // ================ /** Props for a {@link Password}. */ -export interface PasswordProps>> +export interface PasswordProps> extends Omit, 'type'> {} /** A component wrapping {@link Input} with the ability to show and hide password. */ diff --git a/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx b/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx index 91c2d9da3eee..7cf417c0d6b6 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx @@ -35,8 +35,10 @@ export interface ResizableContentEditableInputProps< VariantProps, 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' >, + FieldVariantProps, Omit, FieldVariantProps, + Pick, 'rounded' | 'size' | 'variant'>, Omit< VariantProps, 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx index 569ea018c447..9c5e1cb8d3d7 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx @@ -4,12 +4,14 @@ import * as React from 'react' import type * as twv from 'tailwind-variants' import { mergeProps, type RadioGroupProps } from '#/components/aria' +import type { FieldComponentProps } from '#/components/AriaComponents' import { + Form, type FieldPath, type FieldProps, type FieldStateProps, type FieldValues, - Form, + type FieldVariantProps, type TSchema, } from '#/components/AriaComponents' @@ -18,7 +20,6 @@ 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. */ @@ -29,7 +30,8 @@ export interface SelectorProps, FieldProps, - Omit, 'disabled' | 'invalid'> { + Omit, 'disabled' | 'invalid'>, + FieldVariantProps { readonly items: readonly FieldValues[TFieldName][] readonly children?: (item: FieldValues[TFieldName]) => string readonly columns?: number @@ -90,23 +92,20 @@ export const Selector = forwardRef(function Selector< isDisabled = false, columns, form, - defaultValue, inputRef, label, size, rounded, isRequired = false, + isInvalid = false, + fieldVariants, + defaultValue, ...inputProps } = props const privateInputRef = React.useRef(null) - const { fieldState, formInstance } = Form.useField({ - name, - isDisabled, - form, - ...(defaultValue != null ? { defaultValue } : {}), - }) + const formInstance = Form.useFormContext(form) const classes = SELECTOR_STYLES({ size, @@ -116,51 +115,49 @@ export const Selector = forwardRef(function Selector< }) return ( - -
privateInputRef.current?.focus({ preventScroll: true })} - > - { - const { ref: fieldRef, value, onChange, ...field } = renderProps.field - return ( + render={(renderProps) => { + const { value } = renderProps.field + return ( + >()(inputProps, renderProps.field, { + fullWidth: true, + variants: fieldVariants, + form: formInstance, + label, + isRequired, + })} + name={props.name} + ref={ref} + > +
privateInputRef.current?.focus({ preventScroll: true })} + > ()( { className: classes.radioGroup(), name, isRequired, isDisabled, + isInvalid, style: columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {}, + ...(defaultValue != null ? { defaultValue } : {}), }, inputProps, - field, + renderProps.field, )} + ref={mergeRefs(inputRef, privateInputRef, renderProps.field.ref)} // eslint-disable-next-line no-restricted-syntax aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')} value={String(items.indexOf(value))} onChange={(newValue) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return - onChange(items[Number(newValue)]) + renderProps.field.onChange(items[Number(newValue)]) }} > @@ -169,10 +166,10 @@ export const Selector = forwardRef(function Selector< ))} - ) - }} - /> -
-
+
+
+ ) + }} + /> ) }) diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx index 9298ff2ba9a6..77225f329839 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx @@ -2,9 +2,9 @@ import { AnimatedBackground } from '#/components/AnimatedBackground' import { Radio, type RadioProps } from '#/components/aria' import { forwardRef } from '#/utilities/react' +import type { VariantProps } from '#/utilities/tailwindVariants' 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 SelectorOption}. */ @@ -99,9 +99,18 @@ export const SelectorOption = forwardRef(function SelectorOption( props: SelectorOptionProps, ref: React.ForwardedRef, ) { - const { label, value, size, rounded, variant, className, ...radioProps } = props + const { + label, + value, + size, + rounded, + variant, + className, + variants = SELECTOR_OPTION_STYLES, + ...radioProps + } = props - const styles = SELECTOR_OPTION_STYLES({ size, rounded, variant }) + const styles = variants({ size, rounded, variant }) return ( ()(ariaSwitchProps, fieldProps, { defaultSelected: field.value, className: switchStyles(), + onChange: field.onChange, + onBlur: field.onBlur, })} >
diff --git a/app/dashboard/src/components/AriaComponents/Text/Text.tsx b/app/dashboard/src/components/AriaComponents/Text/Text.tsx index 28bb614cb834..2e7f2495401c 100644 --- a/app/dashboard/src/components/AriaComponents/Text/Text.tsx +++ b/app/dashboard/src/components/AriaComponents/Text/Text.tsx @@ -34,7 +34,7 @@ export const TEXT_STYLE = twv.tv({ danger: 'text-danger', success: 'text-accent-dark', disabled: 'text-primary/30', - invert: 'text-white', + invert: 'text-invert', inherit: 'text-inherit', current: 'text-current', }, @@ -69,6 +69,7 @@ export const TEXT_STYLE = twv.tv({ }, transform: { none: '', + normal: 'normal-case', capitalize: 'capitalize', lowercase: 'lowercase', uppercase: 'uppercase', @@ -89,7 +90,7 @@ export const TEXT_STYLE = twv.tv({ }, monospace: { true: 'font-mono' }, italic: { true: 'italic' }, - nowrap: { true: 'whitespace-nowrap' }, + nowrap: { true: 'whitespace-nowrap', normal: 'whitespace-normal', false: '' }, textSelection: { auto: '', none: 'select-none', @@ -211,6 +212,8 @@ export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref & TextProps> & { // eslint-disable-next-line @typescript-eslint/naming-convention Heading: typeof Heading + // eslint-disable-next-line @typescript-eslint/naming-convention + Group: React.FC } /** @@ -233,3 +236,14 @@ const Heading = forwardRef(function Heading( return }) Text.Heading = Heading + +/** + * Text group component. It's used to visually group text elements together + */ +Text.Group = function TextGroup(props: React.PropsWithChildren) { + return ( + + {props.children} + + ) +} diff --git a/app/dashboard/src/components/Autocomplete.tsx b/app/dashboard/src/components/Autocomplete.tsx index 14a80f606b00..f7033219d50a 100644 --- a/app/dashboard/src/components/Autocomplete.tsx +++ b/app/dashboard/src/components/Autocomplete.tsx @@ -8,7 +8,7 @@ import Input from '#/components/styled/Input' import { Button, Text } from '#/components/AriaComponents' import * as tailwindMerge from '#/utilities/tailwindMerge' -import { twMerge } from 'tailwind-merge' +import { twJoin, twMerge } from 'tailwind-merge' // ================= // === Constants === @@ -100,16 +100,6 @@ export default function Autocomplete(props: AutocompleteProps) { } }, []) - React.useEffect(() => { - const onClick = () => { - setIsDropdownVisible(false) - } - document.addEventListener('click', onClick) - return () => { - document.removeEventListener('click', onClick) - } - }, []) - const fallbackInputRef = React.useRef(null) const inputRef = rawInputRef ?? fallbackInputRef @@ -186,7 +176,7 @@ export default function Autocomplete(props: AutocompleteProps) { } return ( -
+
(props: AutocompleteProps) { /> :
{ setIsDropdownVisible(true) }} diff --git a/app/dashboard/src/components/ContextMenus.tsx b/app/dashboard/src/components/ContextMenus.tsx index 6a8fa106f46e..6b85f38dcd45 100644 --- a/app/dashboard/src/components/ContextMenus.tsx +++ b/app/dashboard/src/components/ContextMenus.tsx @@ -1,23 +1,11 @@ /** @file A context menu. */ import * as React from 'react' -import * as detect from 'enso-common/src/detect' - import Modal from '#/components/Modal' import { forwardRef } from '#/utilities/react' import * as tailwindMerge from '#/utilities/tailwindMerge' -// ================= -// === Constants === -// ================= - -const DEFAULT_MENU_WIDTH = 256 -const MACOS_MENU_WIDTH = 230 -/** The width of a single context menu. */ -const MENU_WIDTH = detect.isOnMacOS() ? MACOS_MENU_WIDTH : DEFAULT_MENU_WIDTH -const HALF_MENU_WIDTH = Math.floor(MENU_WIDTH / 2) - // =================== // === ContextMenu === // =================== @@ -46,7 +34,7 @@ function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef(null) const inputRef = React.useRef(null) + const cancelledRef = React.useRef(false) const checkSubmittableRef = React.useRef(checkSubmittable) checkSubmittableRef.current = checkSubmittable @@ -78,21 +83,20 @@ export default function EditableSpan(props: EditableSpanProps) { cancelledRef.current = false }, [editable]) + aria.useInteractOutside({ + ref: formRef, + onInteractOutside: () => { + onCancel() + }, + }) + + useAutoFocus({ ref: inputRef, disabled: !editable }) + if (editable) { return (
{ - const currentTarget = event.currentTarget - if (!currentTarget.contains(event.relatedTarget)) { - // This must run AFTER the cancel button's event handler runs. - setTimeout(() => { - if (!cancelledRef.current) { - currentTarget.requestSubmit() - } - }) - } - }} onSubmit={(event) => { event.preventDefault() if (inputRef.current != null) { @@ -109,12 +113,12 @@ export default function EditableSpan(props: EditableSpanProps) { className={tailwindMerge.twMerge('rounded-lg', className)} ref={(element) => { inputRef.current = element + if (element) { element.style.width = '0' element.style.width = `${element.scrollWidth}px` } }} - autoFocus type="text" size={1} defaultValue={children} diff --git a/app/dashboard/src/components/JSONSchemaInput.tsx b/app/dashboard/src/components/JSONSchemaInput.tsx index 9b10e0c761ff..690dd743336a 100644 --- a/app/dashboard/src/components/JSONSchemaInput.tsx +++ b/app/dashboard/src/components/JSONSchemaInput.tsx @@ -63,7 +63,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { children.push(
@@ -92,7 +92,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { 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', + 'focus-child text w-full 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')} @@ -115,7 +115,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { 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', + 'focus-child text w-full 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')} @@ -139,7 +139,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { 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', + 'focus-child min-6- text40 w-full 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')} @@ -177,7 +177,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { ) if (constantValueOfSchema(defs, schema).length !== 1) { children.push( -
+
{propertyDefinitions.map((definition) => { const { key, schema: childSchema } = definition const isOptional = !requiredProperties.includes(key) @@ -191,7 +191,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { isDisabled={!isOptional} isActive={!isOptional || isPresent} className={twMerge( - 'col-start-1 inline-block whitespace-nowrap rounded-full px-button-x', + 'col-start-1 inline-block justify-self-start whitespace-nowrap rounded-full px-button-x', isOptional && 'hover:bg-hover-bg', )} onPress={() => { @@ -216,6 +216,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { > {'title' in childSchema ? String(childSchema.title) : key} + {isPresent && (
{ setSelectedChildIndex(index) const newConstantValue = constantValueOfSchema(defs, childSchema, true) diff --git a/app/dashboard/src/components/MenuEntry.tsx b/app/dashboard/src/components/MenuEntry.tsx index c2d028310341..fa7bcda6dfd0 100644 --- a/app/dashboard/src/components/MenuEntry.tsx +++ b/app/dashboard/src/components/MenuEntry.tsx @@ -75,7 +75,6 @@ export const ACTION_TO_TEXT_ID: Readonly< signOut: 'signOutShortcut', downloadApp: 'downloadAppShortcut', cancelCut: 'cancelCutShortcut', - editName: 'editNameShortcut', selectAdditional: 'selectAdditionalShortcut', selectRange: 'selectRangeShortcut', selectAdditionalRange: 'selectAdditionalRangeShortcut', diff --git a/app/dashboard/src/components/Stepper/Step.tsx b/app/dashboard/src/components/Stepper/Step.tsx new file mode 100644 index 000000000000..cb6cd8596e4d --- /dev/null +++ b/app/dashboard/src/components/Stepper/Step.tsx @@ -0,0 +1,199 @@ +/** + * @file Step component. + * A step component is used to represent a single step in a stepper component. + */ +import * as React from 'react' + +import { AnimatePresence, motion } from 'framer-motion' +import * as tvw from 'tailwind-variants' + +import DoneIcon from '#/assets/check_mark.svg' + +import * as ariaComponents from '#/components/AriaComponents' +import SvgMask from '#/components/SvgMask' + +import * as stepperProvider from './StepperProvider' +import type { RenderStepProps } from './types' +import type * as stepperState from './useStepperState' + +/** A prop with the given type, or a function to produce a value of the given type. */ +type StepProp = T | ((props: RenderStepProps) => T) + +/** + * Props for {@link Step} component. + */ +export interface StepProps extends RenderStepProps { + readonly className?: StepProp + readonly icon?: StepProp + readonly completeIcon?: StepProp + readonly title?: StepProp + readonly description?: StepProp + readonly children?: StepProp +} + +const STEP_STYLES = tvw.tv({ + base: 'relative flex items-center gap-2 select-none', + slots: { + icon: 'w-6 h-6 border-0.5 flex-none border-current rounded-full flex items-center justify-center transition-colors duration-200', + titleContainer: '-mt-1 flex flex-col items-start justify-start transition-colors duration-200', + content: 'flex-1', + }, + variants: { + position: { first: 'rounded-l-full', last: 'rounded-r-full' }, + status: { + completed: { + base: 'text-primary', + icon: 'bg-primary border-transparent text-invert', + content: 'text-primary', + }, + current: { base: 'text-primary', content: 'text-primary/30' }, + next: { base: 'text-primary/30', content: 'text-primary/30' }, + }, + }, +}) + +/** + * A step component is used to represent a single step in a stepper component. + */ +export function Step(props: StepProps) { + const { + index, + title, + description, + isCompleted, + goToStep, + nextStep, + previousStep, + totalSteps, + currentStep, + isCurrent, + isLast, + isFirst, + isDisabled, + className, + children, + icon = ( + + {index + 1} + + ), + completeIcon = DoneIcon, + } = props + + const { state } = stepperProvider.useStepperContext() + + const renderStepProps = { + isCompleted, + goToStep, + nextStep, + previousStep, + totalSteps, + currentStep, + isCurrent, + isLast, + isFirst, + isDisabled, + index, + } satisfies RenderStepProps + + const classes = typeof className === 'function' ? className(renderStepProps) : className + const descriptionElement = + typeof description === 'function' ? description(renderStepProps) : description + const titleElement = typeof title === 'function' ? title(renderStepProps) : title + const iconElement = typeof icon === 'function' ? icon(renderStepProps) : icon + const doneIconElement = + typeof completeIcon === 'function' ? completeIcon(renderStepProps) : completeIcon + + const styles = STEP_STYLES({ + className: classes, + position: + isFirst ? 'first' + : isLast ? 'last' + : undefined, + status: + isCompleted ? 'completed' + : isCurrent ? 'current' + : 'next', + }) + + const stepAnimationRotation = 30 + const stepAnimationScale = 0.5 + + return ( +
+ + ({ + rotate: direction === 'back' ? -stepAnimationRotation : stepAnimationRotation, + scale: stepAnimationScale, + opacity: 0, + position: 'absolute', + top: 0, + }), + }} + transition={{ + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + rotate: { type: 'spring', stiffness: 500, damping: 100, bounce: 0, duration: 0.2 }, + }} + > + {(() => { + const renderIconElement = isCompleted ? doneIconElement : iconElement + + if (renderIconElement == null) { + return null + } else if (typeof renderIconElement === 'string') { + return + } else { + return renderIconElement + } + })()} + + + +
+ {titleElement != null && ( +
+ {typeof titleElement === 'string' ? + + {titleElement} + + : titleElement} +
+ )} + + {descriptionElement != null && ( +
+ {typeof descriptionElement === 'string' ? + + {descriptionElement} + + : descriptionElement} +
+ )} +
+
+ {typeof children === 'function' ? children(renderStepProps) : children} +
+
+ ) +} diff --git a/app/dashboard/src/components/Stepper/StepContent.tsx b/app/dashboard/src/components/Stepper/StepContent.tsx new file mode 100644 index 000000000000..3a4c98395d07 --- /dev/null +++ b/app/dashboard/src/components/Stepper/StepContent.tsx @@ -0,0 +1,42 @@ +/** + * @file + * Component to render the step content. + */ +import type { ReactElement, ReactNode } from 'react' +import { useStepperContext } from './StepperProvider' +import type { RenderChildrenProps } from './types' + +/** + * Props for {@link StepContent} component. + */ +export interface StepContentProps { + readonly index: number + readonly children: ReactNode | ((props: RenderChildrenProps) => ReactNode) + readonly forceRender?: boolean +} + +/** + * Step content component. Renders the step content if the step is current or if `forceRender` is true. + */ +export function StepContent(props: StepContentProps): ReactElement | null { + const { index, children, forceRender = false } = props + const { currentStep, goToStep, nextStep, previousStep, totalSteps } = useStepperContext() + + const isCurrent = currentStep === index + + const renderProps = { + currentStep, + totalSteps, + isFirst: currentStep === 0, + isLast: currentStep === totalSteps - 1, + goToStep, + nextStep, + previousStep, + } satisfies RenderChildrenProps + + if (isCurrent || forceRender) { + return <>{typeof children === 'function' ? children(renderProps) : children} + } else { + return null + } +} diff --git a/app/dashboard/src/components/Stepper/Stepper.tsx b/app/dashboard/src/components/Stepper/Stepper.tsx index ad3198055da2..c1b657d6aa6e 100644 --- a/app/dashboard/src/components/Stepper/Stepper.tsx +++ b/app/dashboard/src/components/Stepper/Stepper.tsx @@ -8,62 +8,17 @@ import * as React from 'react' import { AnimatePresence, motion } from 'framer-motion' import * as tvw from 'tailwind-variants' -import DoneIcon from '#/assets/check_mark.svg' - import * as eventCallback from '#/hooks/eventCallbackHooks' -import * as ariaComponents from '#/components/AriaComponents' import { ErrorBoundary } from '#/components/ErrorBoundary' import { Suspense } from '#/components/Suspense' -import SvgMask from '#/components/SvgMask' +import { Step } from './Step' +import { StepContent } from './StepContent' import * as stepperProvider from './StepperProvider' +import type { BaseRenderProps, RenderChildrenProps, RenderStepProps } from './types' import * as stepperState from './useStepperState' -/** - * Render props for the stepper component. - */ -export interface BaseRenderProps { - readonly goToStep: (step: number) => void - readonly nextStep: () => void - readonly previousStep: () => void - readonly currentStep: number - readonly totalSteps: number -} - -/** - * Render props for rendering children of the stepper component. - */ -export interface RenderChildrenProps extends BaseRenderProps { - readonly isFirst: boolean - readonly isLast: boolean -} - -/** - * Render props for lazy rendering of steps. - */ -export interface RenderStepProps extends BaseRenderProps { - /** - * The index of the step, starting from 0. - */ - readonly index: number - readonly isCurrent: boolean - readonly isCompleted: boolean - readonly isFirst: boolean - readonly isLast: boolean - readonly isDisabled: boolean -} - -/** - * Render props for styling the stepper component. - */ -export interface RenderStepperProps { - readonly currentStep: number - readonly totalSteps: number - readonly isFirst: boolean - readonly isLast: boolean -} - /** * Props for {@link Stepper} component. */ @@ -228,187 +183,6 @@ export function Stepper(props: StepperProps) { ) } -/** A prop with the given type, or a function to produce a value of the given type. */ -type StepProp = T | ((props: RenderStepProps) => T) - -/** - * Props for {@link Step} component. - */ -export interface StepProps extends RenderStepProps { - readonly className?: StepProp - readonly icon?: StepProp - readonly completeIcon?: StepProp - readonly title?: StepProp - readonly description?: StepProp - readonly children?: StepProp -} - -const STEP_STYLES = tvw.tv({ - base: 'relative flex items-center gap-2 select-none', - slots: { - icon: 'w-6 h-6 border-0.5 flex-none border-current rounded-full flex items-center justify-center transition-colors duration-200', - titleContainer: '-mt-1 flex flex-col items-start justify-start transition-colors duration-200', - content: 'flex-1', - }, - variants: { - position: { first: 'rounded-l-full', last: 'rounded-r-full' }, - status: { - completed: { - base: 'text-primary', - icon: 'bg-primary border-transparent text-invert', - content: 'text-primary', - }, - current: { base: 'text-primary', content: 'text-primary/30' }, - next: { base: 'text-primary/30', content: 'text-primary/30' }, - }, - }, -}) - -/** - * A step component is used to represent a single step in a stepper component. - */ -function Step(props: StepProps) { - const { - index, - title, - description, - isCompleted, - goToStep, - nextStep, - previousStep, - totalSteps, - currentStep, - isCurrent, - isLast, - isFirst, - isDisabled, - className, - children, - icon = ( - - {index + 1} - - ), - completeIcon = DoneIcon, - } = props - - const { state } = stepperProvider.useStepperContext() - - const renderStepProps = { - isCompleted, - goToStep, - nextStep, - previousStep, - totalSteps, - currentStep, - isCurrent, - isLast, - isFirst, - isDisabled, - index, - } satisfies RenderStepProps - - const classes = typeof className === 'function' ? className(renderStepProps) : className - const descriptionElement = - typeof description === 'function' ? description(renderStepProps) : description - const titleElement = typeof title === 'function' ? title(renderStepProps) : title - const iconElement = typeof icon === 'function' ? icon(renderStepProps) : icon - const doneIconElement = - typeof completeIcon === 'function' ? completeIcon(renderStepProps) : completeIcon - - const styles = STEP_STYLES({ - className: classes, - position: - isFirst ? 'first' - : isLast ? 'last' - : undefined, - status: - isCompleted ? 'completed' - : isCurrent ? 'current' - : 'next', - }) - - const stepAnimationRotation = 30 - const stepAnimationScale = 0.5 - - return ( -
- - ({ - rotate: direction === 'back' ? -stepAnimationRotation : stepAnimationRotation, - scale: stepAnimationScale, - opacity: 0, - position: 'absolute', - top: 0, - }), - }} - transition={{ - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - rotate: { type: 'spring', stiffness: 500, damping: 100, bounce: 0, duration: 0.2 }, - }} - > - {(() => { - const renderIconElement = isCompleted ? doneIconElement : iconElement - - if (renderIconElement == null) { - return null - } else if (typeof renderIconElement === 'string') { - return - } else { - return renderIconElement - } - })()} - - - -
- {titleElement != null && ( -
- {typeof titleElement === 'string' ? - - {titleElement} - - : titleElement} -
- )} - - {descriptionElement != null && ( -
- {typeof descriptionElement === 'string' ? - - {descriptionElement} - - : descriptionElement} -
- )} -
-
- {typeof children === 'function' ? children(renderStepProps) : children} -
-
- ) -} - Stepper.Step = Step +Stepper.StepContent = StepContent Stepper.useStepperState = stepperState.useStepperState diff --git a/app/dashboard/src/components/Stepper/types.ts b/app/dashboard/src/components/Stepper/types.ts new file mode 100644 index 000000000000..3873571959b1 --- /dev/null +++ b/app/dashboard/src/components/Stepper/types.ts @@ -0,0 +1,39 @@ +/** + * @file + * + * Types for the stepper component. + */ + +/** + * Render props for the stepper component. + */ +export interface BaseRenderProps { + readonly goToStep: (step: number) => void + readonly nextStep: () => void + readonly previousStep: () => void + readonly currentStep: number + readonly totalSteps: number +} + +/** + * Render props for rendering children of the stepper component. + */ +export interface RenderChildrenProps extends BaseRenderProps { + readonly isFirst: boolean + readonly isLast: boolean +} + +/** + * Render props for lazy rendering of steps. + */ +export interface RenderStepProps extends BaseRenderProps { + /** + * The index of the step, starting from 0. + */ + readonly index: number + readonly isCurrent: boolean + readonly isCompleted: boolean + readonly isFirst: boolean + readonly isLast: boolean + readonly isDisabled: boolean +} diff --git a/app/dashboard/src/components/Stepper/useStepperState.ts b/app/dashboard/src/components/Stepper/useStepperState.ts index 8828b2802604..72ff5412c288 100644 --- a/app/dashboard/src/components/Stepper/useStepperState.ts +++ b/app/dashboard/src/components/Stepper/useStepperState.ts @@ -81,19 +81,28 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult const setCurrentStep = eventCallbackHooks.useEventCallback( (step: number | ((current: number) => number)) => { - privateSetCurrentStep((current) => { - const nextStep = typeof step === 'function' ? step(current.current) : step - const direction = nextStep > current.current ? 'forward' : 'back' - - if (nextStep < 0) { - return { current: 0, direction: 'back-none' } - } else if (nextStep > steps - 1) { - onCompletedStableCallback() - return { current: steps - 1, direction: 'forward-none' } - } else { - onStepChangeStableCallback(nextStep, direction) - return { current: nextStep, direction } - } + React.startTransition(() => { + privateSetCurrentStep((current) => { + const nextStep = typeof step === 'function' ? step(current.current) : step + const direction = nextStep > current.current ? 'forward' : 'back' + + if (nextStep < 0) { + return { + current: 0, + direction: 'back-none', + } + } else if (nextStep > steps - 1) { + onCompletedStableCallback() + return { + current: steps - 1, + direction: 'forward-none', + } + } else { + onStepChangeStableCallback(nextStep, direction) + + return { current: nextStep, direction } + } + }) }) }, ) diff --git a/app/dashboard/src/components/aria.tsx b/app/dashboard/src/components/aria.tsx index d253b1d3badc..d7217fe9e7c1 100644 --- a/app/dashboard/src/components/aria.tsx +++ b/app/dashboard/src/components/aria.tsx @@ -3,7 +3,6 @@ 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' diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index d6656d850ee6..544aa6cb1b8f 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -160,9 +160,17 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus) const asset = item.item const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible) - const [rowState, setRowState] = React.useState(() => + const [innerRowState, setRowState] = React.useState(() => object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }), ) + + const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id) + const isEditingName = innerRowState.isEditingName || isNewlyCreated + + const rowState = React.useMemo(() => { + return object.merge(innerRowState, { isEditingName }) + }, [isEditingName, innerRowState]) + const nodeParentKeysRef = React.useRef<{ readonly nodeMap: WeakRef> readonly parentKeys: Map @@ -193,6 +201,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink')) const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) + const editDescriptionMutation = useMutation(backendMutationOptions(backend, 'updateAsset')) const setSelected = useEventCallback((newSelected: boolean) => { const { selectedKeys } = driveStore.getState() @@ -224,9 +233,11 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { }, [grabKeyboardFocusRef, isKeyboardSelected, item]) React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item })) + if (updateAssetRef.current) { updateAssetRef.current[item.item.id] = setAsset } + React.useEffect(() => { return () => { if (updateAssetRef.current) { @@ -250,25 +261,25 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { [doDeleteRaw, item.item], ) - const doTriggerDescriptionEdit = React.useCallback(() => { + const doTriggerDescriptionEdit = useEventCallback(() => { setModal( { if (description !== asset.description) { setAsset(object.merger({ description })) - await backend - .updateAsset(item.item.id, { parentDirectoryId: null, description }, item.item.title) - .catch((error) => { - setAsset(object.merger({ description: asset.description })) - throw error - }) + // eslint-disable-next-line no-restricted-syntax + return editDescriptionMutation.mutateAsync([ + asset.id, + { description, parentDirectoryId: null }, + item.item.title, + ]) } }} initialDescription={asset.description} />, ) - }, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title]) + }) const clearDragState = React.useCallback(() => { setIsDraggedOver(false) @@ -331,7 +342,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { break } default: { - return + break } } } else { @@ -549,18 +560,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { } case AssetEventType.deleteLabel: { setAsset((oldAsset) => { - // The IIFE is required to prevent TypeScript from narrowing this value. - let found = (() => false)() - const labels = - oldAsset.labels?.filter((label) => { - if (label === event.labelName) { - found = true - return false - } else { - return true - } - }) ?? null - return found ? object.merge(oldAsset, { labels }) : oldAsset + const oldLabels = oldAsset.labels ?? [] + const labels: backendModule.LabelName[] = [] + + for (const label of oldLabels) { + if (label !== event.labelName) { + labels.push(label) + } + } + + return oldLabels.length !== labels.length ? + object.merge(oldAsset, { labels }) + : oldAsset }) break } diff --git a/app/dashboard/src/components/dashboard/DatalinkInput.tsx b/app/dashboard/src/components/dashboard/DatalinkInput.tsx index de2363dfffbe..f9653c30faf4 100644 --- a/app/dashboard/src/components/dashboard/DatalinkInput.tsx +++ b/app/dashboard/src/components/dashboard/DatalinkInput.tsx @@ -6,10 +6,9 @@ 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 type { FieldPath, FormInstance, TSchema } from '#/components/AriaComponents' +import { Form } from '#/components/AriaComponents' import * as error from '#/utilities/error' -import { Controller, type FieldPath } from 'react-hook-form' // ================= // === Constants === @@ -52,17 +51,17 @@ export default function DatalinkInput(props: DatalinkInputProps) { export interface DatalinkFormInputProps extends Omit { readonly form?: FormInstance - readonly name: FieldPath> + readonly name: FieldPath } /** A dynamic wizard for creating an arbitrary type of Datalink. */ export function DatalinkFormInput(props: DatalinkFormInputProps) { - const fallbackForm = useFormContext() - // eslint-disable-next-line no-restricted-syntax - const { form = fallbackForm as unknown as FormInstance, name, ...inputProps } = props + const { name, ...inputProps } = props + + const form = Form.useFormContext(props.form) return ( - { diff --git a/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx b/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx index f4e55e99a255..f50e8742617e 100644 --- a/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx @@ -3,8 +3,6 @@ import DatalinkIcon from '#/assets/datalink.svg' import * as setAssetHooks from '#/hooks/setAssetHooks' -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' - import type * as column from '#/components/dashboard/column' import EditableSpan from '#/components/EditableSpan' @@ -15,7 +13,6 @@ import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' import * as tailwindMerge from '#/utilities/tailwindMerge' -import { isOnMacOS } from 'enso-common/src/detect' // ==================== // === DatalinkName === @@ -30,7 +27,7 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {} export default function DatalinkNameColumn(props: DatalinkNameColumnProps) { const { item, setItem, selected, rowState, setRowState, isEditable } = props const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible() - const inputBindings = inputBindingsProvider.useInputBindings() + if (item.type !== backendModule.AssetType.datalink) { // eslint-disable-next-line no-restricted-syntax throw new Error('`DatalinkNameColumn` can only display Datalinks.') @@ -49,12 +46,6 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) { // Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505. const doRename = () => Promise.resolve(null) - const handleClick = inputBindings.handler({ - editName: () => { - setIsEditing(true) - }, - }) - return (
{ - if (handleClick(event)) { - // Already handled. - } else if (eventModule.isSingleClick(event) && isOnMacOS() && selected) { + if (eventModule.isSingleClick(event) && selected) { setIsEditing(true) } else if (eventModule.isDoubleClick(event)) { event.stopPropagation() diff --git a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx index 0f433fa255d0..26c033f5d96e 100644 --- a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -9,7 +9,6 @@ import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import { useDriveStore } from '#/providers/DriveProvider' -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' @@ -25,7 +24,6 @@ import * as object from '#/utilities/object' import * as string from '#/utilities/string' import * as tailwindMerge from '#/utilities/tailwindMerge' import * as validation from '#/utilities/validation' -import { isOnMacOS } from 'enso-common/src/detect' // ===================== // === DirectoryName === @@ -43,7 +41,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const { doToggleDirectoryExpansion, expandedDirectoryIds } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { getText } = textProvider.useText() - const inputBindings = inputBindingsProvider.useInputBindings() const driveStore = useDriveStore() if (item.type !== backendModule.AssetType.directory) { @@ -62,6 +59,10 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { if (isEditable) { setRowState(object.merger({ isEditingName })) } + + if (!isEditingName) { + driveStore.setState({ newestFolderId: null }) + } } const doRename = async (newTitle: string) => { @@ -73,12 +74,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const oldTitle = asset.title setAsset(object.merger({ title: newTitle })) try { - const updated = await updateDirectoryMutation.mutateAsync([ - asset.id, - { title: newTitle }, - asset.title, - ]) - setAsset(object.merger(updated)) + await updateDirectoryMutation.mutateAsync([asset.id, { title: newTitle }, asset.title]) } catch (error) { toastAndLog('renameFolderError', error) setAsset(object.merger({ title: oldTitle })) @@ -87,12 +83,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { } } - const handleClick = inputBindings.handler({ - editName: () => { - setIsEditing(true) - }, - }) - return (
{ - if (handleClick(event)) { - // Already handled. - } else if ( + if ( eventModule.isSingleClick(event) && - isOnMacOS() && selected && driveStore.getState().selectedKeys.size === 1 ) { diff --git a/app/dashboard/src/components/dashboard/FileNameColumn.tsx b/app/dashboard/src/components/dashboard/FileNameColumn.tsx index 58204839052f..2266b11a127d 100644 --- a/app/dashboard/src/components/dashboard/FileNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/FileNameColumn.tsx @@ -5,8 +5,6 @@ import { backendMutationOptions } from '#/hooks/backendHooks' import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' - import type * as column from '#/components/dashboard/column' import EditableSpan from '#/components/EditableSpan' import SvgMask from '#/components/SvgMask' @@ -19,7 +17,6 @@ import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' import * as string from '#/utilities/string' import * as tailwindMerge from '#/utilities/tailwindMerge' -import { isOnMacOS } from 'enso-common/src/detect' // ================ // === FileName === @@ -35,7 +32,6 @@ export default function FileNameColumn(props: FileNameColumnProps) { const { item, setItem, selected, state, rowState, setRowState, isEditable } = props const { backend, nodeMap } = state const toastAndLog = toastAndLogHooks.useToastAndLog() - const inputBindings = inputBindingsProvider.useInputBindings() if (item.type !== backendModule.AssetType.file) { // eslint-disable-next-line no-restricted-syntax @@ -74,12 +70,6 @@ export default function FileNameColumn(props: FileNameColumnProps) { } } - const handleClick = inputBindings.handler({ - editName: () => { - setIsEditing(true) - }, - }) - return (
{ - if (handleClick(event)) { - // Already handled. - } else if (eventModule.isSingleClick(event) && isOnMacOS() && selected) { + if (eventModule.isSingleClick(event) && selected) { if (!isCloud) { setIsEditing(true) } diff --git a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx index e8cf48dbc1f0..baed616dde92 100644 --- a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -10,7 +10,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as authProvider from '#/providers/AuthProvider' import { useDriveStore } from '#/providers/DriveProvider' -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' import type * as column from '#/components/dashboard/column' @@ -56,7 +55,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const { user } = authProvider.useFullUserSession() const { getText } = textProvider.useText() - const inputBindings = inputBindingsProvider.useInputBindings() const driveStore = useDriveStore() const doOpenProject = projectHooks.useOpenProject() @@ -116,12 +114,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { } } - const handleClick = inputBindings.handler({ - editName: () => { - setIsEditing(true) - }, - }) - return (
{ if (rowState.isEditingName || isOtherUserUsingProject) { // The project should neither be edited nor opened in these cases. - } else if (handleClick(event)) { - // Already handled. } else if ( !isRunning && eventModule.isSingleClick(event) && diff --git a/app/dashboard/src/components/dashboard/SecretNameColumn.tsx b/app/dashboard/src/components/dashboard/SecretNameColumn.tsx index 517e90935316..ea292b0a3a6b 100644 --- a/app/dashboard/src/components/dashboard/SecretNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/SecretNameColumn.tsx @@ -6,7 +6,6 @@ import KeyIcon from '#/assets/key.svg' import { backendMutationOptions } from '#/hooks/backendHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as modalProvider from '#/providers/ModalProvider' import * as ariaComponents from '#/components/AriaComponents' @@ -21,7 +20,6 @@ import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' import * as tailwindMerge from '#/utilities/tailwindMerge' -import { isOnMacOS } from 'enso-common/src/detect' // ===================== // === ConnectorName === @@ -38,11 +36,12 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { const { backend } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { setModal } = modalProvider.useSetModal() - const inputBindings = inputBindingsProvider.useInputBindings() + if (item.type !== backendModule.AssetType.secret) { // eslint-disable-next-line no-restricted-syntax throw new Error('`SecretNameColumn` can only display secrets.') } + const asset = item.item const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret')) @@ -53,12 +52,6 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { } } - const handleClick = inputBindings.handler({ - editName: () => { - setIsEditing(true) - }, - }) - return (
{ - if (handleClick(event)) { - // Already handled. - } else if (eventModule.isSingleClick(event) && isOnMacOS() && selected) { + if (eventModule.isSingleClick(event) && selected) { setIsEditing(true) } else if (eventModule.isDoubleClick(event) && isEditable) { event.stopPropagation() diff --git a/app/dashboard/src/components/dashboard/TheModal.tsx b/app/dashboard/src/components/dashboard/TheModal.tsx index 4df237732db8..12cdf9eed573 100644 --- a/app/dashboard/src/components/dashboard/TheModal.tsx +++ b/app/dashboard/src/components/dashboard/TheModal.tsx @@ -1,7 +1,9 @@ /** @file A component that renders the modal instance from the modal React Context. */ import * as React from 'react' +import { DialogTrigger } from '#/components/AriaComponents' import * as modalProvider from '#/providers/ModalProvider' +import { AnimatePresence, motion } from 'framer-motion' // ================ // === TheModal === @@ -9,7 +11,25 @@ import * as modalProvider from '#/providers/ModalProvider' /** Renders the modal instance from the modal React Context (if any). */ export default function TheModal() { - const { modal } = modalProvider.useModal() + const { modal, key } = modalProvider.useModal() - return <>{modal} + return ( + + {modal && ( + + + <> + {modal} + + + )} + + ) } diff --git a/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index ccf6a4b2af0a..f50b0eead9db 100644 --- a/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -48,6 +48,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const isUnderPaywall = isFeatureUnderPaywall('share') + const assetPermissions = asset.permissions ?? [] const { setModal } = modalProvider.useSetModal() const self = permissions.tryFindSelfPermission(user, asset.permissions) const plusButtonRef = React.useRef(null) @@ -70,7 +71,12 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { return (
- {(asset.permissions ?? []).map((other, idx) => ( + {(category.type === 'trash' ? + assetPermissions.filter( + (permission) => permission.permission === permissions.PermissionAction.own, + ) + : assetPermissions + ).map((other, idx) => ( > = { // ===================== /** Return the full list of columns given the relevant current state. */ -export function getColumnList(user: backend.User, backendType: backend.BackendType) { +export function getColumnList( + user: backend.User, + backendType: backend.BackendType, + category: Category, +) { const isCloud = backendType === backend.BackendType.remote const isEnterprise = user.plan === backend.Plan.enterprise + const isTrash = category.type === 'trash' const columns = [ Column.name, Column.modified, - isCloud && isEnterprise && Column.sharedWith, + isCloud && (isEnterprise || isTrash) && Column.sharedWith, isCloud && Column.labels, isCloud && Column.accessedByProjects, isCloud && Column.accessedData, diff --git a/app/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx b/app/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx index ca629bc1dd26..e7e8b393c543 100644 --- a/app/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx +++ b/app/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx @@ -11,7 +11,7 @@ import { useText } from '#/providers/TextProvider' /** A heading for the "Shared with" column. */ export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) { const { state } = props - const { hideColumn } = state + const { category, hideColumn } = state const { getText } = useText() const { user } = useFullUserSession() @@ -33,7 +33,11 @@ export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) />
- {getText('sharedWithColumnName')} + + {category.type === 'trash' ? + getText('rootFolderColumnName') + : getText('sharedWithColumnName')} + {isUnderPaywall && ( diff --git a/app/dashboard/src/configurations/inputBindings.ts b/app/dashboard/src/configurations/inputBindings.ts index e889f1dd4f4d..5a200d396863 100644 --- a/app/dashboard/src/configurations/inputBindings.ts +++ b/app/dashboard/src/configurations/inputBindings.ts @@ -108,7 +108,6 @@ export const BINDINGS = inputBindings.defineBindings({ // TODO: support handlers for double click; make single click handlers not work on double click events // [MouseAction.open]: [mousebind(MouseAction.open, [], MouseButton.left, 2)], // [MouseAction.run]: [mousebind(MouseAction.run, ['Shift'], MouseButton.left, 2)], - editName: { name: 'Edit Name', bindings: ['Mod+PointerMain'], rebindable: false }, selectAdditional: { name: 'Select Additional', bindings: ['Mod+PointerMain'], rebindable: false }, selectRange: { name: 'Select Range', bindings: ['Shift+PointerMain'], rebindable: false }, selectAdditionalRange: { diff --git a/app/dashboard/src/globals.d.ts b/app/dashboard/src/globals.d.ts index 6a03d16d5eb8..6c399e31ff2d 100644 --- a/app/dashboard/src/globals.d.ts +++ b/app/dashboard/src/globals.d.ts @@ -203,13 +203,13 @@ declare global { // @ts-expect-error The index signature is intentional to disallow unknown env vars. readonly ENSO_CLOUD_STRIPE_KEY?: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_USER_POOL_ID?: string + readonly ENSO_CLOUD_COGNITO_USER_POOL_ID: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID?: string + readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_DOMAIN?: string + readonly ENSO_CLOUD_COGNITO_DOMAIN: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_REGION?: string + readonly ENSO_CLOUD_COGNITO_REGION: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. diff --git a/app/dashboard/src/hooks/autoFocusHooks.ts b/app/dashboard/src/hooks/autoFocusHooks.ts new file mode 100644 index 000000000000..9244b6d45429 --- /dev/null +++ b/app/dashboard/src/hooks/autoFocusHooks.ts @@ -0,0 +1,110 @@ +/** + * @file + * Hooks for automatically focusing elements. + */ + +import { useInteractOutside } from '#/components/aria' +import { useEffect, useRef } from 'react' +import { useEventCallback } from './eventCallbackHooks' + +/** + * Props for the {@link useAutoFocus} hook. + */ +export interface UseAutoFocusProps { + readonly ref: React.RefObject + readonly disabled?: boolean | undefined +} + +const FOCUS_TRYOUT_DELAY = 300 +const FOCUS_DELAY = 100 + +/** + * Hook for automatically focusing an element. + * Tries to focus the element for a period of time, and if it fails, it will + * try again in a loop. + * If user interacts with the page, the focus will be cancelled. + */ +export function useAutoFocus(props: UseAutoFocusProps) { + const { ref, disabled = false } = props + + const shouldForceFocus = useRef(false) + const scheduledFocusRef = useRef | null>(null) + + useInteractOutside({ + ref, + onInteractOutside: () => { + // If the user clicks outside of the element, we should not force focus. + shouldForceFocus.current = false + clearScheduledFocus() + }, + }) + + const scheduleFocus = useEventCallback(() => { + clearScheduledFocus() + + scheduledFocusRef.current = setTimeout(() => { + const element = ref.current + if (element != null) { + element.focus() + } + + scheduledFocusRef.current = null + }, FOCUS_DELAY) + + return () => { + clearScheduledFocus() + } + }) + + const clearScheduledFocus = useEventCallback(() => { + if (scheduledFocusRef.current != null) { + clearTimeout(scheduledFocusRef.current) + scheduledFocusRef.current = null + } + }) + + useEffect(() => { + if (!disabled) { + shouldForceFocus.current = true + } + }, [disabled]) + + useEffect(() => { + if (!disabled && shouldForceFocus.current) { + return scheduleFocus() + } + }, [disabled, scheduleFocus, clearScheduledFocus]) + + useEffect(() => { + if (disabled) { + return + } + + const body = document.body + + const handleFocus = () => { + const activeElement = document.activeElement + const element = ref instanceof HTMLElement ? ref : ref.current + + if (element == null) { + return + } + + if (activeElement !== element && shouldForceFocus.current) { + scheduleFocus() + } + } + + const id = setTimeout(() => { + shouldForceFocus.current = false + clearScheduledFocus() + }, FOCUS_TRYOUT_DELAY) + + body.addEventListener('focus', handleFocus, { capture: true, passive: true }) + + return () => { + body.removeEventListener('focus', handleFocus) + clearTimeout(id) + } + }, [disabled, scheduleFocus, ref, clearScheduledFocus]) +} diff --git a/app/dashboard/src/hooks/backendHooks.ts b/app/dashboard/src/hooks/backendHooks.ts index 4c7b156a5723..d5ba0aaabe94 100644 --- a/app/dashboard/src/hooks/backendHooks.ts +++ b/app/dashboard/src/hooks/backendHooks.ts @@ -131,6 +131,19 @@ const INVALIDATION_MAP: Partial< deleteTag: ['listTags'], acceptInvitation: [INVALIDATE_ALL_QUERIES], declineInvitation: ['usersMe'], + createProject: ['listDirectory'], + duplicateProject: ['listDirectory'], + createDirectory: ['listDirectory'], + createSecret: ['listDirectory'], + updateSecret: ['listDirectory'], + createDatalink: ['listDirectory'], + uploadFile: ['listDirectory'], + copyAsset: ['listDirectory', 'listAssetVersions'], + deleteAsset: ['listDirectory', 'listAssetVersions'], + undoDeleteAsset: ['listDirectory'], + updateAsset: ['listDirectory', 'listAssetVersions'], + closeProject: ['listDirectory', 'listAssetVersions'], + updateDirectory: ['listDirectory'], } export function backendMutationOptions( diff --git a/app/dashboard/src/hooks/searchParamsStateHooks.ts b/app/dashboard/src/hooks/searchParamsStateHooks.ts index 0d0c09c283c8..1ff3f7ba1547 100644 --- a/app/dashboard/src/hooks/searchParamsStateHooks.ts +++ b/app/dashboard/src/hooks/searchParamsStateHooks.ts @@ -22,9 +22,20 @@ import * as safeJsonParse from '#/utilities/safeJsonParse' * The return type of the `useSearchParamsState` hook. */ type SearchParamsStateReturnType = Readonly< - [value: T, setValue: (nextValue: React.SetStateAction) => void, clear: () => void] + [ + value: T, + setValue: (nextValue: React.SetStateAction, params?: SearchParamsSetOptions) => void, + clear: (replace?: boolean) => void, + ] > +/** + * Set options for the `set` function. + */ +export interface SearchParamsSetOptions { + readonly replace?: boolean +} + // ============================ // === useSearchParamsState === // ============================ @@ -82,18 +93,22 @@ export function useSearchParamsState( * @param nextValue - The next value to set. * @returns void */ - const setValue = eventCallback.useEventCallback((nextValue: React.SetStateAction) => { - if (nextValue instanceof Function) { - nextValue = nextValue(value) - } - - if (nextValue === lazyDefaultValueInitializer()) { - clear() - } else { - searchParams.set(prefixedKey, JSON.stringify(nextValue)) - setSearchParams(searchParams) - } - }) + const setValue = eventCallback.useEventCallback( + (nextValue: React.SetStateAction, params: SearchParamsSetOptions = {}) => { + const { replace = false } = params + + if (nextValue instanceof Function) { + nextValue = nextValue(value) + } + + if (nextValue === lazyDefaultValueInitializer()) { + clear() + } else { + searchParams.set(prefixedKey, JSON.stringify(nextValue)) + setSearchParams(searchParams, { replace, preventScrollReset: true }) + } + }, + ) return [value, setValue, clear] } diff --git a/app/dashboard/src/layouts/AssetContextMenu.tsx b/app/dashboard/src/layouts/AssetContextMenu.tsx index f8198d9199d6..c58c45542368 100644 --- a/app/dashboard/src/layouts/AssetContextMenu.tsx +++ b/app/dashboard/src/layouts/AssetContextMenu.tsx @@ -320,6 +320,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { doAction={() => { setModal( { @@ -361,8 +362,18 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { label={isCloud ? getText('moveToTrashShortcut') : getText('deleteShortcut')} doAction={() => { if (isCloud) { - unsetModal() - doDelete() + if (asset.type === backendModule.AssetType.directory) { + setModal( + , + ) + } else { + unsetModal() + doDelete() + } } else { setModal( )} - {!isOtherUserUsingProject && ( + {!isRunningProject && !isOtherUserUsingProject && (