Show docs on Dashboard (#11391)

Closes: https://github.com/enso-org/cloud-v2/issues/1445

PR is more or less ready for feedback,CR, and QA.

# Read before looking into:

**Known issues:**
- [x] Need to adjust timings for the animations (they're a bit out of sync).
- [x] After the latest rebase, the side panel might expand while clicking on the sidebar toggle.
- [ ] Images no longer display. 🤦
- [x] Need to restore the functionality around spotlighting a component


Demo:

https://github.com/user-attachments/assets/8cec9ec0-4753-482e-8637-f3857b4396a5
This commit is contained in:
Sergei Garin 2024-11-15 15:12:55 +03:00 committed by GitHub
parent 0543cfaec5
commit 58512e701e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 3999 additions and 2343 deletions

View File

@ -35,9 +35,10 @@ pnpm-lock.yaml
# Generated files
app/ide-desktop/lib/client/electron-builder-config.json
app/ide-desktop/lib/content-config/src/config.json
app/ide-desktop/lib/dashboard/playwright-report/
app/ide-desktop/lib/dashboard/playwright/.cache/
app/ide-desktop/lib/dashboard/dist/
app/gui/playwright-report/
app/gui/playwright/.auth/
app/gui/dist/
app/gui/view/documentation/assets/stylesheet.css
app/rust-ffi/pkg
app/gui/src/project-view/assets/font-*.css

View File

@ -709,6 +709,12 @@ export function findLeastUsedColor(labels: Iterable<Label>) {
// === AssetType ===
// =================
export enum SpecialAssetType {
loading = 'specialLoading',
empty = 'specialEmpty',
error = 'specialError',
}
/** All possible types of directory entries. */
export enum AssetType {
project = 'project',
@ -728,12 +734,19 @@ export enum AssetType {
}
/** The corresponding ID newtype for each {@link AssetType}. */
export interface IdType {
export interface IdType extends RealAssetIdType, SpecialAssetIdType {}
export type RealAssetId = ProjectId | FileId | DatalinkId | SecretId | DirectoryId
export interface RealAssetIdType {
readonly [AssetType.project]: ProjectId
readonly [AssetType.file]: FileId
readonly [AssetType.datalink]: DatalinkId
readonly [AssetType.secret]: SecretId
readonly [AssetType.directory]: DirectoryId
}
export type SpecialAssetId = LoadingAssetId | EmptyAssetId | ErrorAssetId
export interface SpecialAssetIdType {
readonly [AssetType.specialLoading]: LoadingAssetId
readonly [AssetType.specialEmpty]: EmptyAssetId
readonly [AssetType.specialError]: ErrorAssetId
@ -805,6 +818,35 @@ export type SpecialEmptyAsset = Asset<AssetType.specialEmpty>
/** A convenience alias for {@link Asset}<{@link AssetType.specialError}>. */
export type SpecialErrorAsset = Asset<AssetType.specialError>
const PLACEHOLDER_SIGNATURE = Symbol('placeholder')
/** Creates a new placeholder id. */
function createPlaceholderId(from?: string): string {
const id = new String(from ?? uniqueString.uniqueString())
Object.defineProperty(id, PLACEHOLDER_SIGNATURE, {
value: true,
enumerable: false,
configurable: false,
writable: false,
})
return id as string
}
/**
* Whether a given {@link AssetId} is a placeholder id.
*/
export function isPlaceholderId(id: AssetId) {
if (typeof id === 'string') {
return false
}
console.log('isPlaceholderId id', id, PLACEHOLDER_SIGNATURE in id)
return PLACEHOLDER_SIGNATURE in id
}
/**
* Creates a {@link DirectoryAsset} representing the root directory for the organization,
* with all irrelevant fields initialized to default values.
@ -839,7 +881,7 @@ export function createPlaceholderFileAsset(
): FileAsset {
return {
type: AssetType.file,
id: FileId(uniqueString.uniqueString()),
id: FileId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
@ -863,7 +905,7 @@ export function createPlaceholderProjectAsset(
): ProjectAsset {
return {
type: AssetType.project,
id: ProjectId(uniqueString.uniqueString()),
id: ProjectId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
@ -890,7 +932,9 @@ export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoad
return {
type: AssetType.specialLoading,
title: '',
id: LoadingAssetId(`${AssetType.specialLoading}-${uniqueString.uniqueString()}`),
id: LoadingAssetId(
createPlaceholderId(`${AssetType.specialLoading}-${uniqueString.uniqueString()}`),
),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId,
permissions: [],
@ -990,7 +1034,7 @@ export function createPlaceholderAssetId<Type extends AssetType>(
): IdType[Type] {
// This is required so that TypeScript can check the `switch` for exhaustiveness.
const assetType: AssetType = type
id ??= uniqueString.uniqueString()
id = createPlaceholderId(id)
let result: AssetId
switch (assetType) {
case AssetType.directory: {
@ -1537,7 +1581,7 @@ export default abstract class Backend {
title: string,
): Promise<UpdatedDirectory>
/** List previous versions of an asset. */
abstract listAssetVersions(assetId: AssetId, title: string | null): Promise<AssetVersions>
abstract listAssetVersions(assetId: AssetId): Promise<AssetVersions>
/** Change the parent directory of an asset. */
abstract updateAsset(assetId: AssetId, body: UpdateAssetRequestBody, title: string): Promise<void>
/** Delete an arbitrary asset. */
@ -1578,7 +1622,6 @@ export default abstract class Backend {
abstract getProjectDetails(
projectId: ProjectId,
directoryId: DirectoryId | null,
title: string,
): Promise<Project>
/** Return Language Server logs for a project session. */
abstract getProjectSessionLogs(
@ -1598,7 +1641,7 @@ export default abstract class Backend {
title: string,
): Promise<UpdatedProject>
/** Fetch the content of the `Main.enso` file of a project. */
abstract getFileContent(projectId: ProjectId, version: string, title: string): Promise<string>
abstract getFileContent(projectId: ProjectId, versionId?: S3ObjectVersionId): Promise<string>
/** Return project memory, processor and storage usage. */
abstract checkResources(projectId: ProjectId, title: string): Promise<ResourceUsage>
/** Return a list of files accessible by the current user. */
@ -1675,4 +1718,7 @@ export default abstract class Backend {
* @param returnUrl - The URL to redirect to after the customer visits the portal.
*/
abstract createCustomerPortalSession(returnUrl: string): Promise<string | null>
/** Resolve the path of an asset relative to a project. */
abstract resolveProjectAssetPath(projectId: ProjectId, relativePath: string): Promise<string>
}

View File

@ -122,8 +122,8 @@
"listRootFolderBackendError": "Could not list root folder",
"createFolderBackendError": "Could not create folder '$0'",
"updateFolderBackendError": "Could not update folder '$0'",
"listAssetVersionsBackendError": "Could not list versions for '$0'",
"getFileContentsBackendError": "Could not get contents of '$0",
"listAssetVersionsBackendError": "Failed to list versions for the selected asset",
"getFileContentsBackendError": "Failed to get contents of the file",
"updateAssetBackendError": "Could not update '$0'",
"deleteAssetBackendError": "Could not delete '$0'",
"undoDeleteAssetBackendError": "Could not restore '$0' from Trash",
@ -134,7 +134,7 @@
"duplicateProjectBackendError": "Could not duplicate project as '$0'",
"closeProjectBackendError": "Could not close project '$0'",
"listProjectSessionsBackendError": "Could not list sessions for project '$0'",
"getProjectDetailsBackendError": "Could not get details of project '$0'",
"getProjectDetailsBackendError": "Could not get details of project",
"getProjectLogsBackendError": "Could not get logs for project '$0'",
"openProjectBackendError": "Could not open project '$0'",
"openProjectMissingCredentialsBackendError": "Could not open project '$0': Missing credentials",
@ -241,7 +241,6 @@
"reset": "Reset",
"members": "Members",
"drop": "Drop",
"projectSessions": "Sessions",
"logs": "Logs",
"showLogs": "Show Logs",
"accept": "Accept",
@ -261,6 +260,9 @@
"active": "Active",
"pendingInvitation": "Pending Invitation",
"versions": "Versions",
"properties": "Properties",
"projectSessions": "Sessions",
"docs": "Docs",
"datalink": "Datalink",
"secret": "Secret",
"createDatalink": "Create Datalink",
@ -415,6 +417,7 @@
"userAgent": "User Agent",
"compareWithLatest": "Compare with latest",
"compareVersionXWithLatest": "Compare version $0 with latest",
"projectSessionX": "Session $0",
"onDateX": "on $0",
"xUsersAndGroupsSelected": "$0 users and groups selected",
"allTrashedItemsForever": "all trashed items forever",
@ -494,14 +497,6 @@
"closeWindowDialogTitle": "Close window?",
"anUploadIsInProgress": "An upload is in progress.",
"enableMultitabs": "Enable Multi-Tabs",
"enableMultitabsDescription": "Open multiple projects at the same time.",
"enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
"enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.",
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
"deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'",
"deleteSelectedAssetsActionText": "delete $0 selected items",
@ -942,6 +937,12 @@
"ensoDevtoolsPlanSelectSubtitle": "User Plan",
"ensoDevtoolsPaywallFeaturesToggles": "Paywall Features",
"ensoDevtoolsFeatureFlags": "Feature Flags",
"ensoDevtoolsFeatureFlags.enableMultitabs": "Enable Multitabs",
"ensoDevtoolsFeatureFlags.enableMultitabsDescription": "Allow multiple asset panels to be open at the same time.",
"ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
"ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefreshDescription": "Enable background refresh of the assets table.",
"ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshInterval": "Assets Table Background Refresh Interval",
"ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshIntervalDescription": "The interval at which the assets table will be refreshed in the background.",
"setupEnso": "Set up Enso",
"termsAndConditions": "Terms and Conditions",
@ -950,5 +951,15 @@
"skip": "Skip",
"setUsernameDescription": "Start by setting your username. You can always change it later.",
"allSet": "All set!",
"allSetDescription": "You're all set up! Click the button below to start using Enso."
"allSetDescription": "You're all set up! Click the button below to start using Enso.",
"assetDocs.notProject": "Please select a project to view its docs.",
"assetDocs.noDocs": "No docs available for this asset.",
"assetProperties.notSelected": "Select a single asset to view its properties.",
"assetVersions.localAssetsDoNotHaveVersions": "Local assets do not have versions.",
"assetVersions.notSelected": "Select a single asset to view its versions.",
"assetProjectSessions.noSessions": "No sessions yet! Open the project to start a session.",
"assetProjectSessions.notSelected": "Select a single project to view its sessions.",
"assetProjectSessions.localBackend": "Sessions are not available for local projects.",
"assetProjectSessions.notProjectAsset": "Select a single project to view its sessions."
}

View File

@ -60,6 +60,7 @@ interface PlaceholderOverrides {
readonly chromeVersionX: [chromeVersion: string]
readonly userAgentX: [userAgent: string]
readonly compareVersionXWithLatest: [versionNumber: number]
readonly projectSessionX: [count: number]
readonly onDateX: [dateString: string]
readonly xUsersAndGroupsSelected: [usersAndGroupsCount: number]
readonly removeTheLocalDirectoryXFromFavorites: [directoryName: string]
@ -77,8 +78,6 @@ interface PlaceholderOverrides {
readonly listFolderBackendError: [string]
readonly createFolderBackendError: [string]
readonly updateFolderBackendError: [string]
readonly listAssetVersionsBackendError: [string]
readonly getFileContentsBackendError: [string]
readonly updateAssetBackendError: [string]
readonly deleteAssetBackendError: [string]
readonly undoDeleteAssetBackendError: [string]
@ -88,7 +87,6 @@ interface PlaceholderOverrides {
readonly duplicateProjectBackendError: [string]
readonly closeProjectBackendError: [string]
readonly listProjectSessionsBackendError: [string]
readonly getProjectDetailsBackendError: [string]
readonly getProjectLogsBackendError: [string]
readonly openProjectBackendError: [string]
readonly openProjectMissingCredentialsBackendError: [string]

1
app/gui/.gitignore vendored
View File

@ -25,6 +25,7 @@ mockDist
test-results/
playwright-report/
playwright/
src/project-view/util/iconList.json
src/project-view/util/iconName.ts

View File

@ -285,11 +285,62 @@ export default class DrivePageActions extends PageActions {
})
}
/** Show the Asset Panel. */
showAssetPanel() {
return this.step('Show asset panel', async (page) => {
const isShown = await this.isAssetPanelShown(page)
if (!isShown) {
await this.toggleAssetPanel()
}
})
}
/** Hide the Asset Panel. */
hideAssetPanel() {
return this.step('Hide asset panel', async (page) => {
const isShown = await this.isAssetPanelShown(page)
if (isShown) {
await this.toggleAssetPanel()
}
})
}
/** Toggle the Asset Panel open or closed. */
toggleAssetPanel() {
return this.step('Toggle asset panel', (page) =>
page.getByLabel('Asset Panel').locator('visible=true').click(),
)
return this.step('Toggle asset panel', async (page) => {
page.getByLabel('Asset Panel').locator('visible=true').click()
await this.waitForAssetPanelShown(page)
})
}
/**
* Check if the Asset Panel is shown.
*/
async isAssetPanelShown(page: test.Page) {
return await page
.getByTestId('asset-panel')
.isVisible({ timeout: 0 })
.then(
() => true,
() => false,
)
}
/**
* Wait for the Asset Panel to be shown and visually stable
*/
async waitForAssetPanelShown(page: test.Page) {
await page.getByTestId('asset-panel').waitFor({ state: 'visible' })
}
/** Show the description tab of the Asset Panel. */
toggleDescriptionAssetPanel() {
return this.step('Toggle description asset panel', async (page) => {
await this.showAssetPanel()
await page.getByTestId('asset-panel-tab-settings').click()
})
}
/** Interact with the container element of the assets table. */

View File

@ -30,7 +30,10 @@ export default class LoginPageActions extends BaseActions {
/** Perform a successful login. */
login(email = VALID_EMAIL, password = VALID_PASSWORD) {
return this.step('Login', () => this.loginInternal(email, password)).into(DrivePageActions)
return this.step('Login', async (page) => {
await this.loginInternal(email, password)
await passAgreementsDialog({ page })
}).into(DrivePageActions)
}
/** Perform a login as a new user (a user that does not yet have a username). */

View File

@ -1,5 +1,6 @@
/** @file Various actions, locators, and constants used in end-to-end tests. */
import * as test from '@playwright/test'
import * as path from 'path'
import { TEXTS } from 'enso-common/src/text'
@ -636,46 +637,6 @@ export async function expectNotOpacity0(locator: test.Locator) {
})
}
/** A test assertion to confirm that the element is onscreen. */
export async function expectOnScreen(locator: test.Locator) {
await test.test.step('Expect to be onscreen', async () => {
await test
.expect(async () => {
const pageBounds = await locator.evaluate(() => document.body.getBoundingClientRect())
const bounds = await locator.evaluate((el) => el.getBoundingClientRect())
test
.expect(
bounds.left < pageBounds.right &&
bounds.right > pageBounds.left &&
bounds.top < pageBounds.bottom &&
bounds.bottom > pageBounds.top,
)
.toBe(true)
})
.toPass()
})
}
/** A test assertion to confirm that the element is onscreen. */
export async function expectNotOnScreen(locator: test.Locator) {
await test.test.step('Expect to not be onscreen', async () => {
await test
.expect(async () => {
const pageBounds = await locator.evaluate(() => document.body.getBoundingClientRect())
const bounds = await locator.evaluate((el) => el.getBoundingClientRect())
test
.expect(
bounds.left >= pageBounds.right ||
bounds.right <= pageBounds.left ||
bounds.top >= pageBounds.bottom ||
bounds.bottom <= pageBounds.top,
)
.toBe(true)
})
.toPass()
})
}
// ==========================
// === Keyboard utilities ===
// ==========================
@ -723,10 +684,18 @@ export async function login(
first = true,
) {
await test.test.step('Login', async () => {
const url = new URL(page.url())
if (url.pathname !== '/login') {
return
}
await locateEmailInput(page).fill(email)
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
if (first) {
await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
@ -808,22 +777,77 @@ export const mockApi = apiModule.mockApi
/** Set up all mocks, without logging in. */
export function mockAll({ page, setupAPI }: MockParams) {
return new LoginPageActions(page).step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
const actions = new LoginPageActions(page)
actions.step('Execute all mocks', async () => {
await Promise.all([
mockApi({ page, setupAPI }),
mockDate({ page, setupAPI }),
mockAllAnimations({ page }),
mockUnneededUrls({ page }),
])
await page.goto('/')
})
return actions
}
/** Set up all mocks, and log in with dummy credentials. */
export function mockAllAndLogin({ page, setupAPI }: MockParams) {
return new DrivePageActions(page)
.step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await page.goto('/')
})
.do((thePage) => login({ page: thePage, setupAPI }))
export function mockAllAndLogin({ page, setupAPI }: MockParams): DrivePageActions {
mockAll({ page, setupAPI })
const actions = new DrivePageActions(page)
actions.step('Login', async () => {
await login({ page, setupAPI })
})
return actions
}
/**
* Mock all animations.
*/
export async function mockAllAnimations({ page }: MockParams) {
await page.addInitScript({
content: `
window.DISABLE_ANIMATIONS = true;
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.classList.add('disable-animations')
})
`,
})
}
/**
* Mock unneeded URLs.
*/
export async function mockUnneededUrls({ page }: MockParams) {
const EULA_JSON = JSON.stringify(apiModule.EULA_JSON)
const PRIVACY_JSON = JSON.stringify(apiModule.PRIVACY_JSON)
return Promise.all([
page.route('https://*.ingest.sentry.io/api/*/envelope/*', async (route) => {
await route.fulfill()
}),
page.route('https://api.mapbox.com/mapbox-gl-js/*/mapbox-gl.css', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
}),
page.route('https://ensoanalytics.com/eula.json', async (route) => {
await route.fulfill({ contentType: 'text/json', body: EULA_JSON })
}),
page.route('https://ensoanalytics.com/privacy.json', async (route) => {
await route.fulfill({ contentType: 'text/json', body: PRIVACY_JSON })
}),
page.route('https://fonts.googleapis.com/css2*', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
}),
])
}
/**

View File

@ -12,6 +12,7 @@ import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import * as actions from './actions'
import invariant from 'tiny-invariant'
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
// =================
@ -62,6 +63,20 @@ export type MockApi = Awaited<ReturnType<typeof mockApiInternal>>
export const mockApi: (params: MockParams) => Promise<MockApi> = mockApiInternal
export const EULA_JSON = {
path: '/eula.md',
size: 9472,
modified: '2024-05-21T10:47:27.000Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
}
export const PRIVACY_JSON = {
path: '/privacy.md',
size: 1234,
modified: '2024-05-21T10:47:27.000Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
}
/** Add route handlers for the mock API to a page. */
async function mockApiInternal({ page, setupAPI }: MockParams) {
const defaultEmail = 'email@example.com' as backend.EmailAddress
@ -130,6 +145,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const addAsset = <T extends backend.AnyAsset>(asset: T) => {
assets.push(asset)
assetMap.set(asset.id, asset)
return asset
}
@ -391,20 +407,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await page.route('https://www.googletagmanager.com/gtag/js*', (route) =>
route.fulfill({ contentType: 'text/javascript', body: 'export {};' }),
)
if (process.env.MOCK_ALL_URLS === 'true') {
await page.route('https://fonts.googleapis.com/css2*', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
})
await page.route('https://ensoanalytics.com/eula.json', async (route) => {
await route.fulfill({
json: {
path: '/eula.md',
size: 9472,
modified: '2024-06-26T10:44:04.939Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
},
})
})
await page.route(
'https://api.github.com/repos/enso-org/enso/releases/latest',
async (route) => {
@ -417,6 +421,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
headers: { location: 'https://objects.githubusercontent.com/foo/bar' },
})
})
await page.route('https://objects.githubusercontent.com/**', async (route) => {
await route.fulfill({
status: 200,
@ -446,10 +451,6 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
})
}
const isActuallyOnline = await page.evaluate(() => navigator.onLine)
if (!isActuallyOnline) {
await page.route('https://fonts.googleapis.com/*', (route) => route.abort())
}
await page.route(BASE_URL + '**', (_route, request) => {
throw new Error(
@ -493,6 +494,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
) as unknown as Query
const parentId = body.parent_id ?? defaultDirectoryId
let filteredAssets = assets.filter((asset) => asset.parentId === parentId)
// This lint rule is broken; there is clearly a case for `undefined` below.
switch (body.filter_by) {
case backend.FilterBy.active: {
@ -565,29 +567,43 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}))
// === Endpoints with dummy implementations ===
await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (!project?.projectState) {
throw new Error('Attempting to get a project that does not exist.')
} else {
return {
organizationId: defaultOrganizationId,
projectId: projectId,
name: 'example project name',
state: project.projectState,
packageName: 'Project_root',
// eslint-disable-next-line camelcase
ide_version: null,
// eslint-disable-next-line camelcase
engine_version: {
value: '2023.2.1-nightly.2023.9.29',
lifecycle: backend.VersionLifecycle.development,
},
address: backend.Address('ws://localhost/'),
} satisfies backend.ProjectRaw
}
invariant(
project,
`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
Please make sure that you've created the project before opening it.
------------------------------------------------------------------------------------------------
Existing projects: ${Array.from(assetMap.values())
.filter((asset) => asset.type === backend.AssetType.project)
.map((asset) => asset.id)
.join(', ')}`,
)
invariant(
project.projectState,
`Attempting to get a project that does not have a state. Usually it is a bug in the application.
------------------------------------------------------------------------------------------------
Tried to get: \n ${JSON.stringify(project, null, 2)}`,
)
return {
organizationId: defaultOrganizationId,
projectId: projectId,
name: 'example project name',
state: project.projectState,
packageName: 'Project_root',
// eslint-disable-next-line camelcase
ide_version: null,
// eslint-disable-next-line camelcase
engine_version: {
value: '2023.2.1-nightly.2023.9.29',
lifecycle: backend.VersionLifecycle.development,
},
address: backend.Address('ws://localhost/'),
} satisfies backend.ProjectRaw
})
// === Endpoints returning `void` ===
@ -616,7 +632,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
}
} else {
const body: Body = await request.postDataJSON()
const body: Body = request.postDataJSON()
const parentId = body.parentDirectoryId
// Can be any asset ID.
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
@ -632,7 +648,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
newAsset.parentId = parentId
newAsset.title += ' (copy)'
addAsset(newAsset)
await route.fulfill({ json })
return json
}
})
@ -661,11 +678,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
invariant(
project,
`Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`,
)
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
}
await route.fulfill()
route.fulfill()
})
await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route) => {
await route.fulfill()
@ -925,6 +950,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
projectId: id,
state: { type: backend.ProjectState.closed, volumeId: '' },
}
addProject(title, {
description: null,
id,
@ -944,6 +970,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
],
projectState: json.state,
})
return json
})
await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => {

View File

@ -25,24 +25,18 @@ const EMAIL = 'baz.quux@email.com'
test.test('open and close asset panel', ({ page }) =>
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.clickRow(0)
.withAssetPanel(async (assetPanel) => {
await actions.expectNotOnScreen(assetPanel)
await test.expect(assetPanel).toBeVisible()
})
.toggleAssetPanel()
.withAssetPanel(async (assetPanel) => {
await actions.expectOnScreen(assetPanel)
})
.toggleAssetPanel()
.withAssetPanel(async (assetPanel) => {
await actions.expectNotOnScreen(assetPanel)
await test.expect(assetPanel).not.toBeVisible()
}),
)
test.test('asset panel contents', ({ page }) =>
actions
.mockAll({
.mockAllAndLogin({
page,
setupAPI: (api) => {
const { defaultOrganizationId, defaultUserId } = api
@ -63,12 +57,8 @@ test.test('asset panel contents', ({ page }) =>
})
},
})
.login()
.do(async (thePage) => {
await actions.passAgreementsDialog({ page: thePage })
})
.driveTable.clickRow(0)
.toggleAssetPanel()
.toggleDescriptionAssetPanel()
.do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions.

View File

@ -0,0 +1,29 @@
import { test as setup } from '@playwright/test'
import { existsSync } from 'node:fs'
import path from 'node:path'
import * as actions from './actions'
const __dirname = path.dirname(new URL(import.meta.url).pathname)
const authFile = path.join(__dirname, '../../playwright/.auth/user.json')
const isProd = process.env.NODE_ENV === 'production'
const isFileExists = () => {
if (isProd) {
return false
}
return existsSync(authFile)
}
setup('authenticate', ({ page }) => {
if (isFileExists()) {
return setup.skip()
}
return actions
.mockAll({ page })
.login()
.do(async () => {
await page.context().storageState({ path: authFile })
})
})

View File

@ -2,6 +2,9 @@
import * as test from '@playwright/test'
import { VALID_EMAIL, mockAll } from './actions'
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('preserve email input when changing pages', ({ page }) =>
mockAll({ page })
.fillEmail(VALID_EMAIL)

View File

@ -14,29 +14,37 @@ export async function clickAssetRow(assetRow: test.Locator) {
test.test('drag labels onto single row', async ({ page }) => {
const label = 'aaaa'
await actions.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await actions.relog({ page })
return actions
.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
.do(async () => {
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(1))
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(1))
await test
.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label))
.not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
await test
.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label))
.not.toBeVisible()
await test
.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label))
.not.toBeVisible()
})
})
test.test('drag labels onto multiple rows', async ({ page }) => {
@ -54,6 +62,7 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)

View File

@ -22,15 +22,12 @@ test.test('labels', async ({ page }) => {
// "New Label" modal
await locateNewLabelButton(page).click()
await test.expect(locateNewLabelModal(page)).toBeVisible()
await page.press('body', 'Escape')
await test.expect(locateNewLabelModal(page)).not.toBeVisible()
await locateNewLabelButton(page).click()
// "New Label" modal with name set
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
await page.press('body', 'Escape')
await page.press('html', 'Escape')
// "New Label" modal with color set
// The exact number is allowed to vary; but to click the fourth color, there must be at least

View File

@ -7,12 +7,14 @@ import * as actions from './actions'
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('login and logout', ({ page }) =>
actions
.mockAll({ page })
.login()
.do(async (thePage) => {
await actions.passAgreementsDialog({ page: thePage })
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
})

View File

@ -14,6 +14,9 @@ import {
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('login screen', ({ page }) =>
mockAll({ page })
.loginThatShouldFail('invalid email', VALID_PASSWORD, {
@ -25,9 +28,6 @@ test.test('login screen', ({ page }) =>
})
// Technically it should not be allowed, but
.login(VALID_EMAIL, INVALID_PASSWORD)
.do(async (thePage) => {
await passAgreementsDialog({ page: thePage })
})
.withDriveView(async (driveView) => {
await test.expect(driveView).toBeVisible()
}),

View File

@ -1,38 +0,0 @@
/** @file Test the user settings tab. */
import * as test from '@playwright/test'
import * as backend from '#/services/Backend'
import * as actions from './actions'
test.test('members settings', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({
page,
setupAPI: (theApi) => {
theApi.setPlan(backend.Plan.enterprise)
// Setup
theApi.setCurrentOrganization(theApi.defaultOrganization)
},
})
const localActions = actions.settings.members
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? ''])
const otherUserName = 'second.user_'
const otherUser = api.addUser(otherUserName)
await actions.relog({ page })
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
api.deleteUser(otherUser.userId)
await actions.relog({ page })
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? ''])
})

View File

@ -4,6 +4,9 @@ import * as test from '@playwright/test'
import { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions'
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('setup (free plan)', ({ page }) =>
actions
.mockAll({

View File

@ -7,6 +7,9 @@ import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('sign up without organization id', ({ page }) =>
mockAll({ page })
.goToPage.register()

7
app/gui/env.d.ts vendored
View File

@ -176,6 +176,13 @@ declare global {
readonly fileBrowserApi?: FileBrowserApi
readonly versionInfo?: VersionInfo
toggleDevtools: () => void
/**
* If set to `true`, animations will be disabled.
* Used by playwright tests to speed up execution.
*
* ATM only affects the framer-motion animations.
*/
readonly DISABLE_ANIMATIONS?: boolean
}
namespace NodeJS {

View File

@ -26,7 +26,7 @@
style-src 'self' 'unsafe-inline' data: https://*;
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
worker-src 'self' blob:;
img-src 'self' blob: data: https://*;
img-src 'self' blob: enso: data: https://*;
font-src 'self' data: https://*"
/>
<meta

View File

@ -59,7 +59,7 @@
"react": "^18.3.1",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
"react-compiler-runtime": "19.0.0-beta-8a03594-20241020",
"react-compiler-runtime": "19.0.0-beta-6fc168f-20241025",
"react-dom": "^18.3.1",
"react-error-boundary": "4.0.13",
"react-hook-form": "^7.51.4",
@ -80,7 +80,7 @@
"@ag-grid-enterprise/core": "^31.1.1",
"@ag-grid-enterprise/range-selection": "^31.1.1",
"@babel/parser": "^7.24.7",
"babel-plugin-react-compiler": "19.0.0-beta-9ee70a1-20241017",
"babel-plugin-react-compiler": "19.0.0-beta-6fc168f-20241025",
"@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2",
"@codemirror/lang-markdown": "^v6.3.0",
@ -123,7 +123,8 @@
"y-textarea": "^1.0.0",
"y-websocket": "^1.5.0",
"ydoc-shared": "workspace:*",
"yjs": "^13.6.7"
"yjs": "^13.6.7",
"marked": "14.1.3"
},
"devDependencies": {
"@fast-check/vitest": "^0.0.8",

View File

@ -10,7 +10,16 @@ import { defineConfig } from '@playwright/test'
import net from 'net'
const DEBUG = process.env.DEBUG_E2E === 'true'
const TIMEOUT_MS = DEBUG ? 100_000_000 : 60_000
const isCI = process.env.CI === 'true'
const isProd = process.env.PROD === 'true'
const TIMEOUT_MS =
DEBUG ? 100_000_000
: isCI ? 60_000
: 15_000
// We tend to use less CPU on CI to reduce the number of failures due to timeouts.
const WORKERS = isCI ? '25%' : '35%'
async function findFreePortInRange(min: number, max: number) {
for (let i = 0; i < 50; i++) {
@ -52,6 +61,7 @@ process.env.PLAYWRIGHT_PORT_PV = `${ports.projectView}`
export default defineConfig({
fullyParallel: true,
...(WORKERS ? { workers: WORKERS } : {}),
forbidOnly: !!process.env.CI,
repeatEach: process.env.CI ? 3 : 1,
reporter: 'html',
@ -86,9 +96,36 @@ export default defineConfig({
}),
},
projects: [
// Setup project
{
name: 'Setup Dashboard',
testDir: './e2e/dashboard',
testMatch: /.*\.setup\.ts/,
timeout: TIMEOUT_MS,
use: {
baseURL: `http://localhost:${ports.dashboard}`,
actionTimeout: TIMEOUT_MS,
},
},
{
name: 'Dashboard',
testDir: './e2e/dashboard',
testMatch: /.*\.spec\.ts/,
dependencies: ['Setup Dashboard'],
expect: {
toHaveScreenshot: { threshold: 0 },
timeout: TIMEOUT_MS,
},
timeout: TIMEOUT_MS,
use: {
baseURL: `http://localhost:${ports.dashboard}`,
actionTimeout: TIMEOUT_MS,
storageState: './playwright/.auth/user.json',
},
},
{
name: 'Auth',
testDir: './e2e/dashboard/auth',
expect: {
toHaveScreenshot: { threshold: 0 },
timeout: TIMEOUT_MS,
@ -120,11 +157,9 @@ export default defineConfig({
],
webServer: [
{
env: {
E2E: 'true',
},
env: { E2E: 'true' },
command:
process.env.CI || process.env.PROD ?
isCI || isProd ?
`corepack pnpm build && corepack pnpm exec vite preview --port ${ports.projectView} --strictPort`
: `corepack pnpm exec vite dev --port ${ports.projectView}`,
// Build from scratch apparently can take a while on CI machines.
@ -135,7 +170,7 @@ export default defineConfig({
},
{
command:
process.env.CI || process.env.PROD ?
isCI || isProd ?
`corepack pnpm exec vite -c vite.test.config.ts build && vite -c vite.test.config.ts preview --port ${ports.dashboard} --strictPort`
: `corepack pnpm exec vite -c vite.test.config.ts --port ${ports.dashboard}`,
timeout: 240 * 1000,

View File

@ -59,6 +59,8 @@ import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider'
import type { Spring } from 'framer-motion'
import { MotionConfig } from 'framer-motion'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ForgotPassword from '#/pages/authentication/ForgotPassword'
@ -103,6 +105,16 @@ import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
// === Global configuration ===
// ============================
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
@ -497,40 +509,42 @@ function AppRouter(props: AppRouterProps) {
return (
<FeatureFlagsProvider>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={refreshUserSession}
>
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
<InputBindingsProvider inputBindings={inputBindings}>
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
* due to modals being in `TheModal`. */}
<DriveProvider>
<errorBoundary.ErrorBoundary>
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>
</AuthProvider>
</BackendProvider>
</SessionProvider>
</RouterProvider>
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={refreshUserSession}
>
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
<InputBindingsProvider inputBindings={inputBindings}>
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
* due to modals being in `TheModal`. */}
<DriveProvider>
<errorBoundary.ErrorBoundary>
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>
</AuthProvider>
</BackendProvider>
</SessionProvider>
</RouterProvider>
</MotionConfig>
</FeatureFlagsProvider>
)
}

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 3.5V7C13 8.10457 13.8954 9 15 9H18.5M9 13H12M9 17H15.5M8 3H12.1716C12.702 3 13.2107 3.21071 13.5858 3.58579L18.4142 8.41421C18.7893 8.78929 19 9.29799 19 9.82843V18C19 19.6569 17.6569 21 16 21H8C6.34315 21 5 19.6569 5 18V6C5 4.34315 6.34315 3 8 3Z" stroke="black" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.2759 13.1471C17.7492 12.5458 20.5095 13.7876 21.7972 16.8725C22.4674 18.4782 21.0226 20 19.2826 20H17.0017M10.5017 7C10.5017 8.65685 9.15859 10 7.50174 10C5.84489 10 4.50174 8.65685 4.50174 7C4.50174 5.34315 5.84489 4 7.50174 4C9.15859 4 10.5017 5.34315 10.5017 7ZM19.5017 7C19.5017 8.65685 18.1586 10 16.5017 10C14.8449 10 13.5017 8.65685 13.5017 7C13.5017 5.34315 14.8449 4 16.5017 4C18.1586 4 19.5017 5.34315 19.5017 7ZM10.2828 20H4.72066C2.98074 20 1.53609 18.4769 2.20657 16.8713C4.36215 11.7096 10.6413 11.7096 12.7969 16.8713C13.4674 18.4769 12.0227 20 10.2828 20Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24">
<path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11V6a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h3.5M8 15h1M8 7h7m-7 4h3m8.121 9.121a3 3 0 1 1-4.242-4.242 3 3 0 0 1 4.242 4.242Zm0 0L21 22"/>
</svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@ -1,8 +1,4 @@
<svg width="16" height="16" viewBox="2 2 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10.7845" y="3.98547" width="5" height="10" transform="rotate(45 10.7845 3.98547)" fill="black" />
<path
d="M12.1987 2.57126C12.9798 1.79021 14.2461 1.79021 15.0271 2.57126L15.7342 3.27836C16.5153 4.05941 16.5153 5.32574 15.7342 6.10679L15.0271 6.8139L11.4916 3.27836L12.1987 2.57126Z"
fill="black" />
<path d="M3.71341 11.0565L7.24894 14.5921L3.71341 14.5921L3.71341 11.0565Z" fill="black" />
<rect x="2" y="16" width="16" height="2" fill="black" />
</svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6716 3.41425C16.2337 1.85215 18.7663 1.85215 20.3284 3.41425L20.5858 3.67161C22.1479 5.23371 22.1479 7.76637 20.5858 9.32846L9.08579 20.8285C8.33564 21.5786 7.31823 22 6.25736 22H3C2.44772 22 2 21.5523 2 21V17.7427C2 16.6818 2.42143 15.6644 3.17157 14.9142L14.6716 3.41425Z" fill="black"/>
<path d="M14 20C13.4477 20 13 20.4477 13 21C13 21.5523 13.4477 22 14 22H21C21.5523 22 22 21.5523 22 21C22 20.4477 21.5523 20 21 20H14Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 557 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 7V6C15 4.34315 13.6569 3 12 3H8C6.34315 3 5 4.34315 5 6V14C5 15.6569 6.34315 17 8 17H9M16 21H12C10.3431 21 9 19.6569 9 18V10C9 8.34315 10.3431 7 12 7H16C17.6569 7 19 8.34315 19 10V18C19 19.6569 17.6569 21 16 21Z" stroke="black" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -6,19 +6,23 @@
import type { Transition } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import type { PropsWithChildren } from 'react'
import { createContext, useContext, useId } from 'react'
import { createContext, memo, useContext, useId, useMemo } from 'react'
import { twJoin } from '#/utilities/tailwindMerge'
import invariant from 'tiny-invariant'
/** Props for {@link AnimatedBackground}. */
interface AnimatedBackgroundProps extends PropsWithChildren {
readonly value: string
/**
* Active value.
* You can omit this prop if you want to use the `isSelected` prop on {@link AnimatedBackground.Item}.
*/
readonly value?: string
readonly transition?: Transition
}
const AnimatedBackgroundContext = createContext<{
value: string | null
value: string | undefined
transition: Transition
layoutId: string
} | null>(null)
@ -38,46 +42,118 @@ const DEFAULT_TRANSITION: Transition = {
/** `<AnimatedBackground />` component visually highlights selected items by sliding a background into view when hovered over or clicked. */
export function AnimatedBackground(props: AnimatedBackgroundProps) {
const { value, transition = DEFAULT_TRANSITION, children } = props
const layoutId = useId()
const contextValue = useMemo(
() => ({ value, transition, layoutId }),
[value, transition, layoutId],
)
return (
<AnimatedBackgroundContext.Provider value={{ value, transition, layoutId }}>
<AnimatedBackgroundContext.Provider value={contextValue}>
{children}
</AnimatedBackgroundContext.Provider>
)
}
/** Props for {@link AnimatedBackground.Item}. */
interface AnimatedBackgroundItemProps extends PropsWithChildren {
readonly value: string
/**
* Props for {@link AnimatedBackground.Item}.
*/
type AnimatedBackgroundItemProps = PropsWithChildren<
AnimatedBackgroundItemPropsWithSelected | AnimatedBackgroundItemPropsWithValue
> & {
readonly className?: string
readonly animationClassName?: string
readonly underlayElement?: React.ReactNode
}
/**
* Props for {@link AnimatedBackground.Item} with a `value` prop.
*/
interface AnimatedBackgroundItemPropsWithValue {
readonly value: string
readonly isSelected?: never
}
/**
* Props for {@link AnimatedBackground.Item} with a `isSelected` prop.
*/
interface AnimatedBackgroundItemPropsWithSelected {
readonly isSelected: boolean
readonly value?: never
}
/** Item within an {@link AnimatedBackground}. */
AnimatedBackground.Item = function AnimatedBackgroundItem(props: AnimatedBackgroundItemProps) {
const context = useContext(AnimatedBackgroundContext)
invariant(context, 'useAnimatedBackground must be used within an AnimatedBackgroundProvider')
AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBackgroundItemProps) {
const {
value,
className,
animationClassName,
children,
isSelected,
underlayElement = <div className={twJoin('h-full w-full', animationClassName)} />,
} = props
const { value, className, animationClassName, children } = props
const context = useContext(AnimatedBackgroundContext)
invariant(context, '<AnimatedBackground.Item /> must be placed within an <AnimatedBackground />')
const { value: activeValue, transition, layoutId } = context
invariant(
activeValue === undefined || isSelected === undefined,
'isSelected shall be passed either directly or via context by matching the value prop in <AnimatedBackground.Item /> and value from <AnimatedBackground />',
)
const isActive = isSelected ?? activeValue === value
return (
<div className={twJoin('relative *:isolate', className)}>
<AnimatePresence initial={false}>
{activeValue === value && (
<motion.div
layoutId={`background-${layoutId}`}
className={twJoin('absolute inset-0', animationClassName)}
transition={transition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<AnimatedBackgroundItemUnderlay
isActive={isActive}
underlayElement={underlayElement}
layoutId={layoutId}
transition={transition}
/>
{children}
</div>
)
})
/**
* Props for {@link AnimatedBackgroundItemUnderlay}.
*/
interface AnimatedBackgroundItemUnderlayProps {
readonly isActive: boolean
readonly underlayElement: React.ReactNode
readonly layoutId: string
readonly transition: Transition
}
/**
* Underlay for {@link AnimatedBackground.Item}.
*/
// eslint-disable-next-line no-restricted-syntax
const AnimatedBackgroundItemUnderlay = memo(function AnimatedBackgroundItemUnderlay(
props: AnimatedBackgroundItemUnderlayProps,
) {
const { isActive, underlayElement, layoutId, transition } = props
return (
<AnimatePresence initial={!isActive}>
{isActive && (
<motion.div
layout="position"
layoutId={`background-${layoutId}`}
className="pointer-events-none absolute inset-0"
transition={transition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{underlayElement}
</motion.div>
)}
</AnimatePresence>
)
})

View File

@ -5,7 +5,7 @@ import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import { forwardRef } from '#/utilities/react'
@ -398,7 +398,7 @@ export const Button = forwardRef(function Button(
} else if (isLoading && loaderPosition === 'icon') {
return (
<span className={styles.icon()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
<StatelessSpinner state="loading-medium" size={16} />
</span>
)
} else {
@ -468,7 +468,7 @@ export const Button = forwardRef(function Button(
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
<StatelessSpinner state="loading-medium" size={16} />
</span>
)}
</span>

View File

@ -182,12 +182,14 @@ export function Dialog(props: DialogProps) {
testId = 'dialog',
size,
rounded,
padding = type === 'modal' ? 'medium' : 'xlarge',
padding: paddingRaw,
fitContent,
variants = DIALOG_STYLES,
...ariaDialogProps
} = props
const padding = paddingRaw ?? (type === 'modal' ? 'medium' : 'xlarge')
const [isScrolledToTop, setIsScrolledToTop] = React.useState(true)
/** Handles the scroll event on the dialog content. */

View File

@ -12,6 +12,7 @@ import * as suspense from '#/components/Suspense'
import * as twv from '#/utilities/tailwindVariants'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import * as utlities from './utilities'
@ -78,22 +79,31 @@ export function Popover(props: PopoverProps) {
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
const closeRef = React.useRef<(() => void) | null>(null)
// We use as here to make the types more accurate
// eslint-disable-next-line no-restricted-syntax
const contextState = React.useContext(
aria.OverlayTriggerStateContext,
) as aria.OverlayTriggerState | null
const root = portal.useStrictPortalContext()
const dialogId = aria.useId()
const close = useEventCallback(() => {
contextState?.close()
})
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: () => closeRef.current?.(),
onInteractOutside: close,
})
return (
<aria.Popover
className={(values) =>
POPOVER_STYLES({
...values,
isEntering: values.isEntering,
isExiting: values.isExiting,
size,
rounded,
className: typeof className === 'function' ? className(values) : className,
@ -110,25 +120,19 @@ export function Popover(props: PopoverProps) {
>
{(opts) => (
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<aria.Dialog
<div
id={dialogId}
ref={dialogRef}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
>
{({ close }) => {
closeRef.current = close
return (
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
)
}}
</aria.Dialog>
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
</dialogStackProvider.DialogStackRegistrar>
)}
</aria.Popover>

View File

@ -100,8 +100,6 @@ export const Form = forwardRef(function Form<
}),
) as Record<keyof FieldValues, string>
const values = components.useWatch({ control: innerForm.control })
return (
<form
{...formProps}
@ -115,9 +113,7 @@ export const Form = forwardRef(function Form<
>
<aria.FormValidationContext.Provider value={errors}>
<components.FormProvider form={innerForm}>
{typeof children === 'function' ?
children({ ...innerForm, form: innerForm, values })
: children}
{typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
</components.FormProvider>
</aria.FormValidationContext.Provider>
</form>
@ -133,6 +129,7 @@ export const Form = forwardRef(function Form<
Reset: typeof components.Reset
Field: typeof components.Field
FormError: typeof components.FormError
FieldValue: typeof components.FieldValue
useFormSchema: typeof components.useFormSchema
Controller: typeof components.Controller
FIELD_STYLES: typeof components.FIELD_STYLES
@ -151,6 +148,7 @@ Form.useFormSchema = components.useFormSchema
Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.FieldValue = components.FieldValue
Form.useFormContext = components.useFormContext
Form.useOptionalFormContext = components.useOptionalFormContext
Form.Field = components.Field

View File

@ -0,0 +1,31 @@
/**
* @file
*
* Component that passes the value of a field to its children.
*/
import { useWatch } from 'react-hook-form'
import { useFormContext } from './FormProvider'
import type { FieldPath, FieldValues, FormInstanceValidated, TSchema } from './types'
/**
*
*/
export interface FieldValueProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>> {
readonly form?: FormInstanceValidated<Schema>
readonly name: TFieldName
readonly children: (value: FieldValues<Schema>[TFieldName]) => React.ReactNode
}
/**
* Component that passes the value of a field to its children.
*/
export function FieldValue<Schema extends TSchema, TFieldName extends FieldPath<Schema>>(
props: FieldValueProps<Schema, TFieldName>,
) {
const { form, name, children } = props
const formInstance = useFormContext(form)
const value = useWatch({ control: formInstance.control, name })
return <>{children(value)}</>
}

View File

@ -5,6 +5,7 @@
*/
export { Controller, useWatch } from 'react-hook-form'
export * from './Field'
export * from './FieldValue'
export * from './FormError'
export * from './FormProvider'
export * from './Reset'

View File

@ -7,7 +7,6 @@ import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form'
import type { DeepPartialSkipArrayKey } from 'react-hook-form'
import type { TestIdProps } from '../types'
import type * as components from './components'
import type * as styles from './styles'
@ -37,7 +36,6 @@ interface BaseFormProps<Schema extends components.TSchema>
| ((
props: components.UseFormReturn<Schema> & {
readonly form: components.UseFormReturn<Schema>
readonly values: DeepPartialSkipArrayKey<components.FieldValues<Schema>>
},
) => React.ReactNode)
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>

View File

@ -103,6 +103,7 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
isDisabled,
onHoverChange: handleHoverChange,
})
const { hoverProps: tooltipHoverProps } = aria.useHover({
isDisabled,
onHoverChange: handleHoverChange,

View File

@ -188,8 +188,8 @@ export function EnsoDevtools() {
<ariaComponents.Switch
form={form}
name="enableMultitabs"
label={getText('enableMultitabs')}
description={getText('enableMultitabsDescription')}
label={getText('ensoDevtoolsFeatureFlags.enableMultitabs')}
description={getText('ensoDevtoolsFeatureFlags.enableMultitabsDescription')}
onChange={(value) => {
setFeatureFlags('enableMultitabs', value)
}}
@ -199,8 +199,10 @@ export function EnsoDevtools() {
<ariaComponents.Switch
form={form}
name="enableAssetsTableBackgroundRefresh"
label={getText('enableAssetsTableBackgroundRefresh')}
description={getText('enableAssetsTableBackgroundRefreshDescription')}
label={getText('ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefresh')}
description={getText(
'ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefreshDescription',
)}
onChange={(value) => {
setFeatureFlags('enableAssetsTableBackgroundRefresh', value)
}}
@ -210,8 +212,12 @@ export function EnsoDevtools() {
type="number"
inputMode="numeric"
name="assetsTableBackgroundRefreshInterval"
label={getText('enableAssetsTableBackgroundRefreshInterval')}
description={getText('enableAssetsTableBackgroundRefreshIntervalDescription')}
label={getText(
'ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshInterval',
)}
description={getText(
'ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshIntervalDescription',
)}
onChange={(event) => {
setFeatureFlags(
'assetsTableBackgroundRefreshInterval',

View File

@ -3,9 +3,9 @@
* This file provides a zustand store that contains the state of the Enso devtools.
*/
import type { PaywallFeatureName } from '#/hooks/billing'
import * as zustand from '#/utilities/zustand'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import * as React from 'react'
import * as zustand from 'zustand'
/** Configuration for a paywall feature. */
export interface PaywallDevtoolsFeatureConfiguration {
@ -60,7 +60,9 @@ export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) =>
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useEnableVersionChecker() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.showVersionChecker)
return zustand.useStore(ensoDevtoolsStore, (state) => state.showVersionChecker, {
unsafeEnableTransition: true,
})
}
// ==================================
@ -69,20 +71,28 @@ export function useEnableVersionChecker() {
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useSetEnableVersionChecker() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.setEnableVersionChecker)
return zustand.useStore(ensoDevtoolsStore, (state) => state.setEnableVersionChecker, {
unsafeEnableTransition: true,
})
}
/** A hook that provides access to the paywall devtools. */
export function usePaywallDevtools() {
return zustand.useStore(ensoDevtoolsStore, (state) => ({
features: state.paywallFeatures,
setFeature: state.setPaywallFeature,
}))
return zustand.useStore(
ensoDevtoolsStore,
(state) => ({
features: state.paywallFeatures,
setFeature: state.setPaywallFeature,
}),
{ unsafeEnableTransition: true },
)
}
/** A hook that provides access to the show devtools state. */
export function useShowDevtools() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.showDevtools)
return zustand.useStore(ensoDevtoolsStore, (state) => state.showDevtools, {
unsafeEnableTransition: true,
})
}
// =================================

View File

@ -23,7 +23,12 @@ import * as errorUtils from '#/utilities/error'
/** Props for an {@link ErrorBoundary}. */
export interface ErrorBoundaryProps
extends Readonly<React.PropsWithChildren>,
Readonly<Pick<errorBoundary.ErrorBoundaryProps, 'FallbackComponent' | 'onError' | 'onReset'>> {}
Readonly<
Pick<
errorBoundary.ErrorBoundaryProps,
'FallbackComponent' | 'onError' | 'onReset' | 'resetKeys'
>
> {}
/**
* Catches errors in child components

View File

@ -1,5 +1,5 @@
/** @file A full-screen loading spinner. */
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import { StatelessSpinner, type SpinnerState } from '#/components/StatelessSpinner'
import * as twv from '#/utilities/tailwindVariants'
@ -57,7 +57,7 @@ export type Size = 'large' | 'medium' | 'small'
export interface LoaderProps extends twv.VariantProps<typeof STYLES> {
readonly className?: string
readonly size?: Size | number
readonly state?: spinnerModule.SpinnerState
readonly state?: SpinnerState
}
/** A full-screen loading spinner. */
@ -65,7 +65,7 @@ export function Loader(props: LoaderProps) {
const {
className,
size: sizeRaw = 'medium',
state = spinnerModule.SpinnerState.loadingFast,
state = 'loading-fast',
minHeight = 'full',
color = 'primary',
} = props

View File

@ -0,0 +1,81 @@
/** @file A WYSIWYG editor using Lexical.js. */
import { useSuspenseQuery } from '@tanstack/react-query'
import type { RendererObject } from 'marked'
import { marked } from 'marked'
import { useMemo } from 'react'
import { BUTTON_STYLES, TEXT_STYLE } from '../AriaComponents'
/** Props for a {@link MarkdownViewer}. */
export interface MarkdownViewerProps {
/** Markdown markup to parse and display. */
readonly text: string
readonly imgUrlResolver: (relativePath: string) => Promise<string>
readonly renderer?: RendererObject
}
const defaultRenderer: RendererObject = {
/** The renderer for headings. */
heading({ depth, tokens }) {
return `<h${depth} class="${TEXT_STYLE({ variant: 'h1', className: 'my-2' })}">${this.parser.parseInline(tokens)}</h${depth}>`
},
/** The renderer for paragraphs. */
paragraph({ tokens }) {
return `<p class="${TEXT_STYLE({ variant: 'body', className: 'my-1' })}">${this.parser.parseInline(tokens)}</p>`
},
/** The renderer for list items. */
listitem({ tokens }) {
return `<li class="${TEXT_STYLE({ variant: 'body' })}">${this.parser.parseInline(tokens)}</li>`
},
/** The renderer for lists. */
list({ items }) {
return `<ul class="my-1 list-disc pl-3">${items.map((item) => this.listitem(item)).join('\n')}</ul>`
},
/** The renderer for links. */
link({ href, tokens }) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="${BUTTON_STYLES({ variant: 'link' }).base()}">${this.parser.parseInline(tokens)}</a>`
},
/** The renderer for images. */
image({ href, title }) {
return `<img src="${href}" alt="${title}" class="my-1 h-auto max-w-full" />`
},
/** The renderer for code. */
code({ text }) {
return `<code class="block my-1 p-2 bg-primary/5 rounded-lg max-w-full overflow-auto max-h-48" >
<pre class="${TEXT_STYLE({ variant: 'body-sm' })}">${text}</pre>
</code>`
},
/** The renderer for blockquotes. */
blockquote({ tokens }) {
return `<blockquote class="${'relative my-1 pl-2 before:bg-primary/20 before:absolute before:left-0 before:top-0 before:h-full before:w-[1.5px] before:rounded-full'}">${this.parser.parse(tokens)}</blockquote>`
},
}
/**
* Markdown viewer component.
* Parses markdown passed in as a `text` prop into HTML and displays it.
*/
export function MarkdownViewer(props: MarkdownViewerProps) {
const { text, imgUrlResolver, renderer = defaultRenderer } = props
const markedInstance = useMemo(
() => marked.use({ renderer: Object.assign({}, defaultRenderer, renderer), async: true }),
[renderer],
)
const { data: markdownToHtml } = useSuspenseQuery({
queryKey: ['markdownToHtml', { text }],
queryFn: () =>
markedInstance.parse(text, {
async: true,
walkTokens: async (token) => {
if (token.type === 'image' && 'href' in token && typeof token.href === 'string') {
token.href = await imgUrlResolver(token.href)
}
},
}),
})
// eslint-disable-next-line @typescript-eslint/naming-convention
return <div className="select-text" dangerouslySetInnerHTML={{ __html: markdownToHtml }} />
}

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel export for MarkdownViewer
*/
export { MarkdownViewer, type MarkdownViewerProps } from './MarkdownViewer'

View File

@ -20,13 +20,10 @@ import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { useSyncRef } from '#/hooks/syncRefHooks'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindVariants from '#/utilities/tailwindVariants'
// =================
// === 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: {
@ -84,10 +81,6 @@ export const ACTION_TO_TEXT_ID: Readonly<
openInFileBrowser: 'openInFileBrowserShortcut',
} satisfies { [Key in inputBindings.DashboardBindingKey]: `${Key}Shortcut` }
// =================
// === MenuEntry ===
// =================
/** Props for a {@link MenuEntry}. */
export interface MenuEntryProps extends tailwindVariants.VariantProps<typeof MENU_ENTRY_VARIANTS> {
readonly icon?: string
@ -119,6 +112,8 @@ export default function MenuEntry(props: MenuEntryProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const focusChildProps = focusHooks.useFocusChild()
const info = inputBindings.metadata[action]
const isDisabledRef = useSyncRef(isDisabled)
const labelTextId: text.TextId = (() => {
if (action === 'openInFileBrowser') {
return (
@ -131,17 +126,16 @@ export default function MenuEntry(props: MenuEntryProps) {
}
})()
React.useEffect(() => {
// This is slower (but more convenient) than registering every shortcut in the context menu
// at once.
if (isDisabled) {
return
}
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: doAction,
})
}, [isDisabled, inputBindings, action, doAction])
React.useEffect(
() =>
inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: () => {
if (isDisabledRef.current) return
doAction()
},
}),
[inputBindings, action, doAction, isDisabledRef],
)
return hidden ? null : (
<FocusRing>

View File

@ -51,6 +51,8 @@ const RESULT_STYLES = tv({
base: 'flex flex-col items-center justify-center max-w-full px-6 py-4 text-center h-[max-content]',
variants: {
centered: {
true: 'm-auto',
false: '',
horizontal: 'mx-auto',
vertical: 'my-auto',
all: 'm-auto',

View File

@ -2,29 +2,20 @@
* @file A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind
* classes.
*/
import * as React from 'react'
import { twMerge } from '#/utilities/tailwindMerge'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ===============
// === Spinner ===
// ===============
/** The state of the spinner. It should go from initial, to loading, to done. */
export enum SpinnerState {
initial = 'initial',
loadingSlow = 'loading-slow',
loadingMedium = 'loading-medium',
loadingFast = 'loading-fast',
done = 'done',
}
/** The state of the spinner. It should go from `initial`, to `loading`, to `done`. */
export type SpinnerState = 'done' | 'initial' | 'loading-fast' | 'loading-medium' | 'loading-slow'
export const SPINNER_CSS_CLASSES: Readonly<Record<SpinnerState, string>> = {
[SpinnerState.initial]: 'dasharray-5 ease-linear',
[SpinnerState.loadingSlow]: 'dasharray-75 duration-spinner-slow ease-linear',
[SpinnerState.loadingMedium]: 'dasharray-75 duration-spinner-medium ease-linear',
[SpinnerState.loadingFast]: 'dasharray-75 duration-spinner-fast ease-linear',
[SpinnerState.done]: 'dasharray-100 duration-spinner-fast ease-in',
initial: 'dasharray-5 ease-linear',
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'loading-slow': 'dasharray-75 duration-spinner-slow ease-linear',
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'loading-medium': 'dasharray-75 duration-spinner-medium ease-linear',
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'loading-fast': 'dasharray-75 duration-spinner-fast ease-linear',
done: 'dasharray-100 duration-spinner-fast ease-in',
}
/** Props for a {@link Spinner}. */
@ -36,8 +27,9 @@ export interface SpinnerProps {
}
/** A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind classes. */
export default function Spinner(props: SpinnerProps) {
export function Spinner(props: SpinnerProps) {
const { size, padding, className, state } = props
return (
<svg
width={size}
@ -58,7 +50,7 @@ export default function Spinner(props: SpinnerProps) {
stroke="currentColor"
strokeLinecap="round"
strokeWidth={3}
className={tailwindMerge.twMerge(
className={twMerge(
'pointer-events-none origin-center !animate-spin-ease transition-stroke-dasharray [transition-duration:var(--spinner-slow-transition-duration)]',
SPINNER_CSS_CLASSES[state],
)}

View File

@ -1,27 +1,23 @@
/** @file A spinner that does not expose its {@link spinner.SpinnerState}. */
import * as React from 'react'
/** @file A spinner that does not expose its {@link SpinnerState}. */
import { startTransition, useEffect, useState } from 'react'
import Spinner, * as spinner from '#/components/Spinner'
// ========================
// === StatelessSpinner ===
// ========================
export { SpinnerState } from './Spinner'
import type { SpinnerProps, SpinnerState } from '#/components/Spinner'
import { Spinner } from '#/components/Spinner'
export type { SpinnerState } from '#/components/Spinner'
/** Props for a {@link StatelessSpinner}. */
export type StatelessSpinnerProps = spinner.SpinnerProps
export type StatelessSpinnerProps = SpinnerProps
/**
* A spinner that does not expose its {@link spinner.SpinnerState}. Instead, it begins at
* {@link spinner.SpinnerState.initial} and immediately changes to the given state.
* A spinner that does not expose its {@link SpinnerState}. Instead, it begins at
* `initial` and immediately changes to the given state.
*/
export default function StatelessSpinner(props: StatelessSpinnerProps) {
const { size, state: rawState, ...spinnerProps } = props
const [, startTransition] = React.useTransition()
const [state, setState] = React.useState(spinner.SpinnerState.initial)
export function StatelessSpinner(props: StatelessSpinnerProps) {
const { state: rawState, ...spinnerProps } = props
React.useLayoutEffect(() => {
const [state, setState] = useState<SpinnerState>('initial')
useEffect(() => {
const id = requestAnimationFrame(() => {
// consider this as a low-priority update
startTransition(() => {
@ -34,5 +30,5 @@ export default function StatelessSpinner(props: StatelessSpinnerProps) {
}
}, [rawState])
return <Spinner state={state} {...(size != null ? { size } : {})} {...spinnerProps} />
return <Spinner state={state} {...spinnerProps} />
}

View File

@ -16,7 +16,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
import * as columnModule from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import FocusRing from '#/components/styled/FocusRing'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
@ -34,7 +34,7 @@ import { useCutAndPaste } from '#/events/assetListEvent'
import {
backendMutationOptions,
backendQueryOptions,
useAssetPassiveListenerStrict,
useAsset,
useBackendMutationState,
} from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
@ -51,6 +51,7 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import invariant from 'tiny-invariant'
// =================
// === Constants ===
@ -80,14 +81,16 @@ export interface AssetRowInnerProps {
/** Props for an {@link AssetRow}. */
export interface AssetRowProps {
readonly isOpened: boolean
readonly isPlaceholder: boolean
readonly visibility: Visibility | undefined
readonly id: backendModule.AssetId
readonly parentId: backendModule.DirectoryId
readonly type: backendModule.AssetType
readonly hidden: boolean
readonly path: string
readonly initialAssetEvents: readonly AssetEvent[] | null
readonly depth: number
readonly state: assetsTable.AssetsTableState
readonly hidden: boolean
readonly columns: columnUtils.Column[]
readonly isKeyboardSelected: boolean
readonly grabKeyboardFocus: (item: backendModule.AnyAsset) => void
@ -116,15 +119,163 @@ export interface AssetRowProps {
}
/** A row containing an {@link backendModule.AnyAsset}. */
// eslint-disable-next-line no-restricted-syntax
export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const { id, parentId, isKeyboardSelected, isOpened, select, state, columns, onClick } = props
const { type, columns, depth, id } = props
switch (type) {
case backendModule.AssetType.specialLoading:
case backendModule.AssetType.specialEmpty:
case backendModule.AssetType.specialError: {
return <AssetSpecialRow columnsLength={columns.length} depth={depth} type={type} />
}
default: {
// This is safe because we filter out special asset types in the switch statement above.
// eslint-disable-next-line no-restricted-syntax
return <RealAssetRow {...props} id={id as backendModule.RealAssetId} />
}
}
})
/**
* Props for a {@link AssetSpecialRow}.
*/
export interface AssetSpecialRowProps {
readonly type: backendModule.AssetType
readonly columnsLength: number
readonly depth: number
}
/**
* Renders a special asset row.
*/
// eslint-disable-next-line no-restricted-syntax
const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialRowProps) {
const { type, columnsLength, depth } = props
const { getText } = textProvider.useText()
switch (type) {
case backendModule.AssetType.specialLoading: {
return (
<tr>
<td colSpan={columnsLength} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twJoin(
'flex h-table-row w-container items-center justify-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<StatelessSpinner size={24} state="loading-medium" />
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialEmpty: {
return (
<tr>
<td colSpan={columnsLength} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twJoin(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text className="px-name-column-x placeholder" disableLineHeightCompensation>
{getText('thisFolderIsEmpty')}
</Text>
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialError: {
return (
<tr>
<td colSpan={columnsLength} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twJoin(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text
className="px-name-column-x text-danger placeholder"
disableLineHeightCompensation
>
{getText('thisFolderFailedToFetch')}
</Text>
</div>
</td>
</tr>
)
}
default: {
invariant(false, 'Unsupported special asset type: ' + type)
}
}
})
/**
* Props for a {@link RealAssetRow}.
*/
type RealAssetRowProps = AssetRowProps & { readonly id: backendModule.RealAssetId }
/**
* Renders a real asset row.
*/
// eslint-disable-next-line no-restricted-syntax
const RealAssetRow = React.memo(function RealAssetRow(props: RealAssetRowProps) {
const { id, parentId, state } = props
const { category, backend } = state
const asset = useAsset({
backend,
parentId,
category,
assetId: id,
})
if (asset == null) {
return null
}
return <RealAssetInternalRow {...props} asset={asset} />
})
/**
* Internal props for a {@link RealAssetRow}.
*/
export interface RealAssetRowInternalProps extends AssetRowProps {
readonly asset: backendModule.AnyAsset
}
/**
* Internal implementation of a {@link RealAssetRow}.
*/
export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
const {
id,
parentId,
isKeyboardSelected,
isOpened,
select,
state,
columns,
onClick,
isPlaceholder,
type,
asset,
} = props
const { path, hidden: hiddenRaw, grabKeyboardFocus, visibility: visibilityRaw, depth } = props
const { initialAssetEvents } = props
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
const { doToggleDirectoryExpansion } = state
const asset = useAssetPassiveListenerStrict(backend.type, id, parentId, category)
const driveStore = useDriveStore()
const queryClient = useQueryClient()
const { user } = useFullUserSession()
@ -178,13 +329,16 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({
...createGetProjectDetailsQuery.createPassiveListener(
// This is SAFE, as `isOpened` is only true for projects.
...createGetProjectDetailsQuery({
// This is safe because we disable the query when the asset is not a project.
// see `enabled` property below.
// eslint-disable-next-line no-restricted-syntax
asset.id as backendModule.ProjectId,
),
select: (data) => data?.state.type,
enabled: asset.type === backendModule.AssetType.project,
assetId: asset.id as backendModule.ProjectId,
parentId: asset.parentId,
backend,
}),
select: (data) => data.state.type,
enabled: asset.type === backendModule.AssetType.project && !isPlaceholder,
})
const toastAndLog = useToastAndLog()
@ -320,11 +474,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
case backendModule.AssetType.project: {
try {
const details = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getProjectDetails', [
asset.id,
asset.parentId,
asset.title,
]),
backendQueryOptions(backend, 'getProjectDetails', [asset.id, asset.parentId]),
)
if (details.url != null) {
await backend.download(details.url, `${asset.title}.enso-project`)
@ -489,7 +639,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
}, initialAssetEvents)
switch (asset.type) {
switch (type) {
case backendModule.AssetType.directory:
case backendModule.AssetType.project:
case backendModule.AssetType.file:
@ -679,6 +829,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
return (
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
<Render
isPlaceholder={isPlaceholder}
keyProp={id}
isOpened={isOpened}
backendType={backend.type}
@ -718,62 +869,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
</>
)
}
case backendModule.AssetType.specialLoading: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row w-container items-center justify-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<StatelessSpinner size={24} state={statelessSpinner.SpinnerState.loadingMedium} />
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialEmpty: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text className="px-name-column-x placeholder" disableLineHeightCompensation>
{getText('thisFolderIsEmpty')}
</Text>
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialError: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text
className="px-name-column-x text-danger placeholder"
disableLineHeightCompensation
>
{getText('thisFolderFailedToFetch')}
</Text>
</div>
</td>
</tr>
)
default: {
invariant(
false,
'Unsupported asset type, expected one of: directory, project, file, datalink, secret, but got: ' +
type,
)
}
}
})
}

View File

@ -11,8 +11,8 @@ import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import Spinner from '#/components/Spinner'
import StatelessSpinner, * as spinner from '#/components/StatelessSpinner'
import { Spinner } from '#/components/Spinner'
import { StatelessSpinner, type SpinnerState } from '#/components/StatelessSpinner'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
@ -21,6 +21,8 @@ import { useBackendQuery } from '#/hooks/backendHooks'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useMemo } from 'react'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
// =================
// === Constants ===
// =================
@ -28,34 +30,34 @@ import { useMemo } from 'react'
export const CLOSED_PROJECT_STATE = { type: backendModule.ProjectState.closed } as const
/**
* The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
* The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
* when using the remote backend.
*/
const REMOTE_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.SpinnerState>> = {
[backendModule.ProjectState.closed]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.closing]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.created]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.new]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.placeholder]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.provisioned]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.scheduled]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
const REMOTE_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, SpinnerState>> = {
[backendModule.ProjectState.closed]: 'loading-slow',
[backendModule.ProjectState.closing]: 'loading-medium',
[backendModule.ProjectState.created]: 'loading-slow',
[backendModule.ProjectState.new]: 'loading-slow',
[backendModule.ProjectState.placeholder]: 'loading-slow',
[backendModule.ProjectState.openInProgress]: 'loading-slow',
[backendModule.ProjectState.provisioned]: 'loading-slow',
[backendModule.ProjectState.scheduled]: 'loading-slow',
[backendModule.ProjectState.opened]: 'done',
}
/**
* The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
* The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
* when using the local backend.
*/
const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.SpinnerState>> = {
[backendModule.ProjectState.closed]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.closing]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.created]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.new]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.placeholder]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.provisioned]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.scheduled]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, SpinnerState>> = {
[backendModule.ProjectState.closed]: 'loading-slow',
[backendModule.ProjectState.closing]: 'loading-medium',
[backendModule.ProjectState.created]: 'loading-slow',
[backendModule.ProjectState.new]: 'loading-slow',
[backendModule.ProjectState.placeholder]: 'loading-medium',
[backendModule.ProjectState.openInProgress]: 'loading-slow',
[backendModule.ProjectState.provisioned]: 'loading-medium',
[backendModule.ProjectState.scheduled]: 'loading-medium',
[backendModule.ProjectState.opened]: 'done',
}
// ===================
@ -64,6 +66,7 @@ const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.S
/** Props for a {@link ProjectIcon}. */
export interface ProjectIconProps {
readonly isPlaceholder: boolean
readonly backend: Backend
readonly isDisabled: boolean
readonly isOpened: boolean
@ -72,7 +75,7 @@ export interface ProjectIconProps {
/** An interactive icon indicating the status of a project. */
export default function ProjectIcon(props: ProjectIconProps) {
const { backend, item, isOpened, isDisabled } = props
const { backend, item, isOpened, isDisabled, isPlaceholder } = props
const openProject = projectHooks.useOpenProject()
const closeProject = projectHooks.useCloseProject()
@ -84,10 +87,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const itemProjectState = item.projectState ?? CLOSED_PROJECT_STATE
const { data: projectState, isError } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id),
select: (data) => data?.state,
enabled: isOpened,
...projectHooks.createGetProjectDetailsQuery({
assetId: item.id,
parentId: item.parentId,
backend,
}),
select: (data) => data.state,
enabled: !isPlaceholder && isOpened,
})
const status = projectState?.type
const isRunningInBackground = projectState?.executeAsync ?? false
@ -109,27 +117,32 @@ export default function ProjectIcon(props: ProjectIconProps) {
userOpeningProject == null ? null : getText('xIsUsingTheProject', userOpeningProject.name)
const state = (() => {
if (!isOpened && !isPlaceholder) {
return backendModule.ProjectState.closed
}
// Project is closed, show open button
if (!isOpened) {
return (projectState ?? itemProjectState).type
} else if (status == null) {
}
if (status == null) {
// Project is opened, but not yet queried.
return backendModule.ProjectState.openInProgress
} else if (status === backendModule.ProjectState.closed) {
}
if (status === backendModule.ProjectState.closed) {
// Project is opened locally, but not on the backend yet.
return backendModule.ProjectState.openInProgress
} else {
return status
}
return status
})()
const spinnerState = (() => {
const spinnerState = ((): SpinnerState => {
if (!isOpened) {
return spinner.SpinnerState.initial
return 'initial'
} else if (isError) {
return spinner.SpinnerState.initial
return 'initial'
} else if (status == null) {
return spinner.SpinnerState.loadingSlow
return 'loading-slow'
} else {
return backend.type === backendModule.BackendType.remote ?
REMOTE_SPINNER_STATE[status]
@ -137,15 +150,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
})()
const doOpenProject = () => {
const doOpenProject = useEventCallback(() => {
openProject({ ...item, type: backend.type })
}
const doCloseProject = () => {
})
const doCloseProject = useEventCallback(() => {
closeProject({ ...item, type: backend.type })
}
const doOpenProjectTab = () => {
})
const doOpenProjectTab = useEventCallback(() => {
openProjectTab(item.id)
}
})
switch (state) {
case backendModule.ProjectState.new:
@ -207,7 +220,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
onPress={doCloseProject}
/>
<Spinner
state={spinner.SpinnerState.done}
state="done"
className={tailwindMerge.twMerge(
'pointer-events-none absolute inset-0',
isRunningInBackground && 'text-green',

View File

@ -38,7 +38,17 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {
* This should never happen.
*/
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const { item, selected, rowState, setRowState, state, isEditable, backendType, isOpened } = props
const {
item,
selected,
rowState,
setRowState,
state,
isEditable,
backendType,
isOpened,
isPlaceholder,
} = props
const { depth } = props
const { backend, nodeMap } = state
@ -114,7 +124,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
}}
>
<ProjectIcon isDisabled={!canExecute} isOpened={isOpened} backend={backend} item={item} />
<ProjectIcon
isDisabled={!canExecute}
isOpened={isOpened}
backend={backend}
item={item}
isPlaceholder={isPlaceholder}
/>
<EditableSpan
data-testid="asset-row-name"
editable={rowState.isEditingName}

View File

@ -1,6 +1,7 @@
/** @file Column types and column display modes. */
import type { Dispatch, JSX, SetStateAction } from 'react'
import type { SortableColumn } from '#/components/dashboard/column/columnUtils'
import { Column } from '#/components/dashboard/column/columnUtils'
import DocsColumn from '#/components/dashboard/column/DocsColumn'
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
@ -9,7 +10,9 @@ import NameColumn from '#/components/dashboard/column/NameColumn'
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import type { AnyAsset, Asset, AssetId, BackendType } from '#/services/Backend'
import type { SortInfo } from '#/utilities/sorting'
// ===================
// === AssetColumn ===
@ -29,11 +32,15 @@ export interface AssetColumnProps {
readonly rowState: AssetRowState
readonly setRowState: Dispatch<SetStateAction<AssetRowState>>
readonly isEditable: boolean
readonly isPlaceholder: boolean
}
/** Props for a {@link AssetColumn}. */
export interface AssetColumnHeadingProps {
readonly state: AssetsTableState
readonly category: Category
readonly hideColumn: (column: Column) => void
readonly sortInfo: SortInfo<SortableColumn> | null
readonly setSortInfo: (sortInfo: SortInfo<SortableColumn> | null) => void
}
/** Metadata describing how to render a column of the table. */

View File

@ -7,7 +7,7 @@ import type { AssetColumnProps } from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import { PaywallDialogButton } from '#/components/Paywall'
import AssetEventType from '#/events/AssetEventType'
import { useAssetPassiveListenerStrict } from '#/hooks/backendHooks'
import { useAssetStrict } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -36,7 +36,12 @@ interface SharedWithColumnPropsInternal extends Pick<AssetColumnProps, 'item'> {
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, state, isReadonly = false } = props
const { backend, category, setQuery } = state
const asset = useAssetPassiveListenerStrict(backend.type, item.id, item.parentId, category)
const asset = useAssetStrict({
backend,
assetId: item.id,
parentId: item.parentId,
category,
})
const { user } = useFullUserSession()
const dispatchAssetEvent = useDispatchAssetEvent()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })

View File

@ -3,14 +3,18 @@ import AccessedByProjectsIcon from '#/assets/accessed_by_projects.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Accessed by projects" column. */
export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.accessedByProjects)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +22,7 @@ export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadin
icon={AccessedByProjectsIcon}
aria-label={getText('accessedByProjectsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.accessedByProjects)
}}
onPress={hideThisColumn}
/>
<Text className="text-sm font-semibold">{getText('accessedByProjectsColumnName')}</Text>
</div>

View File

@ -3,14 +3,18 @@ import AccessedDataIcon from '#/assets/accessed_data.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Accessed data" column. */
export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.accessedData)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +22,7 @@ export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps
icon={AccessedDataIcon}
aria-label={getText('accessedDataColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.accessedData)
}}
onPress={hideThisColumn}
/>
<Text className="text-sm font-semibold">{getText('accessedDataColumnName')}</Text>
</div>

View File

@ -3,14 +3,18 @@ import DocsIcon from '#/assets/docs.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Docs" column. */
export default function DocsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.docs)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +22,7 @@ export default function DocsColumnHeading(props: AssetColumnHeadingProps) {
icon={DocsIcon}
aria-label={getText('docsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.docs)
}}
onPress={hideThisColumn}
/>
<Text className="text-sm font-semibold">{getText('docsColumnName')}</Text>
</div>

View File

@ -3,14 +3,19 @@ import TagIcon from '#/assets/tag.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Labels" column. */
export default function LabelsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.labels)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +23,7 @@ export default function LabelsColumnHeading(props: AssetColumnHeadingProps) {
icon={TagIcon}
aria-label={getText('labelsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.labels)
}}
onPress={hideThisColumn}
/>
<Text className="fond-semibold text-sm">{getText('labelsColumnName')}</Text>
</div>

View File

@ -5,18 +5,40 @@ import { Text } from '#/components/aria'
import { Button } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
import { twMerge } from '#/utilities/tailwindMerge'
import { twJoin } from '#/utilities/tailwindMerge'
/** A heading for the "Modified" column. */
export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { sortInfo, setSortInfo, hideColumn } = state
const { hideColumn, sortInfo, setSortInfo } = props
const { getText } = useText()
const isSortActive = sortInfo?.field === Column.modified
const isDescending = sortInfo?.direction === SortDirection.descending
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.modified)
})
const cycleSortDirection = useEventCallback(() => {
if (!sortInfo) {
setSortInfo({ field: Column.modified, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.modified, direction: nextDirection })
}
})
return (
<div
aria-label={
@ -32,35 +54,19 @@ export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
icon={TimeIcon}
aria-label={getText('modifiedColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.modified)
}}
onPress={hideThisColumn}
/>
<Button
size="custom"
variant="custom"
className="flex grow justify-start gap-icon-with-text"
onPress={() => {
if (!sortInfo) {
setSortInfo({ field: Column.modified, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.modified, direction: nextDirection })
}
}}
onPress={cycleSortDirection}
>
<Text className="text-header">{getText('modifiedColumnName')}</Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={twMerge(
className={twJoin(
'transition-all duration-arrow',
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
isDescending && 'rotate-180',

View File

@ -3,18 +3,34 @@ import SortAscendingIcon from '#/assets/sort_ascending.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
import { twMerge } from '#/utilities/tailwindMerge'
import { twJoin } from '#/utilities/tailwindMerge'
/** A heading for the "Name" column. */
export default function NameColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { sortInfo, setSortInfo } = state
const { sortInfo, setSortInfo } = props
const { getText } = useText()
const isSortActive = sortInfo?.field === Column.name
const isDescending = sortInfo?.direction === SortDirection.descending
const cycleSortDirection = useEventCallback(() => {
if (!sortInfo) {
setSortInfo({ field: Column.name, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.name, direction: nextDirection })
}
})
return (
<Button
size="custom"
@ -26,26 +42,13 @@ export default function NameColumnHeading(props: AssetColumnHeadingProps) {
: getText('sortByNameDescending')
}
className="group flex h-table-row w-full items-center justify-start gap-icon-with-text px-name-column-x"
onPress={() => {
if (!sortInfo) {
setSortInfo({ field: Column.name, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.name, direction: nextDirection })
}
}}
onPress={cycleSortDirection}
>
<Text className="text-sm font-semibold">{getText('nameColumnName')}</Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={twMerge(
className={twJoin(
'transition-all duration-arrow',
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
isDescending && 'rotate-180',

View File

@ -5,19 +5,22 @@ import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { PaywallDialogButton } from '#/components/Paywall'
import { usePaywall } from '#/hooks/billing'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Shared with" column. */
export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { category, hideColumn } = state
const { hideColumn, category } = props
const { getText } = useText()
const { user } = useFullUserSession()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.sharedWith)
})
const isUnderPaywall = isFeatureUnderPaywall('share')
return (
@ -27,9 +30,7 @@ export default function SharedWithColumnHeading(props: AssetColumnHeadingProps)
icon={PeopleIcon}
aria-label={getText('sharedWithColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.sharedWith)
}}
onPress={hideThisColumn}
/>
<div className="flex items-center gap-1">

View File

@ -58,7 +58,7 @@ interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType
readonly templateId: string | null
readonly datalinkId: backend.DatalinkId | null
readonly preferredName: string | null
readonly onCreated?: (project: backend.CreatedProject) => void
readonly onCreated?: (project: backend.CreatedProject, parentId: backend.DirectoryId) => void
readonly onError?: () => void
}

View File

@ -29,7 +29,6 @@ import * as backendModule from '#/services/Backend'
import {
AssetType,
BackendType,
type AnyAsset,
type AssetId,
type DirectoryAsset,
type DirectoryId,
@ -38,7 +37,7 @@ import {
} from '#/services/Backend'
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import { usePreventNavigation } from '#/utilities/preventNavigation'
import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime'
import { toRfc3339 } from '../utilities/dateTime'
// The number of bytes in 1 megabyte.
const MB_BYTES = 1_000_000
@ -282,6 +281,52 @@ export function useListUserGroupsWithUsers(
}, [listUserGroupsQuery.data, listUsersQuery.data])
}
/**
*
*/
export interface ListDirectoryQueryOptions {
readonly backend: Backend
readonly parentId: DirectoryId
readonly category: Category
}
/**
* Builds a query options object to fetch the children of a directory.
*/
export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) {
const { backend, parentId, category } = options
return queryOptions({
queryKey: [
backend.type,
'listDirectory',
parentId,
{
labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent',
},
] as const,
queryFn: async () => {
try {
return await backend.listDirectory(
{
parentId,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
labels: null,
recentProjects: category.type === 'recent',
},
parentId,
)
} catch {
throw Object.assign(new Error(), { parentId })
}
},
meta: { persist: false },
})
}
/**
* Upload progress for {@link useUploadFileMutation}.
*/
@ -295,30 +340,26 @@ export interface UploadFileMutationProgress {
readonly totalMb: number
}
/**
*
*/
export interface UseAssetOptions extends ListDirectoryQueryOptions {
readonly assetId: AssetId
}
/** Data for a specific asset. */
export function useAssetPassiveListener(
backendType: BackendType,
assetId: AssetId | null | undefined,
parentId: DirectoryId | null | undefined,
category: Category,
) {
const { data: asset } = useQuery<readonly AnyAsset[] | undefined, Error, AnyAsset | undefined>({
queryKey: [
backendType,
'listDirectory',
parentId,
{
labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent',
},
],
initialData: undefined,
select: (data) => data?.find((child) => child.id === assetId),
export function useAsset(options: UseAssetOptions) {
const { parentId, assetId } = options
const { data: asset } = useQuery({
...listDirectoryQueryOptions(options),
select: (data) => data.find((child) => child.id === assetId),
})
if (asset || !assetId || !parentId) {
if (asset) {
return asset
}
const shared = {
parentId,
projectState: null,
@ -378,14 +419,14 @@ export function useAssetPassiveListener(
}
/** Data for a specific asset. */
export function useAssetPassiveListenerStrict(
backendType: BackendType,
assetId: AssetId | null | undefined,
parentId: DirectoryId | null | undefined,
category: Category,
) {
const asset = useAssetPassiveListener(backendType, assetId, parentId, category)
invariant(asset, 'Asset not found')
export function useAssetStrict(options: UseAssetOptions) {
const asset = useAsset(options)
invariant(
asset,
`Expected asset to be defined, but got undefined, Asset ID: ${JSON.stringify(options.assetId)}`,
)
return asset
}

View File

@ -5,22 +5,21 @@
*/
import * as React from 'react'
import * as callbackHooks from './eventCallbackHooks'
import * as unmountEffect from './unmountHooks'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'
/** Wrap a callback into debounce function */
export function useDebouncedCallback<Fn extends (...args: never[]) => unknown>(
callback: Fn,
deps: React.DependencyList,
delay: number,
maxWait = 0,
maxWait: number | null = null,
): DebouncedFunction<Fn> {
const stableCallback = callbackHooks.useEventCallback(callback)
const stableCallback = useEventCallback(callback)
const timeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>()
const waitTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>()
const lastCallRef = React.useRef<{ args: Parameters<Fn> }>()
const clear = () => {
const clear = useEventCallback(() => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = undefined
@ -30,53 +29,50 @@ export function useDebouncedCallback<Fn extends (...args: never[]) => unknown>(
clearTimeout(waitTimeoutIdRef.current)
waitTimeoutIdRef.current = undefined
}
}
})
const execute = useEventCallback(() => {
if (!lastCallRef.current) {
return
}
const context = lastCallRef.current
lastCallRef.current = undefined
stableCallback(...context.args)
clear()
})
const wrapped = useEventCallback((...args: Parameters<Fn>) => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
}
lastCallRef.current = { args }
if (delay === 0) {
execute()
} else {
// plan regular execution
timeoutIdRef.current = setTimeout(execute, delay)
// plan maxWait execution if required
if (maxWait != null && !waitTimeoutIdRef.current) {
waitTimeoutIdRef.current = setTimeout(execute, maxWait)
}
}
})
Object.defineProperties(wrapped, {
length: { value: stableCallback.length },
name: { value: `${stableCallback.name || 'anonymous'}__debounced__${delay}` },
})
// cancel scheduled execution on unmount
unmountEffect.useUnmount(clear)
useUnmount(clear)
return React.useMemo(() => {
const execute = () => {
if (!lastCallRef.current) {
return
}
const context = lastCallRef.current
lastCallRef.current = undefined
stableCallback(...context.args)
clear()
}
const wrapped = (...args: Parameters<Fn>) => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
}
lastCallRef.current = { args }
if (delay === 0) {
execute()
} else {
// plan regular execution
timeoutIdRef.current = setTimeout(execute, delay)
// plan maxWait execution if required
if (maxWait > 0 && !waitTimeoutIdRef.current) {
waitTimeoutIdRef.current = setTimeout(execute, maxWait)
}
}
}
Object.defineProperties(wrapped, {
length: { value: stableCallback.length },
name: { value: `${stableCallback.name || 'anonymous'}__debounced__${delay}` },
})
return wrapped
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stableCallback, delay, maxWait, ...deps])
return wrapped
}
/** The type of a wrapped function that has been debounced. */

View File

@ -7,7 +7,7 @@
import * as React from 'react'
import * as debouncedCallback from './debounceCallbackHooks'
import * as eventCallbackHooks from './eventCallbackHooks'
import { useEventCallback } from './eventCallbackHooks'
/** A hook that returns a stateful value, and a function to update it that will debounce updates. */
export function useDebounceState<S>(
@ -17,21 +17,20 @@ export function useDebounceState<S>(
): [S, React.Dispatch<React.SetStateAction<S>>] {
const [state, setState] = React.useState(initialState)
const currentValueRef = React.useRef(state)
const [, startTransition] = React.useTransition()
const debouncedSetState = debouncedCallback.useDebouncedCallback<
React.Dispatch<React.SetStateAction<S>>
>(
(value) => {
startTransition(() => {
React.startTransition(() => {
setState(value)
})
},
[],
delay,
maxWait,
)
const setValue = eventCallbackHooks.useEventCallback((next: S | ((currentValue: S) => S)) => {
const setValue = useEventCallback((next: S | ((currentValue: S) => S)) => {
currentValueRef.current = next instanceof Function ? next(currentValueRef.current) : next
debouncedSetState(currentValueRef.current)

View File

@ -0,0 +1,259 @@
/**
* @file
*
* This file contains the useMeasure hook, which is used to measure the size and position of an element.
*/
import { frame } from 'framer-motion'
import { startTransition, useEffect, useRef, useState } from 'react'
import { unsafeMutable } from '../utilities/object'
import { useDebouncedCallback } from './debounceCallbackHooks'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'
/**
* A read-only version of the DOMRect object.
*/
export interface RectReadOnly {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly top: number
readonly right: number
readonly bottom: number
readonly left: number
}
/**
* A type that represents an HTML or SVG element.
*/
type HTMLOrSVGElement = HTMLElement | SVGElement
/**
* A type that represents the result of the useMeasure hook.
*/
type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void]
/**
* A type that represents the state of the useMeasure hook.
*/
interface State {
readonly element: HTMLOrSVGElement | null
readonly scrollContainers: HTMLOrSVGElement[] | null
readonly lastBounds: RectReadOnly
}
/**
* A type that represents the options for the useMeasure hook.
*/
export interface Options {
readonly debounce?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly scroll?: boolean
readonly offsetSize?: boolean
readonly onResize?: (bounds: RectReadOnly) => void
readonly maxWait?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
}
/**
* Custom hook to measure the size and position of an element
*/
export function useMeasure(options: Options = {}): Result {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const { debounce = 0, scroll = false, offsetSize = false, onResize, maxWait = 500 } = options
const [bounds, set] = useState<RectReadOnly>(() => ({
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
}))
// keep all state in a ref
const state = useRef<State>({
element: null,
scrollContainers: null,
lastBounds: bounds,
})
const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll
const resizeMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.resize
const frameMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.frame
// set actual debounce values early, so effects know if they should react accordingly
const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll
const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize
const frameDebounce = typeof debounce === 'number' ? debounce : debounce.frame
// make sure to update state only as long as the component is truly mounted
const mounted = useRef(false)
useUnmount(() => {
mounted.current = false
})
const callback = useEventCallback(() => {
frame.read(() => {
if (!state.current.element) return
const { left, top, width, height, bottom, right, x, y } =
state.current.element.getBoundingClientRect()
const size = { left, top, width, height, bottom, right, x, y }
if (state.current.element instanceof HTMLElement && offsetSize) {
size.height = state.current.element.offsetHeight
size.width = state.current.element.offsetWidth
}
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
startTransition(() => {
set((unsafeMutable(state.current).lastBounds = size))
onResize?.(size)
})
}
})
})
const [resizeObserver] = useState(() => new ResizeObserver(callback))
const [mutationObserver] = useState(() => new MutationObserver(callback))
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait)
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait)
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
const forceRefresh = useDebouncedCallback(callback, 0)
// cleanup current scroll-listeners / observers
const removeListeners = useEventCallback(() => {
if (state.current.scrollContainers) {
state.current.scrollContainers.forEach((element) => {
element.removeEventListener('scroll', scrollDebounceCallback, true)
})
unsafeMutable(state.current).scrollContainers = null
}
resizeObserver.disconnect()
mutationObserver.disconnect()
})
const addListeners = useEventCallback(() => {
if (!state.current.element) return
resizeObserver.observe(state.current.element)
mutationObserver.observe(state.current.element, {
attributes: true,
attributeFilter: ['style', 'class'],
})
frame.read(frameDebounceCallback, true)
if (scroll && state.current.scrollContainers) {
state.current.scrollContainers.forEach((scrollContainer) => {
scrollContainer.addEventListener('scroll', scrollDebounceCallback, {
capture: true,
passive: true,
})
})
}
})
// the ref we expose to the user
const ref = useEventCallback((node: HTMLOrSVGElement | null) => {
mounted.current = node != null
if (!node || node === state.current.element) return
removeListeners()
unsafeMutable(state.current).element = node
unsafeMutable(state.current).scrollContainers = findScrollContainers(node)
addListeners()
})
// add general event listeners
useOnWindowScroll(scrollDebounceCallback, Boolean(scroll))
useOnWindowResize(resizeDebounceCallback)
// respond to changes that are relevant for the listeners
useEffect(() => {
removeListeners()
addListeners()
}, [scroll, scrollDebounceCallback, resizeDebounceCallback, removeListeners, addListeners])
useUnmount(removeListeners)
return [ref, bounds, forceRefresh]
}
/**
* Adds a window resize event listener
*/
function useOnWindowResize(onWindowResize: (event: Event) => void) {
useEffect(() => {
const cb = onWindowResize
window.addEventListener('resize', cb)
return () => {
window.removeEventListener('resize', cb)
}
}, [onWindowResize])
}
/**
* Adds a window scroll event listener
*/
function useOnWindowScroll(onScroll: () => void, enabled: boolean) {
useEffect(() => {
if (enabled) {
const cb = onScroll
window.addEventListener('scroll', cb, { capture: true, passive: true })
return () => {
window.removeEventListener('scroll', cb, true)
}
}
}, [onScroll, enabled])
}
// Returns a list of scroll offsets
/**
* Finds all scroll containers that have overflow set to 'auto' or 'scroll'
* @param element - The element to start searching from
* @returns An array of scroll containers
*/
function findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] {
const result: HTMLOrSVGElement[] = []
if (!element || element === document.body) return result
const { overflow, overflowX, overflowY } = window.getComputedStyle(element)
if ([overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll')) {
result.push(element)
}
return [...result, ...findScrollContainers(element.parentElement)]
}
// Checks if element boundaries are equal
const RECT_KEYS: readonly (keyof RectReadOnly)[] = [
'x',
'y',
'top',
'bottom',
'left',
'right',
'width',
'height',
]
/**
* Compares two RectReadOnly objects to check if their boundaries are equal
* @param a - First RectReadOnly object
* @param b - Second RectReadOnly object
* @returns boolean indicating whether the boundaries are equal
*/
function areBoundsEqual(a: RectReadOnly, b: RectReadOnly): boolean {
return RECT_KEYS.every((key) => a[key] === b[key])
}

View File

@ -1,6 +1,4 @@
/** @file Mutations related to project management. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
@ -22,9 +20,8 @@ import {
} from '#/providers/ProjectsProvider'
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
// ====================================
// === createGetProjectDetailsQuery ===
@ -44,12 +41,9 @@ const ACTIVE_SYNC_INTERVAL_MS = 100
/** Options for {@link createGetProjectDetailsQuery}. */
export interface CreateOpenedProjectQueryOptions {
readonly type: backendModule.BackendType
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
readonly remoteBackend: RemoteBackend
readonly localBackend: LocalBackend | null
readonly backend: Backend
}
/** Return a function to update a project asset in the TanStack Query cache. */
@ -84,15 +78,18 @@ function useSetProjectAsset() {
/** Project status query. */
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
const { assetId, parentId, backend } = options
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
const isLocal = type === backendModule.BackendType.local
const isLocal = backend.type === backendModule.BackendType.local
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
queryFn: () => backend.getProjectDetails(assetId, parentId),
meta: { persist: false },
gcTime: 0,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always',
refetchInterval: ({ state }) => {
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
@ -115,22 +112,9 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
}
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
queryFn: async () => {
invariant(backend != null, 'Backend is null')
return await backend.getProjectDetails(assetId, parentId, title)
},
})
}
createGetProjectDetailsQuery.getQueryKey = (id: LaunchedProjectId) => ['project', id] as const
createGetProjectDetailsQuery.createPassiveListener = (id: LaunchedProjectId) =>
reactQuery.queryOptions<backendModule.Project | null>({
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
initialData: null,
})
// ==============================
// === useOpenProjectMutation ===
@ -302,6 +286,7 @@ export function useOpenProject() {
predicate: (mutation) => mutation.options.scope?.id === project.id,
})
const isOpeningTheSameProject = existingMutation?.state.status === 'pending'
if (!isOpeningTheSameProject) {
openProjectMutation.mutate(project)
const openingProjectMutation = client.getMutationCache().find({
@ -327,9 +312,7 @@ export function useOpenProject() {
export function useOpenEditor() {
const setPage = useSetPage()
return eventCallbacks.useEventCallback((projectId: LaunchedProjectId) => {
React.startTransition(() => {
setPage(projectId)
})
setPage(projectId)
})
}
@ -342,7 +325,6 @@ export function useCloseProject() {
const client = reactQuery.useQueryClient()
const closeProjectMutation = useCloseProjectMutation()
const removeLaunchedProject = useRemoveLaunchedProject()
const projectsStore = useProjectsStore()
const setPage = useSetPage()
return eventCallbacks.useEventCallback((project: LaunchedProject) => {
@ -356,7 +338,9 @@ export function useCloseProject() {
mutation.setOptions({ ...mutation.options, retry: false })
mutation.destroy()
})
closeProjectMutation.mutate(project)
client
.getMutationCache()
.findAll({
@ -368,13 +352,10 @@ export function useCloseProject() {
.forEach((mutation) => {
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
})
removeLaunchedProject(project.id)
// There is no shared enum type, but the other union member is the same type.
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (projectsStore.getState().page === project.id) {
setPage(TabType.drive)
}
setPage(TabType.drive)
})
}
@ -384,10 +365,13 @@ export function useCloseProject() {
/** A function to close all projects. */
export function useCloseAllProjects() {
const projectsStore = useProjectsStore()
const closeProject = useCloseProject()
const projectsStore = useProjectsStore()
return eventCallbacks.useEventCallback(() => {
for (const launchedProject of projectsStore.getState().launchedProjects) {
const launchedProjects = projectsStore.getState().launchedProjects
for (const launchedProject of launchedProjects) {
closeProject(launchedProject)
}
})

View File

@ -1,9 +1,11 @@
/** @file Hooks for showing an overlay with a cutout for a rectangular element. */
import { useEffect, useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import { startTransition, useEffect, useLayoutEffect, useState, type CSSProperties } from 'react'
import { useDimensions } from '#/hooks/dimensionsHooks'
import Portal from '#/components/Portal'
import { convertCSSUnitString } from '#/utilities/convertCSSUnits'
import { useEventCallback } from './eventCallbackHooks'
import type { RectReadOnly } from './measureHooks'
import { useMeasure } from './measureHooks'
/** Default padding around the spotlight element. */
const DEFAULT_PADDING_PX = 8
@ -17,7 +19,6 @@ const BACKGROUND_ELEMENT = document.getElementsByClassName('enso-spotlight')[0]
/** Props for {@link useSpotlight}. */
export interface SpotlightOptions {
readonly enabled: boolean
readonly ref: RefObject<HTMLElement | SVGElement | null>
readonly close: () => void
readonly backgroundElement?: HTMLElement
readonly paddingPx?: number | undefined
@ -25,26 +26,36 @@ export interface SpotlightOptions {
/** A hook for showing an overlay with a cutout for a rectangular element. */
export function useSpotlight(options: SpotlightOptions) {
const { enabled, ref, close, backgroundElement: backgroundElementRaw } = options
const { enabled, close, backgroundElement: backgroundElementRaw } = options
const { paddingPx = DEFAULT_PADDING_PX } = options
const backgroundElement = backgroundElementRaw ?? BACKGROUND_ELEMENT
const [refElement, setRefElement] = useState<HTMLElement | SVGElement | null>(null)
const refCallback = useEventCallback((node: HTMLElement | SVGElement | null) => {
if (node) {
setRefElement(node)
} else {
setRefElement(null)
}
})
const spotlightElement =
!enabled || !backgroundElement ?
null
: <Spotlight
close={close}
element={ref}
element={refElement}
backgroundElement={backgroundElement}
paddingPx={paddingPx}
/>
const style = { position: 'relative', zIndex: 3 } satisfies CSSProperties
return { spotlightElement, props: { style } }
return { spotlightElement, props: { style, ref: refCallback } }
}
/** Props for a {@link Spotlight}. */
interface SpotlightProps {
readonly element: RefObject<HTMLElement | SVGElement | null>
readonly element: HTMLElement | SVGElement | null
readonly close: () => void
readonly backgroundElement: HTMLElement | SVGElement
readonly paddingPx?: number | undefined
@ -52,28 +63,45 @@ interface SpotlightProps {
/** A spotlight element. */
function Spotlight(props: SpotlightProps) {
const { element, close, backgroundElement, paddingPx = 0 } = props
const [dimensionsRef, { top: topRaw, left: leftRaw, height, width }] = useDimensions()
const top = topRaw - paddingPx
const left = leftRaw - paddingPx
const { element, close, paddingPx = 0 } = props
const [bounds, setBounds] = useState<RectReadOnly>()
const [borderRadius, setBorderRadius] = useState(0)
const r = Math.min(borderRadius, height / 2 + paddingPx, width / 2 + paddingPx)
const straightWidth = Math.max(0, width + paddingPx * 2 - borderRadius * 2)
const straightHeight = Math.max(0, height + paddingPx * 2 - borderRadius * 2)
const [dimensionsRef] = useMeasure({
onResize: (nextBounds) => {
startTransition(() => {
setBounds(nextBounds)
})
},
})
useEffect(() => {
if (element.current) {
dimensionsRef(element.current)
if (element) {
dimensionsRef(element)
}
}, [dimensionsRef, element])
useLayoutEffect(() => {
if (element.current) {
const sizeString = getComputedStyle(element.current).borderRadius
setBorderRadius(convertCSSUnitString(sizeString, 'px', element.current).number)
if (element) {
const sizeString = getComputedStyle(element).borderRadius
setBorderRadius(convertCSSUnitString(sizeString, 'px', element).number)
}
}, [element])
if (!bounds) {
return null
}
const { top: topRaw, left: leftRaw, height, width } = bounds
const top = topRaw - paddingPx
const left = leftRaw - paddingPx
const r = Math.min(borderRadius, height / 2 + paddingPx, width / 2 + paddingPx)
const straightWidth = Math.max(0, width + paddingPx * 2 - borderRadius * 2)
const straightHeight = Math.max(0, height + paddingPx * 2 - borderRadius * 2)
const clipPath =
// A rectangle covering the entire screen
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
@ -97,18 +125,13 @@ function Spotlight(props: SpotlightProps) {
(r !== 0 ? `a${r} ${r} 0 0 1 ${r} -${r}` : '') +
'Z")'
return createPortal(
<div
onClick={close}
style={{
position: 'absolute',
zIndex: 2,
height: '100vh',
width: '100vw',
backgroundColor: 'lch(0 0 0 / 25%)',
clipPath,
}}
/>,
backgroundElement,
return (
<Portal>
<div
onClick={close}
className="absolute inset-0 z-20 h-full w-full bg-primary/25 contain-strict"
style={{ clipPath }}
/>
</Portal>
)
}

View File

@ -28,9 +28,12 @@ import { Suspense } from '#/components/Suspense'
import UIProviders from '#/components/UIProviders'
import HttpClient from '#/utilities/HttpClient'
import { MotionGlobalConfig } from 'framer-motion'
export type { GraphEditorRunner } from '#/layouts/Editor'
MotionGlobalConfig.skipAnimations = window.DISABLE_ANIMATIONS ?? false
// =================
// === Constants ===
// =================

View File

@ -145,11 +145,17 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
}
})
const { data } = reactQuery.useQuery(
asset.type === backendModule.AssetType.project ?
projectHooks.createGetProjectDetailsQuery.createPassiveListener(asset.id)
: { queryKey: ['__IGNORED__'] },
)
const { data } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery({
// This is safe because we disable the query when the asset is not a project.
// see `enabled` property below.
// eslint-disable-next-line no-restricted-syntax
assetId: asset.id as backendModule.ProjectId,
parentId: asset.parentId,
backend,
}),
enabled: asset.type === backendModule.AssetType.project,
})
const isRunningProject =
(asset.type === backendModule.AssetType.project &&

View File

@ -1,13 +1,11 @@
/** @file Diff view comparing `Main.enso` of two versions for a specific project. */
import * as monacoReact from '@monaco-editor/react'
import * as textProvider from '#/providers/TextProvider'
import Spinner, * as spinnerModule from '#/components/Spinner'
import { DiffEditor } from '@monaco-editor/react'
import { useSuspenseQueries } from '@tanstack/react-query'
import { Spinner } from '#/components/Spinner'
import { versionContentQueryOptions } from '#/layouts/AssetDiffView/useFetchVersionContent'
import type * as backendService from '#/services/Backend'
import type Backend from '#/services/Backend'
import { useFetchVersionContent } from './useFetchVersionContent'
// =====================
// === AssetDiffView ===
@ -15,8 +13,8 @@ import { useFetchVersionContent } from './useFetchVersionContent'
/** Props for an {@link AssetDiffView}. */
export interface AssetDiffViewProps {
readonly versionId: string
readonly latestVersionId: string
readonly versionId: backendService.S3ObjectVersionId
readonly latestVersionId: backendService.S3ObjectVersionId
readonly project: backendService.ProjectAsset
readonly backend: Backend
}
@ -24,49 +22,46 @@ export interface AssetDiffViewProps {
/** Diff view comparing `Main.enso` of two versions for a specific project. */
export function AssetDiffView(props: AssetDiffViewProps) {
const { versionId, project, backend, latestVersionId } = props
const { getText } = textProvider.useText()
const versionContent = useFetchVersionContent({
versionId,
project,
backend,
})
const headContent = useFetchVersionContent({
versionId: latestVersionId,
project,
backend,
const [versionContent, headContent] = useSuspenseQueries({
queries: [
versionContentQueryOptions({
versionId,
projectId: project.id,
backend,
}),
versionContentQueryOptions({
versionId: latestVersionId,
projectId: project.id,
backend,
}),
],
})
const loader = (
<div className="flex h-full w-full items-center justify-center">
<Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
<Spinner size={32} state="loading-medium" />
</div>
)
if (versionContent.isError || headContent.isError) {
return <div className="p-indent-8 text-center">{getText('loadFileError')}</div>
} else if (versionContent.isPending || headContent.isPending) {
return loader
} else {
return (
<monacoReact.DiffEditor
beforeMount={(monaco) => {
monaco.editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [],
// The name comes from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
colors: { 'editor.background': '#00000000' },
})
}}
original={versionContent.data}
modified={headContent.data}
language="enso"
options={{ readOnly: true }}
loading={loader}
theme={'myTheme'}
/>
)
}
return (
<DiffEditor
beforeMount={(monaco) => {
monaco.editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [],
// The name comes from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
colors: { 'editor.background': '#00000000' },
})
}}
original={versionContent.data}
modified={headContent.data}
language="enso"
options={{ readOnly: true }}
loading={loader}
theme={'myTheme'}
/>
)
}

View File

@ -4,6 +4,7 @@ import * as reactQuery from '@tanstack/react-query'
import type * as backendService from '#/services/Backend'
import type Backend from '#/services/Backend'
import { splitFileContents } from 'ydoc-shared/ensoFile'
// =================
// === Constants ===
@ -17,26 +18,41 @@ const TWO_MINUTES_MS = 120_000
/** Options for {@link useFetchVersionContent}. */
export interface FetchVersionContentOptions {
readonly project: backendService.ProjectAsset
readonly versionId: string
readonly projectId: backendService.ProjectId
readonly versionId?: backendService.S3ObjectVersionId
readonly backend: Backend
/** If `false`, the metadata is stripped out. Defaults to `false`. */
readonly metadata?: boolean
}
/** Fetch the content of a version. */
export function useFetchVersionContent(params: FetchVersionContentOptions) {
const { versionId, backend, project, metadata = false } = params
return reactQuery.useQuery({
queryKey: ['versionContent', versionId],
queryFn: () => backend.getFileContent(project.id, versionId, project.title),
select: (data) => (metadata ? data : omitMetadata(data)),
/**
* Return the query options for fetching the content of a version.
*/
export function versionContentQueryOptions(params: FetchVersionContentOptions) {
return reactQuery.queryOptions({
queryKey: [
params.backend.type,
{
method: 'getFileContent',
versionId: params.versionId,
projectId: params.projectId,
},
] as const,
queryFn: ({ queryKey }) => {
const [, { method, versionId, projectId }] = queryKey
return params.backend[method](projectId, versionId)
},
select: (data) => (params.metadata === true ? data : omitMetadata(data)),
staleTime: TWO_MINUTES_MS,
})
}
/** Fetch the content of a version. */
export function useFetchVersionContent(params: FetchVersionContentOptions) {
return reactQuery.useQuery(versionContentQueryOptions(params))
}
/** Remove the metadata from the content of a version. */
function omitMetadata(file: string): string {
return file.split('#### METADATA ####')[0]?.replace(/\n+$/, '') ?? file
return splitFileContents(file).code
}

View File

@ -0,0 +1,69 @@
/** @file Documentation display for an asset. */
import { MarkdownViewer } from '#/components/MarkdownViewer'
import { Result } from '#/components/Result'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import type { AnyAsset, Asset } from '#/services/Backend'
import { AssetType } from '#/services/Backend'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import * as ast from 'ydoc-shared/ast'
import { splitFileContents } from 'ydoc-shared/ensoFile'
import { versionContentQueryOptions } from '../AssetDiffView/useFetchVersionContent'
/** Props for a {@link AssetDocs}. */
export interface AssetDocsProps {
readonly backend: Backend
readonly item: AnyAsset | null
}
/** Documentation display for an asset. */
export function AssetDocs(props: AssetDocsProps) {
const { backend, item } = props
const { getText } = useText()
if (item?.type !== AssetType.project) {
return <Result status="info" title={getText('assetDocs.notProject')} centered />
}
return <AssetDocsContent backend={backend} item={item} />
}
/** Props for an {@link AssetDocsContent}. */
interface AssetDocsContentProps {
readonly backend: Backend
readonly item: Asset<AssetType.project>
}
/** Documentation display for an asset. */
export function AssetDocsContent(props: AssetDocsContentProps) {
const { backend, item } = props
const { getText } = useText()
const { data: docs } = useSuspenseQuery({
...versionContentQueryOptions({ backend, projectId: item.id, metadata: false }),
select: (data) => {
const withoutMeta = splitFileContents(data)
const module = ast.parseModule(withoutMeta.code)
for (const statement of module.statements()) {
if (statement instanceof ast.MutableFunctionDef && statement.name.code() === 'main') {
return statement.documentationText() ?? ''
}
}
return ''
},
})
const resolveProjectAssetPath = useCallback(
(relativePath: string) => backend.resolveProjectAssetPath(item.id, relativePath),
[backend, item.id],
)
if (!docs) {
return <Result status="info" title={getText('assetDocs.noDocs')} centered />
}
return <MarkdownViewer text={docs} imgUrlResolver={resolveProjectAssetPath} />
}

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrels for the `AssetDocs`.
*/
export { AssetDocs } from './AssetDocs'

View File

@ -1,182 +0,0 @@
/** @file A panel containing the description and settings for an asset. */
import { useEffect, useRef, useState } from 'react'
import * as z from 'zod'
import { TabPanel, Tabs } from '#/components/aria'
import ProjectSessions from '#/layouts/AssetProjectSessions'
import AssetProperties, { type AssetPropertiesSpotlight } from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import TabBar, { Tab } from '#/layouts/TabBar'
import { useAssetPanelProps, useIsAssetPanelVisible } from '#/providers/DriveProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import { AssetType, BackendType, type AnyAsset } from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import { twMerge } from '#/utilities/tailwindMerge'
// =====================
// === AssetPanelTab ===
// =====================
const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules'] as const
const TABS_SCHEMA = z.enum(ASSET_PANEL_TABS)
/** Determines the content of the {@link AssetPanel}. */
type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly assetPanelTab: AssetPanelTab
readonly assetPanelWidth: number
}
}
LocalStorage.register({
assetPanelTab: { schema: z.enum(ASSET_PANEL_TABS) },
assetPanelWidth: { schema: z.number().int() },
})
// ==================
// === AssetPanel ===
// ==================
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly item: AnyAsset | null
readonly path: string | null
readonly spotlightOn?: AssetPropertiesSpotlight
}
/** Props for an {@link AssetPanel}. */
export interface AssetPanelProps {
readonly backendType: BackendType
readonly category: Category
}
/** A panel containing the description and settings for an asset. */
export default function AssetPanel(props: AssetPanelProps) {
const { backendType, category } = props
const contextPropsRaw = useAssetPanelProps()
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
const { backend, item, path } = contextProps ?? {}
const isReadonly = category.type === 'trash'
const isCloud = backend?.type === BackendType.remote
const isVisible = useIsAssetPanelVisible()
const { getText } = useText()
const { localStorage } = useLocalStorage()
const [initialized, setInitialized] = useState(false)
const initializedRef = useRef(initialized)
initializedRef.current = initialized
const [tabRaw, setTab] = useState(() => localStorage.get('assetPanelTab') ?? 'settings')
const tab = (() => {
if (!isCloud) {
return 'settings'
} else if (
(item?.type === AssetType.secret || item?.type === AssetType.directory) &&
tabRaw === 'versions'
) {
return 'settings'
} else if (item?.type !== AssetType.project && tabRaw === 'sessions') {
return 'settings'
} else {
return tabRaw
}
})()
useEffect(() => {
// This prevents secrets and directories always setting the tab to `properties`
// (because they do not support the `versions` tab).
if (initializedRef.current) {
localStorage.set('assetPanelTab', tabRaw)
}
}, [tabRaw, localStorage])
useEffect(() => {
setInitialized(true)
}, [])
return (
<div
className={twMerge(
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
isVisible ? 'min-w-side-panel' : 'min-w-0',
)}
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<Tabs
data-testid="asset-panel"
className={twMerge(
'absolute flex h-full w-asset-panel flex-col bg-invert transition-[box-shadow] clip-path-left-shadow',
isVisible ? 'shadow-softer' : '',
)}
selectedKey={tab}
onSelectionChange={(newPage) => {
const validated = TABS_SCHEMA.safeParse(newPage)
if (validated.success) {
setTab(validated.data)
}
}}
>
{item == null || backend == null || path == null ?
<div className="grid grow place-items-center text-lg">
{getText('selectExactlyOneAssetToViewItsDetails')}
</div>
: <>
<div className="h-4 bg-primary/5" />
<TabBar className="grow-0">
<Tab id="settings" labelId="settings" isActive={tab === 'settings'} icon={null}>
{getText('settings')}
</Tab>
{isCloud && item.type !== AssetType.secret && item.type !== AssetType.directory && (
<Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}>
{getText('versions')}
</Tab>
)}
{isCloud && item.type === AssetType.project && (
<Tab
id="sessions"
labelId="projectSessions"
isActive={tab === 'sessions'}
icon={null}
>
{getText('projectSessions')}
</Tab>
)}
</TabBar>
<TabPanel id="settings" className="p-4 pl-asset-panel-l">
<AssetProperties
backend={backend}
isReadonly={isReadonly}
item={item}
path={path}
category={category}
spotlightOn={contextProps?.spotlightOn}
/>
</TabPanel>
<TabPanel id="versions" className="p-4 pl-asset-panel-l">
<AssetVersions backend={backend} item={item} />
</TabPanel>
{item.type === AssetType.project && (
<TabPanel id="sessions" className="p-4 pl-asset-panel-l">
<ProjectSessions backend={backend} item={item} />
</TabPanel>
)}
</>
}
</Tabs>
</div>
)
}

View File

@ -0,0 +1,267 @@
/**
* @file
* @description
* The asset panel is a sidebar that can be expanded or collapsed.
* It is used to view and interact with assets in the drive.
*/
import docsIcon from '#/assets/file_text.svg'
import sessionsIcon from '#/assets/group.svg'
import inspectIcon from '#/assets/inspect.svg'
import versionsIcon from '#/assets/versions.svg'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useBackend } from '#/providers/BackendProvider'
import {
useAssetPanelProps,
useAssetPanelSelectedTab,
useIsAssetPanelExpanded,
useIsAssetPanelHidden,
useSetAssetPanelSelectedTab,
useSetIsAssetPanelExpanded,
} from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import type { AnyAsset, BackendType } from 'enso-common/src/services/Backend'
import type { Spring } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import { memo, startTransition } from 'react'
import { z } from 'zod'
import { AssetDocs } from '../AssetDocs'
import AssetProjectSessions from '../AssetProjectSessions'
import type { AssetPropertiesSpotlight } from '../AssetProperties'
import AssetProperties from '../AssetProperties'
import AssetVersions from '../AssetVersions/AssetVersions'
import type { Category } from '../CategorySwitcher/Category'
import { AssetPanelTabs } from './components/AssetPanelTabs'
import { AssetPanelToggle } from './components/AssetPanelToggle'
const ASSET_SIDEBAR_COLLAPSED_WIDTH = 48
const ASSET_PANEL_WIDTH = 480
const ASSET_PANEL_TOTAL_WIDTH = ASSET_PANEL_WIDTH + ASSET_SIDEBAR_COLLAPSED_WIDTH
/** Determines the content of the {@link AssetPanel}. */
const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules', 'docs'] as const
/** Determines the content of the {@link AssetPanel}. */
type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly isAssetPanelHidden: boolean
readonly assetPanelTab: AssetPanelTab
readonly assetPanelWidth: number
}
}
const ASSET_PANEL_TAB_SCHEMA = z.enum(ASSET_PANEL_TABS)
LocalStorage.register({
assetPanelTab: { schema: ASSET_PANEL_TAB_SCHEMA },
assetPanelWidth: { schema: z.number().int() },
isAssetPanelHidden: { schema: z.boolean() },
isAssetPanelVisible: { schema: z.boolean() },
})
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly selectedTab: AssetPanelTab
readonly item: AnyAsset | null
readonly path: string | null
readonly spotlightOn: AssetPropertiesSpotlight | null
}
/**
* Props for an {@link AssetPanel}.
*/
export interface AssetPanelProps {
readonly backendType: BackendType
readonly category: Category
}
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
/**
* The asset panel is a sidebar that can be expanded or collapsed.
* It is used to view and interact with assets in the drive.
*/
export function AssetPanel(props: AssetPanelProps) {
const isHidden = useIsAssetPanelHidden()
const isExpanded = useIsAssetPanelExpanded()
const panelWidth = isExpanded ? ASSET_PANEL_TOTAL_WIDTH : ASSET_SIDEBAR_COLLAPSED_WIDTH
const isVisible = !isHidden
return (
<AnimatePresence initial={!isVisible} mode="sync">
{isVisible && (
<motion.div
data-testid="asset-panel"
initial="initial"
animate="animate"
exit="exit"
custom={panelWidth}
variants={{
initial: { opacity: 0, width: 0 },
animate: (width: number) => ({ opacity: 1, width }),
exit: { opacity: 0, width: 0 },
}}
transition={DEFAULT_TRANSITION_OPTIONS}
className="relative flex h-full flex-col shadow-softer clip-path-left-shadow"
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<InternalAssetPanelTabs {...props} />
</motion.div>
)}
</AnimatePresence>
)
}
/**
* The internal implementation of the Asset Panel Tabs.
*/
const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: AssetPanelProps) {
const { category } = props
const { item, spotlightOn, path } = useAssetPanelProps()
const selectedTab = useAssetPanelSelectedTab()
const setSelectedTab = useSetAssetPanelSelectedTab()
const isHidden = useIsAssetPanelHidden()
const isReadonly = category.type === 'trash'
const { getText } = useText()
const isExpanded = useIsAssetPanelExpanded()
const setIsExpanded = useSetIsAssetPanelExpanded()
const expandTab = useEventCallback(() => {
setIsExpanded(true)
})
const backend = useBackend(category)
return (
<AssetPanelTabs
className="h-full"
orientation="vertical"
selectedKey={selectedTab}
defaultSelectedKey={selectedTab}
onSelectionChange={(key) => {
if (isHidden) {
return
}
startTransition(() => {
if (key === selectedTab && isExpanded) {
setIsExpanded(false)
} else {
// This is safe because we know the key is a valid AssetPanelTab.
// eslint-disable-next-line no-restricted-syntax
setSelectedTab(key as AssetPanelTab)
setIsExpanded(true)
}
})
}}
>
<AnimatePresence initial={!isExpanded} mode="sync">
{isExpanded && (
<div
className="min-h-full"
// We use clipPath to prevent the sidebar from being visible under tabs while expanding.
style={{ clipPath: `inset(0 ${ASSET_SIDEBAR_COLLAPSED_WIDTH}px 0 0)` }}
>
<motion.div
initial={{ filter: 'blur(8px)' }}
animate={{ filter: 'blur(0px)' }}
exit={{ filter: 'blur(8px)' }}
transition={DEFAULT_TRANSITION_OPTIONS}
className="absolute left-0 top-0 h-full w-full bg-background"
style={{ width: ASSET_PANEL_WIDTH }}
>
<AssetPanelTabs.TabPanel id="settings" resetKeys={[item?.id]}>
<AssetProperties
backend={backend}
item={item}
isReadonly={isReadonly}
category={category}
spotlightOn={spotlightOn}
path={path}
/>
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="versions" resetKeys={[item?.id]}>
<AssetVersions backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="sessions" resetKeys={[item?.id]}>
<AssetProjectSessions backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="docs" resetKeys={[item?.id]}>
<AssetDocs backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
</motion.div>
</div>
)}
</AnimatePresence>
<div
className="absolute bottom-0 right-0 top-0 pt-2.5"
style={{ width: ASSET_SIDEBAR_COLLAPSED_WIDTH }}
>
<AssetPanelToggle
showWhen="expanded"
className="flex aspect-square w-full items-center justify-center"
/>
<AssetPanelTabs.TabList>
<AssetPanelTabs.Tab
id="settings"
icon={inspectIcon}
label={getText('properties')}
isExpanded={isExpanded}
onPress={expandTab}
/>
<AssetPanelTabs.Tab
id="versions"
icon={versionsIcon}
label={getText('versions')}
isExpanded={isExpanded}
isDisabled={isHidden}
onPress={expandTab}
/>
<AssetPanelTabs.Tab
id="sessions"
icon={sessionsIcon}
label={getText('projectSessions')}
isExpanded={isExpanded}
onPress={expandTab}
/>
<AssetPanelTabs.Tab
id="docs"
icon={docsIcon}
label={getText('docs')}
isExpanded={isExpanded}
onPress={expandTab}
/>
</AssetPanelTabs.TabList>
</div>
</AssetPanelTabs>
)
})

View File

@ -0,0 +1,150 @@
/** @file Tabs for the asset panel. Contains the visual state for the tabs and animations. */
import { AnimatedBackground } from '#/components/AnimatedBackground'
import type { TabListProps, TabPanelProps, TabProps } from '#/components/aria'
import { Tab, TabList, TabPanel, Tabs, type TabsProps } from '#/components/aria'
import { useVisualTooltip } from '#/components/AriaComponents'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense'
import SvgMask from '#/components/SvgMask'
import { AnimatePresence, motion } from 'framer-motion'
import { memo, useRef } from 'react'
/** Display a set of tabs. */
export function AssetPanelTabs(props: TabsProps) {
const { children } = props
return <Tabs {...props}>{children}</Tabs>
}
/** Display a list of tabs. */
export function AssetPanelTabList<T extends object>(props: TabListProps<T>) {
return (
<AnimatedBackground>
<TabList {...props} />
</AnimatedBackground>
)
}
/** Props for a {@link AssetPanelTab}. */
export interface AssetPanelTabProps extends TabProps {
readonly id: string
readonly icon: string
readonly label: string
readonly isExpanded: boolean
readonly onPress?: () => void
}
const UNDERLAY_ELEMENT = (
<>
<div className="h-full w-full rounded-r-2xl bg-background" />
<div className="absolute -top-5 left-0 aspect-square w-5 [background:radial-gradient(circle_at_100%_0%,_transparent_70%,_var(--color-background)_70%)]" />
<div className="absolute -bottom-5 left-0 aspect-square w-5 [background:radial-gradient(circle_at_100%_100%,_transparent_70%,_var(--color-background)_70%)]" />
</>
)
/** Display a tab. */
export const AssetPanelTab = memo(function AssetPanelTab(props: AssetPanelTabProps) {
const { id, icon, label, isExpanded } = props
const tabRef = useRef<HTMLDivElement>(null)
const { targetProps, tooltip } = useVisualTooltip({
children: label,
targetRef: tabRef,
overlayPositionProps: { placement: 'left' },
})
return (
<Tab
ref={tabRef}
id={id}
aria-label={label}
className="aspect-square w-full cursor-pointer"
data-testid={`asset-panel-tab-${id}`}
>
{({ isSelected, isHovered }) => {
const isActive = isSelected && isExpanded
return (
<>
<AnimatedBackground.Item
isSelected={isActive}
className="h-full w-full rounded-2xl"
underlayElement={UNDERLAY_ELEMENT}
>
<motion.div
className="h-full w-full"
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
initial={{ x: 100 }}
animate={{ x: 0 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
exit={{ x: 100 }}
>
<motion.div
variants={{ active: { opacity: 1 }, inactive: { opacity: 0 } }}
initial="inactive"
animate={!isActive && isHovered ? 'active' : 'inactive'}
className="absolute inset-x-1.5 inset-y-1.5 -z-1 rounded-full bg-invert transition-colors duration-300"
/>
<div
ref={tabRef}
className="flex h-full w-full items-center justify-center"
{...targetProps}
>
<SvgMask src={icon} />
</div>
</motion.div>
</AnimatedBackground.Item>
{tooltip}
</>
)
}}
</Tab>
)
})
/** Props for a {@link AssetPanelTabPanel}. */
export interface AssetPanelTabPanelProps extends TabPanelProps {
readonly resetKeys?: unknown[]
}
/** Display a tab panel. */
export function AssetPanelTabPanel(props: AssetPanelTabPanelProps) {
const { children, id = '', resetKeys = [] } = props
return (
<TabPanel className="contents" shouldForceMount {...props}>
{(renderProps) => {
const isSelected = renderProps.state.selectionManager.isSelected(id)
return (
<AnimatePresence initial={!isSelected} mode="popLayout">
{isSelected && (
<motion.div
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
initial={{ x: 16, filter: 'blur(4px)', opacity: 0 }}
animate={{ x: 0, filter: 'blur(0px)', opacity: 1 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
exit={{ x: 16, filter: 'blur(4px)', opacity: 0 }}
className="flex h-full w-full flex-col overflow-y-auto scroll-offset-edge-3xl"
>
<Suspense loaderProps={{ className: 'my-auto' }}>
<ErrorBoundary resetKeys={[renderProps.state.selectedItem, ...resetKeys]}>
<div className="pointer-events-auto flex h-fit min-h-full w-full shrink-0 px-4 py-5">
{typeof children === 'function' ? children(renderProps) : children}
</div>
</ErrorBoundary>
</Suspense>
</motion.div>
)}
</AnimatePresence>
)
}}
</TabPanel>
)
}
AssetPanelTabs.Tab = AssetPanelTab
AssetPanelTabs.TabPanel = AssetPanelTabPanel
AssetPanelTabs.TabList = AssetPanelTabList

View File

@ -0,0 +1,80 @@
/**
* @file
* Toggle for opening the asset panel.
*/
import RightPanelIcon from '#/assets/right_panel.svg'
import { Button } from '#/components/AriaComponents'
import { useIsAssetPanelHidden, useSetIsAssetPanelHidden } from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
import type { Spring } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import { memo } from 'react'
/**
* Props for a {@link AssetPanelToggle}.
*/
export interface AssetPanelToggleProps {
readonly className?: string
readonly showWhen?: 'collapsed' | 'expanded'
}
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
const COLLAPSED_X_TRANSLATION = 16
const EXPANDED_X_TRANSLATION = -16
/**
* Toggle for opening the asset panel.
*/
export const AssetPanelToggle = memo(function AssetPanelToggle(props: AssetPanelToggleProps) {
const { className, showWhen = 'collapsed' } = props
const { getText } = useText()
const isAssetPanelHidden = useIsAssetPanelHidden()
const setIsAssetPanelHidden = useSetIsAssetPanelHidden()
const canDisplay = showWhen === 'collapsed' ? isAssetPanelHidden : !isAssetPanelHidden
return (
<AnimatePresence initial={!canDisplay} mode="sync">
{canDisplay && (
<motion.div
className={className}
layout="position"
initial={{
opacity: 0,
filter: 'blur(4px)',
x: showWhen === 'collapsed' ? COLLAPSED_X_TRANSLATION : EXPANDED_X_TRANSLATION,
}}
animate={{ opacity: 1, filter: 'blur(0px)', x: 0 }}
exit={{
opacity: 0,
filter: 'blur(4px)',
x: showWhen === 'collapsed' ? COLLAPSED_X_TRANSLATION : EXPANDED_X_TRANSLATION,
}}
transition={DEFAULT_TRANSITION_OPTIONS}
>
<Button
size="medium"
variant="custom"
isActive={!isAssetPanelHidden}
icon={RightPanelIcon}
aria-label={getText('openAssetPanel')}
onPress={() => {
setIsAssetPanelHidden(!isAssetPanelHidden)
}}
/>
</motion.div>
)}
</AnimatePresence>
)
})

View File

@ -0,0 +1,6 @@
/**
* @file
* Barrels for the `AssetPanel` component.
*/
export * from './AssetPanel'
export { AssetPanelToggle, type AssetPanelToggleProps } from './components/AssetPanelToggle'

View File

@ -1,7 +1,6 @@
/** @file Displays information describing a specific version of an asset. */
import { useState } from 'react'
import LogsIcon from '#/assets/logs.svg'
import { Button, DialogTrigger } from '#/components/AriaComponents'
import ProjectLogsModal from '#/modals/ProjectLogsModal'
import { useText } from '#/providers/TextProvider'
@ -9,34 +8,33 @@ import type Backend from '#/services/Backend'
import type { ProjectAsset, ProjectSession } from '#/services/Backend'
import { formatDateTime } from '#/utilities/dateTime'
// ===========================
// === AssetProjectSession ===
// ===========================
/** Props for a {@link AssetProjectSession}. */
export interface AssetProjectSessionProps {
readonly backend: Backend
readonly project: ProjectAsset
readonly projectSession: ProjectSession
readonly index: number
}
/** Displays information describing a specific version of an asset. */
export default function AssetProjectSession(props: AssetProjectSessionProps) {
const { backend, project, projectSession } = props
const { backend, project, projectSession, index } = props
const { getText } = useText()
const [isOpen, setIsOpen] = useState(false)
return (
<div className="flex w-full flex-1 shrink-0 select-none flex-row gap-4 rounded-2xl p-2">
<div className="flex flex-row gap-4 rounded-2xl p-2">
<div className="flex flex-1 flex-col">
<time className="text-xs">{formatDateTime(new Date(projectSession.createdAt))}</time>
{getText('projectSessionX', index)}
<time className="text-xs">
{getText('onDateX', formatDateTime(new Date(projectSession.createdAt)))}
</time>
</div>
<div className="flex items-center gap-1">
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>
<Button variant="icon" isActive icon={LogsIcon} aria-label={getText('showLogs')} />
<ProjectLogsModal
isOpen={isOpen}
backend={backend}
projectSessionId={projectSession.projectSessionId}
projectTitle={project.title}

View File

@ -1,40 +1,50 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import AssetProjectSession from '#/layouts/AssetProjectSession'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import type * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
// ============================
// === AssetProjectSessions ===
// ============================
import { Result } from '#/components/Result'
import { useText } from '#/providers/TextProvider'
import { AssetType, BackendType, type AnyAsset, type ProjectAsset } from '#/services/Backend'
/** Props for a {@link AssetProjectSessions}. */
export interface AssetProjectSessionsProps {
readonly backend: Backend
readonly item: backendModule.ProjectAsset
readonly item: AnyAsset | null
}
/** A list of previous versions of an asset. */
export default function AssetProjectSessions(props: AssetProjectSessionsProps) {
return (
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<AssetProjectSessionsInternal {...props} />
</React.Suspense>
</errorBoundary.ErrorBoundary>
)
const { backend, item } = props
const { getText } = useText()
if (backend.type === BackendType.local) {
return <Result status="info" centered title={getText('assetProjectSessions.localBackend')} />
}
if (item == null) {
return <Result status="info" centered title={getText('assetProjectSessions.notSelected')} />
}
if (item.type !== AssetType.project) {
return <Result status="info" centered title={getText('assetProjectSessions.notProjectAsset')} />
}
return <AssetProjectSessionsInternal {...props} item={item} />
}
/** Props for a {@link AssetProjectSessionsInternal}. */
interface AssetProjectSessionsInternalProps extends AssetProjectSessionsProps {
readonly item: ProjectAsset
}
/** A list of previous versions of an asset. */
function AssetProjectSessionsInternal(props: AssetProjectSessionsProps) {
function AssetProjectSessionsInternal(props: AssetProjectSessionsInternalProps) {
const { backend, item } = props
const { getText } = useText()
const projectSessionsQuery = reactQuery.useSuspenseQuery({
queryKey: ['getProjectSessions', item.id, item.title],
@ -44,16 +54,17 @@ function AssetProjectSessionsInternal(props: AssetProjectSessionsProps) {
},
})
return (
<div className="pointer-events-auto flex flex-col items-center overflow-y-auto overflow-x-hidden">
{projectSessionsQuery.data.map((session) => (
<AssetProjectSession
key={session.projectSessionId}
backend={backend}
project={item}
projectSession={session}
/>
))}
</div>
)
return projectSessionsQuery.data.length === 0 ?
<Result status="info" centered title={getText('assetProjectSessions.noSessions')} />
: <div className="flex w-full flex-col justify-start">
{projectSessionsQuery.data.map((session, i) => (
<AssetProjectSession
key={session.projectSessionId}
backend={backend}
project={item}
projectSession={session}
index={projectSessionsQuery.data.length - i}
/>
))}
</div>
}

View File

@ -16,13 +16,10 @@ import {
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import { DatalinkFormInput } from '#/components/dashboard/DatalinkInput'
import Label from '#/components/dashboard/Label'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import { Result } from '#/components/Result'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import { validateDatalink } from '#/data/datalinkValidator'
import {
backendMutationOptions,
useAssetPassiveListenerStrict,
useBackendQuery,
} from '#/hooks/backendHooks'
import { backendMutationOptions, useAssetStrict, useBackendQuery } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSpotlight } from '#/hooks/spotlightHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
@ -41,10 +38,6 @@ import { mapNonNullish } from '#/utilities/nullable'
import * as permissions from '#/utilities/permissions'
import { tv } from '#/utilities/tailwindVariants'
// =======================
// === AssetProperties ===
// =======================
const ASSET_PROPERTIES_VARIANTS = tv({
base: '',
slots: {
@ -58,27 +51,65 @@ export type AssetPropertiesSpotlight = 'datalink' | 'description' | 'secret'
/** Props for an {@link AssetPropertiesProps}. */
export interface AssetPropertiesProps {
readonly backend: Backend
readonly item: AnyAsset
readonly path: string
readonly item: AnyAsset | null
readonly path: string | null
readonly category: Category
readonly isReadonly?: boolean
readonly spotlightOn: AssetPropertiesSpotlight | undefined
readonly spotlightOn?: AssetPropertiesSpotlight | null
}
/** Display and modify the properties of an asset. */
/**
* Display and modify the properties of an asset.
*/
export default function AssetProperties(props: AssetPropertiesProps) {
const { item, isReadonly = false, backend, category, spotlightOn = null, path } = props
const { getText } = useText()
if (item == null || path == null) {
return <Result status="info" title={getText('assetProperties.notSelected')} centered />
}
return (
<AssetPropertiesInternal
backend={backend}
item={item}
isReadonly={isReadonly}
category={category}
spotlightOn={spotlightOn}
path={path}
/>
)
}
/**
* Props for {@link AssetPropertiesInternal}.
*/
export interface AssetPropertiesInternalProps extends AssetPropertiesProps {
readonly item: NonNullable<AssetPropertiesProps['item']>
readonly path: NonNullable<AssetPropertiesProps['path']>
}
/**
* Internal implementation of {@link AssetProperties}.
*/
function AssetPropertiesInternal(props: AssetPropertiesInternalProps) {
const { backend, item, category, spotlightOn, isReadonly = false, path: pathRaw } = props
const styles = ASSET_PROPERTIES_VARIANTS({})
const asset = useAssetPassiveListenerStrict(backend.type, item.id, item.parentId, category)
const asset = useAssetStrict({
backend,
assetId: item.id,
parentId: item.parentId,
category,
})
const setAssetPanelProps = useSetAssetPanelProps()
const driveStore = useDriveStore()
const closeSpotlight = useEventCallback(() => {
const assetPanelProps = driveStore.getState().assetPanelProps
if (assetPanelProps != null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { spotlightOn: unusedSpotlightOn, ...rest } = assetPanelProps
setAssetPanelProps(rest)
}
setAssetPanelProps({ ...assetPanelProps, spotlightOn: null })
})
const { user } = useFullUserSession()
const isEnterprise = user.plan === Plan.enterprise
@ -86,7 +117,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const localBackend = useLocalBackend()
const [isEditingDescriptionRaw, setIsEditingDescriptionRaw] = React.useState(false)
const isEditingDescription = isEditingDescriptionRaw || spotlightOn === 'description'
const setIsEditingDescription = React.useCallback(
const setIsEditingDescription = useEventCallback(
(valueOrUpdater: React.SetStateAction<boolean>) => {
setIsEditingDescriptionRaw((currentValue) => {
if (typeof valueOrUpdater === 'function') {
@ -98,7 +129,6 @@ export default function AssetProperties(props: AssetPropertiesProps) {
return valueOrUpdater
})
},
[closeSpotlight],
)
const featureFlags = useFeatureFlags()
const datalinkQuery = useBackendQuery(
@ -113,22 +143,15 @@ export default function AssetProperties(props: AssetPropertiesProps) {
: {}),
},
)
const driveStore = useDriveStore()
const descriptionRef = React.useRef<HTMLDivElement>(null)
const descriptionSpotlight = useSpotlight({
ref: descriptionRef,
enabled: spotlightOn === 'description',
close: closeSpotlight,
})
const secretRef = React.useRef<HTMLDivElement>(null)
const secretSpotlight = useSpotlight({
ref: secretRef,
enabled: spotlightOn === 'secret',
close: closeSpotlight,
})
const datalinkRef = React.useRef<HTMLDivElement>(null)
const datalinkSpotlight = useSpotlight({
ref: datalinkRef,
enabled: spotlightOn === 'datalink',
close: closeSpotlight,
})
@ -160,7 +183,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
)
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const displayedDescription =
editDescriptionMutation.variables?.[1].description ?? asset.description
editDescriptionMutation.variables?.[0] === asset.id ?
editDescriptionMutation.variables[1].description ?? asset.description
: asset.description
const editDescriptionForm = Form.useForm({
schema: (z) => z.object({ description: z.string() }),
@ -176,6 +201,15 @@ export default function AssetProperties(props: AssetPropertiesProps) {
setIsEditingDescription(false)
},
})
const resetEditDescriptionForm = editDescriptionForm.reset
React.useEffect(() => {
setIsEditingDescription(false)
}, [asset.id, setIsEditingDescription])
React.useEffect(() => {
resetEditDescriptionForm({ description: asset.description ?? '' })
}, [asset.description, resetEditDescriptionForm])
const editDatalinkForm = Form.useForm({
schema: (z) => z.object({ datalink: z.custom((x) => validateDatalink(x)) }),
@ -200,11 +234,11 @@ export default function AssetProperties(props: AssetPropertiesProps) {
}, [datalinkQuery.data, editDatalinkFormRef])
return (
<>
<div className="flex w-full flex-col gap-8">
{descriptionSpotlight.spotlightElement}
{secretSpotlight.spotlightElement}
{datalinkSpotlight.spotlightElement}
<div ref={descriptionRef} className={styles.section()} {...descriptionSpotlight.props}>
<div className={styles.section()} {...descriptionSpotlight.props}>
<Heading
level={2}
className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug"
@ -304,7 +338,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
)}
{isSecret && (
<div ref={secretRef} className={styles.section()} {...secretSpotlight.props}>
<div className={styles.section()} {...secretSpotlight.props}>
<Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
@ -325,7 +359,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
)}
{isDatalink && (
<div ref={datalinkRef} className={styles.section()} {...datalinkSpotlight.props}>
<div className={styles.section()} {...datalinkSpotlight.props}>
<Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
@ -334,7 +368,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</Heading>
{datalinkQuery.isLoading ?
<div className="grid place-items-center self-stretch">
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
<StatelessSpinner size={48} state="loading-medium" />
</div>
: <Form form={editDatalinkForm} className="w-full">
<DatalinkFormInput
@ -357,6 +391,6 @@ export default function AssetProperties(props: AssetPropertiesProps) {
}
</div>
)}
</>
</div>
)
}

View File

@ -25,6 +25,8 @@ import AssetQuery from '#/utilities/AssetQuery'
import * as eventModule from '#/utilities/event'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { AnimatePresence, motion } from 'framer-motion'
import { useEventCallback } from '../hooks/eventCallbackHooks'
// =============
// === Types ===
@ -126,7 +128,7 @@ export interface AssetSearchBarProps {
}
/** A search bar containing a text input, and a list of suggestions. */
export default function AssetSearchBar(props: AssetSearchBarProps) {
function AssetSearchBar(props: AssetSearchBarProps) {
const { backend, isCloud, query, setQuery } = props
const { getText } = textProvider.useText()
const { modalRef } = modalProvider.useModalRef()
@ -135,17 +137,19 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
const rawSuggestions = useSuggestions()
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
const suggestionsRef = React.useRef(rawSuggestions)
const [selectedIndices, setSelectedIndices] = React.useState<ReadonlySet<number>>(
new Set<number>(),
)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
const [areSuggestionsVisible, privateSetAreSuggestionsVisible] = React.useState(false)
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
const querySource = React.useRef(QuerySource.external)
const rootRef = React.useRef<HTMLLabelElement | null>(null)
const searchRef = React.useRef<HTMLInputElement | null>(null)
const labels = backendHooks.useBackendQuery(backend, 'listTags', []).data ?? []
areSuggestionsVisibleRef.current = areSuggestionsVisible
const setAreSuggestionsVisible = useEventCallback((value: boolean) => {
React.startTransition(() => {
privateSetAreSuggestionsVisible(value)
areSuggestionsVisibleRef.current = value
})
})
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing) {
@ -266,7 +270,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
root?.removeEventListener('keydown', onSearchKeyDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [setQuery, modalRef])
}, [setQuery, modalRef, setAreSuggestionsVisible])
// Reset `querySource` after all other effects have run.
React.useEffect(() => {
@ -282,191 +286,326 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
return (
<FocusArea direction="horizontal">
{(innerProps) => (
<aria.Label
data-testid="asset-search-bar"
{...aria.mergeProps<aria.LabelProps & React.RefAttributes<HTMLLabelElement>>()(
innerProps,
{
className:
'z-1 group relative flex grow max-w-[60em] items-center gap-asset-search-bar rounded-full px-1.5 py-1 text-primary',
ref: rootRef,
onFocus: () => {
setAreSuggestionsVisible(true)
},
onBlur: (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
if (querySource.current === QuerySource.tabbing) {
querySource.current = QuerySource.external
<div className="relative w-full max-w-[60em]">
<aria.Label
data-testid="asset-search-bar"
{...aria.mergeProps<aria.LabelProps & React.RefAttributes<HTMLLabelElement>>()(
innerProps,
{
className:
'z-1 group flex grow items-center gap-asset-search-bar rounded-full px-1.5 py-1 text-primary border-0.5 border-primary/20',
ref: rootRef,
onFocus: () => {
setAreSuggestionsVisible(true)
},
onBlur: (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
if (querySource.current === QuerySource.tabbing) {
querySource.current = QuerySource.external
}
setAreSuggestionsVisible(false)
}
setAreSuggestionsVisible(false)
}
},
},
},
)}
>
<div className="relative size-4 placeholder" />
<div
className={ariaComponents.DIALOG_BACKGROUND({
className: tailwindMerge.twMerge(
'absolute left-0 top-0 z-1 flex w-full flex-col overflow-hidden rounded-default border-0.5 border-primary/20 -outline-offset-1 outline-primary transition-colors',
areSuggestionsVisible ? '' : 'bg-transparent',
),
})}
)}
>
<div className="h-[32px]" />
<div className="relative size-4 placeholder" />
<div
className={tailwindMerge.twMerge(
'grid transition-grid-template-rows duration-200',
areSuggestionsVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
>
<div className="overflow-y-auto overflow-x-hidden">
<div className="relative mt-3 flex flex-col gap-3">
{/* Tags (`name:`, `modified:`, etc.) */}
<Tags
isCloud={isCloud}
querySource={querySource}
query={query}
setQuery={setQuery}
/>
{/* Asset labels */}
{isCloud && labels.length !== 0 && (
<div
data-testid="asset-search-labels"
className="pointer-events-auto flex gap-2 px-1.5"
>
{[...labels]
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
.map((label) => {
const negated = query.negativeLabels.some((term) =>
array.shallowEqual(term, [label.value]),
)
return (
<Label
key={label.id}
color={label.color}
active={
negated ||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
}
negated={negated}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery((oldQuery) => {
const newQuery = oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey,
)
baseQuery.current = newQuery
return newQuery
})
}}
>
{label.value}
</Label>
)
})}
</div>
)}
{/* Suggestions */}
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
{suggestions.map((suggestion, index) => (
// This should not be a `<button>`, since `render()` may output a
// tree containing a button.
<aria.Button
data-testid="asset-search-suggestion"
key={index}
ref={(el) => {
if (index === selectedIndex) {
el?.focus()
}
}}
className={tailwindMerge.twMerge(
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
selectedIndices.has(index) && 'bg-primary/10',
index === selectedIndex && 'bg-selected-frame',
)}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index) ?
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index) ?
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
: [...selectedIndices, index],
),
)
} else {
setAreSuggestionsVisible(false)
}
}}
>
<ariaComponents.Text variant="body" truncate="1" className="w-full">
{suggestion.render()}
</ariaComponents.Text>
</aria.Button>
))}
</div>
</div>
</div>
</div>
</div>
<SvgMask
src={FindIcon}
className="absolute left-2 top-[50%] z-1 mt-[1px] -translate-y-1/2 text-primary/40"
/>
<FocusRing placement="before">
<aria.SearchField
aria-label={getText('assetSearchFieldLabel')}
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()
}}
>
<aria.Input
type="search"
ref={searchRef}
size={1}
placeholder={
isCloud ?
detect.isOnMacOS() ?
getText('remoteBackendSearchPlaceholderMacOs')
: getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="focus-child peer text relative z-1 w-full bg-transparent placeholder-primary/40"
onChange={(event) => {
if (querySource.current !== QuerySource.internal) {
querySource.current = QuerySource.typing
setQuery(AssetQuery.fromString(event.target.value))
}
}}
<AssetSearchBarPopover
areSuggestionsVisible={areSuggestionsVisible}
isCloud={isCloud}
querySource={querySource}
query={query}
setQuery={setQuery}
suggestions={suggestions}
selectedIndex={selectedIndex}
setAreSuggestionsVisible={setAreSuggestionsVisible}
baseQuery={baseQuery}
backend={backend}
/>
<SvgMask
src={FindIcon}
className="absolute left-2 top-[50%] z-1 mt-[1px] -translate-y-1/2 text-primary/40"
/>
<FocusRing placement="before">
<aria.SearchField
aria-label={getText('assetSearchFieldLabel')}
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) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey
) {
// Clone the query to refresh results.
setQuery(query.clone())
}
event.continuePropagation()
}}
/>
</aria.SearchField>
</FocusRing>
</aria.Label>
>
<aria.Input
type="search"
ref={searchRef}
size={1}
placeholder={
isCloud ?
detect.isOnMacOS() ?
getText('remoteBackendSearchPlaceholderMacOs')
: getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="focus-child peer text relative z-1 w-full bg-transparent placeholder-primary/40"
onChange={(event) => {
if (querySource.current !== QuerySource.internal) {
querySource.current = QuerySource.typing
setQuery(AssetQuery.fromString(event.target.value))
}
}}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey
) {
// Clone the query to refresh results.
setQuery(query.clone())
}
}}
/>
</aria.SearchField>
</FocusRing>
</aria.Label>
</div>
)}
</FocusArea>
)
}
/**
* Props for a {@link AssetSearchBarPopover}.
*/
interface AssetSearchBarPopoverProps {
readonly areSuggestionsVisible: boolean
readonly isCloud: boolean
readonly querySource: React.MutableRefObject<QuerySource>
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly suggestions: readonly Suggestion[]
readonly selectedIndex: number | null
readonly setAreSuggestionsVisible: (value: boolean) => void
readonly baseQuery: React.MutableRefObject<AssetQuery>
readonly backend: Backend | null
}
/**
* Renders the popover containing suggestions.
*/
function AssetSearchBarPopover(props: AssetSearchBarPopoverProps) {
const {
areSuggestionsVisible,
isCloud,
querySource,
query,
setQuery,
suggestions,
selectedIndex,
setAreSuggestionsVisible,
baseQuery,
backend,
} = props
const [selectedIndices, setSelectedIndices] = React.useState<ReadonlySet<number>>(
new Set<number>(),
)
return (
<>
<AnimatePresence>
{areSuggestionsVisible && (
<motion.div
initial={{ gridTemplateRows: '0fr', opacity: 0 }}
animate={{ gridTemplateRows: '1fr', opacity: 1 }}
exit={{ gridTemplateRows: '0fr', opacity: 0 }}
className={ariaComponents.DIALOG_BACKGROUND({
className:
'absolute left-0 right-0 top-0 z-1 grid w-full overflow-hidden rounded-default border-0.5 border-primary/20 -outline-offset-1 outline-primary',
})}
>
<div className="overflow-hidden">
<div className="relative mt-3 flex flex-col gap-3 pt-8">
{/* Tags (`name:`, `modified:`, etc.) */}
<Tags
isCloud={isCloud}
querySource={querySource}
query={query}
setQuery={setQuery}
/>
{/* Asset labels */}
<Labels
isCloud={isCloud}
query={query}
setQuery={setQuery}
querySource={querySource}
baseQuery={baseQuery}
backend={backend}
/>
{/* Suggestions */}
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
{suggestions.map((suggestion, index) => (
<SuggestionRenderer
key={index}
index={index}
selectedIndex={selectedIndex}
selectedIndices={selectedIndices}
querySource={querySource}
setQuery={setQuery}
suggestion={suggestion}
setSelectedIndices={setSelectedIndices}
setAreSuggestionsVisible={setAreSuggestionsVisible}
query={query}
baseQuery={baseQuery}
/>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}
/**
* Props for a {@link SuggestionRenderer}.
*/
interface SuggestionRendererProps {
readonly index: number
readonly suggestion: Suggestion
readonly query: AssetQuery
readonly baseQuery: React.MutableRefObject<AssetQuery>
readonly selectedIndex: number | null
readonly selectedIndices: ReadonlySet<number>
readonly setSelectedIndices: React.Dispatch<React.SetStateAction<ReadonlySet<number>>>
readonly querySource: React.MutableRefObject<QuerySource>
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly setAreSuggestionsVisible: (value: boolean) => void
}
/**
* Renders a suggestion.
*/
const SuggestionRenderer = React.memo(function SuggestionRenderer(props: SuggestionRendererProps) {
const {
index,
selectedIndex,
selectedIndices,
querySource,
setQuery,
suggestion,
setSelectedIndices,
setAreSuggestionsVisible,
query,
baseQuery,
} = props
return (
<aria.Button
data-testid="asset-search-suggestion"
key={index}
ref={(el) => {
if (index === selectedIndex) {
el?.focus()
}
}}
className={tailwindMerge.twMerge(
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
selectedIndices.has(index) && 'bg-primary/10',
index === selectedIndex && 'bg-selected-frame',
)}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index) ?
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index) ?
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
: [...selectedIndices, index],
),
)
} else {
setAreSuggestionsVisible(false)
}
}}
>
<ariaComponents.Text variant="body" truncate="1" className="w-full">
{suggestion.render()}
</ariaComponents.Text>
</aria.Button>
)
})
/**
* Props for a {@link Labels}.
*/
interface LabelsProps {
readonly isCloud: boolean
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly backend: Backend | null
readonly querySource: React.MutableRefObject<QuerySource>
readonly baseQuery: React.MutableRefObject<AssetQuery>
}
/**
* Renders labels.
*/
const Labels = React.memo(function Labels(props: LabelsProps) {
const { isCloud, query, setQuery, backend, querySource, baseQuery } = props
const labels = backendHooks.useBackendQuery(backend, 'listTags', []).data ?? []
return (
<>
{isCloud && labels.length !== 0 && (
<div data-testid="asset-search-labels" className="pointer-events-auto flex gap-2 px-1.5">
{[...labels]
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
.map((label) => {
const negated = query.negativeLabels.some((term) =>
array.shallowEqual(term, [label.value]),
)
return (
<Label
key={label.id}
color={label.color}
active={
negated || query.labels.some((term) => array.shallowEqual(term, [label.value]))
}
negated={negated}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery((oldQuery) => {
const newQuery = oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey,
)
baseQuery.current = newQuery
return newQuery
})
}}
>
{label.value}
</Label>
)
})}
</div>
)}
</>
)
})
export default React.memo(AssetSearchBar)

View File

@ -1,22 +1,21 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as textProvider from '#/providers/TextProvider'
import AssetVersion from '#/layouts/AssetVersions/AssetVersion'
import * as useAssetVersions from '#/layouts/AssetVersions/useAssetVersions'
import Spinner, * as spinnerModule from '#/components/Spinner'
import type Backend from '#/services/Backend'
import * as backendService from '#/services/Backend'
import { Result } from '#/components/Result'
import type { AnyAsset } from '#/services/Backend'
import * as dateTime from '#/utilities/dateTime'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import { assetVersionsQueryOptions } from './useAssetVersions.ts'
// ==============================
// === AddNewVersionVariables ===
@ -35,30 +34,65 @@ interface AddNewVersionVariables {
/** Props for a {@link AssetVersions}. */
export interface AssetVersionsProps {
readonly backend: Backend
readonly item: backendService.AnyAsset
readonly item: AnyAsset | null
}
/** A list of previous versions of an asset. */
/**
* Display a list of previous versions of an asset.
*/
export default function AssetVersions(props: AssetVersionsProps) {
const { item, backend } = props
const { getText } = textProvider.useText()
if (backend.type === backendService.BackendType.local) {
return (
<Result
status="info"
centered
title={getText('assetVersions.localAssetsDoNotHaveVersions')}
/>
)
}
if (item == null) {
return <Result status="info" centered title={getText('assetVersions.notSelected')} />
}
return <AssetVersionsInternal {...props} item={item} />
}
/**
* Props for a {@link AssetVersionsInternal}.
*/
interface AssetVersionsInternalProps extends AssetVersionsProps {
readonly item: AnyAsset
}
/**
* Internal implementation of {@link AssetVersions}.
*/
function AssetVersionsInternal(props: AssetVersionsInternalProps) {
const { backend, item } = props
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [placeholderVersions, setPlaceholderVersions] = React.useState<
readonly backendService.S3ObjectVersion[]
>([])
const isCloud = backend.type === backendService.BackendType.remote
const queryKey = [backend.type, 'listAssetVersions', item.id, item.title]
const versionsQuery = useAssetVersions.useAssetVersions({
backend,
queryKey,
assetId: item.id,
title: item.title,
onError: (backendError) => toastAndLog('listVersionsError', backendError),
enabled: isCloud,
})
const latestVersion = versionsQuery.data?.find((version) => version.isLatest)
const restoreMutation = reactQuery.useMutation({
const versionsQuery = useSuspenseQuery(
assetVersionsQueryOptions({
assetId: item.id,
backend,
onError: (backendError) => toastAndLog('listVersionsError', backendError),
}),
)
const latestVersion = versionsQuery.data.find((version) => version.isLatest)
const restoreMutation = useMutation({
mutationFn: async (variables: AddNewVersionVariables) => {
if (item.type === backendService.AssetType.project) {
await backend.restoreProject(item.id, variables.versionId, item.title)
@ -92,13 +126,7 @@ export default function AssetVersions(props: AssetVersionsProps) {
return (
<div className="pointer-events-auto flex flex-1 shrink-0 flex-col items-center overflow-y-auto overflow-x-hidden">
{!isCloud ?
<div>{getText('localAssetsDoNotHaveVersions')}</div>
: versionsQuery.isPending ?
<Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
: versionsQuery.isError ?
<div>{getText('listVersionsError')}</div>
: versionsQuery.data.length === 0 ?
{versionsQuery.data.length === 0 ?
<div>{getText('noVersionsFound')}</div>
: latestVersion == null ?
<div>{getText('fetchLatestVersionError')}</div>

View File

@ -2,32 +2,39 @@
* @file
* Fetches the versions of the selected project asset
*/
import * as reactQuery from '@tanstack/react-query'
import type * as backendService from '#/services/Backend'
import type Backend from '#/services/Backend'
import type { AssetId } from '#/services/Backend'
import { queryOptions, useQuery } from '@tanstack/react-query'
/** Parameters for the {@link useAssetVersions} hook. */
export interface UseAssetVersionsParams {
readonly assetId: backendService.AssetId
readonly title: string
/**
*
*/
export interface AssetVersionsQueryOptions {
readonly assetId: AssetId
readonly backend: Backend
readonly queryKey?: reactQuery.QueryKey
readonly enabled?: boolean
readonly onError?: (error: unknown) => void
readonly onError?: ((error: unknown) => void) | undefined
}
/** Fetches the versions of the selected project asset. */
export function useAssetVersions(params: UseAssetVersionsParams) {
const { enabled = true, title, assetId, backend, onError } = params
const { queryKey = [backend.type, 'listAssetVersions', assetId, title] } = params
export function useAssetVersions(params: AssetVersionsQueryOptions) {
const { enabled = true, assetId, backend, onError } = params
return reactQuery.useQuery({
queryKey,
return useQuery(assetVersionsQueryOptions({ assetId, backend, enabled, onError }))
}
/**
* Options for a query that fetches the versions of an asset.
*/
export function assetVersionsQueryOptions(params: AssetVersionsQueryOptions) {
const { enabled = true, assetId, backend, onError } = params
return queryOptions({
queryKey: [backend.type, 'listAssetVersions', { assetId }] as const,
enabled,
queryFn: () =>
queryFn: ({ queryKey: [, , props] }) =>
backend
.listAssetVersions(assetId, title)
.listAssetVersions(props.assetId)
.then((assetVersions) => assetVersions.versions)
.catch((backendError) => {
onError?.(backendError)

View File

@ -17,7 +17,6 @@ import {
} from 'react'
import {
queryOptions,
useMutation,
useQueries,
useQuery,
@ -28,6 +27,8 @@ import { toast } from 'react-toastify'
import invariant from 'tiny-invariant'
import * as z from 'zod'
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
import DropFilesImage from '#/assets/drop_files.svg'
import { FileTrigger, mergeProps } from '#/components/aria'
import { Button, Text } from '#/components/AriaComponents'
@ -48,7 +49,7 @@ import { COLUMN_HEADING } from '#/components/dashboard/columnHeading'
import Label from '#/components/dashboard/Label'
import { ErrorDisplay } from '#/components/ErrorBoundary'
import SelectionBrush from '#/components/SelectionBrush'
import Spinner, { SpinnerState } from '#/components/Spinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import { ASSETS_MIME_TYPE } from '#/data/mimeTypes'
@ -58,6 +59,7 @@ import AssetListEventType from '#/events/AssetListEventType'
import { useAutoScroll } from '#/hooks/autoScrollHooks'
import {
backendMutationOptions,
listDirectoryQueryOptions,
useBackendQuery,
useUploadFileWithToastMutation,
} from '#/hooks/backendHooks'
@ -70,7 +72,6 @@ import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
import {
canTransferBetweenCategories,
CATEGORY_TO_FILTER_BY,
isLocalCategory,
type Category,
} from '#/layouts/CategorySwitcher/Category'
@ -85,6 +86,7 @@ import {
} from '#/providers/BackendProvider'
import {
useDriveStore,
useResetAssetPanelProps,
useSetAssetPanelProps,
useSetCanCreateAssets,
useSetCanDownload,
@ -110,6 +112,7 @@ import {
assetIsProject,
AssetType,
BackendType,
createPlaceholderAssetId,
createPlaceholderFileAsset,
createPlaceholderProjectAsset,
createRootDirectoryAsset,
@ -164,7 +167,6 @@ import { SortDirection } from '#/utilities/sorting'
import { regexEscape } from '#/utilities/string'
import { twJoin, twMerge } from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
// ============================
// === Global configuration ===
@ -364,6 +366,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const [enabledColumns, setEnabledColumns] = useState(DEFAULT_ENABLED_COLUMNS)
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const setAssetPanelProps = useSetAssetPanelProps()
const resetAssetPanelProps = useResetAssetPanelProps()
const hiddenColumns = getColumnList(user, backend.type, category).filter(
(column) => !enabledColumns.has(column),
@ -434,34 +437,14 @@ export default function AssetsTable(props: AssetsTableProps) {
const directories = useQueries({
// We query only expanded directories, as we don't want to load the data for directories that are not visible.
queries: useMemo(
() =>
expandedDirectoryIds.map((directoryId) =>
queryOptions({
queryKey: [
backend.type,
'listDirectory',
directoryId,
{
labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent',
},
] as const,
queryFn: async ({ queryKey: [, , parentId, params] }) => {
try {
return await backend.listDirectory({ ...params, parentId }, parentId)
} catch {
throw Object.assign(new Error(), { parentId })
}
},
enabled: !hidden,
meta: { persist: false },
}),
),
[hidden, backend, category, expandedDirectoryIds],
),
queries: expandedDirectoryIds.map((directoryId) => ({
...listDirectoryQueryOptions({
backend,
parentId: directoryId,
category,
}),
enabled: !hidden,
})),
combine: (results) => {
const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)]
@ -470,6 +453,7 @@ export default function AssetsTable(props: AssetsTableProps) {
isFetching: rootQuery?.isFetching ?? true,
isLoading: rootQuery?.isLoading ?? true,
isError: rootQuery?.isError ?? false,
error: rootQuery?.error,
data: rootQuery?.data,
},
directories: new Map(
@ -479,6 +463,7 @@ export default function AssetsTable(props: AssetsTableProps) {
isFetching: res.isFetching,
isLoading: res.isLoading,
isError: res.isError,
error: res.error,
data: res.data,
},
]),
@ -491,8 +476,11 @@ export default function AssetsTable(props: AssetsTableProps) {
// This reduces the amount of rerenders by batching them together, so they happen less often.
useQuery({
queryKey: [backend.type, 'refetchListDirectory'],
queryFn: () =>
queryClient.refetchQueries({ queryKey: [backend.type, 'listDirectory'] }).then(() => null),
queryFn: () => {
return queryClient
.refetchQueries({ queryKey: [backend.type, 'listDirectory'] })
.then(() => null)
},
refetchInterval:
enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false,
refetchOnMount: 'always',
@ -874,7 +862,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (item != null && item.isType(AssetType.directory)) {
setTargetDirectory(item)
}
if (item && item.item.id !== driveStore.getState().assetPanelProps?.item?.id) {
if (item != null && item.item.id !== driveStore.getState().assetPanelProps.item?.id) {
setAssetPanelProps({ backend, item: item.item, path: item.path })
setIsAssetPanelTemporarilyVisible(false)
}
@ -1198,11 +1186,11 @@ export default function AssetsTable(props: AssetsTableProps) {
() =>
driveStore.subscribe(({ selectedKeys }) => {
if (selectedKeys.size !== 1) {
setAssetPanelProps(null)
resetAssetPanelProps()
setIsAssetPanelTemporarilyVisible(false)
}
}),
[driveStore, setAssetPanelProps, setIsAssetPanelTemporarilyVisible],
[driveStore, resetAssetPanelProps, setIsAssetPanelTemporarilyVisible],
)
const doToggleDirectoryExpansion = useEventCallback(
@ -1246,8 +1234,8 @@ export default function AssetsTable(props: AssetsTableProps) {
const doMove = useEventCallback(async (newParentId: DirectoryId | null, asset: AnyAsset) => {
try {
if (asset.id === driveStore.getState().assetPanelProps?.item?.id) {
setAssetPanelProps(null)
if (asset.id === driveStore.getState().assetPanelProps.item?.id) {
resetAssetPanelProps()
}
await updateAssetMutation.mutateAsync([
asset.id,
@ -1260,8 +1248,8 @@ export default function AssetsTable(props: AssetsTableProps) {
})
const doDelete = useEventCallback(async (asset: AnyAsset, forever: boolean = false) => {
if (asset.id === driveStore.getState().assetPanelProps?.item?.id) {
setAssetPanelProps(null)
if (asset.id === driveStore.getState().assetPanelProps.item?.id) {
resetAssetPanelProps()
}
if (asset.type === AssetType.directory) {
dispatchAssetListEvent({
@ -1285,8 +1273,8 @@ export default function AssetsTable(props: AssetsTableProps) {
})
const doDeleteById = useEventCallback(async (assetId: AssetId, forever: boolean = false) => {
if (assetId === driveStore.getState().assetPanelProps?.item?.id) {
setAssetPanelProps(null)
if (assetId === driveStore.getState().assetPanelProps.item?.id) {
resetAssetPanelProps()
}
const asset = nodeMapRef.current.get(assetId)?.item
@ -1295,7 +1283,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
})
const [spinnerState, setSpinnerState] = useState(SpinnerState.initial)
const [keyboardSelectedIndex, setKeyboardSelectedIndex] = useState<number | null>(null)
const mostRecentlySelectedIndexRef = useRef<number | null>(null)
const selectionStartIndexRef = useRef<number | null>(null)
@ -1540,22 +1527,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
})
/** All items must have the same type. */
const insertAssets = useEventCallback(
(assets: readonly AnyAsset[], parentId: DirectoryId | null) => {
const actualParentId = parentId ?? rootDirectoryId
const listDirectoryQuery = queryClient.getQueryCache().find<DirectoryQuery>({
queryKey: [backend.type, 'listDirectory', actualParentId],
exact: false,
})
if (listDirectoryQuery?.state.data) {
listDirectoryQuery.setData([...listDirectoryQuery.state.data, ...assets])
}
},
)
const onAssetListEvent = useEventCallback((event: AssetListEvent) => {
switch (event.type) {
case AssetListEventType.newFolder: {
@ -1590,7 +1561,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
insertAssets([placeholderItem], event.parentId)
void createDirectoryMutation
.mutateAsync([{ parentId: placeholderItem.parentId, title: placeholderItem.title }])
@ -1604,7 +1574,7 @@ export default function AssetsTable(props: AssetsTableProps) {
case AssetListEventType.newProject: {
const parent = nodeMapRef.current.get(event.parentKey)
const projectName = getNewProjectName(event.preferredName, event.parentId)
const dummyId = ProjectId(uniqueString())
const dummyId = createPlaceholderAssetId(AssetType.project)
const path =
backend instanceof LocalBackend ? backend.joinPath(event.parentId, projectName) : null
const placeholderItem: ProjectAsset = {
@ -1632,9 +1602,8 @@ export default function AssetsTable(props: AssetsTableProps) {
parentsPath: '',
virtualParentsPath: '',
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
insertAssets([placeholderItem], event.parentId)
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
void createProjectMutation
.mutateAsync([
@ -1654,7 +1623,8 @@ export default function AssetsTable(props: AssetsTableProps) {
throw error
})
.then((createdProject) => {
event.onCreated?.(createdProject)
event.onCreated?.(createdProject, placeholderItem.parentId)
doOpenProject({
id: createdProject.projectId,
type: backend.type,
@ -1885,7 +1855,6 @@ export default function AssetsTable(props: AssetsTableProps) {
virtualParentsPath: '',
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
insertAssets([placeholderItem], event.parentId)
createDatalinkMutation.mutate([
{
@ -1922,7 +1891,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
insertAssets([placeholderItem], event.parentId)
createSecretMutation.mutate([
{
@ -1970,8 +1938,6 @@ export default function AssetsTable(props: AssetsTableProps) {
virtualParentsPath: '',
}
insertAssets([placeholderItem], event.parentId)
void duplicateProjectMutation
.mutateAsync([event.original.id, event.versionId, placeholderItem.title])
.catch((error) => {
@ -2005,9 +1971,11 @@ export default function AssetsTable(props: AssetsTableProps) {
}
case AssetListEventType.delete: {
const asset = nodeMapRef.current.get(event.key)?.item
if (asset) {
void doDelete(asset, false)
}
break
}
case AssetListEventType.emptyTrash: {
@ -2036,6 +2004,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
})
eventListProvider.useAssetListEventListener((event) => {
if (!isLoading) {
onAssetListEvent(event)
@ -2102,8 +2071,8 @@ export default function AssetsTable(props: AssetsTableProps) {
const doRestore = useEventCallback(async (asset: AnyAsset) => {
try {
if (asset.id === driveStore.getState().assetPanelProps?.item?.id) {
setAssetPanelProps(null)
if (asset.id === driveStore.getState().assetPanelProps.item?.id) {
resetAssetPanelProps()
}
await undoDeleteAssetMutation.mutateAsync([asset.id, asset.title])
} catch (error) {
@ -2260,25 +2229,6 @@ export default function AssetsTable(props: AssetsTableProps) {
[setSelectedKeys, inputBindings, setMostRecentlySelectedIndex, driveStore],
)
useEffect(() => {
if (isLoading) {
// Ensure the spinner stays in the "initial" state for at least one frame,
// to ensure the CSS animation begins at the initial state.
requestAnimationFrame(() => {
setSpinnerState(SpinnerState.loadingFast)
})
} else {
const queuedAssetEvents = queuedAssetListEventsRef.current
if (queuedAssetEvents.length !== 0) {
queuedAssetListEventsRef.current = []
for (const event of queuedAssetEvents) {
onAssetListEvent(event)
}
}
setSpinnerState(SpinnerState.initial)
}
}, [isLoading, onAssetListEvent])
const calculateNewKeys = useEventCallback(
(event: MouseEvent | ReactMouseEvent, keys: AssetId[], getRange: () => AssetId[]) => {
event.stopPropagation()
@ -2446,6 +2396,7 @@ export default function AssetsTable(props: AssetsTableProps) {
{nodes.map((node) => (
<NameColumn
key={node.key}
isPlaceholder={node.isPlaceholder()}
isOpened={false}
keyProp={node.key}
item={node.item}
@ -2586,7 +2537,12 @@ export default function AssetsTable(props: AssetsTableProps) {
const Heading = COLUMN_HEADING[column]
return (
<th key={column} className={COLUMN_CSS_CLASS[column]}>
<Heading state={state} />
<Heading
sortInfo={state.sortInfo}
hideColumn={state.hideColumn}
setSortInfo={state.setSortInfo}
category={state.category}
/>
</th>
)
})}
@ -2598,7 +2554,7 @@ export default function AssetsTable(props: AssetsTableProps) {
<tr className="h-row">
<td colSpan={columns.length} className="bg-transparent">
<div className="grid w-container justify-around">
<Spinner size={LOADING_SPINNER_SIZE_PX} state={spinnerState} />
<StatelessSpinner size={LOADING_SPINNER_SIZE_PX} state="initial" />
</div>
</td>
</tr>
@ -2606,16 +2562,18 @@ export default function AssetsTable(props: AssetsTableProps) {
return (
<AssetRow
key={item.key + item.path}
isPlaceholder={item.isPlaceholder()}
isOpened={openedProjects.some(({ id }) => item.item.id === id)}
visibility={visibilities.get(item.key)}
columns={columns}
id={item.item.id}
type={item.item.type}
parentId={item.directoryId}
path={item.path}
initialAssetEvents={item.initialAssetEvents}
depth={item.depth}
state={state}
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
hidden={visibilities.get(item.key) === Visibility.hidden}
isKeyboardSelected={
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
}

View File

@ -14,7 +14,7 @@ import * as textProvider from '#/providers/TextProvider'
import AssetListEventType from '#/events/AssetListEventType'
import AssetPanel from '#/layouts/AssetPanel'
import { AssetPanel } from '#/layouts/AssetPanel'
import type * as assetsTable from '#/layouts/AssetsTable'
import AssetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
@ -120,7 +120,10 @@ export default function Drive(props: DriveProps) {
(
templateId: string | null = null,
templateName: string | null = null,
onCreated?: (project: backendModule.CreatedProject) => void,
onCreated?: (
project: backendModule.CreatedProject,
parentId: backendModule.DirectoryId,
) => void,
onError?: () => void,
) => {
dispatchAssetListEvent({

View File

@ -4,7 +4,7 @@
*/
import * as React from 'react'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import AddDatalinkIcon from '#/assets/add_datalink.svg'
import AddFolderIcon from '#/assets/add_folder.svg'
@ -12,7 +12,6 @@ import AddKeyIcon from '#/assets/add_key.svg'
import DataDownloadIcon from '#/assets/data_download.svg'
import DataUploadIcon from '#/assets/data_upload.svg'
import Plus2Icon from '#/assets/plus2.svg'
import RightPanelIcon from '#/assets/right_panel.svg'
import { Input as AriaInput } from '#/components/aria'
import {
Button,
@ -36,23 +35,17 @@ import StartModal from '#/layouts/StartModal'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import {
useCanCreateAssets,
useCanDownload,
useDriveStore,
useIsAssetPanelVisible,
usePasteData,
useSetIsAssetPanelPermanentlyVisible,
useSetIsAssetPanelTemporarilyVisible,
} from '#/providers/DriveProvider'
import { useCanCreateAssets, useCanDownload, usePasteData } from '#/providers/DriveProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend'
import type { DirectoryId } from '#/services/Backend'
import { ProjectState, type CreatedProject, type ProjectId } from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
import { inputFiles } from '#/utilities/input'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import { AssetPanelToggle } from './AssetPanel'
// ================
// === DriveBar ===
@ -68,7 +61,7 @@ export interface DriveBarProps {
readonly doCreateProject: (
templateId?: string | null,
templateName?: string | null,
onCreated?: (project: CreatedProject) => void,
onCreated?: (project: CreatedProject, parentId: DirectoryId) => void,
onError?: () => void,
) => void
readonly doCreateDirectory: () => void
@ -91,15 +84,11 @@ export default function DriveBar(props: DriveBarProps) {
false,
)
const driveStore = useDriveStore()
const { unsetModal } = useSetModal()
const { getText } = useText()
const inputBindings = useInputBindings()
const dispatchAssetEvent = useDispatchAssetEvent()
const canCreateAssets = useCanCreateAssets()
const isAssetPanelVisible = useIsAssetPanelVisible()
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const setIsAssetPanelPermanentlyVisible = useSetIsAssetPanelPermanentlyVisible()
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = isCloudCategory(category)
@ -118,7 +107,10 @@ export default function DriveBar(props: DriveBarProps) {
})
const [isCreatingProjectFromTemplate, setIsCreatingProjectFromTemplate] = React.useState(false)
const [isCreatingProject, setIsCreatingProject] = React.useState(false)
const [createdProjectId, setCreatedProjectId] = React.useState<ProjectId | null>(null)
const [createdProjectId, setCreatedProjectId] = React.useState<{
projectId: ProjectId
parentId: DirectoryId
} | null>(null)
const pasteData = usePasteData()
const effectivePasteData =
(
@ -142,8 +134,8 @@ export default function DriveBar(props: DriveBarProps) {
doCreateProject(
null,
null,
(project) => {
setCreatedProjectId(project.projectId)
(project, parentId) => {
setCreatedProjectId({ projectId: project.projectId, parentId })
},
() => {
setIsCreatingProject(false)
@ -156,11 +148,18 @@ export default function DriveBar(props: DriveBarProps) {
})
}, [isCloud, doCreateDirectory, doCreateProject, inputBindings])
const createdProjectQuery = useQuery<Project | null>(
createdProjectId ?
createGetProjectDetailsQuery.createPassiveListener(createdProjectId)
: { queryKey: ['__IGNORE__'], queryFn: skipToken },
)
const createdProjectQuery = useQuery({
...createGetProjectDetailsQuery({
// This is safe because we disable the query when `createdProjectId` is `null`.
// see `enabled` property below.
// eslint-disable-next-line no-restricted-syntax
assetId: createdProjectId?.projectId as ProjectId,
// eslint-disable-next-line no-restricted-syntax
parentId: createdProjectId?.parentId as DirectoryId,
backend,
}),
enabled: createdProjectId != null,
})
const isFetching =
(createdProjectQuery.isLoading ||
@ -183,26 +182,8 @@ export default function DriveBar(props: DriveBarProps) {
const assetPanelToggle = (
<>
{/* Spacing. */}
<div className={!isAssetPanelVisible ? 'w-5' : 'hidden'} />
<div className="absolute right-[15px] top-[27px] z-1">
<Button
size="medium"
variant="custom"
isActive={isAssetPanelVisible}
icon={RightPanelIcon}
aria-label={isAssetPanelVisible ? getText('openAssetPanel') : getText('closeAssetPanel')}
onPress={() => {
const isAssetPanelTemporarilyVisible =
driveStore.getState().isAssetPanelTemporarilyVisible
if (isAssetPanelTemporarilyVisible) {
setIsAssetPanelTemporarilyVisible(false)
setIsAssetPanelPermanentlyVisible(false)
} else {
setIsAssetPanelPermanentlyVisible(!isAssetPanelVisible)
}
}}
/>
</div>
<div className="ml-auto" />
<AssetPanelToggle showWhen="collapsed" className="my-auto" />
</>
)
@ -279,8 +260,8 @@ export default function DriveBar(props: DriveBarProps) {
doCreateProject(
templateId,
templateName,
(project) => {
setCreatedProjectId(project.projectId)
({ projectId }, parentId) => {
setCreatedProjectId({ projectId, parentId })
},
() => {
setIsCreatingProjectFromTemplate(false)
@ -301,8 +282,8 @@ export default function DriveBar(props: DriveBarProps) {
doCreateProject(
null,
null,
(project) => {
setCreatedProjectId(project.projectId)
({ projectId }, parentId) => {
setCreatedProjectId({ projectId, parentId })
setIsCreatingProject(false)
},
() => {
@ -336,6 +317,7 @@ export default function DriveBar(props: DriveBarProps) {
<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />
</DialogTrigger>
)}
{isCloud && (
<DialogTrigger>
<Button

View File

@ -18,6 +18,7 @@ import * as suspense from '#/components/Suspense'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as twMerge from '#/utilities/tailwindMerge'
// ====================
@ -67,7 +68,6 @@ const IGNORE_PARAMS_REGEX = new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`)
/** Props for an {@link Editor}. */
export interface EditorProps {
readonly isOpening: boolean
readonly isOpeningFailed: boolean
readonly openingError: Error | null
readonly startProject: (project: LaunchedProject) => void
@ -75,64 +75,64 @@ export interface EditorProps {
readonly hidden: boolean
readonly ydocUrl: string | null
readonly appRunner: GraphEditorRunner | null
readonly renameProject: (newName: string) => void
readonly renameProject: (newName: string, projectId: backendModule.ProjectId) => void
readonly projectId: backendModule.ProjectId
}
/** The container that launches the IDE. */
export default function Editor(props: EditorProps) {
const { project, hidden, isOpening, startProject, isOpeningFailed, openingError } = props
const { project, hidden, startProject, isOpeningFailed, openingError } = props
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const backend = backendProvider.useBackendForProjectType(project.type)
const projectStatusQuery = projectHooks.createGetProjectDetailsQuery({
type: project.type,
assetId: project.id,
parentId: project.parentId,
title: project.title,
remoteBackend,
localBackend,
backend,
})
const projectQuery = reactQuery.useQuery({
...projectStatusQuery,
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
})
const projectQuery = reactQuery.useQuery(projectStatusQuery)
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
const shouldRefetch = !projectQuery.isError && !projectQuery.isLoading
if (!isOpeningFailed && !isOpening && isProjectClosed && shouldRefetch) {
startProject(project)
}
React.useEffect(() => {
if (isProjectClosed) {
startProject(project)
}
}, [isProjectClosed, startProject, project])
return isOpeningFailed ?
if (isOpeningFailed) {
return (
<errorBoundary.ErrorDisplay
error={openingError}
resetErrorBoundary={() => {
startProject(project)
}}
/>
: <div
className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testvalue={project.id}
data-testid="editor"
>
{(() => {
if (projectQuery.isError) {
)
}
return (
<div
className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testvalue={project.id}
data-testid="editor"
>
{(() => {
switch (true) {
case projectQuery.isError:
return (
<errorBoundary.ErrorDisplay
error={projectQuery.error}
resetErrorBoundary={() => projectQuery.refetch()}
/>
)
} else if (
projectQuery.isLoading ||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
) {
case projectQuery.isLoading ||
projectQuery.data?.state.type !== backendModule.ProjectState.opened:
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
} else {
default:
return (
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
@ -144,9 +144,10 @@ export default function Editor(props: EditorProps) {
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
)
}
})()}
</div>
}
})()}
</div>
)
}
// ======================
@ -184,6 +185,10 @@ function EditorInternal(props: EditorInternalProps) {
}
}, [hidden, gtagEvent])
const onRenameProject = useEventCallback((newName: string) => {
renameProject(newName, openedProject.projectId)
})
const appProps = React.useMemo<GraphEditorProps>(() => {
const jsonAddress = openedProject.jsonAddress
const binaryAddress = openedProject.binaryAddress
@ -206,7 +211,7 @@ function EditorInternal(props: EditorInternalProps) {
hidden,
ignoreParamsRegex: IGNORE_PARAMS_REGEX,
logEvent,
renameProject,
renameProject: onRenameProject,
projectBackend,
remoteBackend,
}
@ -217,7 +222,7 @@ function EditorInternal(props: EditorInternalProps) {
getText,
hidden,
logEvent,
renameProject,
onRenameProject,
backendType,
localBackend,
remoteBackend,

View File

@ -61,14 +61,18 @@ export default function Labels(props: LabelsProps) {
return (
<FocusArea direction="vertical">
{(innerProps) => (
<div data-testid="labels" className="flex flex-col items-start gap-4" {...innerProps}>
<div
data-testid="labels"
className="flex flex-1 flex-col items-start gap-4 overflow-auto"
{...innerProps}
>
<ariaComponents.Text variant="subtitle" className="px-2 font-bold">
{getText('labels')}
</ariaComponents.Text>
<div
data-testid="labels-list"
aria-label={getText('labelsListLabel')}
className="flex flex-col items-start gap-labels"
className="flex flex-1 flex-col items-start gap-labels overflow-auto"
>
{labels.map((label) => {
const negated = currentNegativeLabels.some((term) =>

View File

@ -10,7 +10,7 @@ import Play2Icon from '#/assets/play2.svg'
import SortAscendingIcon from '#/assets/sort_ascending.svg'
import TrashIcon from '#/assets/trash.svg'
import { Button, DatePicker, Dropdown, Form, Text } from '#/components/AriaComponents'
import StatelessSpinner, { SpinnerState } from '#/components/StatelessSpinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import { useBackendQuery } from '#/hooks/backendHooks'
@ -358,7 +358,7 @@ export default function ActivityLogSettingsSection(props: ActivityLogSettingsSec
<tr className="h-table-row">
<td colSpan={4} className="rounded-full bg-transparent">
<div className="flex justify-center">
<StatelessSpinner size={32} state={SpinnerState.loadingMedium} />
<StatelessSpinner size={32} state="loading-medium" />
</div>
</td>
</tr>

View File

@ -148,19 +148,21 @@ export function SetupTwoFaForm() {
}
}}
>
{({ values }) => (
<>
<Switch
name="enabled"
description={getText('enable2FADescription')}
label={getText('enable2FA')}
/>
<>
<Switch
name="enabled"
description={getText('enable2FADescription')}
label={getText('enable2FA')}
/>
<ErrorBoundary>
<Suspense>{values.enabled === true && <TwoFa />}</Suspense>
</ErrorBoundary>
</>
)}
<ErrorBoundary>
<Suspense>
<Form.FieldValue name="enabled">
{(enabled) => enabled === true && <TwoFa />}
</Form.FieldValue>
</Suspense>
</ErrorBoundary>
</>
</Form>
)
}

View File

@ -1,12 +1,12 @@
/** @file Settings tab for viewing and editing roles for all users in the organization. */
import * as React from 'react'
import { useMemo, useRef } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Cell, Column, Row, Table, TableBody, TableHeader, useDragAndDrop } from '#/components/aria'
import { Button, ButtonGroup } from '#/components/AriaComponents'
import { PaywallDialogButton } from '#/components/Paywall'
import StatelessSpinner, { SpinnerState } from '#/components/StatelessSpinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import { USER_MIME_TYPE } from '#/data/mimeTypes'
import {
backendMutationOptions,
@ -49,15 +49,15 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
const toastAndLog = useToastAndLog()
const { data: users } = useBackendQuery(backend, 'listUsers', [])
const userGroups = useListUserGroupsWithUsers(backend)
const rootRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLTableSectionElement>(null)
const changeUserGroup = useMutation(
backendMutationOptions(backend, 'changeUserGroup'),
).mutateAsync
const deleteUserGroup = useMutation(
backendMutationOptions(backend, 'deleteUserGroup'),
).mutateAsync
const usersMap = React.useMemo(
const usersMap = useMemo(
() => new Map((users ?? []).map((otherUser) => [otherUser.userId, otherUser])),
[users],
)
@ -214,7 +214,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
}}
>
<div className="flex justify-center">
<StatelessSpinner size={32} state={SpinnerState.loadingMedium} />
<StatelessSpinner size={32} state="loading-medium" />
</div>
</Cell>
</Row>

View File

@ -2,10 +2,8 @@
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import type * as text from 'enso-common/src/text'
import * as tabBar from 'enso-common/src/utilities/style/tabBar'
import * as projectHooks from '#/hooks/projectHooks'
@ -14,138 +12,35 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import FocusArea from '#/components/styled/FocusArea'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import * as backend from '#/services/Backend'
import { AnimatedBackground } from '#/components/AnimatedBackground'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useBackendForProjectType } from '#/providers/BackendProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { ProjectState } from '#/services/Backend'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
/** The corner radius of the tabs. */
const TAB_RADIUS_PX = 24
// =====================
// === TabBarContext ===
// =====================
/** Context for a {@link TabBarContext}. */
interface TabBarContextValue {
readonly setSelectedTab: (element: HTMLElement) => void
}
const TabBarContext = React.createContext<TabBarContextValue | null>(null)
/** Custom hook to get tab bar context. */
function useTabBarContext() {
const context = React.useContext(TabBarContext)
invariant(context, '`useTabBarContext` must be used inside a `<TabBar />`')
return context
}
// ==============
// === TabBar ===
// ==============
import { motion } from 'framer-motion'
/** Props for a {@link TabBar}. */
export interface TabBarProps extends Readonly<React.PropsWithChildren> {
export interface TabBarProps<T extends object> extends aria.TabListProps<T> {
readonly className?: string
}
/** Switcher to choose the currently visible full-screen page. */
export default function TabBar(props: TabBarProps) {
const { children, className } = props
const cleanupResizeObserverRef = React.useRef(() => {})
const backgroundRef = React.useRef<HTMLDivElement | null>(null)
const selectedTabRef = React.useRef<HTMLElement | null>(null)
const [resizeObserver] = React.useState(
() =>
new ResizeObserver(() => {
updateClipPath(selectedTabRef.current)
}),
)
export default function TabBar<T extends object>(props: TabBarProps<T>) {
const { className, ...rest } = props
const [updateClipPath] = React.useState(() => {
return (element: HTMLElement | null) => {
const backgroundElement = backgroundRef.current
if (backgroundElement) {
const rootElement = backgroundElement.parentElement?.parentElement
if (!element) {
backgroundElement.style.clipPath = ''
if (rootElement) {
rootElement.style.clipPath = ''
}
} else {
selectedTabRef.current = element
const bounds = element.getBoundingClientRect()
const rootBounds = backgroundElement.getBoundingClientRect()
const { clipPath, rootClipPath } = tabBar.barClipPath(bounds, rootBounds, TAB_RADIUS_PX)
backgroundElement.style.clipPath = clipPath
if (rootElement) {
rootElement.style.clipPath = rootClipPath
}
}
}
}
})
const setSelectedTab = React.useCallback(
(element: HTMLElement | null) => {
if (element) {
updateClipPath(element)
resizeObserver.observe(element)
return () => {
resizeObserver.unobserve(element)
}
} else {
return
}
},
[resizeObserver, updateClipPath],
)
const updateResizeObserver = (element: HTMLElement | null) => {
cleanupResizeObserverRef.current()
if (!(element instanceof HTMLElement)) {
cleanupResizeObserverRef.current = () => {}
} else {
resizeObserver.observe(element)
cleanupResizeObserverRef.current = () => {
resizeObserver.unobserve(element)
}
}
}
const classes = React.useMemo(() => tailwindMerge.twJoin('flex grow', className), [className])
return (
<FocusArea direction="horizontal">
{(innerProps) => (
<div className={tailwindMerge.twMerge('relative flex grow', className)} {...innerProps}>
<TabBarContext.Provider value={{ setSelectedTab }}>
<aria.TabList className="flex h-12 shrink-0 grow transition-[clip-path] duration-300">
<aria.Tab isDisabled>
{/* Putting the background in a `Tab` is a hack, but it is required otherwise there
* are issues with the ref to the background being detached, resulting in the clip
* path cutout for the current tab not applying at all. */}
<div
ref={(element) => {
backgroundRef.current = element
updateResizeObserver(element)
}}
className="pointer-events-none absolute inset-0 bg-primary/5 transition-[clip-path] duration-300"
/>
</aria.Tab>
{children}
</aria.TabList>
</TabBarContext.Provider>
</div>
)}
</FocusArea>
<AnimatedBackground>
<div className={classes}>
<aria.TabList<T> className="flex h-12 shrink-0 grow" {...rest} />
</div>
</AnimatedBackground>
)
}
@ -154,118 +49,148 @@ export default function TabBar(props: TabBarProps) {
// ===========
/** Props for a {@link Tab}. */
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
export interface TabProps extends Readonly<React.PropsWithChildren> {
readonly 'data-testid'?: string
readonly id: string
readonly project?: LaunchedProject
readonly isActive: boolean
readonly isHidden?: boolean
readonly icon: string | null
readonly icon: React.ReactNode | string | null
readonly labelId: text.TextId
readonly onClose?: () => void
readonly onLoadEnd?: () => void
}
const UNDERLAY_ELEMENT = (
<>
<div className="h-full w-full rounded-t-4xl bg-dashboard" />
<div className="absolute -left-5 bottom-0 aspect-square w-5 -rotate-90 [background:radial-gradient(circle_at_100%_0%,_transparent_70%,_var(--color-dashboard-background)_70%)]" />
<div className="absolute -right-5 bottom-0 aspect-square w-5 -rotate-90 [background:radial-gradient(circle_at_100%_100%,_transparent_70%,_var(--color-dashboard-background)_70%)]" />
</>
)
/** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) {
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
const { onLoadEnd } = props
export function Tab(props: TabProps) {
const { id, isActive, isHidden = false, icon, labelId, children, onClose } = props
const { getText } = textProvider.useText()
const inputBindings = useInputBindings()
const { setSelectedTab } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null)
const isLoadingRef = React.useRef(true)
const actuallyActive = isActive && !isHidden
const [resizeObserver] = React.useState(
() =>
new ResizeObserver(() => {
updateClipPath()
}),
)
const [updateClipPath] = React.useState(() => {
return () => {
const element = ref.current
if (element) {
const bounds = element.getBoundingClientRect()
element.style.clipPath = tabBar.tabClipPath(bounds, TAB_RADIUS_PX)
}
}
const stableOnClose = useEventCallback(() => {
onClose?.()
})
React.useEffect(() => {
if (actuallyActive && onClose) {
if (isActive) {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
closeTab: onClose,
closeTab: stableOnClose,
})
} else {
return
}
}, [inputBindings, actuallyActive, onClose])
React.useLayoutEffect(() => {
if (actuallyActive && ref.current) {
setSelectedTab(ref.current)
}
}, [actuallyActive, id, setSelectedTab])
const { isLoading, data } = reactQuery.useQuery<backend.Project | null>(
project?.id ?
projectHooks.createGetProjectDetailsQuery.createPassiveListener(project.id)
: { queryKey: ['__IGNORE__'], queryFn: reactQuery.skipToken },
)
const isFetching = isLoading || data == null || data.state.type !== backend.ProjectState.opened
React.useEffect(() => {
if (!isFetching && isLoadingRef.current) {
isLoadingRef.current = false
onLoadEnd?.()
}
}, [isFetching, onLoadEnd])
}, [inputBindings, isActive, stableOnClose])
return (
<aria.Tab
data-testid={props['data-testid']}
ref={(element) => {
if (element instanceof HTMLDivElement) {
ref.current = element
if (actuallyActive) {
setSelectedTab(element)
}
resizeObserver.disconnect()
resizeObserver.observe(element)
updateClipPath()
} else {
ref.current = null
}
}}
id={id}
aria-label={getText(labelId)}
className={tailwindMerge.twMerge(
'relative -mx-6 flex h-full items-center gap-3 rounded-t-3xl px-10',
!isActive &&
'cursor-pointer opacity-50 hover:bg-frame hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-30 [&.disabled]:cursor-not-allowed [&.disabled]:opacity-30',
className={tailwindMerge.twJoin(
'disabled:cursor-not-allowed disabled:opacity-30 [&.disabled]:cursor-not-allowed [&.disabled]:opacity-30',
!isActive && 'cursor-pointer',
isHidden && 'hidden',
)}
>
{icon != null &&
(isLoading ?
<StatelessSpinner
state={spinnerModule.SpinnerState.loadingMedium}
size={16}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
: <SvgMask
src={icon}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>)}
{data?.name ?? children}
{onClose && (
<div className="flex">
<ariaComponents.CloseButton onPress={onClose} />
</div>
{({ isSelected, isHovered }) => (
<AnimatedBackground.Item
isSelected={isSelected}
className="h-full w-full rounded-t-3xl pl-4 pr-4"
underlayElement={UNDERLAY_ELEMENT}
>
<div className="relative z-1 flex h-full w-full items-center justify-center gap-3">
<motion.div
variants={{ active: { opacity: 1 }, inactive: { opacity: 0 } }}
initial="inactive"
animate={!isSelected && isHovered ? 'active' : 'inactive'}
className="absolute -inset-x-2.5 inset-y-2 -z-1 rounded-3xl bg-dashboard transition-colors duration-300"
/>
{typeof icon === 'string' ?
<SvgMask
src={icon}
className={tailwindMerge.twJoin(
onClose && 'group-hover:hidden focus-visible:hidden',
)}
/>
: icon}
<ariaComponents.Text truncate="1" className="max-w-40">
{children}
</ariaComponents.Text>
{onClose && (
<div className="relative">
<ariaComponents.CloseButton onPress={onClose} />
</div>
)}
</div>
</AnimatedBackground.Item>
)}
</aria.Tab>
)
}
/**
* Props for a {@link ProjectTab}.
*/
export interface ProjectTabProps extends Omit<TabProps, 'onClose'> {
readonly project: LaunchedProject
readonly onLoadEnd?: (project: LaunchedProject) => void
readonly onClose?: (project: LaunchedProject) => void
}
const SPINNER = <StatelessSpinner state="loading-medium" size={16} />
/**
* Project Tab is a {@link Tab} that displays the name of the project.
*/
export function ProjectTab(props: ProjectTabProps) {
const { project, onLoadEnd, onClose, icon: iconRaw, ...rest } = props
const didNotifyOnLoadEnd = React.useRef(false)
const backend = useBackendForProjectType(project.type)
const stableOnLoadEnd = useEventCallback(() => {
onLoadEnd?.(project)
})
const stableOnClose = useEventCallback(() => {
onClose?.(project)
})
const { data: isOpened, isSuccess } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery({
assetId: project.id,
parentId: project.parentId,
backend,
}),
select: (data) => data.state.type === ProjectState.opened,
})
const isReady = isSuccess && isOpened
React.useEffect(() => {
if (isReady && !didNotifyOnLoadEnd.current) {
didNotifyOnLoadEnd.current = true
stableOnLoadEnd()
}
}, [isReady, stableOnLoadEnd])
React.useEffect(() => {
if (!isReady) {
didNotifyOnLoadEnd.current = false
}
}, [isReady])
const icon = isReady ? iconRaw : SPINNER
return <Tab {...rest} icon={icon} onClose={stableOnClose} />
}
TabBar.ProjectTab = ProjectTab
TabBar.Tab = Tab

View File

@ -12,17 +12,9 @@ import { useFullUserSession } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
import { Plan } from '#/services/Backend'
// =================
// === Constants ===
// =================
/** Whether the chat button should be visible. Temporarily disabled. */
const SHOULD_SHOW_CHAT_BUTTON: boolean = false
// ===============
// === UserBar ===
// ===============
/** Props for a {@link UserBar}. */
export interface UserBarProps {
/**

View File

@ -13,10 +13,6 @@ import { Plan } from '#/services/Backend'
import { download } from '#/utilities/download'
import { getDownloadUrl } from '#/utilities/github'
// ================
// === UserMenu ===
// ================
/** Props for a {@link UserMenu}. */
export interface UserMenuProps {
/** If `true`, disables `data-testid` because it will not be visible. */
@ -36,65 +32,67 @@ export default function UserMenu(props: UserMenuProps) {
const { getText } = useText()
const toastAndLog = useToastAndLog()
const aboutThisAppMenuEntry = (
<MenuEntry
action="aboutThisApp"
doAction={() => {
setModal(<AboutModal />)
}}
/>
const entries = (
<>
{localBackend == null && (
<MenuEntry
action="downloadApp"
doAction={async () => {
unsetModal()
const downloadUrl = await getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download(downloadUrl)
}
}}
/>
)}
<MenuEntry action="settings" doAction={goToSettingsPage} />
<MenuEntry
action="aboutThisApp"
doAction={() => {
setModal(<AboutModal />)
}}
/>
<MenuEntry
action="signOut"
doAction={() => {
onSignOut()
// Wait until React has switched back to drive view, before signing out.
window.setTimeout(() => {
void signOut()
}, 0)
}}
/>
</>
)
return (
<Popover {...(!hidden ? { testId: 'user-menu' } : {})} size="xxsmall">
<div className="mb-2 flex items-center gap-icons overflow-hidden px-menu-entry transition-all duration-user-menu">
<div className="flex size-row-h shrink-0 items-center overflow-clip rounded-full">
<img
src={user.profilePicture ?? DefaultUserIcon}
className="pointer-events-none size-row-h"
/>
</div>
<div className="flex min-w-0 flex-col">
<Text disableLineHeightCompensation variant="body" truncate="1" weight="semibold">
{user.name}
</Text>
<Text disableLineHeightCompensation>{getText(`${user.plan ?? Plan.free}`)}</Text>
</div>
</div>
<FocusArea direction="vertical">
{(innerProps) => (
<div className="flex flex-col overflow-hidden" {...innerProps}>
{localBackend == null && (
<MenuEntry
action="downloadApp"
doAction={async () => {
unsetModal()
const downloadUrl = await getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download(downloadUrl)
}
}}
/>
)}
<MenuEntry action="settings" doAction={goToSettingsPage} />
{aboutThisAppMenuEntry}
<MenuEntry
action="signOut"
doAction={() => {
onSignOut()
// Wait until React has switched back to drive view, before signing out.
window.setTimeout(() => {
void signOut()
}, 0)
}}
return hidden ? entries : (
<Popover data-testid="user-menu" size="xxsmall">
<div className="mb-2 flex select-none items-center gap-icons overflow-hidden px-menu-entry transition-all duration-user-menu">
<div className="flex size-row-h shrink-0 items-center overflow-clip rounded-full">
<img
src={user.profilePicture ?? DefaultUserIcon}
className="pointer-events-none size-row-h"
/>
</div>
)}
</FocusArea>
</Popover>
)
<div className="flex min-w-0 flex-col">
<Text disableLineHeightCompensation variant="body" truncate="1" weight="semibold">
{user.name}
</Text>
<Text disableLineHeightCompensation>{getText(`${user.plan ?? Plan.free}`)}</Text>
</div>
</div>
<FocusArea direction="vertical">
{(innerProps) => (
<div className="flex flex-col overflow-hidden" {...innerProps}>
{entries}
</div>
)}
</FocusArea>
</Popover>
)
}

View File

@ -13,7 +13,7 @@ import PermissionSelector from '#/components/dashboard/PermissionSelector'
import Modal from '#/components/Modal'
import { PaywallAlert } from '#/components/Paywall'
import FocusArea from '#/components/styled/FocusArea'
import { backendMutationOptions, useAssetPassiveListenerStrict } from '#/hooks/backendHooks'
import { backendMutationOptions, useAssetStrict } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category'
@ -75,7 +75,12 @@ export default function ManagePermissionsModal<Asset extends AnyAsset = AnyAsset
props: ManagePermissionsModalProps<Asset>,
) {
const { backend, category, item: itemRaw, self, doRemoveSelf, eventTarget } = props
const item = useAssetPassiveListenerStrict(backend.type, itemRaw.id, itemRaw.parentId, category)
const item = useAssetStrict({
backend,
assetId: itemRaw.id,
parentId: itemRaw.parentId,
category,
})
const remoteBackend = useRemoteBackend()
const { user } = useFullUserSession()
const { unsetModal } = useSetModal()

View File

@ -77,7 +77,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
{(innerProps) => (
<ColorPicker
aria-label={getText('color')}
className="relative flex items-center"
className="relative"
pickerClassName="grow"
setColor={(color) => {
form.setValue('color', color)

View File

@ -18,7 +18,6 @@ import type Backend from '#/services/Backend'
/** Props for a {@link ProjectLogsModal}. */
export interface ProjectLogsModalProps {
readonly isOpen: boolean
readonly backend: Backend
readonly projectSessionId: backendModule.ProjectSessionId
readonly projectTitle: string
@ -26,12 +25,11 @@ export interface ProjectLogsModalProps {
/** A modal for showing logs for a project. */
export default function ProjectLogsModal(props: ProjectLogsModalProps) {
const { isOpen } = props
const { getText } = textProvider.useText()
return (
<ariaComponents.Dialog title={getText('logs')} type="fullscreen">
{isOpen && <ProjectLogsModalInternal {...props} />}
{() => <ProjectLogsModalInternal {...props} />}
</ariaComponents.Dialog>
)
}
@ -40,6 +38,7 @@ export default function ProjectLogsModal(props: ProjectLogsModalProps) {
function ProjectLogsModalInternal(props: ProjectLogsModalProps) {
const { backend, projectSessionId, projectTitle } = props
const { getText } = textProvider.useText()
const logsQuery = reactQuery.useSuspenseQuery({
queryKey: ['projectLogs', { projectSessionId, projectTitle }],
queryFn: async () => {

View File

@ -33,6 +33,14 @@ interface AuthenticationPagePropsBase {
export type AuthenticationPageProps<Schema extends TSchema> = AuthenticationPagePropsBase &
Partial<FormProps<Schema>>
const CONTAINER_CLASSES = DIALOG_BACKGROUND({
className: 'flex w-full flex-col gap-4 rounded-4xl p-12',
})
const OFFLINE_ALERT_CLASSES = DIALOG_BACKGROUND({
className: 'flex mt-auto rounded-sm items-center justify-center p-4 px-12 rounded-4xl',
})
/** A styled authentication page. */
export default function AuthenticationPage<Schema extends TSchema>(
props: AuthenticationPageProps<Schema>,
@ -51,14 +59,6 @@ export default function AuthenticationPage<Schema extends TSchema>(
</Text.Heading>
: null
const containerClasses = DIALOG_BACKGROUND({
className: 'flex w-full flex-col gap-4 rounded-4xl p-12',
})
const offlineAlertClasses = DIALOG_BACKGROUND({
className: 'flex mt-auto rounded-sm items-center justify-center p-4 px-12 rounded-4xl',
})
return (
<Page>
<div className="flex h-full w-full flex-col overflow-y-auto p-12">
@ -67,7 +67,7 @@ export default function AuthenticationPage<Schema extends TSchema>(
data-testid={props['data-testid']}
>
{isOffline && (
<div className={offlineAlertClasses}>
<div className={OFFLINE_ALERT_CLASSES}>
<Text className="text-center" balance elementType="p">
{getText('loginUnavailableOffline')}{' '}
{supportsOffline && getText('loginUnavailableOfflineLocal')}
@ -77,7 +77,7 @@ export default function AuthenticationPage<Schema extends TSchema>(
<div className="row-start-2 row-end-3 flex w-full flex-col items-center gap-auth">
{!isForm ?
<div className={containerClasses}>
<div className={CONTAINER_CLASSES}>
{heading}
{(() => {
invariant(
@ -91,7 +91,7 @@ export default function AuthenticationPage<Schema extends TSchema>(
// This is SAFE, as the props type of this type extends `FormProps`.
// eslint-disable-next-line no-restricted-syntax
{...(form ? { form } : (formProps as FormProps<Schema>))}
className={containerClasses}
className={CONTAINER_CLASSES}
>
{(innerProps) => (
<>

View File

@ -1,10 +1,7 @@
/** @file A loading screen, displayed while the user is logging in. */
import * as React from 'react'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import { Text } from '#/components/AriaComponents'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import { useText } from '#/providers/TextProvider'
// =================
// === Constants ===
@ -19,19 +16,16 @@ const SPINNER_SIZE_PX = 64
/** A loading screen. */
export default function LoadingScreen() {
const { getText } = textProvider.useText()
const { getText } = useText()
return (
<div className="grid h-screen w-screen place-items-center text-primary">
<div className="flex flex-col items-center gap-8 text-center">
<StatelessSpinner
state={statelessSpinner.SpinnerState.loadingFast}
size={SPINNER_SIZE_PX}
/>
<StatelessSpinner state="loading-fast" size={SPINNER_SIZE_PX} />
<ariaComponents.Text.Heading variant="h1" color="inherit">
<Text.Heading variant="h1" color="inherit">
{getText('loadingAppMessage')}
</ariaComponents.Text.Heading>
</Text.Heading>
</div>
</div>
)

Some files were not shown because too many files have changed in this diff Show More