From 7196b9eebf39991aeb3824e537c9e10d868b2ba8 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 22 Nov 2024 17:55:29 +0300 Subject: [PATCH 01/31] Fix remove button tooltip in sidebar --- app/common/src/text/english.json | 2 +- .../modules/payments/components/AddPaymentMethodModal.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index a89c0135bc24..da6631718649 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -490,7 +490,7 @@ "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.", 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' From be206bc2652523c252d294f4e053b78360bd5652 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Wed, 18 Dec 2024 17:47:09 +0400 Subject: [PATCH 02/31] Implementation of the Path column --- app/common/src/text/english.json | 5 + .../AriaComponents/Button/ButtonGroup.tsx | 2 +- .../AriaComponents/Dialog/Dialog.tsx | 3 +- .../AriaComponents/Dialog/DialogTrigger.tsx | 4 +- .../AriaComponents/Dialog/Popover.tsx | 32 ++- .../AriaComponents/Tooltip/Tooltip.tsx | 25 ++- .../dashboard/components/dashboard/column.ts | 18 +- .../dashboard/column/PathColumn.tsx | 88 +++++++++ .../dashboard/column/columnUtils.ts | 15 ++ .../components/dashboard/columnHeading.ts | 20 +- .../AccessedByProjectsColumnHeading.tsx | 4 +- .../AccessedDataColumnHeading.tsx | 4 +- .../columnHeading/DocsColumnHeading.tsx | 4 +- .../columnHeading/LabelsColumnHeading.tsx | 4 +- .../columnHeading/ModifiedColumnHeading.tsx | 10 +- .../columnHeading/NameColumnHeading.tsx | 4 +- .../columnHeading/PathColumnHeading.tsx | 33 ++++ .../columnHeading/SharedWithColumnHeading.tsx | 4 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 187 +++++++++--------- app/gui/src/dashboard/layouts/TabBar.tsx | 53 ++--- app/gui/src/dashboard/layouts/UserBar.tsx | 2 +- .../pages/dashboard/DashboardTabBar.tsx | 2 +- .../src/dashboard/providers/AuthProvider.tsx | 7 + 23 files changed, 361 insertions(+), 169 deletions(-) create mode 100644 app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx create mode 100644 app/gui/src/dashboard/components/dashboard/columnHeading/PathColumnHeading.tsx diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index da6631718649..bfac6e8add7c 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -681,6 +681,7 @@ "accessedDataColumnName": "Accessed data", "docsColumnName": "Docs", "rootFolderColumnName": "Root folder", + "pathColumnName": "Location", "settingsShortcut": "Settings", "closeTabShortcut": "Close Tab", @@ -750,6 +751,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/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/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index bec43019b7b4..46c072c7f902 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 1cb6cdce4598..cf76b4f9ea41 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 z-tooltip', 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,6 +100,7 @@ export function Popover(props: PopoverProps) { isExiting: values.isExiting, size, rounded, + variant, className: typeof className === 'function' ? className(values) : className, }).base() } @@ -109,6 +117,7 @@ export function Popover(props: PopoverProps) { rounded={rounded} opts={opts} isDismissable={isDismissable} + variant={variant} > {children} @@ -127,13 +136,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 +189,7 @@ 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 +202,5 @@ function PopoverContent(props: PopoverContentProps) { ) } + +Popover.Trigger = DialogTrigger diff --git a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx index bb78ac90b492..ec933833c64e 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] z-tooltip', +export const TOOLTIP_STYLES = tv({ + base: 'group flex justify-center items-center text-center [overflow-wrap:anywhere] z-tooltip', 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/dashboard/column.ts b/app/gui/src/dashboard/components/dashboard/column.ts index 0394a3c6f974..9ea88144c16f 100644 --- a/app/gui/src/dashboard/components/dashboard/column.ts +++ b/app/gui/src/dashboard/components/dashboard/column.ts @@ -1,18 +1,19 @@ /** @file Column types and column display modes. */ import { memo, type Dispatch, type JSX, type SetStateAction } from 'react' -import type { SortableColumn } from '#/components/dashboard/column/columnUtils' -import { Column } from '#/components/dashboard/column/columnUtils' -import DocsColumn from '#/components/dashboard/column/DocsColumn' -import LabelsColumn from '#/components/dashboard/column/LabelsColumn' -import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn' -import NameColumn from '#/components/dashboard/column/NameColumn' -import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn' -import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn' import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable' import type { Category } from '#/layouts/CategorySwitcher/Category' import type { AnyAsset, Asset, AssetId, BackendType } from '#/services/Backend' import type { SortInfo } from '#/utilities/sorting' +import type { SortableColumn } from './column/columnUtils' +import { Column } from './column/columnUtils' +import DocsColumn from './column/DocsColumn' +import LabelsColumn from './column/LabelsColumn' +import ModifiedColumn from './column/ModifiedColumn' +import NameColumn from './column/NameColumn' +import PathColumn from './column/PathColumn' +import PlaceholderColumn from './column/PlaceholderColumn' +import SharedWithColumn from './column/SharedWithColumn' // =================== // === AssetColumn === @@ -67,4 +68,5 @@ export const COLUMN_RENDERER: Readonly< [Column.accessedByProjects]: memo(PlaceholderColumn), [Column.accessedData]: memo(PlaceholderColumn), [Column.docs]: memo(DocsColumn), + [Column.path]: memo(PathColumn), } diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx new file mode 100644 index 000000000000..46f8eaca3fce --- /dev/null +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -0,0 +1,88 @@ +/** @file A column displaying the path of the asset. */ +import FolderArrowIcon from '#/assets/folder_arrow.svg' +import { Button, Popover } from '#/components/AriaComponents' +import SvgMask from '#/components/SvgMask' +import { backendQueryOptions } from '#/hooks/backendHooks' +import { useUser } from '#/providers/AuthProvider' +import { DirectoryId } from '#/services/Backend' +import { useSuspenseQuery } from '@tanstack/react-query' +import type { AssetColumnProps } from '../column' + +/** A column displaying the path of the asset. */ +export default function PathColumn(props: AssetColumnProps) { + const { item, state } = props + + const { virtualParentsPath, parentsPath } = item + + const splittedPath = parentsPath.split('/').map((id) => DirectoryId(id)) + const rootDirectoryInPath = splittedPath[0] + + const { data: allUserGroups } = useSuspenseQuery( + backendQueryOptions(state.backend, 'listUserGroups', []), + ) + const { rootDirectoryId, userGroups } = useUser() + + const userGroupsById = new Map( + userGroups?.map((id) => [id, allUserGroups.find((group) => group.id === id)]), + ) + + console.log({ userGroupsById }) + + const finalPath = (() => { + const result = [] + + if (rootDirectoryInPath != null) { + if (rootDirectoryInPath === rootDirectoryId) { + result.push('My Files') + } + + if (userGroupsById.has(rootDirectoryInPath)) { + result.push(userGroupsById.get(rootDirectoryInPath)?.groupName) + } + } + + if (virtualParentsPath.length > 0) { + result.push(...virtualParentsPath.split('/')) + } + + return result + })() + + console.log({ virtualParentsPath, parentsPath, splittedPath, rootDirectoryId, finalPath }) + + if (finalPath.length === 0) { + return <> + } + + if (finalPath.length === 1) { + return ( + + ) + } + + return ( + + + + +
+ {finalPath.map((path, index) => ( + <> + + + {index < finalPath.length - 1 && ( + + )} + + ))} +
+
+
+ ) +} diff --git a/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts b/app/gui/src/dashboard/components/dashboard/column/columnUtils.ts index 7c78549b3261..d59c0cd056fb 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..2bf50fe8ae3e 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/DocsColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/DocsColumnHeading.tsx @@ -24,7 +24,9 @@ export default function DocsColumnHeading(props: AssetColumnHeadingProps) { tooltip={false} onPress={hideThisColumn} /> - {getText('docsColumnName')} + + {getText('docsColumnName')} + ) } diff --git a/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx b/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx index 9fe622c890d8..c5683ec90f01 100644 --- a/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx +++ b/app/gui/src/dashboard/components/dashboard/columnHeading/LabelsColumnHeading.tsx @@ -25,7 +25,9 @@ export default function LabelsColumnHeading(props: AssetColumnHeadingProps) { tooltip={false} onPress={hideThisColumn} /> - {getText('labelsColumnName')} + + {getText('labelsColumnName')} + ) } 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) { + ) } return ( - - +
- {finalPath.map((path, index) => ( + {finalPath.map((entry, index) => ( <> - + {index < finalPath.length - 1 && ( @@ -98,3 +182,41 @@ export default function PathColumn(props: AssetColumnProps) { ) } + +/** + * Individual item in the path. + */ +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/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 4f8e5c24b63d..e0a8a3303097 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -278,6 +278,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. */ @@ -1308,6 +1309,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. () => ({ @@ -1327,6 +1332,7 @@ function AssetsTable(props: AssetsTableProps) { doDelete, doRestore, doMove, + getAssetNodeById, }), [ backend, @@ -1342,6 +1348,7 @@ function AssetsTable(props: AssetsTableProps) { doMove, hideColumn, setQuery, + getAssetNodeById, ], ) diff --git a/app/gui/src/dashboard/layouts/CategorySwitcher.tsx b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx index b1aaa138b6c6..f6b08477ba1b 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 === // ======================== @@ -83,6 +57,7 @@ interface InternalCategorySwitcherItemProps extends CategoryMetadata { readonly currentCategory: Category readonly setCategory: (category: Category) => 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, setCategory, 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 = @@ -249,69 +227,27 @@ export interface CategorySwitcherProps { /** A switcher to choose the currently visible assets table categoryModule.categoryType. */ function CategorySwitcher(props: CategorySwitcherProps) { const { category, setCategory } = props - const { user } = authProvider.useFullUserSession() + 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 { isOffline } = offlineHooks.useOffline() + + const cloudCategories = useCloudCategoryList() + const localCategories = useLocalCategoryList() - 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 { + cloudCategory, + recentCategory, + trashCategory, + userCategory, + teamCategories, + otherUsersCategory, + } = cloudCategories + const { localCategory, directories, addDirectory, removeDirectory } = localCategories return (
@@ -327,101 +263,91 @@ 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 : ( - - ) - } - })} + + {/* Other users spaces */} + {otherUsersCategory?.map((otherUserCategory) => ( + + ))} + + {teamCategories?.map((teamCategory) => ( + + ))} + + - {localBackend && ( + {localCategory != null && (
@@ -442,19 +368,16 @@ function CategorySwitcher(props: CategorySwitcherProps) { />
)} - {localBackend && - localRootDirectories?.map((directory) => ( -
+ {directories != null && + directories.map((directory) => ( +
@@ -468,25 +391,19 @@ function CategorySwitcher(props: CategorySwitcherProps) { className="hidden group-hover:block" /> { - 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 87a10d4bb2ec..6100f8d316ae 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -27,6 +27,7 @@ import * as result from '#/components/Result' import { ErrorBoundary, useErrorBoundary } from '#/components/ErrorBoundary' 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' @@ -46,8 +47,9 @@ import { useDirectoryIds } from './Drive/directoryIdsHooks' /** Props for a {@link Drive}. */ export interface DriveProps { - readonly category: categoryModule.Category - readonly setCategory: (category: categoryModule.Category) => void + readonly category: Category + readonly setCategory: (category: Category) => void + readonly setCategoryId: (categoryId: Category['id']) => void readonly resetCategory: () => void readonly hidden: boolean readonly initialProjectName: string | null @@ -65,6 +67,7 @@ function Drive(props: DriveProps) { const { user } = authProvider.useFullUserSession() const localBackend = backendProvider.useLocalBackend() const { getText } = textProvider.useText() + const isCloud = categoryModule.isCloudCategory(category) const supportLocalBackend = localBackend != null @@ -140,6 +143,7 @@ function DriveAssetsView(props: DriveProps) { const { category, setCategory, + setCategoryId, hidden = false, initialProjectName, assetsManagementApiRef, @@ -276,7 +280,7 @@ function DriveAssetsView(props: DriveProps) { size="small" className="mx-auto" onPress={() => { - setCategory({ type: 'local' }) + setCategoryId('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..1bbad59777a0 --- /dev/null +++ b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts @@ -0,0 +1,305 @@ +/** @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..78520be12f1e --- /dev/null +++ b/app/gui/src/dashboard/layouts/Drive/Categories/categoriesHooks.tsx @@ -0,0 +1,425 @@ +/** + * @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 + * TODO: Improve performance and add ability to subscribe to individual category values + */ + +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 { 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 { Path, userHasUserAndTeamSpaces, type DirectoryId } 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 { createContext, useContext, type PropsWithChildren } from 'react' +import invariant from 'tiny-invariant' +import { z } from 'zod' +import { useOffline } from '../../../hooks/offlineHooks' +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: allUserGroups } = useSuspenseQuery({ + ...backendQueryOptions(remoteBackend, 'listUserGroups', []), + select: (groups) => { + // Additionally ensure that if user doesn't have access to user groups, + // we explicitly return null. + if (groups.length === 0 || !hasUserAndTeamSpaces) { + return null + } + + return groups + }, + }) + + // const { data: otherUsers } = useSuspenseQuery({ + // ...backendQueryOptions(remoteBackend, 'listUsers', []), + // select: (users) => { + // // Additionally ensure that if user doesn't have access to other users, + // // we explicitly return null. + // if (users.length === 0 || !hasUserAndTeamSpaces) { + // return null + // } + + // return users.filter((anyUser) => anyUser.userId !== userId) + // }, + // }) + + 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 + + // Temporary disabled as even org admins do not have access to the other user's spaces + // This is fine as we don't want to narrow the type + // eslint-disable-next-line no-restricted-syntax + const otherUserSpaces = null as UserCategory[] | null + // otherUsers?.map((otherUser) => ({ + // type: 'user', + // id: otherUser.userId, + // user: otherUser, + // rootPath: Path(`enso://Users/${otherUser.name}`), + // homeDirectoryId: userIdToDirectoryId(otherUser.userId), + // label: getText('userCategory', otherUser.name), + // 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] : []), + ...(otherUserSpaces != null ? [...otherUserSpaces] : []), + ...(userGroupDynamicCategories != null ? [...userGroupDynamicCategories] : []), + ] as const + + const getCategoryById = useEventCallback((id: CategoryId) => { + const maybeCategory = categories.find((category) => category.id === id) ?? null + return maybeCategory + }) + + 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) => { + const maybeCategory = + categories.find((category) => { + if ('homeDirectoryId' in category) { + return category.homeDirectoryId === directoryId + } + + return false + }) ?? null + + return maybeCategory + }) + + return { + categories, + cloudCategory, + recentCategory, + trashCategory, + userCategory: userSpace, + otherUsersCategory: otherUserSpaces, + 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: string) => { + setLocalRootDirectories(localRootDirectories.filter((d) => d !== directory)) + }) + + const getCategoryById = useEventCallback((id: CategoryId) => { + const maybeCategory = categories.find((category) => category.id === id) ?? null + return maybeCategory + }) + + 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) + +/** + * Provider for the categories. + */ +export function CategoriesProvider(props: PropsWithChildren) { + const { children } = props + + const { cloudCategories, localCategories, findCategoryById } = useCategories() + const localBackend = useLocalBackend() + const { isOffline } = useOffline() + + const [categoryId, setCategoryId, 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 category = findCategoryById(categoryId) + + // This is safe, because category is always set + // 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 one + if (category == null) { + resetCategoryId(true) + return null + } + + return ( + + {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/TabBar.tsx b/app/gui/src/dashboard/layouts/TabBar.tsx index 45f8a7156aee..b7a12497c71a 100644 --- a/app/gui/src/dashboard/layouts/TabBar.tsx +++ b/app/gui/src/dashboard/layouts/TabBar.tsx @@ -22,6 +22,7 @@ import { useInputBindings } from '#/providers/InputBindingsProvider' import { ProjectState } from '#/services/Backend' 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}. */ @@ -102,34 +103,32 @@ export function Tab(props: TabProps) { className="h-full w-full rounded-t-3xl px-4" underlayElement={UNDERLAY_ELEMENT} > - -
- + {typeof icon === 'string' ? + - {typeof icon === 'string' ? - - : icon} + : icon} + {children} - {onClose && ( -
- -
- )} -
-
+ + + {onClose && } +
)} diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 05054a6deac3..92ad8203e076 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -10,7 +10,6 @@ import { DashboardTabBar } from './DashboardTabBar' import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as projectHooks from '#/hooks/projectHooks' -import * as searchParamsState from '#/hooks/searchParamsStateHooks' import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' @@ -29,7 +28,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 +43,8 @@ import * as backendModule from '#/services/Backend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import { useSetCategory } from '#/providers/DriveProvider' +import type { Category } from '#/layouts/CategorySwitcher/Category' +import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' @@ -113,25 +112,11 @@ 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 categoriesAPI = useCategoriesAPI() - 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 setCategory = eventCallbacks.useEventCallback((newCategory: Category) => { + categoriesAPI.setCategory(newCategory.id) }) - const backend = backendProvider.useBackend(category) const projectsStore = useProjectsStore() const page = usePage() @@ -173,8 +158,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 +169,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 +229,8 @@ function DashboardInner(props: DashboardProps) { if (asset != null && self != null) { setModal( { @@ -293,9 +281,10 @@ function DashboardInner(props: DashboardProps) { initialProjectName={initialProjectName} ydocUrl={ydocUrl} assetManagementApiRef={assetManagementApiRef} - category={category} + category={categoriesAPI.category} setCategory={setCategory} - resetCategory={resetCategory} + setCategoryId={categoriesAPI.setCategory} + resetCategory={categoriesAPI.resetCategory} /> diff --git a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx index 8ddf9130a14c..f39d1279c667 100644 --- a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx +++ b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx @@ -25,6 +25,7 @@ export interface DashboardTabPanelsProps { readonly category: Category readonly setCategory: (category: Category) => void readonly resetCategory: () => void + readonly setCategoryId: (categoryId: Category['id']) => void } /** The tab panels for the dashboard page. */ @@ -37,6 +38,7 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) { category, setCategory, resetCategory, + setCategoryId, } = props const page = usePage() @@ -64,6 +66,7 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) { assetsManagementApiRef={assetManagementApiRef} category={category} setCategory={setCategory} + setCategoryId={setCategoryId} resetCategory={resetCategory} hidden={page !== TabType.drive} initialProjectName={initialProjectName} diff --git a/app/gui/src/dashboard/providers/AuthProvider.tsx b/app/gui/src/dashboard/providers/AuthProvider.tsx index f2584b40657f..682810505b8d 100644 --- a/app/gui/src/dashboard/providers/AuthProvider.tsx +++ b/app/gui/src/dashboard/providers/AuthProvider.tsx @@ -37,6 +37,7 @@ import * as errorModule from '#/utilities/error' import * as cognitoModule from '#/authentication/cognito' import type * as authServiceModule from '#/authentication/service' +import { isOrganizationId } from '#/services/RemoteBackend' import { unsafeWriteValue } from '#/utilities/write' // =================== @@ -330,11 +331,15 @@ export default function AuthProvider(props: AuthProviderProps) { const organizationId = await cognito.organizationId() const email = session?.email ?? '' + invariant( + organizationId == null || isOrganizationId(organizationId), + 'Invalid organization ID', + ) + await createUserMutation.mutateAsync({ userName: username, userEmail: backendModule.EmailAddress(email), - organizationId: - organizationId != null ? backendModule.OrganizationId(organizationId) : null, + organizationId: organizationId != null ? organizationId : null, }) } // Wait until the backend returns a value from `users/me`, diff --git a/app/gui/src/dashboard/providers/BackendProvider.tsx b/app/gui/src/dashboard/providers/BackendProvider.tsx index 8bb73f63053e..1b203242f189 100644 --- a/app/gui/src/dashboard/providers/BackendProvider.tsx +++ b/app/gui/src/dashboard/providers/BackendProvider.tsx @@ -8,7 +8,11 @@ import invariant from 'tiny-invariant' import * as common from 'enso-common' -import { type Category, isCloudCategory } from '#/layouts/CategorySwitcher/Category' +import { + type Category, + type CategoryId, + isCloudCategory, +} from '#/layouts/CategorySwitcher/Category' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { BackendType } from '#/services/Backend' diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index e4f55407db1d..85fe3efbf517 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -5,7 +5,7 @@ import * as zustand from '#/utilities/zustand' import invariant from 'tiny-invariant' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import type { Category } from '#/layouts/CategorySwitcher/Category' +import type { Category, CategoryId } from '#/layouts/CategorySwitcher/Category' import type AssetTreeNode from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' import { EMPTY_SET } from '#/utilities/set' @@ -16,6 +16,7 @@ import type { DirectoryId, } from 'enso-common/src/services/Backend' import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' +import { useCategoriesAPI } from '../layouts/Drive/Categories/categoriesHooks' // ================== // === DriveStore === @@ -30,8 +31,7 @@ export interface DrivePastePayload { /** The state of this zustand store. */ interface DriveStore { - readonly category: Category - readonly setCategory: (category: Category) => void + readonly setCategoryId: (categoryId: CategoryId) => void readonly targetDirectory: AssetTreeNode | null readonly setTargetDirectory: (targetDirectory: AssetTreeNode | null) => void readonly newestFolderId: DirectoryId | null @@ -73,13 +73,15 @@ export type ProjectsProviderProps = Readonly export default function DriveProvider(props: ProjectsProviderProps) { const { children } = props + const categoriesAPI = useCategoriesAPI() + const [store] = React.useState(() => zustand.createStore((set, get) => ({ - category: { type: 'cloud' }, - setCategory: (category) => { - if (get().category !== category) { + setCategoryId: (categoryId) => { + if (categoriesAPI.category.id !== categoryId) { + categoriesAPI.setCategory(categoryId) + set({ - category, targetDirectory: null, selectedKeys: EMPTY_SET, visuallySelectedKeys: null, @@ -150,16 +152,35 @@ export function useDriveStore() { return store } +/** The ID of the category of the Asset Table. */ +export function useCategoryId() { + const categoriesAPI = useCategoriesAPI() + return categoriesAPI.category.id +} + /** The category of the Asset Table. */ export function useCategory() { - const store = useDriveStore() - return zustand.useStore(store, (state) => state.category, { unsafeEnableTransition: true }) + const categoriesAPI = useCategoriesAPI() + + return categoriesAPI.category } -/** A function to set the category of the Asset Table. */ +/** + * A function to set the category of the Asset Table. + * @deprecated Use {@link useSetCategoryId} instead. + */ export function useSetCategory() { - const store = useDriveStore() - return zustand.useStore(store, (state) => state.setCategory, { unsafeEnableTransition: true }) + const driveStore = useDriveStore() + return zustand.useStore(driveStore, (state) => state.setCategoryId, { + unsafeEnableTransition: true, + }) +} + +/** A function to set the category of the Asset Table. */ +export function useSetCategoryId() { + const categoriesAPI = useCategoriesAPI() + + return categoriesAPI.setCategory } /** The target directory of the Asset Table selection. */ diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index ea3099b657d6..10ca1ba80636 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -31,7 +31,7 @@ function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) { /** Create a {@link backend.DirectoryId} from a path. */ export function newDirectoryId(path: projectManager.Path) { - return backend.DirectoryId(`${backend.AssetType.directory}-${path}`) + return backend.DirectoryId(`${backend.AssetType.directory}-${path}` as const) } /** Create a {@link backend.ProjectId} from a UUID. */ @@ -239,7 +239,7 @@ export default class LocalBackend extends Backend { const result = await this.projectManager.listProjects({}) return result.projects.map((project) => ({ name: project.name, - organizationId: backend.OrganizationId(''), + organizationId: backend.OrganizationId('organization-'), projectId: newProjectId(project.id), packageName: project.name, state: { @@ -269,7 +269,7 @@ export default class LocalBackend extends Backend { }) return { name: project.projectName, - organizationId: backend.OrganizationId(''), + organizationId: backend.OrganizationId('organization-'), projectId: newProjectId(project.projectId), packageName: project.projectName, state: { type: backend.ProjectState.closed, volumeId: '' }, @@ -338,7 +338,7 @@ export default class LocalBackend extends Backend { jsonAddress: null, binaryAddress: null, ydocAddress: null, - organizationId: backend.OrganizationId(''), + organizationId: backend.OrganizationId('organization-'), packageName: project.name, projectId, state: { type: backend.ProjectState.closed, volumeId: '' }, @@ -359,7 +359,7 @@ export default class LocalBackend extends Backend { jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress), binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress), ydocAddress: null, - organizationId: backend.OrganizationId(''), + organizationId: backend.OrganizationId('organization-'), packageName: cachedProject.projectNormalizedName, projectId, state: { @@ -441,7 +441,7 @@ export default class LocalBackend extends Backend { engineVersion: version, ideVersion: version, name: project.name, - organizationId: backend.OrganizationId(''), + organizationId: backend.OrganizationId('organization-'), projectId, } } @@ -456,7 +456,7 @@ export default class LocalBackend extends Backend { projectId: newProjectId(project.projectId), name: project.projectName, packageName: project.projectNormalizedName, - organizationId: backend.OrganizationId(''), + organizationId: backend.OrganizationId('organization-'), state: { type: backend.ProjectState.closed, volumeId: '' }, } } diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index f979d05ae77e..f35e8db7a660 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -14,6 +14,7 @@ import type * as textProvider from '#/providers/TextProvider' import Backend, * as backend from '#/services/Backend' import * as remoteBackendPaths from '#/services/remoteBackendPaths' +import { DirectoryId, UserGroupId } from '#/services/Backend' import * as download from '#/utilities/download' import type HttpClient from '#/utilities/HttpClient' import * as object from '#/utilities/object' @@ -68,8 +69,6 @@ export function extractIdFromUserGroupId(id: backend.UserGroupId) { /** * Extract the ID from the given organization ID. * Removes the `organization-` prefix. - * @param id - The organization ID. - * @returns The ID. */ export function extractIdFromOrganizationId(id: backend.OrganizationId) { return id.replace(/^organization-/, '') @@ -78,13 +77,68 @@ export function extractIdFromOrganizationId(id: backend.OrganizationId) { /** * Extract the ID from the given directory ID. * Removes the `directory-` prefix. - * @param id - The directory ID. - * @returns The ID. */ export function extractIdFromDirectoryId(id: backend.DirectoryId) { return id.replace(/^directory-/, '') } +/** + * Convert a user group ID to a directory ID. + */ +export function userGroupIdToDirectoryId(id: backend.UserGroupId): backend.DirectoryId { + return DirectoryId(`directory-${id.replace(/^usergroup-/, '')}` as const) +} + +/** + * Convert a user ID to a directory ID. + */ +export function userIdToDirectoryId(id: backend.UserId): backend.DirectoryId { + return DirectoryId(`directory-${id.replace(/^user-/, '')}` as const) +} + +/** + * Convert organization ID to a directory ID + */ +export function organizationIdToDirectoryId(id: backend.OrganizationId): backend.DirectoryId { + return DirectoryId(`directory-${extractIdFromOrganizationId(id)}` as const) +} + +/** + * Convert a directory ID to a user group ID. + * @param id - The directory ID. + * @returns The user group ID. + */ +export function directoryIdToUserGroupId(id: backend.DirectoryId): backend.UserGroupId { + return UserGroupId(`usergroup-${id.replace(/^directory-/, '')}` as const) +} + +/** + * Whether the given string is a valid organization ID. + * @param id - The string to check. + * @returns Whether the string is a valid organization ID. + */ +export function isOrganizationId(id: string): id is backend.OrganizationId { + return id.startsWith('organization-') +} + +/** + * Whether the given string is a valid user ID. + * @param id - The string to check. + * @returns Whether the string is a valid user ID. + */ +export function isUserId(id: string): id is backend.UserId { + return id.startsWith('user-') +} + +/** + * Whether the given string is a valid user group ID. + * @param id - The string to check. + * @returns Whether the string is a valid user group ID. + */ +export function idIsUserGroupId(id: string): id is backend.UserGroupId { + return id.startsWith('usergroup-') +} + // ============= // === Types === // ============= diff --git a/app/gui/src/dashboard/utilities/tailwindVariants.ts b/app/gui/src/dashboard/utilities/tailwindVariants.ts index 08d26f452a58..7e98f3a1459b 100644 --- a/app/gui/src/dashboard/utilities/tailwindVariants.ts +++ b/app/gui/src/dashboard/utilities/tailwindVariants.ts @@ -1,5 +1,5 @@ /** @file `tailwind-variants` with a custom configuration. */ -import type { VariantProps as TvVariantProps } from 'tailwind-variants' +import type { OmitUndefined } from 'tailwind-variants' import { createTV } from 'tailwind-variants' import { TAILWIND_MERGE_CONFIG } from '#/utilities/tailwindMerge' @@ -22,10 +22,10 @@ export type ExtractFunction = export type TVWithoutExtends = ExtractFunction & Omit /** Props for a component that uses `tailwind-variants`. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type VariantProps any> = Omit< - TvVariantProps, - 'class' | 'className' -> & { - variants?: ExtractFunction | undefined +// TODO: add support for styling individual slots +export type VariantProps< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Component extends (...args: any) => any, +> = Omit[0]>, 'class' | 'className'> & { + variants?: ExtractFunction | undefined } diff --git a/app/gui/tailwind.config.js b/app/gui/tailwind.config.js index fc90077d41b0..9a871bfab34a 100644 --- a/app/gui/tailwind.config.js +++ b/app/gui/tailwind.config.js @@ -19,6 +19,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({ /** The default color of all text. */ // This should be named "regular". primary: 'rgb(var(--color-primary-rgb) / var(--color-primary-opacity))', + disabled: 'rgb(var(--color-primary-rgb) / 30%)', invert: 'rgb(var(--color-invert-rgb) / var(--color-invert-opacity))', background: 'rgb(var(--color-background-rgb) / var(--color-background-opacity))', 'background-hex': 'var(--color-background-hex)', From c7af0cb7c4538964c362a1697787a9a42ee6a7dc Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 22 Dec 2024 14:41:01 +0400 Subject: [PATCH 05/31] Small fixes --- app/gui/src/dashboard/providers/BackendProvider.tsx | 6 +----- app/gui/src/dashboard/tailwind.css | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/gui/src/dashboard/providers/BackendProvider.tsx b/app/gui/src/dashboard/providers/BackendProvider.tsx index 1b203242f189..8bb73f63053e 100644 --- a/app/gui/src/dashboard/providers/BackendProvider.tsx +++ b/app/gui/src/dashboard/providers/BackendProvider.tsx @@ -8,11 +8,7 @@ import invariant from 'tiny-invariant' import * as common from 'enso-common' -import { - type Category, - type CategoryId, - isCloudCategory, -} from '#/layouts/CategorySwitcher/Category' +import { type Category, isCloudCategory } from '#/layouts/CategorySwitcher/Category' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { BackendType } from '#/services/Backend' diff --git a/app/gui/src/dashboard/tailwind.css b/app/gui/src/dashboard/tailwind.css index 6c5fa304afad..20965dc8d4f5 100644 --- a/app/gui/src/dashboard/tailwind.css +++ b/app/gui/src/dashboard/tailwind.css @@ -568,7 +568,3 @@ html.disable-animations * { .Toastify__toast { @apply rounded-2xl; } - -.Toastify--animate { - /* animation-duration: 200ms; */ -} From 70d953d367cbc57ef18659a61fb24cb7cd95b6a8 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 22 Dec 2024 14:43:09 +0400 Subject: [PATCH 06/31] Prettier --- app/gui/src/dashboard/layouts/Drive/Categories/Category.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts index 1bbad59777a0..2eacd4715c39 100644 --- a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts +++ b/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts @@ -29,7 +29,11 @@ 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 }) + .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. */ From 61da2953d95c9b687a5daefd5af4443be12be27d Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 22 Dec 2024 14:59:30 +0400 Subject: [PATCH 07/31] FIx tests --- app/gui/src/dashboard/App.tsx | 24 ++++++----------- .../dashboard/layouts/CategorySwitcher.tsx | 12 ++++----- app/gui/src/dashboard/layouts/Drive.tsx | 26 ++++++++++-------- .../dashboard/pages/dashboard/Dashboard.tsx | 27 +++++++++---------- .../pages/dashboard/DashboardTabPanels.tsx | 20 +------------- 5 files changed, 43 insertions(+), 66 deletions(-) diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 9a6deee3dcd9..db72c7ce26fd 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' @@ -98,7 +97,6 @@ import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import { useInitAuthService } from '#/authentication/service' import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal' -import { CategoriesProvider } from './layouts/Drive/Categories/categoriesHooks' // ============================ // === Global configuration === @@ -536,20 +534,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/layouts/CategorySwitcher.tsx b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx index f6b08477ba1b..278e517eece5 100644 --- a/app/gui/src/dashboard/layouts/CategorySwitcher.tsx +++ b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx @@ -55,7 +55,7 @@ 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 } @@ -71,7 +71,7 @@ const CATEGORY_SWITCHER_VARIANTS = tv({ /** An entry in a {@link CategorySwitcher}. */ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) { - const { currentCategory, setCategory, badgeContent, isDisabled: isDisabledRaw } = props + const { currentCategory, setCategoryId, badgeContent, isDisabled: isDisabledRaw } = props const { isNested = false, category, icon, label, buttonLabel, dropZoneLabel } = props const [isTransitioning, startTransition] = React.useTransition() @@ -126,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) }) } }) @@ -221,12 +221,12 @@ 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 { category, setCategoryId } = props const { getText } = textProvider.useText() const [, setSearchParams] = useSearchParams() @@ -237,7 +237,7 @@ function CategorySwitcher(props: CategorySwitcherProps) { const cloudCategories = useCloudCategoryList() const localCategories = useLocalCategoryList() - const itemProps = { currentCategory: category, setCategory, dispatchAssetEvent } + const itemProps = { currentCategory: category, setCategoryId, dispatchAssetEvent } const { cloudCategory, diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index 6100f8d316ae..b07fc5778ff1 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -39,6 +39,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' // ============= @@ -47,10 +48,6 @@ import { useDirectoryIds } from './Drive/directoryIdsHooks' /** Props for a {@link Drive}. */ export interface DriveProps { - readonly category: Category - readonly setCategory: (category: Category) => void - readonly setCategoryId: (categoryId: Category['id']) => void - readonly resetCategory: () => void readonly hidden: boolean readonly initialProjectName: string | null readonly assetsManagementApiRef: React.Ref @@ -60,13 +57,13 @@ 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 } = 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) @@ -128,7 +125,7 @@ function Drive(props: DriveProps) { }} > - + ) @@ -136,14 +133,21 @@ 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, - setCategoryId, hidden = false, initialProjectName, assetsManagementApiRef, @@ -254,7 +258,7 @@ function DriveAssetsView(props: DriveProps) {
- + {isCloud && ( { - setCategoryId('local') + setCategory('local') }} > {getText('switchToLocal')} diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx index 92ad8203e076..f56c63b29c98 100644 --- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx +++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx @@ -10,6 +10,8 @@ import { DashboardTabBar } from './DashboardTabBar' import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as projectHooks from '#/hooks/projectHooks' +import { CategoriesProvider } from '#/layouts/Drive/Categories/categoriesHooks' +import DriveProvider from '#/providers/DriveProvider' import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' @@ -43,7 +45,6 @@ import * as backendModule from '#/services/Backend' import * as localBackendModule from '#/services/LocalBackend' import * as projectManager from '#/services/ProjectManager' -import type { Category } from '#/layouts/CategorySwitcher/Category' import { useCategoriesAPI } from '#/layouts/Drive/Categories/categoriesHooks' import { baseName } from '#/utilities/fileInfo' import { tryFindSelfPermission } from '#/utilities/permissions' @@ -68,11 +69,17 @@ 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`. */} + + + + + + + + ) } @@ -114,10 +121,6 @@ function DashboardInner(props: DashboardProps) { const categoriesAPI = useCategoriesAPI() - const setCategory = eventCallbacks.useEventCallback((newCategory: Category) => { - categoriesAPI.setCategory(newCategory.id) - }) - const projectsStore = useProjectsStore() const page = usePage() const launchedProjects = useLaunchedProjects() @@ -281,10 +284,6 @@ function DashboardInner(props: DashboardProps) { initialProjectName={initialProjectName} ydocUrl={ydocUrl} assetManagementApiRef={assetManagementApiRef} - category={categoriesAPI.category} - setCategory={setCategory} - setCategoryId={categoriesAPI.setCategory} - resetCategory={categoriesAPI.resetCategory} /> diff --git a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx index f39d1279c667..1bfc4641b2f8 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,24 +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 - readonly setCategoryId: (categoryId: Category['id']) => void } /** The tab panels for the dashboard page. */ export function DashboardTabPanels(props: DashboardTabPanelsProps) { - const { - appRunner, - initialProjectName, - ydocUrl, - assetManagementApiRef, - category, - setCategory, - resetCategory, - setCategoryId, - } = props + const { appRunner, initialProjectName, ydocUrl, assetManagementApiRef } = props const page = usePage() @@ -64,10 +50,6 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) { children: (