Fix various issues on the Dashboard (#10256)
- Fix React DevTools not working in Firefox - Fix selection of asset names when editing them, not working at all in Firefox - Convert tick/cross buttons when editing assets, and the "plus" and "reload" buttons on the "shared with" column, "labels" column, and keyboard shortcuts table, to match more with the rest of the design - Update clip path when the container resizes, so that the icons for hidden columns never overlap the actual table header - Fix #10184 - Fix renames being committed even when cancelling - Fix duplicate name detection - previously, all asset types only checked folders with the same name, not assets with the same name - I'm not 100% sure this is the correct behavior still - Stop using `kbd` (`aria.Keyboard`) to display keyboard shortcuts, since they should not be displayed in a monospace font. - Fix "plus" and "reload" buttons going past the right side of their parent table cell - Limit length of `PermissionDisplay` - if the username of a user with permission is too long, it uses a tooltip instead - Update the username dynamically for all permissions owned by self, when changing username in the settings. - This avoids having to fully invalidate the directory tree every time the username changes, given that nothing changes about the assets' metadata themselves. - Cache children in the Drive tree - This avoids loading spinners when closing a folder and immediately reopening it. - Note that children are still re-fetched on reopen to ensure freshness # Important Notes - This MAY be split into multiple smaller PRs. However, I think it's better to QA as a single PR, to avoid duplicating work checking behavior that may be changed by a sibling PR (assuming the PR was split into multiple).
@ -20,6 +20,7 @@ import tsEslintParser from '@typescript-eslint/parser'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const DEBUG_STATEMENTS_MESSAGE = 'Avoid leaving debugging statements when committing code'
|
||||
const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
const NAME = 'enso'
|
||||
/** An explicit whitelist of CommonJS modules, which do not support namespace imports.
|
||||
@ -204,7 +205,7 @@ const RESTRICTED_SYNTAXES = [
|
||||
{
|
||||
selector: `JSXAttribute[name.name=/^(?:className)$/] TemplateLiteral`,
|
||||
message:
|
||||
'Use `tv` from `tailwind-variants` or `twMerge` from `tailwind-merge` instead of template strings for classes',
|
||||
'Use `tv` from `#/utilities/tailwindVariants` or `twMerge` from `tailwind-merge` instead of template strings for classes',
|
||||
},
|
||||
{
|
||||
selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier',
|
||||
@ -283,11 +284,18 @@ export default [
|
||||
'no-constant-condition': ['error', { checkLoops: false }],
|
||||
'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES],
|
||||
'prefer-const': 'error',
|
||||
'react/forbid-elements': [
|
||||
'error',
|
||||
{ forbid: [{ element: 'Debug', message: DEBUG_STATEMENTS_MESSAGE }] },
|
||||
],
|
||||
// Not relevant because TypeScript checks types.
|
||||
'react/prop-types': 'off',
|
||||
'react/self-closing-comp': 'error',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'error',
|
||||
{ additionalHooks: 'useOnScroll|useStickyTableHeaderOnScroll' },
|
||||
],
|
||||
'react/jsx-pascal-case': ['error', { allowNamespace: true }],
|
||||
// Prefer `interface` over `type`.
|
||||
'@typescript-eslint/consistent-type-definitions': 'error',
|
||||
@ -473,26 +481,11 @@ export default [
|
||||
property: 'useNavigate',
|
||||
message: 'Use `hooks.useNavigate` instead.',
|
||||
},
|
||||
{
|
||||
object: 'console',
|
||||
message: 'Avoid leaving debugging statements when committing code',
|
||||
},
|
||||
{
|
||||
property: 'useDebugState',
|
||||
message: 'Avoid leaving debugging statements when committing code',
|
||||
},
|
||||
{
|
||||
property: 'useDebugEffect',
|
||||
message: 'Avoid leaving debugging statements when committing code',
|
||||
},
|
||||
{
|
||||
property: 'useDebugMemo',
|
||||
message: 'Avoid leaving debugging statements when committing code',
|
||||
},
|
||||
{
|
||||
property: 'useDebugCallback',
|
||||
message: 'Avoid leaving debugging statements when committing code',
|
||||
},
|
||||
{ object: 'console', message: DEBUG_STATEMENTS_MESSAGE },
|
||||
{ property: 'useDebugState', message: DEBUG_STATEMENTS_MESSAGE },
|
||||
{ property: 'useDebugEffect', message: DEBUG_STATEMENTS_MESSAGE },
|
||||
{ property: 'useDebugMemo', message: DEBUG_STATEMENTS_MESSAGE },
|
||||
{ property: 'useDebugCallback', message: DEBUG_STATEMENTS_MESSAGE },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -1,5 +1,4 @@
|
||||
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.8 6.2L6.6 8 4.8 9.8A.5.5 0 1 0 6.2 11.2L8 9.4 9.8 11.2A.5.5 0 1 0 11.2 9.8L9.4 8 11.2 6.2A.5.5 0 1 0 9.8 4.8L8 6.6 6.2 4.8A.5.5 0 1 0 4.8 6.2Z"
|
||||
fill="black" />
|
||||
<rect x="7" y="2" width="2" height="12" fill="black" transform="rotate(45 8 8)" />
|
||||
<rect x="2" y="7" width="12" height="2" fill="black" transform="rotate(45 8 8)" />
|
||||
</svg>
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 264 B |
@ -1,7 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="12" fill="#3e515fe5" fill-opacity="0.1" />
|
||||
<g opacity="0.66">
|
||||
<rect x="11" y="6" width="2" height="12" fill="#3e515fe5" />
|
||||
<rect x="6" y="11" width="12" height="2" fill="#3e515fe5" />
|
||||
</g>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="7" y="2" width="2" height="12" fill="black" />
|
||||
<rect x="2" y="7" width="12" height="2" fill="black" />
|
||||
</svg>
|
Before Width: | Height: | Size: 346 B After Width: | Height: | Size: 222 B |
8
app/ide-desktop/lib/assets/reload.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="1 1 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.39065 7.79933L2.32999 8.85999L4.80486 11.3349L5.86552 10.2742L3.39065 7.79933Z" fill="black" />
|
||||
<path d="M6.22487 7.79512L3.75 10.27L4.81066 11.3306L7.28553 8.85578L6.22487 7.79512Z" fill="black" />
|
||||
<path
|
||||
d="M4 9C4 8.01109 4.29325 7.04439 4.84265 6.22215C5.39206 5.3999 6.17295 4.75904 7.08658 4.3806C8.00021 4.00216 9.00555 3.90315 9.97545 4.09607C10.9454 4.289 11.8363 4.7652 12.5355 5.46447C13.2348 6.16373 13.711 7.05464 13.9039 8.02455C14.0969 8.99445 13.9978 9.99979 13.6194 10.9134C13.241 11.827 12.6001 12.6079 11.7778 13.1573C10.9556 13.7068 9.9889 14 9 14L9 12.5C9.69223 12.5 10.3689 12.2947 10.9445 11.9101C11.5201 11.5256 11.9687 10.9789 12.2336 10.3394C12.4985 9.69985 12.5678 8.99612 12.4327 8.31718C12.2977 7.63825 11.9644 7.01461 11.4749 6.52513C10.9854 6.03564 10.3618 5.7023 9.68282 5.56725C9.00388 5.4322 8.30015 5.50151 7.66061 5.76642C7.02107 6.03133 6.47444 6.47993 6.08986 7.0555C5.70527 7.63108 5.5 8.30777 5.5 9L4 9Z"
|
||||
fill="black" />
|
||||
<rect x="4" y="9" width="2" height="1" fill="black" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -1,20 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_22795_32405)">
|
||||
<path
|
||||
d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18Z"
|
||||
fill="#3E515F" fill-opacity="0.0898039" />
|
||||
<g opacity="0.6">
|
||||
<path d="M3.39065 7.79933L2.32999 8.85999L4.80486 11.3349L5.86552 10.2742L3.39065 7.79933Z" fill="#3E515F" />
|
||||
<path d="M6.22487 7.79512L3.75 10.27L4.81066 11.3306L7.28553 8.85578L6.22487 7.79512Z" fill="#3E515F" />
|
||||
<path
|
||||
d="M4 9C4 8.01109 4.29325 7.04439 4.84265 6.22215C5.39206 5.3999 6.17295 4.75904 7.08658 4.3806C8.00021 4.00216 9.00555 3.90315 9.97545 4.09607C10.9454 4.289 11.8363 4.7652 12.5355 5.46447C13.2348 6.16373 13.711 7.05464 13.9039 8.02455C14.0969 8.99445 13.9978 9.99979 13.6194 10.9134C13.241 11.827 12.6001 12.6079 11.7778 13.1573C10.9556 13.7068 9.9889 14 9 14L9 12.5C9.69223 12.5 10.3689 12.2947 10.9445 11.9101C11.5201 11.5256 11.9687 10.9789 12.2336 10.3394C12.4985 9.69985 12.5678 8.99612 12.4327 8.31718C12.2977 7.63825 11.9644 7.01461 11.4749 6.52513C10.9854 6.03564 10.3618 5.7023 9.68282 5.56725C9.00388 5.4322 8.30015 5.50151 7.66061 5.76642C7.02107 6.03133 6.47444 6.47993 6.08986 7.0555C5.70527 7.63108 5.5 8.30777 5.5 9L4 9Z"
|
||||
fill="#3E515F" />
|
||||
<rect x="4" y="9" width="2" height="1" fill="#3E515F" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_22795_32405">
|
||||
<rect width="18" height="18" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,4 +1,4 @@
|
||||
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.4 9.2L5.7 11.5A1 1 0 0 0 7.3 11.5L12.6 6.2A.5.5 0 1 0 11.2 4.8L6.5 9.5 4.8 7.8A.5.5 0 1 0 3.4 9.2"
|
||||
fill="black" />
|
||||
<rect x="8" y="2" width="2" height="11" fill="black" transform="rotate(45 8 8)" />
|
||||
<rect x="4" y="11" width="6" height="2" fill="black" transform="rotate(45 8 8)" />
|
||||
</svg>
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 264 B |
@ -3,6 +3,13 @@
|
||||
The dashboard is the entrypoint into the application. It includes project
|
||||
management, project sharing, and user accounts and authentication.
|
||||
|
||||
## Further documentation
|
||||
|
||||
Further documentation is provided in the `docs/` folder:
|
||||
|
||||
- [Browser-specific behavior](./docs/browser_specific_behavior.md) details
|
||||
behavior that is inconsistent between browsers and needs to be worked around.
|
||||
|
||||
## Folder structure
|
||||
|
||||
- `mock/`: Overrides for specific files in `src/` when running Playwright tests.
|
||||
|
@ -0,0 +1,65 @@
|
||||
# Browser-specific behavior
|
||||
|
||||
This document details behavior that is inconsistent between browsers and needs
|
||||
to be worked around.
|
||||
|
||||
## List of inconsistent behaviors
|
||||
|
||||
### Drag event missing coordinates
|
||||
|
||||
Firefox sets `MouseEvent.pageX` and `MouseEvent.pageY` to `0` for `drag`
|
||||
events.
|
||||
|
||||
#### Fix
|
||||
|
||||
Pass the `drag` event handlers to `dragover` event as well, and wrap all `drag`
|
||||
event handlers in:
|
||||
|
||||
````ts
|
||||
if (event.pageX !== 0 || event.pageY !== 0) {
|
||||
// original body here
|
||||
}
|
||||
```
|
||||
|
||||
#### Affected files
|
||||
|
||||
- [`DragModal.tsx`](../src/modals/DragModal.tsx)
|
||||
|
||||
### Drag event propagation in text inputs
|
||||
|
||||
Text selection in text inputs DO NOT WORK on Firefox, when the text input is a
|
||||
child of an element with `draggable="true"`.
|
||||
See [Firefox bug 800050].
|
||||
To solve this problem, use `useDraggable` from
|
||||
[`dragAndDropHooks.ts`] on ALL elements that MAY contain a text input.
|
||||
|
||||
[Firefox bug 800050]: https://bugzilla.mozilla.org/show_bug.cgi?id=800050
|
||||
|
||||
#### Fix
|
||||
|
||||
Merge `useDraggable` from [`dragAndDropHooks.ts`] on ALL elements that MAY
|
||||
contain a text input.
|
||||
|
||||
It is recommended to use `aria.mergeProps` to combine these props with existing
|
||||
props.
|
||||
|
||||
```tsx
|
||||
import * as dragAndDropHooks from "#/hooks/dragAndDropHooks.ts";
|
||||
|
||||
const draggableProps = dragAndDropHooks.useDraggable();
|
||||
|
||||
return <div {...draggableProps}></div>;
|
||||
````
|
||||
|
||||
[`draggableHooks.ts`]: ../src/hooks/dragAndDropHooks.ts
|
||||
|
||||
#### Affected browsers
|
||||
|
||||
- Firefox (all versions)
|
||||
|
||||
#### Affected files
|
||||
|
||||
- [`EditableSpan.tsx`](../src/components/EditableSpan.tsx) - the text inputs
|
||||
that are affected
|
||||
- [`AssetRow.tsx`](../src/components/dashboard/AssetRow.tsx) - fixes text
|
||||
selection in `EditableSpan.tsx`
|
@ -142,12 +142,12 @@ export function locateLabelsPanelLabels(page: test.Page) {
|
||||
|
||||
/** Find a tick button (if any) on the current page. */
|
||||
export function locateEditingTick(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Confirm Edit')
|
||||
return page.getByLabel('Confirm Edit')
|
||||
}
|
||||
|
||||
/** Find a cross button (if any) on the current page. */
|
||||
export function locateEditingCross(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Cancel Edit')
|
||||
return page.getByLabel('Cancel Edit')
|
||||
}
|
||||
|
||||
/** Find labels in the "Labels" column of the assets table (if any) on the current page. */
|
||||
|
@ -55,7 +55,8 @@ test.test('asset panel contents', ({ page }) =>
|
||||
permission: permissions.PermissionAction.own,
|
||||
user: {
|
||||
organizationId: defaultOrganizationId,
|
||||
userId: defaultUserId,
|
||||
// Using the default ID causes the asset to have a dynamic username.
|
||||
userId: backend.UserId(defaultUserId + '2'),
|
||||
name: USERNAME,
|
||||
email: backend.EmailAddress(EMAIL),
|
||||
},
|
||||
|
@ -8,85 +8,80 @@ test.test.beforeEach(actions.mockAllAndLogin)
|
||||
test.test('edit name', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const mod = await actions.modModifier(page)
|
||||
const row = assetRows.nth(0)
|
||||
const newName = 'foo bar baz'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateEditingTick(assetRows.nth(0)).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName))
|
||||
await actions.locateAssetRowName(row).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(row).fill(newName)
|
||||
await actions.locateEditingTick(row).click()
|
||||
await test.expect(row).toHaveText(new RegExp('^' + newName))
|
||||
})
|
||||
|
||||
test.test('edit name (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const row = assetRows.nth(0)
|
||||
const newName = 'foo bar baz quux'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click()
|
||||
await actions.locateAssetRowName(row).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).press('Enter')
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName))
|
||||
await actions.locateAssetRowName(row).fill(newName)
|
||||
await actions.locateAssetRowName(row).press('Enter')
|
||||
await test.expect(row).toHaveText(new RegExp('^' + newName))
|
||||
})
|
||||
|
||||
test.test('cancel editing name', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const mod = await actions.modModifier(page)
|
||||
const row = assetRows.nth(0)
|
||||
const newName = 'foo bar baz'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateEditingCross(assetRows.nth(0)).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(row).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(row).fill(newName)
|
||||
await actions.locateEditingCross(row).click()
|
||||
await test.expect(row).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
||||
test.test('cancel editing name (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const row = assetRows.nth(0)
|
||||
const newName = 'foo bar baz quux'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click()
|
||||
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(row).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).press('Escape')
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
await actions.locateAssetRowName(row).fill(newName)
|
||||
await actions.locateAssetRowName(row).press('Escape')
|
||||
await test.expect(row).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
||||
test.test('change to blank name', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const mod = await actions.modModifier(page)
|
||||
const row = assetRows.nth(0)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill('')
|
||||
await actions.locateEditingTick(assetRows.nth(0)).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(row).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(row).fill('')
|
||||
await test.expect(actions.locateEditingTick(row)).not.toBeVisible()
|
||||
await actions.locateEditingCross(row).click()
|
||||
await test.expect(row).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
||||
test.test('change to blank name (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const row = assetRows.nth(0)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click()
|
||||
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(row).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill('')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).press('Enter')
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
await actions.locateAssetRowName(row).fill('')
|
||||
await actions.locateAssetRowName(row).press('Enter')
|
||||
await test.expect(row).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
@ -58,141 +58,3 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.test('drag (recursive)', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labels = actions.locateLabelsPanelLabels(page)
|
||||
const label = 'bbbb'
|
||||
api.addLabel('aaaa', backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel(label, 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]!)
|
||||
const assetsWithLabel = new Set<string>()
|
||||
const shouldHaveLabel = <T extends backend.AnyAsset>(asset: T) => {
|
||||
assetsWithLabel.add(asset.title)
|
||||
return asset
|
||||
}
|
||||
const directory1 = shouldHaveLabel(api.addDirectory('foo'))
|
||||
const directory2 = shouldHaveLabel(api.addDirectory('bar'))
|
||||
shouldHaveLabel(api.addFile('baz', { parentId: directory1.id }))
|
||||
shouldHaveLabel(api.addSecret('quux', { parentId: directory1.id }))
|
||||
const directory3 = api.addDirectory('directory 3')
|
||||
const directory5 = shouldHaveLabel(api.addDirectory('directory 5', { parentId: directory3.id }))
|
||||
api.addFile('file 1', { parentId: directory3.id })
|
||||
api.addProject('file 2', { parentId: directory3.id })
|
||||
api.addFile('another file')
|
||||
const directory4 = shouldHaveLabel(api.addDirectory('blargle', { parentId: directory2.id }))
|
||||
shouldHaveLabel(api.addProject('abcd', { parentId: directory2.id }))
|
||||
shouldHaveLabel(api.addProject('efgh', { parentId: directory2.id }))
|
||||
shouldHaveLabel(api.addFile('ijkl', { parentId: directory4.id }))
|
||||
shouldHaveLabel(api.addProject('mnop', { parentId: directory4.id }))
|
||||
shouldHaveLabel(api.addSecret('secret 1', { parentId: directory5.id }))
|
||||
shouldHaveLabel(api.addFile('yet another file', { parentId: directory5.id }))
|
||||
await actions.login({ page })
|
||||
|
||||
let didExpandRows = false
|
||||
do {
|
||||
didExpandRows = false
|
||||
const directories = await actions.locateExpandableDirectories(page).all()
|
||||
// If going through the directories in forward order, the positions change when
|
||||
// one directory is expanded, making the double click happend on the wrong row
|
||||
// for all directories after it.
|
||||
for (const directory of directories.reverse()) {
|
||||
didExpandRows = true
|
||||
await directory.dblclick()
|
||||
}
|
||||
} while (didExpandRows)
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
const directory1Row = assetRows.filter({ hasText: directory1.title })
|
||||
await directory1Row.click()
|
||||
await actions.clickAssetRow(assetRows.filter({ hasText: directory2.title }))
|
||||
await actions.clickAssetRow(assetRows.filter({ hasText: directory5.title }))
|
||||
await labels.nth(1).dragTo(directory1Row)
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
for (const row of await actions.locateAssetRows(page).all()) {
|
||||
const name = await actions.locateAssetName(row).innerText()
|
||||
const labelElement = actions.locateAssetLabels(row).getByText(label)
|
||||
if (assetsWithLabel.has(name)) {
|
||||
await test.expect(labelElement).toBeVisible()
|
||||
} else {
|
||||
await test.expect(labelElement).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.test('drag (inverted, recursive)', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labels = actions.locateLabelsPanelLabels(page)
|
||||
const label = 'bbbb'
|
||||
api.addLabel('aaaa', backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const backendLabel = api.addLabel(label, 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]!)
|
||||
const assetsWithoutLabel = new Set<string>()
|
||||
const shouldNotHaveLabel = <T extends backend.AnyAsset>(asset: T) => {
|
||||
assetsWithoutLabel.add(asset.title)
|
||||
return asset
|
||||
}
|
||||
const directory1 = shouldNotHaveLabel(api.addDirectory('foo'))
|
||||
const directory2 = shouldNotHaveLabel(api.addDirectory('bar'))
|
||||
shouldNotHaveLabel(api.addFile('baz', { parentId: directory1.id }))
|
||||
shouldNotHaveLabel(api.addSecret('quux', { parentId: directory1.id }))
|
||||
const directory3 = api.addDirectory('directory 3')
|
||||
const directory5 = shouldNotHaveLabel(
|
||||
api.addDirectory('directory 5', { parentId: directory3.id })
|
||||
)
|
||||
api.addFile('file 1', { parentId: directory3.id })
|
||||
api.addProject('file 2', { parentId: directory3.id })
|
||||
api.addFile('another file')
|
||||
const directory4 = shouldNotHaveLabel(api.addDirectory('blargle', { parentId: directory2.id }))
|
||||
shouldNotHaveLabel(api.addProject('abcd', { parentId: directory2.id }))
|
||||
shouldNotHaveLabel(api.addProject('efgh', { parentId: directory2.id }))
|
||||
shouldNotHaveLabel(api.addFile('ijkl', { parentId: directory4.id }))
|
||||
shouldNotHaveLabel(api.addProject('mnop', { parentId: directory4.id }))
|
||||
shouldNotHaveLabel(api.addSecret('secret 1', { parentId: directory5.id }))
|
||||
shouldNotHaveLabel(api.addFile('yet another file', { parentId: directory5.id }))
|
||||
api.setLabels(api.rootDirectoryId, [backendLabel.value])
|
||||
await actions.login({ page })
|
||||
|
||||
/** The default position (the center) cannot be clicked on as it lands exactly on a label -
|
||||
* which has its own mouse action. It also cannot be too far left, otherwise it triggers
|
||||
* edit mode for the name. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const somewhereInRow = { x: 300, y: 16 }
|
||||
let didExpandRows = false
|
||||
do {
|
||||
didExpandRows = false
|
||||
const directories = await actions.locateExpandableDirectories(page).all()
|
||||
// If going through the directories in forward order, the positions change when
|
||||
// one directory is expanded, making the double click happend on the wrong row
|
||||
// for all directories after it.
|
||||
for (const directory of directories.reverse()) {
|
||||
didExpandRows = true
|
||||
await directory.dblclick({ position: somewhereInRow })
|
||||
}
|
||||
} while (didExpandRows)
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
const directory1Row = assetRows.filter({ hasText: directory1.title })
|
||||
await directory1Row.click({ position: somewhereInRow })
|
||||
await assetRows.filter({ hasText: directory2.title }).click({ position: somewhereInRow })
|
||||
await assetRows.filter({ hasText: directory5.title }).click({ position: somewhereInRow })
|
||||
await labels.nth(1).dragTo(directory1Row)
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
for (const row of await actions.locateAssetRows(page).all()) {
|
||||
const name = await actions.locateAssetName(row).innerText()
|
||||
const labelElement = actions.locateAssetLabels(row).getByText(label)
|
||||
if (assetsWithoutLabel.has(name)) {
|
||||
await test.expect(labelElement).not.toBeVisible()
|
||||
} else {
|
||||
await test.expect(labelElement).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -16,14 +16,15 @@
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="
|
||||
default-src 'self';
|
||||
frame-src 'self' data: https://js.stripe.com;
|
||||
script-src 'self' 'unsafe-eval' data: https://*;
|
||||
style-src 'self' 'unsafe-inline' data: https://*;
|
||||
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
|
||||
worker-src 'self' blob:;
|
||||
img-src 'self' blob: data: https://*;
|
||||
font-src 'self' data: https://*"
|
||||
default-src 'self';
|
||||
frame-src 'self' data: https://js.stripe.com;
|
||||
script-src 'self' 'unsafe-eval' data: https://*;
|
||||
script-src-elem 'self' 'unsafe-inline' https://*;
|
||||
style-src 'self' 'unsafe-inline' data: https://*;
|
||||
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
|
||||
worker-src 'self' blob:;
|
||||
img-src 'self' blob: data: https://*;
|
||||
font-src 'self' data: https://*"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
|
@ -253,7 +253,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (remoteBackend) {
|
||||
@ -315,14 +315,14 @@ function AppRouter(props: AppRouterProps) {
|
||||
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
|
||||
},
|
||||
}
|
||||
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
|
||||
const authService = React.useMemo(() => {
|
||||
const authConfig = { navigate, ...props }
|
||||
return authServiceModule.initAuthService(authConfig)
|
||||
}, [props, /* should never change */ navigate])
|
||||
}, [props, navigate])
|
||||
|
||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||
const refreshUserSession =
|
||||
@ -335,7 +335,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
setModal(<AboutModal />)
|
||||
})
|
||||
}
|
||||
}, [/* should never change */ setModal])
|
||||
}, [setModal])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = navigator2D.onKeyDown.bind(navigator2D)
|
||||
@ -366,15 +366,15 @@ function AppRouter(props: AppRouterProps) {
|
||||
app.contains(selection.anchorNode) &&
|
||||
selection.focusNode != null &&
|
||||
app.contains(selection.focusNode)
|
||||
if (selection != null && !appContainsSelection) {
|
||||
selection.removeAllRanges()
|
||||
if (!appContainsSelection) {
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectStart = () => {
|
||||
isClick = false
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('selectstart', onSelectStart)
|
||||
|
@ -179,7 +179,8 @@ export const BUTTON_STYLES = twv.tv({
|
||||
ghost:
|
||||
'text-primary hover:text-primary/80 hover:bg-white focus-visible:text-primary/80 focus-visible:bg-white',
|
||||
submit: 'bg-invite text-white opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
|
||||
outline: 'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2',
|
||||
outline:
|
||||
'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2 hover:bg-primary/10',
|
||||
bar: 'rounded-full border-0.5 border-primary/20 transition-colors hover:bg-primary/10',
|
||||
},
|
||||
iconPosition: {
|
||||
|
@ -66,16 +66,16 @@ export const TEXT_STYLE = twv.tv({
|
||||
},
|
||||
truncate: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'1': 'truncate ellipsis w-full',
|
||||
'2': 'line-clamp-2 ellipsis w-full',
|
||||
'3': 'line-clamp-3 ellipsis w-full',
|
||||
'4': 'line-clamp-4 ellipsis w-full',
|
||||
'5': 'line-clamp-5 ellipsis w-full',
|
||||
'6': 'line-clamp-6 ellipsis w-full',
|
||||
'7': 'line-clamp-7 ellipsis w-full',
|
||||
'8': 'line-clamp-8 ellipsis w-full',
|
||||
'9': 'line-clamp-9 ellipsis w-full',
|
||||
custom: 'line-clamp-[var(--line-clamp)] ellipsis w-full',
|
||||
'1': 'truncate ellipsis',
|
||||
'2': 'line-clamp-2 ellipsis',
|
||||
'3': 'line-clamp-3 ellipsis',
|
||||
'4': 'line-clamp-4 ellipsis',
|
||||
'5': 'line-clamp-5 ellipsis',
|
||||
'6': 'line-clamp-6 ellipsis',
|
||||
'7': 'line-clamp-7 ellipsis',
|
||||
'8': 'line-clamp-8 ellipsis',
|
||||
'9': 'line-clamp-9 ellipsis',
|
||||
custom: 'line-clamp-[var(--line-clamp)] ellipsis',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
monospace: { true: 'font-mono' },
|
||||
|
@ -11,7 +11,7 @@ import * as text from '../Text'
|
||||
// =================
|
||||
|
||||
export const TOOLTIP_STYLES = twv.tv({
|
||||
base: 'group flex justify-center items-center text-center text-balance',
|
||||
base: 'group flex justify-center items-center text-center text-balance break-words',
|
||||
variants: {
|
||||
variant: {
|
||||
custom: '',
|
||||
|
@ -4,18 +4,17 @@ import * as React from 'react'
|
||||
import CrossIcon from 'enso-assets/cross.svg'
|
||||
import TickIcon from 'enso-assets/tick.svg'
|
||||
|
||||
import * as eventCalback from '#/hooks/eventCallbackHooks'
|
||||
import * as eventCallback from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// ====================
|
||||
// === EditableSpan ===
|
||||
@ -36,19 +35,18 @@ export interface EditableSpanProps {
|
||||
|
||||
/** A `<span>` that can turn into an `<input type="text">`. */
|
||||
export default function EditableSpan(props: EditableSpanProps) {
|
||||
const { className, editable = false, children } = props
|
||||
const { className = '', editable = false, children } = props
|
||||
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const [isSubmittable, setIsSubmittable] = React.useState(false)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const cancelledRef = React.useRef(false)
|
||||
const checkSubmittableRef = React.useRef(checkSubmittable)
|
||||
checkSubmittableRef.current = checkSubmittable
|
||||
|
||||
// Making sure that the event callback is stable.
|
||||
// to prevent the effect from re-running.
|
||||
const onCancelEventCallback = eventCalback.useEventCallback(onCancel)
|
||||
// Make sure that the event callback is stable to prevent the effect from re-running.
|
||||
const onCancelEventCallback = eventCallback.useEventCallback(onCancel)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editable) {
|
||||
@ -68,7 +66,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
|
||||
}, [editable, inputBindings, onCancelEventCallback])
|
||||
|
||||
React.useEffect(() => {
|
||||
cancelledRef.current = false
|
||||
@ -77,33 +75,43 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
if (editable) {
|
||||
return (
|
||||
<form
|
||||
className="flex grow"
|
||||
className="flex grow gap-1.5"
|
||||
onBlur={event => {
|
||||
const currentTarget = event.currentTarget
|
||||
if (!currentTarget.contains(event.relatedTarget)) {
|
||||
// This must run AFTER the cancel button's event handler runs.
|
||||
setTimeout(() => {
|
||||
if (!cancelledRef.current) {
|
||||
currentTarget.requestSubmit()
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (isSubmittable) {
|
||||
if (inputRef.current != null) {
|
||||
if (inputRef.current != null) {
|
||||
if (isSubmittable) {
|
||||
onSubmit(inputRef.current.value)
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<aria.Input
|
||||
data-testid={props['data-testid']}
|
||||
className={className ?? ''}
|
||||
ref={inputRef}
|
||||
className={tailwindMerge.twMerge('rounded-lg', className)}
|
||||
ref={element => {
|
||||
inputRef.current = element
|
||||
if (element) {
|
||||
element.style.width = '0'
|
||||
element.style.width = `${element.scrollWidth}px`
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
type="text"
|
||||
size={1}
|
||||
defaultValue={children}
|
||||
onBlur={event => {
|
||||
const currentTarget = event.currentTarget
|
||||
// This must run AFTER the cancel button's event handler runs.
|
||||
setTimeout(() => {
|
||||
if (!cancelledRef.current) {
|
||||
currentTarget.form?.requestSubmit()
|
||||
}
|
||||
})
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
@ -111,6 +119,10 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
if (event.key !== 'Escape') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
if (event.target instanceof HTMLElement) {
|
||||
event.target.style.width = '0'
|
||||
event.target.style.width = `${event.target.scrollWidth}px`
|
||||
}
|
||||
}}
|
||||
{...(inputPattern == null ? {} : { pattern: inputPattern })}
|
||||
{...(inputTitle == null ? {} : { title: inputTitle })}
|
||||
@ -122,21 +134,21 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{isSubmittable && (
|
||||
<ariaComponents.ButtonGroup gap="xsmall" className="grow-0 items-center">
|
||||
{isSubmittable && (
|
||||
<ariaComponents.Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
icon={TickIcon}
|
||||
aria-label={getText('confirmEdit')}
|
||||
onPress={eventModule.submitForm}
|
||||
/>
|
||||
)}
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onPress={eventModule.submitForm}
|
||||
>
|
||||
<SvgMask src={TickIcon} alt={getText('confirmEdit')} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
<FocusRing>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
icon={CrossIcon}
|
||||
aria-label={getText('cancelEdit')}
|
||||
onPress={() => {
|
||||
cancelledRef.current = true
|
||||
onCancel()
|
||||
@ -144,10 +156,8 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
cancelledRef.current = false
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
</FocusRing>
|
||||
/>
|
||||
</ariaComponents.ButtonGroup>
|
||||
</form>
|
||||
)
|
||||
} else {
|
||||
|
@ -35,30 +35,17 @@ export interface JSONSchemaInputProps {
|
||||
/** A dynamic wizard for creating an arbitrary type of Datalink. */
|
||||
export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { dropdownTitle, readOnly = false, defs, schema, path, getValidator } = props
|
||||
const { value: valueRaw, setValue: setValueRaw } = props
|
||||
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 { getText } = textProvider.useText()
|
||||
const [value, setValue] = React.useState(valueRaw)
|
||||
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)
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(valueRaw)
|
||||
// `initializing` is not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueRaw])
|
||||
|
||||
React.useEffect(() => {
|
||||
setValueRaw(value)
|
||||
// `setStateRaw` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
// NOTE: `enum` schemas omitted for now as they are not yet used.
|
||||
if ('const' in schema) {
|
||||
// This value cannot change.
|
||||
@ -221,48 +208,45 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
: {})}
|
||||
>
|
||||
<FocusArea active={isOptional} direction="horizontal">
|
||||
{innerProps => (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isOptional}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left',
|
||||
isOptional && 'hover:bg-hover-bg'
|
||||
)}
|
||||
onPress={() => {
|
||||
if (isOptional) {
|
||||
setValue(oldValue => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<aria.Text
|
||||
{innerProps => {
|
||||
const isPresent = value != null && key in value
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isOptional}
|
||||
isActive={!isOptional || isPresent}
|
||||
className={tailwindMerge.twMerge(
|
||||
'selectable',
|
||||
value != null && key in value && 'active'
|
||||
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left',
|
||||
isOptional && 'hover:bg-hover-bg'
|
||||
)}
|
||||
onPress={() => {
|
||||
if (isOptional) {
|
||||
setValue(oldValue => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</aria.Text>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}}
|
||||
</FocusArea>
|
||||
{value != null && key in value && (
|
||||
<JSONSchemaInput
|
||||
|
@ -42,7 +42,7 @@ export default function Page(props: PageProps) {
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick)
|
||||
}
|
||||
}, [/* should never change */ unsetModal])
|
||||
}, [unsetModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -11,6 +11,14 @@ import * as eventModule from '#/utilities/event'
|
||||
import type * as geometry from '#/utilities/geometry'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** Controls the speed of animation of the {@link SelectionBrush} when the
|
||||
* mouse is released and the selection brush collapses back to zero size. */
|
||||
const ANIMATION_TIME_HORIZON = 60
|
||||
|
||||
// ======================
|
||||
// === SelectionBrush ===
|
||||
// ======================
|
||||
@ -18,6 +26,7 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
/** Props for a {@link SelectionBrush}. */
|
||||
export interface SelectionBrushProps {
|
||||
readonly targetRef: React.RefObject<HTMLElement>
|
||||
readonly margin?: number
|
||||
readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void
|
||||
readonly onDragEnd: (event: MouseEvent) => void
|
||||
readonly onDragCancel: () => void
|
||||
@ -25,37 +34,30 @@ export interface SelectionBrushProps {
|
||||
|
||||
/** A selection brush to indicate the area being selected by the mouse drag action. */
|
||||
export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
const { onDrag, onDragEnd, onDragCancel, targetRef } = props
|
||||
const { targetRef, margin = 0, onDrag, onDragEnd, onDragCancel } = props
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
const isMouseDownRef = React.useRef(false)
|
||||
const didMoveWhileDraggingRef = React.useRef(false)
|
||||
const onDragRef = React.useRef(onDrag)
|
||||
onDragRef.current = onDrag
|
||||
const onDragEndRef = React.useRef(onDragEnd)
|
||||
onDragEndRef.current = onDragEnd
|
||||
const onDragCancelRef = React.useRef(onDragCancel)
|
||||
onDragCancelRef.current = onDragCancel
|
||||
const lastMouseEvent = React.useRef<MouseEvent | null>(null)
|
||||
const parentBounds = React.useRef<DOMRect | null>(null)
|
||||
const [anchor, setAnchor] = React.useState<geometry.Coordinate2D | null>(null)
|
||||
// This will be `null` if `anchor` is `null`.
|
||||
const [position, setPosition] = React.useState<geometry.Coordinate2D | null>(null)
|
||||
const [lastSetAnchor, setLastSetAnchor] = React.useState<geometry.Coordinate2D | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const anchorAnimFactor = animationHooks.useApproach(anchor != null ? 1 : 0, 60)
|
||||
const anchorAnimFactor = animationHooks.useApproach(
|
||||
anchor != null ? 1 : 0,
|
||||
ANIMATION_TIME_HORIZON
|
||||
)
|
||||
const hidden =
|
||||
anchor == null ||
|
||||
position == null ||
|
||||
(anchor.left === position.left && anchor.top === position.top)
|
||||
|
||||
React.useEffect(() => {
|
||||
onDragRef.current = onDrag
|
||||
}, [onDrag])
|
||||
|
||||
React.useEffect(() => {
|
||||
onDragEndRef.current = onDragEnd
|
||||
}, [onDragEnd])
|
||||
|
||||
React.useEffect(() => {
|
||||
onDragCancelRef.current = onDragCancel
|
||||
}, [onDragCancel])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (anchor != null) {
|
||||
anchorAnimFactor.skip()
|
||||
@ -63,77 +65,95 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
}, [anchorAnimFactor, anchor])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (targetRef.current != null) {
|
||||
const target = targetRef.current
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
modalRef.current == null &&
|
||||
!eventModule.isElementTextInput(event.target) &&
|
||||
!(event.target instanceof HTMLButtonElement) &&
|
||||
!(event.target instanceof HTMLAnchorElement)
|
||||
) {
|
||||
isMouseDownRef.current = true
|
||||
didMoveWhileDraggingRef.current = false
|
||||
lastMouseEvent.current = event
|
||||
const newAnchor = { left: event.pageX, top: event.pageY }
|
||||
setAnchor(newAnchor)
|
||||
setLastSetAnchor(newAnchor)
|
||||
setPosition(newAnchor)
|
||||
}
|
||||
const target = targetRef.current ?? document.body
|
||||
const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => {
|
||||
if (parent == null) {
|
||||
return true
|
||||
} else {
|
||||
parentBounds.current = parent.getBoundingClientRect()
|
||||
return eventModule.isElementInBounds(event, parentBounds.current, margin)
|
||||
}
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
if (didMoveWhileDraggingRef.current) {
|
||||
onDragEndRef.current(event)
|
||||
}
|
||||
// The `setTimeout` is required, otherwise the values are changed before the `onClick` handler
|
||||
// is executed.
|
||||
window.setTimeout(() => {
|
||||
isMouseDownRef.current = false
|
||||
didMoveWhileDraggingRef.current = false
|
||||
})
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
modalRef.current == null &&
|
||||
!eventModule.isElementTextInput(event.target) &&
|
||||
!(event.target instanceof HTMLButtonElement) &&
|
||||
!(event.target instanceof HTMLAnchorElement) &&
|
||||
isEventInBounds(event, targetRef.current)
|
||||
) {
|
||||
isMouseDownRef.current = true
|
||||
didMoveWhileDraggingRef.current = false
|
||||
lastMouseEvent.current = event
|
||||
const newAnchor = { left: event.pageX, top: event.pageY }
|
||||
setAnchor(newAnchor)
|
||||
setLastSetAnchor(newAnchor)
|
||||
setPosition(newAnchor)
|
||||
}
|
||||
}
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
if (didMoveWhileDraggingRef.current) {
|
||||
onDragEndRef.current(event)
|
||||
}
|
||||
// The `setTimeout` is required, otherwise the values are changed before the `onClick` handler
|
||||
// is executed.
|
||||
window.setTimeout(() => {
|
||||
isMouseDownRef.current = false
|
||||
didMoveWhileDraggingRef.current = false
|
||||
})
|
||||
setAnchor(null)
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!(event.buttons & 1)) {
|
||||
isMouseDownRef.current = false
|
||||
}
|
||||
if (isMouseDownRef.current) {
|
||||
// Left click is being held.
|
||||
didMoveWhileDraggingRef.current = true
|
||||
lastMouseEvent.current = event
|
||||
const positionLeft =
|
||||
parentBounds.current == null
|
||||
? event.pageX
|
||||
: Math.max(
|
||||
parentBounds.current.left - margin,
|
||||
Math.min(parentBounds.current.right + margin, event.pageX)
|
||||
)
|
||||
const positionTop =
|
||||
parentBounds.current == null
|
||||
? event.pageY
|
||||
: Math.max(
|
||||
parentBounds.current.top - margin,
|
||||
Math.min(parentBounds.current.bottom + margin, event.pageY)
|
||||
)
|
||||
setPosition({ left: positionLeft, top: positionTop })
|
||||
}
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (isMouseDownRef.current && didMoveWhileDraggingRef.current) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
const onDragStart = () => {
|
||||
if (isMouseDownRef.current) {
|
||||
isMouseDownRef.current = false
|
||||
onDragCancelRef.current()
|
||||
setAnchor(null)
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!(event.buttons & 1)) {
|
||||
isMouseDownRef.current = false
|
||||
}
|
||||
if (isMouseDownRef.current) {
|
||||
// Left click is being held.
|
||||
didMoveWhileDraggingRef.current = true
|
||||
lastMouseEvent.current = event
|
||||
setPosition({ left: event.pageX, top: event.pageY })
|
||||
}
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (isMouseDownRef.current && didMoveWhileDraggingRef.current) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
const onDragStart = () => {
|
||||
if (isMouseDownRef.current) {
|
||||
isMouseDownRef.current = false
|
||||
onDragCancelRef.current()
|
||||
setAnchor(null)
|
||||
}
|
||||
}
|
||||
|
||||
target.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('dragstart', onDragStart, { capture: true })
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('click', onClick, { capture: true })
|
||||
|
||||
return () => {
|
||||
target.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('dragstart', onDragStart, { capture: true })
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('click', onClick, { capture: true })
|
||||
}
|
||||
} else {
|
||||
return () => {}
|
||||
}
|
||||
}, [/* should never change */ modalRef, targetRef])
|
||||
|
||||
target.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('dragstart', onDragStart, { capture: true })
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('click', onClick, { capture: true })
|
||||
return () => {
|
||||
target.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('dragstart', onDragStart, { capture: true })
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('click', onClick, { capture: true })
|
||||
}
|
||||
}, [margin, targetRef, modalRef])
|
||||
|
||||
const rectangle = React.useMemo(() => {
|
||||
if (position != null && lastSetAnchor != null) {
|
||||
@ -163,10 +183,8 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectionRectangle != null && lastMouseEvent.current != null) {
|
||||
onDrag(selectionRectangle, lastMouseEvent.current)
|
||||
onDragRef.current(selectionRectangle, lastMouseEvent.current)
|
||||
}
|
||||
// `onChange` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectionRectangle])
|
||||
|
||||
const brushStyle =
|
||||
|
@ -44,7 +44,7 @@ export default function SvgMask(props: SvgMaskProps) {
|
||||
...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}}
|
||||
className={tailwindMerge.twMerge('inline-block h-max w-max', className)}
|
||||
className={tailwindMerge.twMerge('inline-block size-max', className)}
|
||||
>
|
||||
{/* This is required for this component to have the right size. */}
|
||||
<img alt={alt} src={src} className="pointer-events-none opacity-0" draggable={false} />
|
||||
|
@ -4,6 +4,7 @@ import * as React from 'react'
|
||||
import BlankIcon from 'enso-assets/blank.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
@ -99,6 +100,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId } = state
|
||||
|
||||
const draggableProps = dragAndDropHooks.useDraggable()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
@ -156,7 +158,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
if (selected && insertionVisibility !== Visibility.visible) {
|
||||
setSelected(false)
|
||||
}
|
||||
}, [selected, insertionVisibility, /* should never change */ setSelected])
|
||||
}, [selected, insertionVisibility, setSelected])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isKeyboardSelected) {
|
||||
@ -201,10 +203,10 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
asset,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
/* should never change */ copyAssetMutate,
|
||||
/* should never change */ nodeMap,
|
||||
/* should never change */ setAsset,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
copyAssetMutate,
|
||||
nodeMap,
|
||||
setAsset,
|
||||
dispatchAssetListEvent,
|
||||
]
|
||||
)
|
||||
|
||||
@ -316,9 +318,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
item.directoryKey,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
/* should never change */ updateAssetMutate,
|
||||
/* should never change */ setAsset,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
updateAssetMutate,
|
||||
setAsset,
|
||||
dispatchAssetListEvent,
|
||||
]
|
||||
)
|
||||
|
||||
@ -327,13 +329,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
setAssetPanelProps({ backend, item, setItem })
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
}, [
|
||||
item,
|
||||
isSoleSelected,
|
||||
/* should never change */ backend,
|
||||
/* should never change */ setAssetPanelProps,
|
||||
/* should never change */ setIsAssetPanelTemporarilyVisible,
|
||||
])
|
||||
}, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
|
||||
|
||||
const doDelete = React.useCallback(
|
||||
async (forever = false) => {
|
||||
@ -377,14 +373,14 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
},
|
||||
[
|
||||
backend.type,
|
||||
backend,
|
||||
dispatchAssetListEvent,
|
||||
asset,
|
||||
/* should never change */ openProjectMutate,
|
||||
/* should never change */ closeProjectMutate,
|
||||
/* should never change */ deleteAssetMutate,
|
||||
/* should never change */ item.key,
|
||||
/* should never change */ toastAndLog,
|
||||
openProjectMutate,
|
||||
closeProjectMutate,
|
||||
deleteAssetMutate,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
]
|
||||
)
|
||||
|
||||
@ -398,13 +394,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
toastAndLog('restoreAssetError', error, asset.title)
|
||||
}
|
||||
}, [
|
||||
dispatchAssetListEvent,
|
||||
asset,
|
||||
toastAndLog,
|
||||
/* should never change */ undoDeleteAssetMutate,
|
||||
/* should never change */ item.key,
|
||||
])
|
||||
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAssetMutate, item.key])
|
||||
|
||||
const doTriggerDescriptionEdit = React.useCallback(() => {
|
||||
setModal(
|
||||
@ -723,20 +713,12 @@ 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, setItem, state, rowState, setRowState }
|
||||
return (
|
||||
<>
|
||||
{!hidden && (
|
||||
<FocusRing>
|
||||
<tr
|
||||
draggable
|
||||
tabIndex={0}
|
||||
ref={element => {
|
||||
rootRef.current = element
|
||||
@ -761,6 +743,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
visibility,
|
||||
(isDraggedOver || selected) && 'selected'
|
||||
)}
|
||||
{...draggableProps}
|
||||
onClick={event => {
|
||||
unsetModal()
|
||||
onClick(innerProps, event)
|
||||
@ -825,7 +808,6 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
if (state.category === Category.trash) {
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
|
||||
props.onDragOver?.(event)
|
||||
onDragOver(event)
|
||||
}}
|
||||
|
@ -27,6 +27,7 @@ import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import * as validation from '#/utilities/validation'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
|
||||
// =====================
|
||||
@ -52,6 +53,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
}
|
||||
const asset = item.item
|
||||
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')
|
||||
@ -169,11 +171,11 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
icon={FolderArrowIcon}
|
||||
size="icon"
|
||||
variant="custom"
|
||||
aria-label={item.children == null ? getText('expand') : getText('collapse')}
|
||||
aria-label={isExpanded ? getText('collapse') : getText('expand')}
|
||||
tooltipPlacement="left"
|
||||
className={tailwindMerge.twMerge(
|
||||
'm-0 hidden cursor-pointer border-0 transition-transform duration-arrow group-hover:m-name-column-icon group-hover:inline-block',
|
||||
item.children != null && 'rotate-90'
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
onPress={() => {
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
@ -188,16 +190,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||
)}
|
||||
checkSubmittable={newTitle =>
|
||||
newTitle !== item.item.title &&
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
child =>
|
||||
// All siblings,
|
||||
child.key === item.key ||
|
||||
// that are directories,
|
||||
!backendModule.assetIsDirectory(child.item) ||
|
||||
// must have a different name.
|
||||
child.item.title !== newTitle
|
||||
)
|
||||
validation.DIRECTORY_NAME_REGEX.test(newTitle) &&
|
||||
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
|
||||
}
|
||||
onSubmit={doRename}
|
||||
onCancel={() => {
|
||||
|
@ -175,16 +175,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
editable={rowState.isEditingName}
|
||||
className="text grow bg-transparent font-naming"
|
||||
checkSubmittable={newTitle =>
|
||||
newTitle !== item.item.title &&
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
child =>
|
||||
// All siblings,
|
||||
child.key === item.key ||
|
||||
// that are not directories,
|
||||
backendModule.assetIsDirectory(child.item) ||
|
||||
// must have a different name.
|
||||
child.item.title !== newTitle
|
||||
)
|
||||
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
|
||||
}
|
||||
onSubmit={doRename}
|
||||
onCancel={() => {
|
||||
|
@ -16,6 +16,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as inputBindingsModule from '#/utilities/inputBindings'
|
||||
@ -26,9 +27,9 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
// ========================
|
||||
|
||||
/** The size (both width and height) of key icons. */
|
||||
const ICON_SIZE_PX = 13
|
||||
const ICON_SIZE_PX = '1.5cap'
|
||||
|
||||
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX }
|
||||
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX, marginTop: '0.1cap' }
|
||||
|
||||
/** Props for values of {@link MODIFIER_JSX}. */
|
||||
interface InternalModifierProps {
|
||||
@ -124,24 +125,24 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
.sort(inputBindingsModule.compareModifiers)
|
||||
.map(inputBindingsModule.toModifierKey)
|
||||
return (
|
||||
<aria.Text
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-text items-center',
|
||||
'flex items-center',
|
||||
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers'
|
||||
)}
|
||||
>
|
||||
{modifiers.map(
|
||||
modifier =>
|
||||
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
|
||||
<aria.Text key={modifier} className="text">
|
||||
<ariaComponents.Text key={modifier}>
|
||||
{getText(MODIFIER_TO_TEXT_ID[modifier])}
|
||||
</aria.Text>
|
||||
</ariaComponents.Text>
|
||||
)
|
||||
)}
|
||||
<aria.Text className="text">
|
||||
<ariaComponents.Text>
|
||||
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
|
||||
</aria.Text>
|
||||
</aria.Text>
|
||||
</ariaComponents.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import * as focusHooks from '#/hooks/focusHooks'
|
||||
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
|
||||
import type * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
@ -38,7 +39,8 @@ interface InternalLabelProps extends Readonly<React.PropsWithChildren> {
|
||||
/** An label that can be applied to an asset. */
|
||||
export default function Label(props: InternalLabelProps) {
|
||||
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
|
||||
const { className = 'text-tag-text', children, onPress, onDragStart, onContextMenu } = props
|
||||
const { className = 'text-tag-text', onPress, onDragStart, onContextMenu } = props
|
||||
const { children: childrenRaw } = props
|
||||
const focusDirection = focusDirectionProvider.useFocusDirection()
|
||||
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
|
||||
const textClass = /\btext-/.test(className)
|
||||
@ -47,6 +49,15 @@ export default function Label(props: InternalLabelProps) {
|
||||
? 'text-tag-text'
|
||||
: 'text-primary'
|
||||
|
||||
const children =
|
||||
typeof childrenRaw !== 'string' ? (
|
||||
childrenRaw
|
||||
) : (
|
||||
<ariaComponents.Text truncate="1" className="max-w-24 text-inherit">
|
||||
{childrenRaw}
|
||||
</ariaComponents.Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<FocusRing within placement="after">
|
||||
<div
|
||||
|
@ -20,9 +20,18 @@ export interface PermissionDisplayProps extends Readonly<React.PropsWithChildren
|
||||
|
||||
/** Colored border around icons and text indicating permissions. */
|
||||
export default function PermissionDisplay(props: PermissionDisplayProps) {
|
||||
const { action, className, onPress, children } = props
|
||||
const { action, className, onPress, children: childrenRaw } = props
|
||||
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
|
||||
|
||||
const children =
|
||||
typeof childrenRaw !== 'string' ? (
|
||||
childrenRaw
|
||||
) : (
|
||||
<ariaComponents.Text truncate="1" className="max-w-24 text-inherit">
|
||||
{childrenRaw}
|
||||
</ariaComponents.Text>
|
||||
)
|
||||
|
||||
switch (permission.type) {
|
||||
case permissionsModule.Permission.owner:
|
||||
case permissionsModule.Permission.admin:
|
||||
|
@ -202,14 +202,14 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
default: {
|
||||
permissionDisplay = (
|
||||
<ariaComponents.Button
|
||||
size="xxsmall"
|
||||
size="xsmall"
|
||||
variant="custom"
|
||||
ref={permissionSelectorButtonRef}
|
||||
isDisabled={isDisabled}
|
||||
isActive={!isDisabled || !isInput}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-6 w-[121px] rounded-full',
|
||||
'w-[121px] rounded-full border-0 py-0',
|
||||
permissions.PERMISSION_CLASS_NAME[permission.type]
|
||||
)}
|
||||
onPress={doShowPermissionTypeSelector}
|
||||
|
@ -108,7 +108,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
return object.merge(oldItem, { projectState: newProjectState })
|
||||
})
|
||||
},
|
||||
[/* should never change */ user, /* should never change */ setItem]
|
||||
[user, setItem]
|
||||
)
|
||||
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
|
||||
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
|
||||
@ -116,6 +116,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
item.projectState.executeAsync ?? false
|
||||
)
|
||||
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
|
||||
const doOpenEditorRef = React.useRef(doOpenEditor)
|
||||
doOpenEditorRef.current = doOpenEditor
|
||||
const toastId: toast.Id = React.useId()
|
||||
const isOpening =
|
||||
backendModule.IS_OPENING[item.projectState.type] &&
|
||||
@ -163,10 +165,10 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
item,
|
||||
session,
|
||||
toastAndLog,
|
||||
/* should never change */ openProjectMutate,
|
||||
/* should never change */ getProjectDetailsMutate,
|
||||
/* should never change */ setState,
|
||||
/* should never change */ setItem,
|
||||
openProjectMutate,
|
||||
getProjectDetailsMutate,
|
||||
setState,
|
||||
setItem,
|
||||
]
|
||||
)
|
||||
|
||||
@ -231,10 +233,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setShouldOpenWhenReady(!event.runInBackground)
|
||||
setShouldSwitchPage(event.shouldAutomaticallySwitchPage)
|
||||
setIsRunningInBackground(event.runInBackground)
|
||||
void openProject(event.runInBackground)
|
||||
if (backendModule.IS_OPENING_OR_OPENED[state]) {
|
||||
if (!isRunningInBackground) {
|
||||
doOpenEditor(true)
|
||||
}
|
||||
} else {
|
||||
setShouldOpenWhenReady(!event.runInBackground)
|
||||
setShouldSwitchPage(event.shouldAutomaticallySwitchPage)
|
||||
setIsRunningInBackground(event.runInBackground)
|
||||
void openProject(event.runInBackground)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -257,12 +265,10 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
React.useEffect(() => {
|
||||
if (state === backendModule.ProjectState.opened) {
|
||||
if (shouldOpenWhenReady) {
|
||||
doOpenEditor(shouldSwitchPage)
|
||||
doOpenEditorRef.current(shouldSwitchPage)
|
||||
setShouldOpenWhenReady(false)
|
||||
}
|
||||
}
|
||||
// `doOpenEditor` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldOpenWhenReady, shouldSwitchPage, state])
|
||||
|
||||
const closeProject = async () => {
|
||||
@ -284,11 +290,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
variant="icon"
|
||||
icon={PlayIcon}
|
||||
aria-label={getText('openInEditor')}
|
||||
tooltipPlacement="left"
|
||||
className="size-project-icon border-0"
|
||||
className="h-6 border-0"
|
||||
onPress={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
@ -307,17 +313,14 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
<div className="relative flex">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
variant="icon"
|
||||
isDisabled={isOtherUserUsingProject}
|
||||
isActive={!isOtherUserUsingProject}
|
||||
icon={StopIcon}
|
||||
aria-label={getText('stopExecution')}
|
||||
tooltipPlacement="left"
|
||||
{...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})}
|
||||
className={tailwindMerge.twMerge(
|
||||
'size-project-icon border-0',
|
||||
isRunningInBackground && 'text-green'
|
||||
)}
|
||||
className={tailwindMerge.twMerge('h-6 border-0', isRunningInBackground && 'text-green')}
|
||||
onPress={closeProject}
|
||||
/>
|
||||
<Spinner
|
||||
@ -335,7 +338,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
<div className="relative flex">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
variant="icon"
|
||||
isDisabled={isOtherUserUsingProject}
|
||||
isActive={!isOtherUserUsingProject}
|
||||
icon={StopIcon}
|
||||
@ -345,7 +348,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
? { title: getText('otherUserIsUsingProjectError') }
|
||||
: {})}
|
||||
className={tailwindMerge.twMerge(
|
||||
'size-project-icon border-0',
|
||||
'h-6 border-0',
|
||||
isRunningInBackground && 'text-green'
|
||||
)}
|
||||
onPress={closeProject}
|
||||
@ -361,11 +364,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
{!isOtherUserUsingProject && !isRunningInBackground && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="size-project-icon border-0"
|
||||
variant="icon"
|
||||
icon={ArrowUpIcon}
|
||||
aria-label={getText('openInEditor')}
|
||||
tooltipPlacement="right"
|
||||
className="h-6 border-0"
|
||||
onPress={() => {
|
||||
doOpenEditor(true)
|
||||
}}
|
||||
|
@ -270,22 +270,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
)
|
||||
|
||||
const handleClick = inputBindings.handler({
|
||||
open: () => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: asset.id,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
runInBackground: false,
|
||||
})
|
||||
},
|
||||
run: () => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: asset.id,
|
||||
shouldAutomaticallySwitchPage: false,
|
||||
runInBackground: true,
|
||||
})
|
||||
},
|
||||
editName: () => {
|
||||
setIsEditing(true)
|
||||
},
|
||||
@ -314,6 +298,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
selectedKeys.current.size === 1
|
||||
) {
|
||||
setIsEditing(true)
|
||||
} else if (eventModule.isDoubleClick(event)) {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: asset.id,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
runInBackground: false,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -346,16 +337,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
rowState.isEditingName && 'cursor-text'
|
||||
)}
|
||||
checkSubmittable={newTitle =>
|
||||
newTitle !== item.item.title &&
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
child =>
|
||||
// All siblings,
|
||||
child.key === item.key ||
|
||||
// that are not directories,
|
||||
backendModule.assetIsDirectory(child.item) ||
|
||||
// must have a different name.
|
||||
child.item.title !== newTitle
|
||||
)
|
||||
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
|
||||
}
|
||||
onSubmit={doRename}
|
||||
onCancel={() => {
|
||||
|
@ -62,7 +62,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
})
|
||||
)
|
||||
},
|
||||
[/* should never change */ setItem]
|
||||
[setItem]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -141,10 +141,11 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
))}
|
||||
{managesThisAsset && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
ref={plusButtonRef}
|
||||
className="shrink-0 rounded-full opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
showIconOnHover
|
||||
icon={Plus2Icon}
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManageLabelsModal
|
||||
@ -156,9 +157,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={Plus2Icon} />
|
||||
</ariaComponents.Button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -70,7 +70,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
})
|
||||
)
|
||||
},
|
||||
[/* should never change */ setItem]
|
||||
[setItem]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -108,10 +108,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
)}
|
||||
{managesThisAsset && !isUnderPaywall && (
|
||||
<ariaComponents.Button
|
||||
variant="icon"
|
||||
size="xsmall"
|
||||
ref={plusButtonRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
icon={Plus2Icon}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
showIconOnHover
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
@ -122,10 +123,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
self={self}
|
||||
eventTarget={plusButtonRef.current}
|
||||
doRemoveSelf={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.removeSelf,
|
||||
id: asset.id,
|
||||
})
|
||||
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: asset.id })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
@ -49,25 +49,28 @@ export function useDebugState<T>(
|
||||
// === useMonitorDependencies ===
|
||||
|
||||
/** A helper function to log the old and new values of changed dependencies. */
|
||||
function useMonitorDependencies(
|
||||
export function useMonitorDependencies(
|
||||
dependencies: React.DependencyList,
|
||||
description?: string,
|
||||
dependencyDescriptions?: readonly string[]
|
||||
dependencyDescriptions?: readonly string[],
|
||||
active = true
|
||||
) {
|
||||
const oldDependenciesRef = React.useRef(dependencies)
|
||||
const indicesOfChangedDependencies = dependencies.flatMap((dep, i) =>
|
||||
Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i]
|
||||
)
|
||||
if (indicesOfChangedDependencies.length !== 0) {
|
||||
const descriptionText = description == null ? '' : `for '${description}'`
|
||||
console.group(`dependencies changed${descriptionText}`)
|
||||
for (const i of indicesOfChangedDependencies) {
|
||||
console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`)
|
||||
console.log('old value:', oldDependenciesRef.current[i])
|
||||
console.log('new value:', dependencies[i])
|
||||
if (active) {
|
||||
const indicesOfChangedDependencies = dependencies.flatMap((dep, i) =>
|
||||
Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i]
|
||||
)
|
||||
if (indicesOfChangedDependencies.length !== 0) {
|
||||
const descriptionText = description == null ? '' : ` for '${description}'`
|
||||
console.group(`dependencies changed${descriptionText}`)
|
||||
for (const i of indicesOfChangedDependencies) {
|
||||
console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`)
|
||||
console.log('old value:', oldDependenciesRef.current[i])
|
||||
console.log('new value:', dependencies[i])
|
||||
console.groupEnd()
|
||||
}
|
||||
console.groupEnd()
|
||||
}
|
||||
console.groupEnd()
|
||||
}
|
||||
oldDependenciesRef.current = dependencies
|
||||
}
|
||||
|
26
app/ide-desktop/lib/dashboard/src/hooks/dragAndDropHooks.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/** @file Hooks related to the HTML5 Drag and Drop API. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
|
||||
/** Whether an element is actually draggable. This should be used on ALL
|
||||
* elements that are parents of text inputs.
|
||||
*
|
||||
* This is required to work around a Firefox bug:
|
||||
* https://bugzilla.mozilla.org/show_bug.cgi?id=800050
|
||||
* @returns An object that should be merged into the element's props. */
|
||||
export function useDraggable() {
|
||||
const [isDraggable, setIsDraggable] = React.useState(true)
|
||||
|
||||
return {
|
||||
draggable: isDraggable,
|
||||
onFocus: event => {
|
||||
if (eventModule.isElementTextInput(event.target)) {
|
||||
setIsDraggable(false)
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
setIsDraggable(true)
|
||||
},
|
||||
} satisfies Partial<React.HTMLAttributes<HTMLElement>>
|
||||
}
|
90
app/ide-desktop/lib/dashboard/src/hooks/intersectionHooks.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/** @file Track changes in intersection ratio between an element and one of its ancestors. */
|
||||
import * as React from 'react'
|
||||
|
||||
// ============================
|
||||
// === useIntersectionRatio ===
|
||||
// ============================
|
||||
|
||||
export function useIntersectionRatio(
|
||||
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
|
||||
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
|
||||
threshold: number[] | number
|
||||
): number
|
||||
export function useIntersectionRatio<T>(
|
||||
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
|
||||
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
|
||||
threshold: number[] | number,
|
||||
// Undefined MUST be excluded due to how the fallback value works when a `transform` function
|
||||
// is not passed in.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
transform: (ratio: number) => Exclude<T, undefined>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
initialValue: Exclude<T, undefined>
|
||||
): T
|
||||
/** Track changes in intersection ratio between an element and one of its ancestors.
|
||||
*
|
||||
* Note that if `threshold` is an array, it MUST be memoized.
|
||||
* Similarly, `rootRef` and `targetRef` MUST be stable across renders. */
|
||||
export function useIntersectionRatio<T>(
|
||||
rootRef: Readonly<React.MutableRefObject<HTMLDivElement | null>> | null,
|
||||
targetRef: Readonly<React.MutableRefObject<HTMLElement | SVGElement | null>>,
|
||||
threshold: number[] | number,
|
||||
transform?: (ratio: number) => T,
|
||||
initialValue?: T
|
||||
) {
|
||||
// `initialValue` is guaranteed to be the right type by the overloads.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const [value, setValue] = React.useState((initialValue === undefined ? 0 : initialValue) as T)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const transformRef = React.useRef(transform ?? ((ratio: number) => ratio as never))
|
||||
if (transform) {
|
||||
transformRef.current = transform
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = rootRef?.current ?? document.body
|
||||
const mainDropzone = targetRef.current
|
||||
if (mainDropzone != null) {
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
setValue(transformRef.current(entry.intersectionRatio))
|
||||
}
|
||||
},
|
||||
{ root, threshold }
|
||||
)
|
||||
intersectionObserver.observe(mainDropzone)
|
||||
|
||||
const recomputeIntersectionRatio = () => {
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const dropzoneRect = mainDropzone.getBoundingClientRect()
|
||||
const intersectionX = Math.max(rootRect.x, dropzoneRect.x)
|
||||
const intersectionY = Math.max(rootRect.y, dropzoneRect.y)
|
||||
const intersectionRect = new DOMRect(
|
||||
intersectionX,
|
||||
intersectionY,
|
||||
Math.min(rootRect.right, dropzoneRect.right) - intersectionX,
|
||||
Math.min(rootRect.bottom, dropzoneRect.bottom) - intersectionY
|
||||
)
|
||||
const dropzoneArea = dropzoneRect.width * dropzoneRect.height
|
||||
const intersectionArea = intersectionRect.width * intersectionRect.height
|
||||
const intersectionRatio = Math.max(0, dropzoneArea / intersectionArea)
|
||||
setValue(transformRef.current(intersectionRatio))
|
||||
}
|
||||
recomputeIntersectionRatio()
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
recomputeIntersectionRatio()
|
||||
})
|
||||
resizeObserver.observe(root)
|
||||
|
||||
return () => {
|
||||
intersectionObserver.disconnect()
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [targetRef, rootRef, threshold])
|
||||
|
||||
return value
|
||||
}
|
@ -33,7 +33,7 @@ export function useNavigate() {
|
||||
originalNavigate(...(args as [never, never?]))
|
||||
}
|
||||
},
|
||||
[/* should never change */ goOffline, /* should never change */ originalNavigate]
|
||||
[goOffline, originalNavigate]
|
||||
)
|
||||
|
||||
return navigate
|
||||
|
@ -1,51 +1,17 @@
|
||||
/** @file Execute a function on scroll. */
|
||||
import * as React from 'react'
|
||||
|
||||
// ===================
|
||||
// === useOnScroll ===
|
||||
// ===================
|
||||
|
||||
/** Execute a function on scroll. */
|
||||
export function useOnScroll(callback: () => void, dependencies: React.DependencyList = []) {
|
||||
const callbackRef = React.useRef(callback)
|
||||
callbackRef.current = callback
|
||||
const updateClipPathRef = React.useRef(() => {})
|
||||
|
||||
const onScroll = React.useMemo(() => {
|
||||
let isClipPathUpdateQueued = false
|
||||
const updateClipPath = () => {
|
||||
isClipPathUpdateQueued = false
|
||||
callbackRef.current()
|
||||
}
|
||||
updateClipPathRef.current = updateClipPath
|
||||
updateClipPath()
|
||||
return () => {
|
||||
if (!isClipPathUpdateQueued) {
|
||||
isClipPathUpdateQueued = true
|
||||
requestAnimationFrame(updateClipPath)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateClipPathRef.current()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('resize', onScroll)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onScroll)
|
||||
}
|
||||
}, [onScroll])
|
||||
|
||||
return onScroll
|
||||
}
|
||||
import useOnScroll from '#/hooks/useOnScroll'
|
||||
|
||||
// ====================================
|
||||
// === useStickyTableHeaderOnScroll ===
|
||||
// ====================================
|
||||
|
||||
/** Options for the {@link useStickyTableHeaderOnScroll} hook. */
|
||||
interface UseStickyTableHeaderOnScrollOptions {
|
||||
readonly trackShadowClass?: boolean
|
||||
}
|
||||
|
||||
/** Properly clip the table body to avoid the table header on scroll.
|
||||
* This is required to prevent the table body from overlapping the table header,
|
||||
* because the table header is transparent.
|
||||
@ -57,8 +23,9 @@ export function useOnScroll(callback: () => void, dependencies: React.Dependency
|
||||
export function useStickyTableHeaderOnScroll(
|
||||
rootRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||
bodyRef: React.RefObject<HTMLTableSectionElement>,
|
||||
trackShadowClass = false
|
||||
options: UseStickyTableHeaderOnScrollOptions = {}
|
||||
) {
|
||||
const { trackShadowClass = false } = options
|
||||
const trackShadowClassRef = React.useRef(trackShadowClass)
|
||||
trackShadowClassRef.current = trackShadowClass
|
||||
const [shadowClassName, setShadowClass] = React.useState('')
|
||||
@ -79,6 +46,6 @@ export function useStickyTableHeaderOnScroll(
|
||||
setShadowClass(newShadowClass)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [bodyRef, rootRef])
|
||||
return { onScroll, shadowClassName }
|
||||
}
|
||||
|
@ -38,6 +38,6 @@ export function useSetAsset<T extends backend.AnyAsset>(
|
||||
return ret
|
||||
})
|
||||
},
|
||||
[/* should never change */ setNode]
|
||||
[setNode]
|
||||
)
|
||||
}
|
||||
|
@ -50,6 +50,6 @@ export function useToastAndLog() {
|
||||
logger.error(message)
|
||||
return id
|
||||
},
|
||||
[getText, /* should never change */ logger]
|
||||
[getText, logger]
|
||||
)
|
||||
}
|
||||
|
44
app/ide-desktop/lib/dashboard/src/hooks/useOnScroll.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/** @file Execute a function on scroll. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
// ===================
|
||||
// === useOnScroll ===
|
||||
// ===================
|
||||
|
||||
/** Execute a function on scroll. */
|
||||
export default function useOnScroll(callback: () => void, dependencies: React.DependencyList) {
|
||||
const callbackTrampoline = eventCallbackHooks.useEventCallback(callback)
|
||||
const updateClipPathRef = React.useRef(() => {})
|
||||
|
||||
const onScroll = React.useMemo(() => {
|
||||
let isClipPathUpdateQueued = false
|
||||
const updateClipPath = () => {
|
||||
isClipPathUpdateQueued = false
|
||||
callbackTrampoline()
|
||||
}
|
||||
updateClipPathRef.current = updateClipPath
|
||||
updateClipPath()
|
||||
return () => {
|
||||
if (!isClipPathUpdateQueued) {
|
||||
isClipPathUpdateQueued = true
|
||||
requestAnimationFrame(updateClipPath)
|
||||
}
|
||||
}
|
||||
}, [callbackTrampoline])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateClipPathRef.current()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('resize', onScroll)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onScroll)
|
||||
}
|
||||
}, [onScroll])
|
||||
|
||||
return onScroll
|
||||
}
|
@ -75,6 +75,8 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const initializedRef = React.useRef(initialized)
|
||||
initializedRef.current = initialized
|
||||
const [tab, setTab] = React.useState(() => {
|
||||
const savedTab = localStorage.get('assetPanelTab') ?? AssetPanelTab.properties
|
||||
if (
|
||||
@ -91,12 +93,10 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
React.useEffect(() => {
|
||||
// This prevents secrets and directories always setting the tab to `properties`
|
||||
// (because they do not support the `versions` tab).
|
||||
if (initialized) {
|
||||
if (initializedRef.current) {
|
||||
localStorage.set('assetPanelTab', tab)
|
||||
}
|
||||
// `initialized` is NOT a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, /* should never change */ localStorage])
|
||||
}, [tab, localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
setInitialized(true)
|
||||
|
@ -70,7 +70,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
setItemInner(valueOrUpdater)
|
||||
setItemRaw(valueOrUpdater)
|
||||
},
|
||||
[/* should never change */ setItemRaw]
|
||||
[setItemRaw]
|
||||
)
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
const self = item.item.permissions?.find(
|
||||
@ -87,6 +87,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
|
||||
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
|
||||
const getDatalinkMutate = getDatalinkMutation.mutateAsync
|
||||
|
||||
React.useEffect(() => {
|
||||
setDescription(item.item.description ?? '')
|
||||
@ -95,13 +96,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
React.useEffect(() => {
|
||||
void (async () => {
|
||||
if (item.item.type === backendModule.AssetType.datalink) {
|
||||
const value = await getDatalinkMutation.mutateAsync([item.item.id, item.item.title])
|
||||
const value = await getDatalinkMutate([item.item.id, item.item.title])
|
||||
setDatalinkValue(value)
|
||||
setEditedDatalinkValue(value)
|
||||
setIsDatalinkFetched(true)
|
||||
}
|
||||
})()
|
||||
}, [backend, item.item, getDatalinkMutation])
|
||||
}, [backend, item.item, getDatalinkMutate])
|
||||
|
||||
const doEditDescription = async () => {
|
||||
setIsEditingDescription(false)
|
||||
|
@ -260,7 +260,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
root?.removeEventListener('keydown', onSearchKeyDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [setQuery, /* should never change */ modalRef])
|
||||
}, [setQuery, modalRef])
|
||||
|
||||
// Reset `querySource` after all other effects have run.
|
||||
React.useEffect(() => {
|
||||
@ -271,7 +271,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
baseQuery.current = query
|
||||
querySource.current = QuerySource.external
|
||||
}
|
||||
}, [query, /* should never change */ setQuery])
|
||||
}, [query, setQuery])
|
||||
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
|
@ -10,8 +10,9 @@ import * as mimeTypes from '#/data/mimeTypes'
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||
import * as intersectionHooks from '#/hooks/intersectionHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import useOnScroll from '#/hooks/useOnScroll'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
@ -99,6 +100,9 @@ LocalStorage.registerKey('enabledColumns', {
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** If the ratio of intersection between the main dropzone that should be visible, and the
|
||||
* scrollable container, is below this value, then the backup dropzone will be shown. */
|
||||
const MINIMUM_DROPZONE_INTERSECTION_RATIO = 0.5
|
||||
/** If the drag pointer is less than this distance away from the top or bottom of the
|
||||
* scroll container, then the scroll container automatically scrolls upwards if the cursor is near
|
||||
* the top of the scroll container, or downwards if the cursor is near the bottom. */
|
||||
@ -382,7 +386,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { doOpenEditor: doOpenEditorRaw, doCloseEditor: doCloseEditorRaw } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const backend = backendProvider.useBackend(category)
|
||||
const labels = backendHooks.useBackendListTags(backend)
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
@ -392,6 +396,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const initializedRef = React.useRef(initialized)
|
||||
initializedRef.current = initialized
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
|
||||
const [sortInfo, setSortInfo] =
|
||||
@ -418,12 +424,17 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
-1
|
||||
)
|
||||
})
|
||||
const [isDropzoneVisible, setIsDropzoneVisible] = React.useState(false)
|
||||
const [isDraggingFiles, setIsDraggingFiles] = React.useState(false)
|
||||
const [droppedFilesCount, setDroppedFilesCount] = React.useState(0)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
/** Events sent when the asset list was still loading. */
|
||||
const queuedAssetListEventsRef = React.useRef<assetListEvent.AssetListEvent[]>([])
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const cleanupRootRef = React.useRef(() => {})
|
||||
const mainDropzoneRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const lastSelectedIdsRef = React.useRef<
|
||||
backendModule.AssetId | ReadonlySet<backendModule.AssetId> | null
|
||||
>(null)
|
||||
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
|
||||
const assetTreeRef = React.useRef<assetTreeNode.AnyAssetTreeNode>(assetTree)
|
||||
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
|
||||
@ -605,6 +616,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
[displayItems, visibilities]
|
||||
)
|
||||
|
||||
const isMainDropzoneVisible = intersectionHooks.useIntersectionRatio(
|
||||
rootRef,
|
||||
mainDropzoneRef,
|
||||
MINIMUM_DROPZONE_INTERSECTION_RATIO,
|
||||
ratio => ratio >= MINIMUM_DROPZONE_INTERSECTION_RATIO,
|
||||
true
|
||||
)
|
||||
|
||||
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -823,7 +842,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isCloud, assetTree, query, visibilities, labels, /* should never change */ setSuggestions])
|
||||
}, [isCloud, assetTree, query, visibilities, labels, setSuggestions])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoading(true)
|
||||
@ -854,11 +873,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [
|
||||
hidden,
|
||||
/* should never change */ inputBindings,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
])
|
||||
}, [hidden, inputBindings, dispatchAssetEvent])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
@ -916,7 +931,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
)
|
||||
}
|
||||
},
|
||||
[isCloud, /* should never change */ setCanDownload]
|
||||
[isCloud, setCanDownload]
|
||||
)
|
||||
|
||||
const clearSelectedKeys = React.useCallback(() => {
|
||||
@ -940,7 +955,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
newAssets.map(asset =>
|
||||
AssetTreeNode.fromAsset(asset, rootDirectory.id, rootDirectory.id, 0)
|
||||
),
|
||||
-1
|
||||
-1,
|
||||
rootDirectory.id,
|
||||
true
|
||||
)
|
||||
setAssetTree(newRootNode)
|
||||
// The project name here might also be a string with project id, e.g.
|
||||
@ -978,20 +995,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
return null
|
||||
})
|
||||
},
|
||||
[
|
||||
rootDirectoryId,
|
||||
toastAndLog,
|
||||
/* should never change */ setNameOfProjectToImmediatelyOpen,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
]
|
||||
[rootDirectoryId, toastAndLog, setNameOfProjectToImmediatelyOpen, dispatchAssetEvent]
|
||||
)
|
||||
const overwriteNodesRef = React.useRef(overwriteNodes)
|
||||
overwriteNodesRef.current = overwriteNodes
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialized) {
|
||||
overwriteNodes([])
|
||||
if (initializedRef.current) {
|
||||
overwriteNodesRef.current([])
|
||||
}
|
||||
// `overwriteAssets` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [backend, category])
|
||||
|
||||
asyncEffectHooks.useAsyncEffect(
|
||||
@ -1026,7 +1038,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[category, accessToken, user, backend, setSelectedKeys]
|
||||
[category, backend, setSelectedKeys]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -1034,24 +1046,20 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (savedEnabledColumns != null) {
|
||||
setEnabledColumns(new Set(savedEnabledColumns))
|
||||
}
|
||||
}, [/* should never change */ localStorage])
|
||||
}, [localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialized) {
|
||||
localStorage.set('enabledColumns', [...enabledColumns])
|
||||
}
|
||||
}, [enabledColumns, initialized, /* should never change */ localStorage])
|
||||
}, [enabledColumns, initialized, localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedKeysRef.current.size !== 1) {
|
||||
setAssetPanelProps(null)
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
}, [
|
||||
selectedKeysRef.current.size,
|
||||
/* should never change */ setAssetPanelProps,
|
||||
/* should never change */ setIsAssetPanelTemporarilyVisible,
|
||||
])
|
||||
}, [selectedKeysRef.current.size, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
|
||||
|
||||
const directoryListAbortControllersRef = React.useRef(
|
||||
new Map<backendModule.DirectoryId, AbortController>()
|
||||
@ -1064,7 +1072,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
override?: boolean
|
||||
) => {
|
||||
const directory = nodeMapRef.current.get(key)
|
||||
const isExpanded = directory?.children != null
|
||||
const isExpanded = directory?.children != null && directory.isExpanded
|
||||
const shouldExpand = override ?? !isExpanded
|
||||
if (shouldExpand === isExpanded) {
|
||||
// This is fine, as this is near the top of a very long function.
|
||||
@ -1078,23 +1086,26 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
directoryListAbortControllersRef.current.delete(directoryId)
|
||||
}
|
||||
setAssetTree(oldAssetTree =>
|
||||
oldAssetTree.map(item => (item.key !== key ? item : item.with({ children: null })))
|
||||
oldAssetTree.map(item => (item.key !== key ? item : item.with({ isExpanded: false })))
|
||||
)
|
||||
} else {
|
||||
setAssetTree(oldAssetTree =>
|
||||
oldAssetTree.map(item =>
|
||||
item.key !== key
|
||||
? item
|
||||
: item.with({
|
||||
children: [
|
||||
AssetTreeNode.fromAsset(
|
||||
backendModule.createSpecialLoadingAsset(directoryId),
|
||||
key,
|
||||
directoryId,
|
||||
item.depth + 1
|
||||
),
|
||||
],
|
||||
})
|
||||
: item.children != null
|
||||
? item.with({ isExpanded: true })
|
||||
: item.with({
|
||||
isExpanded: true,
|
||||
children: [
|
||||
AssetTreeNode.fromAsset(
|
||||
backendModule.createSpecialLoadingAsset(directoryId),
|
||||
key,
|
||||
directoryId,
|
||||
item.depth + 1
|
||||
),
|
||||
],
|
||||
})
|
||||
)
|
||||
)
|
||||
void (async () => {
|
||||
@ -1818,7 +1829,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const doCopy = React.useCallback(() => {
|
||||
unsetModal()
|
||||
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
|
||||
}, [/* should never change */ unsetModal])
|
||||
}, [unsetModal])
|
||||
|
||||
const doCut = React.useCallback(() => {
|
||||
unsetModal()
|
||||
@ -1828,12 +1839,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setPasteData({ type: PasteType.move, data: selectedKeysRef.current })
|
||||
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeysRef.current })
|
||||
setSelectedKeys(new Set())
|
||||
}, [
|
||||
pasteData,
|
||||
setSelectedKeys,
|
||||
/* should never change */ unsetModal,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
])
|
||||
}, [pasteData, setSelectedKeys, unsetModal, dispatchAssetEvent])
|
||||
|
||||
const doPaste = React.useCallback(
|
||||
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
|
||||
@ -1865,13 +1871,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
pasteData,
|
||||
doToggleDirectoryExpansion,
|
||||
/* should never change */ unsetModal,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
]
|
||||
[pasteData, doToggleDirectoryExpansion, unsetModal, dispatchAssetEvent, dispatchAssetListEvent]
|
||||
)
|
||||
|
||||
const hideColumn = React.useCallback((column: columnUtils.Column) => {
|
||||
@ -1906,9 +1906,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
/* should never change */ clearSelectedKeys,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
clearSelectedKeys,
|
||||
dispatchAssetEvent,
|
||||
dispatchAssetListEvent,
|
||||
]
|
||||
)
|
||||
|
||||
@ -1918,12 +1918,17 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (filtered != null && filtered.length > 0) {
|
||||
event.preventDefault()
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
setIsDropzoneVisible(true)
|
||||
setDroppedFilesCount(event.dataTransfer.items.length)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const updateIsDraggingFiles = (event: React.DragEvent<Element>) => {
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingFiles(true)
|
||||
setDroppedFilesCount(event.dataTransfer.items.length)
|
||||
}
|
||||
}
|
||||
|
||||
const state = React.useMemo<AssetsTableState>(
|
||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||
() => ({
|
||||
@ -1969,19 +1974,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
/* should never change */ hideColumn,
|
||||
/* should never change */ setAssetPanelProps,
|
||||
/* should never change */ setIsAssetPanelTemporarilyVisible,
|
||||
/* should never change */ setProjectStartupInfo,
|
||||
/* should never change */ setQuery,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
hideColumn,
|
||||
setAssetPanelProps,
|
||||
setIsAssetPanelTemporarilyVisible,
|
||||
setQuery,
|
||||
setProjectStartupInfo,
|
||||
dispatchAssetEvent,
|
||||
dispatchAssetListEvent,
|
||||
]
|
||||
)
|
||||
|
||||
// This is required to prevent the table body from overlapping the table header, because
|
||||
// the table header is transparent.
|
||||
const onScroll = scrollHooks.useOnScroll(() => {
|
||||
const updateClipPath = useOnScroll(() => {
|
||||
if (bodyRef.current != null && rootRef.current != null) {
|
||||
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
||||
}
|
||||
@ -1996,7 +2001,12 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const rightOffset = rootRef.current.clientWidth + rootRef.current.scrollLeft - shrinkBy
|
||||
headerRowRef.current.style.clipPath = `polygon(0 0, ${rightOffset}px 0, ${rightOffset}px 100%, 0 100%)`
|
||||
}
|
||||
}, [enabledColumns.size])
|
||||
}, [backend.type, enabledColumns.size])
|
||||
|
||||
const updateClipPathObserver = React.useMemo(
|
||||
() => new ResizeObserver(updateClipPath),
|
||||
[updateClipPath]
|
||||
)
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
@ -2015,11 +2025,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
},
|
||||
false
|
||||
),
|
||||
[
|
||||
setSelectedKeys,
|
||||
/* should never change */ inputBindings,
|
||||
/* should never change */ setMostRecentlySelectedIndex,
|
||||
]
|
||||
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -2076,7 +2082,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})(event, false)
|
||||
return result
|
||||
},
|
||||
[/* should never change */ inputBindings]
|
||||
[inputBindings]
|
||||
)
|
||||
|
||||
// Only non-`null` when it is different to`selectedKeys`.
|
||||
@ -2167,7 +2173,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setVisuallySelectedKeysOverride(null)
|
||||
dragSelectionRangeRef.current = null
|
||||
},
|
||||
[displayItems, calculateNewKeys, /* should never change */ setSelectedKeys]
|
||||
[displayItems, calculateNewKeys, setSelectedKeys]
|
||||
)
|
||||
|
||||
const onSelectionDragCancel = React.useCallback(() => {
|
||||
@ -2197,12 +2203,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
selectionStartIndexRef.current = null
|
||||
}
|
||||
},
|
||||
[
|
||||
visibleItems,
|
||||
calculateNewKeys,
|
||||
/* should never change */ setSelectedKeys,
|
||||
/* should never change */ setMostRecentlySelectedIndex,
|
||||
]
|
||||
[visibleItems, calculateNewKeys, setSelectedKeys, setMostRecentlySelectedIndex]
|
||||
)
|
||||
|
||||
const columns = columnUtils.getColumnList(backend.type, enabledColumns)
|
||||
@ -2290,7 +2291,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
<DragModal
|
||||
event={event}
|
||||
className="flex flex-col rounded-default bg-selected-frame backdrop-blur-default"
|
||||
doCleanup={() => {
|
||||
onDragEnd={() => {
|
||||
drag.ASSET_ROWS.unbind(payload)
|
||||
}}
|
||||
>
|
||||
@ -2319,41 +2320,38 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const ids = new Set(
|
||||
selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key]
|
||||
)
|
||||
// Expand ids to include ids of children as well.
|
||||
for (const node of assetTree.preorderTraversal()) {
|
||||
if (ids.has(node.key) && node.children != null) {
|
||||
for (const child of node.children) {
|
||||
ids.add(child.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
const idsReference = selectedKeysRef.current.has(key) ? selectedKeysRef.current : key
|
||||
// This optimization is required in order to avoid severe lag on Firefox.
|
||||
if (idsReference !== lastSelectedIdsRef.current) {
|
||||
lastSelectedIdsRef.current = idsReference
|
||||
const ids =
|
||||
typeof idsReference === 'string' ? new Set([idsReference]) : idsReference
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
|
||||
window.setTimeout(() => {
|
||||
dispatchAssetEvent({
|
||||
type: shouldAdd
|
||||
? AssetEventType.temporarilyAddLabels
|
||||
: AssetEventType.temporarilyRemoveLabels,
|
||||
ids,
|
||||
labelNames: payload,
|
||||
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
|
||||
window.setTimeout(() => {
|
||||
dispatchAssetEvent({
|
||||
type: shouldAdd
|
||||
? AssetEventType.temporarilyAddLabels
|
||||
: AssetEventType.temporarilyRemoveLabels,
|
||||
ids,
|
||||
labelNames: payload,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
lastSelectedIdsRef.current = null
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids: selectedKeysRef.current,
|
||||
@ -2362,14 +2360,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}}
|
||||
onDrop={event => {
|
||||
const ids = new Set(selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key])
|
||||
// Expand ids to include ids of descendants as well.
|
||||
for (const node of assetTree.preorderTraversal()) {
|
||||
if (ids.has(node.key) && node.children != null) {
|
||||
for (const child of node.children) {
|
||||
ids.add(child.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
@ -2404,7 +2394,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
)
|
||||
|
||||
const dropzoneText = isDropzoneVisible
|
||||
const dropzoneText = isDraggingFiles
|
||||
? droppedFilesCount === 1
|
||||
? getText('assetsDropFileDescription')
|
||||
: getText('assetsDropFilesDescription', droppedFilesCount)
|
||||
@ -2434,7 +2424,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
/>
|
||||
)
|
||||
}}
|
||||
onDragEnter={onDropzoneDragOver}
|
||||
onDragLeave={event => {
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (
|
||||
@ -2442,6 +2431,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
lastSelectedIdsRef.current = null
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.temporarilyAddLabels,
|
||||
ids: selectedKeysRef.current,
|
||||
@ -2480,12 +2470,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
data-testid="root-directory-dropzone"
|
||||
className={tailwindMerge.twMerge(
|
||||
'sticky left-0 grid max-w-container grow place-items-center',
|
||||
category !== Category.cloud && category !== Category.local && 'hidden'
|
||||
)}
|
||||
onDragEnter={onDropzoneDragOver}
|
||||
onDragOver={onDropzoneDragOver}
|
||||
onDragLeave={event => {
|
||||
lastSelectedIdsRef.current = null
|
||||
if (event.currentTarget === event.target) {
|
||||
setIsDraggingFiles(false)
|
||||
}
|
||||
}}
|
||||
onDrop={event => {
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
const filtered = payload?.filter(item => item.asset.parentId !== rootDirectoryId)
|
||||
@ -2517,6 +2514,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
>
|
||||
<FocusRing>
|
||||
<aria.Button
|
||||
ref={mainDropzoneRef}
|
||||
className="my-20 flex flex-col items-center gap-3 text-primary/30 transition-colors duration-200 hover:text-primary/50"
|
||||
onPress={() => {}}
|
||||
>
|
||||
@ -2535,10 +2533,21 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
{innerProps => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
|
||||
ref: rootRef,
|
||||
ref: value => {
|
||||
rootRef.current = value
|
||||
cleanupRootRef.current()
|
||||
if (value) {
|
||||
updateClipPathObserver.observe(value)
|
||||
cleanupRootRef.current = () => {
|
||||
updateClipPathObserver.unobserve(value)
|
||||
}
|
||||
} else {
|
||||
cleanupRootRef.current = () => {}
|
||||
}
|
||||
},
|
||||
className: 'flex-1 overflow-auto container-size w-full h-full',
|
||||
onKeyDown,
|
||||
onScroll,
|
||||
onScroll: updateClipPath,
|
||||
onBlur: event => {
|
||||
if (
|
||||
event.relatedTarget instanceof HTMLElement &&
|
||||
@ -2547,12 +2556,24 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setKeyboardSelectedIndex(null)
|
||||
}
|
||||
},
|
||||
onDragEnter: updateIsDraggingFiles,
|
||||
onDragOver: updateIsDraggingFiles,
|
||||
onDragLeave: event => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
lastSelectedIdsRef.current = null
|
||||
setIsDraggingFiles(false)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
{!hidden && hiddenContextMenu}
|
||||
{!hidden && (
|
||||
<SelectionBrush
|
||||
targetRef={rootRef}
|
||||
margin={8}
|
||||
onDrag={onSelectionDrag}
|
||||
onDragEnd={onSelectionDragEnd}
|
||||
onDragCancel={onSelectionDragCancel}
|
||||
@ -2605,38 +2626,31 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div
|
||||
data-testid="root-directory-dropzone"
|
||||
onDragEnter={onDropzoneDragOver}
|
||||
onDragOver={onDropzoneDragOver}
|
||||
onDragLeave={event => {
|
||||
if (event.currentTarget === event.target) {
|
||||
setIsDropzoneVisible(false)
|
||||
}
|
||||
}}
|
||||
onDrop={event => {
|
||||
setIsDropzoneVisible(false)
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none sticky left-0 top-0 flex h-full w-full flex-col items-center justify-center gap-3 rounded-default bg-selected-frame text-primary/50 opacity-0 backdrop-blur-3xl transition-all',
|
||||
isDropzoneVisible && 'pointer-events-auto opacity-100'
|
||||
)}
|
||||
>
|
||||
<SvgMask src={DropFilesImage} className="size-[186px]" />
|
||||
{dropzoneText}
|
||||
{isDraggingFiles && !isMainDropzoneVisible && (
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 rounded-default bg-selected-frame px-8 py-6 text-primary/50 backdrop-blur-3xl transition-all"
|
||||
onDragEnter={onDropzoneDragOver}
|
||||
onDragOver={onDropzoneDragOver}
|
||||
onDrop={event => {
|
||||
setIsDraggingFiles(false)
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={DropFilesImage} className="size-8" />
|
||||
{dropzoneText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -209,7 +209,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.set('driveCategory', category)
|
||||
}, [category, /* should never change */ localStorage])
|
||||
}, [category, localStorage])
|
||||
|
||||
return (
|
||||
<FocusArea direction="vertical">
|
||||
|
@ -465,7 +465,7 @@ export default function Chat(props: ChatProps) {
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [isOpen, /* should never change */ endpoint])
|
||||
}, [isOpen, endpoint])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = messagesRef.current
|
||||
@ -630,7 +630,7 @@ export default function Chat(props: ChatProps) {
|
||||
})
|
||||
}
|
||||
},
|
||||
[threads, toastAndLog, /* should never change */ sendMessage]
|
||||
[threads, toastAndLog, sendMessage]
|
||||
)
|
||||
|
||||
const sendCurrentMessage = React.useCallback(
|
||||
@ -680,14 +680,7 @@ export default function Chat(props: ChatProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
threads,
|
||||
threadId,
|
||||
threadTitle,
|
||||
shouldIgnoreMessageLimit,
|
||||
getText,
|
||||
/* should never change */ sendMessage,
|
||||
]
|
||||
[threads, threadId, threadTitle, shouldIgnoreMessageLimit, getText, sendMessage]
|
||||
)
|
||||
|
||||
const upgradeToPro = () => {
|
||||
|
@ -161,18 +161,12 @@ export default function Drive(props: DriveProps) {
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
isCloud,
|
||||
rootDirectoryId,
|
||||
sessionType,
|
||||
toastAndLog,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
]
|
||||
[isCloud, rootDirectoryId, sessionType, toastAndLog, dispatchAssetListEvent]
|
||||
)
|
||||
|
||||
const doEmptyTrash = React.useCallback(() => {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
|
||||
}, [/* should never change */ dispatchAssetListEvent])
|
||||
}, [dispatchAssetListEvent])
|
||||
|
||||
const doCreateProject = React.useCallback(
|
||||
(templateId: string | null = null, templateName: string | null = null) => {
|
||||
@ -185,7 +179,7 @@ export default function Drive(props: DriveProps) {
|
||||
preferredName: templateName,
|
||||
})
|
||||
},
|
||||
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
|
||||
[rootDirectoryId, dispatchAssetListEvent]
|
||||
)
|
||||
|
||||
const doCreateDirectory = React.useCallback(() => {
|
||||
@ -194,7 +188,7 @@ export default function Drive(props: DriveProps) {
|
||||
parentKey: targetDirectoryNodeRef.current?.key ?? rootDirectoryId,
|
||||
parentId: targetDirectoryNodeRef.current?.item.id ?? rootDirectoryId,
|
||||
})
|
||||
}, [rootDirectoryId, /* should never change */ dispatchAssetListEvent])
|
||||
}, [rootDirectoryId, dispatchAssetListEvent])
|
||||
|
||||
const doCreateSecret = React.useCallback(
|
||||
(name: string, value: string) => {
|
||||
@ -206,7 +200,7 @@ export default function Drive(props: DriveProps) {
|
||||
value,
|
||||
})
|
||||
},
|
||||
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
|
||||
[rootDirectoryId, dispatchAssetListEvent]
|
||||
)
|
||||
|
||||
const doCreateDatalink = React.useCallback(
|
||||
@ -219,7 +213,7 @@ export default function Drive(props: DriveProps) {
|
||||
value,
|
||||
})
|
||||
},
|
||||
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
|
||||
[rootDirectoryId, dispatchAssetListEvent]
|
||||
)
|
||||
|
||||
switch (status) {
|
||||
|
@ -88,7 +88,7 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
uploadFilesRef.current?.click()
|
||||
},
|
||||
})
|
||||
}, [isCloud, doCreateDirectory, doCreateProject, /* should never change */ inputBindings])
|
||||
}, [isCloud, doCreateDirectory, doCreateProject, inputBindings])
|
||||
|
||||
const searchBar = (
|
||||
<AssetSearchBar
|
||||
|
@ -95,7 +95,7 @@ export default function Labels(props: LabelsProps) {
|
||||
setModal(
|
||||
<DragModal
|
||||
event={event}
|
||||
doCleanup={() => {
|
||||
onDragEnd={() => {
|
||||
drag.LABELS.unbind(payload)
|
||||
}}
|
||||
>
|
||||
|
@ -79,10 +79,10 @@ export default function PageSwitcher(props: PageSwitcherProps) {
|
||||
return (element: HTMLDivElement | null) => {
|
||||
const backgroundElement = backgroundRef.current
|
||||
if (backgroundElement != null) {
|
||||
selectedTabRef.current = element
|
||||
if (element == null) {
|
||||
backgroundElement.style.clipPath = ''
|
||||
} else {
|
||||
selectedTabRef.current = element
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const rootBounds = backgroundElement.getBoundingClientRect()
|
||||
const tabLeft = bounds.left - rootBounds.left
|
||||
@ -120,6 +120,12 @@ export default function PageSwitcher(props: PageSwitcherProps) {
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visiblePageData.every(pageData => page !== pageData.page)) {
|
||||
updateClipPath(null)
|
||||
}
|
||||
}, [page, updateClipPath, visiblePageData])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow">
|
||||
<div
|
||||
|
@ -18,6 +18,7 @@ import UserGroupsSettingsTab from '#/layouts/Settings/UserGroupsSettingsTab'
|
||||
import SettingsSidebar from '#/layouts/SettingsSidebar'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as loader from '#/components/Loader'
|
||||
import * as portal from '#/components/Portal'
|
||||
@ -95,7 +96,7 @@ export default function Settings(props: SettingsProps) {
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
|
||||
<aria.Heading level={1} className="flex h-heading px-heading-x text-xl font-bold">
|
||||
<aria.Heading level={1} className="flex items-center px-heading-x">
|
||||
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
|
||||
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
|
||||
<aria.Popover UNSTABLE_portalContainer={root}>
|
||||
@ -111,27 +112,37 @@ export default function Settings(props: SettingsProps) {
|
||||
/>
|
||||
</aria.Popover>
|
||||
</aria.MenuTrigger>
|
||||
<aria.Text className="py-heading-y">{getText('settingsFor')}</aria.Text>
|
||||
{/* This UI element does not appear anywhere else. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">
|
||||
<ariaComponents.Text.Heading>
|
||||
<span>{getText('settingsFor')}</span>
|
||||
</ariaComponents.Text.Heading>
|
||||
|
||||
<ariaComponents.Text
|
||||
variant="h1"
|
||||
truncate="1"
|
||||
className="ml-2.5 max-w-lg rounded-full bg-frame px-2.5"
|
||||
aria-hidden
|
||||
>
|
||||
{settingsTab !== SettingsTab.organization &&
|
||||
settingsTab !== SettingsTab.members &&
|
||||
settingsTab !== SettingsTab.userGroups
|
||||
? user?.name ?? 'your account'
|
||||
: organization?.name ?? 'your organization'}
|
||||
</div>
|
||||
</ariaComponents.Text>
|
||||
</aria.Heading>
|
||||
<div className="flex flex-1 gap-settings overflow-hidden">
|
||||
<SettingsSidebar
|
||||
hasBackend={backend != null}
|
||||
isUserInOrganization={isUserInOrganization}
|
||||
settingsTab={settingsTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
/>
|
||||
<div className="mt-8 flex flex-1 gap-6 overflow-hidden pr-0.5">
|
||||
<aside className="flex h-full flex-col overflow-y-auto overflow-x-hidden pb-12">
|
||||
<SettingsSidebar
|
||||
hasBackend={backend != null}
|
||||
isUserInOrganization={isUserInOrganization}
|
||||
settingsTab={settingsTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
/>
|
||||
</aside>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<React.Suspense fallback={<loader.Loader size="medium" minHeight="h64" />}>
|
||||
{content}
|
||||
<main className="h-full w-full flex-shrink-0 flex-grow basis-0 overflow-y-auto overflow-x-hidden pb-12 pl-1.5 pr-3">
|
||||
<div className="w-full max-w-[840px]">{content}</div>
|
||||
</main>
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</div>
|
||||
|
@ -7,7 +7,6 @@ import * as inputBindingsManager from '#/providers/InputBindingsProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
|
||||
@ -34,7 +33,6 @@ export default function KeyboardShortcutsSettingsTabBar(
|
||||
return (
|
||||
<HorizontalMenuBar>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="bar"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
@ -52,9 +50,7 @@ export default function KeyboardShortcutsSettingsTabBar(
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||
{getText('resetAll')}
|
||||
</aria.Text>
|
||||
{getText('resetAll')}
|
||||
</ariaComponents.Button>
|
||||
</HorizontalMenuBar>
|
||||
)
|
||||
|
@ -4,7 +4,7 @@ import * as React from 'react'
|
||||
import BlankIcon from 'enso-assets/blank.svg'
|
||||
import CrossIcon from 'enso-assets/cross.svg'
|
||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||
import ReloadInCircleIcon from 'enso-assets/reload_in_circle.svg'
|
||||
import ReloadIcon from 'enso-assets/reload.svg'
|
||||
|
||||
import type * as refreshHooks from '#/hooks/refreshHooks'
|
||||
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||
@ -99,7 +99,7 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
|
||||
{/* I don't know why this padding is needed,
|
||||
* given that this is a flex container. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<div className="flex gap-buttons pr-4">
|
||||
<div className="flex items-center gap-buttons pr-4">
|
||||
{info.bindings.map((binding, j) => (
|
||||
<div
|
||||
key={j}
|
||||
@ -107,23 +107,27 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
|
||||
>
|
||||
<KeyboardShortcut shortcut={binding} />
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="flex rounded-full transition-colors hover:bg-hover-bg focus:bg-hover-bg"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={getText('removeShortcut')}
|
||||
tooltipPlacement="top left"
|
||||
icon={CrossIcon}
|
||||
showIconOnHover
|
||||
onPress={() => {
|
||||
inputBindings.delete(action, binding)
|
||||
doRefresh()
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="gap-keyboard-shortcuts-buttons flex shrink-0">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="focus-default my-auto flex rounded-full"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={getText('addShortcut')}
|
||||
tooltipPlacement="top left"
|
||||
icon={Plus2Icon}
|
||||
showIconOnHover
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<CaptureKeyboardShortcutModal
|
||||
@ -136,20 +140,19 @@ export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProp
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={Plus2Icon} />
|
||||
</ariaComponents.Button>
|
||||
/>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="my-auto flex rounded-full"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={getText('resetShortcut')}
|
||||
tooltipPlacement="top left"
|
||||
icon={ReloadIcon}
|
||||
showIconOnHover
|
||||
onPress={() => {
|
||||
inputBindings.reset(action)
|
||||
doRefresh()
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={ReloadInCircleIcon} />
|
||||
</ariaComponents.Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -63,7 +63,7 @@ export default function MembersTable(props: MembersTableProps) {
|
||||
const { onScroll, shadowClassName } = scrollHooks.useStickyTableHeaderOnScroll(
|
||||
scrollContainerRef,
|
||||
bodyRef,
|
||||
true
|
||||
{ trackShadowClass: true }
|
||||
)
|
||||
|
||||
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||
|
@ -61,7 +61,7 @@ export default function UserAccountSettingsSection(props: UserAccountSettingsSec
|
||||
<aria.Label className="text my-auto w-user-account-settings-label">
|
||||
{getText('name')}
|
||||
</aria.Label>
|
||||
<SettingsInput ref={nameRef} type="text" onSubmit={doUpdateName} />
|
||||
<SettingsInput key={user?.name ?? ''} ref={nameRef} type="text" onSubmit={doUpdateName} />
|
||||
</aria.TextField>
|
||||
<div className="flex h-row gap-settings-entry">
|
||||
<aria.Text className="text my-auto w-user-account-settings-label">
|
||||
|
@ -20,7 +20,6 @@ import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
|
||||
import NewUserGroupModal from '#/modals/NewUserGroupModal'
|
||||
@ -67,7 +66,7 @@ function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
|
||||
const shouldDisplayPaywall = isUnderPaywall ? userGroupsLeft <= 0 : false
|
||||
|
||||
const { onScroll: onUserGroupsTableScroll, shadowClassName } =
|
||||
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, true)
|
||||
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
|
||||
|
||||
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||
getDropOperation: (target, types, allowedOperations) =>
|
||||
@ -137,43 +136,40 @@ function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
|
||||
<div className="flex h min-h-full flex-1 flex-col gap-settings-section overflow-hidden lg:h-auto lg:flex-row">
|
||||
<div className="flex h-3/5 w-settings-main-section max-w-full flex-col gap-settings-subsection lg:h-[unset] lg:min-w">
|
||||
<SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
|
||||
<HorizontalMenuBar>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldDisplayPaywall && (
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature="userGroupsFull"
|
||||
variant="bar"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
iconPosition="end"
|
||||
tooltip={getText('userGroupsPaywallMessage')}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</paywallComponents.PaywallDialogButton>
|
||||
)}
|
||||
{!shouldDisplayPaywall && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={event => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||
}}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldDisplayPaywall ? (
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature="userGroupsFull"
|
||||
variant="bar"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
iconPosition="end"
|
||||
tooltip={getText('userGroupsPaywallMessage')}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</paywallComponents.PaywallDialogButton>
|
||||
) : (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={event => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||
}}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<span className="text-xs">
|
||||
{userGroupsLeft <= 0
|
||||
? getText('userGroupsPaywallMessage')
|
||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalMenuBar>
|
||||
{isUnderPaywall && (
|
||||
<span className="text-xs">
|
||||
{userGroupsLeft <= 0
|
||||
? getText('userGroupsPaywallMessage')
|
||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={tailwindMerge.twMerge(
|
||||
|
@ -8,14 +8,14 @@
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import * as twv from 'tailwind-variants'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
|
||||
import * as twv from '#/utilities/tailwindVariants'
|
||||
|
||||
/**
|
||||
* Props for the `withPaywall` HOC.
|
||||
*/
|
||||
|
@ -23,7 +23,7 @@ export interface DragModalProps
|
||||
extends Readonly<React.PropsWithChildren>,
|
||||
Readonly<JSX.IntrinsicElements['div']> {
|
||||
readonly event: React.DragEvent
|
||||
readonly doCleanup: () => void
|
||||
readonly onDragEnd: () => void
|
||||
readonly offsetPx?: number
|
||||
readonly offsetXPx?: number
|
||||
readonly offsetYPx?: number
|
||||
@ -39,22 +39,24 @@ export default function DragModal(props: DragModalProps) {
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
doCleanup,
|
||||
onDragEnd: onDragEndRaw,
|
||||
...passthrough
|
||||
} = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const [left, setLeft] = React.useState(event.pageX - (offsetPx ?? offsetXPx))
|
||||
const [top, setTop] = React.useState(event.pageY - (offsetPx ?? offsetYPx))
|
||||
const onDragEndRef = React.useRef(onDragEndRaw)
|
||||
onDragEndRef.current = onDragEndRaw
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDrag = (moveEvent: MouseEvent) => {
|
||||
if (moveEvent.pageX !== 0 || moveEvent.pageY !== 0) {
|
||||
setLeft(moveEvent.pageX - (offsetPx ?? offsetXPx))
|
||||
setTop(moveEvent.pageY - (offsetPx ?? offsetYPx))
|
||||
const onDrag = (dragEvent: MouseEvent) => {
|
||||
if (dragEvent.pageX !== 0 || dragEvent.pageY !== 0) {
|
||||
setLeft(dragEvent.pageX - (offsetPx ?? offsetXPx))
|
||||
setTop(dragEvent.pageY - (offsetPx ?? offsetYPx))
|
||||
}
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
doCleanup()
|
||||
onDragEndRef.current()
|
||||
unsetModal()
|
||||
}
|
||||
// Update position (non-FF)
|
||||
@ -67,14 +69,7 @@ export default function DragModal(props: DragModalProps) {
|
||||
document.removeEventListener('dragover', onDrag, { capture: true })
|
||||
document.removeEventListener('dragend', onDragEnd, { capture: true })
|
||||
}
|
||||
// `doCleanup` is a callback, not a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
/* should never change */ offsetPx,
|
||||
/* should never change */ offsetXPx,
|
||||
/* should never change */ offsetYPx,
|
||||
/* should never change */ unsetModal,
|
||||
])
|
||||
}, [offsetPx, offsetXPx, offsetYPx, unsetModal])
|
||||
|
||||
return (
|
||||
<Modal className="pointer-events-none absolute size-full overflow-hidden">
|
||||
|
@ -54,12 +54,12 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
{
|
||||
queryKey: ['listInvitations'],
|
||||
queryFn: async () => backend.listInvitations(),
|
||||
select: (invitations: backendModule.Invitation[]) => invitations.length,
|
||||
select: (invitations: readonly backendModule.Invitation[]) => invitations.length,
|
||||
},
|
||||
{
|
||||
queryKey: ['listUsers'],
|
||||
queryFn: async () => backend.listUsers(),
|
||||
select: (users: backendModule.User[]) => users.length,
|
||||
select: (users: readonly backendModule.User[]) => users.length,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -90,7 +90,7 @@ export default function ManageLabelsModal<
|
||||
} as Partial<Asset>)
|
||||
)
|
||||
},
|
||||
[/* should never change */ setItem]
|
||||
[setItem]
|
||||
)
|
||||
|
||||
const doToggleLabel = async (name: backendModule.LabelName) => {
|
||||
|
@ -129,7 +129,7 @@ export default function ManagePermissionsModal<
|
||||
// This is SAFE, as the type of asset is not being changed.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
setItem(object.merger({ permissions } as Partial<Asset>))
|
||||
}, [permissions, /* should never change */ setItem])
|
||||
}, [permissions, setItem])
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
// This should never happen - the local backend does not have the "shared with" column,
|
||||
|
@ -54,7 +54,7 @@ export default function ResetPassword() {
|
||||
toastAndLog('missingVerificationCodeError')
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
}
|
||||
}, [email, navigate, verificationCode, getText, /* should never change */ toastAndLog])
|
||||
}, [email, navigate, verificationCode, getText, toastAndLog])
|
||||
|
||||
const doSubmit = () => {
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
|
@ -125,6 +125,8 @@ export default function Dashboard(props: DashboardProps) {
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const initializedRef = React.useRef(initialized)
|
||||
initializedRef.current = initialized
|
||||
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
|
||||
|
||||
// These pages MUST be ROUTER PAGES.
|
||||
@ -245,22 +247,20 @@ export default function Dashboard(props: DashboardProps) {
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialized) {
|
||||
if (initializedRef.current) {
|
||||
if (projectStartupInfo != null) {
|
||||
localStorage.set('projectStartupInfo', projectStartupInfo)
|
||||
} else {
|
||||
localStorage.delete('projectStartupInfo')
|
||||
}
|
||||
}
|
||||
// `initialized` is NOT a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectStartupInfo, /* should never change */ localStorage])
|
||||
}, [projectStartupInfo, localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (page !== pageSwitcher.Page.settings) {
|
||||
localStorage.set('page', page)
|
||||
}
|
||||
}, [page, /* should never change */ localStorage])
|
||||
}, [page, localStorage])
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
@ -345,7 +345,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id })
|
||||
setProjectStartupInfo(null)
|
||||
}
|
||||
}, [projectStartupInfo?.projectAsset, /* should never change */ dispatchAssetListEvent])
|
||||
}, [projectStartupInfo?.projectAsset, dispatchAssetListEvent])
|
||||
|
||||
const onSignOut = React.useCallback(() => {
|
||||
if (page === pageSwitcher.Page.editor) {
|
||||
|
@ -171,7 +171,11 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
const navigate = router.useNavigate()
|
||||
const [forceOfflineMode, setForceOfflineMode] = React.useState(shouldStartInOfflineMode)
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const initializedRef = React.useRef(initialized)
|
||||
initializedRef.current = initialized
|
||||
const [userSession, setUserSession] = React.useState<UserSession | null>(null)
|
||||
const userSessionRef = React.useRef(userSession)
|
||||
userSessionRef.current = userSession
|
||||
const toastId = React.useId()
|
||||
|
||||
const setUser = React.useCallback((valueOrUpdater: React.SetStateAction<backendModule.User>) => {
|
||||
@ -205,7 +209,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
navigate(appUtils.DASHBOARD_PATH)
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
[goOfflineInternal, /* should never change */ navigate]
|
||||
[goOfflineInternal, navigate]
|
||||
)
|
||||
|
||||
// This component cannot use `useGtagEvent` because `useGtagEvent` depends on the React Context
|
||||
@ -236,7 +240,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
if (!navigator.onLine) {
|
||||
void goOffline()
|
||||
}
|
||||
}, [/* should never change */ goOffline])
|
||||
}, [goOffline])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authService == null) {
|
||||
@ -244,7 +248,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
goOfflineInternal()
|
||||
navigate(appUtils.DASHBOARD_PATH)
|
||||
}
|
||||
}, [authService, navigate, /* should never change */ goOfflineInternal])
|
||||
}, [authService, navigate, goOfflineInternal])
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
@ -253,7 +257,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
void goOffline()
|
||||
}
|
||||
}),
|
||||
[onSessionError, /* should never change */ goOffline]
|
||||
[onSessionError, goOffline]
|
||||
)
|
||||
|
||||
/** Fetch the JWT access token from the session via the AWS Amplify library.
|
||||
@ -268,7 +272,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
setForceOfflineMode(false)
|
||||
} else if (session == null) {
|
||||
setInitialized(true)
|
||||
if (!initialized) {
|
||||
if (!initializedRef.current) {
|
||||
sentry.setUser(null)
|
||||
setUserSession(null)
|
||||
}
|
||||
@ -277,7 +281,11 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
const backend = new RemoteBackend(client, logger, getText)
|
||||
// The backend MUST be the remote backend before login is finished.
|
||||
// This is because the "set username" flow requires the remote backend.
|
||||
if (!initialized || userSession == null || userSession.type === UserSessionType.offline) {
|
||||
if (
|
||||
!initializedRef.current ||
|
||||
userSessionRef.current == null ||
|
||||
userSessionRef.current.type === UserSessionType.offline
|
||||
) {
|
||||
setRemoteBackend(backend)
|
||||
}
|
||||
gtagEvent('cloud_open')
|
||||
@ -360,18 +368,17 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
logger.error(error)
|
||||
}
|
||||
})
|
||||
// `userSession` MUST NOT be a dependency as `setUserSession` is called every time
|
||||
// by this effect. Because it is an object literal, it will never be equal to the previous
|
||||
// value.
|
||||
// `initialized` MUST NOT be a dependency as it breaks offline mode.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
cognito,
|
||||
logger,
|
||||
onAuthenticated,
|
||||
session,
|
||||
/* should never change */ setRemoteBackend,
|
||||
/* should never change */ goOfflineInternal,
|
||||
goOfflineInternal,
|
||||
forceOfflineMode,
|
||||
getText,
|
||||
gtagEvent,
|
||||
setRemoteBackend,
|
||||
goOffline,
|
||||
])
|
||||
|
||||
/** Wrap a function returning a {@link Promise} to display a loading toast notification
|
||||
|
@ -95,6 +95,6 @@ export function useSetModal() {
|
||||
const updateModal: (updater: (modal: Modal | null) => Modal | null) => void = setModalRaw
|
||||
const unsetModal = React.useCallback(() => {
|
||||
setModalRaw(null)
|
||||
}, [/* should never change */ setModalRaw])
|
||||
}, [setModalRaw])
|
||||
return { setModal, updateModal, unsetModal } as const
|
||||
}
|
||||
|
@ -1260,7 +1260,7 @@ export default abstract class Backend {
|
||||
/** Return the ID of the root directory, if known. */
|
||||
abstract rootDirectoryId(user: User | null): DirectoryId | null
|
||||
/** Return a list of all users in the same organization. */
|
||||
abstract listUsers(): Promise<User[]>
|
||||
abstract listUsers(): Promise<readonly User[]>
|
||||
/** Set the username of the current user. */
|
||||
abstract createUser(body: CreateUserRequestBody): Promise<User>
|
||||
/** Change the username of the current user. */
|
||||
|
@ -118,6 +118,7 @@ interface RemoteBackendPostOptions {
|
||||
export default class RemoteBackend extends Backend {
|
||||
readonly type = backend.BackendType.remote
|
||||
private defaultVersions: Partial<Record<backend.VersionType, DefaultVersionInfo>> = {}
|
||||
private user: object.Mutable<backend.User> | null = null
|
||||
|
||||
/** Create a new instance of the {@link RemoteBackend} API client.
|
||||
* @throws An error if the `Authorization` header is not set on the given `client`. */
|
||||
@ -176,7 +177,7 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
|
||||
/** Return a list of all users in the same organization. */
|
||||
override async listUsers(): Promise<backend.User[]> {
|
||||
override async listUsers(): Promise<readonly backend.User[]> {
|
||||
const path = remoteBackendPaths.LIST_USERS_PATH
|
||||
const response = await this.get<ListUsersResponseBody>(path)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -206,6 +207,9 @@ export default class RemoteBackend extends Backend {
|
||||
? await this.throw(response, 'updateUsernameBackendError')
|
||||
: await this.throw(response, 'updateUserBackendError')
|
||||
} else {
|
||||
if (this.user != null && body.username != null) {
|
||||
this.user.name = body.username
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -389,7 +393,9 @@ export default class RemoteBackend extends Backend {
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return null
|
||||
} else {
|
||||
return await response.json()
|
||||
const user = await response.json()
|
||||
this.user = { ...user }
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
@ -440,6 +446,7 @@ export default class RemoteBackend extends Backend {
|
||||
permissions: [...(asset.permissions ?? [])].sort(backend.compareAssetPermissions),
|
||||
})
|
||||
)
|
||||
.map(asset => this.dynamicAssetUser(asset))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1082,7 +1089,7 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
|
||||
/** Get the default version given the type of version (IDE or backend). */
|
||||
protected async getDefaultVersion(versionType: backend.VersionType) {
|
||||
private async getDefaultVersion(versionType: backend.VersionType) {
|
||||
const cached = this.defaultVersions[versionType]
|
||||
const nowEpochMs = Number(new Date())
|
||||
if (cached != null && nowEpochMs - cached.lastUpdatedEpochMs < ONE_DAY_MS) {
|
||||
@ -1099,6 +1106,29 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the `user` of all permissions for the current user on an asset, so that they always
|
||||
* return the up-to-date user. */
|
||||
private dynamicAssetUser<Asset extends backend.AnyAsset>(asset: Asset) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self = this
|
||||
let foundSelfPermission = (() => false)()
|
||||
const permissions = asset.permissions?.map(permission => {
|
||||
if (!('user' in permission) || permission.user.userId !== this.user?.userId) {
|
||||
return permission
|
||||
} else {
|
||||
foundSelfPermission = true
|
||||
return {
|
||||
...permission,
|
||||
/** Return a dynamic reference to the current user. */
|
||||
get user() {
|
||||
return self.user
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
return !foundSelfPermission ? asset : { ...asset, permissions }
|
||||
}
|
||||
|
||||
/** Send an HTTP GET request to the given path. */
|
||||
private get<T = void>(path: string) {
|
||||
return this.client.get<T>(`${process.env.ENSO_CLOUD_API_URL}/${path}`)
|
||||
|
@ -421,14 +421,12 @@
|
||||
:where(body:not(.vibrancy)) {
|
||||
&::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0 -16vw -16vh 0;
|
||||
z-index: -1;
|
||||
|
||||
background: url("enso-assets/background.jpg");
|
||||
background-size: cover;
|
||||
|
||||
pointer-events: none;
|
||||
@apply fixed bg-cover pointer-events-none;
|
||||
}
|
||||
|
||||
& > * {
|
||||
@ -441,9 +439,7 @@
|
||||
}
|
||||
|
||||
:where(.enso-dashboard) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
@apply absolute inset-0 overflow-hidden;
|
||||
}
|
||||
|
||||
/* These styles MUST still be copied
|
||||
@ -454,9 +450,10 @@
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
font-family: "Enso Prose", "Enso", "M PLUS 1", "Roboto Light", sans-serif;
|
||||
font-weight: 500;
|
||||
font-feature-settings: normal;
|
||||
|
||||
@apply font-medium;
|
||||
|
||||
kbd {
|
||||
font-family: "Enso Prose", "Enso", "M PLUS 1", "Roboto Light", sans-serif;
|
||||
}
|
||||
|
@ -330,6 +330,9 @@
|
||||
"onDateX": "on $0",
|
||||
"xUsersAndGroupsSelected": "$0 users and groups selected",
|
||||
"allTrashedItemsForever": "all trashed items forever",
|
||||
"addShortcut": "Add shortcut",
|
||||
"removeShortcut": "Remove shortcut",
|
||||
"resetShortcut": "Reset shortcut",
|
||||
"resetAllKeyboardShortcuts": "reset all keyboard shortcuts",
|
||||
"mustNotBeBlank": "Must not be blank.",
|
||||
"rightClickToRemoveLabel": "Right click to remove label.",
|
||||
@ -372,8 +375,8 @@
|
||||
"latestIndicator": "(Latest)",
|
||||
"noDateSelected": "No date selected",
|
||||
"assetsDropzoneDescription": "Drag and drop files here, or click to upload.",
|
||||
"assetsDropFileDescription": "Drop to upload 1 file",
|
||||
"assetsDropFilesDescription": "Drop to upload $0 files",
|
||||
"assetsDropFileDescription": "Drop here to upload 1 file",
|
||||
"assetsDropFilesDescription": "Drop here to upload $0 files",
|
||||
"hidePassword": "Hide password",
|
||||
"showPassword": "Show password",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
|
@ -9,7 +9,14 @@ import * as backendModule from '#/services/Backend'
|
||||
export interface AssetTreeNodeData
|
||||
extends Pick<
|
||||
AssetTreeNode,
|
||||
'children' | 'depth' | 'directoryId' | 'directoryKey' | 'item' | 'key'
|
||||
| 'children'
|
||||
| 'createdAt'
|
||||
| 'depth'
|
||||
| 'directoryId'
|
||||
| 'directoryKey'
|
||||
| 'isExpanded'
|
||||
| 'item'
|
||||
| 'key'
|
||||
> {}
|
||||
|
||||
/** All possible variants of {@link AssetTreeNode}s. */
|
||||
@ -31,14 +38,16 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
public readonly directoryKey: backendModule.DirectoryId,
|
||||
/** The actual id of the asset's parent directory (or the placeholder id for new assets). */
|
||||
public readonly directoryId: backendModule.DirectoryId,
|
||||
/** This is `null` if the asset is not a directory asset, OR if it is a collapsed directory
|
||||
* asset. */
|
||||
/** This is `null` if the asset is not a directory asset, OR a directory asset whose contents
|
||||
* have not yet been fetched. */
|
||||
public readonly children: AnyAssetTreeNode[] | null,
|
||||
public readonly depth: number,
|
||||
/** The internal (to the frontend) id of the asset (or the placeholder id for new assets).
|
||||
* This must never change, otherwise the component's state is lost when receiving the real id
|
||||
* from the backend. */
|
||||
public readonly key: Item['id'] = item.id
|
||||
public readonly key: Item['id'] = item.id,
|
||||
public readonly isExpanded = false,
|
||||
public readonly createdAt = new Date()
|
||||
) {
|
||||
this.type = item.type
|
||||
}
|
||||
@ -92,7 +101,9 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
// eslint-disable-next-line eqeqeq
|
||||
update.children === null ? update.children : update.children ?? this.children,
|
||||
update.depth ?? this.depth,
|
||||
update.key ?? this.key
|
||||
update.key ?? this.key,
|
||||
update.isExpanded ?? this.isExpanded,
|
||||
update.createdAt ?? this.createdAt
|
||||
).asUnion()
|
||||
}
|
||||
|
||||
@ -160,8 +171,24 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
preorderTraversal(
|
||||
preprocess: ((tree: AnyAssetTreeNode[]) => AnyAssetTreeNode[]) | null = null
|
||||
): AnyAssetTreeNode[] {
|
||||
return (preprocess?.(this.children ?? []) ?? this.children ?? []).flatMap(node =>
|
||||
const children = !this.isExpanded ? [] : this.children ?? []
|
||||
return (preprocess?.(children) ?? children).flatMap(node =>
|
||||
node.children == null ? [node] : [node, ...node.preorderTraversal(preprocess)]
|
||||
)
|
||||
}
|
||||
|
||||
/** Check whether a pending rename is valid. */
|
||||
isNewTitleValid(newTitle: string, siblings?: readonly AssetTreeNode[] | null) {
|
||||
siblings ??= []
|
||||
return (
|
||||
newTitle !== '' &&
|
||||
newTitle !== this.item.title &&
|
||||
siblings.every(sibling => {
|
||||
const isSelf = sibling.key === this.key
|
||||
const hasSameType = sibling.item.type === this.item.type
|
||||
const hasSameTitle = sibling.item.title === newTitle
|
||||
return !(!isSelf && hasSameType && hasSameTitle)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
63
app/ide-desktop/lib/dashboard/src/utilities/Debug.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
/** @file Utilities related to debugging. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as debugHooks from '#/hooks/debugHooks'
|
||||
|
||||
/* eslint-disable no-restricted-properties */
|
||||
|
||||
// =============
|
||||
// === Debug ===
|
||||
// =============
|
||||
|
||||
/** Props for a {@Link Debug}. */
|
||||
interface DebugProps {
|
||||
readonly name?: string
|
||||
readonly monitorProps?: boolean
|
||||
readonly monitorRender?: boolean
|
||||
readonly children: JSX.Element
|
||||
}
|
||||
|
||||
/** A component that adds debugging info to its direct child. */
|
||||
export default function Debug(props: DebugProps) {
|
||||
const { name, monitorProps = false, monitorRender = false, children } = props
|
||||
const childPropsRaw: unknown = children.props
|
||||
const childProps: object = typeof childPropsRaw === 'object' ? childPropsRaw ?? {} : {}
|
||||
const propsValues: unknown[] = Object.values(childProps)
|
||||
const typeRaw: unknown = children.type
|
||||
const typeName = name ?? (typeof typeRaw === 'function' ? typeRaw.name : String(children.type))
|
||||
debugHooks.useMonitorDependencies(
|
||||
[children.key, ...propsValues],
|
||||
typeName,
|
||||
['key', ...Object.keys(childProps)],
|
||||
monitorProps
|
||||
)
|
||||
|
||||
const patchedChildProps = Object.fromEntries(
|
||||
Object.entries(childProps).map(([key, value]: [string, unknown]) => [
|
||||
key,
|
||||
typeof value !== 'function'
|
||||
? value
|
||||
: (...args: unknown[]) => {
|
||||
console.group(`[Debug(${typeName})] Prop '${key}' called with args: [`, ...args, ']')
|
||||
const result: unknown = value(...args)
|
||||
console.log('Returned', result)
|
||||
console.groupEnd()
|
||||
return result
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const Component = children.type
|
||||
if (monitorRender) {
|
||||
console.group(`[Debug(${typeName})] Rendering`, Component, childProps)
|
||||
}
|
||||
const element = <Component {...patchedChildProps} />
|
||||
React.useEffect(() => {
|
||||
if (monitorRender) {
|
||||
console.log(`[Debug(${typeName})] Finished rendering`)
|
||||
console.groupEnd()
|
||||
}
|
||||
})
|
||||
return element
|
||||
}
|
@ -9,33 +9,62 @@ import * as validation from '#/utilities/validation'
|
||||
|
||||
/** Runs all tests. */
|
||||
v.test('password validation', () => {
|
||||
const pattern = new RegExp(`^(?:${validation.PASSWORD_PATTERN})$`)
|
||||
const regex = validation.PASSWORD_REGEX
|
||||
const emptyPassword = ''
|
||||
v.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(pattern)
|
||||
v.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(regex)
|
||||
const shortPassword = 'Aa0!'
|
||||
v.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(pattern)
|
||||
v.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(regex)
|
||||
const passwordMissingDigit = 'Aa!Aa!Aa!'
|
||||
v.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch(
|
||||
pattern
|
||||
)
|
||||
v.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch(regex)
|
||||
const passwordMissingLowercase = 'A0!A0!A0!'
|
||||
v.expect(
|
||||
passwordMissingLowercase,
|
||||
`'${passwordMissingLowercase}' is missing a lowercase letter`
|
||||
).not.toMatch(pattern)
|
||||
).not.toMatch(regex)
|
||||
const passwordMissingUppercase = 'a0!a0!a0!'
|
||||
v.expect(
|
||||
passwordMissingUppercase,
|
||||
`'${passwordMissingUppercase}' is missing an uppercase letter`
|
||||
).not.toMatch(pattern)
|
||||
).not.toMatch(regex)
|
||||
const passwordMissingSymbol = 'Aa0Aa0Aa0'
|
||||
v.expect(passwordMissingSymbol, `'${passwordMissingSymbol}' is missing a symbol`).not.toMatch(
|
||||
pattern
|
||||
regex
|
||||
)
|
||||
const validPassword = 'Aa0!Aa0!'
|
||||
v.expect(validPassword, `'${validPassword}' passes validation`).toMatch(pattern)
|
||||
v.expect(validPassword, `'${validPassword}' passes validation`).toMatch(regex)
|
||||
const basicPassword = 'Password0!'
|
||||
v.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(pattern)
|
||||
v.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(regex)
|
||||
const issue7498Password = 'ÑéFÛÅÐåÒ.ú¿¼\u00b4N@aö¶U¹jÙÇ3'
|
||||
v.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(pattern)
|
||||
v.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(regex)
|
||||
})
|
||||
|
||||
v.test.each([
|
||||
{ name: 'foo', valid: true },
|
||||
{ name: 'foo/', valid: false },
|
||||
{ name: 'foo\\', valid: false },
|
||||
{ name: 'foo/bar', valid: false },
|
||||
{ name: 'foo\\bar', valid: false },
|
||||
{ name: '/bar', valid: false },
|
||||
{ name: '\\bar', valid: false },
|
||||
{ name: '\\', valid: false },
|
||||
{ name: '/', valid: false },
|
||||
{ name: '......', valid: false },
|
||||
{ name: '..', valid: false },
|
||||
{ name: '.', valid: true },
|
||||
{ name: 'a.a.a.a.a.a.a.a.', valid: true },
|
||||
{ name: 'a.a.a.a.a.a.a.a.a', valid: true },
|
||||
{ name: '.a.a.a.a.a.a.a.a', valid: true },
|
||||
{ name: 'a.a.a.a.a.a.a.a..', valid: false },
|
||||
{ name: './', valid: false },
|
||||
{ name: '//', valid: false },
|
||||
{ name: '/\\', valid: false },
|
||||
{ name: '\\/', valid: false },
|
||||
])('directory name validation', args => {
|
||||
const { name, valid } = args
|
||||
const regex = validation.DIRECTORY_NAME_REGEX
|
||||
if (valid) {
|
||||
v.expect(name, `'${name}' is a valid directory name`).toMatch(regex)
|
||||
} else {
|
||||
v.expect(name, `'${name}' is not a valid directory name`).not.toMatch(regex)
|
||||
}
|
||||
})
|
||||
|
@ -106,9 +106,7 @@ export function isElementSingleLineTextInput(
|
||||
// === isElementPartOfMonaco ===
|
||||
// =============================
|
||||
|
||||
/**
|
||||
* Whether the element is part of a Monaco editor.
|
||||
*/
|
||||
/** Whether the element is part of a Monaco editor. */
|
||||
export function isElementPartOfMonaco(element: EventTarget | null) {
|
||||
const recursiveCheck = (htmlElement: HTMLElement | null): boolean => {
|
||||
if (htmlElement == null || htmlElement === document.body) {
|
||||
@ -125,6 +123,24 @@ export function isElementPartOfMonaco(element: EventTarget | null) {
|
||||
return element != null && element instanceof HTMLElement && recursiveCheck(element)
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === isElementInBounds ===
|
||||
// =========================
|
||||
|
||||
/** Whether the event occurred within the given {@link DOMRect}. */
|
||||
export function isElementInBounds(
|
||||
event: Pick<MouseEvent, 'clientX' | 'clientY'>,
|
||||
bounds: DOMRect,
|
||||
margin = 0
|
||||
) {
|
||||
return (
|
||||
bounds.left - margin <= event.clientX &&
|
||||
event.clientX <= bounds.right + margin &&
|
||||
bounds.top - margin <= event.clientY &&
|
||||
event.clientY <= bounds.bottom + margin
|
||||
)
|
||||
}
|
||||
|
||||
// ==================
|
||||
// === submitForm ===
|
||||
// ==================
|
||||
|
@ -1,4 +1,13 @@
|
||||
/** @file Immutably shallowly merge an object with a partial update. */
|
||||
/** @file Functions related to manipulating objects. */
|
||||
|
||||
// ===============
|
||||
// === Mutable ===
|
||||
// ===============
|
||||
|
||||
/** Remove the `readonly` modifier from all fields in a type. */
|
||||
export type Mutable<T> = {
|
||||
-readonly [K in keyof T]: T[K]
|
||||
}
|
||||
|
||||
// =============
|
||||
// === merge ===
|
||||
@ -10,12 +19,20 @@ type NoInfer<T> = [T][T extends T ? 0 : never]
|
||||
/** Immutably shallowly merge an object with a partial update.
|
||||
* Does not preserve classes. Useful for preserving order of properties. */
|
||||
export function merge<T extends object>(object: T, update: Partial<T>): T {
|
||||
return Object.assign({ ...object }, update)
|
||||
for (const [key, value] of Object.entries(update)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (!Object.is(value, (object as Record<string, unknown>)[key])) {
|
||||
// This is FINE, as the matching `return` is below this `return`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return Object.assign({ ...object }, update)
|
||||
}
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
/** Return a function to update an object with the given partial update. */
|
||||
export function merger<T extends object>(update: Partial<NoInfer<T>>): (object: T) => T {
|
||||
return object => Object.assign({ ...object }, update)
|
||||
return object => merge(object, update)
|
||||
}
|
||||
|
||||
// ================
|
||||
|
@ -12,6 +12,10 @@ export const TAILWIND_MERGE_CONFIG = {
|
||||
p: [{ p: [() => true] }],
|
||||
w: [{ w: [() => true] }],
|
||||
h: [{ h: [() => true] }],
|
||||
size: [{ size: [() => true] }],
|
||||
},
|
||||
conflictingClassGroups: {
|
||||
size: ['w', 'h'] as const,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -27,3 +27,9 @@ export const PASSWORD_REGEX = new RegExp('^' + PASSWORD_PATTERN + '$')
|
||||
* - allow any non-empty string
|
||||
*/
|
||||
export const LOCAL_PROJECT_NAME_PATTERN = '.*\\S.*'
|
||||
|
||||
/** Match only valid names for titles. The following substrings are disallowed:
|
||||
* - `/` - folder separator (non-Windows)
|
||||
* - `\` - folder separator (Windows)
|
||||
* - `..` - parent directory */
|
||||
export const DIRECTORY_NAME_REGEX = /^(?:[^/\\.]|[.](?=[^.]|$))+$/
|
||||
|
@ -469,10 +469,9 @@ inset 0 -36px 51px -51px #00000014`,
|
||||
|
||||
// === States ===
|
||||
|
||||
'.focus-ring, .focus-ring:focus, .focus-ring-outset, .focus-ring-outset:focus, .focus-ring-within[data-focus-visible=true]':
|
||||
{
|
||||
'@apply outline outline-2 -outline-offset-2 outline-primary transition-all': '',
|
||||
},
|
||||
'.focus-ring, .focus-ring:focus, .focus-ring-outset, .focus-ring-outset:focus': {
|
||||
'@apply outline outline-2 -outline-offset-2 outline-primary transition-all': '',
|
||||
},
|
||||
'.focus-ring.checkbox, .focus-ring-outset, .focus-ring-outset:focus': {
|
||||
'@apply outline-offset-0': '',
|
||||
},
|
||||
|