mirror of
https://github.com/enso-org/enso.git
synced 2024-11-24 08:41:40 +03:00
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:
parent
0543cfaec5
commit
58512e701e
@ -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
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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."
|
||||
}
|
||||
|
@ -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
1
app/gui/.gitignore
vendored
@ -25,6 +25,7 @@ mockDist
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/
|
||||
|
||||
src/project-view/util/iconList.json
|
||||
src/project-view/util/iconName.ts
|
||||
|
@ -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. */
|
||||
|
@ -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). */
|
||||
|
@ -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: '' })
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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) => {
|
||||
|
@ -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.
|
||||
|
29
app/gui/e2e/dashboard/auth.setup.ts
Normal file
29
app/gui/e2e/dashboard/auth.setup.ts
Normal 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 })
|
||||
})
|
||||
})
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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()
|
||||
}),
|
||||
|
@ -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 ?? ''])
|
||||
})
|
@ -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({
|
||||
|
@ -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
7
app/gui/env.d.ts
vendored
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
3
app/gui/src/dashboard/assets/file_text.svg
Normal file
3
app/gui/src/dashboard/assets/file_text.svg
Normal 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 |
3
app/gui/src/dashboard/assets/group.svg
Normal file
3
app/gui/src/dashboard/assets/group.svg
Normal 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 |
3
app/gui/src/dashboard/assets/inspect.svg
Normal file
3
app/gui/src/dashboard/assets/inspect.svg
Normal 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 |
@ -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 |
3
app/gui/src/dashboard/assets/versions.svg
Normal file
3
app/gui/src/dashboard/assets/versions.svg
Normal 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 |
@ -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>
|
||||
)
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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. */
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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)}</>
|
||||
}
|
@ -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'
|
||||
|
@ -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>>
|
||||
|
@ -103,6 +103,7 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
|
||||
isDisabled,
|
||||
onHoverChange: handleHoverChange,
|
||||
})
|
||||
|
||||
const { hoverProps: tooltipHoverProps } = aria.useHover({
|
||||
isDisabled,
|
||||
onHoverChange: handleHoverChange,
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
// =================================
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 }} />
|
||||
}
|
6
app/gui/src/dashboard/components/MarkdownViewer/index.ts
Normal file
6
app/gui/src/dashboard/components/MarkdownViewer/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel export for MarkdownViewer
|
||||
*/
|
||||
export { MarkdownViewer, type MarkdownViewerProps } from './MarkdownViewer'
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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],
|
||||
)}
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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. */
|
||||
|
@ -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 })
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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. */
|
||||
|
@ -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)
|
||||
|
259
app/gui/src/dashboard/hooks/measureHooks.ts
Normal file
259
app/gui/src/dashboard/hooks/measureHooks.ts
Normal 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])
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 ===
|
||||
// =================
|
||||
|
@ -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 &&
|
||||
|
@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
69
app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx
Normal file
69
app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx
Normal 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} />
|
||||
}
|
6
app/gui/src/dashboard/layouts/AssetDocs/index.ts
Normal file
6
app/gui/src/dashboard/layouts/AssetDocs/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrels for the `AssetDocs`.
|
||||
*/
|
||||
export { AssetDocs } from './AssetDocs'
|
@ -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>
|
||||
)
|
||||
}
|
267
app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx
Normal file
267
app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx
Normal 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>
|
||||
)
|
||||
})
|
@ -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
|
@ -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>
|
||||
)
|
||||
})
|
6
app/gui/src/dashboard/layouts/AssetPanel/index.ts
Normal file
6
app/gui/src/dashboard/layouts/AssetPanel/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
* Barrels for the `AssetPanel` component.
|
||||
*/
|
||||
export * from './AssetPanel'
|
||||
export { AssetPanelToggle, type AssetPanelToggleProps } from './components/AssetPanelToggle'
|
@ -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}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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) =>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
/**
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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 () => {
|
||||
|
@ -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) => (
|
||||
<>
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user