Merge branch 'develop' into wip/hubert/copying-opt

This commit is contained in:
Hubert Plociniczak 2024-08-14 12:53:04 +02:00
commit 8ef9aebc0f
707 changed files with 20401 additions and 9799 deletions

View File

@ -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'

View File

@ -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
View File

@ -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
View 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
},
}

View File

@ -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

View File

@ -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
View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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}===="],
},
}

View File

@ -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

View File

@ -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. */

View File

@ -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(),
)
}

View File

@ -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

View File

@ -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)

View File

@ -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)
}),
)

View File

@ -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()

File diff suppressed because one or more lines are too long

View File

@ -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()

View File

@ -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)
})
})

View File

@ -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": {

View File

@ -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,
},

View File

@ -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
}

View File

@ -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.
*/

View 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

View File

@ -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}`)

View File

@ -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',

View File

@ -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({

View File

@ -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: {

View File

@ -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,
})

View File

@ -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>
)}

View File

@ -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}

View File

@ -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
}

View File

@ -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
}

View File

@ -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:

View File

@ -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

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel file for Input component.
*/
export * from './Input'

View File

@ -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}>

View File

@ -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}>

View File

@ -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',
}),
},
})

View File

@ -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

View File

@ -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>
)
})

View File

@ -0,0 +1,2 @@
/** @file Barrel file for Selector component. */
export * from './Selector'

View File

@ -4,4 +4,7 @@
* Barrel export file for Inputs
*/
export * from './Input'
export * from './ResizableInput'
export * from './Selector'
export * from './variants'

View File

@ -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',
},
})

View File

@ -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}
/>
)

View File

@ -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 (

View File

@ -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}

View File

@ -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>

View File

@ -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

View File

@ -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',

View File

@ -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"

View File

@ -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>,
)

View File

@ -44,6 +44,7 @@ export const ACTION_TO_TEXT_ID: Readonly<
>
> = {
settings: 'settingsShortcut',
closeTab: 'closeTabShortcut',
open: 'openShortcut',
run: 'runShortcut',
close: 'closeShortcut',

View File

@ -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}

View File

@ -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' },
})
// ==============

View File

@ -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>
)
}

View File

@ -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}

View 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

View 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

View File

@ -0,0 +1,8 @@
/**
* @file
*
* Barrel file for Stepper component.
*/
export * from './Stepper'
export * from './useStepperState'

View 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
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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,

View File

@ -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,
})

View File

@ -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) {

View File

@ -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)

View File

@ -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) {

View File

@ -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>

View File

@ -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)) {

View File

@ -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) {

View File

@ -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}</>
}

View File

@ -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])

View File

@ -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}
/>

View File

@ -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]" />
)
)
}

View File

@ -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,

View File

@ -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 },

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View 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)
}

View File

@ -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) }),

View File

@ -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
}

View File

@ -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>,
)
})

View File

@ -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 ?? '' })

View File

@ -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),
})
// ==================

View File

@ -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)

View File

@ -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}

View File

@ -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}

View File

@ -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,
})
}
}}

View File

@ -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,

View File

@ -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}

View File

@ -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)

View File

@ -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>
}
// ======================

View File

@ -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)

View File

@ -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])
}}
/>,
)

View File

@ -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,
],
)

View File

@ -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

View File

@ -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