diff --git a/app/common/src/detect.ts b/app/common/src/detect.ts index 7dbd00aa2a5d..d2a4af76b34f 100644 --- a/app/common/src/detect.ts +++ b/app/common/src/detect.ts @@ -166,17 +166,21 @@ export function isOnUnknownBrowser() { let detectedArchitecture: string | null = null // Only implemented by Chromium. -// @ts-expect-error This API exists, but no typings exist for it yet. -navigator.userAgentData?.getHighEntropyValues(['architecture']).then((values: unknown) => { - if ( - typeof values === 'object' && - values != null && - 'architecture' in values && - typeof values.architecture === 'string' - ) { - detectedArchitecture = String(values.architecture) - } -}) +// navigator is undefined in Node.js, e.g. in integration tests(mock server). +// So we need to check if it is defined before using it. +if (typeof navigator !== 'undefined' && 'userAgentData' in navigator) { + // @ts-expect-error This API exists, but no typings exist for it yet. + navigator.userAgentData.getHighEntropyValues(['architecture']).then((values: unknown) => { + if ( + typeof values === 'object' && + values != null && + 'architecture' in values && + typeof values.architecture === 'string' + ) { + detectedArchitecture = String(values.architecture) + } + }) +} /** Possible processor architectures. */ export enum Architecture { diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 4734f132c706..15e01e9a15fd 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -14,20 +14,36 @@ export const S3_CHUNK_SIZE_BYTES = 10_000_000 // ================ /** Unique identifier for an organization. */ -export type OrganizationId = newtype.Newtype +export type OrganizationId = newtype.Newtype<`organization-${string}`, 'OrganizationId'> export const OrganizationId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link OrganizationId}. */ +export function isOrganizationId(id: string): id is OrganizationId { + return id.startsWith('organization-') +} /** Unique identifier for a user in an organization. */ export type UserId = newtype.Newtype export const UserId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link UserId}. */ +export function isUserId(id: string): id is UserId { + return id.startsWith('user-') +} /** Unique identifier for a user group. */ -export type UserGroupId = newtype.Newtype +export type UserGroupId = newtype.Newtype<`usergroup-${string}`, 'UserGroupId'> export const UserGroupId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link UserGroupId}. */ +export function isUserGroupId(id: string): id is UserGroupId { + return id.startsWith('usergroup-') +} /** Unique identifier for a directory. */ -export type DirectoryId = newtype.Newtype +export type DirectoryId = newtype.Newtype<`directory-${string}`, 'DirectoryId'> export const DirectoryId = newtype.newtypeConstructor() +/** Whether a given {@link string} is an {@link DirectoryId}. */ +export function isDirectoryId(id: string): id is DirectoryId { + return id.startsWith('directory-') +} /** * Unique identifier for an asset representing the items inside a directory for which the @@ -118,16 +134,6 @@ export type UserPermissionIdentifier = UserGroupId | UserId export type Path = newtype.Newtype export const Path = newtype.newtypeConstructor() -/** Whether a given {@link string} is an {@link UserId}. */ -export function isUserId(id: string): id is UserId { - return id.startsWith('user-') -} - -/** Whether a given {@link string} is an {@link UserGroupId}. */ -export function isUserGroupId(id: string): id is UserGroupId { - return id.startsWith('usergroup-') -} - const PLACEHOLDER_USER_GROUP_PREFIX = 'usergroup-placeholder-' /** @@ -143,7 +149,7 @@ export function isPlaceholderUserGroupId(id: string) { * being created on the backend. */ export function newPlaceholderUserGroupId() { - return UserGroupId(`${PLACEHOLDER_USER_GROUP_PREFIX}${uniqueString.uniqueString()}`) + return UserGroupId(`${PLACEHOLDER_USER_GROUP_PREFIX}${uniqueString.uniqueString()}` as const) } // ============= @@ -830,7 +836,7 @@ export function createRootDirectoryAsset(directoryId: DirectoryId): DirectoryAss title: '(root)', id: directoryId, modifiedAt: dateTime.toRfc3339(new Date()), - parentId: DirectoryId(''), + parentId: DirectoryId('directory-'), permissions: [], projectState: null, extension: null, @@ -905,7 +911,7 @@ export function createPlaceholderDirectoryAsset( ): DirectoryAsset { return { type: AssetType.directory, - id: DirectoryId(createPlaceholderId()), + id: DirectoryId(`directory-${createPlaceholderId()}` as const), title, parentId, permissions: assetPermissions, @@ -1075,7 +1081,7 @@ export function createPlaceholderAssetId( let result: AssetId switch (assetType) { case AssetType.directory: { - result = DirectoryId(id) + result = DirectoryId(`directory-${id}` as const) break } case AssetType.project: { diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index ee0cb1004315..35c1aea3cf49 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -495,13 +495,13 @@ "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "disableAnimations": "Disable animations", "disableAnimationsDescription": "Disable all animations in the app.", - "removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites", + "removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your sidebar", "changeLocalRootDirectoryInSettings": "Change the root folder", "localStorage": "Local Storage", "addLocalDirectory": "Add Folder", "browseForNewLocalRootDirectory": "Browse for new Root Folder", "resetLocalRootDirectory": "Reset Root Folder", - "removeDirectoryFromFavorites": "Remove folder from favorites", + "removeDirectoryFromFavorites": "Remove from Sidebar", "organizationInviteTitle": "You have been invited!", "organizationInvitePrefix": "The organization '", "organizationInviteSuffix": "' is inviting you. Would you like to accept? All your assets will be moved with you to your personal space.", @@ -692,6 +692,7 @@ "accessedDataColumnName": "Accessed data", "docsColumnName": "Docs", "rootFolderColumnName": "Root folder", + "pathColumnName": "Location", "settingsShortcut": "Settings", "closeTabShortcut": "Close Tab", @@ -761,6 +762,10 @@ "accessedDataColumnHide": "Accessed Data", "docsColumnShow": "Docs", "docsColumnHide": "Docs", + "pathColumnShow": "Location", + "pathColumnHide": "Location", + + "hideColumn": "Hide column", "activityLog": "Activity Log", "startDate": "Start Date", diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 13445d403fe8..0ecec073dc50 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -133,6 +133,27 @@ export default class DrivePageActions extends PageActions { ) } + /** + * Expect the category to be selected. + */ + expectCategory(category: string) { + return this.step(`Expect category '${category}'`, (page) => + expect(page.getByRole('button', { name: category })).toHaveAttribute('data-selected', 'true'), + ) + } + + /** + * Expect the category to be not selected. + */ + expectCategoryNotSelected(category: string) { + return this.step(`Expect category '${category}' not selected`, (page) => + expect(page.getByRole('button', { name: category })).toHaveAttribute( + 'data-selected', + 'false', + ), + ) + } + /** Actions specific to the Drive table. */ get driveTable() { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -147,6 +168,11 @@ export default class DrivePageActions extends PageActions { .getByLabel(TEXT.sortByModificationDate) .or(page.getByLabel(TEXT.sortByModificationDateDescending)) .or(page.getByLabel(TEXT.stopSortingByModificationDate)) + + const locatePathColumnHeading = (page: Page) => page.getByTestId('path-column-heading') + const locatePathColumnCell = (page: Page, title: string) => + page.getByTestId(`path-column-cell-${title.toLowerCase().replace(/\s+/g, '-')}`) + return { /** Click the column heading for the "name" column to change its sort order. */ clickNameColumnHeading() { @@ -160,6 +186,16 @@ export default class DrivePageActions extends PageActions { callback(locateNameColumnHeading(page), context), ) }, + withPathColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "path" column heading', (page, context) => + callback(locatePathColumnHeading(page), context), + ) + }, + withPathColumnCell(title: string, callback: LocatorCallback) { + return self.step(`Interact with "path" column cell '${title}'`, (page, context) => + callback(locatePathColumnCell(page, title), context), + ) + }, /** Click the column heading for the "modified" column to change its sort order. */ clickModifiedColumnHeading() { return self.step('Click "modified" column heading', (page) => @@ -208,6 +244,11 @@ export default class DrivePageActions extends PageActions { await callback(locateAssetRows(page), locateNonAssetRows(page), self.context, page) }) }, + withSelectedRows(callback: LocatorCallback) { + return self.step('Interact with selected drive table rows', async (page, context) => { + await callback(locateAssetRows(page).and(page.locator('[data-selected="true"]')), context) + }) + }, /** Drag a row onto another row. */ dragRowToRow(from: number, to: number) { return self.step(`Drag drive table row #${from} to row #${to}`, async (page) => { @@ -230,6 +271,28 @@ export default class DrivePageActions extends PageActions { }), ) }, + expandDirectory(index: number) { + return self.step(`Expand drive table row #${index}`, async (page) => { + const expandButton = locateAssetRows(page) + .nth(index) + .getByTestId('directory-row-expand-button') + + await expect(expandButton).toHaveAttribute('aria-label', TEXT.expand) + + await expandButton.click() + }) + }, + collapseDirectory(index: number) { + return self.step(`Collapse drive table row #${index}`, async (page) => { + const collapseButton = locateAssetRows(page) + .nth(index) + .getByTestId('directory-row-expand-button') + + await expect(collapseButton).toHaveAttribute('aria-label', TEXT.collapse) + + return collapseButton.click() + }) + }, /** * A test assertion to confirm that there is only one row visible, and that row is the * placeholder row displayed when there are no assets to show. diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 975fac1e9b39..980a76c3cef3 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -13,6 +13,7 @@ import * as uniqueString from 'enso-common/src/utilities/uniqueString' import * as actions from '.' import type { FeatureFlags } from '#/providers/FeatureFlagsProvider' +import { organizationIdToDirectoryId } from '#/services/RemoteBackend' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -47,9 +48,9 @@ const HTTP_STATUS_NOT_FOUND = 404 /** A user id that is a path glob. */ const GLOB_USER_ID = backend.UserId('*') /** An asset ID that is a path glob. */ -const GLOB_ASSET_ID: backend.AssetId = backend.DirectoryId('*') +const GLOB_ASSET_ID: backend.AssetId = '*' as backend.DirectoryId /** A directory ID that is a path glob. */ -const GLOB_DIRECTORY_ID = backend.DirectoryId('*') +const GLOB_DIRECTORY_ID = '*' as backend.DirectoryId /** A project ID that is a path glob. */ const GLOB_PROJECT_ID = backend.ProjectId('*') /** A tag ID that is a path glob. */ @@ -212,8 +213,39 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly status: backend.CheckoutSessionStatus } >() + usersMap.set(defaultUser.userId, defaultUser) + function getParentPath(parentId: backend.DirectoryId, acc: string[] = []) { + const parent = assetMap.get(parentId) + + if (parent == null) { + return [parentId, ...acc].join('/') + } + + // this should never happen, but we need to check it for a case + invariant(parent.type === backend.AssetType.directory, 'Parent is not a directory') + + return getParentPath(parent.parentId, [parent.id, ...acc]) + } + + function getVirtualParentPath( + parentId: backend.DirectoryId, + _parentTitle: string, + acc: string[] = [], + ) { + const parent = assetMap.get(parentId) + + if (parent == null) { + return acc.join('/') + } + + // this should never happen, but we need to check it for a case + invariant(parent.type === backend.AssetType.directory, 'Parent is not a directory') + + return getVirtualParentPath(parent.parentId, parent.title, [parent.title, ...acc]) + } + function trackCalls() { const calls = structuredClone(INITIAL_CALLS_OBJECT) callsObjects.add(calls) @@ -247,6 +279,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const deleteAsset = (assetId: backend.AssetId) => { const alreadyDeleted = deletedAssets.has(assetId) deletedAssets.add(assetId) + return !alreadyDeleted } @@ -270,7 +303,35 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return updated } + const createUserPermission = ( + user: backend.User, + permission: permissions.PermissionAction = permissions.PermissionAction.own, + rest: Partial = {}, + ): backend.UserPermission => + object.merge( + { + user, + permission, + }, + rest, + ) + + const createUserGroupPermission = ( + userGroup: backend.UserGroupInfo, + permission: permissions.PermissionAction = permissions.PermissionAction.own, + rest: Partial = {}, + ): backend.UserGroupPermission => + object.merge( + { + userGroup, + permission, + }, + rest, + ) + const createDirectory = (rest: Partial = {}): backend.DirectoryAsset => { + const parentId = rest.parentId ?? defaultDirectoryId + const directoryTitles = new Set( assets .filter((asset) => asset.type === backend.AssetType.directory) @@ -279,33 +340,45 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const title = rest.title ?? `New Folder ${directoryTitles.size + 1}` - return object.merge( + const directory = object.merge( { type: backend.AssetType.directory, - id: backend.DirectoryId('directory-' + uniqueString.uniqueString()), + id: backend.DirectoryId(`directory-${uniqueString.uniqueString()}` as const), projectState: null, extension: null, title, modifiedAt: dateTime.toRfc3339(new Date()), description: rest.description ?? '', labels: [], - parentId: defaultDirectoryId, - permissions: [ - { - user: { - organizationId: defaultOrganizationId, - userId: defaultUserId, - name: defaultUsername, - email: defaultEmail, - }, - permission: permissions.PermissionAction.own, - }, - ], + parentId, + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) + + Object.defineProperty(directory, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(directory, 'parentsPath', { + get: () => getParentPath(directory.parentId), + }) + + Object.defineProperty(directory, 'virtualParentsPath', { + get: () => getVirtualParentPath(directory.id, directory.title), + }) + + return directory } const createProject = (rest: Partial = {}): backend.ProjectAsset => { @@ -317,7 +390,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const title = rest.title ?? `New Project ${projectNames.size + 1}` - return object.merge( + const project = object.merge( { type: backend.AssetType.project, id: backend.ProjectId('project-' + uniqueString.uniqueString()), @@ -331,16 +404,37 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) + Object.defineProperty(project, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(project, 'parentsPath', { + get: () => getParentPath(project.parentId), + }) + + Object.defineProperty(project, 'virtualParentsPath', { + get: () => getVirtualParentPath(project.parentId, project.title), + }) + + return project } - const createFile = (rest: Partial = {}): backend.FileAsset => - object.merge( + const createFile = (rest: Partial = {}): backend.FileAsset => { + const file = object.merge( { type: backend.AssetType.file, id: backend.FileId('file-' + uniqueString.uniqueString()), @@ -351,15 +445,38 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, rest, ) - const createSecret = (rest: Partial): backend.SecretAsset => - object.merge( + Object.defineProperty(file, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(file, 'parentsPath', { + get: () => getParentPath(file.parentId), + }) + + Object.defineProperty(file, 'virtualParentsPath', { + get: () => getVirtualParentPath(file.parentId, file.title), + }) + + return file + } + + const createSecret = (rest: Partial): backend.SecretAsset => { + const secret = object.merge( { type: backend.AssetType.secret, id: backend.SecretId('secret-' + uniqueString.uniqueString()), @@ -377,6 +494,29 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { rest, ) + Object.defineProperty(secret, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(secret, 'parentsPath', { + get: () => getParentPath(secret.parentId), + }) + + Object.defineProperty(secret, 'virtualParentsPath', { + get: () => getVirtualParentPath(secret.parentId, secret.title), + }) + + return secret + } + const createLabel = (value: string, color: backend.LChColor): backend.Label => ({ id: backend.TagId('tag-' + uniqueString.uniqueString()), value: backend.LabelName(value), @@ -448,7 +588,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { name, email: backend.EmailAddress(`${name}@example.org`), organizationId, - rootDirectoryId: backend.DirectoryId(organizationId.replace(/^organization-/, 'directory-')), + rootDirectoryId: organizationIdToDirectoryId(organizationId), isEnabled: true, userGroups: null, plan: backend.Plan.enterprise, @@ -473,7 +613,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const addUserGroup = (name: string, rest?: Partial) => { const userGroup: backend.UserGroupInfo = { - id: backend.UserGroupId(`usergroup-${uniqueString.uniqueString()}`), + id: backend.UserGroupId(`usergroup-${uniqueString.uniqueString()}` as const), groupName: name, organizationId: currentOrganization?.id ?? defaultOrganizationId, ...rest, @@ -572,7 +712,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // === Endpoints returning arrays === - await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', (_route, request) => { + await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', (route, request) => { /** The type for the search query for this endpoint. */ interface Query { readonly parent_id?: string @@ -594,7 +734,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { break } case backend.FilterBy.trashed: { - filteredAssets = filteredAssets.filter((asset) => deletedAssets.has(asset.id)) + filteredAssets = assets.filter((asset) => deletedAssets.has(asset.id)) break } case backend.FilterBy.recent: { @@ -616,7 +756,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { ) const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets } - return json + route.fulfill({ json }) }) await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => { called('listFiles', {}) @@ -699,7 +839,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const maybeId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] if (!maybeId) return - const assetId = maybeId != null ? backend.DirectoryId(decodeURIComponent(maybeId)) : null + const assetId = maybeId != null ? (decodeURIComponent(maybeId) as backend.DirectoryId) : null // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. const asset = assetId != null ? assetMap.get(assetId) : null @@ -720,7 +860,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const parentId = body.parentDirectoryId called('copyAsset', { assetId: assetId!, parentId }) // Can be any asset ID. - const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`) + const id = `${assetId?.split('-')[0]}-${uniqueString.uniqueString()}` as backend.DirectoryId + const json: backend.CopyAssetResponse = { asset: { id, @@ -900,7 +1041,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { if (!maybeId) throw new Error('updateAssetPath: Missing asset ID in path') // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - const assetId = backend.DirectoryId(maybeId) + const assetId = maybeId as backend.DirectoryId const body: backend.UpdateAssetRequestBody = request.postDataJSON() called('updateAsset', { ...body, assetId }) @@ -925,7 +1066,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { if (!maybeId) return // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - const assetId = backend.DirectoryId(maybeId) + const assetId = maybeId as backend.DirectoryId /** The type for the JSON request payload for this endpoint. */ interface Body { readonly labels: readonly backend.LabelName[] @@ -949,7 +1090,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { const maybeId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] if (!maybeId) return - const directoryId = backend.DirectoryId(maybeId) + const directoryId = maybeId as backend.DirectoryId const body: backend.UpdateDirectoryRequestBody = request.postDataJSON() called('updateDirectory', { ...body, directoryId }) const asset = assetMap.get(directoryId) @@ -969,12 +1110,17 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] + if (!maybeId) return + // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - const assetId = backend.DirectoryId(decodeURIComponent(maybeId)) + const assetId = decodeURIComponent(maybeId) as backend.DirectoryId + called('deleteAsset', { assetId }) + deleteAsset(assetId) + await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) @@ -1015,10 +1161,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const body: backend.CreateUserRequestBody = await request.postDataJSON() const organizationId = body.organizationId ?? defaultUser.organizationId - const rootDirectoryId = backend.DirectoryId( - organizationId.replace(/^organization-/, 'directory-'), - ) - + const rootDirectoryId = organizationIdToDirectoryId(organizationId) called('createUser', body) currentUser = { @@ -1108,7 +1251,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { called('createProject', body) const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) const parentId = - body.parentDirectoryId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + body.parentDirectoryId ?? + backend.DirectoryId(`directory-${uniqueString.uniqueString()}` as const) const state = { type: backend.ProjectState.closed, volumeId: '' } @@ -1148,26 +1292,14 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { called('createDirectory', body) - const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}` as const) const parentId = body.parentId ?? defaultDirectoryId const directory = addDirectory({ description: null, id, labels: [], - modifiedAt: dateTime.toRfc3339(new Date()), parentId, - permissions: [ - { - user: { - organizationId: defaultOrganizationId, - userId: defaultUserId, - name: defaultUsername, - email: defaultEmail, - }, - permission: permissions.PermissionAction.own, - }, - ], projectState: null, }) @@ -1275,6 +1407,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { deleteUser, addUserGroup, deleteUserGroup, + createUserPermission, + createUserGroupPermission, setFeatureFlags: (flags: Partial) => { return page.addInitScript((flags: Partial) => { const currentOverrideFeatureFlags = diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index b8c1ef7c25cf..f125a3325196 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -61,18 +61,20 @@ export function contextMenuActions, Context>( .click(), ), moveNonFolderToTrash: () => - step('Move to trash (context menu)', (page) => - page + step('Move to trash (context menu)', async (page) => { + await page .getByRole('button', { name: TEXT.moveToTrashShortcut }) .getByText(TEXT.moveToTrashShortcut) - .click(), - ), + .click() + }), moveFolderToTrash: () => step('Move folder to trash (context menu)', async (page) => { await page .getByRole('button', { name: TEXT.moveToTrashShortcut }) .getByText(TEXT.moveToTrashShortcut) .click() + + // Confirm the deletion in the dialog await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() }), moveAllToTrash: () => diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 3be7e5461dbc..e2506c04c0e1 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -117,6 +117,50 @@ test('can drop onto root directory dropzone', ({ page }) => expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) })) +test('can navigate to parent directory of an asset in the Recent category', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({ title: 'a' }) + api.addProject({ title: 'b' }) + + const directory = api.addDirectory({ title: 'd' }) + const subDirectory = api.addDirectory({ title: 'e', parentId: directory.id }) + + api.addProject({ title: 'c', parentId: subDirectory.id }) + }, + }) + .driveTable.expandDirectory(0) + .driveTable.expandDirectory(1) + // Project in the nested directory (c) + .driveTable.rightClickRow(2) + .contextMenu.moveNonFolderToTrash() + // Project in the root (a) + .driveTable.rightClickRow(2) + .contextMenu.moveNonFolderToTrash() + .goToCategory.trash() + .driveTable.withPathColumnCell('a', async (cell) => { + await expect(cell).toBeVisible() + + await cell.getByRole('button').click() + + await expect(cell).not.toBeVisible() + }) + .expectCategory(TEXT.cloudCategory) + .goToCategory.trash() + .driveTable.withPathColumnCell('c', async (cell) => { + await expect(cell).toBeVisible() + + await cell.getByRole('button').click() + + await page.getByTestId('path-column-item-d').click() + }) + .expectCategory(TEXT.cloudCategory) + .driveTable.withSelectedRows(async (rows) => { + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toHaveText(/^d/) + })) + test("can't run a project in browser by default", ({ page }) => mockAllAndLogin({ page, diff --git a/app/gui/package.json b/app/gui/package.json index 4640ab6faa8b..d66afd5df297 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -17,7 +17,9 @@ }, "//": [ "--max-old-space-size=4096 is required when sourcemaps are enabled,", - "otherwise Rollup runs out of memory when Vite is rendering chunks." + "otherwise Rollup runs out of memory when Vite is rendering chunks.", + "ResizeObserver is required for the dashboard tests to work.", + "ResizeObserver is not supported in vitest, so we need to stub it." ], "scripts": { "typecheck": "vue-tsc --noEmit -p tsconfig.app.json", @@ -217,7 +219,8 @@ "vue-react-wrapper": "^0.3.1", "vue-tsc": "^2.0.24", "yaml": "^2.4.5", - "ydoc-server": "workspace:*" + "ydoc-server": "workspace:*", + "resize-observer-polyfill": "1.5.1" }, "overrides": { "@aws-amplify/auth": "../_IGNORED_", diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 46293fc1f2b4..6d04f6c9c6b6 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -50,7 +50,6 @@ import * as inputBindingsModule from '#/configurations/inputBindings' import AuthProvider, * as authProvider from '#/providers/AuthProvider' import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider' -import DriveProvider from '#/providers/DriveProvider' import { useHttpClientStrict } from '#/providers/HttpClientProvider' import InputBindingsProvider from '#/providers/InputBindingsProvider' import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider' @@ -556,18 +555,14 @@ function AppRouter(props: AppRouterProps) { onAuthenticated={onAuthenticated} > - {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here - * due to modals being in `TheModal`. */} - - - - {routes} - - - - - - + + + {routes} + + + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index 4a3d8dc5a59a..dcc52b9aaaef 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -46,7 +46,12 @@ export interface BaseButtonProps readonly tooltip?: ReactElement | string | false | null readonly tooltipPlacement?: aria.Placement /** The icon to display in the button */ - readonly icon?: ReactElement | string | ((render: Render) => ReactElement | string | null) | null + readonly icon?: + | ReactElement + | string + | ((render: Render) => ReactElement | string | null) + | null + | undefined /** When `true`, icon will be shown only when hovered. */ readonly showIconOnHover?: boolean /** @@ -163,7 +168,7 @@ export const BUTTON_STYLES = tv({ color: 'custom', weight: 'medium', disableLineHeightCompensation: true, - className: 'flex px-[5px] pt-[0.5px] pb-[2.5px]', + className: 'flex px-[5px] pt-[1px] pb-[2px]', }), icon: '-mb-0.5 h-3 w-3', content: 'gap-1', @@ -289,12 +294,13 @@ export const BUTTON_STYLES = tv({ }, compoundVariants: [ { isFocused: true, iconOnly: true, class: 'focus-visible:outline-offset-[3px]' }, + { size: 'custom', iconOnly: true, class: { icon: 'w-full h-full' } }, { size: 'xxsmall', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-2.5 h-2.5' } }, { size: 'xsmall', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-3 h-3' } }, { size: 'small', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-3.5 h-3.5' } }, { size: 'medium', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4 h-4' } }, - { size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4.5 h-4.5' } }, + { size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-5 h-5' } }, { size: 'hero', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-12 h-12' } }, { size: 'xsmall', class: { addonStart: '-ml-[3.5px]', addonEnd: '-mr-[3.5px]' } }, @@ -318,197 +324,200 @@ export const BUTTON_STYLES = tv({ const ICON_LOADER_DELAY = 150 /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ -export const Button = memo( - forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { - const { - className, - contentClassName, - children, - variant, - icon, - loading = false, - isActive, - showIconOnHover, - iconPosition, - size, - fullWidth, - rounded, - tooltip, - tooltipPlacement, - testId, - loaderPosition = 'full', - extraClickZone: extraClickZoneProp, - onPress = () => {}, - variants = BUTTON_STYLES, - addonStart, - addonEnd, - ...ariaProps - } = props - - const [implicitlyLoading, setImplicitlyLoading] = useState(false) - - const contentRef = useRef(null) - const loaderRef = useRef(null) - - const isLink = ariaProps.href != null - - const Tag = isLink ? aria.Link : aria.Button - - const goodDefaults = { - ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), - 'data-testid': testId, - } +// Manually casting types to make TS infer the final type correctly (e.g. RenderProps in icon) +export const Button: (props: ButtonProps & { ref?: ForwardedRef }) => ReactNode = + memo( + forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { + const { + className, + contentClassName, + children, + variant, + icon, + loading = false, + isActive, + showIconOnHover, + iconPosition, + size, + fullWidth, + rounded, + tooltip, + tooltipPlacement, + testId, + loaderPosition = 'full', + extraClickZone: extraClickZoneProp, + onPress = () => {}, + variants = BUTTON_STYLES, + addonStart, + addonEnd, + ...ariaProps + } = props + + const [implicitlyLoading, setImplicitlyLoading] = useState(false) + + const contentRef = useRef(null) + const loaderRef = useRef(null) + + const isLink = ariaProps.href != null + + const Tag = isLink ? aria.Link : aria.Button + + const goodDefaults = { + ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), + 'data-testid': testId, + } - const isIconOnly = (children == null || children === '' || children === false) && icon != null + const isIconOnly = (children == null || children === '' || children === false) && icon != null - const shouldShowTooltip = (() => { - if (tooltip === false) { - return false - } else if (isIconOnly) { - return true - } else { - return tooltip != null - } - })() - - const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null - - const isLoading = loading || implicitlyLoading - const isDisabled = props.isDisabled ?? isLoading - const shouldUseVisualTooltip = shouldShowTooltip && isDisabled - const extraClickZone = extraClickZoneProp ?? variant === 'icon' - - useLayoutEffect(() => { - const delay = ICON_LOADER_DELAY - - if (isLoading) { - const loaderAnimation = loaderRef.current?.animate( - [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], - { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, - ) - const contentAnimation = - loaderPosition !== 'full' ? null : ( - contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 0, - easing: 'linear', - delay, - fill: 'forwards', - }) - ) + const shouldShowTooltip = (() => { + if (tooltip === false) { + return false + } else if (isIconOnly) { + return true + } else { + return tooltip != null + } + })() + + const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null + + const isLoading = loading || implicitlyLoading + const isDisabled = props.isDisabled ?? isLoading + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled + const extraClickZone = extraClickZoneProp ?? variant === 'icon' + + useLayoutEffect(() => { + const delay = ICON_LOADER_DELAY - return () => { - loaderAnimation?.cancel() - contentAnimation?.cancel() + if (isLoading) { + const loaderAnimation = loaderRef.current?.animate( + [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], + { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, + ) + const contentAnimation = + loaderPosition !== 'full' ? null : ( + contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 0, + easing: 'linear', + delay, + fill: 'forwards', + }) + ) + + return () => { + loaderAnimation?.cancel() + contentAnimation?.cancel() + } + } else { + return () => {} } - } else { - return () => {} - } - }, [isLoading, loaderPosition]) + }, [isLoading, loaderPosition]) - const handlePress = useEventCallback((event: aria.PressEvent): void => { - if (!isDisabled) { - const result = onPress?.(event) + const handlePress = useEventCallback((event: aria.PressEvent): void => { + if (!isDisabled) { + const result = onPress?.(event) - if (result instanceof Promise) { - setImplicitlyLoading(true) + if (result instanceof Promise) { + setImplicitlyLoading(true) - void result.finally(() => { - setImplicitlyLoading(false) - }) + void result.finally(() => { + setImplicitlyLoading(false) + }) + } } - } - }) - - const styles = variants({ - isDisabled, - isActive, - loading: isLoading, - fullWidth, - size, - rounded, - variant, - iconPosition, - showIconOnHover, - extraClickZone, - iconOnly: isIconOnly, - }) - - const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ - targetRef: contentRef, - children: tooltipElement, - isDisabled: !shouldUseVisualTooltip, - ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), - }) - - const button = ( - ()(goodDefaults, ariaProps, { - isDisabled, - // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger - // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered - onPressEnd: (e) => { - if (!isDisabled) { - handlePress(e) - } - }, - className: aria.composeRenderProps(className, (classNames, states) => - styles.base({ className: classNames, ...states }), - ), - })} - > - {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( - - - ()(goodDefaults, ariaProps, { + isDisabled, + // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger + // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered + onPressEnd: (e) => { + if (!isDisabled) { + handlePress(e) + } + }, + className: aria.composeRenderProps(className, (classNames, states) => + styles.base({ className: classNames, ...states }), + ), + })} + > + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( + + - {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} - {typeof children === 'function' ? children(render) : children} - - - - {isLoading && loaderPosition === 'full' && ( - - + + {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} + {typeof children === 'function' ? children(render) : children} + - )} - {shouldShowTooltip && visualTooltip} - - )} - - ) + {isLoading && loaderPosition === 'full' && ( + + + + )} - if (tooltipElement == null) { - return button - } + {shouldShowTooltip && visualTooltip} + + )} + + ) - return ( - - {button} + if (tooltipElement == null) { + return button + } - - {tooltipElement} - - - ) - }), -) + return ( + + {button} + + + {tooltipElement} + + + ) + }), + ) /** * Props for {@link ButtonContent}. @@ -517,7 +526,7 @@ interface ButtonContentProps { readonly isIconOnly: boolean readonly isLoading: boolean readonly loaderPosition: 'full' | 'icon' - readonly icon: ButtonProps['icon'] + readonly icon: ReactElement | string | null | undefined readonly styles: ReturnType readonly children: ReactNode readonly addonStart?: ReactElement | string | false | null | undefined @@ -567,7 +576,7 @@ const ButtonContent = memo(function ButtonContent(props: ButtonContentProps) { interface IconProps { readonly isLoading: boolean readonly loaderPosition: 'full' | 'icon' - readonly icon: ButtonProps['icon'] + readonly icon: ReactElement | string | null | undefined readonly styles: ReturnType } @@ -600,13 +609,9 @@ const Icon = memo(function Icon(props: IconProps) { } const actualIcon = (() => { - /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */ - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const iconRender = typeof icon === 'function' ? icon(render) : icon - - return typeof iconRender === 'string' ? - - : {iconRender} + return typeof icon === 'string' ? + + : {icon} })() if (shouldShowLoader) { diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx index 1a60155914c0..11bb5d822aca 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx @@ -8,7 +8,7 @@ import * as twv from '#/utilities/tailwindVariants' // ================= const STYLES = twv.tv({ - base: 'flex flex-1 shrink-0', + base: 'flex flex-1 shrink-0 max-h-max', variants: { wrap: { true: 'flex-wrap' }, direction: { column: 'flex-col', row: 'flex-row' }, diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx index 529e5072b9ee..4e3258921ae6 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx @@ -16,6 +16,7 @@ export type CloseButtonProps = Omit twMerge( 'hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1', isOnMacOS() ? 'bg-primary/30' : ( 'text-primary/90 hover:text-primary focus-visible:text-primary' ), - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + // @ts-expect-error TypeScript fails to infer the type of the `className` prop + // But it's safe because we are passing all values transparently + // and they are typed outside. typeof className === 'function' ? className(values) : className, ) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index 20dcd8cfe06d..802243bb6fb1 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -20,10 +20,10 @@ import { tv } from '#/utilities/tailwindVariants' import { Close } from './Close' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' +import { DialogTrigger } from './DialogTrigger' import type * as types from './types' import * as utlities from './utilities' import { DIALOG_BACKGROUND } from './variants' - // eslint-disable-next-line no-restricted-syntax const MotionDialog = motion(aria.Dialog) @@ -551,3 +551,4 @@ const DialogHeader = React.memo(function DialogHeader(props: DialogHeaderProps) }) Dialog.Close = Close +Dialog.Trigger = DialogTrigger diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx index 7c855b0fc47a..6032bcd0e247 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogTrigger.tsx @@ -20,7 +20,7 @@ export interface DialogTriggerRenderProps { export interface DialogTriggerProps extends Omit { /** The trigger element. */ readonly children: [ - React.ReactElement, + React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement), React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement), ] readonly onOpen?: () => void @@ -68,7 +68,7 @@ export function DialogTrigger(props: DialogTriggerProps) { return ( - {trigger} + {typeof trigger === 'function' ? trigger(renderProps) : trigger} {typeof dialog === 'function' ? dialog(renderProps) : dialog} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx index 59427fa6ac29..1a4abbfc9d04 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx @@ -15,6 +15,7 @@ import * as twv from '#/utilities/tailwindVariants' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' +import { DialogTrigger } from './DialogTrigger' import * as utlities from './utilities' import * as variants from './variants' @@ -31,6 +32,11 @@ export interface PopoverProps export const POPOVER_STYLES = twv.tv({ base: 'shadow-xl w-full overflow-clip', variants: { + variant: { + custom: { dialog: '' }, + primary: { dialog: variants.DIALOG_BACKGROUND({ variant: 'light' }) }, + inverted: { dialog: variants.DIALOG_BACKGROUND({ variant: 'dark' }) }, + }, isEntering: { true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200', }, @@ -38,12 +44,12 @@ export const POPOVER_STYLES = twv.tv({ true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150', }, size: { - auto: { base: 'w-[unset]', dialog: 'p-2.5' }, - xxsmall: { base: 'max-w-[206px]', dialog: 'p-2' }, - xsmall: { base: 'max-w-xs', dialog: 'p-2.5' }, - small: { base: 'max-w-sm', dialog: 'p-3.5' }, - medium: { base: 'max-w-md', dialog: 'p-3.5' }, - large: { base: 'max-w-lg', dialog: 'px-4 py-4' }, + auto: { base: 'w-[unset]', dialog: 'p-2.5 px-0' }, + xxsmall: { base: 'max-w-[206px]', dialog: 'p-2 px-0' }, + xsmall: { base: 'max-w-xs', dialog: 'p-2.5 px-0' }, + small: { base: 'max-w-sm', dialog: 'py-3 px-2' }, + medium: { base: 'max-w-md', dialog: 'p-3.5 px-2.5' }, + large: { base: 'max-w-lg', dialog: 'px-4 py-3' }, hero: { base: 'max-w-xl', dialog: 'px-6 py-5' }, }, rounded: { @@ -58,9 +64,9 @@ export const POPOVER_STYLES = twv.tv({ }, }, slots: { - dialog: variants.DIALOG_BACKGROUND({ class: 'flex-auto overflow-y-auto max-h-[inherit]' }), + dialog: 'flex-auto overflow-y-auto [scrollbar-gutter:stable_both-edges] max-h-[inherit]', }, - defaultVariants: { rounded: 'xxxlarge', size: 'small' }, + defaultVariants: { rounded: 'xxxlarge', size: 'small', variant: 'primary' }, }) const SUSPENSE_LOADER_PROPS = { minHeight: 'h32' } as const @@ -75,6 +81,7 @@ export function Popover(props: PopoverProps) { className, size, rounded, + variant, placement = 'bottom start', isDismissable = true, ...ariaPopoverProps @@ -93,8 +100,10 @@ export function Popover(props: PopoverProps) { isExiting: values.isExiting, size, rounded, + variant, + }).base({ className: typeof className === 'function' ? className(values) : className, - }).base() + }) } UNSTABLE_portalContainer={root} placement={placement} @@ -109,6 +118,7 @@ export function Popover(props: PopoverProps) { rounded={rounded} opts={opts} isDismissable={isDismissable} + variant={variant} > {children} @@ -127,13 +137,14 @@ interface PopoverContentProps { readonly opts: aria.PopoverRenderProps readonly popoverRef: React.RefObject readonly isDismissable: boolean + readonly variant: PopoverProps['variant'] } /** * The content of a popover. */ function PopoverContent(props: PopoverContentProps) { - const { children, size, rounded, opts, isDismissable, popoverRef } = props + const { children, size, rounded, opts, isDismissable, popoverRef, variant } = props const dialogRef = React.useRef(null) const dialogId = aria.useId() @@ -179,7 +190,12 @@ function PopoverContent(props: PopoverContentProps) { role="dialog" aria-labelledby={labelledBy} tabIndex={-1} - className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()} + className={POPOVER_STYLES({ + ...opts, + size, + rounded, + variant, + }).dialog()} > @@ -192,3 +208,5 @@ function PopoverContent(props: PopoverContentProps) { ) } + +Popover.Trigger = DialogTrigger diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx index 51a071b3b2ef..a139f664e7f2 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx @@ -48,9 +48,7 @@ export function Reset(props: ResetProps): React.JSX.Element { form.reset() return onPress?.(event) }} - /* This is safe because we are passing all props to the button */ - /* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */ - {...(buttonProps as any)} + {...buttonProps} /> ) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx index 927d8333bf9f..f8f782e77d61 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Text/Text.tsx @@ -36,7 +36,7 @@ export const TEXT_STYLE = twv.tv({ primary: 'text-primary', danger: 'text-danger', success: 'text-accent-dark', - disabled: 'text-primary/30', + disabled: 'text-disabled', invert: 'text-invert', inherit: 'text-inherit', current: 'text-current', diff --git a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx index fc267d8a569a..f900bc5cb7b0 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx @@ -1,27 +1,27 @@ /** @file Displays the description of an element on hover or focus. */ import * as aria from '#/components/aria' -import * as portal from '#/components/Portal' +import { useStrictPortalContext } from '#/components/Portal' -import * as twv from '#/utilities/tailwindVariants' +import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { DIALOG_BACKGROUND } from '../Dialog' -import * as text from '../Text' +import { TEXT_STYLE } from '../Text' // ================= // === Constants === // ================= -export const TOOLTIP_STYLES = twv.tv({ - base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere]', +export const TOOLTIP_STYLES = tv({ + base: 'group flex justify-center items-center text-center [overflow-wrap:anywhere]', variants: { variant: { custom: '', - primary: DIALOG_BACKGROUND({ variant: 'dark', className: 'text-white/80' }), + primary: DIALOG_BACKGROUND({ variant: 'dark', className: 'text-invert' }), inverted: DIALOG_BACKGROUND({ variant: 'light', className: 'text-primary' }), }, size: { custom: '', - medium: text.TEXT_STYLE({ className: 'px-2 py-1', color: 'custom', balance: true }), + medium: TEXT_STYLE({ className: 'px-2 py-1', color: 'custom', balance: true }), }, rounded: { custom: '', @@ -67,7 +67,7 @@ const DEFAULT_OFFSET = 9 /** Props for a {@link Tooltip}. */ export interface TooltipProps extends Omit, 'offset' | 'UNSTABLE_portalContainer'>, - Omit, 'isEntering' | 'isExiting'> {} + Omit, 'isEntering' | 'isExiting'> {} /** Displays the description of an element on hover or focus. */ export function Tooltip(props: TooltipProps) { @@ -75,9 +75,12 @@ export function Tooltip(props: TooltipProps) { className, containerPadding = DEFAULT_CONTAINER_PADDING, variant, + size, + rounded, ...ariaTooltipProps } = props - const root = portal.useStrictPortalContext() + + const root = useStrictPortalContext() return ( - TOOLTIP_STYLES({ className: classNames, variant, ...values }), + TOOLTIP_STYLES({ className: classNames, variant, size, rounded, ...values }), )} data-ignore-click-outside {...ariaTooltipProps} @@ -96,3 +99,5 @@ export function Tooltip(props: TooltipProps) { // Re-export the TooltipTrigger component from `react-aria-components` // eslint-disable-next-line no-restricted-syntax export const TooltipTrigger = aria.TooltipTrigger + +Tooltip.Trigger = TooltipTrigger diff --git a/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx b/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx index a1ac66cb7520..1e35ced060ce 100644 --- a/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx +++ b/app/gui/src/dashboard/components/Devtools/EnsoDevtools.tsx @@ -83,15 +83,16 @@ export function EnsoDevtools() { return ( - + + + diff --git a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx index 5ca7b0fc97a3..b197f53c31ab 100644 --- a/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/DirectoryNameColumn.tsx @@ -9,13 +9,12 @@ import { backendMutationOptions } from '#/hooks/backendHooks' import { useDriveStore, useToggleDirectoryExpansion } from '#/providers/DriveProvider' import * as textProvider from '#/providers/TextProvider' -import * as ariaComponents from '#/components/AriaComponents' import type * as column from '#/components/dashboard/column' import EditableSpan from '#/components/EditableSpan' -import SvgMask from '#/components/SvgMask' import * as backendModule from '#/services/Backend' +import { Button } from '#/components/AriaComponents' import { useStore } from '#/hooks/storeHooks' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' @@ -86,21 +85,23 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { } }} > - (isHovered || isExpanded ? FolderArrowIcon : FolderIcon)} size="medium" - variant="custom" + variant="icon" aria-label={isExpanded ? getText('collapse') : getText('expand')} tooltipPlacement="left" + data-testid="directory-row-expand-button" + data-expanded={isExpanded} className={tailwindMerge.twJoin( - 'm-0 hidden cursor-pointer border-0 transition-transform duration-arrow group-hover:m-name-column-icon group-hover:inline-block', + 'mx-1 transition-transform duration-arrow', isExpanded && 'rotate-90', )} onPress={() => { toggleDirectoryExpansion(item.id) }} /> - + { + const targetDirectoryIndex = finalPath.findIndex(({ id }) => id === targetDirectory) + + if (targetDirectoryIndex === -1) { + return + } + + const pathToDirectory = finalPath + .slice(0, targetDirectoryIndex + 1) + .map(({ id, categoryId }) => ({ id, categoryId })) + + const rootDirectoryInThePath = pathToDirectory.at(0) + + // This should never happen, as we always have the root directory in the path. + // If it happens, it means you've skrewed up + invariant(rootDirectoryInThePath != null, 'Root directory id is null') + + // If the target directory is null, we assume that this directory is outside of the current tree (in another category) + // Which is the default, because the path path displays in the recent and trash folders. + // But sometimes the user might delete a directory with its whole content, and in that case we'll find it in the tree + // because the parent is always fetched before its children. + const targetDirectoryNode = getAssetNodeById(targetDirectory) + + if (targetDirectoryNode == null && rootDirectoryInThePath.categoryId != null) { + // We reassign the variable only to make TypeScript happy here. + const categoryId = rootDirectoryInThePath.categoryId + + setCategory(categoryId) + setExpandedDirectoryIds(pathToDirectory.map(({ id }) => id).concat(targetDirectory)) + } + + setSelectedKeys(new Set([targetDirectory])) + }) + + const finalPath = (() => { + const result: { + id: DirectoryId + categoryId: AnyCloudCategory['id'] | null + label: AnyCloudCategory['label'] + icon: AnyCloudCategory['icon'] + }[] = [] + + if (rootDirectoryInPath == null) { + return result + } + + const rootCategory = getCategoryByDirectoryId(rootDirectoryInPath) + + // If the root category is not found it might mean + // that user is no longer have access to this root directory. + // Usually this could happen if the user was removed from the organization + // or user group. + // This shouldn't happen though and these files should be filtered out + // by the backend. But we need to handle this case anyway. + if (rootCategory == null) { + return result + } + + result.push({ + id: rootDirectoryId, + categoryId: rootCategory.id, + label: rootCategory.label, + icon: rootCategory.icon, + }) + + for (const [index, id] of virtualParentsIds.entries()) { + const name = splitVirtualParentsPath.at(index) + + if (name == null) { + continue + } + + result.push({ + id, + label: name, + icon: FolderIcon, + categoryId: null, + }) + } + + return result + })() + + if (finalPath.length === 0) { + return <> + } + + const firstItemInPath = finalPath.at(0) + const lastItemInPath = finalPath.at(-1) + + // Should not happen, as we ensure that the final path is not empty. + if (lastItemInPath == null || firstItemInPath == null) { + return <> + } + + // This also means that the first and the last item in the path are the same + if (finalPath.length === 1) { + return ( +
+ +
+ ) + } + + return ( +
+ + + + +
+ {finalPath.map((entry, index) => ( + + + + {index < finalPath.length - 1 && ( + + )} + + ))} +
+
+
+
+ ) +} + +/** + * Props for the {@link PathItem} component. + */ +interface PathItemProps { + readonly id: DirectoryId + readonly label: AnyCloudCategory['label'] + readonly icon: AnyCloudCategory['icon'] + readonly onNavigate: (targetDirectory: DirectoryId) => void +} + +/** + * Individual item in the path. + */ +function PathItem(props: PathItemProps) { + const { id, label, icon, onNavigate } = props + const [transition, startTransition] = useTransition() + + const onPress = useEventCallback(() => { + startTransition(() => { + onNavigate(id) + }) + }) + + return ( + + ) +} diff --git a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts index 6e5cbe3ea15e..68e1cbf2c80a 100644 --- a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts +++ b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts @@ -1,6 +1,8 @@ /** @file Types and constants related to `Column`s. */ import type * as text from 'enso-common/src/text' +import DirectoryIcon from '#/assets/folder.svg' + import AccessedByProjectsIcon from '#/assets/accessed_by_projects.svg' import AccessedDataIcon from '#/assets/accessed_data.svg' import BlankIcon from '#/assets/blank.svg' @@ -22,6 +24,7 @@ export enum Column { modified = 'modified', sharedWith = 'sharedWith', labels = 'labels', + path = 'path', accessedByProjects = 'accessedByProjects', accessedData = 'accessedData', docs = 'docs', @@ -39,6 +42,7 @@ export const DEFAULT_ENABLED_COLUMNS: ReadonlySet = new Set([ Column.modified, Column.sharedWith, Column.labels, + Column.path, ]) export const COLUMN_ICONS: Readonly> = { @@ -51,6 +55,7 @@ export const COLUMN_ICONS: Readonly> = { [Column.accessedByProjects]: AccessedByProjectsIcon, [Column.accessedData]: AccessedDataIcon, [Column.docs]: DocsIcon, + [Column.path]: DirectoryIcon, } export const COLUMN_SHOW_TEXT_ID: Readonly> = { @@ -61,6 +66,7 @@ export const COLUMN_SHOW_TEXT_ID: Readonly> = { [Column.accessedByProjects]: 'accessedByProjectsColumnShow', [Column.accessedData]: 'accessedDataColumnShow', [Column.docs]: 'docsColumnShow', + [Column.path]: 'pathColumnShow', } satisfies { [C in Column]: `${C}ColumnShow` } const COLUMN_CSS_CLASSES = @@ -76,6 +82,7 @@ export const COLUMN_CSS_CLASS: Readonly> = { [Column.accessedByProjects]: `min-w-drive-accessed-by-projects-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, [Column.accessedData]: `min-w-drive-accessed-data-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, [Column.docs]: `min-w-drive-docs-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, + [Column.path]: `min-w-drive-path-column rounded-rows-have-level ${NORMAL_COLUMN_CSS_CLASSES}`, } // ===================== @@ -102,10 +109,18 @@ export function getColumnList( return isCloud && isEnterprise && Column.sharedWith } + const pathColumn = () => { + if (isTrash) return Column.path + if (isRecent) return Column.path + + return false + } + const columns = [ Column.name, Column.modified, sharedWithColumn(), + pathColumn(), isCloud && Column.labels, // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1525 // Bring back these columns when they are ready for use again. diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading.ts b/app/gui/src/dashboard/components/dashboard/columnHeading.ts index 2090dbfe3cbf..cba798a5b891 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading.ts +++ b/app/gui/src/dashboard/components/dashboard/columnHeading.ts @@ -1,14 +1,15 @@ /** @file A lookup containing a component for the corresponding heading for each column type. */ -import type * as column from '#/components/dashboard/column' -import * as columnUtils from '#/components/dashboard/column/columnUtils' -import AccessedByProjectsColumnHeading from '#/components/dashboard/columnHeading/AccessedByProjectsColumnHeading' -import AccessedDataColumnHeading from '#/components/dashboard/columnHeading/AccessedDataColumnHeading' -import DocsColumnHeading from '#/components/dashboard/columnHeading/DocsColumnHeading' -import LabelsColumnHeading from '#/components/dashboard/columnHeading/LabelsColumnHeading' -import ModifiedColumnHeading from '#/components/dashboard/columnHeading/ModifiedColumnHeading' -import NameColumnHeading from '#/components/dashboard/columnHeading/NameColumnHeading' -import SharedWithColumnHeading from '#/components/dashboard/columnHeading/SharedWithColumnHeading' import { memo } from 'react' +import type * as column from './column' +import * as columnUtils from './column/columnUtils' +import AccessedByProjectsColumnHeading from './columnHeading/AccessedByProjectsColumnHeading' +import AccessedDataColumnHeading from './columnHeading/AccessedDataColumnHeading' +import DocsColumnHeading from './columnHeading/DocsColumnHeading' +import LabelsColumnHeading from './columnHeading/LabelsColumnHeading' +import ModifiedColumnHeading from './columnHeading/ModifiedColumnHeading' +import NameColumnHeading from './columnHeading/NameColumnHeading' +import PathColumnHeading from './columnHeading/PathColumnHeading' +import SharedWithColumnHeading from './columnHeading/SharedWithColumnHeading' export const COLUMN_HEADING: Readonly< Record< @@ -23,4 +24,5 @@ export const COLUMN_HEADING: Readonly< [columnUtils.Column.accessedByProjects]: memo(AccessedByProjectsColumnHeading), [columnUtils.Column.accessedData]: memo(AccessedDataColumnHeading), [columnUtils.Column.docs]: memo(DocsColumnHeading), + [columnUtils.Column.path]: memo(PathColumnHeading), } diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx index 2a5f2776b2d1..b1d5b81fcc42 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx @@ -24,7 +24,9 @@ export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadin tooltip={false} onPress={hideThisColumn} /> - {getText('accessedByProjectsColumnName')} + + {getText('accessedByProjectsColumnName')} + ) } diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx index a3c58c4835f6..57c3f87f65eb 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx @@ -24,7 +24,9 @@ export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps tooltip={false} onPress={hideThisColumn} /> - {getText('accessedDataColumnName')} + + {getText('accessedDataColumnName')} + ) } diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/DocsColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/DocsColumnHeading.tsx index 724f94393f25..a277db645c55 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/DocsColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/DocsColumnHeading.tsx @@ -16,7 +16,7 @@ export default function DocsColumnHeading(props: AssetColumnHeadingProps) { }) return ( -
+
) } diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx index 9fe622c890d8..c80304938281 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx @@ -17,7 +17,7 @@ export default function LabelsColumnHeading(props: AssetColumnHeadingProps) { }) return ( -
+
) } diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/ModifiedColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/ModifiedColumnHeading.tsx index 4dc472be7748..ec34eb791320 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/ModifiedColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/ModifiedColumnHeading.tsx @@ -1,8 +1,7 @@ /** @file A heading for the "Modified" column. */ import SortAscendingIcon from '#/assets/sort_ascending.svg' import TimeIcon from '#/assets/time.svg' -import { Text } from '#/components/aria' -import { Button } from '#/components/AriaComponents' +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' @@ -52,7 +51,7 @@ export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
+ ) +} diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/SharedWithColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/SharedWithColumnHeading.tsx index 5ad529e0009c..b6e4a82e3af5 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/SharedWithColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/SharedWithColumnHeading.tsx @@ -17,7 +17,7 @@ export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) }) return ( -
+
) diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index f4c940213b40..19679729b066 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -10,6 +10,8 @@ import { useSuspenseQuery, type Mutation, type MutationKey, + type QueryKey, + type UnusedSkipTokenOptions, type UseMutationOptions, type UseQueryOptions, type UseQueryResult, @@ -124,14 +126,24 @@ export function backendQueryOptions( args: Parameters, options?: Omit>>, 'queryFn' | 'queryKey'> & Partial>>, 'queryKey'>>, -): UseQueryOptions>> +): UnusedSkipTokenOptions< + Awaited>, + Error, + Awaited>, + QueryKey +> export function backendQueryOptions( backend: Backend | null, method: Method, args: Parameters, options?: Omit>>, 'queryFn' | 'queryKey'> & Partial>>, 'queryKey'>>, -): UseQueryOptions> | undefined> +): UnusedSkipTokenOptions< + Awaited | undefined>, + Error, + Awaited | undefined>, + QueryKey +> /** Wrap a backend method call in a React Query. */ export function backendQueryOptions( backend: Backend | null, diff --git a/app/gui/src/dashboard/hooks/storeHooks.ts b/app/gui/src/dashboard/hooks/storeHooks.ts index da9298e84978..e62d0bd8a546 100644 --- a/app/gui/src/dashboard/hooks/storeHooks.ts +++ b/app/gui/src/dashboard/hooks/storeHooks.ts @@ -7,6 +7,7 @@ import type { DispatchWithoutAction, Reducer, RefObject } from 'react' import { useEffect, useReducer, useRef } from 'react' import { type StoreApi } from 'zustand' import { useStoreWithEqualityFn } from 'zustand/traditional' + import { objectEquality, refEquality, shallowEquality } from '../utilities/equalities' /** diff --git a/app/gui/src/dashboard/layouts/AssetVersions/useAssetVersions.ts b/app/gui/src/dashboard/layouts/AssetVersions/useAssetVersions.ts index 69483f6b2f2d..e193abbe5b60 100644 --- a/app/gui/src/dashboard/layouts/AssetVersions/useAssetVersions.ts +++ b/app/gui/src/dashboard/layouts/AssetVersions/useAssetVersions.ts @@ -7,7 +7,7 @@ import type { AssetId } from '#/services/Backend' import { queryOptions, useQuery } from '@tanstack/react-query' /** - * + * Options for {@link useAssetVersions}. */ export interface AssetVersionsQueryOptions { readonly assetId: AssetId diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index e99945387caa..2b1e2fc59144 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -284,6 +284,7 @@ export interface AssetsTableState { readonly doDelete: (item: AnyAsset, forever: boolean) => Promise readonly doRestore: (item: AnyAsset) => Promise readonly doMove: (newParentKey: DirectoryId, item: AnyAsset) => Promise + readonly getAssetNodeById: (id: AssetId) => AnyAssetTreeNode | null } /** Data associated with a {@link AssetRow}, used for rendering. */ @@ -1322,6 +1323,10 @@ function AssetsTable(props: AssetsTableProps) { } } + const getAssetNodeById = useEventCallback( + (id: AssetId) => assetTree.preorderTraversal().find((node) => node.key === id) ?? null, + ) + const state = useMemo( // The type MUST be here to trigger excess property errors at typecheck time. () => ({ @@ -1341,6 +1346,7 @@ function AssetsTable(props: AssetsTableProps) { doDelete, doRestore, doMove, + getAssetNodeById, }), [ backend, @@ -1356,6 +1362,7 @@ function AssetsTable(props: AssetsTableProps) { doMove, hideColumn, setQuery, + getAssetNodeById, ], ) @@ -1902,111 +1909,116 @@ function AssetsTable(props: AssetsTableProps) {
) - return !isCloud && didLoadingProjectManagerFail ? + if (!isCloud && didLoadingProjectManagerFail) { + return ( - :
-
- - {(columnsBarProps) => ( -
()(columnsBarProps, { - className: 'inline-flex gap-icons', - onFocus: () => { - setKeyboardSelectedIndex(null) - }, - })} - > - {hiddenColumns.map((column) => ( - - ))} -
- )} -
-
+ ) + } - - {(innerProps) => ( - -
()(innerProps, { - className: - 'flex-1 overflow-auto container-size w-full h-full scroll-p-24 scroll-smooth', - onKeyDown, - onBlur: (event) => { - if ( - event.relatedTarget instanceof HTMLElement && - !event.currentTarget.contains(event.relatedTarget) - ) { - setKeyboardSelectedIndex(null) - } - }, - onDragEnter: updateIsDraggingFiles, - onDragOver: updateIsDraggingFiles, - onDragLeave: (event) => { - if ( - !(event.relatedTarget instanceof Node) || - !event.currentTarget.contains(event.relatedTarget) - ) { - lastSelectedIdsRef.current = null - } - }, - onDragEnd: () => { - setIsDraggingFiles(false) - }, - ref: rootRef, - })} - > - {!hidden && hiddenContextMenu} - {!hidden && ( - - )} -
-
- {table} - -
-
-
-
+ return ( +
+
+ + {(columnsBarProps) => ( +
()(columnsBarProps, { + className: 'inline-flex gap-icons', + onFocus: () => { + setKeyboardSelectedIndex(null) + }, + })} + > + {hiddenColumns.map((column) => ( + + ))} +
)}
+
- {isDraggingFiles && !isMainDropzoneVisible && ( -
+ + {(innerProps) => ( +
{ - setIsDraggingFiles(false) - }} - onDrop={(event) => { - handleFileDrop(event) - }} + {...mergeProps()(innerProps, { + className: + 'flex-1 overflow-auto container-size w-full h-full scroll-p-24 scroll-smooth', + onKeyDown, + onBlur: (event) => { + if ( + event.relatedTarget instanceof HTMLElement && + !event.currentTarget.contains(event.relatedTarget) + ) { + setKeyboardSelectedIndex(null) + } + }, + onDragEnter: updateIsDraggingFiles, + onDragOver: updateIsDraggingFiles, + onDragLeave: (event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + lastSelectedIdsRef.current = null + } + }, + onDragEnd: () => { + setIsDraggingFiles(false) + }, + ref: rootRef, + })} > - - {dropzoneText} + {!hidden && hiddenContextMenu} + {!hidden && ( + + )} +
+
+ {table} + +
+
-
+ )} -
+
+ {isDraggingFiles && !isMainDropzoneVisible && ( +
+
{ + setIsDraggingFiles(false) + }} + onDrop={(event) => { + handleFileDrop(event) + }} + > + + {dropzoneText} +
+
+ )} +
+ ) } /** @@ -2044,6 +2056,7 @@ const HiddenColumn = memo(function HiddenColumn(props: HiddenColumnProps) { icon={COLUMN_ICONS[column]} aria-label={getText(COLUMN_SHOW_TEXT_ID[column])} onPress={onPress} + className="opacity-50" /> ) }) diff --git a/app/gui/src/dashboard/layouts/CategorySwitcher.tsx b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx index b1aaa138b6c6..8ed9642523d3 100644 --- a/app/gui/src/dashboard/layouts/CategorySwitcher.tsx +++ b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx @@ -2,24 +2,15 @@ import * as React from 'react' import { useSearchParams } from 'react-router-dom' -import * as z from 'zod' import { SEARCH_PARAMS_PREFIX } from '#/appUtils' -import CloudIcon from '#/assets/cloud.svg' -import ComputerIcon from '#/assets/computer.svg' import FolderAddIcon from '#/assets/folder_add.svg' -import FolderFilledIcon from '#/assets/folder_filled.svg' import Minus2Icon from '#/assets/minus2.svg' -import PeopleIcon from '#/assets/people.svg' -import PersonIcon from '#/assets/person.svg' -import RecentIcon from '#/assets/recent.svg' import SettingsIcon from '#/assets/settings.svg' -import Trash2Icon from '#/assets/trash2.svg' import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' import { Badge } from '#/components/Badge' import * as mimeTypes from '#/data/mimeTypes' -import { useBackendQuery } from '#/hooks/backendHooks' import * as offlineHooks from '#/hooks/offlineHooks' import { areCategoriesEqual, @@ -31,33 +22,16 @@ import * as eventListProvider from '#/layouts/Drive/EventListProvider' import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' -import { useLocalStorageState } from '#/providers/LocalStorageProvider' import * as modalProvider from '#/providers/ModalProvider' import { TabType } from '#/providers/ProjectsProvider' import * as textProvider from '#/providers/TextProvider' -import * as backend from '#/services/Backend' -import { newDirectoryId } from '#/services/LocalBackend' -import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths' -import { getFileName } from '#/utilities/fileInfo' -import LocalStorage from '#/utilities/LocalStorage' +import type * as backend from '#/services/Backend' import { tv } from '#/utilities/tailwindVariants' import { twJoin } from 'tailwind-merge' import { AnimatedBackground } from '../components/AnimatedBackground' import { useEventCallback } from '../hooks/eventCallbackHooks' -// ============================ -// === Global configuration === -// ============================ - -declare module '#/utilities/LocalStorage' { - /** */ - interface LocalStorageData { - readonly localRootDirectories: readonly string[] - } -} - -LocalStorage.registerKey('localRootDirectories', { schema: z.string().array().readonly() }) - +import { useCloudCategoryList, useLocalCategoryList } from './Drive/Categories/categoriesHooks' // ======================== // === CategoryMetadata === // ======================== @@ -81,8 +55,9 @@ interface CategoryMetadata { /** Props for a {@link CategorySwitcherItem}. */ interface InternalCategorySwitcherItemProps extends CategoryMetadata { readonly currentCategory: Category - readonly setCategory: (category: Category) => void + readonly setCategoryId: (categoryId: Category['id']) => void readonly badgeContent?: React.ReactNode + readonly isDisabled: boolean } const CATEGORY_SWITCHER_VARIANTS = tv({ @@ -96,7 +71,7 @@ const CATEGORY_SWITCHER_VARIANTS = tv({ /** An entry in a {@link CategorySwitcher}. */ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { - const { currentCategory, setCategory, badgeContent } = props + const { currentCategory, setCategoryId, badgeContent, isDisabled: isDisabledRaw } = props const { isNested = false, category, icon, label, buttonLabel, dropZoneLabel } = props const [isTransitioning, startTransition] = React.useTransition() @@ -106,8 +81,11 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { const { getText } = textProvider.useText() const localBackend = backendProvider.useLocalBackend() const { isOffline } = offlineHooks.useOffline() + const isCurrent = areCategoriesEqual(currentCategory, category) + const transferBetweenCategories = useTransferBetweenCategories(currentCategory) + const getCategoryError = useEventCallback((otherCategory: Category) => { switch (otherCategory.type) { case 'local': @@ -134,7 +112,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { } }) const error = getCategoryError(category) - const isDisabled = error != null + const isDisabled = error != null || isDisabledRaw const tooltip = error ?? false const isDropTarget = @@ -148,7 +126,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { // and to not invoke the Suspense boundary. // This makes the transition feel more responsive and natural. startTransition(() => { - setCategory(category) + setCategoryId(category.id) }) } }) @@ -201,6 +179,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { aria-label={buttonLabel} onPress={onPress} loaderPosition="icon" + data-selected={isCurrent} loading={isTransitioning} className={twJoin(isCurrent && 'opacity-100')} icon={icon} @@ -243,75 +222,27 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { /** Props for a {@link CategorySwitcher}. */ export interface CategorySwitcherProps { readonly category: Category - readonly setCategory: (category: Category) => void + readonly setCategoryId: (categoryId: Category['id']) => void } /** A switcher to choose the currently visible assets table categoryModule.categoryType. */ function CategorySwitcher(props: CategorySwitcherProps) { - const { category, setCategory } = props - const { user } = authProvider.useFullUserSession() + const { category, setCategoryId } = props + const { getText } = textProvider.useText() - const remoteBackend = backendProvider.useRemoteBackend() - const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const [, setSearchParams] = useSearchParams() - const [localRootDirectories, setLocalRootDirectories] = - useLocalStorageState('localRootDirectories') - const hasUserAndTeamSpaces = backend.userHasUserAndTeamSpaces(user) + const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() - const localBackend = backendProvider.useLocalBackend() - const itemProps = { currentCategory: category, setCategory, dispatchAssetEvent } - const selfDirectoryId = backend.DirectoryId(`directory-${user.userId.replace(/^user-/, '')}`) - - const { data: users } = useBackendQuery(remoteBackend, 'listUsers', []) - const { data: teams } = useBackendQuery(remoteBackend, 'listUserGroups', []) - const usersById = React.useMemo>( - () => - new Map( - (users ?? []).map((otherUser) => [ - backend.DirectoryId(`directory-${otherUser.userId.replace(/^user-/, '')}`), - otherUser, - ]), - ), - [users], - ) - const teamsById = React.useMemo>( - () => - new Map( - (teams ?? []).map((team) => [ - backend.DirectoryId(`directory-${team.id.replace(/^usergroup-/, '')}`), - team, - ]), - ), - [teams], - ) - const usersDirectoryQuery = useBackendQuery( - remoteBackend, - 'listDirectory', - [ - { - parentId: USERS_DIRECTORY_ID, - filterBy: backend.FilterBy.active, - labels: null, - recentProjects: false, - }, - 'Users', - ], - { enabled: hasUserAndTeamSpaces }, - ) - const teamsDirectoryQuery = useBackendQuery( - remoteBackend, - 'listDirectory', - [ - { - parentId: TEAMS_DIRECTORY_ID, - filterBy: backend.FilterBy.active, - labels: null, - recentProjects: false, - }, - 'Teams', - ], - { enabled: hasUserAndTeamSpaces }, - ) + const { isOffline } = offlineHooks.useOffline() + + const cloudCategories = useCloudCategoryList() + const localCategories = useLocalCategoryList() + + const itemProps = { currentCategory: category, setCategoryId, dispatchAssetEvent } + + const { cloudCategory, recentCategory, trashCategory, userCategory, teamCategories } = + cloudCategories + const { localCategory, directories, addDirectory, removeDirectory } = localCategories return (
@@ -327,101 +258,76 @@ function CategorySwitcher(props: CategorySwitcherProps) { > - {(user.plan === backend.Plan.team || user.plan === backend.Plan.enterprise) && ( + + {/* Self user space */} + {userCategory != null && ( )} - {usersDirectoryQuery.data?.map((userDirectory) => { - if (userDirectory.type !== backend.AssetType.directory) { - return null - } else { - const otherUser = usersById.get(userDirectory.id) - return !otherUser || otherUser.userId === user.userId ? - null - : - } - })} - {teamsDirectoryQuery.data?.map((teamDirectory) => { - if (teamDirectory.type !== backend.AssetType.directory) { - return null - } else { - const team = teamsById.get(teamDirectory.id) - return !team ? null : ( - - ) - } - })} + + {teamCategories?.map((teamCategory) => ( + + ))} + + - {localBackend && ( + {localCategory != null && (
@@ -442,22 +348,20 @@ function CategorySwitcher(props: CategorySwitcherProps) { />
)} - {localBackend && - localRootDirectories?.map((directory) => ( -
+ {directories != null && + directories.map((directory) => ( +
+ + { - setLocalRootDirectories( - localRootDirectories.filter( - (otherDirectory) => otherDirectory !== directory, - ), - ) + removeDirectory(directory.id) }} />
))} - {localBackend && window.fileBrowserApi && ( + + {directories != null && window.fileBrowserApi && (
+ diff --git a/app/gui/src/dashboard/layouts/CategorySwitcher/Category.ts b/app/gui/src/dashboard/layouts/CategorySwitcher/Category.ts index c94f68c06bae..4f2567bccdc9 100644 --- a/app/gui/src/dashboard/layouts/CategorySwitcher/Category.ts +++ b/app/gui/src/dashboard/layouts/CategorySwitcher/Category.ts @@ -1,285 +1,5 @@ -/** @file The categories available in the category switcher. */ -import { useMutation } from '@tanstack/react-query' -import invariant from 'tiny-invariant' -import * as z from 'zod' - -import AssetEventType from '#/events/AssetEventType' -import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' -import { useEventCallback } from '#/hooks/eventCallbackHooks' -import { useDispatchAssetEvent } from '#/layouts/Drive/EventListProvider' -import { useFullUserSession } from '#/providers/AuthProvider' -import { useBackend, useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider' -import { - FilterBy, - Plan, - type AssetId, - type DirectoryId, - type Path, - type User, - type UserGroupInfo, -} from '#/services/Backend' -import { newDirectoryId } from '#/services/LocalBackend' - -const PATH_SCHEMA = z.string().refine((s): s is Path => true) -const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true) - -/** A category corresponding to the root of the user or organization. */ -const CLOUD_CATEGORY_SCHEMA = z.object({ type: z.literal('cloud') }).readonly() -/** A category corresponding to the root of the user or organization. */ -export type CloudCategory = z.infer - -/** A category containing recently opened Cloud projects. */ -const RECENT_CATEGORY_SCHEMA = z.object({ type: z.literal('recent') }).readonly() -/** A category containing recently opened Cloud projects. */ -export type RecentCategory = z.infer - -/** A category containing recently deleted Cloud items. */ -const TRASH_CATEGORY_SCHEMA = z.object({ type: z.literal('trash') }).readonly() -/** A category containing recently deleted Cloud items. */ -export type TrashCategory = z.infer - -/** A category corresponding to the root directory of a user. */ -export const USER_CATEGORY_SCHEMA = z - .object({ - type: z.literal('user'), - rootPath: PATH_SCHEMA, - homeDirectoryId: DIRECTORY_ID_SCHEMA, - }) - .readonly() -/** A category corresponding to the root directory of a user. */ -export type UserCategory = z.infer - -export const TEAM_CATEGORY_SCHEMA = z - .object({ - type: z.literal('team'), - team: z.custom(() => true), - rootPath: PATH_SCHEMA, - homeDirectoryId: DIRECTORY_ID_SCHEMA, - }) - .readonly() -/** A category corresponding to the root directory of a team within an organization. */ -export type TeamCategory = z.infer - -/** A category corresponding to the primary root directory for Local projects. */ -const LOCAL_CATEGORY_SCHEMA = z.object({ type: z.literal('local') }).readonly() -/** A category corresponding to the primary root directory for Local projects. */ -export type LocalCategory = z.infer - -/** A category corresponding to an alternate local root directory. */ -export const LOCAL_DIRECTORY_CATEGORY_SCHEMA = z - .object({ - type: z.literal('local-directory'), - rootPath: PATH_SCHEMA, - homeDirectoryId: DIRECTORY_ID_SCHEMA, - }) - .readonly() -/** A category corresponding to an alternate local root directory. */ -export type LocalDirectoryCategory = z.infer - -/** Any cloud category. */ -export const ANY_CLOUD_CATEGORY_SCHEMA = z.union([ - CLOUD_CATEGORY_SCHEMA, - RECENT_CATEGORY_SCHEMA, - TRASH_CATEGORY_SCHEMA, - TEAM_CATEGORY_SCHEMA, - USER_CATEGORY_SCHEMA, -]) -/** Any cloud category. */ -export type AnyCloudCategory = z.infer - -/** Any local category. */ -export const ANY_LOCAL_CATEGORY_SCHEMA = z.union([ - LOCAL_CATEGORY_SCHEMA, - LOCAL_DIRECTORY_CATEGORY_SCHEMA, -]) -/** Any local category. */ -export type AnyLocalCategory = z.infer - -/** A category of an arbitrary type. */ -export const CATEGORY_SCHEMA = z.union([ANY_CLOUD_CATEGORY_SCHEMA, ANY_LOCAL_CATEGORY_SCHEMA]) -/** A category of an arbitrary type. */ -export type Category = z.infer - -export const CATEGORY_TO_FILTER_BY: Readonly> = { - cloud: FilterBy.active, - local: FilterBy.active, - recent: null, - trash: FilterBy.trashed, - user: FilterBy.active, - team: FilterBy.active, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'local-directory': FilterBy.active, -} - /** - * The type of the cached value for a category. - * We use const enums because they compile to numeric values and they are faster than strings. + * @file The categories available in the category switcher. + * @deprecated Please import from `#/layouts/Drive/Categories/Category` instead. */ -const enum CategoryCacheType { - cloud = 0, - local = 1, -} - -const CATEGORY_CACHE = new Map() - -/** Whether the category is only accessible from the cloud. */ -export function isCloudCategory(category: Category): category is AnyCloudCategory { - const cached = CATEGORY_CACHE.get(category.type) - - if (cached != null) { - return cached === CategoryCacheType.cloud - } - - const result = ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category) - CATEGORY_CACHE.set( - category.type, - result.success ? CategoryCacheType.cloud : CategoryCacheType.local, - ) - - return result.success -} - -/** Whether the category is only accessible locally. */ -export function isLocalCategory(category: Category): category is AnyLocalCategory { - const cached = CATEGORY_CACHE.get(category.type) - - if (cached != null) { - return cached === CategoryCacheType.local - } - - const result = ANY_LOCAL_CATEGORY_SCHEMA.safeParse(category) - CATEGORY_CACHE.set( - category.type, - result.success ? CategoryCacheType.local : CategoryCacheType.cloud, - ) - return result.success -} - -/** Whether the given categories are equal. */ -export function areCategoriesEqual(a: Category, b: Category) { - if (a.type !== b.type) { - return false - } else if ( - (a.type === 'user' && b.type === 'user') || - (a.type === 'team' && b.type === 'team') || - (a.type === 'local-directory' && b.type === 'local-directory') - ) { - return a.homeDirectoryId === b.homeDirectoryId - } else { - return true - } -} - -/** Whether an asset can be transferred between categories. */ -export function canTransferBetweenCategories(from: Category, to: Category, user: User) { - switch (from.type) { - case 'cloud': - case 'recent': - case 'team': - case 'user': { - if (user.plan === Plan.enterprise || user.plan === Plan.team) { - return to.type !== 'cloud' - } - return to.type === 'trash' || to.type === 'cloud' || to.type === 'team' || to.type === 'user' - } - case 'trash': { - // In the future we want to be able to drag to certain categories to restore directly - // to specific home directories. - return false - } - case 'local': - case 'local-directory': { - return to.type === 'local' || to.type === 'local-directory' - } - } -} - -/** A function to transfer a list of assets between categories. */ -export function useTransferBetweenCategories(currentCategory: Category) { - const remoteBackend = useRemoteBackend() - const localBackend = useLocalBackend() - const backend = useBackend(currentCategory) - const { user } = useFullUserSession() - const { data: organization = null } = useBackendQuery(remoteBackend, 'getOrganization', []) - const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset')) - const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset')) - const dispatchAssetEvent = useDispatchAssetEvent() - return useEventCallback( - ( - from: Category, - to: Category, - keys: Iterable, - newParentKey?: DirectoryId | null, - newParentId?: DirectoryId | null, - ) => { - switch (from.type) { - case 'cloud': - case 'recent': - case 'team': - case 'user': { - if (to.type === 'trash') { - if (from === currentCategory) { - dispatchAssetEvent({ type: AssetEventType.delete, ids: new Set(keys) }) - } else { - for (const id of keys) { - deleteAssetMutation.mutate([id, { force: false }, '(unknown)']) - } - } - } else if (to.type === 'cloud' || to.type === 'team' || to.type === 'user') { - newParentId ??= - to.type === 'cloud' ? - remoteBackend.rootDirectoryId(user, organization) - : to.homeDirectoryId - invariant(newParentId != null, 'The Cloud backend is missing a root directory.') - newParentKey ??= newParentId - if (from === currentCategory) { - dispatchAssetEvent({ - type: AssetEventType.move, - newParentKey, - newParentId, - ids: new Set(keys), - }) - } else { - for (const id of keys) { - updateAssetMutation.mutate([ - id, - { description: null, parentDirectoryId: newParentId }, - '(unknown)', - ]) - } - } - } - break - } - case 'trash': { - break - } - case 'local': - case 'local-directory': { - if (to.type === 'local' || to.type === 'local-directory') { - const parentDirectory = to.type === 'local' ? localBackend?.rootPath() : to.rootPath - invariant(parentDirectory != null, 'The Local backend is missing a root directory.') - newParentId ??= newDirectoryId(parentDirectory) - newParentKey ??= newParentId - if (from === currentCategory) { - dispatchAssetEvent({ - type: AssetEventType.move, - newParentKey, - newParentId, - ids: new Set(keys), - }) - } else { - for (const id of keys) { - updateAssetMutation.mutate([ - id, - { description: null, parentDirectoryId: newParentId }, - '(unknown)', - ]) - } - } - } - } - } - }, - ) -} +export * from '../Drive/Categories/Category' diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index 77ddec049223..5e794ed97d7f 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -29,6 +29,7 @@ import { ErrorBoundary, useErrorBoundary } from '#/components/ErrorBoundary' import SvgMask from '#/components/SvgMask' import { listDirectoryQueryOptions } from '#/hooks/backendHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' +import type { Category } from '#/layouts/CategorySwitcher/Category' import { useTargetDirectory } from '#/providers/DriveProvider' import { DirectoryDoesNotExistError, Plan } from '#/services/Backend' import AssetQuery from '#/utilities/AssetQuery' @@ -41,6 +42,7 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useDeferredValue, useEffect } from 'react' import { toast } from 'react-toastify' import { Suspense } from '../components/Suspense' +import { useCategoriesAPI } from './Drive/Categories/categoriesHooks' import { useDirectoryIds } from './Drive/directoryIdsHooks' // ============= @@ -49,9 +51,6 @@ import { useDirectoryIds } from './Drive/directoryIdsHooks' /** Props for a {@link Drive}. */ export interface DriveProps { - readonly category: categoryModule.Category - readonly setCategory: (category: categoryModule.Category) => void - readonly resetCategory: () => void readonly hidden: boolean readonly initialProjectName: string | null readonly assetsManagementApiRef: React.Ref @@ -61,13 +60,14 @@ const CATEGORIES_TO_DISPLAY_START_MODAL = ['cloud', 'local', 'local-directory'] /** Contains directory path and directory contents (projects, folders, secrets and files). */ function Drive(props: DriveProps) { - const { category, resetCategory, setCategory } = props - const { isOffline } = offlineHooks.useOffline() const toastAndLog = toastAndLogHooks.useToastAndLog() const { user } = authProvider.useFullUserSession() const localBackend = backendProvider.useLocalBackend() const { getText } = textProvider.useText() + const categoriesAPI = useCategoriesAPI() + const { category, resetCategory, setCategory } = categoriesAPI + const isCloud = categoryModule.isCloudCategory(category) const supportLocalBackend = localBackend != null @@ -140,7 +140,7 @@ function Drive(props: DriveProps) { }} > - + ) @@ -148,10 +148,18 @@ function Drive(props: DriveProps) { } } +/** + * Props for a {@link DriveAssetsView}. + */ +interface DriveAssetsViewProps extends DriveProps { + readonly category: Category + readonly setCategory: (categoryId: Category['id']) => void +} + /** * The assets view of the Drive. */ -function DriveAssetsView(props: DriveProps) { +function DriveAssetsView(props: DriveAssetsViewProps) { const { category, setCategory, @@ -263,8 +271,8 @@ function DriveAssetsView(props: DriveProps) { />
-
- +
+ {isCloud && ( void + readonly setCategory: (category: categoryModule.Category['id']) => void } /** @@ -327,7 +335,7 @@ function OfflineMessage(props: OfflineMessageProps) { variant="primary" className="mx-auto" onPress={() => { - setCategory({ type: 'local' }) + setCategory('local') }} > {getText('switchToLocal')} diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts new file mode 100644 index 000000000000..2eacd4715c39 --- /dev/null +++ b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts @@ -0,0 +1,309 @@ +/** @file The categories available in the category switcher. */ +import { useMutation } from '@tanstack/react-query' +import invariant from 'tiny-invariant' +import * as z from 'zod' + +import AssetEventType from '#/events/AssetEventType' +import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useDispatchAssetEvent } from '#/layouts/Drive/EventListProvider' +import { useFullUserSession } from '#/providers/AuthProvider' +import { useBackend, useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider' +import type { UserId } from '#/services/Backend' +import { + FilterBy, + Plan, + type AssetId, + type DirectoryId, + type Path, + type User, + type UserGroupId, + type UserGroupInfo, +} from '#/services/Backend' +import { newDirectoryId } from '#/services/LocalBackend' + +const PATH_SCHEMA = z.string().refine((s): s is Path => true) +const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true) + +const EACH_CATEGORY_SCHEMA = z.object({ label: z.string(), icon: z.string() }) + +/** A category corresponding to the root of the user or organization. */ +const CLOUD_CATEGORY_SCHEMA = z + .object({ + type: z.literal('cloud'), + id: z.literal('cloud'), + homeDirectoryId: DIRECTORY_ID_SCHEMA, + }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category corresponding to the root of the user or organization. */ +export type CloudCategory = z.infer + +/** A category containing recently opened Cloud projects. */ +const RECENT_CATEGORY_SCHEMA = z + .object({ type: z.literal('recent'), id: z.literal('recent') }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category containing recently opened Cloud projects. */ +export type RecentCategory = z.infer + +/** A category containing recently deleted Cloud items. */ +const TRASH_CATEGORY_SCHEMA = z + .object({ type: z.literal('trash'), id: z.literal('trash') }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category containing recently deleted Cloud items. */ +export type TrashCategory = z.infer + +/** A category corresponding to the root directory of a user. */ +export const USER_CATEGORY_SCHEMA = z + .object({ + type: z.literal('user'), + user: z.custom(() => true), + id: z.custom(() => true), + rootPath: PATH_SCHEMA, + homeDirectoryId: DIRECTORY_ID_SCHEMA, + }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category corresponding to the root directory of a user. */ +export type UserCategory = z.infer + +export const TEAM_CATEGORY_SCHEMA = z + .object({ + type: z.literal('team'), + id: z.custom(() => true), + team: z.custom(() => true), + rootPath: PATH_SCHEMA, + homeDirectoryId: DIRECTORY_ID_SCHEMA, + }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category corresponding to the root directory of a team within an organization. */ +export type TeamCategory = z.infer + +/** A category corresponding to the primary root directory for Local projects. */ + +const LOCAL_CATEGORY_SCHEMA = z + .object({ type: z.literal('local'), id: z.literal('local') }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category corresponding to the primary root directory for Local projects. */ +export type LocalCategory = z.infer + +/** A category corresponding to an alternate local root directory. */ +export const LOCAL_DIRECTORY_CATEGORY_SCHEMA = z + .object({ + type: z.literal('local-directory'), + id: z.custom(() => true), + rootPath: PATH_SCHEMA, + homeDirectoryId: DIRECTORY_ID_SCHEMA, + }) + .merge(EACH_CATEGORY_SCHEMA) + .readonly() +/** A category corresponding to an alternate local root directory. */ +export type LocalDirectoryCategory = z.infer + +/** Any cloud category. */ +export const ANY_CLOUD_CATEGORY_SCHEMA = z.union([ + CLOUD_CATEGORY_SCHEMA, + RECENT_CATEGORY_SCHEMA, + TRASH_CATEGORY_SCHEMA, + TEAM_CATEGORY_SCHEMA, + USER_CATEGORY_SCHEMA, +]) +/** Any cloud category. */ +export type AnyCloudCategory = z.infer + +/** Any local category. */ +export const ANY_LOCAL_CATEGORY_SCHEMA = z.union([ + LOCAL_CATEGORY_SCHEMA, + LOCAL_DIRECTORY_CATEGORY_SCHEMA, +]) +/** Any local category. */ +export type AnyLocalCategory = z.infer + +/** A category of an arbitrary type. */ +export const CATEGORY_SCHEMA = z.union([ANY_CLOUD_CATEGORY_SCHEMA, ANY_LOCAL_CATEGORY_SCHEMA]) +/** A category of an arbitrary type. */ +export type Category = z.infer + +/** The `id` of a {@link Category}. */ +export type CategoryId = Category['id'] + +/** An inferred Category type from a specific type. */ +export type CategoryByType = Extract + +export const CATEGORY_TO_FILTER_BY: Readonly> = { + cloud: FilterBy.active, + local: FilterBy.active, + recent: null, + trash: FilterBy.trashed, + user: FilterBy.active, + team: FilterBy.active, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'local-directory': FilterBy.active, +} + +/** + * The type of the cached value for a category. + * We use const enums because they compile to numeric values and they are faster than strings. + */ +const enum CategoryCacheType { + cloud = 0, + local = 1, +} + +const CATEGORY_CACHE = new Map() + +/** Whether the category is only accessible from the cloud. */ +export function isCloudCategory(category: Category): category is AnyCloudCategory { + const cached = CATEGORY_CACHE.get(category.type) + + if (cached != null) { + return cached === CategoryCacheType.cloud + } + + const result = ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category) + CATEGORY_CACHE.set( + category.type, + result.success ? CategoryCacheType.cloud : CategoryCacheType.local, + ) + + return result.success +} + +/** Whether the category is only accessible locally. */ +export function isLocalCategory(category: Category): category is AnyLocalCategory { + const cached = CATEGORY_CACHE.get(category.type) + + if (cached != null) { + return cached === CategoryCacheType.local + } + + const result = ANY_LOCAL_CATEGORY_SCHEMA.safeParse(category) + CATEGORY_CACHE.set( + category.type, + result.success ? CategoryCacheType.local : CategoryCacheType.cloud, + ) + return result.success +} + +/** Whether the given categories are equal. */ +export function areCategoriesEqual(a: Category, b: Category) { + return a.id === b.id +} + +/** Whether an asset can be transferred between categories. */ +export function canTransferBetweenCategories(from: Category, to: Category, user: User) { + switch (from.type) { + case 'cloud': + case 'recent': + case 'team': + case 'user': { + if (user.plan === Plan.enterprise || user.plan === Plan.team) { + return to.type !== 'cloud' + } + return to.type === 'trash' || to.type === 'cloud' || to.type === 'team' || to.type === 'user' + } + case 'trash': { + // In the future we want to be able to drag to certain categories to restore directly + // to specific home directories. + return false + } + case 'local': + case 'local-directory': { + return to.type === 'local' || to.type === 'local-directory' + } + } +} + +/** A function to transfer a list of assets between categories. */ +export function useTransferBetweenCategories(currentCategory: Category) { + const remoteBackend = useRemoteBackend() + const localBackend = useLocalBackend() + const backend = useBackend(currentCategory) + const { user } = useFullUserSession() + const { data: organization = null } = useBackendQuery(remoteBackend, 'getOrganization', []) + const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset')) + const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset')) + const dispatchAssetEvent = useDispatchAssetEvent() + return useEventCallback( + ( + from: Category, + to: Category, + keys: Iterable, + newParentKey?: DirectoryId | null, + newParentId?: DirectoryId | null, + ) => { + switch (from.type) { + case 'cloud': + case 'recent': + case 'team': + case 'user': { + if (to.type === 'trash') { + if (from === currentCategory) { + dispatchAssetEvent({ type: AssetEventType.delete, ids: new Set(keys) }) + } else { + for (const id of keys) { + deleteAssetMutation.mutate([id, { force: false }, '(unknown)']) + } + } + } else if (to.type === 'cloud' || to.type === 'team' || to.type === 'user') { + newParentId ??= + to.type === 'cloud' ? + remoteBackend.rootDirectoryId(user, organization) + : to.homeDirectoryId + invariant(newParentId != null, 'The Cloud backend is missing a root directory.') + newParentKey ??= newParentId + if (from === currentCategory) { + dispatchAssetEvent({ + type: AssetEventType.move, + newParentKey, + newParentId, + ids: new Set(keys), + }) + } else { + for (const id of keys) { + updateAssetMutation.mutate([ + id, + { description: null, parentDirectoryId: newParentId }, + '(unknown)', + ]) + } + } + } + break + } + case 'trash': { + break + } + case 'local': + case 'local-directory': { + if (to.type === 'local' || to.type === 'local-directory') { + const parentDirectory = to.type === 'local' ? localBackend?.rootPath() : to.rootPath + invariant(parentDirectory != null, 'The Local backend is missing a root directory.') + newParentId ??= newDirectoryId(parentDirectory) + newParentKey ??= newParentId + if (from === currentCategory) { + dispatchAssetEvent({ + type: AssetEventType.move, + newParentKey, + newParentId, + ids: new Set(keys), + }) + } else { + for (const id of keys) { + updateAssetMutation.mutate([ + id, + { description: null, parentDirectoryId: newParentId }, + '(unknown)', + ]) + } + } + } + } + } + }, + ) +} diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx new file mode 100644 index 000000000000..d5d6675670be --- /dev/null +++ b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx @@ -0,0 +1,406 @@ +/** + * @file + * + * Hooks for working with categories. + * Categories are shortcuts to specific directories in the Cloud, e.g. team spaces, recent and trash + * It's not the same as the categories like LocalBackend + */ + +import { useSuspenseQuery } from '@tanstack/react-query' + +import CloudIcon from '#/assets/cloud.svg' +import ComputerIcon from '#/assets/computer.svg' +import FolderFilledIcon from '#/assets/folder_filled.svg' +import PeopleIcon from '#/assets/people.svg' +import PersonIcon from '#/assets/person.svg' +import RecentIcon from '#/assets/recent.svg' +import Trash2Icon from '#/assets/trash2.svg' + +import { useUser } from '#/providers/AuthProvider' + +import { backendQueryOptions } from '#/hooks/backendHooks' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useOffline } from '#/hooks/offlineHooks' +import { useSearchParamsState } from '#/hooks/searchParamsStateHooks' +import { useBackend, useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider' +import { useLocalStorageState } from '#/providers/LocalStorageProvider' +import { useText } from '#/providers/TextProvider' +import type Backend from '#/services/Backend' +import { type DirectoryId, Path, userHasUserAndTeamSpaces } from '#/services/Backend' +import { newDirectoryId } from '#/services/LocalBackend' +import { + organizationIdToDirectoryId, + userGroupIdToDirectoryId, + userIdToDirectoryId, +} from '#/services/RemoteBackend' +import { getFileName } from '#/utilities/fileInfo' +import LocalStorage from '#/utilities/LocalStorage' +import type { ReactNode } from 'react' +import { createContext, useContext } from 'react' +import invariant from 'tiny-invariant' +import { z } from 'zod' +import type { + AnyCloudCategory, + AnyLocalCategory, + Category, + CategoryByType, + CategoryId, + CloudCategory, + LocalCategory, + LocalDirectoryCategory, + RecentCategory, + TeamCategory, + TrashCategory, + UserCategory, +} from './Category' +import { isCloudCategory, isLocalCategory } from './Category' + +declare module '#/utilities/LocalStorage' { + /** */ + interface LocalStorageData { + readonly localRootDirectories: z.infer + } +} + +const LOCAL_ROOT_DIRECTORIES_SCHEMA = z.string().array().readonly() + +LocalStorage.registerKey('localRootDirectories', { schema: LOCAL_ROOT_DIRECTORIES_SCHEMA }) + +/** + * Result of the useCloudCategoryList hook. + */ +export type CloudCategoryResult = ReturnType + +/** + * List of categories in the Cloud. + */ +export function useCloudCategoryList() { + const remoteBackend = useRemoteBackend() + + const user = useUser() + const { getText } = useText() + + const { name, userId, organizationId } = user + + const hasUserAndTeamSpaces = userHasUserAndTeamSpaces(user) + + const cloudCategory: CloudCategory = { + type: 'cloud', + id: 'cloud', + label: getText('cloudCategory'), + icon: CloudIcon, + homeDirectoryId: + hasUserAndTeamSpaces ? + organizationIdToDirectoryId(organizationId) + : userIdToDirectoryId(userId), + } + + const recentCategory: RecentCategory = { + type: 'recent', + id: 'recent', + label: getText('recentCategory'), + icon: RecentIcon, + } + + const trashCategory: TrashCategory = { + type: 'trash', + id: 'trash', + label: getText('trashCategory'), + icon: Trash2Icon, + } + + const predefinedCloudCategories: AnyCloudCategory[] = [ + cloudCategory, + recentCategory, + trashCategory, + ] + + const { data: allUserGroupsRaw } = useSuspenseQuery( + backendQueryOptions(remoteBackend, 'listUserGroups', []), + ) + + const allUserGroups = + allUserGroupsRaw.length === 0 || !hasUserAndTeamSpaces ? null : allUserGroupsRaw + + const userSpace: UserCategory | null = + hasUserAndTeamSpaces ? + { + type: 'user', + id: userId, + user: user, + rootPath: Path(`enso://Users/${name}`), + homeDirectoryId: userIdToDirectoryId(userId), + label: getText('myFilesCategory'), + icon: PersonIcon, + } + : null + + const doesHaveUserGroups = + user.userGroups != null && user.userGroups.length > 0 && allUserGroups != null + + const userGroupDynamicCategories = + doesHaveUserGroups ? + user.userGroups.map((id) => { + const group = allUserGroups.find((userGroup) => userGroup.id === id) + + invariant( + group != null, + `Unable to find user group by id: ${id}, allUserGroups: ${JSON.stringify(allUserGroups, null, 2)}`, + ) + + return { + type: 'team', + id, + team: group, + rootPath: Path(`enso://Teams/${group.groupName}`), + homeDirectoryId: userGroupIdToDirectoryId(group.id), + label: getText('teamCategory', group.groupName), + icon: PeopleIcon, + } + }) + : null + + const categories = [ + ...predefinedCloudCategories, + ...(userSpace != null ? [userSpace] : []), + ...(userGroupDynamicCategories != null ? [...userGroupDynamicCategories] : []), + ] as const + + const getCategoryById = useEventCallback( + (id: CategoryId) => categories.find((category) => category.id === id) ?? null, + ) + + const getCategoriesByType = useEventCallback( + (type: T) => + // This is safe, because we know that the result will have the correct type. + // eslint-disable-next-line no-restricted-syntax + categories.filter((category) => category.type === type) as CategoryByType[], + ) + + const getCategoryByDirectoryId = useEventCallback( + (directoryId: DirectoryId) => + categories.find((category) => { + if ('homeDirectoryId' in category) { + return category.homeDirectoryId === directoryId + } + + return false + }) ?? null, + ) + + return { + categories, + cloudCategory, + recentCategory, + trashCategory, + userCategory: userSpace, + teamCategories: userGroupDynamicCategories, + getCategoryById, + getCategoriesByType, + isCloudCategory, + getCategoryByDirectoryId, + } as const +} + +/** + * Result of the useLocalCategoryList hook. + */ +export type LocalCategoryResult = ReturnType + +/** + * List of all categories in the LocalBackend. + * Usually these are the root folder and the list of favorites + */ +export function useLocalCategoryList() { + const { getText } = useText() + const localBackend = useLocalBackend() + + const localCategory: LocalCategory = { + type: 'local', + id: 'local', + label: getText('localCategory'), + icon: ComputerIcon, + } + + const predefinedLocalCategories: AnyLocalCategory[] = [localCategory] + + const [localRootDirectories, setLocalRootDirectories] = useLocalStorageState( + 'localRootDirectories', + [], + ) + + const localCategories = localRootDirectories.map((directory) => ({ + type: 'local-directory', + id: newDirectoryId(Path(directory)), + rootPath: Path(directory), + homeDirectoryId: newDirectoryId(Path(directory)), + label: getFileName(directory), + icon: FolderFilledIcon, + })) + + const categories = + localBackend == null ? [] : ([...predefinedLocalCategories, ...localCategories] as const) + + const addDirectory = useEventCallback((directory: string) => { + setLocalRootDirectories([...localRootDirectories, directory]) + }) + + const removeDirectory = useEventCallback((directory: DirectoryId) => { + const category = getCategoryById(directory) + + if (category != null && category.type === 'local-directory') { + setLocalRootDirectories(localRootDirectories.filter((d) => d !== category.rootPath)) + } + }) + + const getCategoryById = useEventCallback( + (id: CategoryId) => categories.find((category) => category.id === id) ?? null, + ) + + const getCategoriesByType = useEventCallback( + (type: T) => + // This is safe, because we know that the result will have the correct type. + // eslint-disable-next-line no-restricted-syntax + categories.filter((category) => category.type === type) as CategoryByType[], + ) + + if (localBackend == null) { + return { + // We don't have any categories if localBackend is not available. + categories, + localCategory: null, + directories: null, + // noop if localBackend is not available. + addDirectory: () => {}, + // noop if localBackend is not available. + removeDirectory: () => {}, + getCategoryById, + getCategoriesByType, + isLocalCategory, + } + } + + return { + categories, + localCategory, + directories: localCategories, + addDirectory, + removeDirectory, + getCategoryById, + getCategoriesByType, + isLocalCategory, + } as const +} + +/** + * Result of the useCategories hook. + */ +export type CategoriesResult = ReturnType + +/** + * List of all categories. + */ +export function useCategories() { + const cloudCategories = useCloudCategoryList() + const localCategories = useLocalCategoryList() + + const findCategoryById = useEventCallback((id: CategoryId) => { + return cloudCategories.getCategoryById(id) ?? localCategories.getCategoryById(id) + }) + + return { cloudCategories, localCategories, findCategoryById } +} + +/** + * Context value for the categories. + */ +interface CategoriesContextValue { + readonly cloudCategories: CloudCategoryResult + readonly localCategories: LocalCategoryResult + readonly category: Category + readonly setCategory: (category: CategoryId) => void + readonly resetCategory: () => void + readonly associatedBackend: Backend +} + +const CategoriesContext = createContext(null) + +/** + * Props for the {@link CategoriesProvider}. + */ +export interface CategoriesProviderProps { + readonly children: ReactNode | ((contextValue: CategoriesContextValue) => ReactNode) + readonly onCategoryChange?: (previousCategory: Category | null, newCategory: Category) => void +} + +/** + * Provider for the categories. + */ +export function CategoriesProvider(props: CategoriesProviderProps): React.JSX.Element { + const { children, onCategoryChange = () => {} } = props + + const { cloudCategories, localCategories, findCategoryById } = useCategories() + const localBackend = useLocalBackend() + const { isOffline } = useOffline() + + const [categoryId, privateSetCategoryId, resetCategoryId] = useSearchParamsState( + 'driveCategory', + () => { + if (isOffline && localBackend != null) { + return 'local' + } + + return localBackend != null ? 'local' : 'cloud' + }, + // This is safe, because we enshure the type inside the function + // eslint-disable-next-line no-restricted-syntax + (value): value is CategoryId => findCategoryById(value as CategoryId) != null, + ) + + const setCategoryId = useEventCallback((nextCategoryId: CategoryId) => { + const previousCategory = findCategoryById(categoryId) + privateSetCategoryId(nextCategoryId) + // This is safe, because we know that the result will have the correct type. + // eslint-disable-next-line no-restricted-syntax + onCategoryChange(previousCategory, findCategoryById(nextCategoryId) as Category) + }) + + const category = findCategoryById(categoryId) + + // This is safe, because a category always specified + // eslint-disable-next-line no-restricted-syntax + const backend = useBackend(category as Category) + + // This usually doesn't happen but if so, + // We reset the category to the default. + if (category == null) { + resetCategoryId(true) + return <> + } + + const contextValue = { + cloudCategories, + localCategories, + category, + setCategory: setCategoryId, + resetCategory: resetCategoryId, + associatedBackend: backend, + } satisfies CategoriesContextValue + + return ( + + {typeof children === 'function' ? children(contextValue) : children} + + ) +} + +/** + * Gets the api to interact with the categories. + */ +export function useCategoriesAPI() { + const context = useContext(CategoriesContext) + + invariant(context != null, 'useCategory must be used within a CategoriesProvider') + + return context +} diff --git a/app/gui/src/dashboard/layouts/Drive/assetsTableItemsHooks.tsx b/app/gui/src/dashboard/layouts/Drive/assetsTableItemsHooks.tsx index 18b7792824dc..be2828e7d1a2 100644 --- a/app/gui/src/dashboard/layouts/Drive/assetsTableItemsHooks.tsx +++ b/app/gui/src/dashboard/layouts/Drive/assetsTableItemsHooks.tsx @@ -1,5 +1,5 @@ /** @file A hook to return the items in the assets table. */ -import { startTransition, useMemo } from 'react' +import { useMemo } from 'react' import type { AnyAsset, AssetId } from 'enso-common/src/services/Backend' import { AssetType, getAssetPermissionName } from 'enso-common/src/services/Backend' @@ -212,10 +212,6 @@ export function useAssetsTableItems(options: UseAssetsTableOptions) { children.filter((child) => expandedDirectoryIds.includes(child.directoryId)), ) - startTransition(() => { - setAssetItems(flatTree.map((item) => item.item)) - }) - return flatTree } else { const multiplier = sortInfo.direction === SortDirection.ascending ? 1 : -1 @@ -238,13 +234,11 @@ export function useAssetsTableItems(options: UseAssetsTableOptions) { [...tree].filter((child) => expandedDirectoryIds.includes(child.directoryId)).sort(compare), ) - startTransition(() => { - setAssetItems(flatTree.map((item) => item.item)) - }) - return flatTree } - }, [sortInfo, assetTree, expandedDirectoryIds, setAssetItems]) + }, [sortInfo, assetTree, expandedDirectoryIds]) + + setAssetItems(displayItems.map((item) => item.item)) const visibleItems = useMemo( () => displayItems.filter((item) => visibilities.get(item.key) !== Visibility.hidden), diff --git a/app/gui/src/dashboard/layouts/TabBar.tsx b/app/gui/src/dashboard/layouts/TabBar.tsx index d05d69d99ee4..cc4663f63f27 100644 --- a/app/gui/src/dashboard/layouts/TabBar.tsx +++ b/app/gui/src/dashboard/layouts/TabBar.tsx @@ -21,6 +21,7 @@ import { useBackendForProjectType } from '#/providers/BackendProvider' import { useInputBindings } from '#/providers/InputBindingsProvider' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import * as tailwindMerge from '#/utilities/tailwindMerge' +import { twJoin } from '#/utilities/tailwindMerge' import { motion } from 'framer-motion' /** Props for a {@link TabBar}. */ @@ -37,7 +38,7 @@ export default function TabBar(props: TabBarProps) { return (
- className="flex h-12 shrink-0 grow" {...rest} /> + className="flex h-12 shrink-0 grow px-2" {...rest} />
) @@ -98,17 +99,21 @@ export function Tab(props: TabProps) { {({ isSelected, isHovered }) => ( -
+
- {typeof icon === 'string' ? : icon} - - + {children} - {onClose && ( -
- -
- )} + {onClose && }
)} diff --git a/app/gui/src/dashboard/layouts/UserBar.tsx b/app/gui/src/dashboard/layouts/UserBar.tsx index a41424396597..0d995e37ab3f 100644 --- a/app/gui/src/dashboard/layouts/UserBar.tsx +++ b/app/gui/src/dashboard/layouts/UserBar.tsx @@ -55,7 +55,7 @@ export default function UserBar(props: UserBarProps) { return ( {(innerProps) => ( -
+
{getText('confirmPrompt', actionText)} - + {actionButtonLabel} + diff --git a/app/gui/src/dashboard/modules/payments/components/AddPaymentMethodModal.tsx b/app/gui/src/dashboard/modules/payments/components/AddPaymentMethodModal.tsx index 7158be2f1855..86fda76c869c 100644 --- a/app/gui/src/dashboard/modules/payments/components/AddPaymentMethodModal.tsx +++ b/app/gui/src/dashboard/modules/payments/components/AddPaymentMethodModal.tsx @@ -3,8 +3,6 @@ * * A modal for adding a payment method. */ -import * as React from 'react' - import type * as stripeJs from '@stripe/stripe-js' import * as ariaComponents from '#/components/AriaComponents' diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 05054a6deac3..0600894f0131 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -10,7 +10,8 @@ import { DashboardTabBar } from './DashboardTabBar' import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as projectHooks from '#/hooks/projectHooks' -import * as searchParamsState from '#/hooks/searchParamsStateHooks' +import { CategoriesProvider } from '#/layouts/Drive/Categories/categoriesHooks' +import DriveProvider from '#/providers/DriveProvider' import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' @@ -29,7 +30,6 @@ import ProjectsProvider, { import AssetListEventType from '#/events/AssetListEventType' import type * as assetTable from '#/layouts/AssetsTable' -import * as categoryModule from '#/layouts/CategorySwitcher/Category' import Chat from '#/layouts/Chat' import ChatPlaceholder from '#/layouts/ChatPlaceholder' import EventListProvider, * as eventListProvider from '#/layouts/Drive/EventListProvider' @@ -45,7 +45,7 @@ import * as backendModule from '#/services/Backend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import { useSetCategory } from '#/providers/DriveProvider' +import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' @@ -69,11 +69,19 @@ export interface DashboardProps { /** The component that contains the entire UI. */ export default function Dashboard(props: DashboardProps) { return ( - - - - - + /* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here + * due to modals being in `TheModal`. */ + + {({ resetAssetTableState }) => ( + + + + + + + + )} + ) } @@ -113,25 +121,7 @@ function DashboardInner(props: DashboardProps) { initialProjectNameRaw != null ? fileURLToPath(initialProjectNameRaw) : null const initialProjectName = initialLocalProjectPath != null ? null : initialProjectNameRaw - const [category, setCategoryRaw, resetCategory] = - searchParamsState.useSearchParamsState( - 'driveCategory', - () => (localBackend != null ? { type: 'local' } : { type: 'cloud' }), - (value): value is categoryModule.Category => - categoryModule.CATEGORY_SCHEMA.safeParse(value).success, - ) - - const initialCategory = React.useRef(category) - const setStoreCategory = useSetCategory() - React.useEffect(() => { - setStoreCategory(initialCategory.current) - }, [setStoreCategory]) - - const setCategory = eventCallbacks.useEventCallback((newCategory: categoryModule.Category) => { - setCategoryRaw(newCategory) - setStoreCategory(newCategory) - }) - const backend = backendProvider.useBackend(category) + const categoriesAPI = useCategoriesAPI() const projectsStore = useProjectsStore() const page = usePage() @@ -173,8 +163,10 @@ function DashboardInner(props: DashboardProps) { React.useEffect(() => { window.projectManagementApi?.setOpenProjectHandler((project) => { - setCategory({ type: 'local' }) + categoriesAPI.setCategory('local') + const projectId = localBackendModule.newProjectId(projectManager.UUID(project.id)) + openProject({ type: backendModule.BackendType.local, id: projectId, @@ -182,10 +174,11 @@ function DashboardInner(props: DashboardProps) { parentId: localBackendModule.newDirectoryId(backendModule.Path(project.parentDirectory)), }) }) + return () => { window.projectManagementApi?.setOpenProjectHandler(() => {}) } - }, [dispatchAssetListEvent, openEditor, openProject, setCategory]) + }, [dispatchAssetListEvent, openEditor, openProject, categoriesAPI]) React.useEffect( () => @@ -241,8 +234,8 @@ function DashboardInner(props: DashboardProps) { if (asset != null && self != null) { setModal( { @@ -293,9 +286,6 @@ function DashboardInner(props: DashboardProps) { initialProjectName={initialProjectName} ydocUrl={ydocUrl} assetManagementApiRef={assetManagementApiRef} - category={category} - setCategory={setCategory} - resetCategory={resetCategory} /> diff --git a/app/gui/src/dashboard/pages/dashboard/DashboardTabBar.tsx b/app/gui/src/dashboard/pages/dashboard/DashboardTabBar.tsx index 81911cbc5a40..db18e9e3cf82 100644 --- a/app/gui/src/dashboard/pages/dashboard/DashboardTabBar.tsx +++ b/app/gui/src/dashboard/pages/dashboard/DashboardTabBar.tsx @@ -87,7 +87,7 @@ export function DashboardTabBar(props: DashboardTabBarProps) { ] return ( - + {/* @ts-expect-error - Making ts happy here requires too much attention */} {(tab) => } diff --git a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx index ecf77ccc5704..b6d5ea5376f9 100644 --- a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx +++ b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx @@ -7,7 +7,6 @@ import { Suspense } from '#/components/Suspense' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useOpenProjectMutation, useRenameProjectMutation } from '#/hooks/projectHooks' import type { AssetManagementApi } from '#/layouts/AssetsTable' -import type { Category } from '#/layouts/CategorySwitcher/Category' import Drive from '#/layouts/Drive' import type { GraphEditorRunner } from '#/layouts/Editor' import Editor from '#/layouts/Editor' @@ -22,22 +21,11 @@ export interface DashboardTabPanelsProps { readonly initialProjectName: string | null readonly ydocUrl: string | null readonly assetManagementApiRef: React.RefObject | null - readonly category: Category - readonly setCategory: (category: Category) => void - readonly resetCategory: () => void } /** The tab panels for the dashboard page. */ export function DashboardTabPanels(props: DashboardTabPanelsProps) { - const { - appRunner, - initialProjectName, - ydocUrl, - assetManagementApiRef, - category, - setCategory, - resetCategory, - } = props + const { appRunner, initialProjectName, ydocUrl, assetManagementApiRef } = props const page = usePage() @@ -62,9 +50,6 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) { children: (