mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Merge branch 'develop' into wip/hubert/copying-opt
This commit is contained in:
commit
8ef9aebc0f
6
.github/workflows/gui-tests.yml
vendored
6
.github/workflows/gui-tests.yml
vendored
@ -1,7 +1,7 @@
|
||||
# This file is auto-generated. Do not edit it manually!
|
||||
# Edit the enso_build::ci_gen module instead and run `cargo run --package enso-build-ci-gen`.
|
||||
|
||||
name: GUI Tests
|
||||
name: GUI Check
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@ -27,7 +27,7 @@ jobs:
|
||||
access_token: ${{ github.token }}
|
||||
permissions:
|
||||
actions: write
|
||||
enso-build-ci-gen-job-gui-test-linux-x86_64:
|
||||
enso-build-ci-gen-job-gui-check-linux-x86_64:
|
||||
name: GUI tests (linux, x86_64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: ./run gui test
|
||||
- run: ./run gui check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- if: failure() && runner.os == 'Windows'
|
||||
|
6
.github/workflows/gui.yml
vendored
6
.github/workflows/gui.yml
vendored
@ -155,7 +155,7 @@ jobs:
|
||||
access_token: ${{ github.token }}
|
||||
permissions:
|
||||
actions: write
|
||||
enso-build-ci-gen-job-new-gui-build-linux-x86_64:
|
||||
enso-build-ci-gen-job-gui-build-linux-x86_64:
|
||||
name: GUI build (linux, x86_64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
@ -210,7 +210,7 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-new-gui-build-macos-x86_64:
|
||||
enso-build-ci-gen-job-gui-build-macos-x86_64:
|
||||
name: GUI build (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-12
|
||||
@ -264,7 +264,7 @@ jobs:
|
||||
run: ./run git-clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
enso-build-ci-gen-job-new-gui-build-windows-x86_64:
|
||||
enso-build-ci-gen-job-gui-build-windows-x86_64:
|
||||
name: GUI build (windows, x86_64)
|
||||
runs-on:
|
||||
- self-hosted
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -73,6 +73,8 @@ node_modules/
|
||||
.metals
|
||||
tools/performance/engine-benchmarks/generated_site
|
||||
*.tsbuildinfo
|
||||
vite.config.ts.timestamp-*.mjs
|
||||
vitest.config.ts.timestamp-*.mjs
|
||||
|
||||
############################
|
||||
## Rendered Documentation ##
|
||||
|
21
.pnpmfile.cjs
Normal file
21
.pnpmfile.cjs
Normal file
@ -0,0 +1,21 @@
|
||||
const IGNORED_DEPS = ['react-native-url-polyfill', 'react-native-get-random-values']
|
||||
|
||||
const unusedIgnores = new Set(IGNORED_DEPS)
|
||||
module.exports.hooks = {
|
||||
readPackage: (pkg, context) => {
|
||||
for (const ignored of IGNORED_DEPS) {
|
||||
if (pkg.dependencies[ignored]) {
|
||||
delete pkg.dependencies[ignored]
|
||||
context.log(`Ignoring dependency ${ignored} in ${pkg.name}`)
|
||||
unusedIgnores.delete(ignored)
|
||||
}
|
||||
}
|
||||
return pkg
|
||||
},
|
||||
afterAllResolved(lockfile, context) {
|
||||
if (unusedIgnores.size > 0) {
|
||||
context.log(`Unused dependency ignore declarations: ${Array.from(unusedIgnores).join(', ')}`)
|
||||
}
|
||||
return lockfile
|
||||
},
|
||||
}
|
@ -38,8 +38,7 @@ app/ide-desktop/lib/dashboard/playwright-report/
|
||||
app/ide-desktop/lib/dashboard/playwright/.cache/
|
||||
app/ide-desktop/lib/dashboard/dist/
|
||||
app/gui/view/documentation/assets/stylesheet.css
|
||||
app/gui2/rust-ffi/pkg
|
||||
app/gui2/rust-ffi/node-pkg
|
||||
app/rust-ffi/pkg
|
||||
app/gui2/src/assets/font-*.css
|
||||
Cargo.lock
|
||||
build.json
|
||||
|
12
CHANGELOG.md
12
CHANGELOG.md
@ -1,14 +1,24 @@
|
||||
# Next Release
|
||||
|
||||
#### Enso IDE
|
||||
|
||||
- [Table Editor Widget][10774] displayed in `Table.new` component.
|
||||
|
||||
[10774]: https://github.com/enso-org/enso/pull/10774
|
||||
|
||||
#### Enso Standard Library
|
||||
|
||||
- [Implemented in-memory and database mixed `Decimal` column
|
||||
comparisons.][10614]
|
||||
- [Relative paths are now resolved relative to the project location, also in the
|
||||
Cloud.][10660]
|
||||
- [Added Newline option to Text_Cleanse/Text_Replace.][10761]
|
||||
- [Support for reading from Tableau Hyper files.][10733]
|
||||
|
||||
[10614]: https://github.com/enso-org/enso/pull/10614
|
||||
[10660]: https://github.com/enso-org/enso/pull/10660
|
||||
[10761]: https://github.com/enso-org/enso/pull/10761
|
||||
[10733]: https://github.com/enso-org/enso/pull/10733
|
||||
|
||||
# Enso 2023.3
|
||||
|
||||
@ -18,11 +28,13 @@
|
||||
- [Renaming launcher executable to ensoup][10535]
|
||||
- [Space-precedence does not apply to value-level operators][10597]
|
||||
- [Must specify `--repl` to enable debug server][10709]
|
||||
- [Improved parser error reporting and performance][10734]
|
||||
|
||||
[10468]: https://github.com/enso-org/enso/pull/10468
|
||||
[10535]: https://github.com/enso-org/enso/pull/10535
|
||||
[10597]: https://github.com/enso-org/enso/pull/10597
|
||||
[10709]: https://github.com/enso-org/enso/pull/10709
|
||||
[10734]: https://github.com/enso-org/enso/pull/10734
|
||||
|
||||
#### Enso IDE
|
||||
|
||||
|
27
Cargo.lock
generated
27
Cargo.lock
generated
@ -27,6 +27,18 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||
|
||||
[[package]]
|
||||
name = "afl"
|
||||
version = "0.15.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c21e10b6947189c5ff61343b5354e9ad1c1722bd47b69cd0a6b49e5fa7f7ecf6"
|
||||
dependencies = [
|
||||
"home",
|
||||
"libc",
|
||||
"rustc_version",
|
||||
"xdg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@ -1619,6 +1631,7 @@ dependencies = [
|
||||
name = "enso-parser-debug"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap 4.5.4",
|
||||
"enso-metamodel",
|
||||
"enso-metamodel-lexpr",
|
||||
"enso-parser",
|
||||
@ -1628,6 +1641,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enso-parser-fuzz"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"afl",
|
||||
"enso-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enso-parser-generate-java"
|
||||
version = "0.1.0"
|
||||
@ -5480,6 +5501,12 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546"
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
|
@ -7,7 +7,7 @@ resolver = "2"
|
||||
# path, e.g. `lib/rust/ensogl/examples`, or `app/gui/view/examples`; this is used to optimize the application for
|
||||
# loading the IDE.
|
||||
members = [
|
||||
"app/gui2/rust-ffi",
|
||||
"app/rust-ffi",
|
||||
"build/cli",
|
||||
"build/macros/proc-macro",
|
||||
"build/ci-gen",
|
||||
@ -23,6 +23,7 @@ members = [
|
||||
"lib/rust/parser/generate-java",
|
||||
"lib/rust/parser/schema",
|
||||
"lib/rust/parser/debug",
|
||||
"lib/rust/parser/debug/fuzz",
|
||||
"tools/language-server/logstat",
|
||||
"tools/language-server/wstest",
|
||||
]
|
||||
@ -47,6 +48,11 @@ incremental = true
|
||||
debug = false
|
||||
debug-assertions = false
|
||||
|
||||
[profile.fuzz]
|
||||
inherits = "release"
|
||||
debug-assertions = true
|
||||
overflow-checks = true
|
||||
|
||||
[profile.bench]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
|
@ -8,3 +8,5 @@ ENSO_CLOUD_AMPLIFY_USER_POOL_ID=mars_AAAAAAAAA
|
||||
ENSO_CLOUD_AMPLIFY_USER_POOL_WEB_CLIENT_ID=zzzzzzzzzzzzzzzzzzzzzzzzzz
|
||||
ENSO_CLOUD_AMPLIFY_DOMAIN=somewhere.auth.mars.amazoncognito.com
|
||||
ENSO_CLOUD_AMPLIFY_REGION=mars
|
||||
ENSO_POLYGLOT_YDOC_SERVER=false
|
||||
ENSO_YDOC_LS_DEBUG=false
|
26
app/.vscode/react.code-snippets
vendored
26
app/.vscode/react.code-snippets
vendored
@ -2,9 +2,6 @@
|
||||
"React Component": {
|
||||
"prefix": ["$c", "component"],
|
||||
"body": [
|
||||
"/** @file $2 */",
|
||||
"import * as React from 'react'",
|
||||
"",
|
||||
"// ====${1/./=/g}====",
|
||||
"// === $1 ===",
|
||||
"// ====${1/./=/g}====",
|
||||
@ -18,15 +15,28 @@
|
||||
"export default function $1(props: $1Props) {",
|
||||
" const { ${3/(.+?):.+/$1, /g} } = props",
|
||||
" return <>$4</>",
|
||||
"}"
|
||||
]
|
||||
"}",
|
||||
],
|
||||
},
|
||||
"React Hook": {
|
||||
"prefix": ["$h", "hook"],
|
||||
"body": [
|
||||
"// =======${1/./=/g}====",
|
||||
"// === use$1 ===",
|
||||
"// =======${1/./=/g}====",
|
||||
"",
|
||||
"/** $2 */",
|
||||
"export function use$1($3) {",
|
||||
" $4",
|
||||
"}",
|
||||
],
|
||||
},
|
||||
"useState": {
|
||||
"prefix": ["$s", "usestate"],
|
||||
"body": ["const [$1, set${1/(.*)/${1:/pascalcase}/}] = React.useState($2)"]
|
||||
"body": ["const [$1, set${1/(.*)/${1:/pascalcase}/}] = React.useState($2)"],
|
||||
},
|
||||
"section": {
|
||||
"prefix": ["$S", "section"],
|
||||
"body": ["// ====${1/./=/g}====", "// === $1 ===", "// ====${1/./=/g}===="]
|
||||
}
|
||||
"body": ["// ====${1/./=/g}====", "// === $1 ===", "// ====${1/./=/g}===="],
|
||||
},
|
||||
}
|
||||
|
@ -6,20 +6,20 @@ Execute all commands from the parent directory.
|
||||
|
||||
```sh
|
||||
# Run tests normally
|
||||
npm run test:e2e
|
||||
pnpm run test:e2e
|
||||
# Open UI to run tests
|
||||
npm run test:e2e:debug
|
||||
pnpm run test:e2e:debug
|
||||
# Run tests in a specific file only
|
||||
npm run test:e2e -- e2e/file-name-here.spec.ts
|
||||
npm run test:e2e:debug -- e2e/file-name-here.spec.ts
|
||||
pnpm run test:e2e -- e2e/file-name-here.spec.ts
|
||||
pnpm run test:e2e:debug -- e2e/file-name-here.spec.ts
|
||||
# Compile the entire app before running the tests.
|
||||
# DOES NOT hot reload the tests.
|
||||
# Prefer not using this when you are trying to fix a test;
|
||||
# prefer using this when you just want to know which tests are failing (if any).
|
||||
PROD=1 npm run test:e2e
|
||||
PROD=1 npm run test:e2e:debug
|
||||
PROD=1 npm run test:e2e -- e2e/file-name-here.spec.ts
|
||||
PROD=1 npm run test:e2e:debug -- e2e/file-name-here.spec.ts
|
||||
PROD=1 pnpm run test:e2e
|
||||
PROD=1 pnpm run test:e2e:debug
|
||||
PROD=1 pnpm run test:e2e -- e2e/file-name-here.spec.ts
|
||||
PROD=1 pnpm run test:e2e:debug -- e2e/file-name-here.spec.ts
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
@ -225,7 +225,7 @@ export function locateNotEnabledStub(page: test.Locator | test.Page) {
|
||||
|
||||
/** Find a "new folder" icon (if any) on the current page. */
|
||||
export function locateNewFolderIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Folder' })
|
||||
return page.getByRole('button', { name: 'New Folder', exact: true })
|
||||
}
|
||||
|
||||
/** Find a "new secret" icon (if any) on the current page. */
|
||||
@ -325,7 +325,12 @@ export function locateAssetsTable(page: test.Page) {
|
||||
|
||||
/** Find assets table rows (if any) on the current page. */
|
||||
export function locateAssetRows(page: test.Page) {
|
||||
return locateAssetsTable(page).locator('tbody').getByRole('row')
|
||||
return locateAssetsTable(page).getByTestId('asset-row')
|
||||
}
|
||||
|
||||
/** Find assets table placeholder rows (if any) on the current page. */
|
||||
export function locateNonAssetRows(page: test.Page) {
|
||||
return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])')
|
||||
}
|
||||
|
||||
/** Find the name column of the given asset row. */
|
||||
|
@ -17,15 +17,6 @@ import StartModalActions from './StartModalActions'
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
|
||||
|
||||
// =======================
|
||||
// === locateAssetRows ===
|
||||
// =======================
|
||||
|
||||
/** Find all assets table rows (if any). */
|
||||
function locateAssetRows(page: test.Page) {
|
||||
return actions.locateAssetsTable(page).locator('tbody').getByRole('row')
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === DrivePageActions ===
|
||||
// ========================
|
||||
@ -97,14 +88,18 @@ export default class DrivePageActions extends PageActions {
|
||||
/** Click to select a specific row. */
|
||||
clickRow(index: number) {
|
||||
return self.step(`Click drive table row #${index}`, (page) =>
|
||||
locateAssetRows(page).nth(index).click({ position: actions.ASSET_ROW_SAFE_POSITION }),
|
||||
actions
|
||||
.locateAssetRows(page)
|
||||
.nth(index)
|
||||
.click({ position: actions.ASSET_ROW_SAFE_POSITION }),
|
||||
)
|
||||
},
|
||||
/** Right click a specific row to bring up its context menu, or the context menu for multiple
|
||||
* assets when right clicking on a selected asset when multiple assets are selected. */
|
||||
rightClickRow(index: number) {
|
||||
return self.step(`Right click drive table row #${index}`, (page) =>
|
||||
locateAssetRows(page)
|
||||
actions
|
||||
.locateAssetRows(page)
|
||||
.nth(index)
|
||||
.click({ button: 'right', position: actions.ASSET_ROW_SAFE_POSITION }),
|
||||
)
|
||||
@ -112,19 +107,24 @@ export default class DrivePageActions extends PageActions {
|
||||
/** Double click a row. */
|
||||
doubleClickRow(index: number) {
|
||||
return self.step(`Double dlick drive table row #${index}`, (page) =>
|
||||
locateAssetRows(page).nth(index).dblclick({ position: actions.ASSET_ROW_SAFE_POSITION }),
|
||||
actions
|
||||
.locateAssetRows(page)
|
||||
.nth(index)
|
||||
.dblclick({ position: actions.ASSET_ROW_SAFE_POSITION }),
|
||||
)
|
||||
},
|
||||
/** Interact with the set of all rows in the Drive table. */
|
||||
withRows(callback: baseActions.LocatorCallback) {
|
||||
withRows(
|
||||
callback: (assetRows: test.Locator, nonAssetRows: test.Locator) => Promise<void> | void,
|
||||
) {
|
||||
return self.step('Interact with drive table rows', async (page) => {
|
||||
await callback(locateAssetRows(page))
|
||||
await callback(actions.locateAssetRows(page), actions.locateNonAssetRows(page))
|
||||
})
|
||||
},
|
||||
/** Drag a row onto another row. */
|
||||
dragRowToRow(from: number, to: number) {
|
||||
return self.step(`Drag drive table row #${from} to row #${to}`, async (page) => {
|
||||
const rows = locateAssetRows(page)
|
||||
const rows = actions.locateAssetRows(page)
|
||||
await rows.nth(from).dragTo(rows.nth(to), {
|
||||
sourcePosition: ASSET_ROW_SAFE_POSITION,
|
||||
targetPosition: ASSET_ROW_SAFE_POSITION,
|
||||
@ -134,7 +134,8 @@ export default class DrivePageActions extends PageActions {
|
||||
/** Drag a row onto another row. */
|
||||
dragRow(from: number, to: test.Locator, force?: boolean) {
|
||||
return self.step(`Drag drive table row #${from} to custom locator`, (page) =>
|
||||
locateAssetRows(page)
|
||||
actions
|
||||
.locateAssetRows(page)
|
||||
.nth(from)
|
||||
.dragTo(to, {
|
||||
sourcePosition: ASSET_ROW_SAFE_POSITION,
|
||||
@ -146,18 +147,20 @@ export default class DrivePageActions extends PageActions {
|
||||
* placeholder row displayed when there are no assets to show. */
|
||||
expectPlaceholderRow() {
|
||||
return self.step('Expect placeholder row', async (page) => {
|
||||
const rows = locateAssetRows(page)
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
await test.expect(rows).toHaveText(/You have no files/)
|
||||
await test.expect(actions.locateAssetRows(page)).toHaveCount(0)
|
||||
const nonAssetRows = actions.locateNonAssetRows(page)
|
||||
await test.expect(nonAssetRows).toHaveCount(1)
|
||||
await test.expect(nonAssetRows).toHaveText(/You have no files/)
|
||||
})
|
||||
},
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
* placeholder row displayed when there are no assets in Trash. */
|
||||
expectTrashPlaceholderRow() {
|
||||
return self.step('Expect trash placeholder row', async (page) => {
|
||||
const rows = locateAssetRows(page)
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
await test.expect(rows).toHaveText(/Your trash is empty/)
|
||||
await test.expect(actions.locateAssetRows(page)).toHaveCount(0)
|
||||
const nonAssetRows = actions.locateNonAssetRows(page)
|
||||
await test.expect(nonAssetRows).toHaveCount(1)
|
||||
await test.expect(nonAssetRows).toHaveText(/Your trash is empty/)
|
||||
})
|
||||
},
|
||||
/** Toggle a column's visibility. */
|
||||
@ -226,7 +229,7 @@ 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: 'New Folder' }).click(),
|
||||
page.getByRole('button', { name: 'New Folder', exact: true }).click(),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,8 @@ import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -79,6 +81,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
isOrganizationAdmin: true,
|
||||
}
|
||||
const defaultOrganization: backend.OrganizationInfo = {
|
||||
id: defaultOrganizationId,
|
||||
@ -259,6 +262,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
isEnabled: true,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
isOrganizationAdmin: true,
|
||||
...rest,
|
||||
}
|
||||
users.push(user)
|
||||
@ -355,6 +359,67 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
await page.route('https://www.googletagmanager.com/gtag/js*', (route) =>
|
||||
route.fulfill({ contentType: 'text/javascript', body: 'export {};' }),
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (process.env.MOCK_ALL_URLS === 'true') {
|
||||
await page.route('https://fonts.googleapis.com/css2*', async (route) => {
|
||||
await route.fulfill({ contentType: 'text/css', body: '' })
|
||||
})
|
||||
await page.route('https://ensoanalytics.com/eula.json', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
path: '/eula.md',
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
size: 9472,
|
||||
modified: '2024-06-26T10:44:04.939Z',
|
||||
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
'https://api.github.com/repos/enso-org/enso/releases/latest',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: LATEST_GITHUB_RELEASES })
|
||||
},
|
||||
)
|
||||
await page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => {
|
||||
await route.fulfill({
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
status: 302,
|
||||
headers: { location: 'https://objects.githubusercontent.com/foo/bar' },
|
||||
})
|
||||
})
|
||||
await page.route('https://objects.githubusercontent.com/**', async (route) => {
|
||||
await route.fulfill({
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
status: 200,
|
||||
headers: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'content-type': 'application/octet-stream',
|
||||
'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT',
|
||||
etag: '"0x8DCAC053D058EA5"',
|
||||
server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0',
|
||||
'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000',
|
||||
'x-ms-version': '2020-10-02',
|
||||
'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT',
|
||||
'x-ms-lease-status': 'unlocked',
|
||||
'x-ms-lease-state': 'available',
|
||||
'x-ms-blob-type': 'BlockBlob',
|
||||
'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage',
|
||||
'x-ms-server-encrypted': 'true',
|
||||
via: '1.1 varnish, 1.1 varnish',
|
||||
'accept-ranges': 'bytes',
|
||||
age: '1217',
|
||||
date: 'Mon, 29 Jul 2024 09:40:09 GMT',
|
||||
'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE',
|
||||
'x-cache': 'HIT, HIT',
|
||||
'x-cache-hits': '48, 0',
|
||||
'x-timer': 'S1722246008.269342,VS0,VE895',
|
||||
'content-length': '1030383958',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
const isActuallyOnline = await page.evaluate(() => navigator.onLine)
|
||||
if (!isActuallyOnline) {
|
||||
await page.route('https://fonts.googleapis.com/*', (route) => route.abort())
|
||||
@ -733,6 +798,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
rootDirectoryId,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
isOrganizationAdmin: true,
|
||||
}
|
||||
await route.fulfill({ json: currentUser })
|
||||
})
|
||||
@ -773,12 +839,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.CreateTagRequestBody = route.request().postDataJSON()
|
||||
const json: backend.Label = {
|
||||
id: backend.TagId(`tag-${uniqueString.uniqueString()}`),
|
||||
value: backend.LabelName(body.value),
|
||||
color: body.color,
|
||||
}
|
||||
return json
|
||||
return addLabel(body.value, body.color)
|
||||
})
|
||||
await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
|
@ -36,12 +36,9 @@ test.test('labels', async ({ page }) => {
|
||||
page,
|
||||
setupAPI: (api) => {
|
||||
api.addLabel('aaaa', backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('bbbb', backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
api.addLabel('bbbb', backend.COLORS[1])
|
||||
api.addLabel('cccc', backend.COLORS[2])
|
||||
api.addLabel('dddd', backend.COLORS[3])
|
||||
},
|
||||
})
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
|
@ -92,17 +92,15 @@ test.test('can drop onto root directory dropzone', ({ page }) =>
|
||||
.createFolder()
|
||||
.uploadFile('b', 'testing')
|
||||
.driveTable.doubleClickRow(0)
|
||||
.driveTable.withRows(async (rows) => {
|
||||
.driveTable.withRows(async (rows, nonAssetRows) => {
|
||||
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(nonAssetRows.nth(0))
|
||||
test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
})
|
||||
.driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page))
|
||||
.driveTable.withRows(async (rows) => {
|
||||
const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0))
|
||||
// The second row is the indented child of the directory
|
||||
// (the "this folder is empty" row).
|
||||
const secondLeft = await actions.getAssetRowLeftPx(rows.nth(2))
|
||||
const secondLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft)
|
||||
}),
|
||||
)
|
||||
|
@ -11,12 +11,9 @@ test.test('drag labels onto single row', async ({ page }) => {
|
||||
page,
|
||||
setupAPI: (api) => {
|
||||
api.addLabel(label, backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('bbbb', backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
api.addLabel('bbbb', backend.COLORS[1])
|
||||
api.addLabel('cccc', backend.COLORS[2])
|
||||
api.addLabel('dddd', backend.COLORS[3])
|
||||
api.addDirectory('foo')
|
||||
api.addSecret('bar')
|
||||
api.addFile('baz')
|
||||
@ -41,12 +38,9 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
|
||||
page,
|
||||
setupAPI: (api) => {
|
||||
api.addLabel(label, backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('bbbb', backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
api.addLabel('bbbb', backend.COLORS[1])
|
||||
api.addLabel('cccc', backend.COLORS[2])
|
||||
api.addLabel('dddd', backend.COLORS[3])
|
||||
api.addDirectory('foo')
|
||||
api.addSecret('bar')
|
||||
api.addFile('baz')
|
||||
@ -57,6 +51,7 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
|
||||
const labelEl = actions.locateLabelsPanelLabels(page, label)
|
||||
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
await test.expect(assetRows).toHaveCount(4)
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.clickAssetRow(assetRows.nth(2))
|
||||
await test.expect(labelEl).toBeVisible()
|
||||
|
1106
app/dashboard/e2e/latestGithubReleases.json
Normal file
1106
app/dashboard/e2e/latestGithubReleases.json
Normal file
File diff suppressed because one or more lines are too long
@ -8,6 +8,10 @@ test.test('page switcher', ({ page }) =>
|
||||
.mockAllAndLogin({ page })
|
||||
// Create a new project so that the editor page can be switched to.
|
||||
.newEmptyProject()
|
||||
.do(async (thePage) => {
|
||||
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
|
||||
await test.expect(actions.locateEditor(thePage)).toBeVisible()
|
||||
})
|
||||
.goToPage.drive()
|
||||
.do(async (thePage) => {
|
||||
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
|
||||
|
@ -1,123 +0,0 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import type * as api from './api'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const EMAIL = 'example.email+1234@testing.org'
|
||||
const NAME = 'a custom user name'
|
||||
const ORGANIZATION_ID = 'some testing organization id'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
// Note: This does not check that the organization ID is sent in the correct format for the backend.
|
||||
// It only checks that the organization ID is sent in certain places.
|
||||
test.test('sign up with organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.goto(
|
||||
'/registration?' + new URLSearchParams([['organization_id', ORGANIZATION_ID]]).toString(),
|
||||
)
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateRegisterButton(page).click()
|
||||
|
||||
// Log in
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Set username
|
||||
await actions.locateUsernameInput(page).fill('arbitrary username')
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
test
|
||||
.expect(api.currentUser()?.organizationId, 'new user has correct organization id')
|
||||
.toBe(ORGANIZATION_ID)
|
||||
})
|
||||
|
||||
test.test('sign up without organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.goto('/registration')
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateRegisterButton(page).click()
|
||||
|
||||
// Log in
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Set username
|
||||
await actions.locateUsernameInput(page).fill('arbitrary username')
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
test
|
||||
.expect(api.currentUser()?.organizationId, 'new user has correct organization id')
|
||||
.toBe(api.defaultOrganizationId)
|
||||
})
|
||||
|
||||
test.test('sign up flow', ({ page }) => {
|
||||
let api!: api.MockApi
|
||||
return actions
|
||||
.mockAll({
|
||||
page,
|
||||
setupAPI: (theApi) => {
|
||||
api = theApi
|
||||
theApi.setCurrentUser(null)
|
||||
|
||||
// These values should be different, otherwise the email and name may come from the defaults.
|
||||
test.expect(EMAIL).not.toStrictEqual(theApi.defaultEmail)
|
||||
test.expect(NAME).not.toStrictEqual(theApi.defaultName)
|
||||
},
|
||||
})
|
||||
.loginAsNewUser(EMAIL, actions.VALID_PASSWORD)
|
||||
.do(async (thePage) => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
})
|
||||
.setUsername(NAME)
|
||||
.do(async (thePage) => {
|
||||
await test.expect(actions.locateUpgradeButton(thePage)).toBeVisible()
|
||||
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
|
||||
})
|
||||
.do(() => {
|
||||
// Logged in, and account enabled
|
||||
const currentUser = api.currentUser()
|
||||
test.expect(currentUser).toBeDefined()
|
||||
if (currentUser != null) {
|
||||
// This is required because `UserOrOrganization` is `readonly`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, no-extra-semi
|
||||
;(currentUser as { isEnabled: boolean }).isEnabled = true
|
||||
}
|
||||
})
|
||||
.openUserMenu()
|
||||
.userMenu.logout()
|
||||
.login(EMAIL, actions.VALID_PASSWORD)
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateNotEnabledStub(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
})
|
||||
.do(() => {
|
||||
test.expect(api.currentUser()?.email, 'new user has correct email').toBe(EMAIL)
|
||||
test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME)
|
||||
})
|
||||
})
|
@ -15,16 +15,17 @@
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"dev": "vite",
|
||||
"dev:e2e": "vite -c vite.test.config.ts",
|
||||
"dev:e2e:ci": "vite -c vite.test.config.ts build && vite preview --port 8080 --strictPort",
|
||||
"test": "npm run test:unit && npm run test:e2e",
|
||||
"test": "corepack pnpm run /^^^^test:.*/",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:debug": "vitest",
|
||||
"test-dev:unit": "vitest",
|
||||
"test:e2e": "cross-env NODE_ENV=production playwright test",
|
||||
"test:e2e:debug": "cross-env NODE_ENV=production playwright test --ui"
|
||||
"test-dev:e2e": "cross-env NODE_ENV=production playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-amplify/auth": "5.6.5",
|
||||
@ -57,7 +58,8 @@
|
||||
"ts-results": "^3.3.0",
|
||||
"validator": "^13.12.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.4"
|
||||
"zustand": "^4.5.4",
|
||||
"framer-motion": "11.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fast-check/vitest": "^0.0.8",
|
||||
@ -88,7 +90,7 @@
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tailwindcss-react-aria-components": "^1.1.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vite": "^5.3.5",
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
|
@ -57,7 +57,7 @@ export default test.defineConfig({
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
command: process.env.CI || process.env.PROD ? 'npm run dev:e2e:ci' : 'npm run dev:e2e',
|
||||
command: `corepack pnpm run ${process.env.CI || process.env.PROD ? 'dev:e2e:ci' : 'dev:e2e'}`,
|
||||
port: 8080,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
|
@ -38,6 +38,7 @@ import * as React from 'react'
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as router from 'react-router-dom'
|
||||
import * as toastify from 'react-toastify'
|
||||
import * as z from 'zod'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
@ -45,16 +46,14 @@ import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider, { useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import DriveProvider from '#/providers/DriveProvider'
|
||||
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
|
||||
import * as httpClientProvider from '#/providers/HttpClientProvider'
|
||||
import { useHttpClient } from '#/providers/HttpClientProvider'
|
||||
import InputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import { useLogger } from '#/providers/LoggerProvider'
|
||||
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
@ -66,7 +65,7 @@ import Login from '#/pages/authentication/Login'
|
||||
import Registration from '#/pages/authentication/Registration'
|
||||
import ResetPassword from '#/pages/authentication/ResetPassword'
|
||||
import RestoreAccount from '#/pages/authentication/RestoreAccount'
|
||||
import SetUsername from '#/pages/authentication/SetUsername'
|
||||
import * as setup from '#/pages/authentication/Setup'
|
||||
import Dashboard from '#/pages/dashboard/Dashboard'
|
||||
import * as subscribe from '#/pages/subscribe/Subscribe'
|
||||
import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
|
||||
@ -75,10 +74,9 @@ import type * as editor from '#/layouts/Editor'
|
||||
import * as openAppWatcher from '#/layouts/OpenAppWatcher'
|
||||
import VersionChecker from '#/layouts/VersionChecker'
|
||||
|
||||
import { RouterProvider } from '#/components/aria'
|
||||
import * as devtools from '#/components/Devtools'
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as offlineNotificationManager from '#/components/OfflineNotificationManager'
|
||||
import * as rootComponent from '#/components/Root'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
import AboutModal from '#/modals/AboutModal'
|
||||
@ -91,11 +89,10 @@ import RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import type HttpClient from '#/utilities/HttpClient'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
import { useInitAuthService } from '#/authentication/service'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
@ -109,17 +106,16 @@ declare module '#/utilities/LocalStorage' {
|
||||
}
|
||||
|
||||
LocalStorage.registerKey('inputBindings', {
|
||||
tryParse: (value) =>
|
||||
typeof value !== 'object' || value == null ?
|
||||
null
|
||||
: Object.fromEntries(
|
||||
Object.entries<unknown>({ ...value }).flatMap((kv) => {
|
||||
const [k, v] = kv
|
||||
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string') ?
|
||||
[[k, v]]
|
||||
: []
|
||||
}),
|
||||
),
|
||||
schema: z.record(z.string().array().readonly()).transform((value) =>
|
||||
Object.fromEntries(
|
||||
Object.entries<unknown>({ ...value }).flatMap((kv) => {
|
||||
const [k, v] = kv
|
||||
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string') ?
|
||||
[[k, v]]
|
||||
: []
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
// ======================
|
||||
@ -140,7 +136,6 @@ function getMainPageUrl() {
|
||||
/** Global configuration for the `App` component. */
|
||||
export interface AppProps {
|
||||
readonly vibrancy: boolean
|
||||
readonly logger: loggerProvider.Logger
|
||||
/** Whether the application may have the local backend running. */
|
||||
readonly supportsLocalBackend: boolean
|
||||
/** If true, the app can only be used in offline mode. */
|
||||
@ -156,8 +151,6 @@ export interface AppProps {
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
readonly appRunner: editor.GraphEditorRunner | null
|
||||
readonly portalRoot: Element
|
||||
readonly httpClient: HttpClient
|
||||
readonly queryClient: reactQuery.QueryClient
|
||||
}
|
||||
|
||||
@ -261,12 +254,10 @@ export interface AppRouterProps extends AppProps {
|
||||
* because the {@link AppRouter} relies on React hooks, which can't be used in the same React
|
||||
* component as the component that defines the provider. */
|
||||
function AppRouter(props: AppRouterProps) {
|
||||
const { logger, isAuthenticationDisabled, shouldShowDashboard, httpClient } = props
|
||||
const { isAuthenticationDisabled, shouldShowDashboard } = props
|
||||
const { onAuthenticated, projectManagerInstance } = props
|
||||
const { portalRoot } = props
|
||||
// `navigateHooks.useNavigate` cannot be used here as it relies on `AuthProvider`, which has not
|
||||
// yet been initialized at this point.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
const httpClient = useHttpClient()
|
||||
const logger = useLogger()
|
||||
const navigate = router.useNavigate()
|
||||
const { getText } = textProvider.useText()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
@ -355,14 +346,8 @@ function AppRouter(props: AppRouterProps) {
|
||||
},
|
||||
}
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
|
||||
const authService = React.useMemo(() => {
|
||||
const authConfig = { navigate, ...props }
|
||||
return authServiceModule.initAuthService(authConfig)
|
||||
}, [props, navigate])
|
||||
|
||||
const authService = useInitAuthService(props)
|
||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||
const refreshUserSession =
|
||||
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null
|
||||
@ -435,57 +420,44 @@ function AppRouter(props: AppRouterProps) {
|
||||
{/* Protected pages are visible to authenticated users. */}
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route element={<authProvider.ProtectedLayout />}>
|
||||
<router.Route
|
||||
element={
|
||||
detect.IS_DEV_MODE ?
|
||||
<devtools.EnsoDevtools>
|
||||
<router.Outlet />
|
||||
</devtools.EnsoDevtools>
|
||||
: null
|
||||
}
|
||||
>
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
|
||||
<router.Route
|
||||
path={appUtils.DASHBOARD_PATH}
|
||||
element={shouldShowDashboard && <Dashboard {...props} />}
|
||||
/>
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
|
||||
<router.Route
|
||||
path={appUtils.DASHBOARD_PATH}
|
||||
element={shouldShowDashboard && <Dashboard {...props} />}
|
||||
/>
|
||||
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribe.Subscribe />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribe.Subscribe />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribeSuccess.SubscribeSuccess />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribeSuccess.SubscribeSuccess />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
{/* Semi-protected pages are visible to users currently registering. */}
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route element={<authProvider.SemiProtectedLayout />}>
|
||||
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
|
||||
</router.Route>
|
||||
<router.Route path={appUtils.SETUP_PATH} element={<setup.Setup />} />
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
@ -506,76 +478,39 @@ function AppRouter(props: AppRouterProps) {
|
||||
</router.Routes>
|
||||
)
|
||||
|
||||
let result = (
|
||||
<>
|
||||
<MutationListener />
|
||||
<VersionChecker />
|
||||
{routes}
|
||||
</>
|
||||
return (
|
||||
<DevtoolsProvider>
|
||||
<RouterProvider navigate={navigate}>
|
||||
<SessionProvider
|
||||
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
refreshUserSession={refreshUserSession}
|
||||
>
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
<InputBindingsProvider inputBindings={inputBindings}>
|
||||
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
|
||||
* due to modals being in `TheModal`. */}
|
||||
<DriveProvider>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<VersionChecker />
|
||||
{routes}
|
||||
<suspense.Suspense>
|
||||
<devtools.EnsoDevtools />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</DriveProvider>
|
||||
</InputBindingsProvider>
|
||||
</AuthProvider>
|
||||
</BackendProvider>
|
||||
</SessionProvider>
|
||||
</RouterProvider>
|
||||
</DevtoolsProvider>
|
||||
)
|
||||
|
||||
result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary>
|
||||
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
|
||||
result = (
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
{result}
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
result = (
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
{result}
|
||||
</BackendProvider>
|
||||
)
|
||||
|
||||
result = (
|
||||
<SessionProvider
|
||||
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
refreshUserSession={refreshUserSession}
|
||||
>
|
||||
{result}
|
||||
</SessionProvider>
|
||||
)
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
result = (
|
||||
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
|
||||
{result}
|
||||
</rootComponent.Root>
|
||||
)
|
||||
result = (
|
||||
<offlineNotificationManager.OfflineNotificationManager>
|
||||
{result}
|
||||
</offlineNotificationManager.OfflineNotificationManager>
|
||||
)
|
||||
result = (
|
||||
<httpClientProvider.HttpClientProvider httpClient={httpClient}>
|
||||
{result}
|
||||
</httpClientProvider.HttpClientProvider>
|
||||
)
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
result = <DevtoolsProvider>{result}</DevtoolsProvider>
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === MutationListener ===
|
||||
// ========================
|
||||
|
||||
/** A component that applies state updates for successful mutations. */
|
||||
function MutationListener() {
|
||||
const remoteBackend = useRemoteBackend()
|
||||
const localBackend = useLocalBackend()
|
||||
|
||||
backendHooks.useObserveBackend(remoteBackend)
|
||||
backendHooks.useObserveBackend(localBackend)
|
||||
|
||||
return null
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ export const LOGIN_PATH = '/login'
|
||||
export const REGISTRATION_PATH = '/registration'
|
||||
/** Path to the confirm registration page. */
|
||||
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
|
||||
export const SETUP_PATH = '/setup'
|
||||
/** Path to the page in which a user can restore their account after it has been
|
||||
* marked for deletion. */
|
||||
export const RESTORE_USER_PATH = '/restore-user'
|
||||
@ -22,7 +23,6 @@ export const FORGOT_PASSWORD_PATH = '/forgot-password'
|
||||
/** Path to the reset password page. */
|
||||
export const RESET_PASSWORD_PATH = '/password-reset'
|
||||
/** Path to the set username page. */
|
||||
export const SET_USERNAME_PATH = '/set-username'
|
||||
/** Path to the offline mode entrypoint. */
|
||||
/** Path to page in which the currently active payment plan can be managed. */
|
||||
export const SUBSCRIBE_PATH = '/subscribe'
|
||||
@ -30,8 +30,8 @@ export const SUBSCRIBE_SUCCESS_PATH = '/subscribe/success'
|
||||
/** A {@link RegExp} matching all paths. */
|
||||
export const ALL_PATHS_REGEX = new RegExp(
|
||||
`(?:${DASHBOARD_PATH}|${LOGIN_PATH}|${REGISTRATION_PATH}|${CONFIRM_REGISTRATION_PATH}|` +
|
||||
`${FORGOT_PASSWORD_PATH}|${RESET_PASSWORD_PATH}|${SET_USERNAME_PATH}|${RESTORE_USER_PATH}|` +
|
||||
`${SUBSCRIBE_PATH}|${SUBSCRIBE_SUCCESS_PATH})$`,
|
||||
`${FORGOT_PASSWORD_PATH}|${RESET_PASSWORD_PATH}|${RESTORE_USER_PATH}|` +
|
||||
`${SUBSCRIBE_PATH}|${SUBSCRIBE_SUCCESS_PATH}|${SETUP_PATH})$`,
|
||||
)
|
||||
|
||||
// === Constants related to URLs ===
|
||||
@ -45,6 +45,13 @@ export function getUpgradeURL(plan: string): string {
|
||||
return SUBSCRIBE_PATH + '?plan=' + plan
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the mailto URL for contacting sales.
|
||||
*/
|
||||
export function getSalesEmail(): string {
|
||||
return 'mailto:contact@enso.org'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Subscription URL for contacting sales.
|
||||
*/
|
||||
|
7
app/dashboard/src/assets/close_tab.svg
Normal file
7
app/dashboard/src/assets/close_tab.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.3" d="M2 8C2 5.79086 3.79086 4 6 4H10C12.2091 4 14 5.79086 14 8V12H2V8Z" fill="black"/>
|
||||
<rect x="14.4102" y="1" width="2" height="7.65112" rx="1" transform="rotate(45 14.4102 1)" fill="black"/>
|
||||
<rect x="15.8224" y="6.41211" width="2" height="7.64835" rx="1" transform="rotate(135 15.8224 6.41211)" fill="black"/>
|
||||
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M15 10.7655C14.4153 10.1518 14 9.26279 14 8V12H15V10.7655Z" fill="black"/>
|
||||
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M1 10.7655C1.58473 10.1518 2 9.26279 2 8V12H1V10.7655Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 705 B |
@ -1,14 +1,17 @@
|
||||
/** @file Provides an {@link AuthService} which consists of an underyling `Cognito` API
|
||||
* wrapper, along with some convenience callbacks to make URL redirects for the authentication flows
|
||||
* work with Electron. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as amplify from '@aws-amplify/auth'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import * as common from 'enso-common'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import { useLogger, type Logger } from '#/providers/LoggerProvider'
|
||||
|
||||
import type * as saveAccessTokenModule from '#/utilities/accessToken'
|
||||
|
||||
@ -90,16 +93,9 @@ export function toNestedAmplifyConfig(config: AmplifyConfig): NestedAmplifyConfi
|
||||
|
||||
/** Configuration for the authentication service. */
|
||||
export interface AuthConfig {
|
||||
/** Logger for the authentication service. */
|
||||
readonly logger: loggerProvider.Logger
|
||||
/** Whether the application supports deep links. This is only true when using
|
||||
* the installed app on macOS and Windows. */
|
||||
readonly supportsDeepLinks: boolean
|
||||
/** Function to navigate to a given (relative) URL.
|
||||
*
|
||||
* Used to redirect to pages like the password reset page with the query parameters set in the
|
||||
* URL (e.g., `?verification_code=...`). */
|
||||
readonly navigate: (url: string) => void
|
||||
}
|
||||
|
||||
// ===================
|
||||
@ -118,24 +114,28 @@ export interface AuthService {
|
||||
*
|
||||
* # Warning
|
||||
*
|
||||
* This function should only be called once, and the returned service should be used throughout the
|
||||
* application. This is because it performs global configuration of the Amplify library. */
|
||||
export function initAuthService(authConfig: AuthConfig): AuthService | null {
|
||||
const { logger, supportsDeepLinks, navigate } = authConfig
|
||||
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
|
||||
const cognito =
|
||||
amplifyConfig == null ? null : (
|
||||
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
|
||||
)
|
||||
* 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 {
|
||||
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)
|
||||
)
|
||||
|
||||
return cognito == null ? null : (
|
||||
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
|
||||
)
|
||||
return cognito == null ? null : (
|
||||
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
|
||||
)
|
||||
}, [logger, navigate, supportsDeepLinks])
|
||||
}
|
||||
|
||||
/** Return the appropriate Amplify configuration for the current platform. */
|
||||
function loadAmplifyConfig(
|
||||
logger: loggerProvider.Logger,
|
||||
logger: Logger,
|
||||
supportsDeepLinks: boolean,
|
||||
navigate: (url: string) => void,
|
||||
): AmplifyConfig | null {
|
||||
@ -213,7 +213,7 @@ function loadAmplifyConfig(
|
||||
*
|
||||
* All URLs that don't have a pathname that starts with `AUTHENTICATION_PATHNAME_BASE` will be
|
||||
* ignored by this handler. */
|
||||
function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: string) => void) {
|
||||
function setDeepLinkHandler(logger: Logger, navigate: (url: string) => void) {
|
||||
window.authenticationApi.setDeepLinkHandler((urlString: string) => {
|
||||
const url = new URL(urlString)
|
||||
logger.log(`Parsed pathname: ${url.pathname}`)
|
||||
|
@ -67,6 +67,7 @@ export interface BaseButtonProps<Render>
|
||||
/** Defaults to `full`. When `full`, the entire button will be replaced with the loader.
|
||||
* When `icon`, only the icon will be replaced with the loader. */
|
||||
readonly loaderPosition?: 'full' | 'icon'
|
||||
readonly styles?: typeof BUTTON_STYLES
|
||||
}
|
||||
|
||||
export const BUTTON_STYLES = twv.tv({
|
||||
@ -194,7 +195,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
delete:
|
||||
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
|
||||
icon: {
|
||||
base: 'opacity-80 hover:opacity-100 focus-visible:opacity-100',
|
||||
base: 'text-primary opacity-80 hover:opacity-100 focus-visible:opacity-100',
|
||||
wrapper: 'w-full h-full',
|
||||
content: 'w-full h-full',
|
||||
extraClickZone: 'w-full h-full',
|
||||
@ -251,7 +252,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
isActive: 'none',
|
||||
loading: false,
|
||||
fullWidth: false,
|
||||
size: 'xsmall',
|
||||
size: 'medium',
|
||||
rounded: 'full',
|
||||
variant: 'primary',
|
||||
iconPosition: 'start',
|
||||
|
@ -45,11 +45,11 @@ const MODAL_STYLES = twv.tv({
|
||||
|
||||
const DIALOG_STYLES = twv.tv({
|
||||
extend: variants.DIALOG_STYLES,
|
||||
base: '',
|
||||
base: 'w-full',
|
||||
variants: {
|
||||
type: {
|
||||
modal: {
|
||||
base: 'w-full max-w-md min-h-[100px] max-h-[90vh]',
|
||||
base: 'w-full min-h-[100px] max-h-[90vh]',
|
||||
header: 'px-3.5 pt-[3px] pb-0.5 min-h-[42px]',
|
||||
},
|
||||
fullscreen: {
|
||||
@ -63,10 +63,23 @@ const DIALOG_STYLES = twv.tv({
|
||||
floating: {
|
||||
base: '',
|
||||
closeButton: 'absolute left-4 top-4 visible z-1 transition-all duration-150',
|
||||
header: 'invisible p-0 h-0 border-0 z-1',
|
||||
header: 'p-0 max-h-0 min-h-0 h-0 border-0 z-1',
|
||||
content: 'isolate',
|
||||
},
|
||||
},
|
||||
/**
|
||||
* The size of the dialog.
|
||||
* Only applies to the `modal` type.
|
||||
*/
|
||||
size: {
|
||||
small: { base: '' },
|
||||
medium: { base: '' },
|
||||
large: { base: '' },
|
||||
xlarge: { base: '' },
|
||||
xxlarge: { base: '' },
|
||||
xxxlarge: { base: '' },
|
||||
xxxxlarge: { base: '' },
|
||||
},
|
||||
scrolledToTop: { true: { header: 'border-transparent' } },
|
||||
},
|
||||
slots: {
|
||||
@ -76,6 +89,21 @@ const DIALOG_STYLES = twv.tv({
|
||||
heading: 'col-start-2 col-end-2 my-0 text-center',
|
||||
content: 'relative flex-auto overflow-y-auto p-3.5',
|
||||
},
|
||||
compoundVariants: [
|
||||
{ type: 'modal', size: 'small', class: 'max-w-sm' },
|
||||
{ type: 'modal', size: 'medium', class: 'max-w-md' },
|
||||
{ type: 'modal', size: 'large', class: 'max-w-lg' },
|
||||
{ type: 'modal', size: 'xlarge', class: 'max-w-xl' },
|
||||
{ type: 'modal', size: 'xxlarge', class: 'max-w-2xl' },
|
||||
{ type: 'modal', size: 'xxxlarge', class: 'max-w-3xl' },
|
||||
{ type: 'modal', size: 'xxxxlarge', class: 'max-w-4xl' },
|
||||
],
|
||||
defaultVariants: {
|
||||
type: 'modal',
|
||||
closeButton: 'normal',
|
||||
hideCloseButton: false,
|
||||
size: 'medium',
|
||||
},
|
||||
})
|
||||
|
||||
// ==============
|
||||
@ -97,6 +125,7 @@ export function Dialog(props: DialogProps) {
|
||||
onOpenChange = () => {},
|
||||
modalProps = {},
|
||||
testId = 'dialog',
|
||||
size,
|
||||
rounded,
|
||||
...ariaDialogProps
|
||||
} = props
|
||||
@ -126,6 +155,7 @@ export function Dialog(props: DialogProps) {
|
||||
hideCloseButton,
|
||||
closeButton,
|
||||
scrolledToTop: isScrolledToTop,
|
||||
size,
|
||||
})
|
||||
|
||||
utlities.useInteractOutside({
|
||||
|
@ -7,15 +7,8 @@ import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
export const DIALOG_BACKGROUND = twv.tv({
|
||||
base: 'backdrop-blur-md',
|
||||
variants: {
|
||||
variant: {
|
||||
light: 'bg-white/80',
|
||||
dark: 'bg-primary/80',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'light',
|
||||
},
|
||||
variants: { variant: { light: 'bg-white/80', dark: 'bg-primary/80' } },
|
||||
defaultVariants: { variant: 'light' },
|
||||
})
|
||||
|
||||
export const DIALOG_STYLES = twv.tv({
|
||||
@ -24,12 +17,13 @@ export const DIALOG_STYLES = twv.tv({
|
||||
variants: {
|
||||
rounded: {
|
||||
none: '',
|
||||
small: 'rounded-sm before:rounded-sm',
|
||||
medium: 'rounded-md before:rounded-md',
|
||||
large: 'rounded-lg before:rounded-lg',
|
||||
xlarge: 'rounded-xl before:rounded-xl',
|
||||
xxlarge: 'rounded-2xl before:rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl before:rounded-3xl',
|
||||
small: 'rounded-sm',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
xlarge: 'rounded-xl',
|
||||
xxlarge: 'rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl',
|
||||
xxxxlarge: 'rounded-4xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
@ -20,9 +20,7 @@ import type * as types from './types'
|
||||
|
||||
/** Form component. It wraps a `form` and provides form context.
|
||||
* It also handles form submission.
|
||||
* Provides better error handling and form state management and better UX out of the box.
|
||||
*
|
||||
* ## Component is in BETA and will be improved in the future. */
|
||||
* Provides better error handling and form state management and better UX out of the box. */
|
||||
// There is no way to avoid type casting here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Form = React.forwardRef(function Form<
|
||||
@ -110,7 +108,6 @@ export const Form = React.forwardRef(function Form<
|
||||
},
|
||||
onError: onSubmitFailed,
|
||||
onSuccess: onSubmitSuccess,
|
||||
onMutate: onSubmitted,
|
||||
onSettled: onSubmitted,
|
||||
})
|
||||
|
||||
|
@ -23,9 +23,10 @@ export interface FieldComponentProps
|
||||
readonly name: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly form?: types.FormInstance<any, any, any>
|
||||
readonly isInvalid?: boolean
|
||||
readonly isInvalid?: boolean | undefined
|
||||
readonly className?: string | undefined
|
||||
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
|
||||
readonly style?: React.CSSProperties | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,7 +48,7 @@ export const FIELD_STYLES = twv.tv({
|
||||
},
|
||||
slots: {
|
||||
labelContainer: 'contents',
|
||||
label: text.TEXT_STYLE({ variant: 'subtitle' }),
|
||||
label: text.TEXT_STYLE({ variant: 'body', disableLineHeightCompensation: true }),
|
||||
content: 'flex flex-col items-start w-full',
|
||||
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
|
||||
error: text.TEXT_STYLE({ variant: 'body', color: 'danger' }),
|
||||
@ -106,6 +107,13 @@ export const Field = React.forwardRef(function Field(
|
||||
{label != null && (
|
||||
<span id={labelId} className={classes.label()}>
|
||||
{label}
|
||||
|
||||
{isRequired && (
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
<span aria-hidden="true" className="scale-80 text-danger">
|
||||
{' *'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
@ -56,7 +56,6 @@ export function Submit(props: SubmitProps): React.JSX.Element {
|
||||
formnovalidate = false,
|
||||
loading = false,
|
||||
children,
|
||||
rounded = 'large',
|
||||
...buttonProps
|
||||
} = props
|
||||
|
||||
@ -72,7 +71,6 @@ export function Submit(props: SubmitProps): React.JSX.Element {
|
||||
/* This is safe because we are passing all props to the button */
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
|
||||
{...(buttonProps as any)}
|
||||
rounded={rounded}
|
||||
type={type}
|
||||
variant={variant}
|
||||
size={size}
|
||||
|
@ -7,8 +7,6 @@ import type * as React from 'react'
|
||||
import type * as reactHookForm from 'react-hook-form'
|
||||
import type * as z from 'zod'
|
||||
|
||||
import type * as aria from '#/components/aria'
|
||||
|
||||
import type * as schemaModule from './schema'
|
||||
|
||||
/**
|
||||
@ -47,11 +45,11 @@ export interface UseFormProps<Schema extends TSchema, TFieldValues extends Field
|
||||
* Return type of the useForm hook.
|
||||
* @alias reactHookForm.UseFormReturn
|
||||
*/
|
||||
export type UseFormReturn<
|
||||
export interface UseFormReturn<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TTransformedValues extends Record<string, unknown> | undefined = undefined,
|
||||
> = reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues>
|
||||
> extends reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues> {}
|
||||
|
||||
/**
|
||||
* Form state type.
|
||||
@ -68,7 +66,7 @@ export type FormState<
|
||||
*/
|
||||
export type FormInstance<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldValues extends FieldValues<Schema> = FieldValues<Schema>,
|
||||
TTransformedValues extends Record<string, unknown> | undefined = undefined,
|
||||
> = UseFormReturn<Schema, TFieldValues, TTransformedValues>
|
||||
|
||||
@ -81,20 +79,52 @@ export interface FormWithValueValidation<
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
ErrorType = [
|
||||
'Type mismatch: Expected',
|
||||
TFieldValues[TFieldName],
|
||||
'got',
|
||||
BaseValueType,
|
||||
'instead.',
|
||||
],
|
||||
> {
|
||||
readonly form?:
|
||||
| (BaseValueType extends TFieldValues[TFieldName] ?
|
||||
FormInstance<Schema, TFieldValues, TTransformedValues>
|
||||
: 'Type mismatch: Field with this name has a different type than the value of the component.')
|
||||
: ErrorType)
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the Field component.
|
||||
*/
|
||||
export interface FieldProps extends aria.AriaLabelingProps {
|
||||
readonly isRequired?: boolean
|
||||
readonly label?: React.ReactNode
|
||||
readonly description?: React.ReactNode
|
||||
readonly error?: React.ReactNode
|
||||
// Readonly omitted here to avoid type mismatch with native HTML attributes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export interface FieldProps {
|
||||
readonly isRequired?: boolean | undefined
|
||||
readonly label?: React.ReactNode | undefined
|
||||
readonly description?: React.ReactNode | undefined
|
||||
readonly error?: React.ReactNode | undefined
|
||||
/**
|
||||
* Defines a string value that labels the current element.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'aria-label'?: string | undefined
|
||||
|
||||
/**
|
||||
* Identifies the element (or elements) that labels the current element.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'aria-labelledby'?: string | undefined
|
||||
|
||||
/**
|
||||
* Identifies the element (or elements) that describes the object.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'aria-describedby'?: string | undefined
|
||||
|
||||
/**
|
||||
* Identifies the element (or elements) that provide a detailed, extended description for the object.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'aria-details'?: string | undefined
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ export interface UseFieldOptions<
|
||||
TTransformedValues
|
||||
> {
|
||||
readonly name: TFieldName
|
||||
readonly isDisabled?: boolean
|
||||
readonly isDisabled?: boolean | undefined
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly defaultValue?: TFieldValues[TFieldName] | undefined
|
||||
}
|
||||
|
@ -28,8 +28,7 @@ import type * as types from './types'
|
||||
*/
|
||||
export function useForm<
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TFieldValues extends types.FieldValues<Schema> = types.FieldValues<Schema>,
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
optionsOrFormInstance:
|
||||
@ -53,7 +52,6 @@ export function useForm<
|
||||
} else {
|
||||
const { schema, ...options } = optionsOrFormInstance
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
||||
|
||||
return reactHookForm.useForm<TFieldValues, unknown, TTransformedValues>({
|
||||
@ -69,7 +67,6 @@ export function useForm<
|
||||
function getArgsType<
|
||||
Schema extends types.TSchema,
|
||||
TFieldValues extends types.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
args:
|
||||
|
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Basic input component. Input component is a component that is used to get user input in a text field.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as twv from 'tailwind-variants'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
|
||||
import { omit } from 'enso-common/src/utilities/data/object'
|
||||
import * as variants from '../variants'
|
||||
|
||||
/**
|
||||
* Props for the Input component.
|
||||
*/
|
||||
export interface InputProps<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
> extends ariaComponents.FieldStateProps<
|
||||
Omit<aria.InputProps, 'children' | 'size'>,
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
>,
|
||||
ariaComponents.FieldProps,
|
||||
Omit<twv.VariantProps<typeof variants.INPUT_STYLES>, 'disabled' | 'invalid'> {
|
||||
readonly className?: string
|
||||
readonly style?: React.CSSProperties
|
||||
readonly inputRef?: React.Ref<HTMLInputElement>
|
||||
readonly addonStart?: React.ReactNode
|
||||
readonly addonEnd?: React.ReactNode
|
||||
readonly placeholder?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic input component. Input component is a component that is used to get user input in a text field.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Input = React.forwardRef(function Input<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
||||
) {
|
||||
const {
|
||||
name,
|
||||
isDisabled = false,
|
||||
form,
|
||||
defaultValue,
|
||||
description,
|
||||
inputRef,
|
||||
addonStart,
|
||||
addonEnd,
|
||||
label,
|
||||
size,
|
||||
rounded,
|
||||
isRequired = false,
|
||||
min,
|
||||
max,
|
||||
type = 'text',
|
||||
...inputProps
|
||||
} = props
|
||||
|
||||
const privateInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const { fieldState, formInstance } = ariaComponents.Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const classes = variants.INPUT_STYLES({
|
||||
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) => {
|
||||
if (typeof value === 'string') {
|
||||
if (type === 'number') {
|
||||
return Number(value)
|
||||
} else if (type === 'date') {
|
||||
return new Date(value)
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return value
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<ariaComponents.Form.Field
|
||||
form={formInstance}
|
||||
name={name}
|
||||
fullWidth
|
||||
label={label}
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
isRequired={field.required}
|
||||
isInvalid={fieldState.invalid}
|
||||
aria-details={props['aria-details']}
|
||||
ref={ref}
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
>
|
||||
<div
|
||||
className={classes.base()}
|
||||
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<div className={classes.inputContainer()}>
|
||||
{addonStart != null && <div className={classes.addonStart()}>{addonStart}</div>}
|
||||
|
||||
<aria.Input
|
||||
ref={mergeRefs.mergeRefs(inputRef, privateInputRef, fieldRef)}
|
||||
{...aria.mergeProps<aria.InputProps>()(
|
||||
{ className: classes.textArea(), type, name, min, max, isRequired, isDisabled },
|
||||
inputProps,
|
||||
omit(field, 'required', 'disabled'),
|
||||
)}
|
||||
/>
|
||||
|
||||
{addonEnd != null && <div className={classes.addonEnd()}>{addonEnd}</div>}
|
||||
</div>
|
||||
|
||||
{description != null && (
|
||||
<ariaComponents.Text slot="description" className={classes.description()}>
|
||||
{description}
|
||||
</ariaComponents.Text>
|
||||
)}
|
||||
</div>
|
||||
</ariaComponents.Form.Field>
|
||||
)
|
||||
}) as <
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: InputProps<Schema, TFieldValues, TFieldName, TTransformedValues> &
|
||||
React.RefAttributes<HTMLInputElement>,
|
||||
) => React.ReactElement
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for Input component.
|
||||
*/
|
||||
export * from './Input'
|
@ -10,10 +10,10 @@ import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import * as varants from './variants'
|
||||
import * as variants from '../variants'
|
||||
|
||||
const CONTENT_EDITABLE_STYLES = twv.tv({
|
||||
extend: varants.INPUT_STYLES,
|
||||
extend: variants.INPUT_STYLES,
|
||||
base: '',
|
||||
slots: { placeholder: 'opacity-50 absolute inset-0 pointer-events-none' },
|
||||
})
|
||||
@ -25,19 +25,16 @@ export interface ResizableContentEditableInputProps<
|
||||
Schema extends ariaComponents.TSchema,
|
||||
TFieldValues extends ariaComponents.FieldValues<Schema>,
|
||||
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
|
||||
> extends ariaComponents.FieldStateProps<
|
||||
Omit<
|
||||
React.HTMLAttributes<HTMLDivElement> & { value: string },
|
||||
'aria-describedby' | 'aria-details' | 'aria-label' | 'aria-labelledby'
|
||||
>,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value: string },
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
>,
|
||||
ariaComponents.FieldProps {
|
||||
ariaComponents.FieldProps,
|
||||
Omit<twv.VariantProps<typeof variants.INPUT_STYLES>, 'disabled' | 'invalid'> {
|
||||
/**
|
||||
* onChange is called when the content of the input changes.
|
||||
* There is no way to prevent the change, so the value is always the new value.
|
||||
@ -71,6 +68,8 @@ export const ResizableContentEditableInput = React.forwardRef(
|
||||
isDisabled = false,
|
||||
form,
|
||||
defaultValue,
|
||||
size,
|
||||
rounded,
|
||||
...textFieldProps
|
||||
} = props
|
||||
|
||||
@ -103,7 +102,12 @@ export const ResizableContentEditableInput = React.forwardRef(
|
||||
inputContainer,
|
||||
textArea,
|
||||
placeholder: placeholderClass,
|
||||
} = CONTENT_EDITABLE_STYLES({ isInvalid: fieldState.invalid })
|
||||
} = CONTENT_EDITABLE_STYLES({
|
||||
invalid: fieldState.invalid,
|
||||
disabled: isDisabled || formInstance.formState.isSubmitting,
|
||||
rounded,
|
||||
size,
|
||||
})
|
||||
|
||||
return (
|
||||
<ariaComponents.Form.Field form={formInstance} name={name} fullWidth {...textFieldProps}>
|
||||
|
@ -9,7 +9,7 @@ import * as aria from '#/components/aria'
|
||||
|
||||
import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
|
||||
import * as varants from './variants'
|
||||
import * as variants from '../variants'
|
||||
|
||||
/**
|
||||
* Props for a {@link ResizableInput}.
|
||||
@ -51,7 +51,7 @@ export const ResizableInput = React.forwardRef(function ResizableInput(
|
||||
inputContainer,
|
||||
resizableSpan,
|
||||
textArea,
|
||||
} = varants.INPUT_STYLES({ isInvalid: textFieldProps.isInvalid })
|
||||
} = variants.INPUT_STYLES({ invalid: textFieldProps.isInvalid })
|
||||
|
||||
return (
|
||||
<aria.TextField {...textFieldProps}>
|
||||
|
@ -1,27 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Variants for the ResizableInput component.
|
||||
*/
|
||||
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import * as text from '../../Text'
|
||||
|
||||
export const INPUT_STYLES = twv.tv({
|
||||
base: 'w-full overflow-hidden block cursor-text rounded-md border-2 border-primary/10 bg-transparent px-1.5 pb-1 pt-1.5 focus-within:border-primary/50 transition-colors duration-200',
|
||||
variants: { isInvalid: { true: 'border-red-500/70 focus-within:border-red-500' } },
|
||||
slots: {
|
||||
inputContainer: text.TEXT_STYLE({
|
||||
className: 'block max-h-32 min-h-6 text-sm font-medium relative overflow-auto',
|
||||
variant: 'body',
|
||||
}),
|
||||
description: 'block select-none pointer-events-none opacity-80',
|
||||
textArea: 'block h-auto w-full max-h-full resize-none bg-transparent',
|
||||
resizableSpan: text.TEXT_STYLE({
|
||||
className:
|
||||
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all',
|
||||
variant: 'body',
|
||||
}),
|
||||
},
|
||||
})
|
@ -0,0 +1,199 @@
|
||||
/** @file A horizontal selector. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as twv from 'tailwind-variants'
|
||||
|
||||
import { mergeProps, type RadioGroupProps } from '#/components/aria'
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldProps,
|
||||
type FieldStateProps,
|
||||
type FieldValues,
|
||||
Form,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
|
||||
import RadioGroup from '#/components/styled/RadioGroup'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { SelectorOption } from './SelectorOption'
|
||||
|
||||
/** * Props for the Selector component. */
|
||||
export interface SelectorProps<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
> extends FieldStateProps<
|
||||
Omit<RadioGroupProps, 'children' | 'value'> & { value: TFieldValues[TFieldName] },
|
||||
Schema,
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
TTransformedValues
|
||||
>,
|
||||
FieldProps,
|
||||
Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'> {
|
||||
readonly items: readonly TFieldValues[TFieldName][]
|
||||
readonly itemToString?: (item: TFieldValues[TFieldName]) => string
|
||||
readonly className?: string
|
||||
readonly style?: React.CSSProperties
|
||||
readonly inputRef?: React.Ref<HTMLDivElement>
|
||||
readonly placeholder?: string
|
||||
readonly readOnly?: boolean
|
||||
}
|
||||
|
||||
export const SELECTOR_STYLES = tv({
|
||||
base: 'block w-full bg-transparent transition-[border-color,outline] duration-200',
|
||||
variants: {
|
||||
disabled: {
|
||||
true: { base: 'cursor-default opacity-50', textArea: 'cursor-default' },
|
||||
false: { base: 'cursor-text', textArea: 'cursor-text' },
|
||||
},
|
||||
readOnly: { true: 'cursor-default' },
|
||||
size: {
|
||||
medium: { base: '' },
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
small: 'rounded-sm',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
xlarge: 'rounded-xl',
|
||||
xxlarge: 'rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
variant: {
|
||||
outline: {
|
||||
base: 'border-[0.5px] border-primary/20',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: 'xxxlarge',
|
||||
variant: 'outline',
|
||||
},
|
||||
slots: {
|
||||
radioGroup: 'flex',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* A horizontal selector.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Selector = React.forwardRef(function Selector<
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: SelectorProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
||||
) {
|
||||
const {
|
||||
name,
|
||||
items,
|
||||
itemToString = String,
|
||||
isDisabled = false,
|
||||
form,
|
||||
defaultValue,
|
||||
inputRef,
|
||||
label,
|
||||
size,
|
||||
rounded,
|
||||
isRequired = false,
|
||||
...inputProps
|
||||
} = props
|
||||
|
||||
const privateInputRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const { fieldState, formInstance } = Form.useField({
|
||||
name,
|
||||
isDisabled,
|
||||
form,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const classes = SELECTOR_STYLES({
|
||||
size,
|
||||
rounded,
|
||||
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 }),
|
||||
// setValueAs: (value) => {
|
||||
// console.log('WHAT', value)
|
||||
// // eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
// return items[Number(value)]
|
||||
// },
|
||||
// })
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
form={formInstance}
|
||||
name={name}
|
||||
fullWidth
|
||||
label={label}
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
isRequired={isRequired}
|
||||
isInvalid={fieldState.invalid}
|
||||
aria-details={props['aria-details']}
|
||||
ref={ref}
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
>
|
||||
<div
|
||||
className={classes.base()}
|
||||
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<Controller
|
||||
control={formInstance.control}
|
||||
name={name}
|
||||
render={(renderProps) => {
|
||||
const { ref: fieldRef, value, onChange, ...field } = renderProps.field
|
||||
return (
|
||||
<RadioGroup
|
||||
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
|
||||
{...mergeProps<RadioGroupProps>()(
|
||||
{ className: classes.radioGroup(), name, isRequired, isDisabled },
|
||||
inputProps,
|
||||
field,
|
||||
)}
|
||||
// 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)])
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<SelectorOption value={String(i)} label={itemToString(item)} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Form.Field>
|
||||
)
|
||||
}) as <
|
||||
Schema extends TSchema,
|
||||
TFieldValues extends FieldValues<Schema>,
|
||||
TFieldName extends FieldPath<Schema, TFieldValues>,
|
||||
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: React.RefAttributes<HTMLDivElement> &
|
||||
SelectorProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
) => React.ReactElement
|
@ -0,0 +1,68 @@
|
||||
/** @file An option in a selector. */
|
||||
import { Radio, type RadioProps } from '#/components/aria'
|
||||
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}. */
|
||||
export interface SelectorOptionProps
|
||||
extends RadioProps,
|
||||
VariantProps<typeof SELECTOR_OPTION_STYLES> {
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
export const SELECTOR_OPTION_STYLES = tv({
|
||||
base: TEXT_STYLE({
|
||||
className:
|
||||
'flex flex-1 items-center justify-center min-h-8 relative overflow-clip cursor-pointer transition-[background-color,color,outline-offset] duration-200',
|
||||
variant: 'body',
|
||||
}),
|
||||
variants: {
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
small: 'rounded-sm',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
xlarge: 'rounded-xl',
|
||||
xxlarge: 'rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
size: {
|
||||
medium: { base: 'px-[11px] pb-1.5 pt-2' },
|
||||
small: { base: 'px-[11px] pb-0.5 pt-1' },
|
||||
},
|
||||
variant: {
|
||||
primary:
|
||||
'selected:bg-primary selected:text-white hover:bg-primary/5 pressed:bg-primary/10 outline outline-2 outline-transparent outline-offset-[-2px] focus-visible:outline-primary focus-visible:outline-offset-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: 'xxxlarge',
|
||||
variant: 'primary',
|
||||
},
|
||||
})
|
||||
|
||||
export const SelectorOption = React.forwardRef(function SelectorOption(
|
||||
props: SelectorOptionProps,
|
||||
ref: React.ForwardedRef<HTMLLabelElement>,
|
||||
) {
|
||||
const { label, ...radioProps } = props
|
||||
const { className } = props
|
||||
|
||||
return (
|
||||
<Radio
|
||||
ref={ref}
|
||||
{...radioProps}
|
||||
className={(renderProps) =>
|
||||
SELECTOR_OPTION_STYLES({
|
||||
className: typeof className === 'function' ? className(renderProps) : className,
|
||||
})
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Radio>
|
||||
)
|
||||
})
|
@ -0,0 +1,2 @@
|
||||
/** @file Barrel file for Selector component. */
|
||||
export * from './Selector'
|
@ -4,4 +4,7 @@
|
||||
* Barrel export file for Inputs
|
||||
*/
|
||||
|
||||
export * from './Input'
|
||||
export * from './ResizableInput'
|
||||
export * from './Selector'
|
||||
export * from './variants'
|
||||
|
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Variants for the ResizableInput component.
|
||||
*/
|
||||
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
import { TEXT_STYLE } from '../Text'
|
||||
|
||||
export const INPUT_STYLES = tv({
|
||||
base: 'block w-full overflow-hidden bg-transparent transition-[border-color,outline] duration-200',
|
||||
variants: {
|
||||
disabled: {
|
||||
true: { base: 'cursor-default opacity-50', textArea: 'cursor-default' },
|
||||
false: { base: 'cursor-text', textArea: 'cursor-text' },
|
||||
},
|
||||
invalid: {
|
||||
// Specified in compoundVariants. Real classes depend on Variants
|
||||
true: '',
|
||||
},
|
||||
readOnly: {
|
||||
true: 'cursor-default',
|
||||
false: 'cursor-text',
|
||||
},
|
||||
size: {
|
||||
medium: { base: 'px-[11px] pb-1.5 pt-2' },
|
||||
small: { base: 'px-[11px] pb-0.5 pt-1' },
|
||||
},
|
||||
rounded: {
|
||||
none: 'rounded-none',
|
||||
small: 'rounded-sm',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
xlarge: 'rounded-xl',
|
||||
xxlarge: 'rounded-2xl',
|
||||
xxxlarge: 'rounded-3xl',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
variant: {
|
||||
outline: {
|
||||
base: 'border-[0.5px] outline-offset-2 border-primary/20 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[-1px] focus-within:outline-primary focus-within:border-primary/50',
|
||||
textArea: 'border-transparent focus-within:border-transparent',
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
addonStart: '',
|
||||
addonEnd: '',
|
||||
inputContainer: TEXT_STYLE({
|
||||
className: 'flex w-full items-center max-h-32 min-h-6 relative overflow-auto',
|
||||
variant: 'body',
|
||||
}),
|
||||
selectorContainer: 'flex',
|
||||
description: 'block select-none pointer-events-none opacity-80',
|
||||
textArea: 'block h-auto w-full max-h-full resize-none bg-transparent',
|
||||
resizableSpan: TEXT_STYLE({
|
||||
className:
|
||||
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all',
|
||||
variant: 'body',
|
||||
}),
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
invalid: true,
|
||||
variant: 'outline',
|
||||
class: { base: 'border-danger focus-within:border-danger focus-within:outline-danger' },
|
||||
},
|
||||
{
|
||||
readOnly: true,
|
||||
class: { base: 'focus-within:outline-transparent' },
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: 'xlarge',
|
||||
variant: 'outline',
|
||||
},
|
||||
})
|
@ -22,29 +22,73 @@ export interface SeparatorProps
|
||||
* The styles for the {@link Separator} component.
|
||||
*/
|
||||
export const SEPARATOR_STYLES = twv.tv({
|
||||
base: 'isolate rounded-full',
|
||||
base: 'rounded-full border-none',
|
||||
variants: {
|
||||
size: {
|
||||
thin: '',
|
||||
medium: '',
|
||||
thick: '',
|
||||
},
|
||||
orientation: {
|
||||
horizontal: 'border-t',
|
||||
vertical: 'border-l',
|
||||
horizontal: 'w-full',
|
||||
vertical: 'h-full',
|
||||
},
|
||||
variant: {
|
||||
primary: 'border-primary/30',
|
||||
inverted: 'border-white/30',
|
||||
current: 'bg-current',
|
||||
primary: 'bg-primary/30',
|
||||
inverted: 'bg-white/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
// `size: 'thin'` causes the separator to disappear on Firefox.
|
||||
size: 'medium',
|
||||
orientation: 'horizontal',
|
||||
variant: 'primary',
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'thin',
|
||||
orientation: 'horizontal',
|
||||
class: 'h-[0.5px]',
|
||||
},
|
||||
{
|
||||
size: 'thin',
|
||||
orientation: 'vertical',
|
||||
class: 'w-[0.5px]',
|
||||
},
|
||||
{
|
||||
size: 'medium',
|
||||
orientation: 'horizontal',
|
||||
class: 'h-[1px]',
|
||||
},
|
||||
{
|
||||
size: 'medium',
|
||||
orientation: 'vertical',
|
||||
class: 'w-[1px]',
|
||||
},
|
||||
{
|
||||
size: 'thick',
|
||||
orientation: 'horizontal',
|
||||
class: 'h-1',
|
||||
},
|
||||
{
|
||||
size: 'thick',
|
||||
orientation: 'vertical',
|
||||
class: 'w-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* A separator component.
|
||||
*/
|
||||
export function Separator(props: SeparatorProps) {
|
||||
const { orientation = 'horizontal', variant = 'primary', className, ...rest } = props
|
||||
const { orientation = 'horizontal', variant, className, size, ...rest } = props
|
||||
|
||||
return (
|
||||
<aria.Separator
|
||||
orientation={orientation}
|
||||
className={SEPARATOR_STYLES({ orientation, variant, className })}
|
||||
className={SEPARATOR_STYLES({ orientation, variant, size, className })}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
|
@ -20,6 +20,7 @@ export interface TextProps
|
||||
readonly lineClamp?: number
|
||||
readonly tooltip?: React.ReactElement | string | false | null
|
||||
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
|
||||
readonly tooltipPlacement?: aria.Placement
|
||||
}
|
||||
|
||||
export const TEXT_STYLE = twv.tv({
|
||||
@ -29,10 +30,11 @@ export const TEXT_STYLE = twv.tv({
|
||||
custom: '',
|
||||
primary: 'text-primary',
|
||||
danger: 'text-danger',
|
||||
success: 'text-share',
|
||||
success: 'text-accent-dark',
|
||||
disabled: 'text-primary/30',
|
||||
invert: 'text-white',
|
||||
inherit: 'text-inherit',
|
||||
current: 'text-current',
|
||||
},
|
||||
font: {
|
||||
default: '',
|
||||
@ -61,9 +63,9 @@ export const TEXT_STYLE = twv.tv({
|
||||
},
|
||||
transform: {
|
||||
none: '',
|
||||
capitalize: 'text-capitalize',
|
||||
lowercase: 'text-lowercase',
|
||||
uppercase: 'text-uppercase',
|
||||
capitalize: 'capitalize',
|
||||
lowercase: 'lowercase',
|
||||
uppercase: 'uppercase',
|
||||
},
|
||||
truncate: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
@ -91,7 +93,9 @@ export const TEXT_STYLE = twv.tv({
|
||||
disableLineHeightCompensation: {
|
||||
true: 'before:hidden after:hidden before:w-0 after:w-0',
|
||||
false:
|
||||
'inline-block flex-col before:block after:block before:flex-none after:flex-none before:w-full after:w-full',
|
||||
'flex-col before:block after:block before:flex-none after:flex-none before:w-full after:w-full',
|
||||
top: 'flex-col before:hidden before:w-0 after:block after:flex-none after:w-full',
|
||||
bottom: 'flex-col before:block before:flex-none before:w-full after:hidden after:w-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@ -133,6 +137,7 @@ export const Text = React.forwardRef(function Text(
|
||||
elementType: ElementType = 'span',
|
||||
tooltip: tooltipElement = children,
|
||||
tooltipDisplay = 'whenOverflowing',
|
||||
tooltipPlacement,
|
||||
textSelection,
|
||||
disableLineHeightCompensation = false,
|
||||
...ariaProps
|
||||
@ -154,11 +159,13 @@ export const Text = React.forwardRef(function Text(
|
||||
balance,
|
||||
textSelection,
|
||||
disableLineHeightCompensation:
|
||||
disableLineHeightCompensation || textContext.isInsideTextComponent,
|
||||
disableLineHeightCompensation === false ?
|
||||
textContext.isInsideTextComponent
|
||||
: disableLineHeightCompensation,
|
||||
className,
|
||||
})
|
||||
|
||||
const isToolipDisabled = () => {
|
||||
const isTooltipDisabled = () => {
|
||||
if (tooltipDisplay === 'whenOverflowing') {
|
||||
return !truncate
|
||||
} else if (tooltipDisplay === 'always') {
|
||||
@ -169,10 +176,11 @@ export const Text = React.forwardRef(function Text(
|
||||
}
|
||||
|
||||
const { tooltip, targetProps } = visualTooltip.useVisualTooltip({
|
||||
isDisabled: isToolipDisabled(),
|
||||
isDisabled: isTooltipDisabled(),
|
||||
targetRef: textElementRef,
|
||||
display: tooltipDisplay,
|
||||
children: tooltipElement,
|
||||
...(tooltipPlacement ? { overlayPositionProps: { placement: tooltipPlacement } } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -4,6 +4,7 @@ import * as portal from '#/components/Portal'
|
||||
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
import { DIALOG_BACKGROUND } from '../Dialog'
|
||||
import * as text from '../Text'
|
||||
|
||||
// =================
|
||||
@ -11,12 +12,12 @@ import * as text from '../Text'
|
||||
// =================
|
||||
|
||||
export const TOOLTIP_STYLES = twv.tv({
|
||||
base: 'group flex justify-center items-center text-center text-balance break-words',
|
||||
base: 'group flex justify-center items-center text-center text-balance break-words z-50',
|
||||
variants: {
|
||||
variant: {
|
||||
custom: '',
|
||||
primary: 'bg-primary/80 text-white/80',
|
||||
inverted: 'bg-white/80 text-primary/80',
|
||||
primary: DIALOG_BACKGROUND({ variant: 'dark', className: 'text-white/80' }),
|
||||
inverted: DIALOG_BACKGROUND({ variant: 'light', className: 'text-primary' }),
|
||||
},
|
||||
size: {
|
||||
custom: '',
|
||||
@ -70,7 +71,12 @@ export interface TooltipProps
|
||||
|
||||
/** Displays the description of an element on hover or focus. */
|
||||
export function Tooltip(props: TooltipProps) {
|
||||
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
|
||||
const {
|
||||
className,
|
||||
containerPadding = DEFAULT_CONTAINER_PADDING,
|
||||
variant,
|
||||
...ariaTooltipProps
|
||||
} = props
|
||||
const root = portal.useStrictPortalContext()
|
||||
|
||||
return (
|
||||
@ -79,7 +85,7 @@ export function Tooltip(props: TooltipProps) {
|
||||
containerPadding={containerPadding}
|
||||
UNSTABLE_portalContainer={root}
|
||||
className={aria.composeRenderProps(className, (classNames, values) =>
|
||||
TOOLTIP_STYLES({ className: classNames, ...values }),
|
||||
TOOLTIP_STYLES({ className: classNames, variant, ...values }),
|
||||
)}
|
||||
data-ignore-click-outside
|
||||
{...ariaTooltipProps}
|
||||
|
@ -1,10 +1,14 @@
|
||||
/** @file A select menu with a dropdown. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CloseIcon from '#/assets/cross.svg'
|
||||
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import Input from '#/components/styled/Input'
|
||||
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -39,7 +43,7 @@ interface InternalBaseAutocompleteProps<T> {
|
||||
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
|
||||
/** Whether selecting multiple values is allowed. */
|
||||
readonly multiple?: false
|
||||
readonly setValues: (value: [T]) => void
|
||||
readonly setValues: (value: readonly [] | readonly [T]) => void
|
||||
readonly itemsToString?: never
|
||||
}
|
||||
|
||||
@ -76,7 +80,8 @@ export type AutocompleteProps<T> = (
|
||||
/** A select menu with a dropdown. */
|
||||
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
|
||||
const { text, setText, autoFocus, items, itemToKey, itemToString, itemsToString, matches } = props
|
||||
const { text, setText, autoFocus = false, items, itemToKey, itemToString, itemsToString } = props
|
||||
const { matches } = props
|
||||
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
||||
const valuesSet = React.useMemo(() => new Set(values), [values])
|
||||
@ -181,72 +186,83 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div onKeyDown={onKeyDown} className="grow">
|
||||
<FocusRing within>
|
||||
<div className="flex flex-1 rounded-full">
|
||||
{canEditText ?
|
||||
<Input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
size={1}
|
||||
value={text ?? ''}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder == null ? placeholder : placeholder}
|
||||
className="text grow rounded-full bg-transparent px-button-x"
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setIsDropdownVisible(true)
|
||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
||||
<div className="relative h-6 w-full">
|
||||
<div
|
||||
onKeyDown={onKeyDown}
|
||||
className={twMerge(
|
||||
'absolute w-full grow transition-colors',
|
||||
isDropdownVisible && matchingItems.length !== 0 ?
|
||||
'before:absolute before:inset-0 before:z-1 before:rounded-xl before:border-0.5 before:border-primary/20 before:bg-frame before:shadow-soft before:backdrop-blur-default'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
<FocusRing within>
|
||||
<div className="relative z-1 flex flex-1 rounded-full">
|
||||
{canEditText ?
|
||||
<Input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
size={1}
|
||||
value={text ?? ''}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder == null ? placeholder : placeholder}
|
||||
className="text grow rounded-full bg-transparent px-button-x"
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setIsDropdownVisible(true)
|
||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
: <div
|
||||
tabIndex={-1}
|
||||
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
icon={CloseIcon}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onPress={() => {
|
||||
setValues([])
|
||||
// setIsDropdownVisible(true)
|
||||
setText?.('')
|
||||
}}
|
||||
/>
|
||||
: <div
|
||||
ref={(element) => element?.focus()}
|
||||
tabIndex={-1}
|
||||
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</FocusRing>
|
||||
<div className="h">
|
||||
</div>
|
||||
</FocusRing>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default',
|
||||
isDropdownVisible &&
|
||||
matchingItems.length !== 0 &&
|
||||
'before:border before:border-primary/10',
|
||||
'relative z-1 grid h-max w-full rounded-b-xl transition-grid-template-rows duration-200',
|
||||
isDropdownVisible && matchingItems.length !== 0 ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default',
|
||||
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0',
|
||||
)}
|
||||
>
|
||||
<div className="relative max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-b-xl">
|
||||
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
|
||||
* so the suggestions may go offscreen. */}
|
||||
{matchingItems.map((item, index) => (
|
||||
<div
|
||||
key={itemToKey(item)}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text relative cursor-pointer whitespace-nowrap px-input-x first:rounded-t-default last:rounded-b-default hover:bg-hover-bg',
|
||||
'text relative cursor-pointer whitespace-nowrap px-input-x last:rounded-b-xl hover:bg-hover-bg',
|
||||
valuesSet.has(item) && 'bg-hover-bg',
|
||||
index === selectedIndex && 'bg-black/5',
|
||||
)}
|
||||
@ -258,7 +274,9 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
toggleValue(item)
|
||||
}}
|
||||
>
|
||||
{itemToString(item)}
|
||||
<Text truncate="1" className="w-full" tooltipPlacement="left">
|
||||
{itemToString(item)}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -11,9 +11,12 @@ import { IS_DEV_MODE } from 'enso-common/src/detect'
|
||||
|
||||
import DevtoolsLogo from '#/assets/enso_logo.svg'
|
||||
|
||||
import { SETUP_PATH } from '#/appUtils'
|
||||
|
||||
import * as billing from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import { UserSessionType } from '#/providers/AuthProvider'
|
||||
import {
|
||||
useEnableVersionChecker,
|
||||
useSetEnableVersionChecker,
|
||||
@ -58,8 +61,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
const { children } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const { authQueryKey } = authProvider.useAuth()
|
||||
const session = authProvider.useFullUserSession()
|
||||
const { authQueryKey, session } = authProvider.useAuth()
|
||||
const enableVersionChecker = useEnableVersionChecker()
|
||||
const setEnableVersionChecker = useSetEnableVersionChecker()
|
||||
|
||||
@ -108,51 +110,63 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<ariaComponents.Text variant="subtitle">
|
||||
{getText('paywallDevtoolsPlanSelectSubtitle')}
|
||||
</ariaComponents.Text>
|
||||
{session?.type === UserSessionType.full && (
|
||||
<>
|
||||
<ariaComponents.Text variant="subtitle">
|
||||
{getText('paywallDevtoolsPlanSelectSubtitle')}
|
||||
</ariaComponents.Text>
|
||||
|
||||
<ariaComponents.Form
|
||||
gap="small"
|
||||
schema={(schema) => schema.object({ plan: schema.string() })}
|
||||
defaultValues={{ plan: session.user.plan ?? 'free' }}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<ariaComponents.RadioGroup
|
||||
form={form}
|
||||
name="plan"
|
||||
onChange={(value) => {
|
||||
queryClient.setQueryData(authQueryKey, {
|
||||
...session,
|
||||
user: { ...session.user, plan: value },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ariaComponents.Radio label={getText('free')} value="free" />
|
||||
<ariaComponents.Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<ariaComponents.Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<ariaComponents.Radio
|
||||
label={getText('enterprise')}
|
||||
value={backend.Plan.enterprise}
|
||||
/>
|
||||
</ariaComponents.RadioGroup>
|
||||
<ariaComponents.Form
|
||||
gap="small"
|
||||
schema={(schema) => schema.object({ plan: schema.string() })}
|
||||
defaultValues={{ plan: session.user.plan ?? 'free' }}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<ariaComponents.RadioGroup
|
||||
form={form}
|
||||
name="plan"
|
||||
onChange={(value) => {
|
||||
queryClient.setQueryData(authQueryKey, {
|
||||
...session,
|
||||
user: { ...session.user, plan: value },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ariaComponents.Radio label={getText('free')} value={'free'} />
|
||||
<ariaComponents.Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<ariaComponents.Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<ariaComponents.Radio
|
||||
label={getText('enterprise')}
|
||||
value={backend.Plan.enterprise}
|
||||
/>
|
||||
</ariaComponents.RadioGroup>
|
||||
|
||||
<ariaComponents.Button
|
||||
variant="outline"
|
||||
onPress={() =>
|
||||
queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => {
|
||||
form.reset()
|
||||
})
|
||||
}
|
||||
>
|
||||
{getText('reset')}
|
||||
</ariaComponents.Button>
|
||||
</>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
<ariaComponents.Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() =>
|
||||
queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => {
|
||||
form.reset()
|
||||
})
|
||||
}
|
||||
>
|
||||
{getText('reset')}
|
||||
</ariaComponents.Button>
|
||||
</>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<ariaComponents.Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
Open setup page
|
||||
</ariaComponents.Button>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
{getText('productionOnlyFeatures')}
|
||||
@ -188,6 +202,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const featureName = feature as billing.PaywallFeatureName
|
||||
const { label, descriptionTextId } = getFeature(featureName)
|
||||
|
||||
return (
|
||||
<div key={feature} className="flex flex-col">
|
||||
<aria.Switch
|
||||
|
@ -204,7 +204,7 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 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:backdrop-blur-default before:transition-colors',
|
||||
isDropdownVisible ?
|
||||
'before:h-full before:shadow-soft'
|
||||
: 'before:h-text group-hover:before:bg-hover-bg',
|
||||
|
@ -25,9 +25,9 @@ export interface ErrorBoundaryProps
|
||||
extends Readonly<React.PropsWithChildren>,
|
||||
Readonly<Pick<errorBoundary.ErrorBoundaryProps, 'FallbackComponent' | 'onError' | 'onReset'>> {}
|
||||
|
||||
/** Catches errors in the child components
|
||||
/** Catches errors in child components
|
||||
* Shows a fallback UI when there is an error.
|
||||
* The error can also be logged. to an error reporting service. */
|
||||
* The error can also be logged to an error reporting service. */
|
||||
export function ErrorBoundary(props: ErrorBoundaryProps) {
|
||||
const {
|
||||
FallbackComponent = ErrorDisplay,
|
||||
@ -35,6 +35,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
|
||||
onReset = () => {},
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<reactQuery.QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
@ -57,26 +58,34 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
|
||||
|
||||
/** Props for a {@link ErrorDisplay}. */
|
||||
export interface ErrorDisplayProps extends errorBoundary.FallbackProps {
|
||||
readonly status?: result.ResultProps['status']
|
||||
readonly title?: string
|
||||
readonly subtitle?: string
|
||||
readonly error: unknown
|
||||
}
|
||||
|
||||
/** Default fallback component to show when there is an error. */
|
||||
export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
|
||||
const { resetErrorBoundary, error } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { isOffline } = offlineHooks.useOffline()
|
||||
|
||||
const {
|
||||
resetErrorBoundary,
|
||||
error,
|
||||
title = getText('appErroredMessage'),
|
||||
subtitle = isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle'),
|
||||
status = isOffline ? 'info' : 'error',
|
||||
} = props
|
||||
|
||||
const message = errorUtils.getMessageOrToString(error)
|
||||
const stack = errorUtils.tryGetStack(error)
|
||||
|
||||
return (
|
||||
<result.Result
|
||||
className="h-full"
|
||||
status={isOffline ? 'info' : 'error'}
|
||||
title={getText('arbitraryErrorTitle')}
|
||||
subtitle={isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle')}
|
||||
>
|
||||
<result.Result className="h-full" status={status} title={title} subtitle={subtitle}>
|
||||
<ariaComponents.Text color="danger" variant="body">
|
||||
{getText('errorColon')}
|
||||
{message}
|
||||
</ariaComponents.Text>
|
||||
<ariaComponents.ButtonGroup align="center">
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
|
@ -12,6 +12,7 @@ import Checkbox from '#/components/styled/Checkbox'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
import { useBackendQuery } from '#/hooks/backendHooks'
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
@ -38,13 +39,19 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { value, setValue } = props
|
||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||
// but it is more convenient to avoid having plugin infrastructure.
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const { getText } = textProvider.useText()
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
typeof value === 'string' ? value : null,
|
||||
)
|
||||
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
|
||||
const [autocompleteItems, setAutocompleteItems] = React.useState<string[] | null>(null)
|
||||
const isSecret =
|
||||
'type' in schema &&
|
||||
schema.type === 'string' &&
|
||||
'format' in schema &&
|
||||
schema.format === 'enso-secret'
|
||||
const { data: secrets } = useBackendQuery(remoteBackend, 'listSecrets', [], { enabled: isSecret })
|
||||
const autocompleteItems = isSecret ? secrets?.map((secret) => secret.path) ?? null : null
|
||||
|
||||
// NOTE: `enum` schemas omitted for now as they are not yet used.
|
||||
if ('const' in schema) {
|
||||
@ -57,18 +64,11 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
case 'string': {
|
||||
if ('format' in schema && schema.format === 'enso-secret') {
|
||||
const isValid = typeof value === 'string' && value !== ''
|
||||
if (autocompleteItems == null) {
|
||||
setAutocompleteItems([])
|
||||
void (async () => {
|
||||
const secrets = (await remoteBackend?.listSecrets()) ?? []
|
||||
setAutocompleteItems(secrets.map((secret) => secret.path))
|
||||
})()
|
||||
}
|
||||
children.push(
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'grow rounded-default border',
|
||||
isValid ? 'border-primary/10' : 'border-red-700/60',
|
||||
'w-60 rounded-default border-0.5',
|
||||
isValid ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
>
|
||||
<Autocomplete
|
||||
@ -79,7 +79,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
||||
values={isValid ? [value] : []}
|
||||
setValues={(values) => {
|
||||
setValue(values[0])
|
||||
setValue(values[0] ?? '')
|
||||
}}
|
||||
text={autocompleteText}
|
||||
setText={setAutocompleteText}
|
||||
@ -97,8 +97,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterText')}
|
||||
onChange={(event) => {
|
||||
@ -125,8 +125,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterNumber')}
|
||||
onChange={(event) => {
|
||||
@ -154,8 +154,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
|
||||
'focus-child min-6- text40 w-80 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterInteger')}
|
||||
onChange={(event) => {
|
||||
@ -195,19 +195,13 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
)
|
||||
if (jsonSchema.constantValue(defs, schema).length !== 1) {
|
||||
children.push(
|
||||
<div className="flex flex-col gap-json-schema rounded-default border border-primary/10 p-json-schema-object-input">
|
||||
<div className="grid items-center gap-json-schema rounded-default border-0.5 border-primary/20 p-json-schema-object-input">
|
||||
{propertyDefinitions.map((definition) => {
|
||||
const { key, schema: childSchema } = definition
|
||||
const isOptional = !requiredProperties.includes(key)
|
||||
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
|
||||
null
|
||||
: <div
|
||||
key={key}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
{...('description' in childSchema ?
|
||||
{ title: String(childSchema.description) }
|
||||
: {})}
|
||||
>
|
||||
: <>
|
||||
<FocusArea active={isOptional} direction="horizontal">
|
||||
{(innerProps) => {
|
||||
const isPresent = value != null && key in value
|
||||
@ -218,7 +212,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
isDisabled={!isOptional}
|
||||
isActive={!isOptional || isPresent}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left',
|
||||
'text col-start-1 inline-block whitespace-nowrap rounded-full px-button-x text-left',
|
||||
isOptional && 'hover:bg-hover-bg',
|
||||
)}
|
||||
onPress={() => {
|
||||
@ -254,45 +248,47 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
}}
|
||||
</FocusArea>
|
||||
{value != null && key in value && (
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
defs={defs}
|
||||
schema={childSchema}
|
||||
path={`${path}/properties/${key}`}
|
||||
getValidator={getValidator}
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={(newValue) => {
|
||||
setValue((oldValue) => {
|
||||
if (typeof newValue === 'function') {
|
||||
const unsafeValue: unknown = newValue(
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
|
||||
)
|
||||
// The value MAY be `null`, but it is better than the value being a
|
||||
// function (which is *never* the intended result).
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
newValue = unsafeValue!
|
||||
}
|
||||
return (
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue != null &&
|
||||
<div className="col-start-2">
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
defs={defs}
|
||||
schema={childSchema}
|
||||
path={`${path}/properties/${key}`}
|
||||
getValidator={getValidator}
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={(newValue) => {
|
||||
setValue((oldValue) => {
|
||||
if (typeof newValue === 'function') {
|
||||
const unsafeValue: unknown = newValue(
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ===
|
||||
newValue
|
||||
) ?
|
||||
oldValue
|
||||
: { ...oldValue, [key]: newValue }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
|
||||
)
|
||||
// The value MAY be `null`, but it is better than the value being a
|
||||
// function (which is *never* the intended result).
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
newValue = unsafeValue!
|
||||
}
|
||||
return (
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue != null &&
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ===
|
||||
newValue
|
||||
) ?
|
||||
oldValue
|
||||
: { ...oldValue, [key]: newValue }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
})}
|
||||
</div>,
|
||||
)
|
||||
|
@ -44,6 +44,7 @@ export const ACTION_TO_TEXT_ID: Readonly<
|
||||
>
|
||||
> = {
|
||||
settings: 'settingsShortcut',
|
||||
closeTab: 'closeTabShortcut',
|
||||
open: 'openShortcut',
|
||||
run: 'runShortcut',
|
||||
close: 'closeShortcut',
|
||||
|
@ -41,7 +41,11 @@ export default function Page(props: PageProps) {
|
||||
{!hideChat && (
|
||||
<>
|
||||
{/* `session.accessToken` MUST be present in order for the `Chat` component to work. */}
|
||||
{!hideInfoBar && session?.accessToken != null && process.env.ENSO_CLOUD_CHAT_URL != null ?
|
||||
{(
|
||||
!hideInfoBar &&
|
||||
session?.type === authProvider.UserSessionType.full &&
|
||||
process.env.ENSO_CLOUD_CHAT_URL != null
|
||||
) ?
|
||||
<Chat
|
||||
isOpen={isHelpChatOpen}
|
||||
doClose={doCloseChat}
|
||||
|
@ -25,7 +25,7 @@ const STATUS_ICON_MAP: Readonly<Record<Status, StatusIcon>> = {
|
||||
info: {
|
||||
icon: (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]">
|
||||
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]" aria-hidden>
|
||||
!
|
||||
</ariaComponents.Text>
|
||||
),
|
||||
@ -47,14 +47,12 @@ const RESULT_STYLES = twv.tv({
|
||||
slots: {
|
||||
statusIcon:
|
||||
'mb-2 flex h-8 w-8 flex-none items-center justify-center rounded-full bg-opacity-25 p-1 text-green',
|
||||
icon: 'h-8 w-8 flex-none',
|
||||
icon: 'h-6 w-6 flex-none',
|
||||
title: '',
|
||||
subtitle: 'max-w-[750px]',
|
||||
content: 'mt-3 w-full',
|
||||
},
|
||||
defaultVariants: {
|
||||
centered: 'all',
|
||||
},
|
||||
defaultVariants: { centered: 'all' },
|
||||
})
|
||||
|
||||
// ==============
|
||||
|
@ -1,32 +0,0 @@
|
||||
/** @file The root component with required providers */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as portal from '#/components/Portal'
|
||||
|
||||
// ============
|
||||
// === Root ===
|
||||
// ============
|
||||
|
||||
/** Props for {@link Root}. */
|
||||
export interface RootProps extends React.PropsWithChildren {
|
||||
readonly portalRoot: Element
|
||||
readonly navigate: (path: string) => void
|
||||
readonly locale?: string
|
||||
}
|
||||
|
||||
/** The root component with required providers. */
|
||||
export function Root(props: RootProps) {
|
||||
const { children, navigate, locale = 'en-US', portalRoot } = props
|
||||
|
||||
return (
|
||||
<portal.PortalProvider value={portalRoot}>
|
||||
<aria.RouterProvider navigate={navigate}>
|
||||
<aria.I18nProvider locale={locale}>
|
||||
<ariaComponents.DialogStackProvider>{children}</ariaComponents.DialogStackProvider>
|
||||
</aria.I18nProvider>
|
||||
</aria.RouterProvider>
|
||||
</portal.PortalProvider>
|
||||
)
|
||||
}
|
@ -45,6 +45,7 @@ export default function Spinner(props: SpinnerProps) {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x={1.5}
|
||||
|
411
app/dashboard/src/components/Stepper/Stepper.tsx
Normal file
411
app/dashboard/src/components/Stepper/Stepper.tsx
Normal file
@ -0,0 +1,411 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* A stepper component is used to indicate progress through a multi-step process.
|
||||
*/
|
||||
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 * as stepperProvider from './StepperProvider'
|
||||
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 {
|
||||
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.
|
||||
*/
|
||||
export interface StepperProps {
|
||||
readonly state: stepperState.StepperState
|
||||
readonly children: React.ReactNode | ((props: RenderChildrenProps) => React.ReactNode)
|
||||
readonly className?:
|
||||
| string
|
||||
| ((props: BaseRenderProps) => string | null | undefined)
|
||||
| null
|
||||
| undefined
|
||||
readonly renderStep: (props: RenderStepProps) => React.ReactNode
|
||||
readonly style?:
|
||||
| React.CSSProperties
|
||||
| ((props: BaseRenderProps) => React.CSSProperties | undefined)
|
||||
| undefined
|
||||
}
|
||||
|
||||
const STEPPER_STYLES = tvw.tv({
|
||||
base: 'flex flex-col items-center w-full',
|
||||
slots: {
|
||||
steps: 'flex items-center justify-between w-full',
|
||||
step: 'flex-1 last:flex-none',
|
||||
content: 'relative w-full mt-4',
|
||||
},
|
||||
})
|
||||
|
||||
const ANIMATION_OFFSET = 15
|
||||
|
||||
/**
|
||||
* A stepper component is used to indicate progress through a multi-step process.
|
||||
*/
|
||||
export function Stepper(props: StepperProps) {
|
||||
const { renderStep, children, state } = props
|
||||
|
||||
const { onStepChange, currentStep, totalSteps, nextStep, previousStep, direction } = state
|
||||
|
||||
const goToStep = eventCallback.useEventCallback((step: number) => {
|
||||
if (step < 0 || step >= totalSteps) {
|
||||
return
|
||||
} else {
|
||||
onStepChange(step)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const baseRenderProps = {
|
||||
goToStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
} satisfies BaseRenderProps
|
||||
|
||||
const classes = STEPPER_STYLES({})
|
||||
|
||||
const style = typeof props.style === 'function' ? props.style(baseRenderProps) : props.style
|
||||
|
||||
/**
|
||||
* Render children of the stepper component.
|
||||
*/
|
||||
const renderChildren = () => {
|
||||
const renderProps = {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isFirst: currentStep === 0,
|
||||
isLast: currentStep === totalSteps - 1,
|
||||
goToStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
} satisfies RenderChildrenProps
|
||||
|
||||
return typeof children === 'function' ? children(renderProps) : children
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.base({
|
||||
className:
|
||||
typeof props.className === 'function' ?
|
||||
props.className(baseRenderProps)
|
||||
: props.className,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
<stepperProvider.StepperProvider
|
||||
value={{ totalSteps, currentStep, goToStep, nextStep, previousStep, state }}
|
||||
>
|
||||
<div className={classes.steps()}>
|
||||
{Array.from({ length: totalSteps }).map((_, index) => {
|
||||
const renderStepProps = {
|
||||
index,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isFirst: index === 0,
|
||||
isLast: index === totalSteps - 1,
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToStep,
|
||||
isCompleted: index < currentStep,
|
||||
isCurrent: index === currentStep,
|
||||
isDisabled: index > currentStep,
|
||||
} satisfies RenderStepProps
|
||||
|
||||
return (
|
||||
<div key={index} className={classes.step({})}>
|
||||
{renderStep(renderStepProps)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={classes.content()}>
|
||||
<AnimatePresence initial={false} mode="sync" custom={direction}>
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
variants={{
|
||||
enter: {
|
||||
x: direction === 'back' ? -ANIMATION_OFFSET : ANIMATION_OFFSET,
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
height: 'auto',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
},
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
height: 'auto',
|
||||
position: 'static',
|
||||
width: '100%',
|
||||
},
|
||||
exit: (currentDirection: stepperState.StepperState['direction']) => ({
|
||||
zIndex: 0,
|
||||
x: currentDirection === 'back' ? ANIMATION_OFFSET : -ANIMATION_OFFSET,
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
}),
|
||||
}}
|
||||
transition={{
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
x: { type: 'spring', stiffness: 500, damping: 50, duration: 0.2 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<Suspense loaderProps={{ minHeight: 'h32' }}>{renderChildren()}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</stepperProvider.StepperProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A prop with the given type, or a function to produce a value of the given type. */
|
||||
type StepProp<T> = T | ((props: RenderStepProps) => T)
|
||||
|
||||
/**
|
||||
* Props for {@link Step} component.
|
||||
*/
|
||||
export interface StepProps extends RenderStepProps {
|
||||
readonly className?: StepProp<string | null | undefined>
|
||||
readonly icon?: StepProp<React.ReactElement | string | null | undefined>
|
||||
readonly completeIcon?: StepProp<React.ReactElement | string | null | undefined>
|
||||
readonly title?: StepProp<React.ReactElement | string | null | undefined>
|
||||
readonly description?: StepProp<React.ReactElement | string | null | undefined>
|
||||
readonly children?: StepProp<React.ReactNode>
|
||||
}
|
||||
|
||||
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 = (
|
||||
<ariaComponents.Text variant="subtitle" color="current" aria-hidden>
|
||||
{index + 1}
|
||||
</ariaComponents.Text>
|
||||
),
|
||||
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 = 45
|
||||
const stepAnimationScale = 0.5
|
||||
|
||||
return (
|
||||
<div className={styles.base()}>
|
||||
<AnimatePresence initial={false} mode="sync" custom={state.direction}>
|
||||
<motion.div
|
||||
key={isCompleted ? 'done' : 'icon'}
|
||||
className={styles.icon()}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
variants={{
|
||||
enter: {
|
||||
rotate:
|
||||
state.direction === 'forward' ? -stepAnimationRotation : stepAnimationRotation,
|
||||
scale: stepAnimationScale,
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
},
|
||||
center: {
|
||||
rotate: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
position: 'static',
|
||||
},
|
||||
exit: (direction: stepperState.StepperState['direction']) => ({
|
||||
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 <SvgMask src={renderIconElement} />
|
||||
} else {
|
||||
return renderIconElement
|
||||
}
|
||||
})()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={styles.titleContainer()}>
|
||||
{titleElement != null && (
|
||||
<div>
|
||||
{typeof titleElement === 'string' ?
|
||||
<ariaComponents.Text nowrap color="current">
|
||||
{titleElement}
|
||||
</ariaComponents.Text>
|
||||
: titleElement}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{descriptionElement != null && (
|
||||
<div>
|
||||
{typeof descriptionElement === 'string' ?
|
||||
<ariaComponents.Text variant="body" color="current" truncate="2">
|
||||
{descriptionElement}
|
||||
</ariaComponents.Text>
|
||||
: descriptionElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content()}>
|
||||
{typeof children === 'function' ? children(renderStepProps) : children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Stepper.Step = Step
|
||||
Stepper.useStepperState = stepperState.useStepperState
|
39
app/dashboard/src/components/Stepper/StepperProvider.tsx
Normal file
39
app/dashboard/src/components/Stepper/StepperProvider.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* StepperProvider component
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import type { StepperState } from './useStepperState'
|
||||
|
||||
/**
|
||||
* StepperProvider props
|
||||
*/
|
||||
export interface StepperContextType {
|
||||
readonly currentStep: number
|
||||
readonly goToStep: (step: number) => void
|
||||
readonly totalSteps: number
|
||||
readonly nextStep: () => void
|
||||
readonly previousStep: () => void
|
||||
readonly state: StepperState
|
||||
}
|
||||
|
||||
const StepperContext = React.createContext<StepperContextType | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to use the stepper context
|
||||
* @private
|
||||
*/
|
||||
export function useStepperContext() {
|
||||
const context = React.useContext(StepperContext)
|
||||
|
||||
invariant(context, 'useStepper must be used within a StepperProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const StepperProvider = StepperContext.Provider
|
8
app/dashboard/src/components/Stepper/index.ts
Normal file
8
app/dashboard/src/components/Stepper/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for Stepper component.
|
||||
*/
|
||||
|
||||
export * from './Stepper'
|
||||
export * from './useStepperState'
|
134
app/dashboard/src/components/Stepper/useStepperState.ts
Normal file
134
app/dashboard/src/components/Stepper/useStepperState.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Stepper state hook
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
/**
|
||||
* Direction of the stepper
|
||||
*/
|
||||
type Direction = 'back-none' | 'back' | 'forward-none' | 'forward' | 'initial'
|
||||
|
||||
/**
|
||||
* Props for {@link useStepperState}
|
||||
*/
|
||||
export interface StepperStateProps {
|
||||
readonly defaultStep?: number
|
||||
readonly steps: number
|
||||
readonly onStepChange?: (step: number, direction: 'back' | 'forward') => void
|
||||
readonly onCompleted?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* State for a stepper component
|
||||
*/
|
||||
export interface StepperState {
|
||||
readonly currentStep: number
|
||||
readonly onStepChange: (step: number) => void
|
||||
readonly totalSteps: number
|
||||
readonly nextStep: () => void
|
||||
readonly previousStep: () => void
|
||||
readonly direction: Direction
|
||||
readonly percentComplete: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link useStepperState}
|
||||
*/
|
||||
export interface UseStepperStateResult {
|
||||
readonly stepperState: StepperState
|
||||
readonly direction: Direction
|
||||
readonly currentStep: number
|
||||
readonly setCurrentStep: (step: number | ((current: number) => number)) => void
|
||||
readonly isCurrentStep: (step: number) => boolean
|
||||
readonly isFirstStep: boolean
|
||||
readonly isLastStep: boolean
|
||||
readonly percentComplete: number
|
||||
readonly nextStep: () => void
|
||||
readonly previousStep: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the state of a stepper component
|
||||
* @param props - {@link StepperState}
|
||||
* @returns current step and a function to set the current step
|
||||
*/
|
||||
export function useStepperState(props: StepperStateProps): UseStepperStateResult {
|
||||
const { steps, defaultStep = 0, onStepChange, onCompleted } = props
|
||||
|
||||
invariant(steps > 0, 'Invalid number of steps')
|
||||
invariant(defaultStep >= 0, 'Default step must be greater than or equal to 0')
|
||||
invariant(defaultStep < steps, 'Default step must be less than the number of steps')
|
||||
|
||||
const [currentStep, privateSetCurrentStep] = React.useState<{
|
||||
current: number
|
||||
direction: Direction
|
||||
}>(() => ({ current: defaultStep, direction: 'initial' }))
|
||||
|
||||
const setCurrentStep = eventCallbackHooks.useEventCallback(
|
||||
(step: number | ((current: number) => number)) => {
|
||||
privateSetCurrentStep((current) => {
|
||||
const newStep = typeof step === 'function' ? step(current.current) : step
|
||||
const direction = newStep > current.current ? 'forward' : 'back'
|
||||
|
||||
if (newStep < 0) {
|
||||
return {
|
||||
current: 0,
|
||||
direction: 'back-none',
|
||||
}
|
||||
} else if (newStep > steps) {
|
||||
onCompleted?.()
|
||||
|
||||
return {
|
||||
current: steps,
|
||||
direction: 'forward-none',
|
||||
}
|
||||
} else {
|
||||
onStepChange?.(newStep, direction)
|
||||
|
||||
return { current: newStep, direction }
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const isCurrentStep = eventCallbackHooks.useEventCallback(
|
||||
(step: number) => step === currentStep.current,
|
||||
)
|
||||
|
||||
const nextStep = eventCallbackHooks.useEventCallback(() => {
|
||||
setCurrentStep((current) => current + 1)
|
||||
})
|
||||
const previousStep = eventCallbackHooks.useEventCallback(() => {
|
||||
setCurrentStep((current) => current - 1)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const percentComplete = (currentStep.current / (steps - 1)) * 100
|
||||
|
||||
return {
|
||||
stepperState: {
|
||||
currentStep: currentStep.current,
|
||||
direction: currentStep.direction,
|
||||
onStepChange: setCurrentStep,
|
||||
totalSteps: steps,
|
||||
nextStep,
|
||||
previousStep,
|
||||
percentComplete,
|
||||
},
|
||||
currentStep: currentStep.current,
|
||||
direction: currentStep.direction,
|
||||
setCurrentStep,
|
||||
isCurrentStep,
|
||||
isFirstStep: currentStep.current === 0,
|
||||
isLastStep: currentStep.current === steps - 1,
|
||||
percentComplete,
|
||||
nextStep,
|
||||
previousStep,
|
||||
} satisfies UseStepperStateResult
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
/** @file A styled submit button. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Button } from '#/components/AriaComponents'
|
||||
import { submitForm } from '#/utilities/event'
|
||||
|
||||
// ====================
|
||||
// === SubmitButton ===
|
||||
@ -14,15 +14,14 @@ export interface SubmitButtonProps {
|
||||
readonly isDisabled?: boolean
|
||||
readonly text: string
|
||||
readonly icon: string
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
/** A styled submit button. */
|
||||
export default function SubmitButton(props: SubmitButtonProps) {
|
||||
const { isDisabled = false, text, icon, onPress, isLoading } = props
|
||||
const { isDisabled = false, text, icon, isLoading } = props
|
||||
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="large"
|
||||
fullWidth
|
||||
variant="submit"
|
||||
@ -33,9 +32,9 @@ export default function SubmitButton(props: SubmitButtonProps) {
|
||||
icon={icon}
|
||||
iconPosition="end"
|
||||
rounded="full"
|
||||
onPress={onPress}
|
||||
onPress={submitForm}
|
||||
>
|
||||
{text}
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
28
app/dashboard/src/components/UIProviders.tsx
Normal file
28
app/dashboard/src/components/UIProviders.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
/** @file A wrapper containing all UI-related React Provdiers. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { I18nProvider } from '#/components/aria'
|
||||
import { DialogStackProvider } from '#/components/AriaComponents'
|
||||
import { PortalProvider } from '#/components/Portal'
|
||||
|
||||
// ===================
|
||||
// === UIProviders ===
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link UIProviders}. */
|
||||
export interface UIProvidersProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly portalRoot: Element
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
/** A wrapper containing all UI-related React Provdiers. */
|
||||
export default function UIProviders(props: UIProvidersProps) {
|
||||
const { portalRoot, locale, children } = props
|
||||
return (
|
||||
<PortalProvider value={portalRoot}>
|
||||
<DialogStackProvider>
|
||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||
</DialogStackProvider>
|
||||
</PortalProvider>
|
||||
)
|
||||
}
|
@ -1,14 +1,19 @@
|
||||
/** @file A table row for an arbitrary asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import BlankIcon from '#/assets/blank.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -33,8 +38,8 @@ import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as download from '#/utilities/download'
|
||||
import * as drag from '#/utilities/drag'
|
||||
@ -46,6 +51,7 @@ import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -79,29 +85,35 @@ export interface AssetRowProps
|
||||
readonly state: assetsTable.AssetsTableState
|
||||
readonly hidden: boolean
|
||||
readonly columns: columnUtils.Column[]
|
||||
readonly selected: boolean
|
||||
readonly setSelected: (selected: boolean) => void
|
||||
readonly isSoleSelected: boolean
|
||||
readonly isKeyboardSelected: boolean
|
||||
readonly grabKeyboardFocus: () => void
|
||||
readonly allowContextMenu: boolean
|
||||
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
|
||||
readonly onContextMenu?: (
|
||||
props: AssetRowInnerProps,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void
|
||||
readonly select: () => void
|
||||
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
|
||||
}
|
||||
|
||||
/** A row containing an {@link backendModule.AnyAsset}. */
|
||||
export default function AssetRow(props: AssetRowProps) {
|
||||
const { selected, isSoleSelected, isKeyboardSelected, isOpened } = props
|
||||
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
|
||||
const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props
|
||||
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
|
||||
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
|
||||
const { visibilities } = state
|
||||
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const driveStore = useDriveStore()
|
||||
const setSelectedKeys = useSetSelectedKeys()
|
||||
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
|
||||
(visuallySelectedKeys ?? selectedKeys).has(item.key),
|
||||
)
|
||||
const isSoleSelected = useStore(
|
||||
driveStore,
|
||||
({ selectedKeys }) => selected && selectedKeys.size === 1,
|
||||
)
|
||||
const allowContextMenu = useStore(
|
||||
driveStore,
|
||||
({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected,
|
||||
)
|
||||
const draggableProps = dragAndDropHooks.useDraggable()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
@ -110,7 +122,6 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const rootRef = React.useRef<HTMLElement | null>(null)
|
||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||
const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus)
|
||||
@ -120,32 +131,46 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
||||
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
|
||||
)
|
||||
const key = AssetTreeNode.getKey(item)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const outerVisibility = visibilities.get(key)
|
||||
const outerVisibility = visibilities.get(item.key)
|
||||
const visibility =
|
||||
outerVisibility == null || outerVisibility === Visibility.visible ?
|
||||
insertionVisibility
|
||||
: outerVisibility
|
||||
const hidden = hiddenRaw || visibility === Visibility.hidden
|
||||
|
||||
const copyAssetMutation = backendHooks.useBackendMutation(backend, 'copyAsset')
|
||||
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
|
||||
const deleteAssetMutation = backendHooks.useBackendMutation(backend, 'deleteAsset')
|
||||
const undoDeleteAssetMutation = backendHooks.useBackendMutation(backend, 'undoDeleteAsset')
|
||||
const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject')
|
||||
const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject')
|
||||
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
|
||||
const getFileDetailsMutation = backendHooks.useBackendMutation(backend, 'getFileDetails')
|
||||
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
|
||||
const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission')
|
||||
const associateTagMutation = backendHooks.useBackendMutation(backend, 'associateTag')
|
||||
const copyAssetMutate = copyAssetMutation.mutateAsync
|
||||
const updateAssetMutate = updateAssetMutation.mutateAsync
|
||||
const deleteAssetMutate = deleteAssetMutation.mutateAsync
|
||||
const undoDeleteAssetMutate = undoDeleteAssetMutation.mutateAsync
|
||||
const openProjectMutate = openProjectMutation.mutateAsync
|
||||
const closeProjectMutate = closeProjectMutation.mutateAsync
|
||||
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
||||
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
|
||||
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
||||
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
||||
const openProjectMutation = useMutation(backendMutationOptions(backend, 'openProject'))
|
||||
const closeProjectMutation = useMutation(backendMutationOptions(backend, 'closeProject'))
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
|
||||
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
|
||||
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||
const copyAsset = copyAssetMutation.mutateAsync
|
||||
const updateAsset = updateAssetMutation.mutateAsync
|
||||
const deleteAsset = deleteAssetMutation.mutateAsync
|
||||
const undoDeleteAsset = undoDeleteAssetMutation.mutateAsync
|
||||
const openProject = openProjectMutation.mutateAsync
|
||||
const closeProject = closeProjectMutation.mutateAsync
|
||||
|
||||
const { data: projectState } = useQuery({
|
||||
// This is SAFE, as `isOpened` is only true for projects.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
...createGetProjectDetailsQuery.createPassiveListener(item.item.id as backendModule.ProjectId),
|
||||
select: (data) => data.state.type,
|
||||
enabled: item.type === backendModule.AssetType.project,
|
||||
})
|
||||
|
||||
const setSelected = useEventCallback((newSelected: boolean) => {
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(rawItem)
|
||||
@ -185,7 +210,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}),
|
||||
)
|
||||
newParentId ??= rootDirectoryId
|
||||
const copiedAsset = await copyAssetMutate([
|
||||
const copiedAsset = await copyAsset([
|
||||
asset.id,
|
||||
newParentId,
|
||||
asset.title,
|
||||
@ -212,7 +237,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
asset,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
copyAssetMutate,
|
||||
copyAsset,
|
||||
nodeMap,
|
||||
setAsset,
|
||||
dispatchAssetListEvent,
|
||||
@ -268,7 +293,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
item: newAsset,
|
||||
})
|
||||
setAsset(newAsset)
|
||||
await updateAssetMutate([
|
||||
await updateAsset([
|
||||
asset.id,
|
||||
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
|
||||
asset.title,
|
||||
@ -305,7 +330,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
item.directoryKey,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
updateAssetMutate,
|
||||
updateAsset,
|
||||
setAsset,
|
||||
dispatchAssetListEvent,
|
||||
],
|
||||
@ -340,15 +365,15 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
asset.projectState.type !== backendModule.ProjectState.placeholder &&
|
||||
asset.projectState.type !== backendModule.ProjectState.closed
|
||||
) {
|
||||
await openProjectMutate([asset.id, null, asset.title])
|
||||
await openProject([asset.id, null, asset.title])
|
||||
}
|
||||
try {
|
||||
await closeProjectMutate([asset.id, asset.title])
|
||||
await closeProject([asset.id, asset.title])
|
||||
} catch {
|
||||
// Ignored. The project was already closed.
|
||||
}
|
||||
}
|
||||
await deleteAssetMutate([asset.id, { force: forever }, asset.title])
|
||||
await deleteAsset([asset.id, { force: forever }, asset.title])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
@ -359,9 +384,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
backend,
|
||||
dispatchAssetListEvent,
|
||||
asset,
|
||||
openProjectMutate,
|
||||
closeProjectMutate,
|
||||
deleteAssetMutate,
|
||||
openProject,
|
||||
closeProject,
|
||||
deleteAsset,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
],
|
||||
@ -371,13 +396,13 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
// Visually, the asset is deleted from the Trash view.
|
||||
setInsertionVisibility(Visibility.hidden)
|
||||
try {
|
||||
await undoDeleteAssetMutate([asset.id, asset.title])
|
||||
await undoDeleteAsset([asset.id, asset.title])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
toastAndLog('restoreAssetError', error, asset.title)
|
||||
}
|
||||
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAssetMutate, item.key])
|
||||
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAsset, item.key])
|
||||
|
||||
const doTriggerDescriptionEdit = React.useCallback(() => {
|
||||
setModal(
|
||||
@ -570,30 +595,30 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
break
|
||||
}
|
||||
case AssetEventType.temporarilyAddLabels: {
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
|
||||
setRowState((oldRowState) =>
|
||||
(
|
||||
oldRowState.temporarilyAddedLabels === labels &&
|
||||
oldRowState.temporarilyRemovedLabels === set.EMPTY
|
||||
oldRowState.temporarilyRemovedLabels === set.EMPTY_SET
|
||||
) ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, {
|
||||
temporarilyAddedLabels: labels,
|
||||
temporarilyRemovedLabels: set.EMPTY,
|
||||
temporarilyRemovedLabels: set.EMPTY_SET,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case AssetEventType.temporarilyRemoveLabels: {
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
|
||||
setRowState((oldRowState) =>
|
||||
(
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY &&
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET &&
|
||||
oldRowState.temporarilyRemovedLabels === labels
|
||||
) ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, {
|
||||
temporarilyAddedLabels: set.EMPTY,
|
||||
temporarilyAddedLabels: set.EMPTY_SET,
|
||||
temporarilyRemovedLabels: labels,
|
||||
}),
|
||||
)
|
||||
@ -601,9 +626,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
case AssetEventType.addLabels: {
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY ?
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY }),
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
const labels = asset.labels
|
||||
if (
|
||||
@ -626,9 +651,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
case AssetEventType.removeLabels: {
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY ?
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY }),
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
const labels = asset.labels
|
||||
if (
|
||||
@ -677,9 +702,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const clearDragState = React.useCallback(() => {
|
||||
setIsDraggedOver(false)
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY ?
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY }),
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
}, [])
|
||||
|
||||
@ -707,12 +732,20 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
case backendModule.AssetType.file:
|
||||
case backendModule.AssetType.datalink:
|
||||
case backendModule.AssetType.secret: {
|
||||
const innerProps: AssetRowInnerProps = { key, item, setItem, state, rowState, setRowState }
|
||||
const innerProps: AssetRowInnerProps = {
|
||||
key: item.key,
|
||||
item,
|
||||
setItem,
|
||||
state,
|
||||
rowState,
|
||||
setRowState,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{!hidden && (
|
||||
<FocusRing>
|
||||
<tr
|
||||
data-testid="asset-row"
|
||||
tabIndex={0}
|
||||
ref={(element) => {
|
||||
rootRef.current = element
|
||||
@ -758,7 +791,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
if (allowContextMenu) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onContextMenu?.(innerProps, event)
|
||||
if (!selected) {
|
||||
select()
|
||||
}
|
||||
setModal(
|
||||
<AssetContextMenu
|
||||
innerProps={innerProps}
|
||||
@ -774,12 +809,15 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
onContextMenu?.(innerProps, event)
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
if (rowState.isEditingName) {
|
||||
if (
|
||||
rowState.isEditingName ||
|
||||
(projectState !== backendModule.ProjectState.closed &&
|
||||
projectState !== backendModule.ProjectState.created &&
|
||||
projectState != null)
|
||||
) {
|
||||
event.preventDefault()
|
||||
} else {
|
||||
props.onDragStart?.(event)
|
||||
@ -872,7 +910,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
return (
|
||||
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
|
||||
<Render
|
||||
keyProp={key}
|
||||
keyProp={item.key}
|
||||
isOpened={isOpened}
|
||||
backendType={backend.type}
|
||||
item={item}
|
||||
@ -898,7 +936,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
<AssetContextMenu
|
||||
hidden
|
||||
innerProps={{
|
||||
key,
|
||||
key: item.key,
|
||||
item,
|
||||
setItem,
|
||||
state,
|
||||
|
@ -9,6 +9,6 @@ export const INITIAL_ROW_STATE: assetsTable.AssetRowState = Object.freeze({
|
||||
// Ignored. This MUST be replaced by the row component. It should also update `visibility`.
|
||||
},
|
||||
isEditingName: false,
|
||||
temporarilyAddedLabels: set.EMPTY,
|
||||
temporarilyRemovedLabels: set.EMPTY,
|
||||
temporarilyAddedLabels: set.EMPTY_SET,
|
||||
temporarilyRemovedLabels: set.EMPTY_SET,
|
||||
})
|
||||
|
@ -1,9 +1,11 @@
|
||||
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import DatalinkIcon from '#/assets/datalink.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
@ -48,7 +50,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const asset = item.item
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
|
@ -1,13 +1,16 @@
|
||||
/** @file The icon and name of a {@link backendModule.DirectoryAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import FolderIcon from '#/assets/folder.svg'
|
||||
import FolderArrowIcon from '#/assets/folder_arrow.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
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'
|
||||
|
||||
@ -43,11 +46,12 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { backend, selectedKeys, nodeMap } = state
|
||||
const { backend, nodeMap } = state
|
||||
const { doToggleDirectoryExpansion } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const driveStore = useDriveStore()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
if (item.type !== backendModule.AssetType.directory) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -57,8 +61,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const isExpanded = item.children != null && item.isExpanded
|
||||
|
||||
const createDirectoryMutation = backendHooks.useBackendMutation(backend, 'createDirectory')
|
||||
const updateDirectoryMutation = backendHooks.useBackendMutation(backend, 'updateDirectory')
|
||||
const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory'))
|
||||
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
@ -165,7 +169,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
} else if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
selected &&
|
||||
selectedKeys.current.size === 1
|
||||
driveStore.getState().selectedKeys.size === 1
|
||||
) {
|
||||
event.stopPropagation()
|
||||
setIsEditing(true)
|
||||
|
@ -1,7 +1,9 @@
|
||||
/** @file The icon and name of a {@link backendModule.FileAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
@ -50,13 +52,15 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
|
||||
const updateFileMutation = backendHooks.useBackendMutation(backend, 'updateFile')
|
||||
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
|
||||
meta: {
|
||||
invalidates: [['assetVersions', item.item.id, item.item.title]],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
})
|
||||
const updateFileMutation = useMutation(backendMutationOptions(backend, 'updateFile'))
|
||||
const uploadFileMutation = useMutation(
|
||||
backendMutationOptions(backend, 'uploadFile', {
|
||||
meta: {
|
||||
invalidates: [['assetVersions', item.item.id, item.item.title]],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
|
@ -1,20 +1,22 @@
|
||||
/** @file Permissions for a specific user or user group on a specific asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import type * as text from 'enso-common/src/text'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { Text } from '#/components/AriaComponents'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =================
|
||||
@ -58,7 +60,9 @@ export default function Permission(props: PermissionProps) {
|
||||
const isDisabled = isOnlyOwner && permissionId === self.user.userId
|
||||
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
||||
|
||||
const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission')
|
||||
const createPermission = useMutation(
|
||||
backendMutationOptions(backend, 'createPermission'),
|
||||
).mutateAsync
|
||||
|
||||
React.useEffect(() => {
|
||||
setPermission(initialPermission)
|
||||
@ -68,7 +72,7 @@ export default function Permission(props: PermissionProps) {
|
||||
try {
|
||||
setPermission(newPermission)
|
||||
outerSetPermission(newPermission)
|
||||
await createPermissionMutation.mutateAsync([
|
||||
await createPermission([
|
||||
{
|
||||
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
|
||||
resourceId: asset.id,
|
||||
@ -85,7 +89,7 @@ export default function Permission(props: PermissionProps) {
|
||||
return (
|
||||
<FocusArea active={!isDisabled} direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<div className="flex items-center gap-user-permission" {...innerProps}>
|
||||
<div className="flex w-full items-center gap-user-permission" {...innerProps}>
|
||||
<PermissionSelector
|
||||
showDelete
|
||||
isDisabled={isDisabled}
|
||||
@ -100,7 +104,7 @@ export default function Permission(props: PermissionProps) {
|
||||
doDelete(backendModule.getAssetPermissionId(permission))
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text">{backendModule.getAssetPermissionName(permission)}</aria.Text>
|
||||
<Text truncate="1">{backendModule.getAssetPermissionName(permission)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
|
@ -1,16 +1,17 @@
|
||||
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import NetworkIcon from '#/assets/network.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
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'
|
||||
|
||||
@ -59,13 +60,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
backendType,
|
||||
isOpened,
|
||||
} = props
|
||||
const { backend, selectedKeys, nodeMap } = state
|
||||
const client = reactQuery.useQueryClient()
|
||||
const { backend, nodeMap } = state
|
||||
const client = useQueryClient()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const driveStore = useDriveStore()
|
||||
const doOpenProject = projectHooks.useOpenProject()
|
||||
|
||||
if (item.type !== backendModule.AssetType.project) {
|
||||
@ -94,16 +96,20 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const isOtherUserUsingProject =
|
||||
isCloud && projectState.openedBy != null && projectState.openedBy !== user.email
|
||||
|
||||
const createProjectMutation = backendHooks.useBackendMutation(backend, 'createProject')
|
||||
const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject')
|
||||
const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject')
|
||||
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
|
||||
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
|
||||
meta: {
|
||||
invalidates: [['assetVersions', item.item.id, item.item.title]],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
})
|
||||
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
|
||||
const updateProjectMutation = useMutation(backendMutationOptions(backend, 'updateProject'))
|
||||
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const uploadFileMutation = useMutation(
|
||||
backendMutationOptions(backend, 'uploadFile', {
|
||||
meta: {
|
||||
invalidates: [['assetVersions', item.item.id, item.item.title]],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
@ -321,7 +327,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
!isRunning &&
|
||||
eventModule.isSingleClick(event) &&
|
||||
selected &&
|
||||
selectedKeys.current.size === 1
|
||||
driveStore.getState().selectedKeys.size === 1
|
||||
) {
|
||||
setIsEditing(true)
|
||||
} else if (eventModule.isDoubleClick(event)) {
|
||||
|
@ -1,9 +1,11 @@
|
||||
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import KeyIcon from '#/assets/key.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
@ -52,8 +54,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
}
|
||||
const asset = item.item
|
||||
|
||||
const createSecretMutation = backendHooks.useBackendMutation(backend, 'createSecret')
|
||||
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
|
||||
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
|
||||
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
|
@ -10,5 +10,6 @@ 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()
|
||||
|
||||
return <>{modal}</>
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const labels = backendHooks.useBackendListTags(backend)
|
||||
const labels = backendHooks.useListTags(backend)
|
||||
const labelsByName = React.useMemo(() => {
|
||||
return new Map(labels?.map((label) => [label.value, label]))
|
||||
}, [labels])
|
||||
|
@ -103,7 +103,6 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
feature="share"
|
||||
variant="icon"
|
||||
size="medium"
|
||||
tooltipPlacement="left"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
children={false}
|
||||
/>
|
||||
|
@ -18,7 +18,7 @@ export default function Separator(props: SeparatorProps) {
|
||||
|
||||
return (
|
||||
!hidden && (
|
||||
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-[0.5px] border-black/[0.16]" />
|
||||
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-0.5 border-black/[0.16]" />
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
import * as aria from '#/components/aria'
|
||||
import Button from '#/components/styled/Button'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
// =====================
|
||||
// === SettingsInput ===
|
||||
@ -61,8 +62,10 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
|
||||
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
|
||||
{
|
||||
ref,
|
||||
className:
|
||||
'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame',
|
||||
className: twMerge(
|
||||
'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame px-1 border-0.5 border-transparent',
|
||||
!isDisabled && 'border-primary/20',
|
||||
),
|
||||
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
|
||||
disabled: isDisabled,
|
||||
size: 1,
|
||||
|
@ -10,6 +10,7 @@ import ArrowLeftIcon from '#/assets/arrow_left.svg'
|
||||
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
||||
import CameraIcon from '#/assets/camera.svg'
|
||||
import CloseIcon from '#/assets/close.svg'
|
||||
import CloseTabIcon from '#/assets/close_tab.svg'
|
||||
import CloudToIcon from '#/assets/cloud_to.svg'
|
||||
import CopyIcon from '#/assets/copy.svg'
|
||||
import CopyAsPathIcon from '#/assets/copy_as_path.svg'
|
||||
@ -52,6 +53,8 @@ export function createBindings() {
|
||||
|
||||
export const BINDINGS = inputBindings.defineBindings({
|
||||
settings: { name: 'Settings', bindings: ['Mod+,'], icon: SettingsIcon },
|
||||
// An alternative shortcut is required because Mod+W cannot be overridden in browsers.
|
||||
closeTab: { name: 'Close Tab', bindings: ['Mod+W', 'Mod+Alt+W'], icon: CloseTabIcon },
|
||||
open: { name: 'Open', bindings: ['Enter'], icon: OpenIcon },
|
||||
run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon },
|
||||
close: { name: 'Close', bindings: [], icon: CloseIcon },
|
||||
|
@ -16,16 +16,17 @@ import * as uniqueString from '#/utilities/uniqueString'
|
||||
// === revokeUserPictureUrl ===
|
||||
// ============================
|
||||
|
||||
const USER_PICTURE_URL_REVOKERS = new WeakMap<Backend, () => void>()
|
||||
const USER_PICTURE_URLS = new Map<backendModule.BackendType, string>()
|
||||
|
||||
/** Create the corresponding "user picture" URL for the given backend. */
|
||||
function createUserPictureUrl(backend: Backend | null, picture: Blob) {
|
||||
if (backend != null) {
|
||||
USER_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
function createUserPictureUrl(
|
||||
backendType: backendModule.BackendType | null | undefined,
|
||||
picture: Blob,
|
||||
) {
|
||||
if (backendType != null) {
|
||||
revokeUserPictureUrl(backendType)
|
||||
const url = URL.createObjectURL(picture)
|
||||
USER_PICTURE_URL_REVOKERS.set(backend, () => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
USER_PICTURE_URLS.set(backendType, url)
|
||||
return url
|
||||
} else {
|
||||
// This should never happen, so use an arbitrary URL.
|
||||
@ -34,9 +35,12 @@ function createUserPictureUrl(backend: Backend | null, picture: Blob) {
|
||||
}
|
||||
|
||||
/** Revoke the corresponding "user picture" URL for the given backend. */
|
||||
function revokeUserPictureUrl(backend: Backend | null) {
|
||||
if (backend != null) {
|
||||
USER_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
function revokeUserPictureUrl(backendType: backendModule.BackendType | null | undefined) {
|
||||
if (backendType != null) {
|
||||
const url = USER_PICTURE_URLS.get(backendType)
|
||||
if (url != null) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,16 +48,17 @@ function revokeUserPictureUrl(backend: Backend | null) {
|
||||
// === revokeOrganizationPictureUrl ===
|
||||
// ====================================
|
||||
|
||||
const ORGANIZATION_PICTURE_URL_REVOKERS = new WeakMap<Backend, () => void>()
|
||||
const ORGANIZATION_PICTURE_URLS = new Map<backendModule.BackendType, string>()
|
||||
|
||||
/** Create the corresponding "organization picture" URL for the given backend. */
|
||||
function createOrganizationPictureUrl(backend: Backend | null, picture: Blob) {
|
||||
if (backend != null) {
|
||||
ORGANIZATION_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
function createOrganizationPictureUrl(
|
||||
backendType: backendModule.BackendType | null | undefined,
|
||||
picture: Blob,
|
||||
) {
|
||||
if (backendType != null) {
|
||||
revokeOrganizationPictureUrl(backendType)
|
||||
const url = URL.createObjectURL(picture)
|
||||
ORGANIZATION_PICTURE_URL_REVOKERS.set(backend, () => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
ORGANIZATION_PICTURE_URLS.set(backendType, url)
|
||||
return url
|
||||
} else {
|
||||
// This should never happen, so use an arbitrary URL.
|
||||
@ -62,102 +67,70 @@ function createOrganizationPictureUrl(backend: Backend | null, picture: Blob) {
|
||||
}
|
||||
|
||||
/** Revoke the corresponding "organization picture" URL for the given backend. */
|
||||
function revokeOrganizationPictureUrl(backend: Backend | null) {
|
||||
if (backend != null) {
|
||||
ORGANIZATION_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
function revokeOrganizationPictureUrl(backendType: backendModule.BackendType | null | undefined) {
|
||||
if (backendType != null) {
|
||||
const url = ORGANIZATION_PICTURE_URLS.get(backendType)
|
||||
if (url != null) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === useObserveBackend ===
|
||||
// =========================
|
||||
// ============================
|
||||
// === DefineBackendMethods ===
|
||||
// ============================
|
||||
|
||||
/** Listen to all mutations and update state as appropriate when they succeed.
|
||||
* MUST be unconditionally called exactly once for each backend type. */
|
||||
export function useObserveBackend(backend: Backend | null) {
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
const [seen] = React.useState(new WeakSet())
|
||||
const useObserveMutations = <Method extends backendQuery.BackendMethods>(
|
||||
method: Method,
|
||||
onSuccess: (
|
||||
state: reactQuery.MutationState<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>,
|
||||
) => void,
|
||||
) => {
|
||||
const states = reactQuery.useMutationState<Parameters<Backend[Method]>>({
|
||||
// Errored mutations can be safely ignored as they should not change the state.
|
||||
filters: { mutationKey: [backend?.type, method], status: 'success' },
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
select: (mutation) => mutation.state as never,
|
||||
})
|
||||
for (const state of states) {
|
||||
if (!seen.has(state)) {
|
||||
seen.add(state)
|
||||
// This is SAFE - it is just too highly dynamic for TypeScript to typecheck.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onSuccess(state as never)
|
||||
}
|
||||
}
|
||||
}
|
||||
const setQueryData = <Method extends backendQuery.BackendMethods>(
|
||||
method: Method,
|
||||
updater: (
|
||||
variable: Awaited<ReturnType<Backend[Method]>>,
|
||||
) => Awaited<ReturnType<Backend[Method]>>,
|
||||
) => {
|
||||
queryClient.setQueryData<Awaited<ReturnType<Backend[Method]>>>(
|
||||
[backend?.type, method],
|
||||
(data) => (data == null ? data : updater(data)),
|
||||
)
|
||||
}
|
||||
useObserveMutations('uploadUserPicture', (state) => {
|
||||
revokeUserPictureUrl(backend)
|
||||
setQueryData('usersMe', (user) => state.data ?? user)
|
||||
})
|
||||
useObserveMutations('updateOrganization', (state) => {
|
||||
setQueryData('getOrganization', (organization) => state.data ?? organization)
|
||||
})
|
||||
useObserveMutations('uploadOrganizationPicture', (state) => {
|
||||
revokeOrganizationPictureUrl(backend)
|
||||
setQueryData('getOrganization', (organization) => state.data ?? organization)
|
||||
})
|
||||
useObserveMutations('createUserGroup', (state) => {
|
||||
if (state.data != null) {
|
||||
const data = state.data
|
||||
setQueryData('listUserGroups', (userGroups) => [data, ...userGroups])
|
||||
}
|
||||
})
|
||||
useObserveMutations('deleteUserGroup', (state) => {
|
||||
setQueryData('listUserGroups', (userGroups) =>
|
||||
userGroups.filter((userGroup) => userGroup.id !== state.variables?.[0]),
|
||||
)
|
||||
})
|
||||
useObserveMutations('changeUserGroup', (state) => {
|
||||
if (state.variables != null) {
|
||||
const [userId, body] = state.variables
|
||||
setQueryData('listUsers', (users) =>
|
||||
users.map((user) =>
|
||||
user.userId !== userId ? user : { ...user, userGroups: body.userGroups },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
useObserveMutations('createTag', (state) => {
|
||||
if (state.data != null) {
|
||||
const data = state.data
|
||||
setQueryData('listTags', (tags) => [...tags, data])
|
||||
}
|
||||
})
|
||||
useObserveMutations('deleteTag', (state) => {
|
||||
if (state.variables != null) {
|
||||
const [tagId] = state.variables
|
||||
setQueryData('listTags', (tags) => tags.filter((tag) => tag.id !== tagId))
|
||||
}
|
||||
})
|
||||
}
|
||||
/** Ensure that the given type contains only names of backend methods. */
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
type DefineBackendMethods<T extends keyof Backend> = T
|
||||
|
||||
// ======================
|
||||
// === MutationMethod ===
|
||||
// ======================
|
||||
|
||||
/** Names of methods corresponding to mutations. */
|
||||
export type MutationMethod = DefineBackendMethods<
|
||||
| 'associateTag'
|
||||
| 'changeUserGroup'
|
||||
| 'closeProject'
|
||||
| 'copyAsset'
|
||||
| 'createCheckoutSession'
|
||||
| 'createDatalink'
|
||||
| 'createDirectory'
|
||||
| 'createPermission'
|
||||
| 'createProject'
|
||||
| 'createSecret'
|
||||
| 'createTag'
|
||||
| 'createUser'
|
||||
| 'createUserGroup'
|
||||
| 'deleteAsset'
|
||||
| 'deleteDatalink'
|
||||
| 'deleteInvitation'
|
||||
| 'deleteTag'
|
||||
| 'deleteUser'
|
||||
| 'deleteUserGroup'
|
||||
| 'duplicateProject'
|
||||
// TODO: `get*` are not mutations, but are currently used in some places.
|
||||
| 'getDatalink'
|
||||
| 'getFileDetails'
|
||||
| 'getProjectDetails'
|
||||
| 'inviteUser'
|
||||
| 'logEvent'
|
||||
| 'openProject'
|
||||
| 'removeUser'
|
||||
| 'resendInvitation'
|
||||
| 'undoDeleteAsset'
|
||||
| 'updateAsset'
|
||||
| 'updateDirectory'
|
||||
| 'updateFile'
|
||||
| 'updateOrganization'
|
||||
| 'updateProject'
|
||||
| 'updateSecret'
|
||||
| 'updateUser'
|
||||
| 'uploadFile'
|
||||
| 'uploadOrganizationPicture'
|
||||
| 'uploadUserPicture'
|
||||
>
|
||||
|
||||
// =======================
|
||||
// === useBackendQuery ===
|
||||
@ -168,28 +141,20 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>,
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
|
||||
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>,
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): reactQuery.UseQueryResult<
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
Awaited<ReturnType<Backend[Method]>> | undefined
|
||||
@ -200,21 +165,12 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>,
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
) {
|
||||
return reactQuery.useQuery<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>({
|
||||
return reactQuery.useQuery<Awaited<ReturnType<Backend[Method]>>>({
|
||||
...options,
|
||||
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
@ -226,7 +182,19 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
// === useBackendMutation ===
|
||||
// ==========================
|
||||
|
||||
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
|
||||
const INVALIDATION_MAP: Partial<Record<MutationMethod, readonly backendQuery.BackendMethods[]>> = {
|
||||
updateUser: ['usersMe'],
|
||||
uploadUserPicture: ['usersMe'],
|
||||
updateOrganization: ['getOrganization'],
|
||||
uploadOrganizationPicture: ['getOrganization'],
|
||||
createUserGroup: ['listUserGroups'],
|
||||
deleteUserGroup: ['listUserGroups'],
|
||||
changeUserGroup: ['listUsers'],
|
||||
createTag: ['listTags'],
|
||||
deleteTag: ['listTags'],
|
||||
}
|
||||
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
@ -237,12 +205,12 @@ export function useBackendMutation<Method extends backendQuery.BackendMethods>(
|
||||
>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): reactQuery.UseMutationResult<
|
||||
): reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>
|
||||
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
@ -253,14 +221,13 @@ export function useBackendMutation<Method extends backendQuery.BackendMethods>(
|
||||
>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): reactQuery.UseMutationResult<
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
): reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>> | undefined,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>
|
||||
/** Wrap a backend method call in a React Query Mutation. */
|
||||
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
@ -271,18 +238,25 @@ export function useBackendMutation<Method extends backendQuery.BackendMethods>(
|
||||
>,
|
||||
'mutationFn'
|
||||
>,
|
||||
) {
|
||||
return reactQuery.useMutation<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>({
|
||||
): reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
> {
|
||||
return {
|
||||
...options,
|
||||
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
mutationFn: (args) => (backend?.[method] as any)?.(...args),
|
||||
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online',
|
||||
})
|
||||
meta: {
|
||||
invalidates: [
|
||||
...(options?.meta?.invalidates ?? []),
|
||||
...(INVALIDATION_MAP[method]?.map((queryMethod) => [backend?.type, queryMethod]) ?? []),
|
||||
],
|
||||
awaitInvalidates: options?.meta?.awaitInvalidates ?? true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================
|
||||
@ -305,32 +279,6 @@ export function useBackendMutationVariables<Method extends backendQuery.BackendM
|
||||
})
|
||||
}
|
||||
|
||||
// =======================================
|
||||
// === useBackendMutationWithVariables ===
|
||||
// =======================================
|
||||
|
||||
/** Wrap a backend method call in a React Query Mutation, and access its variables. */
|
||||
export function useBackendMutationWithVariables<Method extends backendQuery.BackendMethods>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>,
|
||||
'mutationFn'
|
||||
>,
|
||||
) {
|
||||
const mutation = useBackendMutation(backend, method, options)
|
||||
return {
|
||||
mutation,
|
||||
mutate: mutation.mutate,
|
||||
mutateAsync: mutation.mutateAsync,
|
||||
variables: useBackendMutationVariables(backend, method, options?.mutationKey),
|
||||
}
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === Placeholder ===
|
||||
// ===================
|
||||
@ -356,12 +304,12 @@ function toNonPlaceholder<T extends object>(object: T) {
|
||||
return { ...object, isPlaceholder: false }
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// === useBackendListUsers ===
|
||||
// ===========================
|
||||
// ====================
|
||||
// === useListUsers ===
|
||||
// ====================
|
||||
|
||||
/** A list of users, taking into account optimistic state. */
|
||||
export function useBackendListUsers(
|
||||
export function useListUsers(
|
||||
backend: Backend,
|
||||
): readonly WithPlaceholder<backendModule.User>[] | null {
|
||||
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
|
||||
@ -384,12 +332,12 @@ export function useBackendListUsers(
|
||||
}, [changeUserGroupVariables, listUsersQuery.data])
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === useBackendListUserGroups ===
|
||||
// ================================
|
||||
// =========================
|
||||
// === useListUserGroups ===
|
||||
// =========================
|
||||
|
||||
/** A list of user groups, taking into account optimistic state. */
|
||||
export function useBackendListUserGroups(
|
||||
export function useListUserGroups(
|
||||
backend: Backend,
|
||||
): readonly WithPlaceholder<backendModule.UserGroupInfo>[] | null {
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
@ -422,9 +370,9 @@ export function useBackendListUserGroups(
|
||||
])
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// === useBackendListUserGroupsWithUsers ===
|
||||
// =========================================
|
||||
// ==================================
|
||||
// === useListUserGroupsWithUsers ===
|
||||
// ==================================
|
||||
|
||||
/** A user group, as well as the users that are a part of the user group. */
|
||||
export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
|
||||
@ -432,14 +380,14 @@ export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
|
||||
}
|
||||
|
||||
/** A list of user groups, taking into account optimistic state. */
|
||||
export function useBackendListUserGroupsWithUsers(
|
||||
export function useListUserGroupsWithUsers(
|
||||
backend: Backend,
|
||||
): readonly WithPlaceholder<UserGroupInfoWithUsers>[] | null {
|
||||
const userGroupsRaw = useBackendListUserGroups(backend)
|
||||
const userGroupsRaw = useListUserGroups(backend)
|
||||
// Old user list
|
||||
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
|
||||
// Current user list, including optimistic updates
|
||||
const users = useBackendListUsers(backend)
|
||||
const users = useListUsers(backend)
|
||||
return React.useMemo(() => {
|
||||
if (userGroupsRaw == null || listUsersQuery.data == null || users == null) {
|
||||
return null
|
||||
@ -464,12 +412,12 @@ export function useBackendListUserGroupsWithUsers(
|
||||
}, [listUsersQuery.data, userGroupsRaw, users])
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === useBackendListTags ===
|
||||
// ==========================
|
||||
// ===================
|
||||
// === useListTags ===
|
||||
// ===================
|
||||
|
||||
/** A list of asset tags, taking into account optimistic state. */
|
||||
export function useBackendListTags(
|
||||
export function useListTags(
|
||||
backend: Backend | null,
|
||||
): readonly WithPlaceholder<backendModule.Label>[] | null {
|
||||
const listTagsQuery = useBackendQuery(backend, 'listTags', [])
|
||||
@ -496,12 +444,12 @@ export function useBackendListTags(
|
||||
}, [createTagVariables, deleteTagVariables, listTagsQuery.data])
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === useBackendUsersMe ===
|
||||
// =========================
|
||||
// ==================
|
||||
// === useUsersMe ===
|
||||
// ==================
|
||||
|
||||
/** The current user, taking into account optimistic state. */
|
||||
export function useBackendUsersMe(backend: Backend | null) {
|
||||
export function useUsersMe(backend: Backend | null) {
|
||||
const usersMeQuery = useBackendQuery(backend, 'usersMe', [])
|
||||
const updateUserVariables = useBackendMutationVariables(backend, 'updateUser')
|
||||
const uploadUserPictureVariables = useBackendMutationVariables(backend, 'uploadUserPicture')
|
||||
@ -518,7 +466,7 @@ export function useBackendUsersMe(backend: Backend | null) {
|
||||
for (const [, file] of uploadUserPictureVariables) {
|
||||
result = {
|
||||
...result,
|
||||
profilePicture: backendModule.HttpsUrl(createUserPictureUrl(backend, file)),
|
||||
profilePicture: backendModule.HttpsUrl(createUserPictureUrl(backend?.type, file)),
|
||||
}
|
||||
}
|
||||
return result
|
||||
@ -526,12 +474,12 @@ export function useBackendUsersMe(backend: Backend | null) {
|
||||
}, [backend, usersMeQuery.data, updateUserVariables, uploadUserPictureVariables])
|
||||
}
|
||||
|
||||
// =================================
|
||||
// === useBackendGetOrganization ===
|
||||
// =================================
|
||||
// ==========================
|
||||
// === useGetOrganization ===
|
||||
// ==========================
|
||||
|
||||
/** The current user's organization, taking into account optimistic state. */
|
||||
export function useBackendGetOrganization(backend: Backend | null) {
|
||||
export function useGetOrganization(backend: Backend | null) {
|
||||
const getOrganizationQuery = useBackendQuery(backend, 'getOrganization', [])
|
||||
const updateOrganizationVariables = useBackendMutationVariables(backend, 'updateOrganization')
|
||||
const uploadOrganizationPictureVariables = useBackendMutationVariables(
|
||||
@ -549,7 +497,7 @@ export function useBackendGetOrganization(backend: Backend | null) {
|
||||
for (const [, file] of uploadOrganizationPictureVariables) {
|
||||
result = {
|
||||
...result,
|
||||
picture: backendModule.HttpsUrl(createOrganizationPictureUrl(backend, file)),
|
||||
picture: backendModule.HttpsUrl(createOrganizationPictureUrl(backend?.type, file)),
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
@ -28,9 +28,11 @@ export function usePaywall(props: UsePaywallProps) {
|
||||
|
||||
const { getFeature } = paywallFeatures.usePaywallFeatures()
|
||||
const { features } = devtools.usePaywallDevtools()
|
||||
const paywallLevel = paywallConfiguration.mapPlanOnPaywall(plan)
|
||||
|
||||
const getPaywallLevel = eventCallbackHooks.useEventCallback(() =>
|
||||
paywallConfiguration.mapPlanOnPaywall(plan),
|
||||
const getPaywallLevel = eventCallbackHooks.useEventCallback(
|
||||
(specifiedPlan: backend.Plan | undefined) =>
|
||||
paywallConfiguration.mapPlanOnPaywall(specifiedPlan),
|
||||
)
|
||||
|
||||
const isFeatureUnderPaywall = eventCallbackHooks.useEventCallback(
|
||||
@ -38,7 +40,6 @@ export function usePaywall(props: UsePaywallProps) {
|
||||
const featureConfig = getFeature(feature)
|
||||
const { isForceEnabled } = features[feature]
|
||||
const { level } = featureConfig
|
||||
const paywallLevel = getPaywallLevel()
|
||||
|
||||
if (isForceEnabled == null) {
|
||||
return level > paywallLevel
|
||||
@ -48,5 +49,10 @@ export function usePaywall(props: UsePaywallProps) {
|
||||
},
|
||||
)
|
||||
|
||||
return { isFeatureUnderPaywall, getPaywallLevel, getFeature } as const
|
||||
return {
|
||||
paywallLevel,
|
||||
isFeatureUnderPaywall,
|
||||
getPaywallLevel,
|
||||
getFeature,
|
||||
} as const
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
|
||||
// ======================
|
||||
// === contextMenuRef ===
|
||||
@ -16,44 +17,52 @@ export function useContextMenuRef(
|
||||
key: string,
|
||||
label: string,
|
||||
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null,
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const createEntriesRef = React.useRef(createEntries)
|
||||
createEntriesRef.current = createEntries
|
||||
const optionsRef = useSyncRef(options)
|
||||
const cleanupRef = React.useRef(() => {})
|
||||
const [contextMenuRef] = React.useState(() => (element: HTMLElement | null) => {
|
||||
cleanupRef.current()
|
||||
if (element == null) {
|
||||
cleanupRef.current = () => {}
|
||||
} else {
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||
const children = createEntriesRef.current(position)
|
||||
if (children != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<ContextMenus
|
||||
ref={(contextMenusElement) => {
|
||||
if (contextMenusElement != null) {
|
||||
const rect = contextMenusElement.getBoundingClientRect()
|
||||
position.pageX = rect.left
|
||||
position.pageY = rect.top
|
||||
}
|
||||
}}
|
||||
key={key}
|
||||
event={event}
|
||||
>
|
||||
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
||||
</ContextMenus>,
|
||||
)
|
||||
const contextMenuRef = React.useMemo(
|
||||
() => (element: HTMLElement | null) => {
|
||||
cleanupRef.current()
|
||||
if (element == null) {
|
||||
cleanupRef.current = () => {}
|
||||
} else {
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const { enabled = true } = optionsRef.current
|
||||
if (enabled) {
|
||||
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||
const children = createEntriesRef.current(position)
|
||||
if (children != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<ContextMenus
|
||||
ref={(contextMenusElement) => {
|
||||
if (contextMenusElement != null) {
|
||||
const rect = contextMenusElement.getBoundingClientRect()
|
||||
position.pageX = rect.left
|
||||
position.pageY = rect.top
|
||||
}
|
||||
}}
|
||||
key={key}
|
||||
event={event}
|
||||
>
|
||||
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
||||
</ContextMenus>,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
element.addEventListener('contextmenu', onContextMenu)
|
||||
cleanupRef.current = () => {
|
||||
element.removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}
|
||||
element.addEventListener('contextmenu', onContextMenu)
|
||||
cleanupRef.current = () => {
|
||||
element.removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[key, label, optionsRef, setModal],
|
||||
)
|
||||
return contextMenuRef
|
||||
}
|
||||
|
48
app/dashboard/src/hooks/mountHooks.ts
Normal file
48
app/dashboard/src/hooks/mountHooks.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Contains hooks that are called when the component mounts.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { useEventCallback } from './eventCallbackHooks'
|
||||
|
||||
/**
|
||||
* Executes the provided callback during the first render of the component.
|
||||
* Unlike `useEffect(() => {}, [])`, this hook executes the callback during the first render.
|
||||
*/
|
||||
export function useMount(callback: () => void) {
|
||||
const isFirstRender = useIsFirstRender()
|
||||
const stableCallback = useEventCallback(callback)
|
||||
|
||||
if (isFirstRender()) {
|
||||
stableCallback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the provided callback once component is mounted.
|
||||
* Similar to `componentDidMount` in class components,
|
||||
* or `useLayoutEffect(() => {}, [])` with an empty dependency array.
|
||||
*/
|
||||
export function useMounted(callback: () => void) {
|
||||
const stableCallback = useEventCallback(callback)
|
||||
|
||||
useEffect(() => {
|
||||
stableCallback()
|
||||
// stable callback never changes.
|
||||
}, [stableCallback])
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function that returns `true` if the component renders for the first time.
|
||||
*/
|
||||
export function useIsFirstRender() {
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
isFirstMount.current = false
|
||||
}, [])
|
||||
|
||||
return useEventCallback(() => isFirstMount.current)
|
||||
}
|
@ -144,11 +144,11 @@ export function useOpenProjectMutation() {
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: async (_, { id }) => {
|
||||
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
|
||||
},
|
||||
onSuccess: (_, { id }) =>
|
||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
onError: (_, { id }) =>
|
||||
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
})
|
||||
}
|
||||
|
||||
@ -177,7 +177,6 @@ export function useCloseProjectMutation() {
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onSuccess: (_, { id }) =>
|
||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
|
@ -9,9 +9,14 @@ import * as React from 'react'
|
||||
/**
|
||||
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
|
||||
*/
|
||||
export function useSyncRef<T>(value: T): React.MutableRefObject<T> {
|
||||
export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> {
|
||||
const ref = React.useRef(value)
|
||||
ref.current = value
|
||||
|
||||
// Update the ref value whenever the provided value changes
|
||||
// Refs shall never change during the render phase, so we use `useEffect` here.
|
||||
React.useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
|
||||
return ref
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as sentry from '@sentry/react'
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import * as reactDOM from 'react-dom/client'
|
||||
import * as reactRouter from 'react-router-dom'
|
||||
import invariant from 'tiny-invariant'
|
||||
@ -14,11 +14,16 @@ import * as detect from 'enso-common/src/detect'
|
||||
import type * as app from '#/App'
|
||||
import App from '#/App'
|
||||
|
||||
import { HttpClientProvider } from '#/providers/HttpClientProvider'
|
||||
import LoggerProvider, { type Logger } from '#/providers/LoggerProvider'
|
||||
|
||||
import LoadingScreen from '#/pages/authentication/LoadingScreen'
|
||||
|
||||
import * as devtools from '#/components/Devtools'
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
import { ReactQueryDevtools } from '#/components/Devtools'
|
||||
import { ErrorBoundary } from '#/components/ErrorBoundary'
|
||||
import { OfflineNotificationManager } from '#/components/OfflineNotificationManager'
|
||||
import { Suspense } from '#/components/Suspense'
|
||||
import UIProviders from '#/components/UIProviders'
|
||||
|
||||
import HttpClient from '#/utilities/HttpClient'
|
||||
|
||||
@ -35,6 +40,15 @@ const ROOT_ELEMENT_ID = 'enso-dashboard'
|
||||
/** The fraction of non-erroring interactions that should be sampled by Sentry. */
|
||||
const SENTRY_SAMPLE_RATE = 0.005
|
||||
|
||||
// ======================
|
||||
// === DashboardProps ===
|
||||
// ======================
|
||||
|
||||
/** Props for the dashboard. */
|
||||
export interface DashboardProps extends app.AppProps {
|
||||
readonly logger: Logger
|
||||
}
|
||||
|
||||
// ===========
|
||||
// === run ===
|
||||
// ===========
|
||||
@ -47,8 +61,8 @@ const SENTRY_SAMPLE_RATE = 0.005
|
||||
export // This export declaration must be broken up to satisfy the `require-jsdoc` rule.
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
function run(props: Omit<app.AppProps, 'httpClient' | 'portalRoot'>) {
|
||||
const { vibrancy, supportsDeepLinks, queryClient } = props
|
||||
function run(props: DashboardProps) {
|
||||
const { vibrancy, supportsDeepLinks, queryClient, logger } = props
|
||||
if (
|
||||
!detect.IS_DEV_MODE &&
|
||||
process.env.ENSO_CLOUD_SENTRY_DSN != null &&
|
||||
@ -67,8 +81,9 @@ function run(props: Omit<app.AppProps, 'httpClient' | 'portalRoot'>) {
|
||||
reactRouter.matchRoutes,
|
||||
),
|
||||
}),
|
||||
sentry.extraErrorDataIntegration({ captureErrorCause: true }),
|
||||
sentry.replayIntegration(),
|
||||
new sentry.BrowserProfilingIntegration(),
|
||||
new sentry.Replay(),
|
||||
],
|
||||
profilesSampleRate: SENTRY_SAMPLE_RATE,
|
||||
tracesSampleRate: SENTRY_SAMPLE_RATE,
|
||||
@ -98,20 +113,23 @@ function run(props: Omit<app.AppProps, 'httpClient' | 'portalRoot'>) {
|
||||
React.startTransition(() => {
|
||||
reactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<reactQuery.QueryClientProvider client={queryClient}>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense fallback={<LoadingScreen />}>
|
||||
<App
|
||||
{...props}
|
||||
supportsDeepLinks={actuallySupportsDeepLinks}
|
||||
portalRoot={portalRoot}
|
||||
httpClient={httpClient}
|
||||
/>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<OfflineNotificationManager>
|
||||
<LoggerProvider logger={logger}>
|
||||
<HttpClientProvider httpClient={httpClient}>
|
||||
<UIProviders locale="en-US" portalRoot={portalRoot}>
|
||||
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
|
||||
</UIProviders>
|
||||
</HttpClientProvider>
|
||||
</LoggerProvider>
|
||||
</OfflineNotificationManager>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
<devtools.ReactQueryDevtools />
|
||||
</reactQuery.QueryClientProvider>
|
||||
<ReactQueryDevtools />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
})
|
||||
|
@ -88,7 +88,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
category !== Category.cloud && category !== Category.local ? null
|
||||
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
|
||||
: asset.type === backendModule.AssetType.project ?
|
||||
localBackend?.getProjectDirectoryPath(asset.id) ?? null
|
||||
localBackend?.getProjectPath(asset.id) ?? null
|
||||
: localBackendModule.extractTypeAndId(asset.id).id
|
||||
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A panel containing the description and settings for an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -14,7 +16,6 @@ import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
@ -41,9 +42,8 @@ declare module '#/utilities/LocalStorage' {
|
||||
}
|
||||
}
|
||||
|
||||
const TABS = Object.values(AssetPanelTab)
|
||||
LocalStorage.registerKey('assetPanelTab', {
|
||||
tryParse: (value) => (array.includes(TABS, value) ? value : null),
|
||||
schema: z.nativeEnum(AssetPanelTab),
|
||||
})
|
||||
|
||||
// ==================
|
||||
|
@ -1,11 +1,13 @@
|
||||
/** @file Display and modify the properties of an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import PenIcon from '#/assets/pen.svg'
|
||||
|
||||
import * as datalinkValidator from '#/data/datalinkValidator'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions, useListTags } from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -71,7 +73,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
},
|
||||
[setItemRaw],
|
||||
)
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
const labels = useListTags(backend) ?? []
|
||||
const self = item.item.permissions?.find(
|
||||
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
|
||||
)
|
||||
@ -86,13 +88,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const path =
|
||||
isCloud ? null
|
||||
: item.item.type === backendModule.AssetType.project ?
|
||||
localBackend?.getProjectDirectoryPath(item.item.id) ?? null
|
||||
localBackend?.getProjectPath(item.item.id) ?? null
|
||||
: localBackendModule.extractTypeAndId(item.item.id).id
|
||||
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
|
||||
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
|
||||
const getDatalinkMutate = getDatalinkMutation.mutateAsync
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
|
||||
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
|
||||
const getDatalink = getDatalinkMutation.mutateAsync
|
||||
|
||||
React.useEffect(() => {
|
||||
setDescription(item.item.description ?? '')
|
||||
@ -101,13 +103,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
React.useEffect(() => {
|
||||
void (async () => {
|
||||
if (item.item.type === backendModule.AssetType.datalink) {
|
||||
const value = await getDatalinkMutate([item.item.id, item.item.title])
|
||||
const value = await getDatalink([item.item.id, item.item.title])
|
||||
setDatalinkValue(value)
|
||||
setEditedDatalinkValue(value)
|
||||
setIsDatalinkFetched(true)
|
||||
}
|
||||
})()
|
||||
}, [backend, item.item, getDatalinkMutate])
|
||||
}, [backend, item.item, getDatalink])
|
||||
|
||||
const doEditDescription = async () => {
|
||||
setIsEditingDescription(false)
|
||||
|
@ -141,7 +141,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const querySource = React.useRef(QuerySource.external)
|
||||
const rootRef = React.useRef<HTMLLabelElement | null>(null)
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
const labels = backendHooks.useListTags(backend) ?? []
|
||||
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -310,102 +310,109 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
>
|
||||
<div className="h-[32px]" />
|
||||
|
||||
{areSuggestionsVisible && (
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||
<Tags
|
||||
isCloud={isCloud}
|
||||
querySource={querySource}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
{/* Asset labels */}
|
||||
{isCloud && labels.length !== 0 && (
|
||||
<div
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-2 px-1.5"
|
||||
>
|
||||
{[...labels]
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map((label) => {
|
||||
const negated = query.negativeLabels.some((term) =>
|
||||
array.shallowEqual(term, [label.value]),
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
key={label.id}
|
||||
color={label.color}
|
||||
active={
|
||||
negated ||
|
||||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery((oldQuery) => {
|
||||
const newQuery = oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey,
|
||||
)
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Suggestions */}
|
||||
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<aria.Button
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
if (index === selectedIndex) {
|
||||
el?.focus()
|
||||
}
|
||||
}}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
|
||||
selectedIndices.has(index) && 'bg-primary/10',
|
||||
index === selectedIndex && 'bg-selected-frame',
|
||||
)}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index) ?
|
||||
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
|
||||
)
|
||||
if (event.shiftKey) {
|
||||
setSelectedIndices(
|
||||
new Set(
|
||||
selectedIndices.has(index) ?
|
||||
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
|
||||
: [...selectedIndices, index],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'grid transition-grid-template-rows duration-200',
|
||||
areSuggestionsVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||
<Tags
|
||||
isCloud={isCloud}
|
||||
querySource={querySource}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
{/* Asset labels */}
|
||||
{isCloud && labels.length !== 0 && (
|
||||
<div
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-2 px-1.5"
|
||||
>
|
||||
<ariaComponents.Text variant="body" truncate="1" className="w-full">
|
||||
{suggestion.render()}
|
||||
</ariaComponents.Text>
|
||||
</aria.Button>
|
||||
))}
|
||||
{[...labels]
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map((label) => {
|
||||
const negated = query.negativeLabels.some((term) =>
|
||||
array.shallowEqual(term, [label.value]),
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
key={label.id}
|
||||
color={label.color}
|
||||
active={
|
||||
negated ||
|
||||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery((oldQuery) => {
|
||||
const newQuery = oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey,
|
||||
)
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Suggestions */}
|
||||
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<aria.Button
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
if (index === selectedIndex) {
|
||||
el?.focus()
|
||||
}
|
||||
}}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
|
||||
selectedIndices.has(index) && 'bg-primary/10',
|
||||
index === selectedIndex && 'bg-selected-frame',
|
||||
)}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index) ?
|
||||
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
|
||||
)
|
||||
if (event.shiftKey) {
|
||||
setSelectedIndices(
|
||||
new Set(
|
||||
selectedIndices.has(index) ?
|
||||
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
|
||||
: [...selectedIndices, index],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ariaComponents.Text variant="body" truncate="1" className="w-full">
|
||||
{suggestion.render()}
|
||||
</ariaComponents.Text>
|
||||
</aria.Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SvgMask
|
||||
src={FindIcon}
|
||||
|
@ -78,6 +78,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('compareWithLatest')}
|
||||
icon={CompareIcon}
|
||||
@ -94,6 +95,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('restoreThisVersion')}
|
||||
icon={RestoreIcon}
|
||||
@ -109,6 +111,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
</ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('duplicateThisVersion')}
|
||||
icon={DuplicateIcon}
|
||||
@ -137,6 +140,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
{isProject && (
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('restoreThisVersion')}
|
||||
icon={RestoreIcon}
|
||||
@ -149,6 +153,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
{isProject && (
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('duplicateThisVersion')}
|
||||
icon={DuplicateIcon}
|
||||
|
@ -1,14 +1,16 @@
|
||||
/** @file Table displaying a list of projects. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import * as toast from 'react-toastify'
|
||||
import * as z from 'zod'
|
||||
|
||||
import DropFilesImage from '#/assets/drop_files.svg'
|
||||
|
||||
import * as mimeTypes from '#/data/mimeTypes'
|
||||
|
||||
import * as autoScrollHooks from '#/hooks/autoScrollHooks'
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions, useBackendQuery, useListTags } from '#/hooks/backendHooks'
|
||||
import * as intersectionHooks from '#/hooks/intersectionHooks'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
@ -16,6 +18,12 @@ import useOnScroll from '#/hooks/useOnScroll'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import {
|
||||
useDriveStore,
|
||||
useSetCanDownload,
|
||||
useSetSelectedKeys,
|
||||
useSetVisuallySelectedKeys,
|
||||
} from '#/providers/DriveProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
@ -86,16 +94,12 @@ import Visibility from '#/utilities/Visibility'
|
||||
declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly enabledColumns: columnUtils.Column[]
|
||||
readonly enabledColumns: readonly columnUtils.Column[]
|
||||
}
|
||||
}
|
||||
|
||||
LocalStorage.registerKey('enabledColumns', {
|
||||
tryParse: (value) => {
|
||||
const possibleColumns = Array.isArray(value) ? value : []
|
||||
const values = possibleColumns.filter(array.includesPredicate(columnUtils.CLOUD_COLUMNS))
|
||||
return values.length === 0 ? null : values
|
||||
},
|
||||
schema: z.enum(columnUtils.CLOUD_COLUMNS).array().readonly(),
|
||||
})
|
||||
|
||||
// =================
|
||||
@ -310,7 +314,6 @@ const CATEGORY_TO_FILTER_BY: Readonly<Record<Category, backendModule.FilterBy |
|
||||
export interface AssetsTableState {
|
||||
readonly backend: Backend
|
||||
readonly rootDirectoryId: backendModule.DirectoryId
|
||||
readonly selectedKeys: React.MutableRefObject<ReadonlySet<backendModule.AssetId>>
|
||||
readonly scrollContainerRef: React.RefObject<HTMLElement>
|
||||
readonly visibilities: ReadonlyMap<backendModule.AssetId, Visibility>
|
||||
readonly category: Category
|
||||
@ -356,7 +359,6 @@ export interface AssetsTableProps {
|
||||
readonly setSuggestions: React.Dispatch<
|
||||
React.SetStateAction<readonly assetSearchBar.Suggestion[]>
|
||||
>
|
||||
readonly setCanDownload: (canDownload: boolean) => void
|
||||
readonly category: Category
|
||||
readonly initialProjectName: string | null
|
||||
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
|
||||
@ -375,16 +377,17 @@ export interface AssetManagementApi {
|
||||
|
||||
/** The table of project assets. */
|
||||
export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { hidden, query, setQuery, setCanDownload, category, assetManagementApiRef } = props
|
||||
const { hidden, query, setQuery, category, assetManagementApiRef } = props
|
||||
const { setSuggestions, initialProjectName } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const openedProjects = projectsProvider.useLaunchedProjects()
|
||||
const doOpenProject = projectHooks.useOpenProject()
|
||||
const setCanDownload = useSetCanDownload()
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const backend = backendProvider.useBackend(category)
|
||||
const labels = backendHooks.useBackendListTags(backend)
|
||||
const labels = useListTags(backend)
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
@ -400,10 +403,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
|
||||
const [sortInfo, setSortInfo] =
|
||||
React.useState<sorting.SortInfo<columnUtils.SortableColumn> | null>(null)
|
||||
const [selectedKeys, setSelectedKeysRaw] = React.useState<ReadonlySet<backendModule.AssetId>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const selectedKeysRef = React.useRef(selectedKeys)
|
||||
const driveStore = useDriveStore()
|
||||
const setSelectedKeys = useSetSelectedKeys()
|
||||
const setVisuallySelectedKeys = useSetVisuallySelectedKeys()
|
||||
const updateAssetRef = React.useRef<
|
||||
Record<backendModule.AnyAsset['id'], (asset: backendModule.AnyAsset) => void>
|
||||
>({})
|
||||
@ -631,12 +633,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
true,
|
||||
)
|
||||
|
||||
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
|
||||
const updateSecret = useMutation(backendMutationOptions(backend, 'updateSecret')).mutateAsync
|
||||
React.useEffect(() => {
|
||||
previousCategoryRef.current = category
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
if (selectedKeys.size === 0) {
|
||||
targetDirectoryNodeRef.current = null
|
||||
} else if (selectedKeys.size === 1) {
|
||||
@ -676,7 +679,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
targetDirectoryNodeRef.current = node
|
||||
}
|
||||
}
|
||||
}, [targetDirectoryNodeRef, selectedKeys])
|
||||
}, [driveStore, targetDirectoryNodeRef])
|
||||
|
||||
React.useEffect(() => {
|
||||
const nodeToSuggestion = (
|
||||
@ -881,39 +884,37 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}, [hidden, inputBindings, dispatchAssetEvent])
|
||||
|
||||
const setSelectedKeys = React.useCallback(
|
||||
(newSelectedKeys: ReadonlySet<backendModule.AssetId>) => {
|
||||
selectedKeysRef.current = newSelectedKeys
|
||||
setSelectedKeysRaw(newSelectedKeys)
|
||||
if (!isCloud) {
|
||||
setCanDownload(
|
||||
newSelectedKeys.size !== 0 &&
|
||||
Array.from(newSelectedKeys).every((key) => {
|
||||
React.useEffect(
|
||||
() =>
|
||||
driveStore.subscribe(({ selectedKeys }) => {
|
||||
let newCanDownload: boolean
|
||||
if (!isCloud) {
|
||||
newCanDownload =
|
||||
selectedKeys.size !== 0 &&
|
||||
Array.from(selectedKeys).every((key) => {
|
||||
const node = nodeMapRef.current.get(key)
|
||||
return node?.item.type === backendModule.AssetType.project
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
setCanDownload(
|
||||
newSelectedKeys.size !== 0 &&
|
||||
Array.from(newSelectedKeys).every((key) => {
|
||||
})
|
||||
} else {
|
||||
newCanDownload =
|
||||
selectedKeys.size !== 0 &&
|
||||
Array.from(selectedKeys).every((key) => {
|
||||
const node = nodeMapRef.current.get(key)
|
||||
return (
|
||||
node?.item.type === backendModule.AssetType.project ||
|
||||
node?.item.type === backendModule.AssetType.file ||
|
||||
node?.item.type === backendModule.AssetType.datalink
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
[isCloud, setCanDownload],
|
||||
})
|
||||
}
|
||||
const currentCanDownload = driveStore.getState().canDownload
|
||||
if (currentCanDownload !== newCanDownload) {
|
||||
setCanDownload(newCanDownload)
|
||||
}
|
||||
}),
|
||||
[driveStore, isCloud, setCanDownload],
|
||||
)
|
||||
|
||||
const clearSelectedKeys = React.useCallback(() => {
|
||||
setSelectedKeys(new Set())
|
||||
}, [setSelectedKeys])
|
||||
|
||||
const overwriteNodes = React.useCallback(
|
||||
(newAssets: backendModule.AnyAsset[]) => {
|
||||
setInitialized(true)
|
||||
@ -985,7 +986,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}, [backend, category])
|
||||
|
||||
const rootDirectoryQuery = backendHooks.useBackendQuery(
|
||||
const rootDirectoryQuery = useBackendQuery(
|
||||
backend,
|
||||
'listDirectory',
|
||||
[
|
||||
@ -1050,12 +1051,16 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}, [enabledColumns, initialized, localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedKeysRef.current.size !== 1) {
|
||||
setAssetPanelProps(null)
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
}, [selectedKeysRef.current.size, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
|
||||
React.useEffect(
|
||||
() =>
|
||||
driveStore.subscribe(({ selectedKeys }) => {
|
||||
if (selectedKeys.size !== 1) {
|
||||
setAssetPanelProps(null)
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
}),
|
||||
[driveStore, setAssetPanelProps, setIsAssetPanelTemporarilyVisible],
|
||||
)
|
||||
|
||||
const directoryListAbortControllersRef = React.useRef(
|
||||
new Map<backendModule.DirectoryId, AbortController>(),
|
||||
@ -1211,14 +1216,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
// This is not a React component, even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const prevIndex = mostRecentlySelectedIndexRef.current
|
||||
const item = prevIndex == null ? null : visibleItems[prevIndex]
|
||||
if (selectedKeysRef.current.size === 1 && item != null) {
|
||||
if (selectedKeys.size === 1 && item != null) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
if (event.key === ' ' && event.ctrlKey) {
|
||||
const keys = selectedKeysRef.current
|
||||
const keys = selectedKeys
|
||||
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
|
||||
} else {
|
||||
switch (item.type) {
|
||||
@ -1255,7 +1261,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
name={item.item.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await updateSecretMutation.mutateAsync([id, { value }, item.item.title])
|
||||
await updateSecret([id, { value }, item.item.title])
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
@ -1311,7 +1317,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
switch (event.key) {
|
||||
case ' ': {
|
||||
if (event.ctrlKey && item != null) {
|
||||
const keys = selectedKeysRef.current
|
||||
const keys = selectedKeys
|
||||
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
|
||||
}
|
||||
break
|
||||
@ -1754,8 +1760,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
break
|
||||
}
|
||||
case AssetListEventType.willDelete: {
|
||||
if (selectedKeysRef.current.has(event.key)) {
|
||||
const newSelectedKeys = new Set(selectedKeysRef.current)
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
if (selectedKeys.has(event.key)) {
|
||||
const newSelectedKeys = new Set(selectedKeys)
|
||||
newSelectedKeys.delete(event.key)
|
||||
setSelectedKeys(newSelectedKeys)
|
||||
}
|
||||
@ -1828,18 +1835,20 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const doCopy = React.useCallback(() => {
|
||||
unsetModal()
|
||||
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
|
||||
}, [unsetModal])
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setPasteData({ type: PasteType.copy, data: selectedKeys })
|
||||
}, [driveStore, unsetModal])
|
||||
|
||||
const doCut = React.useCallback(() => {
|
||||
unsetModal()
|
||||
if (pasteData != null) {
|
||||
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data })
|
||||
}
|
||||
setPasteData({ type: PasteType.move, data: selectedKeysRef.current })
|
||||
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeysRef.current })
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setPasteData({ type: PasteType.move, data: selectedKeys })
|
||||
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys })
|
||||
setSelectedKeys(new Set())
|
||||
}, [pasteData, setSelectedKeys, unsetModal, dispatchAssetEvent])
|
||||
}, [unsetModal, pasteData, driveStore, dispatchAssetEvent, setSelectedKeys])
|
||||
|
||||
const doPaste = React.useCallback(
|
||||
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
|
||||
@ -1885,8 +1894,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
backend={backend}
|
||||
category={category}
|
||||
pasteData={pasteData}
|
||||
selectedKeys={selectedKeys}
|
||||
clearSelectedKeys={clearSelectedKeys}
|
||||
nodeMapRef={nodeMapRef}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
event={{ pageX: 0, pageY: 0 }}
|
||||
@ -1895,17 +1902,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
doPaste={doPaste}
|
||||
/>
|
||||
),
|
||||
[
|
||||
backend,
|
||||
rootDirectoryId,
|
||||
category,
|
||||
selectedKeys,
|
||||
pasteData,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
clearSelectedKeys,
|
||||
],
|
||||
[backend, rootDirectoryId, category, pasteData, doCopy, doCut, doPaste],
|
||||
)
|
||||
|
||||
const onDropzoneDragOver = (event: React.DragEvent<Element>) => {
|
||||
@ -1944,7 +1941,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
backend,
|
||||
rootDirectoryId,
|
||||
visibilities,
|
||||
selectedKeys: selectedKeysRef,
|
||||
scrollContainerRef: rootRef,
|
||||
category,
|
||||
hasPasteData: pasteData != null,
|
||||
@ -2014,15 +2010,16 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
selectAdditional: () => {},
|
||||
selectAdditionalRange: () => {},
|
||||
[inputBindingsModule.DEFAULT_HANDLER]: () => {
|
||||
if (selectedKeysRef.current.size !== 0) {
|
||||
setSelectedKeys(new Set())
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
if (selectedKeys.size !== 0) {
|
||||
setSelectedKeys(set.EMPTY_SET)
|
||||
setMostRecentlySelectedIndex(null)
|
||||
}
|
||||
},
|
||||
},
|
||||
false,
|
||||
),
|
||||
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex],
|
||||
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex, driveStore],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -2057,13 +2054,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
result = new Set(getRange())
|
||||
},
|
||||
selectAdditionalRange: () => {
|
||||
result = new Set([...selectedKeysRef.current, ...getRange()])
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
result = new Set([...selectedKeys, ...getRange()])
|
||||
},
|
||||
selectAdditional: () => {
|
||||
const newSelectedKeys = new Set(selectedKeysRef.current)
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const newSelectedKeys = new Set(selectedKeys)
|
||||
let count = 0
|
||||
for (const key of keys) {
|
||||
if (selectedKeysRef.current.has(key)) {
|
||||
if (selectedKeys.has(key)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
@ -2079,13 +2078,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})(event, false)
|
||||
return result
|
||||
},
|
||||
[inputBindings],
|
||||
[driveStore, inputBindings],
|
||||
)
|
||||
|
||||
// Only non-`null` when it is different to`selectedKeys`.
|
||||
const [visuallySelectedKeysOverride, setVisuallySelectedKeysOverride] =
|
||||
React.useState<ReadonlySet<backendModule.AssetId> | null>(null)
|
||||
|
||||
const { startAutoScroll, endAutoScroll, onMouseEvent } = autoScrollHooks.useAutoScroll(rootRef)
|
||||
|
||||
const dragSelectionChangeLoopHandle = React.useRef(0)
|
||||
@ -2129,14 +2124,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
if (range == null) {
|
||||
setVisuallySelectedKeysOverride(null)
|
||||
setVisuallySelectedKeys(null)
|
||||
} else {
|
||||
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
|
||||
setVisuallySelectedKeysOverride(calculateNewKeys(event, keys, () => []))
|
||||
setVisuallySelectedKeys(calculateNewKeys(event, keys, () => []))
|
||||
}
|
||||
}
|
||||
},
|
||||
[startAutoScroll, onMouseEvent, displayItems, calculateNewKeys],
|
||||
[startAutoScroll, onMouseEvent, setVisuallySelectedKeys, displayItems, calculateNewKeys],
|
||||
)
|
||||
|
||||
const onSelectionDragEnd = React.useCallback(
|
||||
@ -2148,24 +2143,29 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const keys = displayItems.slice(range.start, range.end).map((node) => node.key)
|
||||
setSelectedKeys(calculateNewKeys(event, keys, () => []))
|
||||
}
|
||||
setVisuallySelectedKeysOverride(null)
|
||||
setVisuallySelectedKeys(null)
|
||||
dragSelectionRangeRef.current = null
|
||||
},
|
||||
[endAutoScroll, onMouseEvent, displayItems, setSelectedKeys, calculateNewKeys],
|
||||
[
|
||||
endAutoScroll,
|
||||
onMouseEvent,
|
||||
setVisuallySelectedKeys,
|
||||
displayItems,
|
||||
setSelectedKeys,
|
||||
calculateNewKeys,
|
||||
],
|
||||
)
|
||||
|
||||
const onSelectionDragCancel = React.useCallback(() => {
|
||||
setVisuallySelectedKeysOverride(null)
|
||||
setVisuallySelectedKeys(null)
|
||||
dragSelectionRangeRef.current = null
|
||||
}, [])
|
||||
}, [setVisuallySelectedKeys])
|
||||
|
||||
const onRowClick = React.useCallback(
|
||||
(innerRowProps: assetRow.AssetRowInnerProps, event: React.MouseEvent) => {
|
||||
const { key } = innerRowProps
|
||||
event.stopPropagation()
|
||||
const newIndex = visibleItems.findIndex(
|
||||
(innerItem) => AssetTreeNode.getKey(innerItem) === key,
|
||||
)
|
||||
const newIndex = visibleItems.findIndex((innerItem) => innerItem.key === key)
|
||||
const getRange = () => {
|
||||
if (mostRecentlySelectedIndexRef.current == null) {
|
||||
return [key]
|
||||
@ -2174,7 +2174,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const index2 = newIndex
|
||||
const startIndex = Math.min(index1, index2)
|
||||
const endIndex = Math.max(index1, index2) + 1
|
||||
return visibleItems.slice(startIndex, endIndex).map(AssetTreeNode.getKey)
|
||||
return visibleItems.slice(startIndex, endIndex).map((innerItem) => innerItem.key)
|
||||
}
|
||||
}
|
||||
setSelectedKeys(calculateNewKeys(event, [key], getRange))
|
||||
@ -2233,13 +2233,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
</td>
|
||||
</tr>
|
||||
: displayItems.map((item, i) => {
|
||||
const key = AssetTreeNode.getKey(item)
|
||||
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
|
||||
const isSoleSelected = isSelected && selectedKeys.size === 1
|
||||
|
||||
return (
|
||||
<AssetRow
|
||||
key={key}
|
||||
key={item.key}
|
||||
updateAssetRef={(instance) => {
|
||||
if (instance != null) {
|
||||
updateAssetRef.current[item.item.id] = instance
|
||||
@ -2255,37 +2251,27 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
item={item}
|
||||
state={state}
|
||||
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
|
||||
selected={isSelected}
|
||||
setSelected={(selected) => {
|
||||
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
|
||||
}}
|
||||
isSoleSelected={isSoleSelected}
|
||||
isKeyboardSelected={
|
||||
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
|
||||
}
|
||||
grabKeyboardFocus={() => {
|
||||
setSelectedKeys(new Set([key]))
|
||||
setSelectedKeys(new Set([item.key]))
|
||||
setMostRecentlySelectedIndex(i, true)
|
||||
}}
|
||||
allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelected}
|
||||
onClick={onRowClick}
|
||||
onContextMenu={(_innerProps, event) => {
|
||||
if (!isSelected) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
setSelectedKeys(new Set([key]))
|
||||
}
|
||||
select={() => {
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
setSelectedKeys(new Set([item.key]))
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
startAutoScroll()
|
||||
onMouseEvent(event)
|
||||
let newSelectedKeys = selectedKeysRef.current
|
||||
if (!newSelectedKeys.has(key)) {
|
||||
let newSelectedKeys = driveStore.getState().selectedKeys
|
||||
if (!newSelectedKeys.has(item.key)) {
|
||||
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
|
||||
selectionStartIndexRef.current = null
|
||||
newSelectedKeys = new Set([key])
|
||||
newSelectedKeys = new Set([item.key])
|
||||
setSelectedKeys(newSelectedKeys)
|
||||
}
|
||||
const nodes = assetTree
|
||||
@ -2337,8 +2323,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const idsReference =
|
||||
selectedKeysRef.current.has(key) ? selectedKeysRef.current : key
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const idsReference = selectedKeys.has(item.key) ? selectedKeys : item.key
|
||||
// This optimization is required in order to avoid severe lag on Firefox.
|
||||
if (idsReference !== lastSelectedIdsRef.current) {
|
||||
lastSelectedIdsRef.current = idsReference
|
||||
@ -2372,17 +2358,17 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
onDragEnd={() => {
|
||||
endAutoScroll()
|
||||
lastSelectedIdsRef.current = null
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids: selectedKeysRef.current,
|
||||
labelNames: set.EMPTY,
|
||||
ids: selectedKeys,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
endAutoScroll()
|
||||
const ids = new Set(
|
||||
selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key],
|
||||
)
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
const ids = new Set(selectedKeys.has(item.key) ? selectedKeys : [item.key])
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
@ -2408,7 +2394,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids,
|
||||
labelNames: set.EMPTY,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
}
|
||||
}}
|
||||
@ -2434,8 +2420,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
backend={backend}
|
||||
category={category}
|
||||
pasteData={pasteData}
|
||||
selectedKeys={selectedKeys}
|
||||
clearSelectedKeys={clearSelectedKeys}
|
||||
nodeMapRef={nodeMapRef}
|
||||
event={event}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
@ -2453,10 +2437,11 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
lastSelectedIdsRef.current = null
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids: selectedKeysRef.current,
|
||||
labelNames: set.EMPTY,
|
||||
ids: selectedKeys,
|
||||
labelNames: set.EMPTY_SET,
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import { useSelectedKeys, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -24,6 +25,7 @@ import * as backendModule from '#/services/Backend'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import { EMPTY_SET } from '#/utilities/set'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// =================
|
||||
@ -37,8 +39,6 @@ export interface AssetsTableContextMenuProps {
|
||||
readonly category: Category
|
||||
readonly rootDirectoryId: backendModule.DirectoryId
|
||||
readonly pasteData: pasteDataModule.PasteData<ReadonlySet<backendModule.AssetId>> | null
|
||||
readonly selectedKeys: ReadonlySet<backendModule.AssetId>
|
||||
readonly clearSelectedKeys: () => void
|
||||
readonly nodeMapRef: React.MutableRefObject<
|
||||
ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>
|
||||
>
|
||||
@ -54,7 +54,7 @@ export interface AssetsTableContextMenuProps {
|
||||
/** A context menu for an `AssetsTable`, when no row is selected, or multiple rows
|
||||
* are selected. */
|
||||
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
|
||||
const { hidden = false, backend, category, pasteData, selectedKeys, clearSelectedKeys } = props
|
||||
const { hidden = false, backend, category, pasteData } = props
|
||||
const { nodeMapRef, event, rootDirectoryId } = props
|
||||
const { doCopy, doCut, doPaste } = props
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
@ -62,6 +62,8 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
const selectedKeys = useSelectedKeys()
|
||||
const setSelectedKeys = useSetSelectedKeys()
|
||||
|
||||
// This works because all items are mutated, ensuring their value stays
|
||||
// up to date.
|
||||
@ -93,7 +95,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
: getText('deleteSelectedAssetsActionText', selectedKeys.size)
|
||||
}
|
||||
doDelete={() => {
|
||||
clearSelectedKeys()
|
||||
setSelectedKeys(EMPTY_SET)
|
||||
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
|
||||
}}
|
||||
/>,
|
||||
@ -134,7 +136,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
: getText('deleteSelectedAssetsForeverActionText', selectedKeys.size)
|
||||
}
|
||||
doDelete={() => {
|
||||
clearSelectedKeys()
|
||||
setSelectedKeys(EMPTY_SET)
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.deleteForever,
|
||||
ids: selectedKeys,
|
||||
|
@ -81,7 +81,6 @@ export default function Drive(props: DriveProps) {
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
|
||||
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
|
||||
const [canDownload, setCanDownload] = React.useState(false)
|
||||
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
|
||||
const [assetPanelPropsRaw, setAssetPanelProps] =
|
||||
React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
|
||||
@ -224,7 +223,7 @@ export default function Drive(props: DriveProps) {
|
||||
subtitle={`${getText('notEnabledSubtitle')}${localBackend == null ? ' ' + getText('downloadFreeEditionMessage') : ''}`}
|
||||
>
|
||||
<ariaComponents.ButtonGroup align="center">
|
||||
<ariaComponents.Button variant="tertiary" size="medium" href={appUtils.SUBSCRIBE_PATH}>
|
||||
<ariaComponents.Button variant="primary" size="medium" href={appUtils.SUBSCRIBE_PATH}>
|
||||
{getText('upgrade')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
@ -232,6 +231,7 @@ export default function Drive(props: DriveProps) {
|
||||
<ariaComponents.Button
|
||||
data-testid="download-free-edition"
|
||||
size="medium"
|
||||
variant="tertiary"
|
||||
onPress={async () => {
|
||||
const downloadUrl = await github.getDownloadUrl()
|
||||
if (downloadUrl == null) {
|
||||
@ -262,7 +262,6 @@ export default function Drive(props: DriveProps) {
|
||||
setQuery={setQuery}
|
||||
suggestions={suggestions}
|
||||
category={category}
|
||||
canDownload={canDownload}
|
||||
isAssetPanelOpen={isAssetPanelVisible}
|
||||
setIsAssetPanelOpen={(valueOrUpdater) => {
|
||||
const newValue =
|
||||
@ -318,7 +317,6 @@ export default function Drive(props: DriveProps) {
|
||||
hidden={hidden}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
setCanDownload={setCanDownload}
|
||||
category={category}
|
||||
setSuggestions={setSuggestions}
|
||||
initialProjectName={initialProjectName}
|
||||
|
@ -15,6 +15,7 @@ import RightPanelIcon from '#/assets/right_panel.svg'
|
||||
import * as offlineHooks from '#/hooks/offlineHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
|
||||
import { useCanDownload } from '#/providers/DriveProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -51,7 +52,6 @@ export interface DriveBarProps {
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly suggestions: readonly assetSearchBar.Suggestion[]
|
||||
readonly category: Category
|
||||
readonly canDownload: boolean
|
||||
readonly isAssetPanelOpen: boolean
|
||||
readonly setIsAssetPanelOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
readonly doEmptyTrash: () => void
|
||||
@ -70,7 +70,7 @@ export interface DriveBarProps {
|
||||
/** Displays the current directory path and permissions, upload and download buttons,
|
||||
* and a column display mode switcher. */
|
||||
export default function DriveBar(props: DriveBarProps) {
|
||||
const { backend, query, setQuery, suggestions, category, canDownload } = props
|
||||
const { backend, query, setQuery, suggestions, category } = props
|
||||
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
|
||||
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
|
||||
const { isAssetPanelOpen, setIsAssetPanelOpen } = props
|
||||
@ -81,6 +81,7 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
const { isOffline } = offlineHooks.useOffline()
|
||||
const canDownload = useCanDownload()
|
||||
const [isCreatingProjectFromTemplate, setIsCreatingProjectFromTemplate] = React.useState(false)
|
||||
const [isCreatingProject, setIsCreatingProject] = React.useState(false)
|
||||
const [createdProjectId, setCreatedProjectId] = React.useState<ProjectId | null>(null)
|
||||
|
@ -97,60 +97,53 @@ export default function Editor(props: EditorProps) {
|
||||
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
|
||||
})
|
||||
|
||||
if (isOpeningFailed) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return (
|
||||
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
|
||||
const shouldRefetch = !projectQuery.isError && !projectQuery.isLoading
|
||||
|
||||
if (!isOpeningFailed && !isOpening && isProjectClosed && shouldRefetch) {
|
||||
startProject(project)
|
||||
}
|
||||
|
||||
return isOpeningFailed ?
|
||||
<errorBoundary.ErrorDisplay
|
||||
error={openingError}
|
||||
resetErrorBoundary={() => {
|
||||
startProject(project)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
|
||||
const shouldRefetch = !(projectQuery.isError || projectQuery.isLoading)
|
||||
|
||||
if (!isOpening && isProjectClosed && shouldRefetch) {
|
||||
startProject(project)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
||||
data-testvalue={project.id}
|
||||
data-testid="editor"
|
||||
>
|
||||
{(() => {
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<errorBoundary.ErrorDisplay
|
||||
error={projectQuery.error}
|
||||
resetErrorBoundary={() => projectQuery.refetch()}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
projectQuery.isLoading ||
|
||||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
|
||||
) {
|
||||
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
|
||||
} else {
|
||||
return (
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<EditorInternal
|
||||
{...props}
|
||||
openedProject={projectQuery.data}
|
||||
backendType={project.type}
|
||||
/>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
: <div
|
||||
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
||||
data-testvalue={project.id}
|
||||
data-testid="editor"
|
||||
>
|
||||
{(() => {
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<errorBoundary.ErrorDisplay
|
||||
error={projectQuery.error}
|
||||
resetErrorBoundary={() => projectQuery.refetch()}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
projectQuery.isLoading ||
|
||||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
|
||||
) {
|
||||
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
|
||||
} else {
|
||||
return (
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<EditorInternal
|
||||
{...props}
|
||||
openedProject={projectQuery.data}
|
||||
backendType={project.type}
|
||||
/>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ======================
|
||||
|
@ -31,8 +31,7 @@ export interface InfoMenuProps {
|
||||
/** A menu containing info about the app. */
|
||||
export default function InfoMenu(props: InfoMenuProps) {
|
||||
const { hidden = false } = props
|
||||
const session = authProvider.useUserSession()
|
||||
const { signOut } = authProvider.useAuth()
|
||||
const { signOut, session } = authProvider.useAuth()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
|
@ -1,10 +1,12 @@
|
||||
/** @file A list of selectable labels. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import PlusIcon from '#/assets/plus.svg'
|
||||
import Trash2Icon from '#/assets/trash2.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions, useListTags } from '#/hooks/backendHooks'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -21,6 +23,8 @@ import NewLabelModal from '#/modals/NewLabelModal'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import * as array from '#/utilities/array'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import * as drag from '#/utilities/drag'
|
||||
@ -44,9 +48,15 @@ export default function Labels(props: LabelsProps) {
|
||||
const currentNegativeLabels = query.negativeLabels
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
|
||||
const deleteTagMutation = backendHooks.useBackendMutation(backend, 'deleteTag')
|
||||
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||
const labels = useListTags(backend) ?? []
|
||||
const deleteTag = useMutation(
|
||||
backendMutationOptions(backend, 'deleteTag', {
|
||||
onSuccess: (_data, [, labelName]) => {
|
||||
dispatchAssetEvent({ type: AssetEventType.deleteLabel, labelName })
|
||||
},
|
||||
}),
|
||||
).mutate
|
||||
|
||||
return (
|
||||
<FocusArea direction="vertical">
|
||||
@ -120,7 +130,7 @@ export default function Labels(props: LabelsProps) {
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('deleteLabelActionText', label.value)}
|
||||
doDelete={() => {
|
||||
deleteTagMutation.mutate([label.id, label.value])
|
||||
deleteTag([label.id, label.value])
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
@ -1,11 +1,11 @@
|
||||
/** @file Settings screen. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import BurgerMenuIcon from '#/assets/burger_menu.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions, useGetOrganization } from '#/hooks/backendHooks'
|
||||
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
@ -41,6 +41,7 @@ export interface SettingsProps {
|
||||
|
||||
/** Settings screen. */
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const [tab, setTab] = searchParamsState.useSearchParamsState(
|
||||
@ -49,24 +50,20 @@ export default function Settings() {
|
||||
array.includesPredicate(Object.values(SettingsTabType)),
|
||||
)
|
||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { authQueryKey } = authProvider.useAuth()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [query, setQuery] = React.useState('')
|
||||
const root = portal.useStrictPortalContext()
|
||||
const [isSidebarPopoverOpen, setIsSidebarPopoverOpen] = React.useState(false)
|
||||
const organization = backendHooks.useBackendGetOrganization(backend)
|
||||
const organization = useGetOrganization(backend)
|
||||
const isQueryBlank = !/\S/.test(query)
|
||||
|
||||
const client = reactQuery.useQueryClient()
|
||||
const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser', {
|
||||
meta: { invalidates: [authQueryKey], awaitInvalidates: true },
|
||||
})
|
||||
const updateOrganizationMutation = backendHooks.useBackendMutation(backend, 'updateOrganization')
|
||||
const updateUser = updateUserMutation.mutateAsync
|
||||
const updateOrganization = updateOrganizationMutation.mutateAsync
|
||||
const updateUser = useMutation(backendMutationOptions(backend, 'updateUser')).mutateAsync
|
||||
const updateOrganization = useMutation(
|
||||
backendMutationOptions(backend, 'updateOrganization'),
|
||||
).mutateAsync
|
||||
|
||||
const updateLocalRootPathMutation = reactQuery.useMutation({
|
||||
const updateLocalRootPath = useMutation({
|
||||
mutationKey: [localBackend?.type, 'updateRootPath'],
|
||||
mutationFn: (value: string) => {
|
||||
if (localBackend) {
|
||||
@ -75,9 +72,7 @@ export default function Settings() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
meta: { invalidates: [[localBackend?.type, 'listDirectory']], awaitInvalidates: true },
|
||||
})
|
||||
|
||||
const updateLocalRootPath = updateLocalRootPathMutation.mutateAsync
|
||||
}).mutateAsync
|
||||
|
||||
const context = React.useMemo<settingsData.SettingsContext>(
|
||||
() => ({
|
||||
@ -91,7 +86,7 @@ export default function Settings() {
|
||||
updateLocalRootPath,
|
||||
toastAndLog,
|
||||
getText,
|
||||
queryClient: client,
|
||||
queryClient,
|
||||
}),
|
||||
[
|
||||
accessToken,
|
||||
@ -104,7 +99,7 @@ export default function Settings() {
|
||||
updateOrganization,
|
||||
updateUser,
|
||||
user,
|
||||
client,
|
||||
queryClient,
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -78,7 +78,7 @@ export default function ActivityLogSettingsSection(props: ActivityLogSettingsSec
|
||||
const [emailIndices, setEmailIndices] = React.useState<readonly number[]>(() => [])
|
||||
const [sortInfo, setSortInfo] =
|
||||
React.useState<sorting.SortInfo<ActivityLogSortableColumn> | null>(null)
|
||||
const users = backendHooks.useBackendListUsers(backend)
|
||||
const users = backendHooks.useListUsers(backend)
|
||||
const allEmails = React.useMemo(() => (users ?? []).map((user) => user.email), [users])
|
||||
const logsQuery = backendHooks.useBackendQuery(backend, 'getLogEvents', [])
|
||||
const logs = logsQuery.data
|
||||
|
@ -55,6 +55,7 @@ export default function KeyboardShortcutsSettingsSection() {
|
||||
<>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user