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:
somebody1234 2024-06-12 20:20:07 +10:00 committed by GitHub
parent 55af1b9ffd
commit 46f6b4f698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
177 changed files with 3558 additions and 3194 deletions

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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