diff --git a/app/ide-desktop/lib/assets/plus.svg b/app/ide-desktop/lib/assets/plus.svg index 7d5c4ce7877d..395a7476ec6b 100644 --- a/app/ide-desktop/lib/assets/plus.svg +++ b/app/ide-desktop/lib/assets/plus.svg @@ -1,7 +1,4 @@ - - - - - - + + \ No newline at end of file diff --git a/app/ide-desktop/lib/assets/plus2.svg b/app/ide-desktop/lib/assets/plus2.svg new file mode 100644 index 000000000000..7d5c4ce7877d --- /dev/null +++ b/app/ide-desktop/lib/assets/plus2.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index a7adabc312b9..a73475672ea4 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -45,7 +45,11 @@ export const SecretId = newtype.newtypeConstructor() /** Unique identifier for an arbitrary asset. */ export type AssetId = IdType[keyof IdType] -/** Unique identifier for a file tag or project tag. */ +/** The name of an asset label. */ +export type LabelName = newtype.Newtype +export const LabelName = newtype.newtypeConstructor() + +/** Unique identifier for a label. */ export type TagId = newtype.Newtype export const TagId = newtype.newtypeConstructor() @@ -240,29 +244,11 @@ export interface SecretInfo { id: SecretId } -/** The type of asset a specific tag can be applied to. */ -export enum TagObjectType { - file = 'File', - project = 'Project', -} - -/** A file tag or project tag. */ -export interface Tag { - /* eslint-disable @typescript-eslint/naming-convention */ - organization_id: UserOrOrganizationId +/** A label. */ +export interface Label { id: TagId - name: string - value: string - object_type: TagObjectType - object_id: string - /* eslint-enable @typescript-eslint/naming-convention */ -} - -/** Metadata uniquely identifying a file tag or project tag. */ -export interface TagInfo { - id: TagId - name: string - value: string + value: LabelName + color: LChColor } /** Type of application that a {@link Version} applies to. @@ -352,6 +338,47 @@ export enum FilterBy { trashed = 'Trashed', } +/** A color in the LCh colorspace. */ +export interface LChColor { + readonly lightness: number + readonly chroma: number + readonly hue: number + readonly alpha?: number +} + +/** A pre-selected list of colors to be used in color pickers. */ +export const COLORS: readonly LChColor[] = [ + /* eslint-disable @typescript-eslint/no-magic-numbers */ + // Red + { lightness: 50, chroma: 66, hue: 7 }, + // Orange + { lightness: 50, chroma: 66, hue: 34 }, + // Yellow + { lightness: 50, chroma: 66, hue: 80 }, + // Turquoise + { lightness: 50, chroma: 66, hue: 139 }, + // Teal + { lightness: 50, chroma: 66, hue: 172 }, + // Blue + { lightness: 50, chroma: 66, hue: 271 }, + // Lavender + { lightness: 50, chroma: 66, hue: 295 }, + // Pink + { lightness: 50, chroma: 66, hue: 332 }, + // Light blue + { lightness: 50, chroma: 22, hue: 252 }, + // Dark blue + { lightness: 22, chroma: 13, hue: 252 }, + /* eslint-enable @typescript-eslint/no-magic-numbers */ +] + +/** Converts a {@link LChColor} to a CSS color string. */ +export function lChColorToCssColor(color: LChColor): string { + return 'alpha' in color + ? `lcha(${color.lightness}% ${color.chroma} ${color.hue} / ${color.alpha})` + : `lch(${color.lightness}% ${color.chroma} ${color.hue})` +} + // ================= // === AssetType === // ================= @@ -418,6 +445,7 @@ export interface BaseAsset { * (and currently safe) to assume it is always a {@link DirectoryId}. */ parentId: DirectoryId permissions: UserPermission[] | null + labels: LabelName[] | null } /** Metadata uniquely identifying a directory entry. @@ -454,6 +482,7 @@ export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoad parentId: directoryId, permissions: [], projectState: null, + labels: [], } } @@ -471,6 +500,7 @@ export function createSpecialEmptyAsset(directoryId: DirectoryId): SpecialEmptyA parentId: directoryId, permissions: [], projectState: null, + labels: [], } } @@ -603,16 +633,15 @@ export interface CreateSecretRequestBody { /** HTTP request body for the "create tag" endpoint. */ export interface CreateTagRequestBody { - name: string value: string - objectType: TagObjectType - objectId: string + color: LChColor } /** URL query string parameters for the "list directory" endpoint. */ export interface ListDirectoryRequestParams { parentId: string | null filterBy: FilterBy | null + labels: LabelName[] | null recentProjects: boolean } @@ -623,11 +652,6 @@ export interface UploadFileRequestParams { parentDirectoryId: DirectoryId | null } -/** URL query string parameters for the "list tags" endpoint. */ -export interface ListTagsRequestParams { - tagType: TagObjectType -} - /** URL query string parameters for the "list versions" endpoint. */ export interface ListVersionsRequestParams { versionType: VersionType @@ -782,12 +806,18 @@ export abstract class Backend { abstract getSecret(secretId: SecretId, title: string | null): Promise /** Return the secret environment variables accessible by the user. */ abstract listSecrets(): Promise - /** Create a file tag or project tag. */ - abstract createTag(body: CreateTagRequestBody): Promise - /** Return file tags or project tags accessible by the user. */ - abstract listTags(params: ListTagsRequestParams): Promise - /** Delete a file tag or project tag. */ - abstract deleteTag(tagId: TagId): Promise + /** Create a label used for categorizing assets. */ + abstract createTag(body: CreateTagRequestBody): Promise + + + )} + + ) +} + +// ==================== +// === LabelsColumn === +// ==================== + +/** A column listing the labels on this asset. */ +function LabelsColumn(props: AssetColumnProps) { + const { + item: { item: asset }, + setItem, + state: { category, labels, deletedLabelNames, doCreateLabel }, + } = props + const session = authProvider.useNonPartialUserSession() + const { setModal } = modalProvider.useSetModal() + const { backend } = backendProvider.useBackend() + const [isHovered, setIsHovered] = React.useState(false) + const self = asset.permissions?.find( + permission => permission.user.user_email === session.organization?.email + ) + const managesThisAsset = + category !== categorySwitcher.Category.trash && + (self?.permission === permissions.PermissionAction.own || + self?.permission === permissions.PermissionAction.admin) + const setAsset = React.useCallback( + (valueOrUpdater: React.SetStateAction) => { + if (typeof valueOrUpdater === 'function') { + setItem(oldItem => ({ + ...oldItem, + item: valueOrUpdater(oldItem.item), + })) + } else { + setItem(oldItem => ({ ...oldItem, item: valueOrUpdater })) + } + }, + [/* should never change */ setItem] + ) + return ( +
{ + setIsHovered(true) + }} + onMouseLeave={() => { + setIsHovered(false) + }} + > + {(asset.labels ?? []) + .filter(label => !deletedLabelNames.has(label)) + .map(label => ( + + ))} + {managesThisAsset && ( + )}
@@ -321,10 +431,10 @@ export const COLUMN_HEADING: Record< {COLUMN_NAME[Column.sharedWith]} ), - [Column.tags]: () => ( + [Column.labels]: () => (
- {COLUMN_NAME[Column.tags]} + {COLUMN_NAME[Column.labels]}
), [Column.accessedByProjects]: () => ( @@ -356,7 +466,7 @@ export const COLUMN_RENDERER: Record JSX.El [Column.name]: AssetNameColumn, [Column.modified]: LastModifiedColumn, [Column.sharedWith]: SharedWithColumn, - [Column.tags]: PlaceholderColumn, + [Column.labels]: LabelsColumn, [Column.accessedByProjects]: PlaceholderColumn, [Column.accessedData]: PlaceholderColumn, [Column.docs]: PlaceholderColumn, diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx index 7a2ebe97467b..c6a29ac94494 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx @@ -5,13 +5,14 @@ import BlankIcon from 'enso-assets/blank.svg' import * as assetEventModule from '../events/assetEvent' import * as assetListEventModule from '../events/assetListEvent' -import type * as assetTreeNode from '../assetTreeNode' +import * as assetTreeNode from '../assetTreeNode' import * as authProvider from '../../authentication/providers/auth' import * as backendModule from '../backend' import * as backendProvider from '../../providers/backend' import * as download from '../../download' import * as errorModule from '../../error' import * as hooks from '../../hooks' +import * as identity from '../identity' import * as indent from '../indent' import * as modalProvider from '../../providers/modal' import * as visibilityModule from '../visibility' @@ -71,6 +72,7 @@ export default function AssetRow(props: AssetRowProps) { // parent. rawItem.item = asset }, [asset, rawItem]) + const setAsset = assetTreeNode.useSetAsset(asset, setItem) React.useEffect(() => { if (selected && visibility !== visibilityModule.Visibility.visible) { @@ -281,6 +283,21 @@ export default function AssetRow(props: AssetRowProps) { } break } + case assetEventModule.AssetEventType.deleteLabel: { + setAsset(oldAsset => { + let found = identity.identity(false) + const labels = + oldAsset.labels?.filter(label => { + if (label === event.labelName) { + found = true + return false + } else { + return true + } + }) ?? null + return found ? { ...oldAsset, labels } : oldAsset + }) + } } }) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx index fd786096c1d0..788792293036 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx @@ -56,10 +56,12 @@ const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL) /** The default placeholder row. */ const PLACEHOLDER = ( - You have no projects yet. Go ahead and create one using the button above, or open a template - from the home screen. + You have no files. Go ahead and create one using the buttons above, or open a template from + the home screen. ) +/** A placeholder row for when a query (text or labels) is active. */ +const QUERY_PLACEHOLDER = No files match the current filters. /** The placeholder row for the Trash category. */ const TRASH_PLACEHOLDER = Your trash is empty. /** Placeholder row for directories that are empty. */ @@ -166,6 +168,8 @@ const CATEGORY_TO_FILTER_BY: Record + deletedLabelNames: Set hasCopyData: boolean sortColumn: columnModule.SortableColumn | null setSortColumn: (column: columnModule.SortableColumn | null) => void @@ -191,6 +195,7 @@ export interface AssetsTableState { switchPage: boolean ) => void doCloseIde: (project: backendModule.ProjectAsset) => void + doCreateLabel: (value: string, color: backendModule.LChColor) => Promise doCut: () => void doPaste: ( newParentKey: backendModule.AssetId | null, @@ -216,8 +221,11 @@ export const INITIAL_ROW_STATE: AssetRowState = Object.freeze({ export interface AssetsTableProps { query: string category: categorySwitcher.Category + allLabels: Map + currentLabels: backendModule.LabelName[] | null initialProjectName: string | null projectStartupInfo: backendModule.ProjectStartupInfo | null + deletedLabelNames: Set /** These events will be dispatched the next time the assets list is refreshed, rather than * immediately. */ queuedAssetEvents: assetEventModule.AssetEvent[] @@ -231,6 +239,7 @@ export interface AssetsTableProps { switchPage: boolean ) => void doCloseIde: (project: backendModule.ProjectAsset) => void + doCreateLabel: (value: string, color: backendModule.LChColor) => Promise loadingProjectManagerDidFail: boolean isListingRemoteDirectoryWhileOffline: boolean isListingLocalDirectoryAndWillFail: boolean @@ -242,6 +251,9 @@ export default function AssetsTable(props: AssetsTableProps) { const { query, category, + allLabels, + currentLabels, + deletedLabelNames, initialProjectName, projectStartupInfo, queuedAssetEvents: rawQueuedAssetEvents, @@ -251,6 +263,7 @@ export default function AssetsTable(props: AssetsTableProps) { dispatchAssetEvent, doOpenIde, doCloseIde: rawDoCloseIde, + doCreateLabel, loadingProjectManagerDidFail, isListingRemoteDirectoryWhileOffline, isListingLocalDirectoryAndWillFail, @@ -399,7 +412,7 @@ export default function AssetsTable(props: AssetsTableProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialProjectName]) - const overwriteAssets = React.useCallback( + const overwriteNodes = React.useCallback( (newAssets: backendModule.AnyAsset[]) => { // This is required, otherwise we are using an outdated // `nameOfProjectToImmediatelyOpen`. @@ -461,7 +474,7 @@ export default function AssetsTable(props: AssetsTableProps) { React.useEffect(() => { if (initialized) { - overwriteAssets([]) + overwriteNodes([]) } // `overwriteAssets` is a callback, not a dependency. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -478,12 +491,13 @@ export default function AssetsTable(props: AssetsTableProps) { parentId: null, filterBy: CATEGORY_TO_FILTER_BY[category], recentProjects: category === categorySwitcher.Category.recent, + labels: currentLabels, }, null ) if (!signal.aborted) { setIsLoading(false) - overwriteAssets(newAssets) + overwriteNodes(newAssets) } } break @@ -493,17 +507,104 @@ export default function AssetsTable(props: AssetsTableProps) { !isListingRemoteDirectoryAndWillFail && !isListingRemoteDirectoryWhileOffline ) { + const queuedDirectoryListings = new Map< + backendModule.AssetId, + backendModule.AnyAsset[] + >() + /** An {@link assetTreeNode.AssetTreeNode} with no children. */ + interface AssetTreeNodeWithNoChildren extends assetTreeNode.AssetTreeNode { + children: null + } + const withChildren = ( + node: AssetTreeNodeWithNoChildren + ): assetTreeNode.AssetTreeNode => { + const queuedListing = queuedDirectoryListings.get(node.item.id) + if ( + queuedListing == null || + !backendModule.assetIsDirectory(node.item) + ) { + return node + } else { + const directoryAsset = node.item + const depth = node.depth + 1 + return { + ...node, + children: queuedListing.map(asset => + withChildren({ + key: asset.id, + item: asset, + directoryKey: directoryAsset.id, + directoryId: directoryAsset.id, + children: null, + depth, + }) + ), + } + } + } + for (const entry of nodeMapRef.current.values()) { + if ( + backendModule.assetIsDirectory(entry.item) && + entry.children != null + ) { + const id = entry.item.id + void backend + .listDirectory( + { + parentId: id, + filterBy: CATEGORY_TO_FILTER_BY[category], + recentProjects: + category === categorySwitcher.Category.recent, + labels: currentLabels, + }, + entry.item.title + ) + .then(assets => { + setAssetTree(oldTree => { + let found = signal.aborted + const newTree = signal.aborted + ? oldTree + : assetTreeNode.assetTreeMap(oldTree, oldAsset => { + if (oldAsset.key === entry.key) { + found = true + const depth = oldAsset.depth + 1 + return { + ...oldAsset, + children: assets.map(asset => + withChildren({ + key: asset.id, + item: asset, + directoryKey: entry.key, + directoryId: id, + children: null, + depth, + }) + ), + } + } else { + return oldAsset + } + }) + if (!found) { + queuedDirectoryListings.set(entry.key, assets) + } + return newTree + }) + }) + } + } const newAssets = await backend.listDirectory( { parentId: null, filterBy: CATEGORY_TO_FILTER_BY[category], recentProjects: category === categorySwitcher.Category.recent, + labels: currentLabels, }, null ) if (!signal.aborted) { setIsLoading(false) - overwriteAssets(newAssets) + overwriteNodes(newAssets) } } else { setIsLoading(false) @@ -512,7 +613,7 @@ export default function AssetsTable(props: AssetsTableProps) { } } }, - [category, accessToken, organization, backend] + [category, currentLabels, accessToken, organization, backend] ) React.useEffect(() => { @@ -610,6 +711,7 @@ export default function AssetsTable(props: AssetsTableProps) { parentId: directoryId, filterBy: CATEGORY_TO_FILTER_BY[category], recentProjects: category === categorySwitcher.Category.recent, + labels: currentLabels, }, title ?? null ) @@ -676,7 +778,7 @@ export default function AssetsTable(props: AssetsTableProps) { })() } }, - [category, nodeMapRef, backend] + [category, currentLabels, backend] ) const getNewProjectName = React.useCallback( @@ -710,13 +812,14 @@ export default function AssetsTable(props: AssetsTableProps) { Math.max(0, ...directoryIndices) + 1 }` const placeholderItem: backendModule.DirectoryAsset = { + type: backendModule.AssetType.directory, id: backendModule.DirectoryId(uniqueString.uniqueString()), title, modifiedAt: dateTime.toRfc3339(new Date()), parentId: event.parentId ?? rootDirectoryId, permissions: permissions.tryGetSingletonOwnerPermission(organization, user), projectState: null, - type: backendModule.AssetType.directory, + labels: [], } if ( event.parentId != null && @@ -755,6 +858,7 @@ export default function AssetsTable(props: AssetsTableProps) { const projectName = getNewProjectName(event.templateId, event.parentId) const dummyId = backendModule.ProjectId(uniqueString.uniqueString()) const placeholderItem: backendModule.ProjectAsset = { + type: backendModule.AssetType.project, id: dummyId, title: projectName, modifiedAt: dateTime.toRfc3339(new Date()), @@ -767,7 +871,7 @@ export default function AssetsTable(props: AssetsTableProps) { // eslint-disable-next-line @typescript-eslint/naming-convention ...(organization != null ? { opened_by: organization.email } : {}), }, - type: backendModule.AssetType.project, + labels: [], } if ( event.parentId != null && @@ -816,6 +920,7 @@ export default function AssetsTable(props: AssetsTableProps) { permissions: permissions.tryGetSingletonOwnerPermission(organization, user), modifiedAt: dateTime.toRfc3339(new Date()), projectState: null, + labels: [], }) ) const placeholderProjects = reversedFiles.filter(backendModule.fileIsProject).map( @@ -833,6 +938,7 @@ export default function AssetsTable(props: AssetsTableProps) { // eslint-disable-next-line @typescript-eslint/naming-convention ...(organization != null ? { opened_by: organization.email } : {}), }, + labels: [], }) ) if ( @@ -889,13 +995,14 @@ export default function AssetsTable(props: AssetsTableProps) { } case assetListEventModule.AssetListEventType.newDataConnector: { const placeholderItem: backendModule.SecretAsset = { + type: backendModule.AssetType.secret, id: backendModule.SecretId(uniqueString.uniqueString()), title: event.name, modifiedAt: dateTime.toRfc3339(new Date()), parentId: event.parentId ?? rootDirectoryId, permissions: permissions.tryGetSingletonOwnerPermission(organization, user), projectState: null, - type: backendModule.AssetType.secret, + labels: [], } if ( event.parentId != null && @@ -1184,6 +1291,8 @@ export default function AssetsTable(props: AssetsTableProps) { (): AssetsTableState => ({ numberOfSelectedItems: selectedKeys.size, category, + labels: allLabels, + deletedLabelNames, hasCopyData: copyData != null, sortColumn, setSortColumn, @@ -1198,12 +1307,15 @@ export default function AssetsTable(props: AssetsTableProps) { doOpenManually, doOpenIde, doCloseIde, + doCreateLabel, doCut, doPaste, }), [ selectedKeys.size, category, + allLabels, + deletedLabelNames, copyData, sortColumn, sortDirection, @@ -1212,6 +1324,7 @@ export default function AssetsTable(props: AssetsTableProps) { doOpenManually, doOpenIde, doCloseIde, + doCreateLabel, doCut, doPaste, /* should never change */ setSortColumn, @@ -1300,6 +1413,8 @@ export default function AssetsTable(props: AssetsTableProps) { placeholder={ category === categorySwitcher.Category.trash ? TRASH_PLACEHOLDER + : query !== '' || currentLabels != null + ? QUERY_PLACEHOLDER : PLACEHOLDER } columns={columnModule.getColumnList(backend.type, extraColumns).map(column => ({ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/colorPicker.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/colorPicker.tsx new file mode 100644 index 000000000000..d14efbca6eb9 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/colorPicker.tsx @@ -0,0 +1,39 @@ +/** @file A color picker to select from a predetermined list of colors. */ +import * as React from 'react' + +import * as backend from '../backend' + +/** Props for a {@link ColorPicker}. */ +export interface ColorPickerProps { + setColor: (color: backend.LChColor) => void +} + +/** A color picker to select from a predetermined list of colors. */ +export default function ColorPicker(props: ColorPickerProps) { + const { setColor } = props + return ( + <> + {backend.COLORS.map((currentColor, i) => ( + + ))} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx index af098d9c5b78..dc5ba167e527 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx @@ -68,7 +68,8 @@ export default function ConnectorNameColumn(props: ConnectorNameColumnProps) { case assetEventModule.AssetEventType.delete: case assetEventModule.AssetEventType.restore: case assetEventModule.AssetEventType.downloadSelected: - case assetEventModule.AssetEventType.removeSelf: { + case assetEventModule.AssetEventType.removeSelf: + case assetEventModule.AssetEventType.deleteLabel: { // Ignored. These events should all be unrelated to secrets. // `deleteMultiple`, `restoreMultiple` and `downloadSelected` are handled by // `AssetRow`. diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx index 994f6f58bd0a..15fc09e021c4 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx @@ -97,7 +97,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { case assetEventModule.AssetEventType.delete: case assetEventModule.AssetEventType.restore: case assetEventModule.AssetEventType.downloadSelected: - case assetEventModule.AssetEventType.removeSelf: { + case assetEventModule.AssetEventType.removeSelf: + case assetEventModule.AssetEventType.deleteLabel: { // Ignored. These events should all be unrelated to directories. // `deleteMultiple`, `restoreMultiple` and `downloadSelected` are handled by // `AssetRow`. diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/drive.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/drive.tsx index 6781acfc1ed4..fd575aeb0d69 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/drive.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/drive.tsx @@ -3,14 +3,16 @@ import * as React from 'react' import * as common from 'enso-common' -import type * as assetEventModule from '../events/assetEvent' +import * as assetEventModule from '../events/assetEvent' import * as assetListEventModule from '../events/assetListEvent' import * as authProvider from '../../authentication/providers/auth' import * as backendModule from '../backend' import * as backendProvider from '../../providers/backend' import * as hooks from '../../hooks' +import * as identity from '../identity' import * as localStorageModule from '../localStorage' import * as localStorageProvider from '../../providers/localStorage' +import * as uniqueString from '../../uniqueString' import * as app from '../../components/app' import * as pageSwitcher from './pageSwitcher' @@ -18,6 +20,7 @@ import type * as spinner from './spinner' import CategorySwitcher, * as categorySwitcher from './categorySwitcher' import AssetsTable from './assetsTable' import DriveBar from './driveBar' +import Labels from './labels' // ============= // === Drive === @@ -81,6 +84,16 @@ export default function Drive(props: DriveProps) { localStorage.get(localStorageModule.LocalStorageKey.driveCategory) ?? categorySwitcher.Category.home ) + const [labels, setLabels] = React.useState([]) + const [currentLabels, setCurrentLabels] = React.useState(null) + const [newLabelNames, setNewLabelNames] = React.useState(new Set()) + const [deletedLabelNames, setDeletedLabelNames] = React.useState( + new Set() + ) + const allLabels = React.useMemo( + () => new Map(labels.map(label => [label.value, label])), + [labels] + ) React.useEffect(() => { const onBlur = () => { @@ -92,6 +105,14 @@ export default function Drive(props: DriveProps) { } }, []) + React.useEffect(() => { + void (async () => { + if (backend.type !== backendModule.BackendType.local) { + setLabels(await backend.listTags()) + } + })() + }, [backend]) + const doUploadFiles = React.useCallback( (files: File[]) => { if (backend.type !== backendModule.BackendType.local && organization == null) { @@ -133,6 +154,102 @@ export default function Drive(props: DriveProps) { }) }, [/* should never change */ dispatchAssetListEvent]) + const doCreateLabel = React.useCallback( + async (value: string, color: backendModule.LChColor) => { + const newLabelName = backendModule.LabelName(value) + const placeholderLabel: backendModule.Label = { + id: backendModule.TagId(uniqueString.uniqueString()), + value: newLabelName, + color, + } + setNewLabelNames(labelNames => new Set([...labelNames, newLabelName])) + setLabels(oldLabels => [...oldLabels, placeholderLabel]) + try { + const newLabel = await backend.createTag({ value, color }) + setLabels(oldLabels => + oldLabels.map(oldLabel => + oldLabel.id === placeholderLabel.id ? newLabel : oldLabel + ) + ) + setCurrentLabels(oldLabels => { + let found = identity.identity(false) + const newLabels = + oldLabels?.map(oldLabel => { + if (oldLabel === placeholderLabel.value) { + found = true + return newLabel.value + } else { + return oldLabel + } + }) ?? null + return found ? newLabels : oldLabels + }) + } catch (error) { + toastAndLog(null, error) + setLabels(oldLabels => + oldLabels.filter(oldLabel => oldLabel.id !== placeholderLabel.id) + ) + setCurrentLabels(oldLabels => { + let found = identity.identity(false) + const newLabels = (oldLabels ?? []).filter(oldLabel => { + if (oldLabel === placeholderLabel.value) { + found = true + return false + } else { + return true + } + }) + return found ? (newLabels.length === 0 ? null : newLabels) : oldLabels + }) + } + setNewLabelNames( + labelNames => + new Set([...labelNames].filter(labelName => labelName !== newLabelName)) + ) + }, + [backend, /* should never change */ toastAndLog] + ) + + const doDeleteLabel = React.useCallback( + async (id: backendModule.TagId, value: backendModule.LabelName) => { + setDeletedLabelNames(oldNames => new Set([...oldNames, value])) + setCurrentLabels(oldLabels => { + let found = identity.identity(false) + const newLabels = oldLabels?.filter(oldLabel => { + if (oldLabel === value) { + found = true + return false + } else { + return true + } + }) + return newLabels != null && newLabels.length > 0 + ? found + ? newLabels + : oldLabels + : null + }) + try { + await backend.deleteTag(id, value) + dispatchAssetEvent({ + type: assetEventModule.AssetEventType.deleteLabel, + labelName: value, + }) + setLabels(oldLabels => oldLabels.filter(oldLabel => oldLabel.id !== id)) + } catch (error) { + toastAndLog(null, error) + } + setDeletedLabelNames( + oldNames => new Set([...oldNames].filter(oldValue => oldValue !== value)) + ) + }, + [ + backend, + /* should never change */ dispatchAssetEvent, + /* should never change */ toastAndLog, + ] + ) + const doCreateDataConnector = React.useCallback( (name: string, value: string) => { dispatchAssetListEvent({ @@ -218,13 +335,25 @@ export default function Drive(props: DriveProps) { setCategory={setCategory} dispatchAssetEvent={dispatchAssetEvent} /> + )} +} + +/** An label that can be applied to an asset. */ +export default function Label(props: InternalLabelProps) { + const { + active = false, + disabled = false, + color, + className = 'text-tag-text', + onClick, + children, + } = props + return ( + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/labels.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/labels.tsx new file mode 100644 index 000000000000..4adced453bc8 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/labels.tsx @@ -0,0 +1,123 @@ +/** @file A list of selectable labels. */ +import * as React from 'react' + +import PlusIcon from 'enso-assets/plus.svg' +import Trash2Icon from 'enso-assets/trash2.svg' + +import type * as backend from '../backend' +import * as modalProvider from '../../providers/modal' + +import Label, * as labelModule from './label' +import ConfirmDeleteModal from './confirmDeleteModal' +import NewLabelModal from './newLabelModal' +import SvgMask from '../../authentication/components/svgMask' + +// ============== +// === Labels === +// ============== + +/** Props for a {@link Labels}. */ +export interface LabelsProps { + labels: backend.Label[] + currentLabels: backend.LabelName[] | null + setCurrentLabels: React.Dispatch> + doCreateLabel: (name: string, color: backend.LChColor) => void + doDeleteLabel: (id: backend.TagId, name: backend.LabelName) => void + newLabelNames: Set + deletedLabelNames: Set +} + +/** A list of selectable labels. */ +export default function Labels(props: LabelsProps) { + const { + labels, + currentLabels, + setCurrentLabels, + doCreateLabel, + doDeleteLabel, + newLabelNames, + deletedLabelNames, + } = props + const { setModal } = modalProvider.useSetModal() + + return ( +
+
+ + Labels + +
+
    + {labels + .filter(label => !deletedLabelNames.has(label.value)) + .map(label => ( +
  • + + {!newLabelNames.has(label.value) && ( + + )} +
  • + ))} +
  • + +
  • +
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/manageLabelsModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/manageLabelsModal.tsx new file mode 100644 index 000000000000..3bd343693419 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/manageLabelsModal.tsx @@ -0,0 +1,209 @@ +/** @file A modal to select labels for an asset. */ +import * as React from 'react' + +import * as auth from '../../authentication/providers/auth' +import * as backendModule from '../backend' +import * as backendProvider from '../../providers/backend' +import * as hooks from '../../hooks' +import * as modalProvider from '../../providers/modal' +import * as string from '../../string' + +import ColorPicker from './colorPicker' +import Label from './label' +import Modal from './modal' + +// ========================= +// === ManageLabelsModal === +// ========================= + +/** Props for a {@link ManageLabelsModal}. */ +export interface ManageLabelsModalProps< + Asset extends backendModule.AnyAsset = backendModule.AnyAsset, +> { + item: Asset + setItem: React.Dispatch> + allLabels: Map + doCreateLabel: (value: string, color: backendModule.LChColor) => Promise + /** If this is `null`, this modal will be centered. */ + eventTarget: HTMLElement | null +} + +/** A modal to select labels for an asset. + * @throws {Error} when the current backend is the local backend, or when the user is offline. + * This should never happen, as this modal should not be accessible in either case. */ +export default function ManageLabelsModal< + Asset extends backendModule.AnyAsset = backendModule.AnyAsset, +>(props: ManageLabelsModalProps) { + const { item, setItem, allLabels, doCreateLabel, eventTarget } = props + const { organization } = auth.useNonPartialUserSession() + const { backend } = backendProvider.useBackend() + const { unsetModal } = modalProvider.useSetModal() + const toastAndLog = hooks.useToastAndLog() + const [labels, setLabels] = React.useState(item.labels ?? []) + const [query, setQuery] = React.useState('') + const [color, setColor] = React.useState(null) + const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget]) + const labelNames = React.useMemo(() => new Set(labels), [labels]) + const regex = React.useMemo(() => new RegExp(string.regexEscape(query), 'i'), [query]) + const canSelectColor = React.useMemo( + () => + query !== '' && + Array.from(allLabels.keys()).filter(label => regex.test(label)).length === 0, + [allLabels, query, regex] + ) + const canCreateNewLabel = canSelectColor && color != null + + React.useEffect(() => { + setItem(oldItem => ({ ...oldItem, labels })) + }, [labels, /* should never change */ setItem]) + + if (backend.type === backendModule.BackendType.local || organization == null) { + // This should never happen - the local backend does not have the "labels" column, + // and `organization` is absent only when offline - in which case the user should only + // be able to access the local backend. + // This MUST be an error, otherwise the hooks below are considered as conditionally called. + throw new Error('Cannot add labels to assets on the local backend.') + } else { + const doToggleLabel = React.useCallback( + async (name: backendModule.LabelName) => { + const newLabels = labelNames.has(name) + ? labels.filter(label => label !== name) + : [...labels, name] + setLabels(newLabels) + try { + await backend.associateTag(item.id, newLabels, item.title) + } catch (error) { + toastAndLog(null, error) + setLabels(labels) + } + }, + [ + labelNames, + labels, + item.id, + item.title, + backend, + /* should never change */ toastAndLog, + ] + ) + + return ( + +
{ + mouseEvent.stopPropagation() + }} + onContextMenu={mouseEvent => { + mouseEvent.stopPropagation() + mouseEvent.preventDefault() + }} + onKeyDown={event => { + if (event.key !== 'Escape') { + event.stopPropagation() + } + }} + > +
+
+
+

Labels

+ {/* Space reserved for other tabs. */} +
+
{ + event.preventDefault() + setLabels(oldLabels => [ + ...oldLabels, + backendModule.LabelName(query), + ]) + try { + if (color != null) { + await doCreateLabel(query, color) + } + } catch (error) { + toastAndLog(null, error) + setLabels(oldLabels => + oldLabels.filter(oldLabel => oldLabel !== query) + ) + } + unsetModal() + }} + > +
+ { + setQuery(event.currentTarget.value) + }} + /> +
+ +
+ {canSelectColor && ( +
+
+ +
+
+ )} +
+ {Array.from(allLabels.values()) + .filter(label => regex.test(label.value)) + .map(label => ( +
+ +
+ ))} +
+
+
+ + ) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx index 0519ccdb3b94..61f086f37031 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/managePermissionsModal.tsx @@ -97,7 +97,7 @@ export default function ManagePermissionsModal< // and `organization` is absent only when offline - in which case the user should only // be able to access the local backend. // This MUST be an error, otherwise the hooks below are considered as conditionally called. - throw new Error('Cannot share projects on the local backend.') + throw new Error('Cannot share assets on the local backend.') } else { const listedUsers = hooks.useAsyncEffect([], () => backend.listUsers(), []) const allUsers = React.useMemo( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/newLabelModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/newLabelModal.tsx new file mode 100644 index 000000000000..50237835de17 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/newLabelModal.tsx @@ -0,0 +1,132 @@ +/** @file A modal for creating a new label. */ +import * as React from 'react' +import * as toastify from 'react-toastify' + +import * as backend from '../backend' +import * as errorModule from '../../error' +import * as loggerProvider from '../../providers/logger' +import * as modalProvider from '../../providers/modal' + +import ColorPicker from './colorPicker' +import Modal from './modal' + +// ===================== +// === NewLabelModal === +// ===================== + +/** Props for a {@link ConfirmDeleteModal}. */ +export interface NewLabelModalProps { + labelNames: Set + eventTarget: HTMLElement + doCreate: (value: string, color: backend.LChColor) => void +} + +/** A modal for creating a new label. */ +export default function NewLabelModal(props: NewLabelModalProps) { + const { labelNames, eventTarget, doCreate } = props + const logger = loggerProvider.useLogger() + const { unsetModal } = modalProvider.useSetModal() + const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget]) + + const [value, setName] = React.useState('') + const [color, setColor] = React.useState(null) + const canSubmit = Boolean(value && !labelNames.has(value) && color) + + const onSubmit = () => { + unsetModal() + try { + if (color != null) { + doCreate(value, color) + } + } catch (error) { + const message = errorModule.getMessageOrToString(error) + toastify.toast.error(message) + logger.error(message) + } + } + + return ( + +
{ + if (event.key !== 'Escape') { + event.stopPropagation() + } + }} + > +
+
{ + event.stopPropagation() + }} + onSubmit={event => { + event.preventDefault() + // Consider not calling `onSubmit()` here to make it harder to accidentally + // delete an important asset. + onSubmit() + }} + className="relative flex flex-col rounded-2xl gap-2 w-80 px-4 py-2" + > +

New Label

+ + +
+ + +
+
+
+ + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx index 89a3da366186..533146556d72 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx @@ -246,7 +246,8 @@ export default function ProjectIcon(props: ProjectIconProps) { case assetEventModule.AssetEventType.delete: case assetEventModule.AssetEventType.restore: case assetEventModule.AssetEventType.downloadSelected: - case assetEventModule.AssetEventType.removeSelf: { + case assetEventModule.AssetEventType.removeSelf: + case assetEventModule.AssetEventType.deleteLabel: { // Ignored. Any missing project-related events should be handled by // `ProjectNameColumn`. `deleteMultiple`, `restoreMultiple` and `downloadSelected` // are handled by `AssetRow`. diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx index fff3eb2603c1..2be4ffe57591 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx @@ -111,7 +111,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { case assetEventModule.AssetEventType.delete: case assetEventModule.AssetEventType.restore: case assetEventModule.AssetEventType.downloadSelected: - case assetEventModule.AssetEventType.removeSelf: { + case assetEventModule.AssetEventType.removeSelf: + case assetEventModule.AssetEventType.deleteLabel: { // Ignored. Any missing project-related events should be handled by `ProjectIcon`. // `deleteMultiple`, `restoreMultiple` and `downloadSelected` are handled by // `AssetRow`. diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts index 94af7d31131b..e917bc06a743 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts @@ -32,6 +32,7 @@ export enum AssetEventType { restore = 'restore', downloadSelected = 'download-selected', removeSelf = 'remove-self', + deleteLabel = 'delete-label', } /** Properties common to all asset state change events. */ @@ -55,6 +56,7 @@ interface AssetEvents { restore: AssetRestoreEvent downloadSelected: AssetDownloadSelectedEvent removeSelf: AssetRemoveSelfEvent + deleteLabel: AssetDeleteLabelEvent } /** A type to ensure that {@link AssetEvents} contains every {@link AssetLEventType}. */ @@ -143,5 +145,10 @@ export interface AssetRemoveSelfEvent extends AssetBaseEvent { + labelName: backendModule.LabelName +} + /** Every possible type of asset event. */ export type AssetEvent = AssetEvents[keyof AssetEvents] diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/identity.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/identity.ts new file mode 100644 index 000000000000..8bb7702f9183 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/identity.ts @@ -0,0 +1,6 @@ +/** @file An identity function. Useful to make TypeScript avoid narrowing. */ + +/** An identity function. */ +export function identity(value: T): T { + return value +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts index 7d8cb29fc958..5e70631f913e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts @@ -67,6 +67,7 @@ export class LocalBackend extends backend.Backend { // eslint-disable-next-line @typescript-eslint/naming-convention volume_id: '', }, + labels: [], })) } @@ -323,7 +324,9 @@ export class LocalBackend extends backend.Backend { /** @throws An error stating that the operation is intentionally unavailable on the local * backend. */ invalidOperation(): never { - throw new Error('Cannot manage users, folders, files, and secrets on the local backend.') + throw new Error( + 'Cannot manage users, folders, files, tags, and secrets on the local backend.' + ) } /** Do nothing. This function should never need to be called. */ @@ -412,6 +415,11 @@ export class LocalBackend extends backend.Backend { return Promise.resolve([]) } + /** Do nothing. This function should never need to be called. */ + override associateTag() { + return Promise.resolve() + } + /** Do nothing. This function should never need to be called. */ override deleteTag() { return Promise.resolve() diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts index 92f3d3b7d149..cba0c068f5e7 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts @@ -103,7 +103,7 @@ export interface ListSecretsResponseBody { /** HTTP response body for the "list tag" endpoint. */ export interface ListTagsResponseBody { - tags: backendModule.Tag[] + tags: backendModule.Label[] } /** HTTP response body for the "list versions" endpoint. */ @@ -242,14 +242,12 @@ export class RemoteBackend extends backendModule.Backend { const response = await this.get( remoteBackendPaths.LIST_DIRECTORY_PATH + '?' + - new URLSearchParams({ - // eslint-disable-next-line @typescript-eslint/naming-convention - ...(query.parentId != null ? { parent_id: query.parentId } : {}), - // eslint-disable-next-line @typescript-eslint/naming-convention - ...(query.filterBy != null ? { filter_by: query.filterBy } : {}), - // eslint-disable-next-line @typescript-eslint/naming-convention - ...(query.recentProjects ? { recent_projects: String(true) } : {}), - }).toString() + new URLSearchParams([ + ...(query.parentId != null ? [['parent_id', query.parentId]] : []), + ...(query.filterBy != null ? [['filter_by', query.filterBy]] : []), + ...(query.recentProjects ? [['recent_projects', String(true)]] : []), + ...(query.labels != null ? query.labels.map(label => ['label', label]) : []), + ]).toString() ) if (!responseIsSuccessful(response)) { if (response.status === STATUS_SERVER_ERROR) { @@ -642,58 +640,70 @@ export class RemoteBackend extends backendModule.Backend { } } - /** Create a file tag or project tag. + /** Create a label used for categorizing assets. * * @throws An error if a non-successful status code (not 200-299) was received. */ override async createTag( body: backendModule.CreateTagRequestBody - ): Promise { - const response = await this.post( + ): Promise { + const response = await this.post( remoteBackendPaths.CREATE_TAG_PATH, - { - /* eslint-disable @typescript-eslint/naming-convention */ - tag_name: body.name, - tag_value: body.value, - object_type: body.objectType, - object_id: body.objectId, - /* eslint-enable @typescript-eslint/naming-convention */ - } + body ) if (!responseIsSuccessful(response)) { - return this.throw(`Could not create create tag with name '${body.name}'.`) + return this.throw(`Could not create label '${body.value}'.`) } else { return await response.json() } } - /** Return file tags or project tags accessible by the user. + /** Return all labels accessible by the user. * * @throws An error if a non-successful status code (not 200-299) was received. */ - override async listTags( - params: backendModule.ListTagsRequestParams - ): Promise { - const response = await this.get( - remoteBackendPaths.LIST_TAGS_PATH + - '?' + - new URLSearchParams({ - // eslint-disable-next-line @typescript-eslint/naming-convention - tag_type: params.tagType, - }).toString() - ) + override async listTags(): Promise { + const response = await this.get(remoteBackendPaths.LIST_TAGS_PATH) if (!responseIsSuccessful(response)) { - return this.throw(`Could not list tags of type '${params.tagType}'.`) + return this.throw(`Could not list labels.`) } else { return (await response.json()).tags } } - /** Delete a secret environment variable. + /** Set the full list of labels for a specific asset. * * @throws An error if a non-successful status code (not 200-299) was received. */ - override async deleteTag(tagId: backendModule.TagId): Promise { + override async associateTag( + assetId: backendModule.AssetId, + labels: backendModule.LabelName[], + title: string | null + ) { + const response = await this.patch( + remoteBackendPaths.associateTagPath(assetId), + { + labels, + } + ) + if (!responseIsSuccessful(response)) { + return this.throw( + `Could not set labels for asset ${ + title != null ? `'${title}'` : `with ID '${assetId}'` + }.` + ) + } else { + return + } + } + + /** Delete a label. + * + * @throws An error if a non-successful status code (not 200-299) was received. */ + override async deleteTag( + tagId: backendModule.TagId, + value: backendModule.LabelName + ): Promise { const response = await this.delete(remoteBackendPaths.deleteTagPath(tagId)) if (!responseIsSuccessful(response)) { - return this.throw(`Could not delete tag with ID '${tagId}'.`) + return this.throw(`Could not delete label '${value}'.`) } else { return } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts index 5d4a8b7b59bb..d4b510557522 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts @@ -75,7 +75,11 @@ export function checkResourcesPath(projectId: backend.ProjectId) { export function getSecretPath(secretId: backend.SecretId) { return `secrets/${secretId}` } +/** Relative HTTP path to the "associate tag" endpoint of the Cloud backend API. */ +export function associateTagPath(assetId: backend.AssetId) { + return `assets/${assetId}/labels` +} /** Relative HTTP path to the "delete tag" endpoint of the Cloud backend API. */ export function deleteTagPath(tagId: backend.TagId) { - return `secrets/${tagId}` + return `tags/${tagId}` } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx index 6b55439f85d2..62540f9bdfeb 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx @@ -20,17 +20,19 @@ export function useToastAndLog() { const logger = loggerProvider.useLogger() return React.useCallback( ( - messagePrefix: string, + messagePrefix: string | null, error?: errorModule.MustNotBeKnown, options?: toastify.ToastOptions ) => { const message = error == null - ? `${messagePrefix}.` + ? `${messagePrefix ?? ''}.` : // DO NOT explicitly pass the generic parameter anywhere else. // It is only being used here because this function also checks for // `MustNotBeKnown`. - `${messagePrefix}: ${errorModule.getMessageOrToString(error)}` + `${ + messagePrefix != null ? messagePrefix + ': ' : '' + }${errorModule.getMessageOrToString(error)}` const id = toastify.toast.error(message, options) logger.error(message) return id diff --git a/app/ide-desktop/lib/dashboard/tailwind.config.ts b/app/ide-desktop/lib/dashboard/tailwind.config.ts index b590692e550b..48334471a6b8 100644 --- a/app/ide-desktop/lib/dashboard/tailwind.config.ts +++ b/app/ide-desktop/lib/dashboard/tailwind.config.ts @@ -53,6 +53,8 @@ export const theme = { 'permission-docs': 'rgba(91, 8, 226, 0.64)', 'permission-exec': 'rgba(236, 2, 2, 0.70)', 'permission-view': 'rgba(0, 0, 0, 0.10)', + 'label-running-project': '#257fd2', + 'label-low-resources': '#ff6b18', 'call-to-action': '#fa6c08', 'black-a5': 'rgba(0, 0, 0, 0.05)', 'black-a10': 'rgba(0, 0, 0, 0.10)', diff --git a/app/ide-desktop/lib/dashboard/test-e2e/actions.ts b/app/ide-desktop/lib/dashboard/test-e2e/actions.ts index fd4b5e4263e8..7860a45fc39e 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/test-e2e/actions.ts @@ -53,21 +53,36 @@ export function locateUsernameInput(page: test.Locator | test.Page) { return page.getByPlaceholder('Username') } +/** Find a "name" input for a "new label" modal (if any) on the current page. */ +export function locateNewLabelModalNameInput(page: test.Locator | test.Page) { + return locateNewLabelModal(page).getByLabel('Name') +} + +/** Find all color radio button inputs for a "new label" modal (if any) on the current page. */ +export function locateNewLabelModalColorButtons(page: test.Locator | test.Page) { + return ( + locateNewLabelModal(page) + .filter({ has: page.getByText('Color') }) + // The `radio` inputs are invisible, so they cannot be used in the locator. + .getByRole('button') + ) +} + // === Button locators === /** Find a login button (if any) on the current page. */ export function locateLoginButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Login', exact: true }) + return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') } /** Find a register button (if any) on the current page. */ export function locateRegisterButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Register' }) + return page.getByRole('button', { name: 'Register' }).getByText('Register') } /** Find a reset button (if any) on the current page. */ export function locateResetButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Reset' }) + return page.getByRole('button', { name: 'Reset' }).getByText('Reset') } /** Find a user menu button (if any) on the current page. */ @@ -77,22 +92,34 @@ export function locateUserMenuButton(page: test.Locator | test.Page) { /** Find a change password button (if any) on the current page. */ export function locateChangePasswordButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Change your password' }) + return page + .getByRole('button', { name: 'Change your password' }) + .getByText('Change your password') } /** Find a "sign out" button (if any) on the current page. */ export function locateSignOutButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Sign out' }) + return page.getByRole('button', { name: 'Sign out' }).getByText('Sign out') } /** Find a "set username" button (if any) on the current page. */ export function locateSetUsernameButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Set Username' }) + return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username') } /** Find a "delete" button (if any) on the current page. */ export function locateDeleteButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Delete' }) + return page.getByRole('button', { name: 'Delete' }).getByText('Delete') +} + +/** Find a button to delete something (if any) on the current page. */ +export function locateDeleteIcon(page: test.Locator | test.Page) { + return page.getByAltText('Delete') +} + +/** Find a "create" button (if any) on the current page. */ +export function locateCreateButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Create' }).getByText('Create') } /** Find a button to open the editor (if any) on the current page. */ @@ -105,86 +132,96 @@ export function locateStopProjectButton(page: test.Locator | test.Page) { return page.getByAltText('Stop execution') } +/** Find all labels in the labels panel (if any) on the current page. */ +export function locateLabelsPanelLabels(page: test.Locator | test.Page) { + return locateLabelsPanel(page).getByRole('button') +} + // === Context menu buttons === /** Find an "open" button (if any) on the current page. */ export function locateOpenButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Open' }) + return page.getByRole('button', { name: 'Open' }).getByText('Open') } /** Find an "upload to cloud" button (if any) on the current page. */ export function locateUploadToCloudButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Upload To Cloud' }) + return page.getByRole('button', { name: 'Upload To Cloud' }).getByText('Upload To Cloud') } /** Find a "rename" button (if any) on the current page. */ export function locateRenameButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Rename' }) + return page.getByRole('button', { name: 'Rename' }).getByText('Rename') } /** Find a "snapshot" button (if any) on the current page. */ export function locateSnapshotButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Snapshot' }) + return page.getByRole('button', { name: 'Snapshot' }).getByText('Snapshot') } /** Find a "move to trash" button (if any) on the current page. */ export function locateMoveToTrashButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Move To Trash' }) + return page.getByRole('button', { name: 'Move To Trash' }).getByText('Move To Trash') } /** Find a "move all to trash" button (if any) on the current page. */ export function locateMoveAllToTrashButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Move All To Trash' }) + return page.getByRole('button', { name: 'Move All To Trash' }).getByText('Move All To Trash') } /** Find a "share" button (if any) on the current page. */ export function locateShareButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Share' }) + return page.getByRole('button', { name: 'Share' }).getByText('Share') } /** Find a "label" button (if any) on the current page. */ export function locateLabelButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Label' }) + return page.getByRole('button', { name: 'Label' }).getByText('Label') } /** Find a "duplicate" button (if any) on the current page. */ export function locateDuplicateButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Duplicate' }) + return page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate') } /** Find a "copy" button (if any) on the current page. */ export function locateCopyButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Copy' }) + return page.getByRole('button', { name: 'Copy' }).getByText('Copy') } /** Find a "cut" button (if any) on the current page. */ export function locateCutButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Cut' }) + return page.getByRole('button', { name: 'Cut' }).getByText('Cut') } /** Find a "download" button (if any) on the current page. */ export function locateDownloadButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Download' }) + return page.getByRole('button', { name: 'Download' }).getByText('Download') } /** Find an "upload files" button (if any) on the current page. */ export function locateUploadFilesButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Upload Files' }) + return page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files') } /** Find a "new project" button (if any) on the current page. */ export function locateNewProjectButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'New Project' }) + return page.getByRole('button', { name: 'New Project' }).getByText('New Project') } /** Find a "new folder" button (if any) on the current page. */ export function locateNewFolderButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'New Folder' }) + return page.getByRole('button', { name: 'New Folder' }).getByText('New Folder') } /** Find a "new data connector" button (if any) on the current page. */ export function locateNewDataConnectorButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'New Data Connector' }) + return page.getByRole('button', { name: 'New Data Connector' }).getByText('New Data Connector') +} + +/** Find a "new label" button (if any) on the current page. */ +export function locateNewLabelButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'new label' }).getByText('new label') } // === Container locators === @@ -217,6 +254,12 @@ export function locateConfirmDeleteModal(page: test.Locator | test.Page) { return page.getByTestId('confirm-delete-modal') } +/** Find a "new label" modal (if any) on the current page. */ +export function locateNewLabelModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('new-label-modal') +} + /** Find a user menu (if any) on the current page. */ export function locateUserMenu(page: test.Locator | test.Page) { // This has no identifying features. @@ -225,14 +268,28 @@ export function locateUserMenu(page: test.Locator | test.Page) { /** Find a "set username" panel (if any) on the current page. */ export function locateSetUsernamePanel(page: test.Locator | test.Page) { + // This has no identifying features. return page.getByTestId('set-username-panel') } /** Find a set of context menus (if any) on the current page. */ export function locateContextMenus(page: test.Locator | test.Page) { + // This has no identifying features. return page.getByTestId('context-menus') } +/** Find a labels panel (if any) on the current page. */ +export function locateLabelsPanel(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find a list of labels (if any) on the current page. */ +export function locateLabelsList(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('labels-list') +} + // ============= // === login === // ============= diff --git a/app/ide-desktop/lib/dashboard/test-e2e/api.ts b/app/ide-desktop/lib/dashboard/test-e2e/api.ts index 351daacdd9e0..f11404a8ef39 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/api.ts +++ b/app/ide-desktop/lib/dashboard/test-e2e/api.ts @@ -1,11 +1,12 @@ /** @file The mock API. */ import type * as test from '@playwright/test' -import type * as backend from '../src/authentication/src/dashboard/backend' +import * as backend from '../src/authentication/src/dashboard/backend' import * as config from '../src/authentication/src/config' import * as dateTime from '../src/authentication/src/dashboard/dateTime' import type * as remoteBackend from '../src/authentication/src/dashboard/remoteBackend' import * as remoteBackendPaths from '../src/authentication/src/dashboard/remoteBackendPaths' +import * as uniqueString from '../src/authentication/src/uniqueString' // ================= // === Constants === @@ -185,6 +186,18 @@ export async function mockApi(page: test.Page) { json: currentUser, }) }) + await page.route(BASE_URL + remoteBackendPaths.CREATE_TAG_PATH + '*', async route => { + if (route.request().method() === 'POST') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.CreateTagRequestBody = route.request().postDataJSON() + const json: backend.Label = { + id: backend.TagId(`tag-${uniqueString.uniqueString()}`), + value: backend.LabelName(body.value), + color: body.color, + } + await route.fulfill({ json }) + } + }) return { defaultEmail, diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png index de345d850922..75e8f831754f 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png and b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png index 8177cfd90c71..0ef1977ad68e 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png and b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png index a10a07f24c17..637d37e65049 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png index beacda250b48..66c191da8b1f 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png index 0a9db724499c..75069abc43d0 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts new file mode 100644 index 000000000000..b2eada947be2 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts @@ -0,0 +1,51 @@ +/** @file Test the labels sidebar panel. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as api from './api' + +test.test('labels', async ({ page }) => { + await api.mockApi(page) + await actions.login(page) + + // Screenshot #1: Empty labels panel + await test.expect(actions.locateLabelsPanel(page)).toHaveScreenshot() + + // Screenshot #2: "Create label" modal + await actions.locateNewLabelButton(page).click() + await test.expect(actions.locateNewLabelModal(page)).toHaveScreenshot() + + // Screenshot #3: "Create label" modal with name set + await actions.locateNewLabelModalNameInput(page).fill('New Label') + await test.expect(actions.locateNewLabelModal(page)).toHaveScreenshot() + + await page.press('body', 'Escape') + + // Screenshot #4: "Create label" modal with color set + // The exact number is allowed to vary; but to click the fourth color, there must be at least + // four colors. + await actions.locateNewLabelButton(page).click() + test.expect(await actions.locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual( + 4 + ) + // `force: true` is required because the `label` needs to handle the click event, not the + // `button`. + await actions.locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) + await test.expect(actions.locateNewLabelModal(page)).toHaveScreenshot() + + // Screenshot #5: "Create label" modal with name and color set + await actions.locateNewLabelModalNameInput(page).fill('New Label') + await test.expect(actions.locateNewLabelModal(page)).toHaveScreenshot() + + // Screenshot #6: Labels panel with one entry + await actions.locateCreateButton(actions.locateNewLabelModal(page)).click() + await test.expect(actions.locateLabelsPanel(page)).toHaveScreenshot() + + // Screenshot #7: Empty labels panel again, after deleting the only entry + // This uses a screenshot instead of `toHaveCount(count)` because it is less prone to breakage + // and easier to maintain. + await actions.locateLabelsPanelLabels(page).first().hover() + await actions.locateDeleteIcon(actions.locateLabelsPanel(page)).first().click() + await actions.locateDeleteButton(page).click() + await test.expect(actions.locateLabelsPanel(page)).toHaveScreenshot() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-darwin.png new file mode 100644 index 000000000000..4d6a49cf3fc8 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-linux.png new file mode 100644 index 000000000000..bacb2349e652 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-darwin.png new file mode 100644 index 000000000000..7ece137da219 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-linux.png new file mode 100644 index 000000000000..534be859a7b3 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-darwin.png new file mode 100644 index 000000000000..496a66179a8e Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-linux.png new file mode 100644 index 000000000000..98b0ebcf7d2d Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-darwin.png new file mode 100644 index 000000000000..ff9a5521a1f2 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-linux.png new file mode 100644 index 000000000000..69f606801186 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-darwin.png new file mode 100644 index 000000000000..56f139af5bce Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-linux.png new file mode 100644 index 000000000000..223bfdaa590f Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-darwin.png new file mode 100644 index 000000000000..0d89b322b8bc Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-linux.png new file mode 100644 index 000000000000..97b87e5e7278 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-7-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-7-darwin.png new file mode 100644 index 000000000000..4d6a49cf3fc8 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-7-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-7-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-7-linux.png new file mode 100644 index 000000000000..bacb2349e652 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-7-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png index c8d896b1f920..2e3f225a61d7 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png index ee1923cd286b..99f98901a073 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png index c8d896b1f920..2e3f225a61d7 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png index ee1923cd286b..99f98901a073 100644 Binary files a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png differ