mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 00:52:09 +03:00
Update dashboard to design v122 (Part 1) (#9896)
- Close #9886 - Update style of buttons in Drive Bar - Move "Home" page into a "Start" dialog - Remove icons that are no longer needed - Remove Backend Switcher in top bar - they have now been converted to categories - Incidental changes - Refactor Backend provider so that both Remote and Local backends are available. - This was done because both Cloud and Local backends are now easily accessible by switching tabs - the Local backend no longer has its own views with the hidden category switcher # Important Notes None
This commit is contained in:
parent
55af1b9ffd
commit
46f6b4f698
@ -50,8 +50,7 @@ const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z
|
||||
/** @type {{ selector: string; message: string; }[]} */
|
||||
const RESTRICTED_SYNTAXES = [
|
||||
{
|
||||
selector:
|
||||
':matches(ImportDeclaration:has(ImportSpecifier), ExportDeclaration, ExportSpecifier)',
|
||||
selector: ':matches(ImportDeclaration:has(ImportSpecifier))',
|
||||
message: 'No {} imports and exports',
|
||||
},
|
||||
{
|
||||
@ -214,6 +213,11 @@ const RESTRICTED_SYNTAXES = [
|
||||
)`,
|
||||
message: 'Use a `getText()` from `useText` instead of a literal string',
|
||||
},
|
||||
{
|
||||
selector: `JSXAttribute[name.name=/^(?:className)$/] TemplateLiteral`,
|
||||
message:
|
||||
'Use `tv` from `tailwind-variants` or `twMerge` from `tailwind-merge` instead of template strings for classes',
|
||||
},
|
||||
{
|
||||
selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier',
|
||||
message: 'Use `Button` or `UnstyledButton` instead of `button`',
|
||||
|
12
app/ide-desktop/lib/assets/drop_files.svg
Normal file
12
app/ide-desktop/lib/assets/drop_files.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="186" height="186" viewBox="0 0 186 186" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M35.857 96.4941C35.2422 92.8346 38.0633 89.5 41.7741 89.5H144.226C147.937 89.5 150.758 92.8346 150.143 96.4941L141.995 144.994C141.51 147.884 139.008 150 136.078 150H49.9221C46.992 150 44.4905 147.884 44.005 144.994L35.857 96.4941Z"
|
||||
fill="black" fill-opacity="0.3" stroke="black" stroke-width="2" />
|
||||
<path d="M53 35C53 33.8954 53.8954 33 55 33H120.086L133 45.9142V61V89.5H53V35Z" stroke="black" stroke-width="2" />
|
||||
<path d="M44 59C44 55.6863 46.6863 53 50 53H53V89.5H44V59Z" fill="black" stroke="black" stroke-width="2" />
|
||||
<path d="M142 67C142 63.6863 139.314 61 136 61H133V89H142V67Z" fill="black" fill-opacity="0.8" stroke="black"
|
||||
stroke-width="2" />
|
||||
<path d="M93 111V127M85 119H101" stroke="black" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M93 43.5V73.5M93 73.5L103.5 63.5M93 73.5L82.5 63.5" stroke="black" stroke-width="2" stroke-linecap="round" />
|
||||
<circle cx="93" cy="93" r="92" stroke="black" stroke-opacity="0.5" stroke-width="2" stroke-dasharray="4 4" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="-1.5 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 5H0V16H5V10H8V16H13V5Z" fill="black" />
|
||||
<path d="M6.5 0L13 5H0L6.5 0Z" fill="black" />
|
||||
</svg>
|
Before Width: | Height: | Size: 253 B |
@ -1,8 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M13 7.5H3V15H6.5V12.5C6.5 11.6716 7.17157 11 8 11C8.82843 11 9.5 11.6716 9.5 12.5V15H13V7.5Z" fill="black" />
|
||||
<path d="M8 4.5L13 7.5H3L8 4.5Z" fill="black" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0.494615 6.61464C0.770757 7.09293 1.38235 7.25681 1.86064 6.98066L8.01172 3.42934L14.1318 6.96274C14.6101 7.23888 15.2216 7.07501 15.4978 6.59672C15.7739 6.11842 15.6101 5.50683 15.1318 5.23069L8.01744 1.12323L8.01458 1.11829L8.01172 1.11994L8.0089 1.1183L8.00607 1.1232L0.860641 5.24861C0.382348 5.52476 0.218473 6.13635 0.494615 6.61464Z"
|
||||
fill="black" />
|
||||
</svg>
|
Before Width: | Height: | Size: 753 B |
@ -36,7 +36,6 @@
|
||||
<script type="module" src="./src/entrypoint.ts" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="enso-dashboard" class="enso-dashboard"></div>
|
||||
<div id="enso-chat" class="enso-chat"></div>
|
||||
<div id="enso-portal-root" class="enso-portal-root"></div>
|
||||
|
@ -167,9 +167,14 @@ export function locateLabelsPanelLabels(page: test.Page) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Find a "home" button (if any) on the current page. */
|
||||
export function locateHomeButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Home' }).getByText('Home')
|
||||
/** Find a "cloud" category button (if any) on the current page. */
|
||||
export function locateCloudButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Cloud' }).getByText('Cloud')
|
||||
}
|
||||
|
||||
/** Find a "local" category button (if any) on the current page. */
|
||||
export function locateLocalButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Local' }).getByText('Local')
|
||||
}
|
||||
|
||||
/** Find a "trash" button (if any) on the current page. */
|
||||
@ -331,9 +336,16 @@ export function locateUploadFilesButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files')
|
||||
}
|
||||
|
||||
/** Find a "start modal" button (if any) on the current page. */
|
||||
export function locateStartModalButton(page: test.Locator | test.Page) {
|
||||
return page
|
||||
.getByRole('button', { name: 'Start with a template' })
|
||||
.getByText('Start with a template')
|
||||
}
|
||||
|
||||
/** Find a "new project" button (if any) on the current page. */
|
||||
export function locateNewProjectButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Project' }).getByText('New Project')
|
||||
return page.getByRole('button', { name: 'New Empty Project' }).getByText('New Empty Project')
|
||||
}
|
||||
|
||||
/** Find a "new folder" button (if any) on the current page. */
|
||||
@ -425,11 +437,6 @@ export function locateSortDescendingIcon(page: test.Locator | test.Page) {
|
||||
|
||||
// === Page locators ===
|
||||
|
||||
/** Find a "home page" icon (if any) on the current page. */
|
||||
export function locateHomePageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('Home') })
|
||||
}
|
||||
|
||||
/** Find a "drive page" icon (if any) on the current page. */
|
||||
export function locateDrivePageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('Catalog') })
|
||||
@ -803,23 +810,6 @@ export async function passTermsAndConditionsDialog({ page }: MockParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === mockIDEContainer ===
|
||||
// ========================
|
||||
|
||||
/** Make the IDE container have a non-zero size. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockIDEContainer({ page }: MockParams) {
|
||||
await page.evaluate(() => {
|
||||
const ideContainer = document.getElementById('app')
|
||||
if (ideContainer) {
|
||||
ideContainer.style.height = '100vh'
|
||||
ideContainer.style.width = '100vw'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === mockApi ===
|
||||
// ===============
|
||||
@ -838,7 +828,6 @@ export const mockApi = apiModule.mockApi
|
||||
export async function mockAll({ page }: MockParams) {
|
||||
const api = await mockApi({ page })
|
||||
await mockDate({ page })
|
||||
await mockIDEContainer({ page })
|
||||
return { api }
|
||||
}
|
||||
|
||||
@ -852,12 +841,6 @@ export async function mockAll({ page }: MockParams) {
|
||||
export async function mockAllAndLogin({ page }: MockParams) {
|
||||
const mocks = await mockAll({ page })
|
||||
await login({ page })
|
||||
|
||||
await passTermsAndConditionsDialog({ page })
|
||||
|
||||
// This MUST run after login, otherwise the element's styles are reset when the browser
|
||||
// is navigated to another page.
|
||||
await mockIDEContainer({ page })
|
||||
|
||||
return mocks
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ test.test('can drop onto root directory dropzone', async ({ page }) => {
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
await assetRows.nth(1).dragTo(actions.locateRootDirectoryDropzone(page))
|
||||
await assetRows.nth(1).dragTo(actions.locateRootDirectoryDropzone(page), { force: true })
|
||||
const firstLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const secondLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
test.expect(firstLeft, 'siblings have same indentation').toEqual(secondLeft)
|
||||
|
@ -24,7 +24,7 @@ test.test('delete and restore', async ({ page }) => {
|
||||
await actions.locateRestoreFromTrashButton(contextMenu).click()
|
||||
await actions.expectTrashPlaceholderRow(page)
|
||||
|
||||
await actions.locateHomeButton(page).click()
|
||||
await actions.locateCloudButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
||||
|
||||
@ -45,6 +45,6 @@ test.test('delete and restore (keyboard)', async ({ page }) => {
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.expectTrashPlaceholderRow(page)
|
||||
|
||||
await actions.locateHomeButton(page).click()
|
||||
await actions.locateCloudButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
||||
|
@ -19,7 +19,6 @@ test.test('drive view', async ({ page }) => {
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(actions.locateAssetsTable(page)).toBeVisible()
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
|
@ -12,16 +12,9 @@ test.test('page switcher', async ({ page }) => {
|
||||
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await test.expect(actions.locateSamplesList(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).not.toBeVisible()
|
||||
|
||||
await actions.locateHomePageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateSamplesList(page)).toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).not.toBeVisible()
|
||||
|
||||
await actions.locateEditorPageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateSamplesList(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
|
@ -5,16 +5,10 @@ import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('create empty project', async ({ page }) => {
|
||||
await actions.locateHomePageIcon(page).click()
|
||||
// The first "sample" is a button to create a new empty project.
|
||||
await actions.locateSamples(page).nth(0).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test.test('create project from template', async ({ page }) => {
|
||||
await actions.locateHomePageIcon(page).click()
|
||||
await actions.locateStartModalButton(page).click()
|
||||
// The second "sample" is the first template.
|
||||
await actions.locateSamples(page).nth(1).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
|
||||
})
|
@ -37,7 +37,6 @@
|
||||
<script type="module" src="./src/entrypoint.ts" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="enso-dashboard" class="enso-dashboard"></div>
|
||||
<div id="enso-chat" class="enso-chat"></div>
|
||||
<div id="enso-portal-root" class="enso-portal-root"></div>
|
||||
|
@ -19,6 +19,7 @@
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"dev:e2e": "vite -c vite.test.config.ts",
|
||||
"dev:e2e:ci": "vite -c vite.test.config.ts build && vite preview --port 8080 --strictPort",
|
||||
"test": "npm run test:unit && npm run test:e2e",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:debug": "vitest",
|
||||
@ -39,7 +40,6 @@
|
||||
"@sentry/react": "^7.74.0",
|
||||
"@tanstack/react-query": "5.37.1",
|
||||
"ajv": "^8.12.0",
|
||||
"clsx": "^1.1.1",
|
||||
"enso-common": "^1.0.0",
|
||||
"is-network-error": "^1.0.1",
|
||||
"monaco-editor": "0.48.0",
|
||||
@ -53,11 +53,11 @@
|
||||
"react-stately": "^3.31.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwind-variants": "0.2.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"ts-results": "^3.3.0",
|
||||
"validator": "^13.12.0",
|
||||
"zod": "^3.23.8",
|
||||
"tailwind-variants": "0.2.1"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-syntax-import-assertions": "^7.23.3",
|
||||
|
@ -16,7 +16,7 @@ export default test.defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: true,
|
||||
workers: 1,
|
||||
workers: process.env.PROD ? 8 : 1,
|
||||
repeatEach: process.env.CI ? 3 : 1,
|
||||
expect: {
|
||||
toHaveScreenshot: { threshold: 0 },
|
||||
@ -50,7 +50,7 @@ export default test.defineConfig({
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev:e2e',
|
||||
command: process.env.CI || process.env.PROD ? 'npm run dev:e2e:ci' : 'npm run dev:e2e',
|
||||
port: 8080,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
|
@ -45,6 +45,8 @@ import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import InputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
@ -53,9 +55,7 @@ import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
import RemoteBackendProvider from '#/providers/RemoteBackendProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
import SupportsLocalBackendProvider from '#/providers/SupportsLocalBackendProvider'
|
||||
|
||||
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
|
||||
import ForgotPassword from '#/pages/authentication/ForgotPassword'
|
||||
@ -212,7 +212,7 @@ export interface AppRouterProps extends AppProps {
|
||||
* because the {@link AppRouter} relies on React hooks, which can't be used in the same React
|
||||
* component as the component that defines the provider. */
|
||||
function AppRouter(props: AppRouterProps) {
|
||||
const { logger, supportsLocalBackend, isAuthenticationDisabled, shouldShowDashboard } = props
|
||||
const { logger, isAuthenticationDisabled, shouldShowDashboard } = props
|
||||
const { onAuthenticated, projectManagerUrl, projectManagerRootDirectory } = props
|
||||
const { portalRoot } = props
|
||||
// `navigateHooks.useNavigate` cannot be used here as it relies on `AuthProvider`, which has not
|
||||
@ -222,6 +222,14 @@ function AppRouter(props: AppRouterProps) {
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
const [remoteBackend, setRemoteBackend] = React.useState<Backend | null>(null)
|
||||
const [localBackend] = React.useState(() =>
|
||||
projectManagerUrl != null && projectManagerRootDirectory != null
|
||||
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
|
||||
: null
|
||||
)
|
||||
backendHooks.useObserveBackend(remoteBackend)
|
||||
backendHooks.useObserveBackend(localBackend)
|
||||
if (detect.IS_DEV_MODE) {
|
||||
// @ts-expect-error This is used exclusively for debugging.
|
||||
window.navigate = navigate
|
||||
@ -246,6 +254,20 @@ function AppRouter(props: AppRouterProps) {
|
||||
}
|
||||
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (remoteBackend) {
|
||||
void remoteBackend.logEvent('open_app')
|
||||
const logCloseEvent = () => void remoteBackend.logEvent('close_app')
|
||||
window.addEventListener('beforeunload', logCloseEvent)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', logCloseEvent)
|
||||
logCloseEvent()
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [remoteBackend])
|
||||
|
||||
const inputBindings = React.useMemo(() => {
|
||||
const updateLocalStorage = () => {
|
||||
localStorage.set(
|
||||
@ -305,12 +327,6 @@ function AppRouter(props: AppRouterProps) {
|
||||
const refreshUserSession =
|
||||
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null
|
||||
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
|
||||
const initialBackend: Backend =
|
||||
isAuthenticationDisabled && projectManagerUrl != null && projectManagerRootDirectory != null
|
||||
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
|
||||
: // This is SAFE, because the backend is always set by the authentication flow.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
null!
|
||||
|
||||
React.useEffect(() => {
|
||||
if ('menuApi' in window) {
|
||||
@ -439,27 +455,22 @@ function AppRouter(props: AppRouterProps) {
|
||||
)
|
||||
|
||||
let result = routes
|
||||
|
||||
result = (
|
||||
<SupportsLocalBackendProvider supportsLocalBackend={supportsLocalBackend}>
|
||||
{result}
|
||||
</SupportsLocalBackendProvider>
|
||||
)
|
||||
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
|
||||
result = <RemoteBackendProvider>{result}</RemoteBackendProvider>
|
||||
result = (
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
{result}
|
||||
</BackendProvider>
|
||||
)
|
||||
result = (
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
supportsLocalBackend={supportsLocalBackend}
|
||||
setRemoteBackend={setRemoteBackend}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
projectManagerUrl={projectManagerUrl}
|
||||
projectManagerRootDirectory={projectManagerRootDirectory}
|
||||
>
|
||||
{result}
|
||||
</AuthProvider>
|
||||
)
|
||||
result = <BackendProvider initialBackend={initialBackend}>{result}</BackendProvider>
|
||||
result = (
|
||||
<SessionProvider
|
||||
mainPageUrl={mainPageUrl}
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* ReactQueryDevtools component. Shows the React Query Devtools.
|
||||
*/
|
||||
/** @file Show the React Query Devtools. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
@ -14,15 +10,12 @@ const ReactQueryDevtoolsProduction = React.lazy(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* ReactQueryDevtools component.
|
||||
* Shows the React Query Devtools and provide ability to show them in production.
|
||||
*/
|
||||
/** Show the React Query Devtools and provide the ability to show them in production. */
|
||||
export function ReactQueryDevtools() {
|
||||
const [showDevtools, setShowDevtools] = React.useState(false)
|
||||
// It's safer to pass the client directly to the devtools
|
||||
// since there might be a chance that we have multiple versions of react-query,
|
||||
// in case we forgot to update the devtools, npm messed up the versions,
|
||||
// It is safer to pass the client directly to the devtools
|
||||
// since there might be a chance that we have multiple versions of `react-query`,
|
||||
// in case we forget to update the devtools, npm messes up the versions,
|
||||
// or there are hoisting issues.
|
||||
const client = reactQuery.useQueryClient()
|
||||
|
||||
|
@ -4,9 +4,7 @@
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
// =============
|
||||
// === Paths ===
|
||||
// =============
|
||||
|
||||
/** Path to the root of the app (i.e., the Cloud dashboard). */
|
||||
export const DASHBOARD_PATH = '/'
|
||||
@ -16,10 +14,8 @@ export const LOGIN_PATH = '/login'
|
||||
export const REGISTRATION_PATH = '/registration'
|
||||
/** Path to the confirm registration page. */
|
||||
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
|
||||
/**
|
||||
* Path to the page in which a user can restore their account after it has been
|
||||
* marked for deletion.
|
||||
*/
|
||||
/** Path to the page in which a user can restore their account after it has been
|
||||
* marked for deletion. */
|
||||
export const RESTORE_USER_PATH = '/restore-user'
|
||||
/** Path to the forgot password page. */
|
||||
export const FORGOT_PASSWORD_PATH = '/forgot-password'
|
||||
@ -38,8 +34,6 @@ export const ALL_PATHS_REGEX = new RegExp(
|
||||
`${SUBSCRIBE_PATH}|${SUBSCRIBE_SUCCESS_PATH})$`
|
||||
)
|
||||
|
||||
// ===========
|
||||
// === URL ===
|
||||
// ===========
|
||||
// === Constants related to URLs ===
|
||||
|
||||
export const SEARCH_PARAMS_PREFIX = 'cloud-ide_'
|
||||
|
@ -1,6 +1,4 @@
|
||||
/**
|
||||
* @file Alert component.
|
||||
*/
|
||||
/** @file Alert component. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as twv from 'tailwind-variants'
|
||||
@ -9,13 +7,9 @@ import * as mergeRefs from '#/utilities/mergeRefs'
|
||||
|
||||
import * as text from '../Text'
|
||||
|
||||
/**
|
||||
* Props for the Alert component.
|
||||
*/
|
||||
export interface AlertProps
|
||||
extends React.PropsWithChildren,
|
||||
twv.VariantProps<typeof ALERT_STYLES>,
|
||||
React.HTMLAttributes<HTMLDivElement> {}
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
export const ALERT_STYLES = twv.tv({
|
||||
base: 'flex flex-col items-stretch',
|
||||
@ -66,9 +60,17 @@ export const ALERT_STYLES = twv.tv({
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Alert component.
|
||||
*/
|
||||
// =============
|
||||
// === Alert ===
|
||||
// =============
|
||||
|
||||
/** Props for an {@link Alert}. */
|
||||
export interface AlertProps
|
||||
extends React.PropsWithChildren,
|
||||
twv.VariantProps<typeof ALERT_STYLES>,
|
||||
React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/** Alert component. */
|
||||
export const Alert = React.forwardRef(function Alert(
|
||||
props: AlertProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
|
@ -156,7 +156,8 @@ export const BUTTON_STYLES = twv.tv({
|
||||
icon: 'h-[1.25cap] mt-[0.25cap]',
|
||||
},
|
||||
primary: 'bg-primary text-white hover:bg-primary/70',
|
||||
tertiary: 'bg-share text-white hover:bg-share/90',
|
||||
tertiary:
|
||||
'relative flex items-center rounded-full text-white before:absolute before:inset before:rounded-full before:bg-accent before:transition-all hover:before:brightness-90',
|
||||
cancel: 'bg-white/50 hover:bg-white',
|
||||
delete:
|
||||
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
|
||||
@ -170,6 +171,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
'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',
|
||||
bar: 'rounded-full border-0.5 border-primary/20 px-new-project-button-x transition-colors hover:bg-primary/10',
|
||||
},
|
||||
iconPosition: {
|
||||
start: { content: '' },
|
||||
@ -184,14 +186,14 @@ export const BUTTON_STYLES = twv.tv({
|
||||
wrapper: 'relative block',
|
||||
loader: 'absolute inset-0 flex items-center justify-center',
|
||||
content: 'flex items-center gap-[0.5em]',
|
||||
text: '',
|
||||
text: 'inline-flex items-center gap-1',
|
||||
icon: 'h-[2cap] flex-none aspect-square',
|
||||
},
|
||||
defaultVariants: {
|
||||
loading: false,
|
||||
fullWidth: false,
|
||||
size: 'xsmall',
|
||||
rounded: 'large',
|
||||
rounded: 'full',
|
||||
variant: 'primary',
|
||||
iconPosition: 'start',
|
||||
showIconOnHover: false,
|
||||
@ -274,9 +276,11 @@ export const Button = React.forwardRef(function Button(
|
||||
|
||||
const Tag = isLink ? aria.Link : aria.Button
|
||||
|
||||
const goodDefaults = isLink
|
||||
? { rel: 'noopener noreferrer', 'data-testid': testId ?? 'link' }
|
||||
: { type: 'button', 'data-testid': testId ?? 'button' }
|
||||
const goodDefaults = {
|
||||
...(isLink ? { rel: 'noopener noreferrer' } : {}),
|
||||
...(isLink ? {} : { type: 'button' as const }),
|
||||
'data-testid': testId ?? (isLink ? 'link' : 'button'),
|
||||
}
|
||||
const isIconOnly = (children == null || children === '' || children === false) && icon != null
|
||||
const shouldShowTooltip = isIconOnly && tooltip !== false
|
||||
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
|
||||
@ -366,17 +370,21 @@ export const Button = React.forwardRef(function Button(
|
||||
|
||||
const button = (
|
||||
<Tag
|
||||
// @ts-expect-error eventhough typescript is complaining about the type of ariaProps, it is actually correct
|
||||
{...aria.mergeProps()(goodDefaults, ariaProps, focusChildProps, {
|
||||
ref,
|
||||
isDisabled,
|
||||
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
|
||||
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
|
||||
onPressEnd: handlePress,
|
||||
})}
|
||||
// @ts-expect-error eventhough typescript is complaining about the type of className, it is actually correct
|
||||
className={aria.composeRenderProps(className, (classNames, states) =>
|
||||
base({ className: classNames, ...states })
|
||||
{...aria.mergeProps<aria.ButtonProps | aria.LinkProps>()(
|
||||
goodDefaults,
|
||||
ariaProps,
|
||||
focusChildProps,
|
||||
{
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
...{ ref: ref as never },
|
||||
isDisabled,
|
||||
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
|
||||
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
|
||||
onPressEnd: handlePress,
|
||||
className: aria.composeRenderProps(className, (classNames, states) =>
|
||||
base({ className: classNames, ...states })
|
||||
),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className={wrapper()}>
|
||||
|
@ -1,14 +1,11 @@
|
||||
/** @file A styled button. */
|
||||
/** @file A group of buttons. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as twv from 'tailwind-variants'
|
||||
|
||||
/**
|
||||
* Props for a {@link ButtonGroup}.
|
||||
*/
|
||||
interface ButtonGroupProps extends React.PropsWithChildren, twv.VariantProps<typeof STYLES> {
|
||||
readonly className?: string
|
||||
}
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const STYLES = twv.tv({
|
||||
base: 'flex w-full flex-1 shrink-0',
|
||||
@ -40,9 +37,16 @@ const STYLES = twv.tv({
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* A group of buttons.
|
||||
*/
|
||||
// ===================
|
||||
// === ButtonGroup ===
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link ButtonGroup}. */
|
||||
interface ButtonGroupProps extends React.PropsWithChildren, twv.VariantProps<typeof STYLES> {
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
/** A group of buttons. */
|
||||
export function ButtonGroup(props: ButtonGroupProps) {
|
||||
const {
|
||||
children,
|
||||
@ -51,7 +55,7 @@ export function ButtonGroup(props: ButtonGroupProps) {
|
||||
wrap = false,
|
||||
direction = 'row',
|
||||
align,
|
||||
...rest
|
||||
...passthrough
|
||||
} = props
|
||||
|
||||
return (
|
||||
@ -63,7 +67,7 @@ export function ButtonGroup(props: ButtonGroupProps) {
|
||||
align,
|
||||
className,
|
||||
})}
|
||||
{...rest}
|
||||
{...passthrough}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -1,30 +1,26 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Button component for closing a modal.
|
||||
*/
|
||||
/** @file A button for closing a modal. */
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import * as twMerge from 'tailwind-merge'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import Dismiss from 'enso-assets/dismiss.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as button from './Button'
|
||||
import * as button from '#/components/AriaComponents/Button'
|
||||
|
||||
/**
|
||||
* Props for a {@link CloseButton}.
|
||||
*/
|
||||
// ===================
|
||||
// === CloseButton ===
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link CloseButton}. */
|
||||
export type CloseButtonProps = Omit<
|
||||
button.ButtonProps,
|
||||
'children' | 'rounding' | 'size' | 'variant'
|
||||
>
|
||||
|
||||
/**
|
||||
* A close button. This is a styled button with a close icon that appears on hover
|
||||
*/
|
||||
/** A styled button with a close icon that appears on hover. */
|
||||
export function CloseButton(props: CloseButtonProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const {
|
||||
@ -40,7 +36,7 @@ export function CloseButton(props: CloseButtonProps) {
|
||||
variant="icon"
|
||||
// @ts-expect-error ts fails to infer the type of the className prop
|
||||
className={values =>
|
||||
twMerge.twJoin(
|
||||
tailwindMerge.twMerge(
|
||||
'h-3 w-3 bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
typeof className === 'function' ? className(values) : className
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* A button that copies text to the clipboard.
|
||||
*/
|
||||
/** @file A button that copies text to the clipboard. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Error from 'enso-assets/cross.svg'
|
||||
@ -15,54 +11,38 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as button from './Button'
|
||||
|
||||
/**
|
||||
* Props for a {@link CopyButton}.
|
||||
*/
|
||||
export type CopyButtonProps = CopyButtonBaseProps &
|
||||
Omit<button.ButtonProps, 'icon' | 'loading' | 'onPress'>
|
||||
// ==================
|
||||
// === CopyButton ===
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* Base props for a {@link CopyButton}.
|
||||
*/
|
||||
interface CopyButtonBaseProps {
|
||||
/**
|
||||
* The text to copy to the clipboard.
|
||||
*/
|
||||
/** Props for a {@link CopyButton}. */
|
||||
export interface CopyButtonProps extends Omit<button.ButtonProps, 'icon' | 'loading' | 'onPress'> {
|
||||
/** The text to copy to the clipboard. */
|
||||
readonly copyText: string
|
||||
/**
|
||||
* Custom icon
|
||||
* If `false` is provided, no icon will be shown.
|
||||
*/
|
||||
/** Custom icon
|
||||
* If `false` is provided, no icon will be shown. */
|
||||
readonly copyIcon?: string | false
|
||||
readonly errorIcon?: string
|
||||
readonly successIcon?: string
|
||||
readonly onCopy?: () => void
|
||||
/**
|
||||
* Show a toast message when the copy is successful.
|
||||
/** Show a toast message when the copy is successful.
|
||||
* If a string is provided, it will be used as the toast message.
|
||||
* If `true` is provided, a default toast message will be shown with the text "Copied to clipboard".
|
||||
* If `false` is provided, no toast message will be shown.
|
||||
*/
|
||||
* If `false` is provided, no toast message will be shown. */
|
||||
readonly successToastMessage?: boolean | string
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that copies text to the clipboard.*
|
||||
*/
|
||||
/** A button that copies text to the clipboard. */
|
||||
export function CopyButton(props: CopyButtonProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const {
|
||||
'aria-label': ariaLabel = getText('copyShortcut'),
|
||||
variant = 'icon',
|
||||
copyIcon = CopyIcon,
|
||||
successIcon = Done,
|
||||
errorIcon = Error,
|
||||
...buttonProps
|
||||
} = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const copyQuery = copyHook.useCopy(props)
|
||||
|
||||
const successfullyCopied = copyQuery.isSuccess
|
||||
const isError = copyQuery.isError
|
||||
const showIcon = copyIcon !== false
|
||||
@ -74,7 +54,7 @@ export function CopyButton(props: CopyButtonProps) {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
|
||||
{...(buttonProps as any)}
|
||||
variant={variant}
|
||||
aria-label={ariaLabel}
|
||||
aria-label={props['aria-label'] ?? getText('copyShortcut')}
|
||||
onPress={() => copyQuery.mutateAsync()}
|
||||
icon={icon}
|
||||
/>
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel export file for Button component.
|
||||
*/
|
||||
/** @file Barrel export file for Button component. */
|
||||
export * from './Button'
|
||||
export * from './ButtonGroup'
|
||||
export * from './CopyButton'
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* CopyBlock component.
|
||||
*/
|
||||
/** @file A block of text with a copy button. */
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
@ -10,15 +6,9 @@ import * as twv from 'tailwind-variants'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface CopyBlockProps {
|
||||
readonly title?: React.ReactNode
|
||||
readonly copyText: string
|
||||
readonly className?: string
|
||||
readonly onCopy?: () => void
|
||||
}
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const COPY_BLOCK_STYLES = twv.tv({
|
||||
base: 'relative grid grid-cols-[minmax(0,_1fr)_auto] max-w-full bg-primary/10 items-center',
|
||||
@ -47,18 +37,26 @@ const COPY_BLOCK_STYLES = twv.tv({
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* A block of text with a copy button.
|
||||
*/
|
||||
// =================
|
||||
// === CopyBlock ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link CopyBlock}. */
|
||||
export interface CopyBlockProps {
|
||||
readonly title?: React.ReactNode
|
||||
readonly copyText: string
|
||||
readonly className?: string
|
||||
readonly onCopy?: () => void
|
||||
}
|
||||
|
||||
/** A block of text with a copy button. */
|
||||
export function CopyBlock(props: CopyBlockProps) {
|
||||
const { copyText, className, onCopy = () => {} } = props
|
||||
|
||||
const { copyTextBlock, base, copyButton } = COPY_BLOCK_STYLES()
|
||||
|
||||
return (
|
||||
<div className={base({ className })}>
|
||||
<div className={copyTextBlock()}>{copyText}</div>
|
||||
|
||||
<ariaComponents.CopyButton copyText={copyText} onCopy={onCopy} className={copyButton()} />
|
||||
</div>
|
||||
)
|
||||
|
@ -18,6 +18,9 @@ import type * as types from './types'
|
||||
import * as utlities from './utilities'
|
||||
import * as variants from './variants'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
/**
|
||||
* Props for the {@link Dialog} component.
|
||||
*/
|
||||
@ -69,6 +72,7 @@ export function Dialog(props: DialogProps) {
|
||||
children,
|
||||
title,
|
||||
type = 'modal',
|
||||
closeButton = 'none',
|
||||
isDismissable = true,
|
||||
isKeyboardDismissDisabled = false,
|
||||
hideCloseButton = false,
|
||||
@ -83,7 +87,6 @@ export function Dialog(props: DialogProps) {
|
||||
const dialogId = aria.useId()
|
||||
const dialogRef = React.useRef<HTMLDivElement>(null)
|
||||
const overlayState = React.useRef<aria.OverlayTriggerState | null>(null)
|
||||
|
||||
const root = portal.useStrictPortalContext()
|
||||
const shouldRenderTitle = typeof title === 'string'
|
||||
const dialogSlots = DIALOG_STYLES({ className, type, rounded, hideCloseButton })
|
||||
@ -175,6 +178,11 @@ export function Dialog(props: DialogProps) {
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</div>
|
||||
{closeButton === 'floating' && (
|
||||
<div className="absolute m-[19px] flex gap-1">
|
||||
<ariaComponents.CloseButton onPress={opts.close} />
|
||||
</div>
|
||||
)}
|
||||
</dialogProvider.DialogProvider>
|
||||
)}
|
||||
</aria.Dialog>
|
||||
|
@ -1,10 +1,13 @@
|
||||
/** @file Types for the Dialog component. */
|
||||
import type * as aria from '#/components/aria'
|
||||
|
||||
/** The type of close button for the Dialog.
|
||||
* Note that Dialogs with a title have a regular close button by default. */
|
||||
export type DialogCloseButtonType = 'floating' | 'none'
|
||||
|
||||
/** Props for the Dialog component. */
|
||||
export interface DialogProps extends aria.DialogProps {
|
||||
/** The type of dialog to render.
|
||||
* @default 'modal' */
|
||||
readonly closeButton?: DialogCloseButtonType
|
||||
readonly title?: string
|
||||
readonly isDismissable?: boolean
|
||||
readonly onOpenChange?: (isOpen: boolean) => void
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Form component
|
||||
*/
|
||||
/** @file Form component. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as sentry from '@sentry/react'
|
||||
@ -20,14 +16,11 @@ import * as components from './components'
|
||||
import * as styles from './styles'
|
||||
import type * as types from './types'
|
||||
|
||||
/**
|
||||
* Form component. It wraps the form and provides the form context.
|
||||
* It also handles the form submission.
|
||||
* Provides better error handling and form state management.
|
||||
* And serves a better UX out of the box.
|
||||
/** Form component. It wraps a `form` and provides form context.
|
||||
* It also handles form submission.
|
||||
* Provides better error handling and form state management and better UX out of the box.
|
||||
*
|
||||
* ## Component is in BETA and will be improved in the future.
|
||||
*/
|
||||
* ## Component is in BETA and will be improved in the future. */
|
||||
// There is no way to avoid type casting here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Form = React.forwardRef(function Form<
|
||||
|
@ -1,17 +1,16 @@
|
||||
/**
|
||||
* @file This file contains the useFormSchema hook for creating form schemas.
|
||||
*/
|
||||
|
||||
/** @file A hook to create a form schema. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as callbackEventHooks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import * as schemaComponent from './schema'
|
||||
import type * as types from './types'
|
||||
import * as schemaComponent from '#/components/AriaComponents/Form/components/schema'
|
||||
import type * as types from '#/components/AriaComponents/Form/components/types'
|
||||
|
||||
/**
|
||||
* Hook to create a form schema.
|
||||
*/
|
||||
// =====================
|
||||
// === useFormSchema ===
|
||||
// =====================
|
||||
|
||||
/** A hook to create a form schema. */
|
||||
export function useFormSchema<Schema extends types.TSchema, T extends types.FieldValues<Schema>>(
|
||||
callback: (schema: typeof schemaComponent.schema) => schemaComponent.schema.ZodObject<T>
|
||||
) {
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A select menu with a dropdown. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import Input from '#/components/styled/Input'
|
||||
|
||||
@ -225,25 +227,29 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
</FocusRing>
|
||||
<div className="h">
|
||||
<div
|
||||
className={`relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default ${
|
||||
isDropdownVisible && matchingItems.length !== 0
|
||||
? 'before:border before:border-primary/10'
|
||||
: ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default',
|
||||
isDropdownVisible &&
|
||||
matchingItems.length !== 0 &&
|
||||
'before:border before:border-primary/10'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`relative max-h-autocomplete-suggestions w-full overflow-auto rounded-default ${
|
||||
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h'
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative max-h-autocomplete-suggestions w-full overflow-auto rounded-default',
|
||||
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0'
|
||||
)}
|
||||
>
|
||||
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
|
||||
* so the suggestions may go offscreen. */}
|
||||
{matchingItems.map((item, index) => (
|
||||
<div
|
||||
key={itemToKey(item)}
|
||||
className={`text relative cursor-pointer whitespace-nowrap px-input-x first:rounded-t-default last:rounded-b-default hover:bg-hover-bg ${
|
||||
index === selectedIndex ? 'bg-black/5' : valuesSet.has(item) ? 'bg-hover-bg' : ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text relative cursor-pointer whitespace-nowrap px-input-x first:rounded-t-default last:rounded-b-default hover:bg-hover-bg',
|
||||
valuesSet.has(item) && 'bg-hover-bg',
|
||||
index === selectedIndex && 'bg-black/5'
|
||||
)}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A color picker to select from a predetermined list of colors. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as focusClassProvider from '#/providers/FocusClassProvider'
|
||||
@ -69,7 +71,7 @@ function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivEle
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div className={`flex items-center gap-colors ${pickerClassName}`}>
|
||||
<div className={tailwindMerge.twMerge('flex items-center gap-colors', pickerClassName)}>
|
||||
{backend.COLORS.map((currentColor, i) => (
|
||||
<ColorPickerItem key={i} color={currentColor} />
|
||||
))}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A context menu. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
@ -31,9 +33,10 @@ export default function ContextMenu(props: ContextMenuProps) {
|
||||
>
|
||||
<div
|
||||
aria-label={props['aria-label']}
|
||||
className={`relative flex flex-col rounded-default ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative flex flex-col rounded-default p-context-menu',
|
||||
detect.isOnMacOS() ? 'w-context-menu-macos' : 'w-context-menu'
|
||||
} p-context-menu`}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -14,5 +14,5 @@ export interface ContextMenuEntryProps
|
||||
|
||||
/** An item in a menu. */
|
||||
export default function ContextMenuEntry(props: ContextMenuEntryProps) {
|
||||
return <MenuEntry isContextMenuEntry {...props} />
|
||||
return <MenuEntry variant="context-menu" {...props} />
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A context menu. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import Modal from '#/components/Modal'
|
||||
@ -33,11 +35,12 @@ function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivE
|
||||
data-testid="context-menus"
|
||||
ref={ref}
|
||||
style={{ left: event.pageX, top: event.pageY }}
|
||||
className={`pointer-events-none sticky flex w-min items-start gap-context-menus ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none sticky flex w-min items-start gap-context-menus',
|
||||
detect.isOnMacOS()
|
||||
? 'ml-context-menu-macos-half-x -translate-x-context-menu-macos-half-x'
|
||||
: 'ml-context-menu-half-x -translate-x-context-menu-half-x'
|
||||
}`}
|
||||
)}
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
}}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file An input that outputs a {@link Date}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CrossIcon from 'enso-assets/cross.svg'
|
||||
import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg'
|
||||
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
||||
@ -10,9 +12,9 @@ import * as focusHooks from '#/hooks/focusHooks'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
@ -105,7 +107,10 @@ export default function DateInput(props: DateInputProps) {
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
className: `flex h-text w-date-picker items-center rounded-full border border-primary/10 px-date-input transition-colors hover:[&:not(:has(button:hover))]:bg-hover-bg ${date == null ? 'placeholder' : ''}`,
|
||||
className: tailwindMerge.twMerge(
|
||||
'flex h-text w-date-picker items-center rounded-full border border-primary/10 px-date-input transition-colors hover:[&:not(:has(button:hover))]:bg-hover-bg',
|
||||
date == null && 'placeholder'
|
||||
),
|
||||
onClick: event => {
|
||||
event.stopPropagation()
|
||||
setIsPickerVisible(!isPickerVisible)
|
||||
@ -122,14 +127,16 @@ export default function DateInput(props: DateInputProps) {
|
||||
{date != null ? dateTime.formatDate(date) : getText('noDateSelected')}
|
||||
</div>
|
||||
{date != null && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
onInput(null)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>
|
||||
</FocusRing>
|
||||
@ -138,15 +145,19 @@ export default function DateInput(props: DateInputProps) {
|
||||
<div className="relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft before:absolute before:inset-0 before:rounded-2xl before:backdrop-blur-3xl">
|
||||
<div className="relative mb-date-input-gap">
|
||||
<div className="flex items-center">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
setSelectedYear(selectedYear - 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
if (selectedMonthIndex === 0) {
|
||||
@ -158,11 +169,13 @@ export default function DateInput(props: DateInputProps) {
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-180" />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
<aria.Text className="grow text-center">
|
||||
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
|
||||
</aria.Text>
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
if (selectedMonthIndex === LAST_MONTH_INDEX) {
|
||||
@ -174,15 +187,17 @@ export default function DateInput(props: DateInputProps) {
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} />
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
setSelectedYear(selectedYear + 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="relative w-full">
|
||||
@ -213,16 +228,21 @@ export default function DateInput(props: DateInputProps) {
|
||||
currentDate.getDate() === date.getDate()
|
||||
return (
|
||||
<td key={j} className="text-tight p">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isSelectedDate}
|
||||
className={`w-full rounded-small-rectangle-button text-center hover:bg-primary/10 disabled:bg-frame disabled:font-bold ${day.monthOffset === 0 ? '' : 'opacity-unimportant'}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'w-full rounded-small-rectangle-button text-center hover:bg-primary/10 disabled:bg-frame disabled:font-bold',
|
||||
day.monthOffset !== 0 && 'opacity-unimportant'
|
||||
)}
|
||||
onPress={() => {
|
||||
setIsPickerVisible(false)
|
||||
onInput(currentDate)
|
||||
}}
|
||||
>
|
||||
{day.date}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A styled dropdown. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CheckMarkIcon from 'enso-assets/check_mark.svg'
|
||||
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
||||
|
||||
@ -166,9 +168,10 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
rootRef.current = element
|
||||
}}
|
||||
tabIndex={0}
|
||||
className={`focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
|
||||
className ?? ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
|
||||
className
|
||||
)}
|
||||
onFocus={event => {
|
||||
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(true)
|
||||
@ -191,14 +194,18 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute left-0 h-full w-full min-w-max ${isDropdownVisible ? 'z-1' : 'overflow-hidden'}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'absolute left-0 h-full w-full min-w-max',
|
||||
isDropdownVisible ? 'z-1' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors',
|
||||
isDropdownVisible
|
||||
? 'before:h-full before:shadow-soft'
|
||||
: 'before:h-text group-hover:before:bg-hover-bg'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{/* Spacing. */}
|
||||
<div
|
||||
@ -212,21 +219,22 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
|
||||
isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={`flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors ${
|
||||
multiple ? 'hover:font-semibold' : ''
|
||||
} ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors',
|
||||
multiple && 'hover:font-semibold',
|
||||
i === visuallySelectedIndex
|
||||
? `cursor-default bg-frame font-bold focus-ring`
|
||||
? 'cursor-default bg-frame font-bold focus-ring'
|
||||
: 'hover:bg-hover-bg'
|
||||
}`}
|
||||
)}
|
||||
key={i}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
@ -279,9 +287,11 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`relative flex h-text items-center gap-dropdown-arrow px-input-x ${isDropdownVisible ? 'z-1' : ''} ${
|
||||
readOnly ? 'read-only' : ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative flex h-text items-center gap-dropdown-arrow px-input-x',
|
||||
isDropdownVisible && 'z-1',
|
||||
readOnly && 'read-only'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
@ -319,4 +329,4 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export default React.forwardRef(Dropdown) as <T>(
|
||||
props: DropdownProps<T> & React.RefAttributes<HTMLDivElement>
|
||||
) => JSX.Element
|
||||
) => React.JSX.Element
|
||||
|
@ -10,9 +10,9 @@ 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 UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
@ -123,15 +123,19 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
})}
|
||||
/>
|
||||
{isSubmittable && (
|
||||
<UnstyledButton
|
||||
<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-icon" />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
<FocusRing>
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
cancelledRef.current = true
|
||||
@ -142,7 +146,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</FocusRing>
|
||||
</form>
|
||||
)
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* ErrorBoundary component to catch errors in the child components
|
||||
*/
|
||||
/** @file Catches errors in child components. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as sentry from '@sentry/react'
|
||||
@ -18,26 +14,21 @@ import * as result from '#/components/Result'
|
||||
|
||||
import * as errorUtils from '#/utilities/error'
|
||||
|
||||
/**
|
||||
* Props for the ErrorBoundary component
|
||||
*/
|
||||
export interface ErrorBoundaryProps {
|
||||
readonly children?: React.ReactNode
|
||||
readonly onError?: errorBoundary.ErrorBoundaryProps['onError']
|
||||
readonly onReset?: errorBoundary.ErrorBoundaryProps['onReset']
|
||||
// Field comes from an external library and we don't want to change the name
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
readonly FallbackComponent?: errorBoundary.ErrorBoundaryProps['FallbackComponent']
|
||||
}
|
||||
// =====================
|
||||
// === ErrorBoundary ===
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* ErrorBoundary component to catch errors in the child components
|
||||
* Shows a fallback UI when there is an error
|
||||
* You can also log the error to an error reporting service
|
||||
*/
|
||||
/** Props for an {@link ErrorBoundary}. */
|
||||
export interface ErrorBoundaryProps
|
||||
extends Readonly<React.PropsWithChildren>,
|
||||
Readonly<Pick<errorBoundary.ErrorBoundaryProps, 'FallbackComponent' | 'onError' | 'onReset'>> {}
|
||||
|
||||
/** Catches errors in the child components
|
||||
* Shows a fallback UI when there is an error.
|
||||
* The error can also be logged. to an error reporting service. */
|
||||
export function ErrorBoundary(props: ErrorBoundaryProps) {
|
||||
const {
|
||||
FallbackComponent = DefaultFallbackComponent,
|
||||
FallbackComponent = ErrorDisplay,
|
||||
onError = () => {},
|
||||
onReset = () => {},
|
||||
...rest
|
||||
@ -62,17 +53,13 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the DefaultFallbackComponent
|
||||
*/
|
||||
export interface FallBackProps extends errorBoundary.FallbackProps {
|
||||
/** Props for a {@link ErrorDisplay}. */
|
||||
export interface ErrorDisplayProps extends errorBoundary.FallbackProps {
|
||||
readonly error: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Default fallback component to show when there is an error
|
||||
*/
|
||||
function DefaultFallbackComponent(props: FallBackProps): React.JSX.Element {
|
||||
/** Default fallback component to show when there is an error. */
|
||||
function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
|
||||
const { resetErrorBoundary, error } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
@ -114,6 +101,4 @@ function DefaultFallbackComponent(props: FallBackProps): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
// Re-exporting the ErrorBoundary component
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export { useErrorBoundary, withErrorBoundary } from 'react-error-boundary'
|
||||
|
@ -1,33 +0,0 @@
|
||||
/** @file An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
|
||||
* target. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// =====================
|
||||
// === FocusableText ===
|
||||
// =====================
|
||||
|
||||
/** Props for a {@link FocusableText}. */
|
||||
export interface FocusableTextProps extends Readonly<aria.TextProps> {}
|
||||
|
||||
/** An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
|
||||
* target. */
|
||||
function FocusableText(props: FocusableTextProps, ref: React.ForwardedRef<HTMLElement>) {
|
||||
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
|
||||
const [props2, ref2] = aria.useContextProps(props, ref, aria.TextContext)
|
||||
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
|
||||
const { focusableProps } = aria.useFocusable(props2, ref2)
|
||||
const { elementType: ElementType = 'span', ...domProps } = props2
|
||||
return (
|
||||
<ElementType
|
||||
className="react-aria-Text"
|
||||
{...aria.mergeProps<FocusableTextProps>()(domProps, focusableProps)}
|
||||
// @ts-expect-error This is required because the dynamic element type is too complex for
|
||||
// TypeScript to typecheck.
|
||||
ref={ref2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(FocusableText)
|
@ -30,8 +30,7 @@ export default function Input(props: InputProps) {
|
||||
{type === 'password' && allowShowingPassword && (
|
||||
<SvgIcon
|
||||
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||
className="cursor-pointer rounded-full"
|
||||
positionClassName="top right"
|
||||
className="right-0 top-0 cursor-pointer rounded-full"
|
||||
onClick={() => {
|
||||
setIsShowingPassword(show => !show)
|
||||
}}
|
||||
|
@ -1,16 +1,18 @@
|
||||
/** @file A dynamic wizard for creating an arbitrary type of Datalink. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Dropdown from '#/components/Dropdown'
|
||||
import Checkbox from '#/components/styled/Checkbox'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
@ -37,7 +39,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { value: valueRaw, setValue: setValueRaw } = 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 { backend } = backendProvider.useStrictBackend()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const [value, setValue] = React.useState(valueRaw)
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
@ -63,7 +65,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
// This value cannot change.
|
||||
return null
|
||||
} else {
|
||||
const children: JSX.Element[] = []
|
||||
const children: React.JSX.Element[] = []
|
||||
if ('type' in schema) {
|
||||
switch (schema.type) {
|
||||
case 'string': {
|
||||
@ -72,15 +74,16 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
if (autocompleteItems == null) {
|
||||
setAutocompleteItems([])
|
||||
void (async () => {
|
||||
const secrets = await backend.listSecrets()
|
||||
const secrets = (await remoteBackend?.listSecrets()) ?? []
|
||||
setAutocompleteItems(secrets.map(secret => secret.path))
|
||||
})()
|
||||
}
|
||||
children.push(
|
||||
<div
|
||||
className={`grow rounded-default border ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'grow rounded-default border',
|
||||
isValid ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<Autocomplete
|
||||
items={autocompleteItems ?? []}
|
||||
@ -107,9 +110,10 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
)}
|
||||
placeholder={getText('enterText')}
|
||||
onChange={event => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
@ -134,9 +138,10 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
)}
|
||||
placeholder={getText('enterNumber')}
|
||||
onChange={event => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
@ -162,9 +167,10 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
)}
|
||||
placeholder={getText('enterInteger')}
|
||||
onChange={event => {
|
||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||
@ -217,11 +223,14 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
>
|
||||
<FocusArea active={isOptional} direction="horizontal">
|
||||
{innerProps => (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isOptional}
|
||||
className={`text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left ${
|
||||
isOptional ? 'hover:bg-hover-bg' : ''
|
||||
}`}
|
||||
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 => {
|
||||
@ -246,13 +255,14 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
{...innerProps}
|
||||
>
|
||||
<aria.Text
|
||||
className={`selectable ${
|
||||
value != null && key in value ? 'active' : ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'selectable',
|
||||
value != null && key in value && 'active'
|
||||
)}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</FocusArea>
|
||||
{value != null && key in value && (
|
||||
@ -341,7 +351,12 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
</FocusArea>
|
||||
)
|
||||
children.push(
|
||||
<div className={`flex flex-col gap-json-schema ${childValue.length === 0 ? 'w-full' : ''}`}>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex flex-col gap-json-schema',
|
||||
childValue.length === 0 && 'w-full'
|
||||
)}
|
||||
>
|
||||
{dropdownTitle != null ? (
|
||||
<div className="flex h-row items-center">
|
||||
<div className="h-text w-json-schema-dropdown-title">{dropdownTitle}</div>
|
||||
|
@ -1,24 +1,11 @@
|
||||
/**
|
||||
* @file
|
||||
* A full-screen loading spinner.
|
||||
*/
|
||||
/** @file A full-screen loading spinner. */
|
||||
import * as twv from 'tailwind-variants'
|
||||
|
||||
import Spinner, * as spinnerModule from '#/components/Spinner'
|
||||
|
||||
/**
|
||||
* Props for a {@link Loader}.
|
||||
*/
|
||||
export interface LoaderProps extends twv.VariantProps<typeof STYLES> {
|
||||
readonly className?: string
|
||||
readonly size?: Size | number
|
||||
readonly state?: spinnerModule.SpinnerState
|
||||
}
|
||||
|
||||
/**
|
||||
* The possible sizes for a {@link Loader}.
|
||||
*/
|
||||
export type Size = 'large' | 'medium' | 'small'
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const STYLES = twv.tv({
|
||||
base: 'animate-appear-delayed flex h-full w-full items-center justify-center duration-200',
|
||||
@ -55,23 +42,39 @@ const SIZE_MAP: Record<Size, number> = {
|
||||
small: 16,
|
||||
}
|
||||
|
||||
/**
|
||||
* A full-screen loading spinner.
|
||||
*/
|
||||
// ============
|
||||
// === Size ===
|
||||
// ============
|
||||
|
||||
/** The possible sizes for a {@link Loader}. */
|
||||
export type Size = 'large' | 'medium' | 'small'
|
||||
|
||||
// ==============
|
||||
// === Loader ===
|
||||
// ==============
|
||||
|
||||
/** Props for a {@link Loader}. */
|
||||
export interface LoaderProps extends twv.VariantProps<typeof STYLES> {
|
||||
readonly className?: string
|
||||
readonly size?: Size | number
|
||||
readonly state?: spinnerModule.SpinnerState
|
||||
}
|
||||
|
||||
/** A full-screen loading spinner. */
|
||||
export function Loader(props: LoaderProps) {
|
||||
const {
|
||||
className,
|
||||
size = 'medium',
|
||||
size: sizeRaw = 'medium',
|
||||
state = spinnerModule.SpinnerState.loadingFast,
|
||||
minHeight = 'full',
|
||||
color = 'primary',
|
||||
} = props
|
||||
|
||||
const sizeValue = typeof size === 'number' ? size : SIZE_MAP[size]
|
||||
const size = typeof sizeRaw === 'number' ? sizeRaw : SIZE_MAP[sizeRaw]
|
||||
|
||||
return (
|
||||
<div className={STYLES({ minHeight, className, color })}>
|
||||
<Spinner size={sizeValue} state={state} className="text-current" />
|
||||
<Spinner size={size} state={state} className="text-current" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,19 +1,23 @@
|
||||
/** @file An entry in a menu. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindVariants from 'tailwind-variants'
|
||||
|
||||
import BlankIcon from 'enso-assets/blank.svg'
|
||||
|
||||
import type * as text from '#/text'
|
||||
|
||||
import type * as inputBindings from '#/configurations/inputBindings'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
|
||||
@ -21,6 +25,16 @@ import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const MENU_ENTRY_VARIANTS = tailwindVariants.tv({
|
||||
base: 'flex h-row grow place-content-between items-center rounded-inherit p-menu-entry text-left selectable group-enabled:active hover:bg-hover-bg disabled:bg-transparent',
|
||||
variants: {
|
||||
variant: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'context-menu': 'px-context-menu-entry-x',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text.TextId>> = {
|
||||
settings: 'settingsShortcut',
|
||||
open: 'openShortcut',
|
||||
@ -66,7 +80,7 @@ const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text
|
||||
// =================
|
||||
|
||||
/** Props for a {@link MenuEntry}. */
|
||||
export interface MenuEntryProps {
|
||||
export interface MenuEntryProps extends tailwindVariants.VariantProps<typeof MENU_ENTRY_VARIANTS> {
|
||||
readonly hidden?: boolean
|
||||
readonly action: inputBindings.DashboardBindingKey
|
||||
/** Overrides the text for the menu entry. */
|
||||
@ -74,16 +88,23 @@ export interface MenuEntryProps {
|
||||
/** When true, the button is not clickable. */
|
||||
readonly isDisabled?: boolean
|
||||
readonly title?: string
|
||||
readonly isContextMenuEntry?: boolean
|
||||
readonly doAction: () => void
|
||||
}
|
||||
|
||||
/** An item in a menu. */
|
||||
export default function MenuEntry(props: MenuEntryProps) {
|
||||
const { hidden = false, action, label, isDisabled = false, title } = props
|
||||
const { isContextMenuEntry = false, doAction } = props
|
||||
const {
|
||||
hidden = false,
|
||||
action,
|
||||
label,
|
||||
isDisabled = false,
|
||||
title,
|
||||
doAction,
|
||||
...variantProps
|
||||
} = props
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
const info = inputBindings.metadata[action]
|
||||
React.useEffect(() => {
|
||||
// This is slower (but more convenient) than registering every shortcut in the context menu
|
||||
@ -98,22 +119,22 @@ export default function MenuEntry(props: MenuEntryProps) {
|
||||
}, [isDisabled, inputBindings, action, doAction])
|
||||
|
||||
return hidden ? null : (
|
||||
<UnstyledButton
|
||||
isDisabled={isDisabled}
|
||||
className="group flex w-full rounded-menu-entry"
|
||||
onPress={doAction}
|
||||
>
|
||||
<div
|
||||
className={`flex h-row grow place-content-between items-center rounded-inherit p-menu-entry text-left selectable group-enabled:active hover:bg-hover-bg disabled:bg-transparent ${
|
||||
isContextMenuEntry ? 'px-context-menu-entry-x' : ''
|
||||
}`}
|
||||
<FocusRing>
|
||||
<aria.Button
|
||||
{...aria.mergeProps<aria.ButtonProps>()(focusChildProps, {
|
||||
isDisabled,
|
||||
className: 'group flex w-full rounded-menu-entry',
|
||||
onPress: doAction,
|
||||
})}
|
||||
>
|
||||
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
|
||||
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
|
||||
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
|
||||
<div className={MENU_ENTRY_VARIANTS(variantProps)}>
|
||||
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
|
||||
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
|
||||
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
|
||||
</div>
|
||||
<KeyboardShortcut action={action} />
|
||||
</div>
|
||||
<KeyboardShortcut action={action} />
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
@ -1,16 +1,31 @@
|
||||
/** @file Base modal component that provides the full-screen element that blocks mouse events. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindVariants from 'tailwind-variants'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import FocusRoot from '#/components/styled/FocusRoot'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const MODAL_VARIANTS = tailwindVariants.tv({
|
||||
base: 'inset z-1',
|
||||
variants: {
|
||||
centered: { true: 'size-screen fixed grid place-items-center' },
|
||||
},
|
||||
})
|
||||
|
||||
// =================
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link Modal}. */
|
||||
export interface ModalProps extends Readonly<React.PropsWithChildren> {
|
||||
export interface ModalProps
|
||||
extends Readonly<React.PropsWithChildren>,
|
||||
Readonly<tailwindVariants.VariantProps<typeof MODAL_VARIANTS>> {
|
||||
/** If `true`, disables `data-testid` because it will not be visible. */
|
||||
readonly hidden?: boolean
|
||||
// This can intentionally be `undefined`, in order to simplify consumers of this component.
|
||||
@ -26,8 +41,7 @@ export interface ModalProps extends Readonly<React.PropsWithChildren> {
|
||||
* background transparency can be enabled with Tailwind's `bg-opacity` classes, like
|
||||
* `className="bg-opacity-50"`. */
|
||||
export default function Modal(props: ModalProps) {
|
||||
const { hidden = false, children, centered = false, style, className } = props
|
||||
const { onClick, onContextMenu } = props
|
||||
const { hidden = false, children, style, onClick, onContextMenu, ...variantProps } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
@ -36,9 +50,7 @@ export default function Modal(props: ModalProps) {
|
||||
<div
|
||||
{...(!hidden ? { 'data-testid': 'modal-background' } : {})}
|
||||
style={style}
|
||||
className={`inset z-1 ${centered ? 'size-screen fixed grid place-items-center' : ''} ${
|
||||
className ?? ''
|
||||
}`}
|
||||
className={MODAL_VARIANTS(variantProps)}
|
||||
onClick={
|
||||
onClick ??
|
||||
(event => {
|
||||
|
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @file Portal component
|
||||
* Renders its children outside the current DOM hierarchy
|
||||
*/
|
||||
|
||||
/** @file Render elements outside the current DOM hierarchy. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactDom from 'react-dom'
|
||||
@ -10,8 +6,7 @@ import * as reactDom from 'react-dom'
|
||||
import type * as types from './types'
|
||||
import * as usePortal from './usePortal'
|
||||
|
||||
/**
|
||||
* This component renders its children outside the current DOM hierarchy.
|
||||
/** This component renders its children outside the current DOM hierarchy.
|
||||
*
|
||||
* React [doesn't support](https://github.com/facebook/react/issues/13097) portal API in SSR, so, if you want to
|
||||
* render a Portal in SSR, use prop `disabled`.
|
||||
|
@ -1,44 +1,58 @@
|
||||
/**
|
||||
* @file A component for displaying the result of an operation.
|
||||
*/
|
||||
/** @file Display the result of an operation. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tw from 'tailwind-merge'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import Success from 'enso-assets/check_mark.svg'
|
||||
import Error from 'enso-assets/cross.svg'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as aria from './aria'
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/**
|
||||
* The possible statuses for a result.
|
||||
*/
|
||||
const STATUS_ICON_MAP: Readonly<Record<Status, StatusIcon>> = {
|
||||
error: { icon: Error, colorClassName: 'text-red-500', bgClassName: 'bg-red-500' },
|
||||
success: { icon: Success, colorClassName: 'text-green-500', bgClassName: 'bg-green' },
|
||||
}
|
||||
|
||||
// ==============
|
||||
// === Status ===
|
||||
// ==============
|
||||
|
||||
/** Possible statuses for a result. */
|
||||
export type Status = 'error' | 'success'
|
||||
|
||||
/**
|
||||
* The props for the Result component.
|
||||
*/
|
||||
// ==================
|
||||
// === StatusIcon ===
|
||||
// ==================
|
||||
|
||||
/** The corresponding icon and color for each status. */
|
||||
interface StatusIcon {
|
||||
readonly icon: string
|
||||
readonly colorClassName: string
|
||||
readonly bgClassName: string
|
||||
}
|
||||
|
||||
// ==============
|
||||
// === Result ===
|
||||
// ==============
|
||||
|
||||
/** Props for a {@link Result}. */
|
||||
export interface ResultProps extends React.PropsWithChildren {
|
||||
/**
|
||||
* The class name for the component.
|
||||
*/
|
||||
readonly className?: string
|
||||
readonly title?: React.JSX.Element | string
|
||||
readonly subtitle?: React.JSX.Element | string
|
||||
/**
|
||||
* The status of the result.
|
||||
* @default 'success'
|
||||
*/
|
||||
/** The status of the result.
|
||||
* @default 'success' */
|
||||
readonly status?: React.ReactElement | Status
|
||||
readonly icon?: string | false
|
||||
readonly testId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A component for displaying the result of an operation.
|
||||
*/
|
||||
/** Display the result of an operation. */
|
||||
export function Result(props: ResultProps) {
|
||||
const {
|
||||
title,
|
||||
@ -55,7 +69,7 @@ export function Result(props: ResultProps) {
|
||||
|
||||
return (
|
||||
<section
|
||||
className={tw.twMerge(
|
||||
className={tailwindMerge.twMerge(
|
||||
'm-auto flex flex-col items-center justify-center px-6 py-4 text-center',
|
||||
className
|
||||
)}
|
||||
@ -65,14 +79,14 @@ export function Result(props: ResultProps) {
|
||||
<>
|
||||
{statusIcon != null ? (
|
||||
<div
|
||||
className={tw.twJoin(
|
||||
className={tailwindMerge.twMerge(
|
||||
'mb-4 flex rounded-full bg-opacity-25 p-1 text-green',
|
||||
statusIcon.bgClassName
|
||||
)}
|
||||
>
|
||||
<SvgMask
|
||||
src={icon ?? statusIcon.icon}
|
||||
className={tw.twJoin('h-16 w-16 flex-none', statusIcon.colorClassName)}
|
||||
className={tailwindMerge.twMerge('h-16 w-16 flex-none', statusIcon.colorClassName)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -100,17 +114,3 @@ export function Result(props: ResultProps) {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon and color for each status.
|
||||
*/
|
||||
interface StatusIcon {
|
||||
readonly icon: string
|
||||
readonly colorClassName: string
|
||||
readonly bgClassName: string
|
||||
}
|
||||
|
||||
const STATUS_ICON_MAP: Record<Status, StatusIcon> = {
|
||||
error: { icon: Error, colorClassName: 'text-red-500', bgClassName: 'bg-red-500' },
|
||||
success: { icon: Success, colorClassName: 'text-green-500', bgClassName: 'bg-green' },
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A selection brush to indicate the area being selected by the mouse drag action. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as animationHooks from '#/hooks/animationHooks'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
@ -180,9 +182,10 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
className={`pointer-events-none fixed z-1 box-content rounded-selection-brush border-transparent bg-selection-brush transition-border-margin ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none fixed z-1 box-content rounded-selection-brush border-transparent bg-selection-brush transition-border-margin',
|
||||
hidden ? 'm border-0' : '-m-selection-brush-border border-selection-brush'
|
||||
}`}
|
||||
)}
|
||||
style={brushStyle}
|
||||
/>
|
||||
</Portal>
|
||||
|
@ -2,6 +2,8 @@
|
||||
* classes. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
// ===============
|
||||
// === Spinner ===
|
||||
// ===============
|
||||
@ -53,7 +55,10 @@ export default function Spinner(props: SpinnerProps) {
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={3}
|
||||
className={`pointer-events-none origin-center animate-spin-ease transition-stroke-dasharray ${SPINNER_CSS_CLASSES[state]}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none origin-center !animate-spin-ease transition-stroke-dasharray [transition-duration:var(--spinner-slow-transition-duration)]',
|
||||
SPINNER_CSS_CLASSES[state]
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
@ -2,8 +2,8 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
// ====================
|
||||
// === SubmitButton ===
|
||||
@ -22,13 +22,15 @@ export default function SubmitButton(props: SubmitButtonProps) {
|
||||
const { isDisabled = false, text, icon, onPress } = props
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isDisabled}
|
||||
className={`flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth selectable enabled:active hover:bg-blue-700 focus:bg-blue-700`}
|
||||
className="flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth selectable enabled:active hover:bg-blue-700 focus:bg-blue-700"
|
||||
onPress={onPress}
|
||||
>
|
||||
{text}
|
||||
<SvgMask src={icon} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file Styled wrapper around SVG images. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
// ===============
|
||||
@ -11,16 +13,18 @@ import SvgMask from '#/components/SvgMask'
|
||||
export interface SvgIconProps {
|
||||
readonly src: string
|
||||
readonly className?: string
|
||||
readonly positionClassName?: string
|
||||
readonly onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
/** A fixed-size container for a SVG image. */
|
||||
export default function SvgIcon(props: SvgIconProps) {
|
||||
const { src, className = '', positionClassName = 'top left', onClick } = props
|
||||
const { src, className, onClick } = props
|
||||
return (
|
||||
<div
|
||||
className={`absolute inline-flex h-full w-auth-icon-container items-center justify-center text-gray-400 ${className} ${positionClassName}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'absolute left top inline-flex h-full w-auth-icon-container items-center justify-center text-gray-400',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<SvgMask src={src} />
|
||||
|
@ -52,7 +52,7 @@ export default function SvgMask(props: SvgMaskProps) {
|
||||
className={tailwindMerge.twMerge('inline-block h-max w-max', className)}
|
||||
>
|
||||
{/* This is required for this component to have the right size. */}
|
||||
<img alt={alt} src={src} className="transparent" draggable={false} />
|
||||
<img alt={alt} src={src} className="pointer-events-none opacity-0" draggable={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,61 +0,0 @@
|
||||
/** @file An unstyled button with a focus ring and focus movement behavior. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as focusRing from '#/components/styled/FocusRing'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
// ======================
|
||||
// === UnstyledButton ===
|
||||
// ======================
|
||||
|
||||
/** Props for a {@link UnstyledButton}. */
|
||||
export interface UnstyledButtonProps extends Readonly<React.PropsWithChildren> {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
readonly 'aria-label'?: string
|
||||
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
|
||||
readonly tooltip?: React.ReactNode
|
||||
readonly focusRingPlacement?: focusRing.FocusRingPlacement
|
||||
readonly autoFocus?: boolean
|
||||
/** When `true`, the button is not clickable. */
|
||||
readonly isDisabled?: boolean
|
||||
readonly className?: string
|
||||
readonly style?: React.CSSProperties
|
||||
readonly onPress?: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
/** An unstyled button with a focus ring and focus movement behavior. */
|
||||
function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const { tooltip, focusRingPlacement, children, ...buttonProps } = props
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
|
||||
const tooltipElement = tooltip === false ? null : tooltip ?? buttonProps['aria-label']
|
||||
|
||||
const button = (
|
||||
<FocusRing {...(focusRingPlacement == null ? {} : { placement: focusRingPlacement })}>
|
||||
<aria.Button
|
||||
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
|
||||
buttonProps,
|
||||
focusChildProps,
|
||||
{ ref }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)
|
||||
|
||||
return tooltipElement == null ? (
|
||||
button
|
||||
) : (
|
||||
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
|
||||
{button}
|
||||
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
|
||||
</ariaComponents.TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(UnstyledButton)
|
@ -1,18 +1,33 @@
|
||||
/** @file A toolbar for displaying asset information. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindVariants from 'tailwind-variants'
|
||||
|
||||
import SettingsIcon from 'enso-assets/settings.svg'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import Button from '#/components/styled/Button'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const ASSET_INFO_BAR_VARIANTS = tailwindVariants.tv({
|
||||
base: 'pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x',
|
||||
variants: {
|
||||
hidden: { true: 'invisible' },
|
||||
},
|
||||
})
|
||||
|
||||
// ====================
|
||||
// === AssetInfoBar ===
|
||||
// ====================
|
||||
|
||||
/** Props for an {@link AssetInfoBar}. */
|
||||
export interface AssetInfoBarProps {
|
||||
export interface AssetInfoBarProps
|
||||
extends tailwindVariants.VariantProps<typeof ASSET_INFO_BAR_VARIANTS> {
|
||||
/** When `true`, the element occupies space in the layout but is not visible.
|
||||
* Defaults to `false`. */
|
||||
readonly invisible?: boolean
|
||||
@ -21,22 +36,14 @@ export interface AssetInfoBarProps {
|
||||
}
|
||||
|
||||
/** A menubar for displaying asset information. */
|
||||
// This parameter will be used in the future.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export default function AssetInfoBar(props: AssetInfoBarProps) {
|
||||
const { invisible = false, isAssetPanelEnabled, setIsAssetPanelEnabled } = props
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { invisible = false, isAssetPanelEnabled, setIsAssetPanelEnabled, ...variantProps } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
<FocusArea active={!invisible} direction="horizontal">
|
||||
{innerProps => (
|
||||
<div
|
||||
className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${
|
||||
backend.type === backendModule.BackendType.remote ? '' : 'invisible'
|
||||
}`}
|
||||
{...innerProps}
|
||||
>
|
||||
<div className={ASSET_INFO_BAR_VARIANTS(variantProps)} {...innerProps}>
|
||||
<Button
|
||||
alt={isAssetPanelEnabled ? getText('closeAssetPanel') : getText('openAssetPanel')}
|
||||
active={isAssetPanelEnabled}
|
||||
|
@ -1,14 +1,16 @@
|
||||
/** @file A table row for an arbitrary asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import BlankIcon from 'enso-assets/blank.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -94,12 +96,11 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
|
||||
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
|
||||
const { grabKeyboardFocus } = props
|
||||
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent, nodeMap } = state
|
||||
const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
const { backend, visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId } = state
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
@ -123,9 +124,28 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
: outerVisibility
|
||||
const hidden = hiddenRaw || visibility === Visibility.hidden
|
||||
|
||||
const copyAssetMutation = backendHooks.useBackendMutation(backend, 'copyAsset')
|
||||
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
|
||||
const deleteAssetMutation = backendHooks.useBackendMutation(backend, 'deleteAsset')
|
||||
const undoDeleteAssetMutation = backendHooks.useBackendMutation(backend, 'undoDeleteAsset')
|
||||
const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject')
|
||||
const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject')
|
||||
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
|
||||
const getFileDetailsMutation = backendHooks.useBackendMutation(backend, 'getFileDetails')
|
||||
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
|
||||
const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission')
|
||||
const associateTagMutation = backendHooks.useBackendMutation(backend, 'associateTag')
|
||||
const copyAssetMutate = copyAssetMutation.mutateAsync
|
||||
const updateAssetMutate = updateAssetMutation.mutateAsync
|
||||
const deleteAssetMutate = deleteAssetMutation.mutateAsync
|
||||
const undoDeleteAssetMutate = undoDeleteAssetMutation.mutateAsync
|
||||
const openProjectMutate = openProjectMutation.mutateAsync
|
||||
const closeProjectMutate = closeProjectMutation.mutateAsync
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(rawItem)
|
||||
}, [rawItem])
|
||||
|
||||
React.useEffect(() => {
|
||||
// Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to avoid
|
||||
// re-rendering the parent.
|
||||
@ -158,12 +178,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
})
|
||||
)
|
||||
newParentId ??= rootDirectoryId
|
||||
const copiedAsset = await backend.copyAsset(
|
||||
const copiedAsset = await copyAssetMutate([
|
||||
asset.id,
|
||||
newParentId,
|
||||
asset.title,
|
||||
nodeMap.current.get(newParentId)?.item.title ?? '(unknown)'
|
||||
)
|
||||
nodeMap.current.get(newParentId)?.item.title ?? '(unknown)',
|
||||
])
|
||||
setAsset(
|
||||
// This is SAFE, as the type of the copied asset is guaranteed to be the same
|
||||
// as the type of the original asset.
|
||||
@ -177,12 +197,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
},
|
||||
[
|
||||
backend,
|
||||
user,
|
||||
rootDirectoryId,
|
||||
asset,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
/* should never change */ copyAssetMutate,
|
||||
/* should never change */ nodeMap,
|
||||
/* should never change */ setAsset,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
@ -256,15 +276,15 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
item: newAsset,
|
||||
})
|
||||
setAsset(newAsset)
|
||||
await backend.updateAsset(
|
||||
await updateAssetMutate([
|
||||
asset.id,
|
||||
{
|
||||
parentDirectoryId: newParentId ?? rootDirectoryId,
|
||||
description: null,
|
||||
...(asset.projectState?.path == null ? {} : { projectPath: asset.projectState.path }),
|
||||
},
|
||||
asset.title
|
||||
)
|
||||
asset.title,
|
||||
])
|
||||
} catch (error) {
|
||||
toastAndLog('moveAssetError', error, asset.title)
|
||||
setAsset(
|
||||
@ -291,13 +311,13 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
},
|
||||
[
|
||||
isCloud,
|
||||
backend,
|
||||
asset,
|
||||
rootDirectoryId,
|
||||
item.directoryId,
|
||||
item.directoryKey,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
/* should never change */ updateAssetMutate,
|
||||
/* should never change */ setAsset,
|
||||
/* should never change */ dispatchAssetListEvent,
|
||||
]
|
||||
@ -305,12 +325,13 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSoleSelected) {
|
||||
setAssetPanelProps({ item, setItem })
|
||||
setAssetPanelProps({ backend, item, setItem })
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
}, [
|
||||
item,
|
||||
isSoleSelected,
|
||||
/* should never change */ backend,
|
||||
/* should never change */ setAssetPanelProps,
|
||||
/* should never change */ setIsAssetPanelTemporarilyVisible,
|
||||
])
|
||||
@ -337,19 +358,19 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
asset.projectState.type !== backendModule.ProjectState.placeholder &&
|
||||
asset.projectState.type !== backendModule.ProjectState.closed
|
||||
) {
|
||||
await backend.openProject(asset.id, null, asset.title)
|
||||
await openProjectMutate([asset.id, null, asset.title])
|
||||
}
|
||||
try {
|
||||
await backend.closeProject(asset.id, asset.title)
|
||||
await closeProjectMutate([asset.id, asset.title])
|
||||
} catch {
|
||||
// Ignored. The project was already closed.
|
||||
}
|
||||
}
|
||||
await backend.deleteAsset(
|
||||
await deleteAssetMutate([
|
||||
asset.id,
|
||||
{ force: forever, parentId: asset.parentId },
|
||||
asset.title
|
||||
)
|
||||
asset.title,
|
||||
])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
@ -357,9 +378,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
},
|
||||
[
|
||||
backend,
|
||||
backend.type,
|
||||
dispatchAssetListEvent,
|
||||
asset,
|
||||
/* should never change */ openProjectMutate,
|
||||
/* should never change */ closeProjectMutate,
|
||||
/* should never change */ deleteAssetMutate,
|
||||
/* should never change */ item.key,
|
||||
/* should never change */ toastAndLog,
|
||||
]
|
||||
@ -369,13 +393,19 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
// Visually, the asset is deleted from the Trash view.
|
||||
setInsertionVisibility(Visibility.hidden)
|
||||
try {
|
||||
await backend.undoDeleteAsset(asset.id, asset.title)
|
||||
await undoDeleteAssetMutate([asset.id, asset.title])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
toastAndLog('restoreAssetError', error, asset.title)
|
||||
}
|
||||
}, [backend, dispatchAssetListEvent, asset, toastAndLog, /* should never change */ item.key])
|
||||
}, [
|
||||
dispatchAssetListEvent,
|
||||
asset,
|
||||
toastAndLog,
|
||||
/* should never change */ undoDeleteAssetMutate,
|
||||
/* should never change */ item.key,
|
||||
])
|
||||
|
||||
const doTriggerDescriptionEdit = React.useCallback(() => {
|
||||
setModal(
|
||||
@ -479,11 +509,11 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
switch (asset.type) {
|
||||
case backendModule.AssetType.project: {
|
||||
try {
|
||||
const details = await backend.getProjectDetails(
|
||||
const details = await getProjectDetailsMutation.mutateAsync([
|
||||
asset.id,
|
||||
asset.parentId,
|
||||
asset.title
|
||||
)
|
||||
asset.title,
|
||||
])
|
||||
if (details.url != null) {
|
||||
download.download(details.url, asset.title)
|
||||
} else {
|
||||
@ -497,7 +527,10 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
case backendModule.AssetType.file: {
|
||||
try {
|
||||
const details = await backend.getFileDetails(asset.id, asset.title)
|
||||
const details = await getFileDetailsMutation.mutateAsync([
|
||||
asset.id,
|
||||
asset.title,
|
||||
])
|
||||
if (details.url != null) {
|
||||
download.download(details.url, asset.title)
|
||||
} else {
|
||||
@ -511,7 +544,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
case backendModule.AssetType.datalink: {
|
||||
try {
|
||||
const value = await backend.getDatalink(asset.id, asset.title)
|
||||
const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title])
|
||||
const fileName = `${asset.title}.datalink`
|
||||
download.download(
|
||||
URL.createObjectURL(
|
||||
@ -548,11 +581,13 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
if (event.id === asset.id && user != null && user.isEnabled) {
|
||||
setInsertionVisibility(Visibility.hidden)
|
||||
try {
|
||||
await backend.createPermission({
|
||||
action: null,
|
||||
resourceId: asset.id,
|
||||
actorsIds: [user.userId],
|
||||
})
|
||||
await createPermissionMutation.mutateAsync([
|
||||
{
|
||||
action: null,
|
||||
resourceId: asset.id,
|
||||
actorsIds: [user.userId],
|
||||
},
|
||||
])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
@ -604,7 +639,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
]
|
||||
setAsset(object.merger({ labels: newLabels }))
|
||||
try {
|
||||
await backend.associateTag(asset.id, newLabels, asset.title)
|
||||
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
|
||||
} catch (error) {
|
||||
setAsset(object.merger({ labels }))
|
||||
toastAndLog(null, error)
|
||||
@ -627,7 +662,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const newLabels = labels.filter(label => !event.labelNames.has(label))
|
||||
setAsset(object.merger({ labels: newLabels }))
|
||||
try {
|
||||
await backend.associateTag(asset.id, newLabels, asset.title)
|
||||
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
|
||||
} catch (error) {
|
||||
setAsset(object.merger({ labels }))
|
||||
toastAndLog(null, error)
|
||||
@ -722,7 +757,11 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
element.focus()
|
||||
}
|
||||
}}
|
||||
className={`h-row rounded-full transition-all ease-in-out rounded-rows-child ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-row rounded-full transition-all ease-in-out rounded-rows-child',
|
||||
visibility,
|
||||
(isDraggedOver || selected) && 'selected'
|
||||
)}
|
||||
onClick={event => {
|
||||
unsetModal()
|
||||
onClick(innerProps, event)
|
||||
@ -905,11 +944,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
case backendModule.AssetType.specialLoading: {
|
||||
return hidden ? null : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
|
||||
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
|
||||
<div
|
||||
className={`flex h-row w-container justify-center rounded-full rounded-rows-child ${indent.indentClass(
|
||||
item.depth
|
||||
)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-row w-container justify-center rounded-full rounded-rows-child',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
>
|
||||
<StatelessSpinner size={24} state={statelessSpinner.SpinnerState.loadingMedium} />
|
||||
</div>
|
||||
@ -920,9 +960,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
case backendModule.AssetType.specialEmpty: {
|
||||
return hidden ? null : (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
|
||||
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
|
||||
<div
|
||||
className={`flex h-row items-center rounded-full rounded-rows-child ${indent.indentClass(item.depth)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-row items-center rounded-full rounded-rows-child',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
>
|
||||
<img src={BlankIcon} />
|
||||
<aria.Text className="px-name-column-x placeholder">
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file Displays a few details of an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import BreadcrumbArrowIcon from 'enso-assets/breadcrumb_arrow.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -12,6 +14,10 @@ import type * as backend from '#/services/Backend'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
// ====================
|
||||
// === AssetSummary ===
|
||||
// ====================
|
||||
|
||||
/** Props for an {@link AssetSummary}. */
|
||||
export interface AssetSummaryProps {
|
||||
readonly asset: backend.AnyAsset
|
||||
@ -27,7 +33,10 @@ export default function AssetSummary(props: AssetSummaryProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-row items-center gap-icon-with-text rounded-default bg-frame px-button-x ${className}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex min-h-row items-center gap-icon-with-text rounded-default bg-frame px-button-x',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="grid size-icon place-items-center">
|
||||
<AssetIcon asset={asset} />
|
||||
|
@ -1,13 +1,15 @@
|
||||
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import DatalinkIcon from 'enso-assets/datalink.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
@ -35,9 +37,8 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { assetEvents, dispatchAssetListEvent, setIsAssetPanelTemporarilyVisible } = state
|
||||
const { backend, assetEvents, dispatchAssetListEvent, setIsAssetPanelTemporarilyVisible } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.datalink) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -46,6 +47,8 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const asset = item.item
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
setRowState(object.merger({ isEditingName }))
|
||||
@ -97,12 +100,14 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
} else {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const { id } = await backend.createDatalink({
|
||||
parentDirectoryId: asset.parentId,
|
||||
datalinkId: null,
|
||||
name: asset.title,
|
||||
value: event.value,
|
||||
})
|
||||
const { id } = await createDatalinkMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: asset.parentId,
|
||||
datalinkId: null,
|
||||
name: asset.title,
|
||||
value: event.value,
|
||||
},
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merger({ id }))
|
||||
} catch (error) {
|
||||
@ -126,9 +131,10 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y ${indent.indentClass(
|
||||
item.depth
|
||||
)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
onKeyDown={event => {
|
||||
if (rowState.isEditingName && event.key === 'Enter') {
|
||||
event.stopPropagation()
|
||||
|
@ -1,14 +1,16 @@
|
||||
/** @file The icon and name of a {@link backendModule.DirectoryAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
||||
import FolderIcon from 'enso-assets/folder.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -40,10 +42,9 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { selectedKeys, assetEvents, dispatchAssetListEvent, nodeMap } = state
|
||||
const { backend, selectedKeys, assetEvents, dispatchAssetListEvent, nodeMap } = state
|
||||
const { doToggleDirectoryExpansion } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.directory) {
|
||||
@ -53,6 +54,9 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const asset = item.item
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
const createDirectoryMutation = backendHooks.useBackendMutation(backend, 'createDirectory')
|
||||
const updateDirectoryMutation = backendHooks.useBackendMutation(backend, 'updateDirectory')
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
setRowState(object.merger({ isEditingName }))
|
||||
@ -68,7 +72,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const oldTitle = asset.title
|
||||
setAsset(object.merger({ title: newTitle }))
|
||||
try {
|
||||
await backend.updateDirectory(asset.id, { title: newTitle }, asset.title)
|
||||
await updateDirectoryMutation.mutateAsync([asset.id, { title: newTitle }, asset.title])
|
||||
} catch (error) {
|
||||
toastAndLog('renameFolderError', error)
|
||||
setAsset(object.merger({ title: oldTitle }))
|
||||
@ -112,10 +116,12 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
if (item.key === event.placeholderId) {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const createdDirectory = await backend.createDirectory({
|
||||
parentId: asset.parentId,
|
||||
title: asset.title,
|
||||
})
|
||||
const createdDirectory = await createDirectoryMutation.mutateAsync([
|
||||
{
|
||||
parentId: asset.parentId,
|
||||
title: asset.title,
|
||||
},
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merge(asset, createdDirectory))
|
||||
} catch (error) {
|
||||
@ -138,9 +144,10 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y ${indent.indentClass(
|
||||
item.depth
|
||||
)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'group flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
onKeyDown={event => {
|
||||
if (rowState.isEditingName && event.key === 'Enter') {
|
||||
event.stopPropagation()
|
||||
@ -159,23 +166,28 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
image={FolderArrowIcon}
|
||||
alt={item.children == null ? getText('expand') : getText('collapse')}
|
||||
className={`m-name-column-icon hidden size-icon cursor-pointer transition-transform duration-arrow group-hover:inline-block ${
|
||||
item.children != null ? 'rotate-90' : ''
|
||||
}`}
|
||||
onPress={() => {
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
}}
|
||||
/>
|
||||
<div className="m-name-column-icon hidden group-hover:inline-block">
|
||||
<Button
|
||||
image={FolderArrowIcon}
|
||||
alt={item.children == null ? getText('expand') : getText('collapse')}
|
||||
tooltipPlacement="left"
|
||||
className={tailwindMerge.twMerge(
|
||||
'size-icon cursor-pointer transition-transform duration-arrow',
|
||||
item.children != null && 'rotate-90'
|
||||
)}
|
||||
onPress={() => {
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SvgMask src={FolderIcon} className="m-name-column-icon size-icon group-hover:hidden" />
|
||||
<EditableSpan
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className={`text grow cursor-pointer bg-transparent ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'text grow cursor-pointer bg-transparent',
|
||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||
}`}
|
||||
)}
|
||||
checkSubmittable={newTitle =>
|
||||
newTitle !== item.item.title &&
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
|
@ -1,11 +1,13 @@
|
||||
/** @file The icon and name of a {@link backendModule.FileAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
@ -36,9 +38,8 @@ export interface FileNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { nodeMap, assetEvents, dispatchAssetListEvent } = state
|
||||
const { backend, nodeMap, assetEvents, dispatchAssetListEvent } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.file) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -48,6 +49,9 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
|
||||
const updateFileMutation = backendHooks.useBackendMutation(backend, 'updateFile')
|
||||
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile')
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
setRowState(object.merger({ isEditingName }))
|
||||
@ -66,7 +70,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const oldTitle = asset.title
|
||||
setAsset(object.merger({ title: newTitle }))
|
||||
try {
|
||||
await backend.updateFile(asset.id, { title: newTitle }, asset.title)
|
||||
await updateFileMutation.mutateAsync([asset.id, { title: newTitle }, asset.title])
|
||||
} catch (error) {
|
||||
toastAndLog('renameFolderError', error)
|
||||
setAsset(object.merger({ title: oldTitle }))
|
||||
@ -112,10 +116,10 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const fileId = event.type !== AssetEventType.updateFiles ? null : asset.id
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const createdFile = await backend.uploadFile(
|
||||
const createdFile = await uploadFileMutation.mutateAsync([
|
||||
{ fileId, fileName: asset.title, parentDirectoryId: asset.parentId },
|
||||
file
|
||||
)
|
||||
file,
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merge(asset, { id: createdFile.id }))
|
||||
} catch (error) {
|
||||
@ -147,9 +151,10 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y ${indent.indentClass(
|
||||
item.depth
|
||||
)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
onKeyDown={event => {
|
||||
if (rowState.isEditingName && event.key === 'Enter') {
|
||||
event.stopPropagation()
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A visual representation of a keyboard shortcut. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CommandKeyIcon from 'enso-assets/command_key.svg'
|
||||
import CtrlKeyIcon from 'enso-assets/ctrl_key.svg'
|
||||
import OptionKeyIcon from 'enso-assets/option_key.svg'
|
||||
@ -123,10 +125,11 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
.sort(inputBindingsModule.compareModifiers)
|
||||
.map(inputBindingsModule.toModifierKey)
|
||||
return (
|
||||
<aria.Keyboard
|
||||
className={`flex h-text items-center ${
|
||||
<aria.Text
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-text items-center',
|
||||
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{modifiers.map(
|
||||
modifier =>
|
||||
@ -139,7 +142,7 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
<aria.Text className="text">
|
||||
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
|
||||
</aria.Text>
|
||||
</aria.Keyboard>
|
||||
</aria.Text>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file An label that can be applied to an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
@ -48,7 +50,10 @@ export default function Label(props: InternalLabelProps) {
|
||||
return (
|
||||
<FocusRing within placement="after">
|
||||
<div
|
||||
className={`relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-inherit ${negated ? 'after:!outline-offset-0' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-inherit',
|
||||
negated && 'after:!outline-offset-0'
|
||||
)}
|
||||
>
|
||||
{/* An `aria.Button` MUST NOT be used here, as it breaks dragging. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
@ -58,11 +63,13 @@ export default function Label(props: InternalLabelProps) {
|
||||
draggable={draggable}
|
||||
title={title}
|
||||
disabled={isDisabled}
|
||||
className={`focus-child selectable ${
|
||||
active ? 'active' : ''
|
||||
} relative flex h-text items-center whitespace-nowrap rounded-inherit px-label-x transition-all after:pointer-events-none after:absolute after:inset after:rounded-full ${
|
||||
negated ? 'after:border-2 after:border-delete' : ''
|
||||
} ${className} ${textClass}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child relative flex h-text items-center whitespace-nowrap rounded-inherit px-label-x transition-all selectable after:pointer-events-none after:absolute after:inset after:rounded-full',
|
||||
active && 'active',
|
||||
negated && 'after:border-2 after:border-delete',
|
||||
className,
|
||||
textClass
|
||||
)}
|
||||
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
@ -3,9 +3,9 @@ import * as React from 'react'
|
||||
|
||||
import type * as text from '#/text'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
@ -13,6 +13,7 @@ import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
@ -36,6 +37,7 @@ const ASSET_TYPE_TO_TEXT_ID: Readonly<Record<backendModule.AssetType, text.TextI
|
||||
|
||||
/** Props for a {@link Permission}. */
|
||||
export interface PermissionProps {
|
||||
readonly backend: Backend
|
||||
readonly asset: backendModule.Asset
|
||||
readonly self: backendModule.UserPermission
|
||||
readonly isOnlyOwner: boolean
|
||||
@ -46,9 +48,8 @@ export interface PermissionProps {
|
||||
|
||||
/** A user or group, and their permissions for a specific asset. */
|
||||
export default function Permission(props: PermissionProps) {
|
||||
const { asset, self, isOnlyOwner, doDelete } = props
|
||||
const { backend, asset, self, isOnlyOwner, doDelete } = props
|
||||
const { permission: initialPermission, setPermission: outerSetPermission } = props
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [permission, setPermission] = React.useState(initialPermission)
|
||||
@ -56,6 +57,8 @@ export default function Permission(props: PermissionProps) {
|
||||
const isDisabled = isOnlyOwner && permissionId === self.user.userId
|
||||
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
||||
|
||||
const createPermissionMutation = backendHooks.useBackendMutation(backend, 'createPermission')
|
||||
|
||||
React.useEffect(() => {
|
||||
setPermission(initialPermission)
|
||||
}, [initialPermission])
|
||||
@ -64,11 +67,13 @@ export default function Permission(props: PermissionProps) {
|
||||
try {
|
||||
setPermission(newPermission)
|
||||
outerSetPermission(newPermission)
|
||||
await backend.createPermission({
|
||||
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
|
||||
resourceId: asset.id,
|
||||
action: newPermission.permission,
|
||||
})
|
||||
await createPermissionMutation.mutateAsync([
|
||||
{
|
||||
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
|
||||
resourceId: asset.id,
|
||||
action: newPermission.permission,
|
||||
},
|
||||
])
|
||||
} catch (error) {
|
||||
setPermission(permission)
|
||||
outerSetPermission(permission)
|
||||
|
@ -1,8 +1,10 @@
|
||||
/** @file Colored border around icons and text indicating permissions. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import type * as aria from '#/components/aria'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import * as permissionsModule from '#/utilities/permissions'
|
||||
|
||||
@ -27,24 +29,31 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
||||
case permissionsModule.Permission.admin:
|
||||
case permissionsModule.Permission.edit: {
|
||||
return (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!onPress}
|
||||
className={`${
|
||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||
} inline-block h-text whitespace-nowrap rounded-full px-permission-mini-button-x py-permission-mini-button-y ${
|
||||
className ?? ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'inline-block h-text whitespace-nowrap rounded-full px-permission-mini-button-x py-permission-mini-button-y',
|
||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type],
|
||||
className
|
||||
)}
|
||||
onPress={onPress ?? (() => {})}
|
||||
>
|
||||
{children}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
case permissionsModule.Permission.read:
|
||||
case permissionsModule.Permission.view: {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={`relative inline-block whitespace-nowrap rounded-full ${className ?? ''}`}
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative inline-block whitespace-nowrap rounded-full',
|
||||
className
|
||||
)}
|
||||
onPress={onPress ?? (() => {})}
|
||||
>
|
||||
{permission.docs && (
|
||||
@ -54,13 +63,14 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
||||
<div className="absolute size-full rounded-full border-2 border-permission-exec clip-path-bottom" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
className={tailwindMerge.twMerge(
|
||||
'm-permission-with-border h-text rounded-full px-permission-mini-button-x py-permission-mini-button-y',
|
||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||
} m-permission-with-border h-text rounded-full px-permission-mini-button-x py-permission-mini-button-y`}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
/** @file A selector for all possible permissions. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
|
||||
import Modal from '#/components/Modal'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
@ -59,7 +60,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
const { onChange, doDelete } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const [action, setActionRaw] = React.useState(actionRaw)
|
||||
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
|
||||
const [TheChild, setTheChild] = React.useState<(() => React.JSX.Element) | null>()
|
||||
const permissionSelectorButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
|
||||
|
||||
@ -130,29 +131,40 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
let permissionDisplay: JSX.Element
|
||||
let permissionDisplay: React.JSX.Element
|
||||
|
||||
switch (permission.type) {
|
||||
case permissionsModule.Permission.read:
|
||||
case permissionsModule.Permission.view: {
|
||||
permissionDisplay = (
|
||||
<div className="flex w-permission-display gap-px">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
rounded="none"
|
||||
ref={permissionSelectorButtonRef}
|
||||
isDisabled={isDisabled}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={`selectable ${!isDisabled || !input ? 'active' : ''} ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-text flex-1 rounded-l-full py-permission-mini-button-y selectable',
|
||||
(!isDisabled || !input) && 'active',
|
||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||
} h-text grow rounded-l-full px-permission-mini-button-x py-permission-mini-button-y`}
|
||||
)}
|
||||
onPress={doShowPermissionTypeSelector}
|
||||
>
|
||||
<aria.Text>{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}</aria.Text>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
rounded="none"
|
||||
isDisabled={isDisabled}
|
||||
focusRingPlacement="after"
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className="relative h-text grow after:absolute after:inset"
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-text flex-1 py-permission-mini-button-y selectable',
|
||||
permission.docs && (!isDisabled || !input) && 'active',
|
||||
permissionsModule.DOCS_CLASS_NAME
|
||||
)}
|
||||
onPress={() => {
|
||||
setAction(
|
||||
permissionsModule.toPermissionAction({
|
||||
@ -163,19 +175,19 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text
|
||||
className={`selectable ${permission.docs && (!isDisabled || !input) ? 'active' : ''} ${
|
||||
permissionsModule.DOCS_CLASS_NAME
|
||||
} h-text grow px-permission-mini-button-x py-permission-mini-button-y`}
|
||||
>
|
||||
{getText('docsPermissionModifier')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
{getText('docsPermissionModifier')}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
rounded="none"
|
||||
isDisabled={isDisabled}
|
||||
focusRingPlacement="after"
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className="relative h-text grow rounded-r-full after:absolute after:inset after:rounded-r-full"
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-text flex-1 rounded-r-full py-permission-mini-button-y selectable',
|
||||
permission.execute && (!isDisabled || !input) && 'active',
|
||||
permissionsModule.EXEC_CLASS_NAME
|
||||
)}
|
||||
onPress={() => {
|
||||
setAction(
|
||||
permissionsModule.toPermissionAction({
|
||||
@ -186,31 +198,29 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text
|
||||
className={`selectable ${permission.execute && (!isDisabled || !input) ? 'active' : ''} ${
|
||||
permissionsModule.EXEC_CLASS_NAME
|
||||
} rounded-r-full px-permission-mini-button-x py-permission-mini-button-y`}
|
||||
>
|
||||
{getText('execPermissionModifier')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
{getText('execPermissionModifier')}
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
permissionDisplay = (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
ref={permissionSelectorButtonRef}
|
||||
isDisabled={isDisabled}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={`selectable ${!isDisabled || !input ? 'active' : ''} ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-text w-permission-display rounded-full selectable',
|
||||
(!isDisabled || !input) && 'active',
|
||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||
} h-text w-permission-display rounded-full`}
|
||||
)}
|
||||
onPress={doShowPermissionTypeSelector}
|
||||
>
|
||||
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
break
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
/** @file A selector for all possible permission types. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
@ -105,21 +107,23 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
|
||||
? true
|
||||
: data.type !== permissions.Permission.owner)
|
||||
).map(data => (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
key={data.type}
|
||||
className={`flex h-row items-start gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5 ${
|
||||
type === data.type
|
||||
? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent'
|
||||
: ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-row items-start justify-stretch gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5',
|
||||
type === data.type && 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent'
|
||||
)}
|
||||
onPress={() => {
|
||||
onChange(data.type)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-permission-type rounded-full py-permission-type-y ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-text w-permission-type rounded-full py-permission-type-y',
|
||||
permissions.PERMISSION_CLASS_NAME[data.type]
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{data.type}
|
||||
</div>
|
||||
@ -130,9 +134,10 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
|
||||
{data.previous != null && (
|
||||
<>
|
||||
<div
|
||||
className={`h-full w-permission-type rounded-full py-permission-type-y text-center ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-text w-permission-type rounded-full py-permission-type-y text-center',
|
||||
permissions.PERMISSION_CLASS_NAME[data.previous]
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{data.previous}
|
||||
</div>
|
||||
@ -143,7 +148,7 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
|
||||
</>
|
||||
)}
|
||||
<aria.Label className="text">{data.description(assetType)}</aria.Label>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,30 +1,31 @@
|
||||
/** @file An interactive button indicating the status of a project. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as toast from 'react-toastify'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import ArrowUpIcon from 'enso-assets/arrow_up.svg'
|
||||
import PlayIcon from 'enso-assets/play.svg'
|
||||
import StopIcon from 'enso-assets/stop.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as sessionProvider from '#/providers/SessionProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import Spinner, * as spinner from '#/components/Spinner'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as remoteBackend from '#/services/RemoteBackend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
@ -32,8 +33,6 @@ import * as object from '#/utilities/object'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The size of the icon, in pixels. */
|
||||
const ICON_SIZE_PX = 24
|
||||
const LOADING_MESSAGE =
|
||||
'Your environment is being created. It will take some time, please be patient.'
|
||||
/** The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
|
||||
@ -69,24 +68,22 @@ const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.S
|
||||
|
||||
/** Props for a {@link ProjectIcon}. */
|
||||
export interface ProjectIconProps {
|
||||
readonly keyProp: string
|
||||
readonly backend: Backend
|
||||
readonly item: backendModule.ProjectAsset
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
/** Called when the project is opened via the {@link ProjectIcon}. */
|
||||
readonly doOpenManually: (projectId: backendModule.ProjectId) => void
|
||||
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
|
||||
readonly doCloseEditor: () => void
|
||||
readonly doOpenEditor: (switchPage: boolean) => void
|
||||
}
|
||||
|
||||
/** An interactive icon indicating the status of a project. */
|
||||
export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const { keyProp: key, item, setItem, assetEvents, doOpenManually } = props
|
||||
const { backend, item, setItem, assetEvents, setProjectStartupInfo, dispatchAssetEvent } = props
|
||||
const { doCloseEditor, doOpenEditor } = props
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { session } = sessionProvider.useSession()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
const state = item.projectState.type
|
||||
@ -112,106 +109,106 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
return object.merge(oldItem, { projectState: newProjectState })
|
||||
})
|
||||
},
|
||||
[user, /* should never change */ setItem]
|
||||
[/* should never change */ user, /* should never change */ setItem]
|
||||
)
|
||||
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
|
||||
const [onSpinnerStateChange, setOnSpinnerStateChange] = React.useState<
|
||||
((state: spinner.SpinnerState | null) => void) | null
|
||||
>(null)
|
||||
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
|
||||
const [isRunningInBackground, setIsRunningInBackground] = React.useState(
|
||||
item.projectState.executeAsync ?? false
|
||||
)
|
||||
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
|
||||
const [toastId, setToastId] = React.useState<toast.Id | null>(null)
|
||||
const [openProjectAbortController, setOpenProjectAbortController] =
|
||||
React.useState<AbortController | null>(null)
|
||||
const [closeProjectAbortController, setCloseProjectAbortController] =
|
||||
React.useState<AbortController | null>(null)
|
||||
const toastId: toast.Id = React.useId()
|
||||
const isOpening =
|
||||
backendModule.IS_OPENING[item.projectState.type] &&
|
||||
item.projectState.type !== backendModule.ProjectState.placeholder
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isOtherUserUsingProject =
|
||||
backend.type !== backendModule.BackendType.local && item.projectState.openedBy !== user?.email
|
||||
isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user?.email
|
||||
|
||||
const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject')
|
||||
const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject')
|
||||
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
|
||||
const waitUntilProjectIsReadyMutation = backendHooks.useBackendMutation(
|
||||
backend,
|
||||
'waitUntilProjectIsReady'
|
||||
)
|
||||
const openProjectMutate = openProjectMutation.mutateAsync
|
||||
const getProjectDetailsMutate = getProjectDetailsMutation.mutateAsync
|
||||
|
||||
const openProject = React.useCallback(
|
||||
async (shouldRunInBackground: boolean) => {
|
||||
closeProjectAbortController?.abort()
|
||||
setCloseProjectAbortController(null)
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
try {
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.remote: {
|
||||
if (state !== backendModule.ProjectState.opened) {
|
||||
if (!shouldRunInBackground) {
|
||||
setToastId(toast.toast.loading(LOADING_MESSAGE))
|
||||
}
|
||||
await backend.openProject(
|
||||
item.id,
|
||||
{
|
||||
executeAsync: shouldRunInBackground,
|
||||
parentId: item.parentId,
|
||||
cognitoCredentials: session,
|
||||
},
|
||||
item.title
|
||||
)
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
setOpenProjectAbortController(abortController)
|
||||
await remoteBackend.waitUntilProjectIsReady(backend, item, abortController)
|
||||
setToastId(null)
|
||||
if (!abortController.signal.aborted) {
|
||||
setState(oldState =>
|
||||
oldState === backendModule.ProjectState.openInProgress
|
||||
? backendModule.ProjectState.opened
|
||||
: oldState
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case backendModule.BackendType.local: {
|
||||
await backend.openProject(
|
||||
item.id,
|
||||
{
|
||||
executeAsync: shouldRunInBackground,
|
||||
parentId: item.parentId,
|
||||
cognitoCredentials: null,
|
||||
},
|
||||
item.title
|
||||
)
|
||||
setState(oldState =>
|
||||
oldState === backendModule.ProjectState.openInProgress
|
||||
? backendModule.ProjectState.opened
|
||||
: oldState
|
||||
)
|
||||
break
|
||||
}
|
||||
if (state !== backendModule.ProjectState.opened) {
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
try {
|
||||
await openProjectMutate([
|
||||
item.id,
|
||||
{
|
||||
executeAsync: shouldRunInBackground,
|
||||
parentId: item.parentId,
|
||||
cognitoCredentials: session,
|
||||
},
|
||||
item.title,
|
||||
])
|
||||
} catch (error) {
|
||||
const project = await getProjectDetailsMutate([item.id, item.parentId, item.title])
|
||||
// `setState` is not used here as `project` contains the full state information,
|
||||
// not just the state type.
|
||||
setItem(object.merger({ projectState: project.state }))
|
||||
toastAndLog('openProjectError', error, item.title)
|
||||
setState(backendModule.ProjectState.closed)
|
||||
}
|
||||
} catch (error) {
|
||||
const project = await backend.getProjectDetails(item.id, item.parentId, item.title)
|
||||
setItem(object.merger({ projectState: project.state }))
|
||||
toastAndLog('openProjectError', error, item.title)
|
||||
setState(backendModule.ProjectState.closed)
|
||||
}
|
||||
},
|
||||
[
|
||||
state,
|
||||
backend,
|
||||
item,
|
||||
closeProjectAbortController,
|
||||
session,
|
||||
toastAndLog,
|
||||
/* should never change */ openProjectMutate,
|
||||
/* should never change */ getProjectDetailsMutate,
|
||||
/* should never change */ setState,
|
||||
/* should never change */ setItem,
|
||||
]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toastId != null) {
|
||||
return () => {
|
||||
const openEditorMutation = reactQuery.useMutation({
|
||||
mutationKey: ['openEditor', item.id],
|
||||
mutationFn: async (abortController: AbortController) => {
|
||||
if (!isRunningInBackground) {
|
||||
toast.toast.loading(LOADING_MESSAGE, { toastId })
|
||||
}
|
||||
const project = await waitUntilProjectIsReadyMutation.mutateAsync([
|
||||
item.id,
|
||||
item.parentId,
|
||||
item.title,
|
||||
abortController,
|
||||
])
|
||||
setProjectStartupInfo({
|
||||
project,
|
||||
projectAsset: item,
|
||||
setProjectAsset: setItem,
|
||||
backendType: backend.type,
|
||||
accessToken: session?.accessToken ?? null,
|
||||
})
|
||||
if (!abortController.signal.aborted) {
|
||||
toast.toast.dismiss(toastId)
|
||||
setState(backendModule.ProjectState.opened)
|
||||
}
|
||||
},
|
||||
})
|
||||
const openEditorMutate = openEditorMutation.mutate
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpening) {
|
||||
const abortController = new AbortController()
|
||||
openEditorMutate(abortController)
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [toastId])
|
||||
}, [isOpening, openEditorMutate])
|
||||
|
||||
React.useEffect(() => {
|
||||
// Ensure that the previous spinner state is visible for at least one frame.
|
||||
@ -221,50 +218,17 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
? REMOTE_SPINNER_STATE[state]
|
||||
: LOCAL_SPINNER_STATE[state]
|
||||
setSpinnerState(newSpinnerState)
|
||||
onSpinnerStateChange?.(state === backendModule.ProjectState.closed ? null : newSpinnerState)
|
||||
})
|
||||
}, [state, backend.type, onSpinnerStateChange])
|
||||
|
||||
React.useEffect(() => {
|
||||
onSpinnerStateChange?.(spinner.SpinnerState.initial)
|
||||
return () => {
|
||||
onSpinnerStateChange?.(null)
|
||||
}
|
||||
}, [onSpinnerStateChange])
|
||||
}, [state, backend.type])
|
||||
|
||||
eventHooks.useEventHandler(assetEvents, event => {
|
||||
switch (event.type) {
|
||||
case AssetEventType.newFolder:
|
||||
case AssetEventType.uploadFiles:
|
||||
case AssetEventType.newDatalink:
|
||||
case AssetEventType.newSecret:
|
||||
case AssetEventType.copy:
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.cut:
|
||||
case AssetEventType.cancelCut:
|
||||
case AssetEventType.move:
|
||||
case AssetEventType.delete:
|
||||
case AssetEventType.deleteForever:
|
||||
case AssetEventType.restore:
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected:
|
||||
case AssetEventType.removeSelf:
|
||||
case AssetEventType.temporarilyAddLabels:
|
||||
case AssetEventType.temporarilyRemoveLabels:
|
||||
case AssetEventType.addLabels:
|
||||
case AssetEventType.removeLabels:
|
||||
case AssetEventType.deleteLabel: {
|
||||
// Ignored. Any missing project-related events should be handled by `ProjectNameColumn`.
|
||||
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
|
||||
// are handled by`AssetRow`.
|
||||
break
|
||||
}
|
||||
case AssetEventType.openProject: {
|
||||
if (event.id !== item.id) {
|
||||
if (!event.runInBackground && !isRunningInBackground) {
|
||||
setShouldOpenWhenReady(false)
|
||||
if (!isOtherUserUsingProject && backendModule.IS_OPENING_OR_OPENED[state]) {
|
||||
void closeProject(false)
|
||||
void closeProject()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -278,16 +242,14 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
case AssetEventType.closeProject: {
|
||||
if (event.id === item.id) {
|
||||
setShouldOpenWhenReady(false)
|
||||
void closeProject(false)
|
||||
void closeProject()
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.newProject: {
|
||||
if (event.placeholderId === key) {
|
||||
setOnSpinnerStateChange(() => event.onSpinnerStateChange)
|
||||
} else if (event.onSpinnerStateChange === onSpinnerStateChange) {
|
||||
setOnSpinnerStateChange(null)
|
||||
}
|
||||
default: {
|
||||
// Ignored. Any missing project-related events should be handled by `ProjectNameColumn`.
|
||||
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
|
||||
// are handled by`AssetRow`.
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -304,40 +266,14 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldOpenWhenReady, shouldSwitchPage, state])
|
||||
|
||||
const closeProject = async (triggerOnClose = true) => {
|
||||
if (triggerOnClose) {
|
||||
const closeProject = async () => {
|
||||
if (!isRunningInBackground) {
|
||||
doCloseEditor()
|
||||
}
|
||||
setToastId(null)
|
||||
toast.toast.dismiss(toastId)
|
||||
setShouldOpenWhenReady(false)
|
||||
setState(backendModule.ProjectState.closing)
|
||||
onSpinnerStateChange?.(null)
|
||||
setOnSpinnerStateChange(null)
|
||||
openProjectAbortController?.abort()
|
||||
setOpenProjectAbortController(null)
|
||||
const abortController = new AbortController()
|
||||
setCloseProjectAbortController(abortController)
|
||||
if (backendModule.IS_OPENING_OR_OPENED[state]) {
|
||||
try {
|
||||
if (
|
||||
backend.type === backendModule.BackendType.local &&
|
||||
state === backendModule.ProjectState.openInProgress
|
||||
) {
|
||||
// Projects that are not opened cannot be closed.
|
||||
// This is the only way to wait until the project is open.
|
||||
await backend.openProject(item.id, null, item.title)
|
||||
}
|
||||
try {
|
||||
await backend.closeProject(item.id, item.title)
|
||||
} catch {
|
||||
// Ignored. The project is already closed.
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setState(backendModule.ProjectState.closed)
|
||||
}
|
||||
}
|
||||
}
|
||||
await closeProjectMutation.mutateAsync([item.id, item.title])
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
@ -347,66 +283,89 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
case backendModule.ProjectState.closing:
|
||||
case backendModule.ProjectState.closed:
|
||||
return (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="size-project-icon rounded-full"
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
doOpenManually(item.id)
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: item.id,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
runInBackground: false,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SvgMask alt={getText('openInEditor')} src={PlayIcon} className="size-project-icon" />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
case backendModule.ProjectState.openInProgress:
|
||||
case backendModule.ProjectState.scheduled:
|
||||
case backendModule.ProjectState.provisioned:
|
||||
case backendModule.ProjectState.placeholder:
|
||||
return (
|
||||
<UnstyledButton
|
||||
isDisabled={isOtherUserUsingProject}
|
||||
{...(isOtherUserUsingProject ? { title: 'Someone else is using this project.' } : {})}
|
||||
className="size-project-icon rounded-full selectable enabled:active"
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
void closeProject(!isRunningInBackground)
|
||||
}}
|
||||
>
|
||||
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
|
||||
<Spinner size={ICON_SIZE_PX} state={spinnerState} />
|
||||
</div>
|
||||
<SvgMask
|
||||
alt={getText('stopExecution')}
|
||||
src={StopIcon}
|
||||
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
)
|
||||
case backendModule.ProjectState.opened:
|
||||
return (
|
||||
<div>
|
||||
<UnstyledButton
|
||||
<div className="relative">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isOtherUserUsingProject}
|
||||
{...(isOtherUserUsingProject ? { title: 'Someone else has this project open.' } : {})}
|
||||
{...(isOtherUserUsingProject ? { title: 'Someone else is using this project.' } : {})}
|
||||
className="size-project-icon rounded-full selectable enabled:active"
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
void closeProject(!isRunningInBackground)
|
||||
}}
|
||||
onPress={closeProject}
|
||||
>
|
||||
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
|
||||
<Spinner className="size-project-icon" state={spinnerState} />
|
||||
</div>
|
||||
<SvgMask
|
||||
alt={getText('stopExecution')}
|
||||
src={StopIcon}
|
||||
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'size-project-icon',
|
||||
isRunningInBackground && 'text-green'
|
||||
)}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
<Spinner
|
||||
state={spinnerState}
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none absolute top-0 size-project-icon',
|
||||
isRunningInBackground && 'text-green'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case backendModule.ProjectState.opened:
|
||||
return (
|
||||
<div className="flex flex-row gap-0.5">
|
||||
<div className="relative">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isOtherUserUsingProject}
|
||||
{...(isOtherUserUsingProject ? { title: 'Someone else has this project open.' } : {})}
|
||||
className="size-project-icon rounded-full selectable enabled:active"
|
||||
onPress={closeProject}
|
||||
>
|
||||
<SvgMask
|
||||
alt={getText('stopExecution')}
|
||||
src={StopIcon}
|
||||
className={tailwindMerge.twMerge(
|
||||
'size-project-icon',
|
||||
isRunningInBackground && 'text-green'
|
||||
)}
|
||||
/>
|
||||
</ariaComponents.Button>
|
||||
<Spinner
|
||||
state={spinnerState}
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-none absolute top-0 size-project-icon',
|
||||
isRunningInBackground && 'text-green'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!isOtherUserUsingProject && !isRunningInBackground && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="size-project-icon rounded-full"
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
doOpenEditor(true)
|
||||
}}
|
||||
>
|
||||
@ -415,7 +374,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
src={ArrowUpIcon}
|
||||
className="size-project-icon"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -1,14 +1,16 @@
|
||||
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import NetworkIcon from 'enso-assets/network.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -44,10 +46,9 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const { item, setItem, selected, rowState, setRowState, state, isEditable } = props
|
||||
const { selectedKeys, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { nodeMap, doOpenManually, doOpenEditor, doCloseEditor } = state
|
||||
const { backend, selectedKeys, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { nodeMap, setProjectStartupInfo, doOpenEditor, doCloseEditor } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
@ -73,10 +74,15 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
(backend.type === backendModule.BackendType.local ||
|
||||
(ownPermission != null &&
|
||||
permissions.PERMISSION_ACTION_CAN_EXECUTE[ownPermission.permission]))
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isOtherUserUsingProject =
|
||||
backend.type !== backendModule.BackendType.local &&
|
||||
projectState.openedBy != null &&
|
||||
projectState.openedBy !== user?.email
|
||||
isCloud && projectState.openedBy != null && projectState.openedBy !== user?.email
|
||||
|
||||
const createProjectMutation = backendHooks.useBackendMutation(backend, 'createProject')
|
||||
const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject')
|
||||
const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject')
|
||||
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
|
||||
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile')
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
@ -93,11 +99,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const oldTitle = asset.title
|
||||
setAsset(object.merger({ title: newTitle }))
|
||||
try {
|
||||
await backend.updateProject(
|
||||
await updateProjectMutation.mutateAsync([
|
||||
asset.id,
|
||||
{ ami: null, ideVersion: null, projectName: newTitle, parentId: asset.parentId },
|
||||
asset.title
|
||||
)
|
||||
asset.title,
|
||||
])
|
||||
} catch (error) {
|
||||
toastAndLog('renameProjectError', error)
|
||||
setAsset(object.merger({ title: oldTitle }))
|
||||
@ -143,15 +149,21 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
try {
|
||||
const createdProject =
|
||||
event.originalId == null || event.versionId == null
|
||||
? await backend.createProject({
|
||||
parentDirectoryId: asset.parentId,
|
||||
projectName: asset.title,
|
||||
...(event.templateId == null
|
||||
? {}
|
||||
: { projectTemplateName: event.templateId }),
|
||||
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
|
||||
})
|
||||
: await backend.duplicateProject(event.originalId, event.versionId, asset.title)
|
||||
? await createProjectMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: asset.parentId,
|
||||
projectName: asset.title,
|
||||
...(event.templateId == null
|
||||
? {}
|
||||
: { projectTemplateName: event.templateId }),
|
||||
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
|
||||
},
|
||||
])
|
||||
: await duplicateProjectMutation.mutateAsync([
|
||||
event.originalId,
|
||||
event.versionId,
|
||||
asset.title,
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(
|
||||
object.merge(asset, {
|
||||
@ -210,18 +222,18 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
id = await response.text()
|
||||
}
|
||||
const projectId = localBackend.newProjectId(projectManager.UUID(id))
|
||||
const listedProject = await backend.getProjectDetails(
|
||||
const listedProject = await getProjectDetailsMutation.mutateAsync([
|
||||
projectId,
|
||||
asset.parentId,
|
||||
file.name
|
||||
)
|
||||
file.name,
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merge(asset, { title: listedProject.packageName, id: projectId }))
|
||||
} else {
|
||||
const createdFile = await backend.uploadFile(
|
||||
const createdFile = await uploadFileMutation.mutateAsync([
|
||||
{ fileId, fileName: `${title}.${extension}`, parentDirectoryId: asset.parentId },
|
||||
file
|
||||
)
|
||||
file,
|
||||
])
|
||||
const project = createdFile.project
|
||||
if (project == null) {
|
||||
throw new Error('The uploaded file was not a project.')
|
||||
@ -282,9 +294,10 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y ${indent.indentClass(
|
||||
item.depth
|
||||
)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
onKeyDown={event => {
|
||||
if (rowState.isEditingName && event.key === 'Enter') {
|
||||
event.stopPropagation()
|
||||
@ -309,13 +322,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
<SvgMask src={NetworkIcon} className="m-name-column-icon size-icon" />
|
||||
) : (
|
||||
<ProjectIcon
|
||||
keyProp={item.key}
|
||||
backend={backend}
|
||||
// This is a workaround for a temporary bad state in the backend causing the
|
||||
// `projectState` key to be absent.
|
||||
item={object.merge(asset, { projectState })}
|
||||
setItem={setAsset}
|
||||
assetEvents={assetEvents}
|
||||
doOpenManually={doOpenManually}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
setProjectStartupInfo={setProjectStartupInfo}
|
||||
doOpenEditor={switchPage => {
|
||||
doOpenEditor(asset, setAsset, switchPage)
|
||||
}}
|
||||
@ -327,13 +341,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
<EditableSpan
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className={`text grow bg-transparent ${
|
||||
rowState.isEditingName
|
||||
? 'cursor-text'
|
||||
: canExecute && !isOtherUserUsingProject
|
||||
? 'cursor-pointer'
|
||||
: ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text grow bg-transparent',
|
||||
canExecute && !isOtherUserUsingProject && 'cursor-pointer',
|
||||
rowState.isEditingName && 'cursor-text'
|
||||
)}
|
||||
checkSubmittable={newTitle =>
|
||||
newTitle !== item.item.title &&
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
|
@ -1,13 +1,15 @@
|
||||
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import KeyIcon from 'enso-assets/key.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
@ -39,10 +41,9 @@ export interface SecretNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { assetEvents, dispatchAssetListEvent } = state
|
||||
const { backend, assetEvents, dispatchAssetListEvent } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.secret) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -50,6 +51,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
}
|
||||
const asset = item.item
|
||||
|
||||
const createSecretMutation = backendHooks.useBackendMutation(backend, 'createSecret')
|
||||
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
setRowState(object.merger({ isEditingName }))
|
||||
@ -96,11 +100,13 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
} else {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const id = await backend.createSecret({
|
||||
parentDirectoryId: asset.parentId,
|
||||
name: asset.title,
|
||||
value: event.value,
|
||||
})
|
||||
const id = await createSecretMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: asset.parentId,
|
||||
name: asset.title,
|
||||
value: event.value,
|
||||
},
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merger({ id }))
|
||||
} catch (error) {
|
||||
@ -127,9 +133,10 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y ${indent.indentClass(
|
||||
item.depth
|
||||
)}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex h-full min-w-max items-center gap-name-column-icon whitespace-nowrap rounded-l-full px-name-column-x py-name-column-y',
|
||||
indent.indentClass(item.depth)
|
||||
)}
|
||||
onKeyDown={event => {
|
||||
if (rowState.isEditingName && event.key === 'Enter') {
|
||||
event.stopPropagation()
|
||||
@ -148,7 +155,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
name={asset.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await backend.updateSecret(asset.id, { value }, asset.title)
|
||||
await updateSecretMutation.mutateAsync([asset.id, { value }, asset.title])
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
|
@ -3,22 +3,22 @@ import * as React from 'react'
|
||||
|
||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import Label from '#/components/dashboard/Label'
|
||||
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
||||
|
||||
@ -35,14 +35,17 @@ import * as uniqueString from '#/utilities/uniqueString'
|
||||
/** A column listing the labels on this asset. */
|
||||
export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
const { item, setItem, state, rowState } = props
|
||||
const { category, labels, setQuery, deletedLabelNames, doCreateLabel } = state
|
||||
const { backend, category, setQuery } = state
|
||||
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const labels = backendHooks.useBackendListTags(backend)
|
||||
const labelsByName = React.useMemo(() => {
|
||||
return new Map(labels?.map(label => [label.value, label]))
|
||||
}, [labels])
|
||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
@ -66,13 +69,13 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
return (
|
||||
<div className="group flex items-center gap-column-items">
|
||||
{(asset.labels ?? [])
|
||||
.filter(label => !deletedLabelNames.has(label))
|
||||
.filter(label => labelsByName.has(label))
|
||||
.map(label => (
|
||||
<Label
|
||||
key={label}
|
||||
data-testid="asset-label"
|
||||
title={getText('rightClickToRemoveLabel')}
|
||||
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||
color={labelsByName.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||
active={!temporarilyRemovedLabels.has(label)}
|
||||
isDisabled={temporarilyRemovedLabels.has(label)}
|
||||
negated={temporarilyRemovedLabels.has(label)}
|
||||
@ -130,7 +133,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
<Label
|
||||
isDisabled
|
||||
key={label}
|
||||
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||
color={labelsByName.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||
className="pointer-events-none"
|
||||
onPress={() => {}}
|
||||
>
|
||||
@ -138,24 +141,25 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
</Label>
|
||||
))}
|
||||
{managesThisAsset && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
ref={plusButtonRef}
|
||||
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
|
||||
className="shrink-0 rounded-full opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManageLabelsModal
|
||||
key={uniqueString.uniqueString()}
|
||||
backend={backend}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
allLabels={labels}
|
||||
doCreateLabel={doCreateLabel}
|
||||
eventTarget={plusButtonRef.current}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={Plus2Icon} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -10,9 +10,9 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
|
||||
@ -27,7 +27,10 @@ import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
/** The type of the `state` prop of a {@link SharedWithColumn}. */
|
||||
interface SharedWithColumnStateProp
|
||||
extends Pick<column.AssetColumnProps['state'], 'category' | 'dispatchAssetEvent' | 'setQuery'> {}
|
||||
extends Pick<
|
||||
column.AssetColumnProps['state'],
|
||||
'backend' | 'category' | 'dispatchAssetEvent' | 'setQuery'
|
||||
> {}
|
||||
|
||||
/** Props for a {@link SharedWithColumn}. */
|
||||
interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'item' | 'setItem'> {
|
||||
@ -38,7 +41,7 @@ interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'i
|
||||
/** A column listing the users with which this asset is shared. */
|
||||
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
const { item, setItem, state, isReadonly = false } = props
|
||||
const { category, dispatchAssetEvent, setQuery } = state
|
||||
const { backend, category, dispatchAssetEvent, setQuery } = state
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
@ -84,13 +87,16 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
</PermissionDisplay>
|
||||
))}
|
||||
{managesThisAsset && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
ref={plusButtonRef}
|
||||
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
|
||||
className="shrink-0 rounded-full opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
key={uniqueString.uniqueString()}
|
||||
backend={backend}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
@ -106,7 +112,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={Plus2Icon} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -83,7 +83,7 @@ const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}`
|
||||
|
||||
/** CSS classes for every column. */
|
||||
export const COLUMN_CSS_CLASS: Readonly<Record<Column, string>> = {
|
||||
[Column.name]: `rounded-rows-skip-level min-w-drive-name-column h-full p border-l-0 ${COLUMN_CSS_CLASSES}`,
|
||||
[Column.name]: `rounded-rows-skip-level min-w-drive-name-column h-full p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
|
||||
[Column.modified]: `min-w-drive-modified-column ${NORMAL_COLUMN_CSS_CLASSES}`,
|
||||
[Column.sharedWith]: `min-w-drive-shared-with-column ${NORMAL_COLUMN_CSS_CLASSES}`,
|
||||
[Column.labels]: `min-w-drive-labels-column ${NORMAL_COLUMN_CSS_CLASSES}`,
|
||||
|
@ -10,7 +10,7 @@ import NameColumnHeading from '#/components/dashboard/columnHeading/NameColumnHe
|
||||
import SharedWithColumnHeading from '#/components/dashboard/columnHeading/SharedWithColumnHeading'
|
||||
|
||||
export const COLUMN_HEADING: Readonly<
|
||||
Record<columnUtils.Column, (props: column.AssetColumnHeadingProps) => JSX.Element>
|
||||
Record<columnUtils.Column, (props: column.AssetColumnHeadingProps) => React.JSX.Element>
|
||||
> = {
|
||||
[columnUtils.Column.name]: NameColumnHeading,
|
||||
[columnUtils.Column.modified]: ModifiedColumnHeading,
|
||||
|
@ -1,21 +1,25 @@
|
||||
/** @file A heading for the "Modified" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||
import TimeIcon from 'enso-assets/time.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
|
||||
/** A heading for the "Modified" column. */
|
||||
export default function ModifiedColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
||||
export default function ModifiedColumnHeading(
|
||||
props: column.AssetColumnHeadingProps
|
||||
): React.JSX.Element {
|
||||
const { state } = props
|
||||
const { sortInfo, setSortInfo, hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
@ -42,8 +46,10 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
||||
hideColumn(columnUtils.Column.modified)
|
||||
}}
|
||||
/>
|
||||
<UnstyledButton
|
||||
className="flex grow gap-icon-with-text"
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="flex grow justify-start gap-icon-with-text"
|
||||
onPress={() => {
|
||||
const nextDirection = isSortActive
|
||||
? sorting.nextSortDirection(sortInfo.direction)
|
||||
@ -59,11 +65,13 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
||||
<img
|
||||
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
||||
src={SortAscendingIcon}
|
||||
className={`transition-all duration-arrow ${
|
||||
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
|
||||
} ${isDescending ? 'rotate-180' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'transition-all duration-arrow',
|
||||
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
|
||||
isDescending && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,19 +1,23 @@
|
||||
/** @file A heading for the "Name" column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
|
||||
/** A heading for the "Name" column. */
|
||||
export default function NameColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
||||
export default function NameColumnHeading(
|
||||
props: column.AssetColumnHeadingProps
|
||||
): React.JSX.Element {
|
||||
const { state } = props
|
||||
const { sortInfo, setSortInfo } = state
|
||||
const { getText } = textProvider.useText()
|
||||
@ -21,7 +25,9 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
||||
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
aria-label={
|
||||
!isSortActive
|
||||
? getText('sortByName')
|
||||
@ -29,7 +35,7 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
||||
? getText('stopSortingByName')
|
||||
: getText('sortByNameDescending')
|
||||
}
|
||||
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
||||
className="group flex h-drive-table-heading w-full items-center justify-start gap-icon-with-text px-name-column-x"
|
||||
onPress={() => {
|
||||
const nextDirection = isSortActive
|
||||
? sorting.nextSortDirection(sortInfo.direction)
|
||||
@ -45,10 +51,12 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
||||
<img
|
||||
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
||||
src={SortAscendingIcon}
|
||||
className={`transition-all duration-arrow ${
|
||||
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
|
||||
} ${isDescending ? 'rotate-180' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'transition-all duration-arrow',
|
||||
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
|
||||
isDescending && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
@ -19,6 +19,8 @@ export interface ButtonProps {
|
||||
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
|
||||
readonly tooltip?: React.ReactNode
|
||||
readonly autoFocus?: boolean
|
||||
/** When `true`, the button uses a lighter color when it is not active. */
|
||||
readonly light?: boolean
|
||||
/** When `true`, the button is not faded out even when not hovered. */
|
||||
readonly active?: boolean
|
||||
/** When `true`, the button is clickable, but displayed as not clickable.
|
||||
@ -28,6 +30,7 @@ export interface ButtonProps {
|
||||
readonly isDisabled?: boolean
|
||||
readonly image: string
|
||||
readonly alt?: string
|
||||
readonly tooltipPlacement?: aria.Placement
|
||||
/** A title that is only shown when `disabled` is `true`. */
|
||||
readonly error?: string | null
|
||||
/** Class names for the icon itself. */
|
||||
@ -42,6 +45,7 @@ export interface ButtonProps {
|
||||
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const {
|
||||
tooltip,
|
||||
light = false,
|
||||
active = false,
|
||||
softDisabled = false,
|
||||
image,
|
||||
@ -49,6 +53,7 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
||||
alt,
|
||||
className,
|
||||
buttonClassName,
|
||||
tooltipPlacement,
|
||||
...buttonProps
|
||||
} = props
|
||||
const { isDisabled = false } = buttonProps
|
||||
@ -62,13 +67,18 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
||||
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
|
||||
ref,
|
||||
className: tailwindMerge.twMerge(
|
||||
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
|
||||
'relative after:pointer-events-none after:absolute after:inset after:rounded-button-focus-ring transition-colors hover:enabled:bg-primary/10 rounded-button-focus-ring -m-1 p-1',
|
||||
buttonClassName
|
||||
),
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'group flex selectable',
|
||||
light && 'opacity-25',
|
||||
(isDisabled || softDisabled) && 'disabled',
|
||||
active && 'active'
|
||||
)}
|
||||
>
|
||||
<SvgMask
|
||||
src={image}
|
||||
@ -86,7 +96,11 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
|
||||
) : (
|
||||
<ariaComponents.TooltipTrigger>
|
||||
{button}
|
||||
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
|
||||
<ariaComponents.Tooltip
|
||||
{...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}
|
||||
>
|
||||
{tooltipElement}
|
||||
</ariaComponents.Tooltip>
|
||||
</ariaComponents.TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A styled horizontal button row. Does not have padding; does not have a background. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
// =================
|
||||
@ -25,7 +27,10 @@ export default function ButtonRow(props: ButtonRowProps) {
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className={`relative flex gap-buttons self-start ${positionClass}`} {...innerProps}>
|
||||
<div
|
||||
className={tailwindMerge.twMerge('relative flex gap-buttons', positionClass)}
|
||||
{...innerProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
@ -25,7 +25,7 @@ export default function Checkbox(props: CheckboxProps) {
|
||||
<SvgMask
|
||||
invert
|
||||
src={CheckMarkIcon}
|
||||
className="-m-0.5 size-icon transition-all duration-75 transparent group-selected:opacity-100"
|
||||
className="-m-0.5 size-icon opacity-0 transition-all duration-75 group-selected:opacity-100"
|
||||
/>
|
||||
</aria.Checkbox>
|
||||
</FocusRing>
|
||||
|
@ -31,7 +31,7 @@ export interface FocusAreaProps {
|
||||
readonly focusDefaultClass?: string
|
||||
readonly active?: boolean
|
||||
readonly direction: focusDirectionProvider.FocusDirection
|
||||
readonly children: (props: FocusWithinProps) => JSX.Element
|
||||
readonly children: (props: FocusWithinProps) => React.JSX.Element
|
||||
}
|
||||
|
||||
/** An area that can be focused within. */
|
||||
|
@ -21,7 +21,7 @@ export interface FocusRootInnerProps {
|
||||
/** Props for a {@link FocusRoot} */
|
||||
export interface FocusRootProps {
|
||||
readonly active?: boolean
|
||||
readonly children: (props: FocusRootInnerProps) => JSX.Element
|
||||
readonly children: (props: FocusRootInnerProps) => React.JSX.Element
|
||||
}
|
||||
|
||||
/** An element that prevents navigation outside of itself. */
|
||||
|
@ -93,7 +93,7 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
|
||||
active
|
||||
image={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||
alt={isShowingPassword ? getText('hidePassword') : getText('showPassword')}
|
||||
buttonClassName="absolute right-2 top-1 cursor-pointer rounded-full size-icon"
|
||||
buttonClassName="absolute right-2 top-1 cursor-pointer rounded-full size-6"
|
||||
onPress={() => {
|
||||
setIsShowingPassword(show => !show)
|
||||
}}
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A styled settings section. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
@ -26,7 +28,7 @@ export default function SettingsSection(props: SettingsSectionProps) {
|
||||
)
|
||||
|
||||
return noFocusArea ? (
|
||||
<div className={`flex flex-col gap-settings-section-header ${className ?? ''}`}>
|
||||
<div className={tailwindMerge.twMerge('flex flex-col gap-settings-section-header', className)}>
|
||||
{heading}
|
||||
{children}
|
||||
</div>
|
||||
@ -34,7 +36,7 @@ export default function SettingsSection(props: SettingsSectionProps) {
|
||||
<FocusArea direction="vertical">
|
||||
{innerProps => (
|
||||
<div
|
||||
className={`flex flex-col gap-settings-section-header ${className ?? ''}`}
|
||||
className={tailwindMerge.twMerge('flex flex-col gap-settings-section-header', className)}
|
||||
{...innerProps}
|
||||
>
|
||||
{heading}
|
||||
|
@ -1,8 +1,6 @@
|
||||
/** @file Events related to changes in asset state. */
|
||||
import type AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import type * as spinner from '#/components/Spinner'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
@ -66,7 +64,6 @@ export interface AssetNewProjectEvent extends AssetBaseEvent<AssetEventType.newP
|
||||
readonly datalinkId: backend.DatalinkId | null
|
||||
readonly originalId: backend.ProjectId | null
|
||||
readonly versionId: backend.S3ObjectVersionId | null
|
||||
readonly onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
|
||||
}
|
||||
|
||||
/** A signal to create a directory. */
|
||||
|
@ -1,8 +1,6 @@
|
||||
/** @file Events related to changes in the asset list. */
|
||||
import type AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import type * as spinner from '#/components/Spinner'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
@ -65,7 +63,6 @@ interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType
|
||||
readonly templateId: string | null
|
||||
readonly datalinkId: backend.DatalinkId | null
|
||||
readonly preferredName: string | null
|
||||
readonly onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
|
||||
}
|
||||
|
||||
/** A signal to upload files. */
|
||||
|
537
app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts
Normal file
537
app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts
Normal file
@ -0,0 +1,537 @@
|
||||
/** @file Hooks for interacting with the backend. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// ============================
|
||||
// === revokeUserPictureUrl ===
|
||||
// ============================
|
||||
|
||||
const USER_PICTURE_URL_REVOKERS = new WeakMap<Backend, () => void>()
|
||||
|
||||
/** Create the corresponding "user picture" URL for the given backend. */
|
||||
function createUserPictureUrl(backend: Backend | null, picture: Blob) {
|
||||
if (backend != null) {
|
||||
USER_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
const url = URL.createObjectURL(picture)
|
||||
USER_PICTURE_URL_REVOKERS.set(backend, () => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
return url
|
||||
} else {
|
||||
// This should never happen, so use an arbitrary URL.
|
||||
return location.href
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke the corresponding "user picture" URL for the given backend. */
|
||||
function revokeUserPictureUrl(backend: Backend | null) {
|
||||
if (backend != null) {
|
||||
USER_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// === revokeOrganizationPictureUrl ===
|
||||
// ====================================
|
||||
|
||||
const ORGANIZATION_PICTURE_URL_REVOKERS = new WeakMap<Backend, () => void>()
|
||||
|
||||
/** Create the corresponding "organization picture" URL for the given backend. */
|
||||
function createOrganizationPictureUrl(backend: Backend | null, picture: Blob) {
|
||||
if (backend != null) {
|
||||
ORGANIZATION_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
const url = URL.createObjectURL(picture)
|
||||
ORGANIZATION_PICTURE_URL_REVOKERS.set(backend, () => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
return url
|
||||
} else {
|
||||
// This should never happen, so use an arbitrary URL.
|
||||
return location.href
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke the corresponding "organization picture" URL for the given backend. */
|
||||
function revokeOrganizationPictureUrl(backend: Backend | null) {
|
||||
if (backend != null) {
|
||||
ORGANIZATION_PICTURE_URL_REVOKERS.get(backend)?.()
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === useObserveBackend ===
|
||||
// =========================
|
||||
|
||||
/** Listen to all mutations and update state as appropriate when they succeed.
|
||||
* MUST be unconditionally called exactly once for each backend type. */
|
||||
export function useObserveBackend(backend: Backend | null) {
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
const [seen] = React.useState(new WeakSet())
|
||||
const useObserveMutations = <Method extends keyof Backend>(
|
||||
method: Method,
|
||||
onSuccess: (
|
||||
state: reactQuery.MutationState<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
|
||||
>
|
||||
) => void
|
||||
) => {
|
||||
const states = reactQuery.useMutationState<
|
||||
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
|
||||
>({
|
||||
// Errored mutations can be safely ignored as they should not change the state.
|
||||
filters: { mutationKey: [backend, method], status: 'success' },
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
select: mutation => mutation.state as never,
|
||||
})
|
||||
for (const state of states) {
|
||||
if (!seen.has(state)) {
|
||||
seen.add(state)
|
||||
// This is SAFE - it is just too highly dynamic for TypeScript to typecheck.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onSuccess(state as never)
|
||||
}
|
||||
}
|
||||
}
|
||||
const setQueryData = <Method extends keyof Backend>(
|
||||
method: Method,
|
||||
updater: (
|
||||
variable: Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
|
||||
) => Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
|
||||
) => {
|
||||
queryClient.setQueryData<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
|
||||
>([backend, method], data => (data == null ? data : updater(data)))
|
||||
}
|
||||
useObserveMutations('uploadUserPicture', state => {
|
||||
revokeUserPictureUrl(backend)
|
||||
setQueryData('usersMe', user => state.data ?? user)
|
||||
})
|
||||
useObserveMutations('updateOrganization', state => {
|
||||
setQueryData('getOrganization', organization => state.data ?? organization)
|
||||
})
|
||||
useObserveMutations('uploadOrganizationPicture', state => {
|
||||
revokeOrganizationPictureUrl(backend)
|
||||
setQueryData('getOrganization', organization => state.data ?? organization)
|
||||
})
|
||||
useObserveMutations('createUserGroup', state => {
|
||||
if (state.data != null) {
|
||||
const data = state.data
|
||||
setQueryData('listUserGroups', userGroups => [data, ...userGroups])
|
||||
}
|
||||
})
|
||||
useObserveMutations('deleteUserGroup', state => {
|
||||
setQueryData('listUserGroups', userGroups =>
|
||||
userGroups.filter(userGroup => userGroup.id !== state.variables?.[0])
|
||||
)
|
||||
})
|
||||
useObserveMutations('changeUserGroup', state => {
|
||||
if (state.variables != null) {
|
||||
const [userId, body] = state.variables
|
||||
setQueryData('listUsers', users =>
|
||||
users.map(user =>
|
||||
user.userId !== userId ? user : { ...user, userGroups: body.userGroups }
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
useObserveMutations('createTag', state => {
|
||||
if (state.data != null) {
|
||||
const data = state.data
|
||||
setQueryData('listTags', tags => [...tags, data])
|
||||
}
|
||||
})
|
||||
useObserveMutations('deleteTag', state => {
|
||||
if (state.variables != null) {
|
||||
const [tagId] = state.variables
|
||||
setQueryData('listTags', tags => tags.filter(tag => tag.id !== tagId))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === useBackendQuery ===
|
||||
// =======================
|
||||
|
||||
export function useBackendQuery<Method extends keyof Backend>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
args: Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>
|
||||
): reactQuery.UseQueryResult<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
|
||||
>
|
||||
export function useBackendQuery<Method extends keyof Backend>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>
|
||||
): reactQuery.UseQueryResult<
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>> | undefined
|
||||
>
|
||||
/** Wrap a backend method call in a React Query. */
|
||||
export function useBackendQuery<Method extends keyof Backend>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>
|
||||
) {
|
||||
return reactQuery.useQuery<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
readonly unknown[]
|
||||
>({
|
||||
...options,
|
||||
queryKey: [backend, method, ...args, ...(options?.queryKey ?? [])],
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
queryFn: () => (backend?.[method] as any)?.(...args),
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === useBackendMutation ===
|
||||
// ==========================
|
||||
|
||||
/** Wrap a backend method call in a React Query Mutation. */
|
||||
export function useBackendMutation<Method extends keyof Backend>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
|
||||
unknown
|
||||
>,
|
||||
'mutationFn'
|
||||
>
|
||||
) {
|
||||
return reactQuery.useMutation<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
|
||||
unknown
|
||||
>({
|
||||
...options,
|
||||
mutationKey: [backend, method, ...(options?.mutationKey ?? [])],
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
mutationFn: args => (backend[method] as any)(...args),
|
||||
})
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// === useBackendMutationVariables ===
|
||||
// ===================================
|
||||
|
||||
/** Access mutation variables from a React Query Mutation. */
|
||||
export function useBackendMutationVariables<Method extends keyof Backend>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
mutationKey?: readonly unknown[]
|
||||
) {
|
||||
return reactQuery.useMutationState<
|
||||
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
|
||||
>({
|
||||
filters: {
|
||||
mutationKey: [backend, method, ...(mutationKey ?? [])],
|
||||
status: 'pending',
|
||||
},
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
select: mutation => mutation.state.variables as never,
|
||||
})
|
||||
}
|
||||
|
||||
// =======================================
|
||||
// === useBackendMutationWithVariables ===
|
||||
// =======================================
|
||||
|
||||
/** Wrap a backend method call in a React Query Mutation, and access its variables. */
|
||||
export function useBackendMutationWithVariables<Method extends keyof Backend>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
|
||||
Error,
|
||||
Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
|
||||
unknown
|
||||
>,
|
||||
'mutationFn'
|
||||
>
|
||||
) {
|
||||
const mutation = useBackendMutation(backend, method, options)
|
||||
return {
|
||||
mutation,
|
||||
mutate: mutation.mutate,
|
||||
mutateAsync: mutation.mutateAsync,
|
||||
variables: useBackendMutationVariables(backend, method, options?.mutationKey),
|
||||
}
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === Placeholder ===
|
||||
// ===================
|
||||
|
||||
/** An object with a `isPlaceholder` property. */
|
||||
interface Placeholder {
|
||||
readonly isPlaceholder: boolean
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === WithPlaceholder ===
|
||||
// =======================
|
||||
|
||||
/** An existing type, with an added `isPlaceholder` property. */
|
||||
export type WithPlaceholder<T extends object> = Placeholder & T
|
||||
|
||||
// ========================
|
||||
// === toNonPlaceholder ===
|
||||
// ========================
|
||||
|
||||
/** Return an object with an additional field `isPlaceholder: false`. */
|
||||
function toNonPlaceholder<T extends object>(object: T) {
|
||||
return { ...object, isPlaceholder: false }
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// === useBackendListUsers ===
|
||||
// ===========================
|
||||
|
||||
/** A list of users, taking into account optimistic state. */
|
||||
export function useBackendListUsers(
|
||||
backend: Backend
|
||||
): readonly WithPlaceholder<backendModule.User>[] | null {
|
||||
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
|
||||
const changeUserGroupVariables = useBackendMutationVariables(backend, 'changeUserGroup')
|
||||
return React.useMemo(() => {
|
||||
if (listUsersQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
const result = listUsersQuery.data.map(toNonPlaceholder)
|
||||
const userIdToIndex = new Map(result.map((user, i) => [user.userId, i]))
|
||||
for (const [userId, body] of changeUserGroupVariables) {
|
||||
const index = userIdToIndex.get(userId)
|
||||
const user = index == null ? null : result[index]
|
||||
if (index != null && user != null) {
|
||||
result[index] = { ...user, userGroups: body.userGroups }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}, [changeUserGroupVariables, listUsersQuery.data])
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === useBackendListUserGroups ===
|
||||
// ================================
|
||||
|
||||
/** A list of user groups, taking into account optimistic state. */
|
||||
export function useBackendListUserGroups(
|
||||
backend: Backend
|
||||
): readonly WithPlaceholder<backendModule.UserGroupInfo>[] | null {
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
invariant(user != null, 'User must exist for user groups to be listed.')
|
||||
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
|
||||
const createUserGroupVariables = useBackendMutationVariables(backend, 'createUserGroup')
|
||||
const deleteUserGroupVariables = useBackendMutationVariables(backend, 'deleteUserGroup')
|
||||
return React.useMemo(() => {
|
||||
if (listUserGroupsQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
const deletedUserGroupIds = new Set(deleteUserGroupVariables.map(([id]) => id))
|
||||
const userGroupsBase = listUserGroupsQuery.data
|
||||
.filter(userGroup => !deletedUserGroupIds.has(userGroup.id))
|
||||
.map(toNonPlaceholder)
|
||||
return [
|
||||
...createUserGroupVariables.map(([body]) => ({
|
||||
organizationId: user.organizationId,
|
||||
id: backendModule.newPlaceholderUserGroupId(),
|
||||
groupName: body.name,
|
||||
isPlaceholder: true,
|
||||
})),
|
||||
...userGroupsBase,
|
||||
]
|
||||
}
|
||||
}, [
|
||||
user.organizationId,
|
||||
createUserGroupVariables,
|
||||
deleteUserGroupVariables,
|
||||
listUserGroupsQuery.data,
|
||||
])
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// === useBackendListUserGroupsWithUsers ===
|
||||
// =========================================
|
||||
|
||||
/** A user group, as well as the users that are a part of the user group. */
|
||||
export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
|
||||
readonly users: readonly WithPlaceholder<backendModule.User>[]
|
||||
}
|
||||
|
||||
/** A list of user groups, taking into account optimistic state. */
|
||||
export function useBackendListUserGroupsWithUsers(
|
||||
backend: Backend
|
||||
): readonly WithPlaceholder<UserGroupInfoWithUsers>[] | null {
|
||||
const userGroupsRaw = useBackendListUserGroups(backend)
|
||||
// Old user list
|
||||
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
|
||||
// Current user list, including optimistic updates
|
||||
const users = useBackendListUsers(backend)
|
||||
return React.useMemo(() => {
|
||||
if (userGroupsRaw == null || listUsersQuery.data == null || users == null) {
|
||||
return null
|
||||
} else {
|
||||
const currentUserGroupsById = new Map(
|
||||
listUsersQuery.data.map(user => [user.userId, new Set(user.userGroups)])
|
||||
)
|
||||
const result = userGroupsRaw.map(userGroup => {
|
||||
const usersInGroup: readonly WithPlaceholder<backendModule.User>[] = users
|
||||
.filter(user => user.userGroups?.includes(userGroup.id))
|
||||
.map(user => {
|
||||
if (currentUserGroupsById.get(user.userId)?.has(userGroup.id) !== true) {
|
||||
return { ...user, isPlaceholder: true }
|
||||
} else {
|
||||
return user
|
||||
}
|
||||
})
|
||||
return { ...userGroup, users: usersInGroup }
|
||||
})
|
||||
return result
|
||||
}
|
||||
}, [listUsersQuery.data, userGroupsRaw, users])
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === useBackendListTags ===
|
||||
// ==========================
|
||||
|
||||
/** A list of asset tags, taking into account optimistic state. */
|
||||
export function useBackendListTags(
|
||||
backend: Backend | null
|
||||
): readonly WithPlaceholder<backendModule.Label>[] | null {
|
||||
const listTagsQuery = useBackendQuery(backend, 'listTags', [])
|
||||
const createTagVariables = useBackendMutationVariables(backend, 'createTag')
|
||||
const deleteTagVariables = useBackendMutationVariables(backend, 'deleteTag')
|
||||
return React.useMemo(() => {
|
||||
if (listTagsQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
const deletedTags = new Set(deleteTagVariables.map(variables => variables[0]))
|
||||
const result = listTagsQuery.data
|
||||
.filter(tag => !deletedTags.has(tag.id))
|
||||
.map(toNonPlaceholder)
|
||||
return [
|
||||
...result,
|
||||
...createTagVariables.map(variables => ({
|
||||
id: backendModule.TagId(`tag-${uniqueString.uniqueString()}`),
|
||||
value: backendModule.LabelName(variables[0].value),
|
||||
color: variables[0].color,
|
||||
isPlaceholder: true,
|
||||
})),
|
||||
]
|
||||
}
|
||||
}, [createTagVariables, deleteTagVariables, listTagsQuery.data])
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === useBackendUsersMe ===
|
||||
// =========================
|
||||
|
||||
/** The current user, taking into account optimistic state. */
|
||||
export function useBackendUsersMe(backend: Backend | null) {
|
||||
const usersMeQuery = useBackendQuery(backend, 'usersMe', [])
|
||||
const updateUserVariables = useBackendMutationVariables(backend, 'updateUser')
|
||||
const uploadUserPictureVariables = useBackendMutationVariables(backend, 'uploadUserPicture')
|
||||
return React.useMemo(() => {
|
||||
if (usersMeQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
let result = usersMeQuery.data
|
||||
for (const [{ username }] of updateUserVariables) {
|
||||
if (username != null) {
|
||||
result = { ...result, name: username }
|
||||
}
|
||||
}
|
||||
for (const [, file] of uploadUserPictureVariables) {
|
||||
result = {
|
||||
...result,
|
||||
profilePicture: backendModule.HttpsUrl(createUserPictureUrl(backend, file)),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}, [backend, usersMeQuery.data, updateUserVariables, uploadUserPictureVariables])
|
||||
}
|
||||
|
||||
// =================================
|
||||
// === useBackendGetOrganization ===
|
||||
// =================================
|
||||
|
||||
/** The current user's organization, taking into account optimistic state. */
|
||||
export function useBackendGetOrganization(backend: Backend | null) {
|
||||
const getOrganizationQuery = useBackendQuery(backend, 'getOrganization', [])
|
||||
const updateOrganizationVariables = useBackendMutationVariables(backend, 'updateOrganization')
|
||||
const uploadOrganizationPictureVariables = useBackendMutationVariables(
|
||||
backend,
|
||||
'uploadOrganizationPicture'
|
||||
)
|
||||
return React.useMemo(() => {
|
||||
if (getOrganizationQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
let result = getOrganizationQuery.data
|
||||
for (const [update] of updateOrganizationVariables) {
|
||||
result = { ...result, ...update }
|
||||
}
|
||||
for (const [, file] of uploadOrganizationPictureVariables) {
|
||||
result = {
|
||||
...result,
|
||||
picture: backendModule.HttpsUrl(createOrganizationPictureUrl(backend, file)),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}, [
|
||||
backend,
|
||||
getOrganizationQuery.data,
|
||||
updateOrganizationVariables,
|
||||
uploadOrganizationPictureVariables,
|
||||
])
|
||||
}
|
@ -15,7 +15,7 @@ import ContextMenus from '#/components/ContextMenus'
|
||||
export function useContextMenuRef(
|
||||
key: string,
|
||||
label: string,
|
||||
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => JSX.Element | null
|
||||
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null
|
||||
) {
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const createEntriesRef = React.useRef(createEntries)
|
||||
|
@ -61,7 +61,7 @@ export function useStickyTableHeaderOnScroll(
|
||||
) {
|
||||
const trackShadowClassRef = React.useRef(trackShadowClass)
|
||||
trackShadowClassRef.current = trackShadowClass
|
||||
const [shadowClass, setShadowClass] = React.useState('')
|
||||
const [shadowClassName, setShadowClass] = React.useState('')
|
||||
const onScroll = useOnScroll(() => {
|
||||
if (rootRef.current != null && bodyRef.current != null) {
|
||||
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
||||
@ -80,5 +80,5 @@ export function useStickyTableHeaderOnScroll(
|
||||
}
|
||||
}
|
||||
})
|
||||
return { onScroll, shadowClass }
|
||||
return { onScroll, shadowClassName }
|
||||
}
|
||||
|
@ -43,10 +43,7 @@ export // This export declaration must be broken up to satisfy the `require-jsdo
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
function run(props: Omit<app.AppProps, 'portalRoot'>) {
|
||||
const { logger, vibrancy, supportsDeepLinks } = props
|
||||
|
||||
logger.log('Starting authentication/dashboard UI.')
|
||||
|
||||
const { vibrancy, supportsDeepLinks } = props
|
||||
if (
|
||||
!detect.IS_DEV_MODE &&
|
||||
process.env.ENSO_CLOUD_SENTRY_DSN != null &&
|
||||
|
@ -9,13 +9,12 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as remoteBackendProvider from '#/providers/RemoteBackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
|
||||
import GlobalContextMenu from '#/layouts/GlobalContextMenu'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
@ -61,20 +60,18 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const { innerProps, rootDirectoryId, event, eventTarget, hidden = false } = props
|
||||
const { doTriggerDescriptionEdit, doCopy, doCut, doPaste, doDelete } = props
|
||||
const { item, setItem, state, setRowState } = innerProps
|
||||
const { category, hasPasteData, labels, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { doCreateLabel } = state
|
||||
const { backend, category, hasPasteData, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const remoteBackend = remoteBackendProvider.useRemoteBackend()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const asset = item.item
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
||||
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
||||
const canEditThisAsset =
|
||||
@ -83,10 +80,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
asset.type === backendModule.AssetType.project &&
|
||||
backendModule.IS_OPENING_OR_OPENED[asset.projectState.type]
|
||||
const canExecute =
|
||||
backend.type === backendModule.BackendType.local ||
|
||||
!isCloud ||
|
||||
(self?.permission != null && permissions.PERMISSION_ACTION_CAN_EXECUTE[self.permission])
|
||||
const isOtherUserUsingProject =
|
||||
backend.type !== backendModule.BackendType.local &&
|
||||
isCloud &&
|
||||
backendModule.assetIsProject(asset) &&
|
||||
asset.projectState.openedBy != null &&
|
||||
asset.projectState.openedBy !== user?.email
|
||||
@ -140,7 +137,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
templateId: null,
|
||||
datalinkId: asset.id,
|
||||
preferredName: asset.title,
|
||||
onSpinnerStateChange: null,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
@ -244,27 +240,29 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{asset.type === backendModule.AssetType.secret && canEditThisAsset && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="edit"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<UpsertSecretModal
|
||||
id={asset.id}
|
||||
name={asset.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await backend.updateSecret(asset.id, { value }, asset.title)
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{asset.type === backendModule.AssetType.secret &&
|
||||
canEditThisAsset &&
|
||||
remoteBackend != null && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="edit"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<UpsertSecretModal
|
||||
id={asset.id}
|
||||
name={asset.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await remoteBackend.updateSecret(asset.id, { value }, asset.title)
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCloud && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
@ -289,13 +287,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="delete"
|
||||
label={
|
||||
backend.type === backendModule.BackendType.local
|
||||
? getText('deleteShortcut')
|
||||
: getText('moveToTrashShortcut')
|
||||
}
|
||||
label={isCloud ? getText('moveToTrashShortcut') : getText('deleteShortcut')}
|
||||
doAction={() => {
|
||||
if (backend.type === backendModule.BackendType.remote) {
|
||||
if (isCloud) {
|
||||
unsetModal()
|
||||
doDelete()
|
||||
} else {
|
||||
@ -317,6 +311,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
backend={backend}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
@ -339,10 +334,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManageLabelsModal
|
||||
backend={backend}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
allLabels={labels}
|
||||
doCreateLabel={doCreateLabel}
|
||||
eventTarget={eventTarget}
|
||||
/>
|
||||
)
|
||||
@ -399,9 +393,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
{category === Category.home && (
|
||||
{(category === Category.cloud || category === Category.local) && (
|
||||
<GlobalContextMenu
|
||||
hidden={hidden}
|
||||
backend={backend}
|
||||
hasPasteData={hasPasteData}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
directoryKey={
|
||||
|
@ -9,9 +9,7 @@ import type Backend from '#/services/Backend'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const MS_IN_SECOND = 1000
|
||||
const HUNDRED = 100
|
||||
const HUNDRED_SECONDS = HUNDRED * MS_IN_SECOND
|
||||
const TWO_MINUTES_MS = 120_000
|
||||
|
||||
// ==============================
|
||||
// === useFetchVersionContent ===
|
||||
@ -34,7 +32,7 @@ export function useFetchVersionContent(params: FetchVersionContentOptions) {
|
||||
queryKey: ['versionContent', versionId],
|
||||
queryFn: () => backend.getFileContent(project.id, versionId, project.title),
|
||||
select: data => (metadata ? data : omitMetadata(data)),
|
||||
staleTime: HUNDRED_SECONDS,
|
||||
staleTime: TWO_MINUTES_MS,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A panel containing the description and settings for an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -11,9 +13,10 @@ import AssetProperties from '#/layouts/AssetProperties'
|
||||
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
|
||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
@ -52,6 +55,7 @@ LocalStorage.registerKey('assetPanelTab', {
|
||||
|
||||
/** The subset of {@link AssetPanelProps} that are required to be supplied by the row. */
|
||||
export interface AssetPanelRequiredProps {
|
||||
readonly backend: Backend | null
|
||||
readonly item: assetTreeNode.AnyAssetTreeNode | null
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>> | null
|
||||
}
|
||||
@ -61,14 +65,13 @@ export interface AssetPanelProps extends AssetPanelRequiredProps {
|
||||
readonly isReadonly?: boolean
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly category: Category
|
||||
readonly labels: backend.Label[]
|
||||
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
}
|
||||
|
||||
/** A panel containing the description and settings for an asset. */
|
||||
export default function AssetPanel(props: AssetPanelProps) {
|
||||
const { item, isReadonly = false, setItem, setQuery, category, labels } = props
|
||||
const { backend, item, isReadonly = false, setItem, setQuery, category } = props
|
||||
const { dispatchAssetEvent, dispatchAssetListEvent } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
@ -77,8 +80,8 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
const [tab, setTab] = React.useState(() => {
|
||||
const savedTab = localStorage.get('assetPanelTab') ?? AssetPanelTab.properties
|
||||
if (
|
||||
(item?.item.type === backend.AssetType.secret ||
|
||||
item?.item.type === backend.AssetType.directory) &&
|
||||
(item?.item.type === backendModule.AssetType.secret ||
|
||||
item?.item.type === backendModule.AssetType.directory) &&
|
||||
savedTab === AssetPanelTab.versions
|
||||
) {
|
||||
return AssetPanelTab.properties
|
||||
@ -111,12 +114,15 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
>
|
||||
<div className="flex">
|
||||
{item != null &&
|
||||
item.item.type !== backend.AssetType.secret &&
|
||||
item.item.type !== backend.AssetType.directory && (
|
||||
<UnstyledButton
|
||||
className={`button pointer-events-auto select-none bg-frame px-button-x leading-cozy transition-colors hover:bg-selected-frame ${
|
||||
tab !== AssetPanelTab.versions ? '' : 'bg-selected-frame active'
|
||||
}`}
|
||||
item.item.type !== backendModule.AssetType.secret &&
|
||||
item.item.type !== backendModule.AssetType.directory && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className={tailwindMerge.twMerge(
|
||||
'button pointer-events-auto select-none bg-frame px-button-x leading-cozy transition-colors hover:bg-selected-frame',
|
||||
tab === AssetPanelTab.versions && 'bg-selected-frame active'
|
||||
)}
|
||||
onPress={() => {
|
||||
setTab(oldTab =>
|
||||
oldTab === AssetPanelTab.versions
|
||||
@ -126,12 +132,12 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
}}
|
||||
>
|
||||
{getText('versions')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
{/* Spacing. The top right asset and user bars overlap this area. */}
|
||||
<div className="grow" />
|
||||
</div>
|
||||
{item == null || setItem == null ? (
|
||||
{item == null || setItem == null || backend == null ? (
|
||||
<div className="grid grow place-items-center text-lg">
|
||||
{getText('selectExactlyOneAssetToViewItsDetails')}
|
||||
</div>
|
||||
@ -139,17 +145,21 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
<>
|
||||
{tab === AssetPanelTab.properties && (
|
||||
<AssetProperties
|
||||
backend={backend}
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
category={category}
|
||||
labels={labels}
|
||||
setQuery={setQuery}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
/>
|
||||
)}
|
||||
{tab === AssetPanelTab.versions && (
|
||||
<AssetVersions item={item} dispatchAssetListEvent={dispatchAssetListEvent} />
|
||||
<AssetVersions
|
||||
backend={backend}
|
||||
item={item}
|
||||
dispatchAssetListEvent={dispatchAssetListEvent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
@ -5,10 +5,10 @@ import PenIcon from 'enso-assets/pen.svg'
|
||||
|
||||
import * as datalinkValidator from '#/data/datalinkValidator'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
@ -16,14 +16,15 @@ import type * as assetEvent from '#/events/assetEvent'
|
||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
import DatalinkInput from '#/components/dashboard/DatalinkInput'
|
||||
import Label from '#/components/dashboard/Label'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import Button from '#/components/styled/Button'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
@ -36,10 +37,10 @@ import * as permissions from '#/utilities/permissions'
|
||||
|
||||
/** Props for an {@link AssetPropertiesProps}. */
|
||||
export interface AssetPropertiesProps {
|
||||
readonly backend: Backend
|
||||
readonly item: assetTreeNode.AnyAssetTreeNode
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
|
||||
readonly category: Category
|
||||
readonly labels: backendModule.Label[]
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
readonly isReadonly?: boolean
|
||||
@ -47,18 +48,10 @@ export interface AssetPropertiesProps {
|
||||
|
||||
/** Display and modify the properties of an asset. */
|
||||
export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const {
|
||||
item: itemRaw,
|
||||
setItem: setItemRaw,
|
||||
category,
|
||||
labels,
|
||||
setQuery,
|
||||
isReadonly = false,
|
||||
} = props
|
||||
const { dispatchAssetEvent } = props
|
||||
const { backend, item: itemRaw, setItem: setItemRaw, category, setQuery } = props
|
||||
const { isReadonly = false, dispatchAssetEvent } = props
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [item, setItemInner] = React.useState(itemRaw)
|
||||
@ -81,6 +74,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
},
|
||||
[/* should never change */ setItemRaw]
|
||||
)
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
const self = item.item.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
)
|
||||
@ -92,6 +86,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const isDatalink = item.item.type === backendModule.AssetType.datalink
|
||||
const isDatalinkDisabled = datalinkValue === editedDatalinkValue || !isDatalinkSubmittable
|
||||
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
|
||||
const updateAssetMutation = backendHooks.useBackendMutation(backend, 'updateAsset')
|
||||
|
||||
React.useEffect(() => {
|
||||
setDescription(item.item.description ?? '')
|
||||
}, [item.item.description])
|
||||
@ -99,13 +97,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
React.useEffect(() => {
|
||||
void (async () => {
|
||||
if (item.item.type === backendModule.AssetType.datalink) {
|
||||
const value = await backend.getDatalink(item.item.id, item.item.title)
|
||||
const value = await getDatalinkMutation.mutateAsync([item.item.id, item.item.title])
|
||||
setDatalinkValue(value)
|
||||
setEditedDatalinkValue(value)
|
||||
setIsDatalinkFetched(true)
|
||||
}
|
||||
})()
|
||||
}, [backend, item.item])
|
||||
}, [backend, item.item, getDatalinkMutation])
|
||||
|
||||
const doEditDescription = async () => {
|
||||
setIsEditingDescription(false)
|
||||
@ -114,15 +112,15 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
setItem(oldItem => oldItem.with({ item: object.merge(oldItem.item, { description }) }))
|
||||
try {
|
||||
const projectPath = item.item.projectState?.path
|
||||
await backend.updateAsset(
|
||||
await updateAssetMutation.mutateAsync([
|
||||
item.item.id,
|
||||
{
|
||||
parentDirectoryId: null,
|
||||
description,
|
||||
...(projectPath == null ? {} : { projectPath }),
|
||||
},
|
||||
item.item.title
|
||||
)
|
||||
item.item.title,
|
||||
])
|
||||
} catch (error) {
|
||||
toastAndLog('editDescriptionError')
|
||||
setItem(oldItem =>
|
||||
@ -188,12 +186,14 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
className="-m-multiline-input-p w-full resize-none rounded-input bg-frame p-multiline-input"
|
||||
/>
|
||||
<div className="flex gap-buttons">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="button self-start bg-selected-frame"
|
||||
onPress={doEditDescription}
|
||||
>
|
||||
{getText('update')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
@ -217,7 +217,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
state={{ category, dispatchAssetEvent, setQuery }}
|
||||
state={{ backend, category, dispatchAssetEvent, setQuery }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@ -261,7 +261,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
/>
|
||||
{canEditThisAsset && (
|
||||
<div className="flex gap-buttons">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isDatalinkDisabled}
|
||||
{...(isDatalinkDisabled
|
||||
? { title: 'Edit the Datalink before updating it.' }
|
||||
@ -273,12 +275,14 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const oldDatalinkValue = datalinkValue
|
||||
try {
|
||||
setDatalinkValue(editedDatalinkValue)
|
||||
await backend.createDatalink({
|
||||
datalinkId: item.item.id,
|
||||
name: item.item.title,
|
||||
parentDirectoryId: null,
|
||||
value: editedDatalinkValue,
|
||||
})
|
||||
await createDatalinkMutation.mutateAsync([
|
||||
{
|
||||
datalinkId: item.item.id,
|
||||
name: item.item.title,
|
||||
parentDirectoryId: null,
|
||||
value: editedDatalinkValue,
|
||||
},
|
||||
])
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
setDatalinkValue(oldDatalinkValue)
|
||||
@ -289,8 +293,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
}}
|
||||
>
|
||||
{getText('update')}
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={isDatalinkDisabled}
|
||||
className="button bg-selected-frame enabled:active"
|
||||
onPress={() => {
|
||||
@ -298,7 +304,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
}}
|
||||
>
|
||||
{getText('cancel')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -1,9 +1,13 @@
|
||||
/** @file A search bar containing a text input, and a list of suggestions. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import FindIcon from 'enso-assets/find.svg'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -12,7 +16,7 @@ import Label from '#/components/dashboard/Label'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
@ -108,16 +112,16 @@ function Tags(props: InternalTagsProps) {
|
||||
|
||||
/** Props for a {@link AssetSearchBar}. */
|
||||
export interface AssetSearchBarProps {
|
||||
readonly backend: Backend | null
|
||||
readonly isCloud: boolean
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly labels: backend.Label[]
|
||||
readonly suggestions: Suggestion[]
|
||||
}
|
||||
|
||||
/** A search bar containing a text input, and a list of suggestions. */
|
||||
export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const { isCloud, query, setQuery, labels, suggestions: rawSuggestions } = props
|
||||
const { backend, isCloud, query, setQuery, suggestions: rawSuggestions } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
/** A cached query as of the start of tabbing. */
|
||||
@ -133,6 +137,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const querySource = React.useRef(QuerySource.external)
|
||||
const rootRef = React.useRef<HTMLLabelElement | null>(null)
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -308,7 +313,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-buttons p-search-suggestions"
|
||||
>
|
||||
{labels
|
||||
{[...labels]
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map(label => {
|
||||
const negated = query.negativeLabels.some(term =>
|
||||
@ -356,13 +361,11 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
el?.focus()
|
||||
}
|
||||
}}
|
||||
className={`pointer-events-auto mx-search-suggestion cursor-pointer rounded-default px-search-suggestions py-search-suggestion-y text-left transition-colors last:mb-search-suggestion hover:bg-selected-frame ${
|
||||
index === selectedIndex
|
||||
? 'bg-selected-frame'
|
||||
: selectedIndices.has(index)
|
||||
? 'bg-frame'
|
||||
: ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'pointer-events-auto mx-search-suggestion cursor-pointer rounded-default px-search-suggestions py-search-suggestion-y text-left transition-colors last:mb-search-suggestion hover:bg-selected-frame',
|
||||
selectedIndices.has(index) && 'bg-frame',
|
||||
index === selectedIndex && 'bg-selected-frame'
|
||||
)}
|
||||
onPress={event => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
@ -393,7 +396,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
<FocusRing placement="before">
|
||||
<aria.SearchField
|
||||
aria-label={getText('assetSearchFieldLabel')}
|
||||
className="relative grow before:text before:absolute before:inset-x-button-focus-ring-inset before:my-auto before:rounded-full before:transition-all"
|
||||
className="relative grow before:text before:absolute before:-inset-x-1 before:my-auto before:rounded-full before:transition-all"
|
||||
value={query.query}
|
||||
onKeyDown={event => {
|
||||
event.continuePropagation()
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file Displays information describing a specific version of an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CompareIcon from 'enso-assets/compare.svg'
|
||||
import DuplicateIcon from 'enso-assets/duplicate.svg'
|
||||
import RestoreIcon from 'enso-assets/restore.svg'
|
||||
@ -59,14 +61,17 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2 ${placeholder ? 'opacity-50' : ''}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2',
|
||||
placeholder && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div>
|
||||
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
|
||||
</div>
|
||||
|
||||
<time className="text-not-selected text-xs">
|
||||
<time className="text-xs text-not-selected">
|
||||
{getText('onDateX', dateTime.formatDateTime(new Date(version.lastModified)))}
|
||||
</time>
|
||||
</div>
|
||||
|
@ -5,7 +5,6 @@ import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
@ -17,6 +16,7 @@ import Spinner from '#/components/Spinner'
|
||||
import * as spinnerModule from '#/components/Spinner'
|
||||
|
||||
import * as backendService from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
@ -38,14 +38,14 @@ interface AddNewVersionVariables {
|
||||
|
||||
/** Props for a {@link AssetVersions}. */
|
||||
export interface AssetVersionsProps {
|
||||
readonly backend: Backend
|
||||
readonly item: AssetTreeNode
|
||||
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
}
|
||||
|
||||
/** A list of previous versions of an asset. */
|
||||
export default function AssetVersions(props: AssetVersionsProps) {
|
||||
const { item, dispatchAssetListEvent } = props
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { backend, item, dispatchAssetListEvent } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [placeholderVersions, setPlaceholderVersions] = React.useState<
|
||||
|
@ -2,10 +2,14 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toast from 'react-toastify'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import DropFilesImage from 'enso-assets/drop_files.svg'
|
||||
|
||||
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 toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
@ -40,12 +44,15 @@ import SelectionBrush from '#/components/SelectionBrush'
|
||||
import Spinner, * as spinner from '#/components/Spinner'
|
||||
import Button from '#/components/styled/Button'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import DragModal from '#/modals/DragModal'
|
||||
import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
|
||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
@ -280,8 +287,9 @@ interface DragSelectionInfo {
|
||||
// =============================
|
||||
|
||||
const CATEGORY_TO_FILTER_BY: Readonly<Record<Category, backendModule.FilterBy | null>> = {
|
||||
[Category.cloud]: backendModule.FilterBy.active,
|
||||
[Category.local]: backendModule.FilterBy.active,
|
||||
[Category.recent]: null,
|
||||
[Category.home]: backendModule.FilterBy.active,
|
||||
[Category.trash]: backendModule.FilterBy.trashed,
|
||||
}
|
||||
|
||||
@ -291,19 +299,19 @@ const CATEGORY_TO_FILTER_BY: Readonly<Record<Category, backendModule.FilterBy |
|
||||
|
||||
/** State passed through from a {@link AssetsTable} to every cell. */
|
||||
export interface AssetsTableState {
|
||||
readonly backend: Backend
|
||||
readonly rootDirectoryId: backendModule.DirectoryId
|
||||
readonly selectedKeys: React.MutableRefObject<ReadonlySet<backendModule.AssetId>>
|
||||
readonly scrollContainerRef: React.RefObject<HTMLElement>
|
||||
readonly visibilities: ReadonlyMap<backendModule.AssetId, Visibility>
|
||||
readonly category: Category
|
||||
readonly labels: Map<backendModule.LabelName, backendModule.Label>
|
||||
readonly deletedLabelNames: Set<backendModule.LabelName>
|
||||
readonly hasPasteData: boolean
|
||||
readonly setPasteData: (pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>>) => void
|
||||
readonly sortInfo: sorting.SortInfo<columnUtils.SortableColumn> | null
|
||||
readonly setSortInfo: (sortInfo: sorting.SortInfo<columnUtils.SortableColumn> | null) => void
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
|
||||
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
@ -319,15 +327,12 @@ export interface AssetsTableState {
|
||||
title?: string | null,
|
||||
override?: boolean
|
||||
) => void
|
||||
/** Called when the project is opened via the `ProjectActionButton`. */
|
||||
readonly doOpenManually: (projectId: backendModule.ProjectId) => void
|
||||
readonly doOpenEditor: (
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => void
|
||||
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
|
||||
readonly doCreateLabel: (value: string, color: backendModule.LChColor) => Promise<void>
|
||||
readonly doCopy: () => void
|
||||
readonly doCut: () => void
|
||||
readonly doPaste: (
|
||||
@ -347,19 +352,14 @@ export interface AssetRowState {
|
||||
/** Props for a {@link AssetsTable}. */
|
||||
export interface AssetsTableProps {
|
||||
readonly hidden: boolean
|
||||
readonly hideRows: boolean
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
|
||||
readonly setCanDownload: (canDownload: boolean) => void
|
||||
readonly category: Category
|
||||
readonly allLabels: Map<backendModule.LabelName, backendModule.Label>
|
||||
readonly setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
||||
readonly initialProjectName: string | null
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||
readonly deletedLabelNames: Set<backendModule.LabelName>
|
||||
/** These events will be dispatched the next time the assets list is refreshed, rather than
|
||||
* immediately. */
|
||||
readonly queuedAssetEvents: assetEvent.AssetEvent[]
|
||||
readonly assetListEvents: assetListEvent.AssetListEvent[]
|
||||
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
@ -368,25 +368,25 @@ export interface AssetsTableProps {
|
||||
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
|
||||
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
|
||||
readonly doOpenEditor: (
|
||||
backend: Backend,
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => void
|
||||
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
|
||||
readonly doCreateLabel: (value: string, color: backendModule.LChColor) => Promise<void>
|
||||
}
|
||||
|
||||
/** The table of project assets. */
|
||||
export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { hidden, hideRows, query, setQuery, setCanDownload, category, allLabels } = props
|
||||
const { setSuggestions, deletedLabelNames, initialProjectName, projectStartupInfo } = props
|
||||
const { queuedAssetEvents: rawQueuedAssetEvents } = props
|
||||
const { hidden, query, setQuery, setProjectStartupInfo, setCanDownload, category } = props
|
||||
const { setSuggestions, initialProjectName, projectStartupInfo } = props
|
||||
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
|
||||
const { setAssetPanelProps, doOpenEditor, doCloseEditor: rawDoCloseEditor, doCreateLabel } = props
|
||||
const { targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
const { doOpenEditor: doOpenEditorRaw, doCloseEditor: doCloseEditorRaw } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const backend = backendProvider.useBackend(category)
|
||||
const labels = backendHooks.useBackendListTags(backend)
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
@ -420,6 +420,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
-1
|
||||
)
|
||||
})
|
||||
const [isDropzoneVisible, setIsDropzoneVisible] = 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[]>([])
|
||||
@ -457,7 +459,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
? null
|
||||
: fileInfo.fileExtension(node.item.title).toLowerCase()
|
||||
const assetModifiedAt = new Date(node.item.modifiedAt)
|
||||
const labels: string[] = node.item.labels ?? []
|
||||
const nodeLabels: string[] = node.item.labels ?? []
|
||||
const lowercaseName = node.item.title.toLowerCase()
|
||||
const lowercaseDescription = node.item.description?.toLowerCase() ?? ''
|
||||
const owners =
|
||||
@ -474,7 +476,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
case 'labels': {
|
||||
return labels.length === 0
|
||||
return nodeLabels.length === 0
|
||||
}
|
||||
case 'name': {
|
||||
// Should never be true, but handle it just in case.
|
||||
@ -524,7 +526,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
) &&
|
||||
filterTag(query.names, query.negativeNames, name => globMatch(name, lowercaseName)) &&
|
||||
filterTag(query.labels, query.negativeLabels, label =>
|
||||
labels.some(assetLabel => globMatch(label, assetLabel))
|
||||
nodeLabels.some(assetLabel => globMatch(label, assetLabel))
|
||||
) &&
|
||||
filterTag(query.types, query.negativeTypes, type => type === assetType) &&
|
||||
filterTag(
|
||||
@ -605,6 +607,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
[displayItems, visibilities]
|
||||
)
|
||||
|
||||
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedKeys.size === 0) {
|
||||
targetDirectoryNodeRef.current = null
|
||||
@ -795,28 +799,24 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
case 'label':
|
||||
case '-label': {
|
||||
setSuggestions(
|
||||
!isCloud
|
||||
? []
|
||||
: Array.from(
|
||||
allLabels.values(),
|
||||
(label): assetSearchBar.Suggestion => ({
|
||||
render: () => (
|
||||
<Label active color={label.color} onPress={() => {}}>
|
||||
{label.value}
|
||||
</Label>
|
||||
),
|
||||
addToQuery: oldQuery =>
|
||||
oldQuery.addToLastTerm(
|
||||
negative ? { negativeLabels: [label.value] } : { labels: [label.value] }
|
||||
),
|
||||
deleteFromQuery: oldQuery =>
|
||||
oldQuery.deleteFromLastTerm(
|
||||
negative ? { negativeLabels: [label.value] } : { labels: [label.value] }
|
||||
),
|
||||
})
|
||||
)
|
||||
(labels ?? []).map(
|
||||
(label): assetSearchBar.Suggestion => ({
|
||||
render: () => (
|
||||
<Label active color={label.color} onPress={() => {}}>
|
||||
{label.value}
|
||||
</Label>
|
||||
),
|
||||
addToQuery: oldQuery =>
|
||||
oldQuery.addToLastTerm(
|
||||
negative ? { negativeLabels: [label.value] } : { labels: [label.value] }
|
||||
),
|
||||
deleteFromQuery: oldQuery =>
|
||||
oldQuery.deleteFromLastTerm(
|
||||
negative ? { negativeLabels: [label.value] } : { labels: [label.value] }
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
default: {
|
||||
@ -825,13 +825,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isCloud, assetTree, query, visibilities, allLabels, /* should never change */ setSuggestions])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rawQueuedAssetEvents.length !== 0) {
|
||||
setQueuedAssetEvents(oldEvents => [...oldEvents, ...rawQueuedAssetEvents])
|
||||
}
|
||||
}, [rawQueuedAssetEvents])
|
||||
}, [isCloud, assetTree, query, visibilities, labels, /* should never change */ setSuggestions])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoading(true)
|
||||
@ -1246,7 +1240,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
name={item.item.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await backend.updateSecret(id, { value }, item.item.title)
|
||||
await updateSecretMutation.mutateAsync([id, { value }, item.item.title])
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
@ -1517,7 +1511,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
datalinkId: event.datalinkId,
|
||||
originalId: null,
|
||||
versionId: null,
|
||||
onSpinnerStateChange: event.onSpinnerStateChange,
|
||||
})
|
||||
break
|
||||
}
|
||||
@ -1736,7 +1729,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
datalinkId: null,
|
||||
originalId: event.original.id,
|
||||
versionId: event.versionId,
|
||||
onSpinnerStateChange: null,
|
||||
})
|
||||
break
|
||||
}
|
||||
@ -1805,25 +1797,24 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const doOpenManually = React.useCallback(
|
||||
(projectId: backendModule.ProjectId) => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: projectId,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
runInBackground: false,
|
||||
})
|
||||
const doOpenEditor = React.useCallback(
|
||||
(
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => {
|
||||
doOpenEditorRaw(backend, project, setProject, switchPage)
|
||||
},
|
||||
[/* should never change */ dispatchAssetEvent]
|
||||
[backend, doOpenEditorRaw]
|
||||
)
|
||||
|
||||
const doCloseEditor = React.useCallback(
|
||||
(project: backendModule.ProjectAsset) => {
|
||||
if (project.id === projectStartupInfo?.projectAsset.id) {
|
||||
rawDoCloseEditor(project)
|
||||
doCloseEditorRaw(project)
|
||||
}
|
||||
},
|
||||
[projectStartupInfo, rawDoCloseEditor]
|
||||
[projectStartupInfo, doCloseEditorRaw]
|
||||
)
|
||||
|
||||
const doCopy = React.useCallback(() => {
|
||||
@ -1893,6 +1884,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
() => (
|
||||
<AssetsTableContextMenu
|
||||
hidden
|
||||
backend={backend}
|
||||
category={category}
|
||||
pasteData={pasteData}
|
||||
selectedKeys={selectedKeys}
|
||||
@ -1908,6 +1900,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
/>
|
||||
),
|
||||
[
|
||||
backend,
|
||||
rootDirectoryId,
|
||||
category,
|
||||
selectedKeys,
|
||||
@ -1921,10 +1914,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
]
|
||||
)
|
||||
|
||||
const onDragOver = (event: React.DragEvent<Element>) => {
|
||||
const onDropzoneDragOver = (event: React.DragEvent<Element>) => {
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
const filtered = payload?.filter(item => item.asset.parentId !== rootDirectoryId)
|
||||
if ((filtered != null && filtered.length > 0) || event.dataTransfer.types.includes('Files')) {
|
||||
if (filtered != null && filtered.length > 0) {
|
||||
event.preventDefault()
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
setIsDropzoneVisible(true)
|
||||
setDroppedFilesCount(event.dataTransfer.items.length)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
@ -1932,19 +1929,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const state = React.useMemo<AssetsTableState>(
|
||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||
() => ({
|
||||
backend,
|
||||
rootDirectoryId,
|
||||
visibilities,
|
||||
selectedKeys: selectedKeysRef,
|
||||
scrollContainerRef: rootRef,
|
||||
category,
|
||||
labels: allLabels,
|
||||
deletedLabelNames,
|
||||
hasPasteData: pasteData != null,
|
||||
setPasteData,
|
||||
sortInfo,
|
||||
setSortInfo,
|
||||
query,
|
||||
setQuery,
|
||||
setProjectStartupInfo,
|
||||
assetEvents,
|
||||
dispatchAssetEvent,
|
||||
dispatchAssetListEvent,
|
||||
@ -1953,35 +1950,31 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
nodeMap: nodeMapRef,
|
||||
hideColumn,
|
||||
doToggleDirectoryExpansion,
|
||||
doOpenManually,
|
||||
doOpenEditor: doOpenEditor,
|
||||
doCloseEditor: doCloseEditor,
|
||||
doCreateLabel,
|
||||
doOpenEditor,
|
||||
doCloseEditor,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
}),
|
||||
[
|
||||
backend,
|
||||
rootDirectoryId,
|
||||
visibilities,
|
||||
category,
|
||||
allLabels,
|
||||
deletedLabelNames,
|
||||
pasteData,
|
||||
sortInfo,
|
||||
assetEvents,
|
||||
query,
|
||||
doToggleDirectoryExpansion,
|
||||
doOpenManually,
|
||||
doOpenEditor,
|
||||
doCloseEditor,
|
||||
doCreateLabel,
|
||||
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,
|
||||
@ -2250,7 +2243,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
columns={columns}
|
||||
item={item}
|
||||
state={state}
|
||||
hidden={hideRows || visibilities.get(item.key) === Visibility.hidden}
|
||||
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
|
||||
selected={isSelected}
|
||||
setSelected={selected => {
|
||||
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
|
||||
@ -2341,9 +2334,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (labels != null) {
|
||||
for (const label of labels) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
@ -2385,9 +2378,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
event.stopPropagation()
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (labels != null) {
|
||||
for (const label of labels) {
|
||||
const nodeLabels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
if (nodeLabels != null) {
|
||||
for (const label of nodeLabels) {
|
||||
if (payload.has(label)) {
|
||||
labelsPresent += 1
|
||||
}
|
||||
@ -2413,6 +2406,12 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
)
|
||||
|
||||
const dropzoneText = isDropzoneVisible
|
||||
? droppedFilesCount === 1
|
||||
? getText('assetsDropFileDescription')
|
||||
: getText('assetsDropFilesDescription', droppedFilesCount)
|
||||
: getText('assetsDropzoneDescription')
|
||||
|
||||
const table = (
|
||||
<div
|
||||
className="flex grow flex-col"
|
||||
@ -2421,6 +2420,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<AssetsTableContextMenu
|
||||
backend={backend}
|
||||
category={category}
|
||||
pasteData={pasteData}
|
||||
selectedKeys={selectedKeys}
|
||||
@ -2436,6 +2436,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
/>
|
||||
)
|
||||
}}
|
||||
onDragEnter={onDropzoneDragOver}
|
||||
onDragLeave={event => {
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (
|
||||
@ -2458,15 +2459,17 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
<tr className="hidden h-row first:table-row">
|
||||
<td colSpan={columns.length} className="bg-transparent">
|
||||
{category === Category.trash ? (
|
||||
query.query !== '' ? (
|
||||
<aria.Text className="px-cell-x placeholder">
|
||||
{getText('noFilesMatchTheCurrentFilters')}
|
||||
</aria.Text>
|
||||
) : (
|
||||
<aria.Text className="px-cell-x placeholder">
|
||||
{getText('yourTrashIsEmpty')}
|
||||
</aria.Text>
|
||||
)
|
||||
<aria.Text className="px-cell-x placeholder">
|
||||
{query.query !== ''
|
||||
? getText('noFilesMatchTheCurrentFilters')
|
||||
: getText('yourTrashIsEmpty')}
|
||||
</aria.Text>
|
||||
) : category === Category.recent ? (
|
||||
<aria.Text className="px-cell-x placeholder">
|
||||
{query.query !== ''
|
||||
? getText('noFilesMatchTheCurrentFilters')
|
||||
: getText('youHaveNoRecentProjects')}
|
||||
</aria.Text>
|
||||
) : query.query !== '' ? (
|
||||
<aria.Text className="px-cell-x placeholder">
|
||||
{getText('noFilesMatchTheCurrentFilters')}
|
||||
@ -2479,13 +2482,12 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
data-testid="root-directory-dropzone"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
setSelectedKeys(new Set())
|
||||
}}
|
||||
onDragEnter={onDragOver}
|
||||
onDragOver={onDragOver}
|
||||
className={tailwindMerge.twMerge(
|
||||
'sticky left grid max-w-container grow place-items-center',
|
||||
category !== Category.cloud && category !== Category.local && 'hidden'
|
||||
)}
|
||||
onDragEnter={onDropzoneDragOver}
|
||||
onDragOver={onDropzoneDragOver}
|
||||
onDrop={event => {
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
const filtered = payload?.filter(item => item.asset.parentId !== rootDirectoryId)
|
||||
@ -2499,95 +2501,144 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
newParentId: rootDirectoryId,
|
||||
ids: new Set(filtered.map(dragItem => dragItem.asset.id)),
|
||||
})
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedKeys(new Set())
|
||||
}}
|
||||
>
|
||||
<aria.FileTrigger
|
||||
onSelect={event => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
files: Array.from(event ?? []),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
>
|
||||
<FocusRing>
|
||||
<aria.Button
|
||||
className="my-20 flex flex-col items-center gap-3 text-primary/30 transition-colors duration-200 hover:text-primary/50"
|
||||
onPress={() => {}}
|
||||
>
|
||||
<SvgMask src={DropFilesImage} className="size-[186px]" />
|
||||
{dropzoneText}
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
</aria.FileTrigger>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<FocusArea direction="vertical">
|
||||
{innerProps => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
|
||||
ref: rootRef,
|
||||
className: 'flex-1 overflow-auto container-size',
|
||||
onKeyDown,
|
||||
onScroll,
|
||||
onBlur: event => {
|
||||
if (
|
||||
event.relatedTarget instanceof HTMLElement &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setKeyboardSelectedIndex(null)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
{!hidden && hiddenContextMenu}
|
||||
{!hidden && (
|
||||
<SelectionBrush
|
||||
targetRef={rootRef}
|
||||
onDrag={onSelectionDrag}
|
||||
onDragEnd={onSelectionDragEnd}
|
||||
onDragCancel={onSelectionDragCancel}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-max min-h-full w-max min-w-full flex-col">
|
||||
{isCloud && (
|
||||
<div className="flex-0 sticky top flex h flex-col">
|
||||
<div
|
||||
data-testid="extra-columns"
|
||||
className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y"
|
||||
>
|
||||
<FocusArea direction="horizontal">
|
||||
{columnsBarProps => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(columnsBarProps, {
|
||||
className: 'inline-flex gap-icons',
|
||||
onFocus: () => {
|
||||
setKeyboardSelectedIndex(null)
|
||||
},
|
||||
})}
|
||||
>
|
||||
{columnUtils.CLOUD_COLUMNS.filter(
|
||||
column => !enabledColumns.has(column)
|
||||
).map(column => (
|
||||
<Button
|
||||
key={column}
|
||||
active
|
||||
image={columnUtils.COLUMN_ICONS[column]}
|
||||
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
|
||||
onPress={() => {
|
||||
const newExtraColumns = new Set(enabledColumns)
|
||||
if (enabledColumns.has(column)) {
|
||||
newExtraColumns.delete(column)
|
||||
} else {
|
||||
newExtraColumns.add(column)
|
||||
}
|
||||
setEnabledColumns(newExtraColumns)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative grow">
|
||||
<FocusArea direction="vertical">
|
||||
{innerProps => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
|
||||
ref: rootRef,
|
||||
className: 'flex-1 overflow-auto container-size w-full h-full',
|
||||
onKeyDown,
|
||||
onScroll,
|
||||
onBlur: event => {
|
||||
if (
|
||||
event.relatedTarget instanceof HTMLElement &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setKeyboardSelectedIndex(null)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
{!hidden && hiddenContextMenu}
|
||||
{!hidden && (
|
||||
<SelectionBrush
|
||||
targetRef={rootRef}
|
||||
onDrag={onSelectionDrag}
|
||||
onDragEnd={onSelectionDragEnd}
|
||||
onDragCancel={onSelectionDragCancel}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
|
||||
<div className="flex h-max min-h-full w-max min-w-full flex-col">
|
||||
{isCloud && (
|
||||
<div className="flex-0 sticky top flex h flex-col">
|
||||
<div
|
||||
data-testid="extra-columns"
|
||||
className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y"
|
||||
>
|
||||
<FocusArea direction="horizontal">
|
||||
{columnsBarProps => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(columnsBarProps, {
|
||||
className: 'inline-flex gap-icons',
|
||||
onFocus: () => {
|
||||
setKeyboardSelectedIndex(null)
|
||||
},
|
||||
})}
|
||||
>
|
||||
{columnUtils.CLOUD_COLUMNS.filter(
|
||||
column => !enabledColumns.has(column)
|
||||
).map(column => (
|
||||
<Button
|
||||
key={column}
|
||||
light
|
||||
image={columnUtils.COLUMN_ICONS[column]}
|
||||
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
|
||||
onPress={() => {
|
||||
const newExtraColumns = new Set(enabledColumns)
|
||||
if (enabledColumns.has(column)) {
|
||||
newExtraColumns.delete(column)
|
||||
} else {
|
||||
newExtraColumns.add(column)
|
||||
}
|
||||
setEnabledColumns(newExtraColumns)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
|
||||
</div>
|
||||
</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}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -3,7 +3,6 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -11,7 +10,7 @@ import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
|
||||
import GlobalContextMenu from '#/layouts/GlobalContextMenu'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
@ -21,6 +20,7 @@ import ContextMenus from '#/components/ContextMenus'
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
||||
@ -34,6 +34,7 @@ import * as uniqueString from '#/utilities/uniqueString'
|
||||
/** Props for an {@link AssetsTableContextMenu}. */
|
||||
export interface AssetsTableContextMenuProps {
|
||||
readonly hidden?: boolean
|
||||
readonly backend: Backend
|
||||
readonly category: Category
|
||||
readonly rootDirectoryId: backendModule.DirectoryId
|
||||
readonly pasteData: pasteDataModule.PasteData<ReadonlySet<backendModule.AssetId>> | null
|
||||
@ -56,14 +57,13 @@ export interface AssetsTableContextMenuProps {
|
||||
/** A context menu for an `AssetsTable`, when no row is selected, or multiple rows
|
||||
* are selected. */
|
||||
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
|
||||
const { category, pasteData, selectedKeys, clearSelectedKeys, nodeMapRef, event } = props
|
||||
const { dispatchAssetEvent, dispatchAssetListEvent, rootDirectoryId, hidden = false } = props
|
||||
const { hidden = false, backend, category, pasteData, selectedKeys, clearSelectedKeys } = props
|
||||
const { nodeMapRef, event, dispatchAssetEvent, dispatchAssetListEvent, rootDirectoryId } = props
|
||||
const { doCopy, doCut, doPaste } = props
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
|
||||
// This works because all items are mutated, ensuring their value stays
|
||||
// up to date.
|
||||
@ -149,7 +149,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
</ContextMenu>
|
||||
</ContextMenus>
|
||||
)
|
||||
} else if (category !== Category.home) {
|
||||
} else if (category !== Category.cloud && category !== Category.local) {
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
@ -203,6 +203,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
)}
|
||||
<GlobalContextMenu
|
||||
hidden={hidden}
|
||||
backend={backend}
|
||||
hasPasteData={pasteData != null}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
directoryKey={null}
|
||||
|
@ -1,65 +0,0 @@
|
||||
/** @file Switcher for choosing the project management backend. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CloudIcon from 'enso-assets/cloud.svg'
|
||||
import NotCloudIcon from 'enso-assets/not_cloud.svg'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
// =======================
|
||||
// === BackendSwitcher ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link BackendSwitcher}. */
|
||||
export interface BackendSwitcherProps {
|
||||
readonly setBackendType: (backendType: backendModule.BackendType) => void
|
||||
}
|
||||
|
||||
/** Switcher for choosing the project management backend. */
|
||||
export default function BackendSwitcher(props: BackendSwitcherProps) {
|
||||
const { setBackendType } = props
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className="flex shrink-0 gap-px" {...innerProps}>
|
||||
<UnstyledButton
|
||||
isDisabled={isCloud}
|
||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||
onPress={() => {
|
||||
setBackendType(backendModule.BackendType.remote)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-icon-with-text">
|
||||
<SvgMask src={CloudIcon} />
|
||||
<aria.Label className="text">{getText('cloud')}</aria.Label>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
isDisabled={!isCloud}
|
||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||
onPress={() => {
|
||||
setBackendType(backendModule.BackendType.local)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-icon-with-text">
|
||||
<SvgMask src={NotCloudIcon} />
|
||||
<aria.Label className="text">{getText('local')}</aria.Label>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
/** @file Switcher to choose the currently visible assets table category. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Home2Icon from 'enso-assets/home2.svg'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CloudIcon from 'enso-assets/cloud.svg'
|
||||
import NotCloudIcon from 'enso-assets/not_cloud.svg'
|
||||
import RecentIcon from 'enso-assets/recent.svg'
|
||||
import Trash2Icon from 'enso-assets/trash2.svg'
|
||||
|
||||
@ -9,6 +12,7 @@ import type * as text from '#/text'
|
||||
|
||||
import * as mimeTypes from '#/data/mimeTypes'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -19,9 +23,9 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
@ -42,7 +46,21 @@ interface CategoryMetadata {
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const CATEGORY_DATA: CategoryMetadata[] = [
|
||||
const CATEGORY_DATA: readonly CategoryMetadata[] = [
|
||||
{
|
||||
category: Category.cloud,
|
||||
icon: CloudIcon,
|
||||
textId: 'cloudCategory',
|
||||
buttonTextId: 'cloudCategoryButtonLabel',
|
||||
dropZoneTextId: 'cloudCategoryDropZoneLabel',
|
||||
},
|
||||
{
|
||||
category: Category.local,
|
||||
icon: NotCloudIcon,
|
||||
textId: 'localCategory',
|
||||
buttonTextId: 'localCategoryButtonLabel',
|
||||
dropZoneTextId: 'localCategoryDropZoneLabel',
|
||||
},
|
||||
{
|
||||
category: Category.recent,
|
||||
icon: RecentIcon,
|
||||
@ -50,13 +68,6 @@ const CATEGORY_DATA: CategoryMetadata[] = [
|
||||
buttonTextId: 'recentCategoryButtonLabel',
|
||||
dropZoneTextId: 'recentCategoryDropZoneLabel',
|
||||
},
|
||||
{
|
||||
category: Category.home,
|
||||
icon: Home2Icon,
|
||||
textId: 'homeCategory',
|
||||
buttonTextId: 'homeCategoryButtonLabel',
|
||||
dropZoneTextId: 'homeCategoryDropZoneLabel',
|
||||
},
|
||||
{
|
||||
category: Category.trash,
|
||||
icon: Trash2Icon,
|
||||
@ -95,16 +106,19 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
||||
className="group relative flex items-center rounded-full drop-target-after"
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
tooltip={false}
|
||||
className={`rounded-inherit ${isCurrent ? 'focus-default' : ''}`}
|
||||
className={tailwindMerge.twMerge('rounded-full', isCurrent && 'focus-default')}
|
||||
aria-label={getText(buttonTextId)}
|
||||
onPress={onPress}
|
||||
>
|
||||
<div
|
||||
className={`selectable ${
|
||||
isCurrent ? 'disabled bg-selected-frame active' : ''
|
||||
} group flex h-row items-center gap-icon-with-text rounded-inherit px-button-x hover:bg-selected-frame`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'group flex h-row items-center gap-icon-with-text rounded-full px-button-x selectable',
|
||||
isCurrent ? 'disabled active' : 'hover:bg-selected-frame'
|
||||
)}
|
||||
>
|
||||
<SvgMask
|
||||
src={icon}
|
||||
@ -116,7 +130,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
||||
/>
|
||||
<aria.Text slot="description">{getText(textId)}</aria.Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
<div className="absolute left-full ml-2 hidden group-focus-visible:block">
|
||||
{getText('drop')}
|
||||
</div>
|
||||
@ -137,10 +151,27 @@ export interface CategorySwitcherProps {
|
||||
|
||||
/** A switcher to choose the currently visible assets table category. */
|
||||
export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
const { category, setCategory, dispatchAssetEvent } = props
|
||||
const { category, setCategory } = props
|
||||
const { dispatchAssetEvent } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const categoryData = React.useMemo(
|
||||
() =>
|
||||
CATEGORY_DATA.filter(data => {
|
||||
switch (data.category) {
|
||||
case Category.local: {
|
||||
return localBackend != null
|
||||
}
|
||||
default: {
|
||||
return remoteBackend != null
|
||||
}
|
||||
}
|
||||
}),
|
||||
[remoteBackend, localBackend]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.set('driveCategory', category)
|
||||
@ -161,7 +192,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
role="grid"
|
||||
className="flex flex-col items-start"
|
||||
>
|
||||
{CATEGORY_DATA.map(data => (
|
||||
{categoryData.map(data => (
|
||||
<CategorySwitcherItem
|
||||
key={data.category}
|
||||
id={data.category}
|
||||
@ -171,7 +202,8 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
setCategory(data.category)
|
||||
}}
|
||||
acceptedDragTypes={
|
||||
(category === Category.trash && data.category === Category.home) ||
|
||||
(category === Category.trash &&
|
||||
(data.category === Category.cloud || data.category === Category.local)) ||
|
||||
(category !== Category.trash && data.category === Category.trash)
|
||||
? [mimeTypes.ASSETS_MIME_TYPE]
|
||||
: []
|
||||
|
@ -6,11 +6,22 @@
|
||||
|
||||
/** The categories available in the category switcher. */
|
||||
enum Category {
|
||||
cloud = 'cloud',
|
||||
local = 'local',
|
||||
recent = 'recent',
|
||||
home = 'home',
|
||||
trash = 'trash',
|
||||
}
|
||||
|
||||
// This is REQUIRED, as `export default enum` is invalid syntax.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export default Category
|
||||
|
||||
// ===============
|
||||
// === isCloud ===
|
||||
// ===============
|
||||
|
||||
/** Return `true` if the category is only accessible from the cloud.
|
||||
* Return `false` if the category is only accessibly locally. */
|
||||
export function isCloud(category: Category) {
|
||||
return category !== Category.local
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactDom from 'react-dom'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CloseLargeIcon from 'enso-assets/close_large.svg'
|
||||
import DefaultUserIcon from 'enso-assets/default_user.svg'
|
||||
@ -16,9 +17,9 @@ import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import Twemoji from '#/components/Twemoji'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as newtype from '#/utilities/newtype'
|
||||
@ -110,9 +111,16 @@ function ReactionBar(props: ReactionBarProps) {
|
||||
const { selectedReactions, doReact, doRemoveReaction, className } = props
|
||||
|
||||
return (
|
||||
<div className={`m-chat-reaction-bar inline-block rounded-full bg-frame ${className ?? ''}`}>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'm-chat-reaction-bar inline-block rounded-full bg-frame',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{REACTION_EMOJIS.map(emoji => (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
key={emoji}
|
||||
onPress={() => {
|
||||
if (selectedReactions.has(emoji)) {
|
||||
@ -121,12 +129,13 @@ function ReactionBar(props: ReactionBarProps) {
|
||||
doReact(emoji)
|
||||
}
|
||||
}}
|
||||
className={`m-chat-reaction rounded-full p-chat-reaction selectable hover:bg-hover-bg hover:grayscale-0 ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'm-chat-reaction rounded-full p-chat-reaction selectable hover:bg-hover-bg hover:grayscale-0',
|
||||
selectedReactions.has(emoji) ? 'active' : 'grayscale'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<Twemoji key={emoji} emoji={emoji} size={REACTION_BUTTON_SIZE} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@ -265,16 +274,19 @@ function ChatHeader(props: InternalChatHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-chat-header-x mt-chat-header-t flex text-sm font-semibold">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="flex grow items-center gap-icon-with-text"
|
||||
onPress={() => {
|
||||
setIsThreadListVisible(visible => !visible)
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
className={`shrink-0 transition-transform duration-arrow ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'shrink-0 transition-transform duration-arrow',
|
||||
isThreadListVisible ? '-rotate-90' : 'rotate-90'
|
||||
}`}
|
||||
)}
|
||||
src={FolderArrowIcon}
|
||||
/>
|
||||
<div className="grow">
|
||||
@ -316,26 +328,33 @@ function ChatHeader(props: InternalChatHeaderProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton className="mx-close-icon" onPress={doClose}>
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="mx-close-icon"
|
||||
onPress={doClose}
|
||||
>
|
||||
<img src={CloseLargeIcon} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
<div className="relative text-sm font-semibold">
|
||||
<div
|
||||
className={`absolute z-1 grid w-full overflow-hidden bg-frame shadow-soft backdrop-blur-default transition-grid-template-rows clip-path-bottom-shadow ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'absolute z-1 grid w-full overflow-hidden bg-frame shadow-soft backdrop-blur-default transition-grid-template-rows clip-path-bottom-shadow',
|
||||
isThreadListVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<div className="max-h-chat-thread-list min-h overflow-y-auto">
|
||||
{threads.map(thread => (
|
||||
<div
|
||||
key={thread.id}
|
||||
className={`flex p-chat-thread-button ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex p-chat-thread-button',
|
||||
thread.id === threadId
|
||||
? 'cursor-default bg-selected-frame'
|
||||
: 'cursor-pointer hover:bg-frame'
|
||||
}`}
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (thread.id !== threadId) {
|
||||
@ -683,7 +702,10 @@ export default function Chat(props: ChatProps) {
|
||||
|
||||
return reactDom.createPortal(
|
||||
<div
|
||||
className={`fixed right top z-1 flex h-screen w-chat flex-col py-chat-y text-xs text-primary shadow-soft backdrop-blur-default transition-[transform,opacity] ${isOpen ? 'opacity-1' : 'translate-x-full opacity-0'}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'fixed right top z-1 flex h-screen w-chat flex-col py-chat-y text-xs text-primary shadow-soft backdrop-blur-default transition-[transform,opacity]',
|
||||
isOpen ? 'opacity-1' : 'translate-x-full opacity-0'
|
||||
)}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<ChatHeader
|
||||
@ -800,18 +822,23 @@ export default function Chat(props: ChatProps) {
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-chat-buttons">
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isReplyEnabled}
|
||||
className={`text-xxs grow rounded-full px-chat-button-x py-chat-button-y text-left text-white ${
|
||||
className={tailwindMerge.twMerge(
|
||||
'text-xxs grow rounded-full px-chat-button-x py-chat-button-y text-left text-white',
|
||||
isReplyEnabled ? 'bg-gray-400' : 'bg-gray-300'
|
||||
}`}
|
||||
)}
|
||||
onPress={() => {
|
||||
sendCurrentMessage(true)
|
||||
}}
|
||||
>
|
||||
{getText('clickForNewQuestion')}
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
isDisabled={!isReplyEnabled}
|
||||
className="rounded-full bg-blue-600/90 px-chat-button-x py-chat-button-y text-white selectable enabled:active"
|
||||
onPress={() => {
|
||||
@ -819,18 +846,20 @@ export default function Chat(props: ChatProps) {
|
||||
}}
|
||||
>
|
||||
{getText('replyExclamation')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</form>
|
||||
{!isPaidUser && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
// This UI element does not appear anywhere else.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
className="bg-call-to-action/90 mx-2 my-1 rounded-default p-2 text-center leading-cozy text-white"
|
||||
className="mx-2 my-1 text-wrap rounded-2xl bg-call-to-action/90 p-2 text-center leading-cozy text-white hover:bg-call-to-action"
|
||||
onPress={upgradeToPro}
|
||||
>
|
||||
{getText('upgradeToProNag')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>,
|
||||
container
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactDom from 'react-dom'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import CloseLargeIcon from 'enso-assets/close_large.svg'
|
||||
|
||||
@ -14,7 +15,7 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as chat from '#/layouts/Chat'
|
||||
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
/** Props for a {@link ChatPlaceholder}. */
|
||||
export interface ChatPlaceholderProps {
|
||||
@ -40,13 +41,21 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
||||
} else {
|
||||
return reactDom.createPortal(
|
||||
<div
|
||||
className={`fixed right top z-1 flex h-screen w-chat flex-col py-chat-y text-xs text-primary shadow-soft backdrop-blur-default transition-transform ${isOpen ? '' : 'translate-x-full'}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'fixed right top z-1 flex h-screen w-chat flex-col py-chat-y text-xs text-primary shadow-soft backdrop-blur-default transition-transform',
|
||||
!isOpen && 'translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="mx-chat-header-x mt-chat-header-t flex text-sm font-semibold">
|
||||
<div className="grow" />
|
||||
<UnstyledButton className="mx-close-icon" onPress={doClose}>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="mx-close-icon"
|
||||
onPress={doClose}
|
||||
>
|
||||
<img src={CloseLargeIcon} />
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
<div className="grid grow place-items-center">
|
||||
<div className="flex flex-col gap-status-page text-center text-base">
|
||||
@ -54,24 +63,28 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
||||
{getText('placeholderChatPrompt')}
|
||||
</div>
|
||||
{!hideLoginButtons && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="button self-center bg-help text-white"
|
||||
onPress={() => {
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
}}
|
||||
>
|
||||
{getText('login')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
{!hideLoginButtons && (
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="button self-center bg-help text-white"
|
||||
onPress={() => {
|
||||
navigate(appUtils.REGISTRATION_PATH)
|
||||
}}
|
||||
>
|
||||
{getText('register')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file The directory header bar and directory item listing. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as eventCallback from '#/hooks/eventCallbackHooks'
|
||||
@ -13,7 +15,6 @@ import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
@ -21,23 +22,21 @@ import type * as assetPanel from '#/layouts/AssetPanel'
|
||||
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
|
||||
import AssetsTable from '#/layouts/AssetsTable'
|
||||
import CategorySwitcher from '#/layouts/CategorySwitcher'
|
||||
import Category from '#/layouts/CategorySwitcher/Category'
|
||||
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
|
||||
import DriveBar from '#/layouts/DriveBar'
|
||||
import Labels from '#/layouts/Labels'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as result from '#/components/Result'
|
||||
import type * as spinner from '#/components/Spinner'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as download from '#/utilities/download'
|
||||
import * as github from '#/utilities/github'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// ===================
|
||||
// === DriveStatus ===
|
||||
@ -65,27 +64,21 @@ enum DriveStatus {
|
||||
export interface DriveProps {
|
||||
readonly category: Category
|
||||
readonly setCategory: (category: Category) => void
|
||||
readonly supportsLocalBackend: boolean
|
||||
readonly hidden: boolean
|
||||
readonly hideRows: boolean
|
||||
readonly initialProjectName: string | null
|
||||
/** These events will be dispatched the next time the assets list is refreshed, rather than
|
||||
* immediately. */
|
||||
readonly queuedAssetEvents: assetEvent.AssetEvent[]
|
||||
readonly assetListEvents: assetListEvent.AssetListEvent[]
|
||||
readonly dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
readonly dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly labels: backendModule.Label[]
|
||||
readonly setLabels: React.Dispatch<React.SetStateAction<backendModule.Label[]>>
|
||||
readonly setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
|
||||
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
|
||||
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
|
||||
readonly doCreateProject: (templateId: string | null) => void
|
||||
readonly doOpenEditor: (
|
||||
backend: Backend,
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
@ -95,29 +88,21 @@ export interface DriveProps {
|
||||
|
||||
/** Contains directory path and directory contents (projects, folders, secrets and files). */
|
||||
export default function Drive(props: DriveProps) {
|
||||
const { supportsLocalBackend, hidden, hideRows, initialProjectName, queuedAssetEvents } = props
|
||||
const { query, setQuery, labels, setLabels, setSuggestions, projectStartupInfo } = props
|
||||
const { hidden, initialProjectName, query, setQuery } = props
|
||||
const { setSuggestions, projectStartupInfo, setProjectStartupInfo } = props
|
||||
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
|
||||
const { setAssetPanelProps, doOpenEditor, doCloseEditor } = props
|
||||
const { setIsAssetPanelTemporarilyVisible } = props
|
||||
const { category, setCategory } = props
|
||||
const { setIsAssetPanelTemporarilyVisible, category, setCategory } = props
|
||||
|
||||
const navigate = navigateHooks.useNavigate()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const backend = backendProvider.useBackend(category)
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
const [canDownload, setCanDownload] = React.useState(false)
|
||||
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
|
||||
const [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
|
||||
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
|
||||
new Set<backendModule.LabelName>()
|
||||
)
|
||||
const allLabels = React.useMemo(
|
||||
() => new Map(labels.map(label => [label.value, label])),
|
||||
[labels]
|
||||
)
|
||||
const rootDirectoryId = React.useMemo(
|
||||
() => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''),
|
||||
[backend, user]
|
||||
@ -125,7 +110,7 @@ export default function Drive(props: DriveProps) {
|
||||
const targetDirectoryNodeRef = React.useRef<AssetTreeNode<backendModule.DirectoryAsset> | null>(
|
||||
null
|
||||
)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
const status =
|
||||
!isCloud && didLoadingProjectManagerFail
|
||||
? DriveStatus.noProjectManager
|
||||
@ -156,14 +141,6 @@ export default function Drive(props: DriveProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
void (async () => {
|
||||
if (backend.type !== backendModule.BackendType.local && user?.isEnabled === true) {
|
||||
setLabels(await backend.listTags())
|
||||
}
|
||||
})()
|
||||
}, [backend, user?.isEnabled, /* should never change */ setLabels])
|
||||
|
||||
const doUploadFiles = React.useCallback(
|
||||
(files: File[]) => {
|
||||
if (isCloud && sessionType === authProvider.UserSessionType.offline) {
|
||||
@ -192,11 +169,7 @@ export default function Drive(props: DriveProps) {
|
||||
}, [/* should never change */ dispatchAssetListEvent])
|
||||
|
||||
const doCreateProject = React.useCallback(
|
||||
(
|
||||
templateId: string | null = null,
|
||||
templateName: string | null = null,
|
||||
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null = null
|
||||
) => {
|
||||
(templateId: string | null = null, templateName: string | null = null) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newProject,
|
||||
parentKey: targetDirectoryNodeRef.current?.key ?? rootDirectoryId,
|
||||
@ -204,7 +177,6 @@ export default function Drive(props: DriveProps) {
|
||||
templateId,
|
||||
datalinkId: null,
|
||||
preferredName: templateName,
|
||||
onSpinnerStateChange,
|
||||
})
|
||||
},
|
||||
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
|
||||
@ -218,59 +190,6 @@ export default function Drive(props: DriveProps) {
|
||||
})
|
||||
}, [rootDirectoryId, /* should never change */ dispatchAssetListEvent])
|
||||
|
||||
const doCreateLabel = React.useCallback(
|
||||
async (value: string, color: backendModule.LChColor) => {
|
||||
const newLabelName = backendModule.LabelName(value)
|
||||
const placeholderLabel: backendModule.Label = {
|
||||
id: backendModule.TagId(uniqueString.uniqueString()),
|
||||
value: newLabelName,
|
||||
color,
|
||||
}
|
||||
setNewLabelNames(labelNames => new Set([...labelNames, newLabelName]))
|
||||
setLabels(oldLabels => [...oldLabels, placeholderLabel])
|
||||
try {
|
||||
const newLabel = await backend.createTag({ value, color })
|
||||
setLabels(oldLabels =>
|
||||
oldLabels.map(oldLabel => (oldLabel.id === placeholderLabel.id ? newLabel : oldLabel))
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
setLabels(oldLabels => oldLabels.filter(oldLabel => oldLabel.id !== placeholderLabel.id))
|
||||
}
|
||||
setNewLabelNames(
|
||||
labelNames => new Set([...labelNames].filter(labelName => labelName !== newLabelName))
|
||||
)
|
||||
},
|
||||
[backend, toastAndLog, /* should never change */ setLabels]
|
||||
)
|
||||
|
||||
const doDeleteLabel = React.useCallback(
|
||||
async (id: backendModule.TagId, value: backendModule.LabelName) => {
|
||||
setDeletedLabelNames(oldNames => new Set([...oldNames, value]))
|
||||
setQuery(oldQuery => oldQuery.deleteFromEveryTerm({ labels: [value] }))
|
||||
try {
|
||||
await backend.deleteTag(id, value)
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.deleteLabel,
|
||||
labelName: value,
|
||||
})
|
||||
setLabels(oldLabels => oldLabels.filter(oldLabel => oldLabel.id !== id))
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
setDeletedLabelNames(
|
||||
oldNames => new Set([...oldNames].filter(oldValue => oldValue !== value))
|
||||
)
|
||||
},
|
||||
[
|
||||
backend,
|
||||
toastAndLog,
|
||||
/* should never change */ setQuery,
|
||||
/* should never change */ dispatchAssetEvent,
|
||||
/* should never change */ setLabels,
|
||||
]
|
||||
)
|
||||
|
||||
const doCreateSecret = React.useCallback(
|
||||
(name: string, value: string) => {
|
||||
dispatchAssetListEvent({
|
||||
@ -300,24 +219,26 @@ export default function Drive(props: DriveProps) {
|
||||
switch (status) {
|
||||
case DriveStatus.offline: {
|
||||
return (
|
||||
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
|
||||
<div className={tailwindMerge.twMerge('grid grow place-items-center', hidden && 'hidden')}>
|
||||
<div className="flex flex-col gap-status-page text-center text-base">
|
||||
<div>{getText('youAreNotLoggedIn')}</div>
|
||||
<UnstyledButton
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="button self-center bg-help text-white"
|
||||
onPress={() => {
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
}}
|
||||
>
|
||||
{getText('login')}
|
||||
</UnstyledButton>
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case DriveStatus.noProjectManager: {
|
||||
return (
|
||||
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
|
||||
<div className={tailwindMerge.twMerge('grid grow place-items-center', hidden && 'hidden')}>
|
||||
<div className="flex flex-col gap-status-page text-center text-base">
|
||||
{getText('couldNotConnectToPM')}
|
||||
</div>
|
||||
@ -330,10 +251,10 @@ export default function Drive(props: DriveProps) {
|
||||
status="error"
|
||||
title={getText('notEnabledTitle')}
|
||||
testId="not-enabled-stub"
|
||||
subtitle={`${getText('notEnabledSubtitle')}${!supportsLocalBackend ? ' ' + getText('downloadFreeEditionMessage') : ''}`}
|
||||
subtitle={`${getText('notEnabledSubtitle')}${localBackend == null ? ' ' + getText('downloadFreeEditionMessage') : ''}`}
|
||||
>
|
||||
<ariaComponents.ButtonGroup align="center">
|
||||
{!supportsLocalBackend && (
|
||||
{localBackend == null && (
|
||||
<ariaComponents.Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
@ -359,59 +280,48 @@ export default function Drive(props: DriveProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="drive-view"
|
||||
className={`flex flex-1 flex-col gap-drive-heading overflow-hidden px-page-x ${
|
||||
hidden ? 'hidden' : ''
|
||||
}`}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex flex-1 flex-col gap-drive-heading overflow-visible px-page-x',
|
||||
hidden && 'hidden'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-icons self-start">
|
||||
<ariaComponents.Text.Heading>
|
||||
{isCloud ? getText('cloudDrive') : getText('localDrive')}
|
||||
</ariaComponents.Text.Heading>
|
||||
<DriveBar
|
||||
category={category}
|
||||
canDownload={canDownload}
|
||||
doEmptyTrash={doEmptyTrash}
|
||||
doCreateProject={doCreateProject}
|
||||
doUploadFiles={doUploadFiles}
|
||||
doCreateDirectory={doCreateDirectory}
|
||||
doCreateSecret={doCreateSecret}
|
||||
doCreateDatalink={doCreateDatalink}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
/>
|
||||
</div>
|
||||
<DriveBar
|
||||
category={category}
|
||||
canDownload={canDownload}
|
||||
doEmptyTrash={doEmptyTrash}
|
||||
doCreateProject={doCreateProject}
|
||||
doUploadFiles={doUploadFiles}
|
||||
doCreateDirectory={doCreateDirectory}
|
||||
doCreateSecret={doCreateSecret}
|
||||
doCreateDatalink={doCreateDatalink}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
/>
|
||||
<div className="flex flex-1 gap-drive overflow-hidden">
|
||||
{isCloud && (
|
||||
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
|
||||
<CategorySwitcher
|
||||
category={category}
|
||||
setCategory={onSetCategory}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
/>
|
||||
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
|
||||
<CategorySwitcher
|
||||
category={category}
|
||||
setCategory={onSetCategory}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
/>
|
||||
{isCloud && (
|
||||
<Labels
|
||||
backend={backend}
|
||||
draggable={category !== Category.trash}
|
||||
labels={labels}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
doCreateLabel={doCreateLabel}
|
||||
doDeleteLabel={doDeleteLabel}
|
||||
newLabelNames={newLabelNames}
|
||||
deletedLabelNames={deletedLabelNames}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<AssetsTable
|
||||
hidden={hidden}
|
||||
hideRows={hideRows}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
setCanDownload={setCanDownload}
|
||||
setProjectStartupInfo={setProjectStartupInfo}
|
||||
category={category}
|
||||
allLabels={allLabels}
|
||||
setSuggestions={setSuggestions}
|
||||
initialProjectName={initialProjectName}
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
deletedLabelNames={deletedLabelNames}
|
||||
queuedAssetEvents={queuedAssetEvents}
|
||||
assetEvents={assetEvents}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
assetListEvents={assetListEvents}
|
||||
@ -421,7 +331,6 @@ export default function Drive(props: DriveProps) {
|
||||
targetDirectoryNodeRef={targetDirectoryNodeRef}
|
||||
doOpenEditor={doOpenEditor}
|
||||
doCloseEditor={doCloseEditor}
|
||||
doCreateLabel={doCreateLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user