Sort assets (#7540)

- Closes https://github.com/enso-org/cloud-v2/issues/511
- Adds sorting. Currently supported columns: "Name" and "Last Modified"

# Important Notes
The sort arrows have a slightly thicker border (changed from 2px to 2.14px), to remove the (very small) internal hole in the Figma design. It is possible to make the shape more accurate to the original design by using a polygon (or a path) that traces around the original outline instead, but I figured it's not worth spending the extra time on a fix that may not be correct.

ℹ️ The comparison function for sorting is quite complicated. I think this is the least intrusive change for now, but it is worth considering changing `AssetsTable` to store items internally as a tree instead, and do a preorder traversal to flatten it into an array when needed.
This commit is contained in:
somebody1234 2023-08-19 01:47:23 +10:00 committed by GitHub
parent 1d67667792
commit f3f2f06bd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1092 additions and 746 deletions

View File

@ -0,0 +1,3 @@
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5L4 2L2 5L6 5Z" stroke="black" stroke-opacity="0.6" stroke-width="2.14" />
</svg>

After

Width:  |  Height:  |  Size: 189 B

View File

@ -0,0 +1,3 @@
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 1L4 4L2 1L6 1Z" stroke="black" stroke-opacity="0.6" stroke-width="2.14" />
</svg>

After

Width:  |  Height:  |  Size: 189 B

View File

@ -22,6 +22,13 @@ import * as project from './project-management'
const logger = contentConfig.logger
// =================
// === Constants ===
// =================
/** Returned by {@link String.indexOf} when the substring was not found. */
const NOT_FOUND = -1
// =================
// === Reexports ===
// =================
@ -69,8 +76,7 @@ function getClientArguments(): string[] {
// Client arguments are separated from the electron dev mode arguments by a '--' argument.
const separator = '--'
const separatorIndex = process.argv.indexOf(separator)
const notFoundIndexPlaceholder = -1
if (separatorIndex === notFoundIndexPlaceholder) {
if (separatorIndex === NOT_FOUND) {
// If there is no separator, client gets no arguments.
return []
} else {

View File

@ -16,13 +16,13 @@ export function includesPredicate<T>(array: Iterable<T>) {
// ======================
/** The value returned when {@link Array.findIndex} fails. */
const FIND_INDEX_FAILURE = -1
const NOT_FOUND = -1
/** Insert items before the first index `i` for which `predicate(array[i])` is `true`.
* Insert the items at the end if the `predicate` never returns `true`. */
export function spliceBefore<T>(array: T[], items: T[], predicate: (value: T) => boolean) {
const index = array.findIndex(predicate)
array.splice(index === FIND_INDEX_FAILURE ? array.length : index, 0, ...items)
array.splice(index === NOT_FOUND ? array.length : index, 0, ...items)
return array
}
@ -37,7 +37,7 @@ export function splicedBefore<T>(array: T[], items: T[], predicate: (value: T) =
* Insert the items at the end if the `predicate` never returns `true`. */
export function spliceAfter<T>(array: T[], items: T[], predicate: (value: T) => boolean) {
const index = array.findIndex(predicate)
array.splice(index === FIND_INDEX_FAILURE ? array.length : index + 1, 0, ...items)
array.splice(index === NOT_FOUND ? array.length : index + 1, 0, ...items)
return array
}
@ -52,7 +52,7 @@ export function splicedAfter<T>(array: T[], items: T[], predicate: (value: T) =>
* Do not insert items if the `predicate` never returns `true`. */
export function spliceReplacing<T>(array: T[], items: T[], predicate: (value: T) => boolean) {
const index = array.findIndex(predicate)
if (index !== FIND_INDEX_FAILURE) {
if (index !== NOT_FOUND) {
array.splice(index, 1, ...items)
}
return array

View File

@ -0,0 +1,153 @@
/** @file A node in the drive's item tree. */
import * as React from 'react'
import * as backendModule from './backend'
// =====================
// === AssetTreeNode ===
// =====================
/** A node in the drive's item tree. */
export interface AssetTreeNode {
/** The original id of the asset (the placeholder id for new assets). This must never change. */
key: backendModule.AssetId
/** The actual asset. This MAY change if this is initially a placeholder item, but rows MAY
* keep updated values within the row itself as well. */
item: backendModule.AnyAsset
/** This is `null` if the asset is not a directory asset, OR if it is a collapsed directory
* asset. */
children: AssetTreeNode[] | null
depth: number
}
/** Get an {@link AssetTreeNode.key} from an {@link AssetTreeNode}. Useful for React, references
* of global functions do not change. */
export function getAssetTreeNodeKey(node: AssetTreeNode) {
return node.key
}
/** Return a positive number if `a > b`, a negative number if `a < b`, and zero if `a === b`.
* Uses {@link backendModule.compareAssets} internally. */
export function compareAssetTreeNodes(a: AssetTreeNode, b: AssetTreeNode) {
return backendModule.compareAssets(a.item, b.item)
}
/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. */
export function assetTreeMap(
tree: AssetTreeNode[],
transform: (node: AssetTreeNode) => AssetTreeNode
) {
let result: AssetTreeNode[] | null = null
for (let i = 0; i < tree.length; i += 1) {
const node = tree[i]
if (node == null) {
break
}
const intermediateNode = transform(node)
let newNode: AssetTreeNode = intermediateNode
if (intermediateNode.children != null) {
const newChildren = assetTreeMap(intermediateNode.children, transform)
if (newChildren !== intermediateNode.children) {
newNode = { ...intermediateNode, children: newChildren }
}
}
if (newNode !== node) {
result ??= Array.from(tree)
result[i] = newNode
}
}
return result ?? tree
}
/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. The predicate is applied to
* a parent node before it is applied to its children. */
export function assetTreeFilter(
tree: AssetTreeNode[],
predicate: (node: AssetTreeNode) => boolean
) {
let result: AssetTreeNode[] | null = null
for (let i = 0; i < tree.length; i += 1) {
const node = tree[i]
if (node == null) {
break
}
if (!predicate(node)) {
result = tree.slice(0, i)
} else {
if (node.children != null) {
const newChildren = assetTreeFilter(node.children, predicate)
if (newChildren !== node.children) {
result ??= tree.slice(0, i)
const newNode = {
...node,
children: newChildren.length === 0 ? null : newChildren,
}
result.push(newNode)
} else if (result != null) {
result.push(node)
}
} else if (result != null) {
result.push(node)
}
}
}
return result ?? tree
}
/** Returns all items in the tree, flattened into an array using pre-order traversal. */
export function assetTreePreorderTraversal(
tree: AssetTreeNode[],
preprocess?: ((tree: AssetTreeNode[]) => AssetTreeNode[]) | null
): AssetTreeNode[] {
return (preprocess?.(tree) ?? tree).flatMap(node => {
if (node.children != null) {
return [node, ...assetTreePreorderTraversal(node.children, preprocess ?? null)]
} else {
return [node]
}
})
}
/** Creates an {@link AssetTreeNode} from a {@link backendModule.AnyAsset}. */
export function assetTreeNodeFromAsset(
asset: backendModule.AnyAsset,
depth: number
): AssetTreeNode {
return {
key: asset.id,
item: asset,
children: null,
depth,
}
}
// ===================
// === useSetAsset ===
// ===================
/** Converts a React set state action for an {@link AssetTreeNode} to a set state action for any
* subset of {@link backendModule.AnyAsset}. This is unsafe when `T` does not match the type of the
* item contained in the `AssetTreeNode`, so this MUST be guarded by checking that the item is of
* the correct type. A value of type `T` must be provided as the first parameter to ensure that this
* has been done. */
export function useSetAsset<T extends backendModule.AnyAsset>(
_value: T,
setNode: React.Dispatch<React.SetStateAction<AssetTreeNode>>
) {
return React.useCallback(
(valueOrUpdater: React.SetStateAction<T>) => {
setNode(oldNode => {
const item =
typeof valueOrUpdater === 'function'
? // This is SAFE, because it is a mistake for an item to change type.
// eslint-disable-next-line no-restricted-syntax
valueOrUpdater(oldNode.item as T)
: valueOrUpdater
return { ...oldNode, item }
})
},
[/* should never change */ setNode]
)
}

View File

@ -3,6 +3,7 @@
import * as dateTime from './dateTime'
import * as newtype from '../newtype'
import * as permissions from './permissions'
import * as uniqueString from '../uniqueString'
// =============
// === Types ===
@ -380,7 +381,7 @@ export const ASSET_TYPE_ORDER: Record<AssetType, number> = {
export interface BaseAsset {
id: AssetId
title: string
modifiedAt: dateTime.Rfc3339DateTime | null
modifiedAt: dateTime.Rfc3339DateTime
/** This is defined as a generic {@link AssetId} in the backend, however it is more convenient
* (and currently safe) to assume it is always a {@link DirectoryId}. */
parentId: DirectoryId
@ -410,9 +411,37 @@ export interface SecretAsset extends Asset<AssetType.secret> {}
/** A convenience alias for {@link Asset}<{@link AssetType.specialLoading}>. */
export interface SpecialLoadingAsset extends Asset<AssetType.specialLoading> {}
/** Creates a {@link SpecialLoadingAsset}, with all irrelevant fields initialized to default
* values. */
export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoadingAsset {
return {
type: AssetType.specialLoading,
title: '',
id: LoadingAssetId(uniqueString.uniqueString()),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId,
permissions: [],
projectState: null,
}
}
/** A convenience alias for {@link Asset}<{@link AssetType.specialEmpty}>. */
export interface SpecialEmptyAsset extends Asset<AssetType.specialEmpty> {}
/** Creates a {@link SpecialEmptyAsset}, with all irrelevant fields initialized to default
* values. */
export function createSpecialEmptyAsset(directoryId: DirectoryId): SpecialEmptyAsset {
return {
type: AssetType.specialEmpty,
title: '',
id: EmptyAssetId(uniqueString.uniqueString()),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId,
permissions: [],
projectState: null,
}
}
/** A union of all possible {@link Asset} variants. */
export type AnyAsset =
| DirectoryAsset

View File

@ -6,14 +6,18 @@ import AccessedDataIcon from 'enso-assets/accessed_data.svg'
import DocsIcon from 'enso-assets/docs.svg'
import PeopleIcon from 'enso-assets/people.svg'
import PlusIcon from 'enso-assets/plus.svg'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import SortDescendingIcon from 'enso-assets/sort_descending.svg'
import TagIcon from 'enso-assets/tag.svg'
import TimeIcon from 'enso-assets/time.svg'
import * as assetEvent from './events/assetEvent'
import * as assetTreeNode from './assetTreeNode'
import * as authProvider from '../authentication/providers/auth'
import * as backend from './backend'
import * as dateTime from './dateTime'
import * as modalProvider from '../providers/modal'
import * as sorting from './sorting'
import * as tableColumn from './components/tableColumn'
import * as uniqueString from '../uniqueString'
@ -55,6 +59,9 @@ export enum Column {
/** Columns that can be toggled between visible and hidden. */
export type ExtraColumn = (typeof EXTRA_COLUMNS)[number]
/** Columns that can be used as a sort column. */
export type SortableColumn = Column.modified | Column.name
// =================
// === Constants ===
// =================
@ -103,11 +110,11 @@ export const COLUMN_CSS_CLASS: Record<Column, string> = {
} as const
/** {@link table.ColumnProps} for an unknown variant of {@link backend.Asset}. */
export type AssetColumnProps<T extends backend.AnyAsset> = tableColumn.TableColumnProps<
T,
export type AssetColumnProps = tableColumn.TableColumnProps<
assetTreeNode.AssetTreeNode,
assetsTable.AssetsTableState,
assetsTable.AssetRowState,
T['id']
backend.AssetId
>
// =====================
@ -136,8 +143,8 @@ export function getColumnList(backendType: backend.BackendType, extraColumns: Se
// ==========================
/** A column displaying the time at which the asset was last modified. */
function LastModifiedColumn(props: AssetColumnProps<backend.AnyAsset>) {
return <>{props.item.modifiedAt && dateTime.formatDateTime(new Date(props.item.modifiedAt))}</>
function LastModifiedColumn(props: AssetColumnProps) {
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
}
/** Props for a {@link UserPermissionDisplay}. */
@ -170,9 +177,9 @@ function UserPermissionDisplay(props: InternalUserPermissionDisplayProps) {
// ========================
/** A column listing the users with which this asset is shared. */
function SharedWithColumn(props: AssetColumnProps<backend.AnyAsset>) {
function SharedWithColumn(props: AssetColumnProps) {
const {
item,
item: { item },
setItem,
state: { dispatchAssetEvent },
} = props
@ -185,6 +192,19 @@ function SharedWithColumn(props: AssetColumnProps<backend.AnyAsset>) {
const managesThisAsset =
self?.permission === backend.PermissionAction.own ||
self?.permission === backend.PermissionAction.admin
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backend.AnyAsset>) => {
if (typeof valueOrUpdater === 'function') {
setItem(oldItem => ({
...oldItem,
item: valueOrUpdater(oldItem.item),
}))
} else {
setItem(oldItem => ({ ...oldItem, item: valueOrUpdater }))
}
},
[/* should never change */ setItem]
)
return (
<div
className="flex items-center gap-1"
@ -206,7 +226,7 @@ function SharedWithColumn(props: AssetColumnProps<backend.AnyAsset>) {
<ManagePermissionsModal
key={uniqueString.uniqueString()}
item={item}
setItem={setItem}
setItem={setAsset}
self={self}
eventTarget={event.currentTarget}
doRemoveSelf={() => {
@ -235,16 +255,80 @@ function PlaceholderColumn() {
return <></>
}
// =================
// === Constants ===
// =================
/** The corresponding icon URL for each {@link sorting.SortDirection}. */
const SORT_ICON: Record<sorting.SortDirection, string> = {
[sorting.SortDirection.ascending]: SortAscendingIcon,
[sorting.SortDirection.descending]: SortDescendingIcon,
}
export const COLUMN_HEADING: Record<
Column,
(props: tableColumn.TableColumnHeadingProps<assetsTable.AssetsTableState>) => JSX.Element
> = {
[Column.name]: () => <>{COLUMN_NAME[Column.name]}</>,
[Column.modified]: () => (
<div className="flex items-center gap-2">
<SvgMask src={TimeIcon} /> {COLUMN_NAME[Column.modified]}
</div>
),
[Column.name]: ({ state: { sortColumn, setSortColumn, sortDirection, setSortDirection } }) => {
const [isHovered, setIsHovered] = React.useState(false)
const isSortActive = sortColumn === Column.name && sortDirection != null
return (
<div
className="flex items-center cursor-pointer gap-2"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
onClick={() => {
if (sortColumn === Column.name) {
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
} else {
setSortColumn(Column.name)
setSortDirection(sorting.SortDirection.ascending)
}
}}
>
{COLUMN_NAME[Column.name]}
<img
src={isSortActive ? SORT_ICON[sortDirection] : SortAscendingIcon}
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
/>
</div>
)
},
[Column.modified]: ({
state: { sortColumn, setSortColumn, sortDirection, setSortDirection },
}) => {
const [isHovered, setIsHovered] = React.useState(false)
const isSortActive = sortColumn === Column.modified && sortDirection != null
return (
<div
className="flex items-center cursor-pointer gap-2"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
onClick={() => {
if (sortColumn === Column.modified) {
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
} else {
setSortColumn(Column.modified)
setSortDirection(sorting.SortDirection.ascending)
}
}}
>
<SvgMask src={TimeIcon} /> {COLUMN_NAME[Column.modified]}
<img
src={isSortActive ? SORT_ICON[sortDirection] : SortAscendingIcon}
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
/>
</div>
)
},
[Column.sharedWith]: () => (
<div className="flex items-center gap-2">
<SvgMask src={PeopleIcon} /> {COLUMN_NAME[Column.sharedWith]}
@ -275,10 +359,7 @@ export const COLUMN_HEADING: Record<
/** React components for every column except for the name column. */
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
export const COLUMN_RENDERER: Record<
Column,
(props: AssetColumnProps<backend.AnyAsset>) => JSX.Element
> = {
export const COLUMN_RENDERER: Record<Column, (props: AssetColumnProps) => JSX.Element> = {
[Column.name]: AssetNameColumn,
[Column.modified]: LastModifiedColumn,
[Column.sharedWith]: SharedWithColumn,

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as toast from 'react-toastify'
import * as assetEventModule from '../events/assetEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as hooks from '../../hooks'
import * as http from '../../http'
@ -29,13 +30,12 @@ import ManagePermissionsModal from './managePermissionsModal'
// ========================
/** Props for a {@link AssetContextMenu}. */
export interface AssetContextMenuProps<T extends backendModule.AnyAsset> {
export interface AssetContextMenuProps {
hidden?: boolean
innerProps: tableRow.TableRowInnerProps<
T,
assetTreeNode.AssetTreeNode,
assetsTable.AssetsTableState,
assetsTable.AssetRowState,
T['id']
assetsTable.AssetRowState
>
event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
eventTarget: HTMLElement | null
@ -43,11 +43,10 @@ export interface AssetContextMenuProps<T extends backendModule.AnyAsset> {
}
/** The context menu for an arbitrary {@link backendModule.Asset}. */
export default function AssetContextMenu(props: AssetContextMenuProps<backendModule.AnyAsset>) {
export default function AssetContextMenu(props: AssetContextMenuProps) {
const {
hidden = false,
innerProps: {
key,
item,
setItem,
state: { dispatchAssetEvent, dispatchAssetListEvent },
@ -62,16 +61,30 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const self = item.permissions?.find(
const asset = item.item
const self = asset.permissions?.find(
permission => permission.user.user_email === organization?.email
)
const managesThisAsset =
self?.permission === backendModule.PermissionAction.own ||
self?.permission === backendModule.PermissionAction.admin
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
if (typeof valueOrUpdater === 'function') {
setItem(oldItem => ({
...oldItem,
item: valueOrUpdater(oldItem.item),
}))
} else {
setItem(oldItem => ({ ...oldItem, item: valueOrUpdater }))
}
},
[/* should never change */ setItem]
)
return (
<ContextMenus hidden={hidden} key={props.innerProps.item.id} event={event}>
<ContextMenus hidden={hidden} key={asset.id} event={event}>
<ContextMenu hidden={hidden}>
{item.type === backendModule.AssetType.project && (
{asset.type === backendModule.AssetType.project && (
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.open}
@ -79,12 +92,12 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
unsetModal()
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: item.id,
id: asset.id,
})
}}
/>
)}
{item.type === backendModule.AssetType.project &&
{asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local && (
<ContextMenuEntry
hidden={hidden}
@ -104,7 +117,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
logger
)
const projectResponse = await fetch(
`./api/project-manager/projects/${item.id}/enso-project`
`./api/project-manager/projects/${asset.id}/enso-project`
)
// This DOES NOT update the cloud assets list when it
// completes, as the current backend is not the remote
@ -113,7 +126,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
// uncommon enough that it is not worth the added complexity.
await remoteBackend.uploadFile(
{
fileName: `${item.title}.enso-project`,
fileName: `${asset.title}.enso-project`,
fileId: null,
parentDirectoryId: null,
},
@ -135,8 +148,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
<ContextMenuEntry
hidden={hidden}
disabled={
item.type !== backendModule.AssetType.project &&
item.type !== backendModule.AssetType.directory
asset.type !== backendModule.AssetType.project &&
asset.type !== backendModule.AssetType.directory
}
action={shortcuts.KeyboardAction.rename}
doAction={() => {
@ -161,7 +174,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
doAction={() => {
setModal(
<ConfirmDeleteModal
description={`the ${item.type} '${item.title}'`}
description={`the ${asset.type} '${asset.title}'`}
doDelete={doDelete}
/>
)
@ -175,14 +188,14 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
doAction={() => {
setModal(
<ManagePermissionsModal
item={item}
setItem={setItem}
item={asset}
setItem={setAsset}
self={self}
eventTarget={eventTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.removeSelf,
id: item.id,
id: asset.id,
})
}}
/>
@ -232,13 +245,13 @@ export default function AssetContextMenu(props: AssetContextMenuProps<backendMod
}}
/>
</ContextMenu>
{item.type === backendModule.AssetType.directory ? (
{asset.type === backendModule.AssetType.directory ? (
<GlobalContextMenu
hidden={hidden}
// This is SAFE, as this only exists when the item is a directory.
// eslint-disable-next-line no-restricted-syntax
directoryKey={key as backendModule.DirectoryId}
directoryId={item.id}
directoryKey={item.key as backendModule.DirectoryId}
directoryId={asset.id}
dispatchAssetListEvent={dispatchAssetListEvent}
/>
) : null}

View File

@ -14,46 +14,28 @@ import SecretNameColumn from './secretNameColumn'
// =================
/** Props for a {@link AssetNameColumn}. */
export interface AssetNameColumnProps extends column.AssetColumnProps<backendModule.AnyAsset> {}
export interface AssetNameColumnProps extends column.AssetColumnProps {}
/** The icon and name of an {@link backendModule.Asset}. */
export default function AssetNameColumn(props: AssetNameColumnProps) {
const { item } = props
switch (item.type) {
// These type assertions are SAFE, as switching on `item.type` ensures that the `item`
// is of the correct type.
/* eslint-disable no-restricted-syntax */
switch (item.item.type) {
case backendModule.AssetType.directory: {
return (
<DirectoryNameColumn
{...(props as column.AssetColumnProps<backendModule.DirectoryAsset>)}
/>
)
return <DirectoryNameColumn {...props} />
}
case backendModule.AssetType.project: {
return (
<ProjectNameColumn
{...(props as column.AssetColumnProps<backendModule.ProjectAsset>)}
/>
)
return <ProjectNameColumn {...props} />
}
case backendModule.AssetType.file: {
return (
<FileNameColumn {...(props as column.AssetColumnProps<backendModule.FileAsset>)} />
)
return <FileNameColumn {...props} />
}
case backendModule.AssetType.secret: {
return (
<SecretNameColumn
{...(props as column.AssetColumnProps<backendModule.SecretAsset>)}
/>
)
return <SecretNameColumn {...props} />
}
case backendModule.AssetType.specialLoading:
case backendModule.AssetType.specialEmpty: {
// Special rows do not display columns at all.
return <></>
}
/* eslint-enable no-restricted-syntax */
}
}

View File

@ -0,0 +1,243 @@
/** @file A {@link TableRow} for an arbitrary asset. */
import * as React from 'react'
import BlankIcon from 'enso-assets/blank.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as download from '../../download'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as modalProvider from '../../providers/modal'
import * as presenceModule from '../presence'
import * as assetsTable from './assetsTable'
import StatelessSpinner, * as statelessSpinner from './statelessSpinner'
import TableRow, * as tableRow from './tableRow'
import AssetContextMenu from './assetContextMenu'
// ================
// === AssetRow ===
// ================
/** Props for an {@link AssetRow}. */
export interface AssetRowProps
extends tableRow.TableRowProps<
assetTreeNode.AssetTreeNode,
assetsTable.AssetsTableState,
assetsTable.AssetRowState,
backendModule.AssetId
> {}
/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
const {
keyProp: key,
item: rawItem,
initialRowState,
hidden,
selected,
allowContextMenu,
onContextMenu,
state,
columns,
} = props
const { assetEvents, dispatchAssetListEvent } = state
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const { user } = authProvider.useNonPartialUserSession()
const toastAndLog = hooks.useToastAndLog()
const [item, setItem] = React.useState(rawItem)
const asset = item.item
const [presence, setPresence] = React.useState(presenceModule.Presence.present)
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() => ({
...initialRowState,
setPresence,
}))
React.useEffect(() => {
// Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to avoid re-rendering the
// parent.
rawItem.item = asset
}, [asset, rawItem])
const doDelete = React.useCallback(async () => {
setPresence(presenceModule.Presence.deleting)
try {
if (
asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local
) {
if (
asset.projectState.type !== backendModule.ProjectState.placeholder &&
asset.projectState.type !== backendModule.ProjectState.closed
) {
await backend.openProject(asset.id, null, asset.title)
}
try {
await backend.closeProject(asset.id, asset.title)
} catch {
// Ignored. The project was already closed.
}
}
await backend.deleteAsset(asset)
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
key: item.key,
})
} catch (error) {
setPresence(presenceModule.Presence.present)
toastAndLog('Unable to delete project', error)
}
}, [
backend,
dispatchAssetListEvent,
asset,
/* should never change */ item.key,
/* should never change */ toastAndLog,
])
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
// These events are handled in the specific NameColumn files.
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.newSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects: {
break
}
case assetEventModule.AssetEventType.deleteMultiple: {
if (event.ids.has(item.key)) {
await doDelete()
}
break
}
case assetEventModule.AssetEventType.downloadSelected: {
if (selected) {
download.download(
'./api/project-manager/' + `projects/${asset.id}/enso-project`,
`${asset.title}.enso-project`
)
}
break
}
case assetEventModule.AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user != null) {
setPresence(presenceModule.Presence.deleting)
try {
await backend.createPermission({
action: null,
resourceId: asset.id,
userSubjects: [user.id],
})
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
key: item.key,
})
} catch (error) {
setPresence(presenceModule.Presence.present)
toastAndLog('Unable to delete project', error)
}
}
break
}
}
})
switch (asset.type) {
case backendModule.AssetType.directory:
case backendModule.AssetType.project:
case backendModule.AssetType.file:
case backendModule.AssetType.secret: {
return (
<>
<TableRow
className={presenceModule.CLASS_NAME[presence]}
{...props}
hidden={hidden || presence === presenceModule.Presence.deleting}
onContextMenu={(innerProps, event) => {
if (allowContextMenu) {
event.preventDefault()
event.stopPropagation()
onContextMenu?.(innerProps, event)
setModal(
<AssetContextMenu
innerProps={innerProps}
event={event}
eventTarget={event.currentTarget}
doDelete={doDelete}
/>
)
} else {
onContextMenu?.(innerProps, event)
}
}}
item={item}
setItem={setItem}
initialRowState={rowState}
setRowState={setRowState}
/>
{selected &&
allowContextMenu &&
presence !== presenceModule.Presence.deleting && (
// This is a copy of the context menu, since the context menu registers keyboard
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
// the entire context menu (once for the keyboard actions, once for the JSX).
<AssetContextMenu
hidden
innerProps={{
key,
item,
setItem,
state,
rowState,
setRowState,
}}
event={{ pageX: 0, pageY: 0 }}
eventTarget={null}
doDelete={doDelete}
/>
)}
</>
)
}
case backendModule.AssetType.specialLoading: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="p-0 rounded-full border-r">
<div
className={`flex justify-center py-2 ${indent.indentClass(item.depth)}`}
>
<StatelessSpinner
size={24}
state={statelessSpinner.SpinnerState.loadingMedium}
/>
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialEmpty: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="p-0 rounded-full border-r">
<div
className={`flex items-center h-10 py-2 ${indent.indentClass(
item.depth
)}`}
>
<img src={BlankIcon} />
{assetsTable.EMPTY_DIRECTORY_PLACEHOLDER}
</div>
</td>
</tr>
)
}
}
}

View File

@ -5,6 +5,7 @@ import DirectoryIcon from 'enso-assets/folder.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as column from '../column'
@ -23,29 +24,35 @@ import SvgMask from '../../authentication/components/svgMask'
// =====================
/** Props for a {@link DirectoryNameColumn}. */
export interface DirectoryNameColumnProps
extends column.AssetColumnProps<backendModule.DirectoryAsset> {}
export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
/** The icon and name of a {@link backendModule.DirectoryAsset}. */
/** The icon and name of a {@link backendModule.DirectoryAsset}.
* @throws {Error} when the asset is not a {@link backendModule.DirectoryAsset}.
* This should never happen. */
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const {
keyProp: key,
item,
setItem,
selected,
setSelected,
state: { assetEvents, dispatchAssetListEvent, doToggleDirectoryExpansion, getDepth },
state: { assetEvents, dispatchAssetListEvent, doToggleDirectoryExpansion },
rowState,
setRowState,
} = props
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
const asset = item.item
if (asset.type !== backendModule.AssetType.directory) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DirectoryNameColumn` can only display directory assets.')
}
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
const doRename = async (newName: string) => {
if (backend.type !== backendModule.BackendType.local) {
try {
await backend.updateDirectory(item.id, { title: newName }, item.title)
await backend.updateDirectory(asset.id, { title: newName }, asset.title)
return
} catch (error) {
toastAndLog('Error renaming folder', error)
@ -69,26 +76,25 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
break
}
case assetEventModule.AssetEventType.newFolder: {
if (key === event.placeholderId) {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Folders cannot be created on the local backend')
} else {
rowState.setPresence(presence.Presence.inserting)
try {
const createdDirectory = await backend.createDirectory({
parentId: item.parentId,
title: item.title,
parentId: asset.parentId,
title: asset.title,
})
rowState.setPresence(presence.Presence.present)
const newItem: backendModule.DirectoryAsset = {
...item,
setAsset({
...asset,
...createdDirectory,
}
setItem(newItem)
})
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
key: item.key,
})
toastAndLog('Error creating new folder', error)
}
@ -102,7 +108,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
return (
<div
className={`flex text-left items-center whitespace-nowrap ${indent.indentClass(
getDepth(key)
item.depth
)}`}
onClick={event => {
if (
@ -121,7 +127,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
window.setTimeout(() => {
setSelected(false)
}, 0)
doToggleDirectoryExpansion(item.id, key, item.title)
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
}
}
}}
@ -134,13 +140,13 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
if (newTitle !== asset.title) {
const oldTitle = asset.title
setAsset(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(newTitle)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
setAsset(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
@ -152,7 +158,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
}}
className="cursor-pointer bg-transparent grow px-2"
>
{item.title}
{asset.title}
</EditableSpan>
</div>
)

View File

@ -1,6 +1,5 @@
/** @file The directory header bar and directory item listing. */
import * as React from 'react'
import * as toastify from 'react-toastify'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
@ -8,8 +7,6 @@ import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as loggerProvider from '../../providers/logger'
import * as string from '../../string'
import * as pageSwitcher from './pageSwitcher'
import AssetsTable from './assetsTable'
@ -56,26 +53,11 @@ export default function DriveView(props: DriveViewProps) {
isListingLocalDirectoryAndWillFail,
isListingRemoteDirectoryAndWillFail,
} = props
const logger = loggerProvider.useLogger()
const { organization, accessToken } = authProvider.useNonPartialUserSession()
const { organization } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const [initialized, setInitialized] = React.useState(false)
const [assets, rawSetAssets] = React.useState<backendModule.AnyAsset[]>([])
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
const [isFileBeingDragged, setIsFileBeingDragged] = React.useState(false)
const [assetEvents, dispatchAssetEvent] = hooks.useEvent<assetEventModule.AssetEvent>()
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
React.useState(initialProjectName)
const assetFilter = React.useMemo(() => {
if (query === '') {
return null
} else {
const regex = new RegExp(string.regexEscape(query), 'i')
return (asset: backendModule.AnyAsset) => regex.test(asset.title)
}
}, [query])
React.useEffect(() => {
const onBlur = () => {
@ -87,98 +69,6 @@ export default function DriveView(props: DriveViewProps) {
}
}, [])
React.useEffect(() => {
setIsLoadingAssets(true)
}, [backend])
React.useEffect(() => {
if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) {
setIsLoadingAssets(false)
}
}, [loadingProjectManagerDidFail, backend.type])
const setAssets = React.useCallback(
(newAssets: backendModule.AnyAsset[]) => {
rawSetAssets(newAssets)
// The project name here might also be a string with project id, e.g. when opening
// a project file from explorer on Windows.
const isInitialProject = (asset: backendModule.AnyAsset) =>
asset.title === initialProjectName || asset.id === initialProjectName
if (nameOfProjectToImmediatelyOpen != null) {
const projectToLoad = newAssets
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: projectToLoad.id,
})
}
setNameOfProjectToImmediatelyOpen(null)
}
if (!initialized) {
setInitialized(true)
if (initialProjectName != null) {
if (!newAssets.some(isInitialProject)) {
const errorMessage = `No project named '${initialProjectName}' was found.`
toastify.toast.error(errorMessage)
logger.error(`Error opening project on startup: ${errorMessage}`)
}
}
}
},
[
initialized,
initialProjectName,
logger,
nameOfProjectToImmediatelyOpen,
/* should never change */ setNameOfProjectToImmediatelyOpen,
/* should never change */ dispatchAssetEvent,
]
)
React.useEffect(() => {
if (initialized) {
setAssets([])
}
// `setAssets` is a callback, not a dependency. `initialized` is not a dependency either.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backend])
hooks.useAsyncEffect(
null,
async signal => {
switch (backend.type) {
case backendModule.BackendType.local: {
if (!isListingLocalDirectoryAndWillFail) {
const newAssets = await backend.listDirectory({ parentId: null }, null)
if (!signal.aborted) {
setIsLoadingAssets(false)
setAssets(newAssets)
}
}
break
}
case backendModule.BackendType.remote: {
if (
!isListingRemoteDirectoryAndWillFail &&
!isListingRemoteDirectoryWhileOffline
) {
const newAssets = await backend.listDirectory({ parentId: null }, null)
if (!signal.aborted) {
setIsLoadingAssets(false)
setAssets(newAssets)
}
} else {
setIsLoadingAssets(false)
}
break
}
}
},
[accessToken, organization, backend]
)
const doUploadFiles = React.useCallback(
(files: File[]) => {
if (backend.type !== backendModule.BackendType.local && organization == null) {
@ -245,16 +135,19 @@ export default function DriveView(props: DriveViewProps) {
</div>
)}
<AssetsTable
items={assets}
filter={assetFilter}
isLoading={isLoadingAssets}
query={query}
appRunner={appRunner}
initialProjectName={initialProjectName}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
doOpenIde={doOpenEditor}
doCloseIde={doCloseEditor}
loadingProjectManagerDidFail={loadingProjectManagerDidFail}
isListingRemoteDirectoryWhileOffline={isListingRemoteDirectoryWhileOffline}
isListingLocalDirectoryAndWillFail={isListingLocalDirectoryAndWillFail}
isListingRemoteDirectoryAndWillFail={isListingRemoteDirectoryAndWillFail}
/>
</div>
{isFileBeingDragged &&

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as eventModule from '../event'
@ -22,22 +23,29 @@ import SvgMask from '../../authentication/components/svgMask'
// ================
/** Props for a {@link FileNameColumn}. */
export interface FileNameColumnProps extends column.AssetColumnProps<backendModule.FileAsset> {}
export interface FileNameColumnProps extends column.AssetColumnProps {}
/** The icon and name of a {@link backendModule.FileAsset}. */
/** The icon and name of a {@link backendModule.FileAsset}.
* @throws {Error} when the asset is not a {@link backendModule.FileAsset}.
* This should never happen. */
export default function FileNameColumn(props: FileNameColumnProps) {
const {
keyProp: key,
item,
setItem,
selected,
state: { assetEvents, dispatchAssetListEvent, getDepth },
state: { assetEvents, dispatchAssetListEvent },
rowState,
setRowState,
} = props
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
const asset = item.item
if (asset.type !== backendModule.AssetType.file) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`FileNameColumn` can only display file assets.')
}
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
@ -61,28 +69,27 @@ export default function FileNameColumn(props: FileNameColumnProps) {
break
}
case assetEventModule.AssetEventType.uploadFiles: {
const file = event.files.get(key)
const file = event.files.get(item.key)
if (file != null) {
rowState.setPresence(presence.Presence.inserting)
try {
const createdFile = await backend.uploadFile(
{
fileId: null,
fileName: item.title,
parentDirectoryId: item.parentId,
fileName: asset.title,
parentDirectoryId: asset.parentId,
},
file
)
rowState.setPresence(presence.Presence.present)
const newItem: backendModule.FileAsset = {
...item,
...createdFile,
}
setItem(newItem)
setAsset({
...asset,
id: createdFile.id,
})
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
key: item.key,
})
toastAndLog('Could not upload file', error)
}
@ -95,7 +102,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
return (
<div
className={`flex text-left items-center align-middle whitespace-nowrap ${indent.indentClass(
getDepth(key)
item.depth
)}`}
onClick={event => {
if (
@ -118,13 +125,13 @@ export default function FileNameColumn(props: FileNameColumnProps) {
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
if (newTitle !== asset.title) {
const oldTitle = asset.title
setAsset(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(/* newTitle */)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
setAsset(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
@ -136,7 +143,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
}}
className="bg-transparent grow px-2"
>
{item.title}
{asset.title}
</EditableSpan>
</div>
)

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as eventModule from '../event'
@ -22,13 +23,13 @@ import ProjectIcon from './projectIcon'
// ===================
/** Props for a {@link ProjectNameColumn}. */
export interface ProjectNameColumnProps
extends column.AssetColumnProps<backendModule.ProjectAsset> {}
export interface ProjectNameColumnProps extends column.AssetColumnProps {}
/** The icon and name of a {@link backendModule.ProjectAsset}. */
/** The icon and name of a {@link backendModule.ProjectAsset}.
* @throws {Error} when the asset is not a {@link backendModule.ProjectAsset}.
* This should never happen. */
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const {
keyProp: key,
item,
setItem,
selected,
@ -42,23 +43,28 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
doOpenManually,
doOpenIde,
doCloseIde,
getDepth,
},
} = props
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
const asset = item.item
if (asset.type !== backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`ProjectNameColumn` can only display project assets.')
}
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
const doRename = async (newName: string) => {
try {
await backend.projectUpdate(
item.id,
asset.id,
{
ami: null,
ideVersion: null,
projectName: newName,
},
item.title
asset.title
)
return
} catch (error) {
@ -84,17 +90,17 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// This should only run before this project gets replaced with the actual project
// by this event handler. In both cases `key` will match, so using `key` here
// is a mistake.
if (item.id === event.placeholderId) {
if (asset.id === event.placeholderId) {
rowState.setPresence(presence.Presence.inserting)
try {
const createdProject = await backend.createProject({
parentDirectoryId: item.parentId,
projectName: item.title,
parentDirectoryId: asset.parentId,
projectName: asset.title,
projectTemplateName: event.templateId,
})
rowState.setPresence(presence.Presence.present)
setItem({
...item,
setAsset({
...asset,
id: createdProject.projectId,
projectState: { type: backendModule.ProjectState.placeholder },
})
@ -105,7 +111,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
key: item.key,
})
toastAndLog('Error creating new project', error)
}
@ -113,7 +119,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
break
}
case assetEventModule.AssetEventType.uploadFiles: {
const file = event.files.get(key)
const file = event.files.get(item.key)
if (file != null) {
rowState.setPresence(presence.Presence.inserting)
try {
@ -142,23 +148,23 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
null
)
rowState.setPresence(presence.Presence.present)
setItem({
...item,
setAsset({
...asset,
title: listedProject.packageName,
id: backendModule.ProjectId(id),
})
} else {
const fileName = item.title
const title = backendModule.stripProjectExtension(item.title)
setItem({
...item,
const fileName = asset.title
const title = backendModule.stripProjectExtension(asset.title)
setAsset({
...asset,
title,
})
const createdFile = await backend.uploadFile(
{
fileId: null,
fileName,
parentDirectoryId: item.parentId,
parentDirectoryId: asset.parentId,
},
file
)
@ -167,8 +173,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
throw new Error('The uploaded file was not a project.')
} else {
rowState.setPresence(presence.Presence.present)
setItem({
...item,
setAsset({
...asset,
title,
id: project.projectId,
projectState: project.state,
@ -179,7 +185,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
key: item.key,
})
toastAndLog('Could not upload project', error)
}
@ -192,14 +198,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
return (
<div
className={`flex text-left items-center whitespace-nowrap ${indent.indentClass(
getDepth(key)
item.depth
)}`}
onClick={event => {
if (!rowState.isEditingName && eventModule.isDoubleClick(event)) {
// It is a double click; open the project.
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: item.id,
id: asset.id,
})
} else if (
eventModule.isSingleClick(event) &&
@ -214,14 +220,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}}
>
<ProjectIcon
keyProp={key}
item={item}
setItem={setItem}
keyProp={item.key}
item={asset}
setItem={setAsset}
assetEvents={assetEvents}
doOpenManually={doOpenManually}
appRunner={appRunner}
openIde={() => {
doOpenIde(item)
doOpenIde(asset)
}}
onClose={doCloseIde}
/>
@ -232,13 +238,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
if (newTitle !== asset.title) {
const oldTitle = asset.title
setAsset(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(newTitle)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
setAsset(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
@ -258,7 +264,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
}`}
>
{item.title}
{asset.title}
</EditableSpan>
</div>
)

View File

@ -5,6 +5,7 @@ import SecretIcon from 'enso-assets/secret.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as eventModule from '../event'
@ -23,22 +24,29 @@ import SvgMask from '../../authentication/components/svgMask'
// ==================
/** Props for a {@link SecretNameColumn}. */
export interface SecretNameColumnProps extends column.AssetColumnProps<backendModule.SecretAsset> {}
export interface SecretNameColumnProps extends column.AssetColumnProps {}
/** The icon and name of a {@link backendModule.SecretAsset}. */
/** The icon and name of a {@link backendModule.SecretAsset}.
* @throws {Error} when the asset is not a {@link backendModule.SecretAsset}.
* This should never happen. */
export default function SecretNameColumn(props: SecretNameColumnProps) {
const {
keyProp: key,
item,
setItem,
selected,
state: { assetEvents, dispatchAssetListEvent, getDepth },
state: { assetEvents, dispatchAssetListEvent },
rowState,
setRowState,
} = props
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
const asset = item.item
if (asset.type !== backendModule.AssetType.secret) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`SecretNameColumn` can only display secret assets.')
}
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
@ -62,27 +70,26 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
break
}
case assetEventModule.AssetEventType.newSecret: {
if (key === event.placeholderId) {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Secrets cannot be created on the local backend')
} else {
rowState.setPresence(presence.Presence.inserting)
try {
const createdSecret = await backend.createSecret({
parentDirectoryId: item.parentId,
secretName: item.title,
parentDirectoryId: asset.parentId,
secretName: asset.title,
secretValue: event.value,
})
rowState.setPresence(presence.Presence.present)
const newItem: backendModule.SecretAsset = {
...item,
...createdSecret,
}
setItem(newItem)
setAsset({
...asset,
id: createdSecret.id,
})
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
key: item.key,
})
toastAndLog('Error creating new secret', error)
}
@ -96,7 +103,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
return (
<div
className={`flex text-left items-center whitespace-nowrap ${indent.indentClass(
getDepth(key)
item.depth
)}`}
onClick={event => {
if (
@ -119,13 +126,13 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
if (newTitle !== asset.title) {
const oldTitle = asset.title
setAsset(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(/* newTitle */)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
setAsset(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
@ -137,7 +144,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
}}
className="bg-transparent grow px-2"
>
{item.title}
{asset.title}
</EditableSpan>
</div>
)

View File

@ -51,8 +51,9 @@ interface InternalNoSelectedKeysProps {
/** Props for a {@link Table}. */
interface InternalTableProps<T, State = never, RowState = never, Key extends string = string> {
footer?: JSX.Element
rowComponent?: (props: tableRow.TableRowProps<T, State, RowState, Key>) => JSX.Element
rowComponent?: (props: tableRow.TableRowProps<T, State, RowState, Key>) => JSX.Element | null
items: T[]
filter?: ((item: T) => boolean) | null
state?: State
initialRowState?: RowState
getKey: (item: T) => Key
@ -88,6 +89,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
footer,
rowComponent: RowComponent = TableRow,
items,
filter,
getKey,
selectedKeys: rawSelectedKeys,
setSelectedKeys: rawSetSelectedKeys,
@ -248,6 +250,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
key={key}
keyProp={key}
item={item}
hidden={filter != null ? !filter(item) : false}
selected={selectedKeys.has(key)}
setSelected={selected => {
setSelectedKeys(oldSelectedKeys =>

View File

@ -55,6 +55,7 @@ interface InternalBaseTableRowProps<T, State = never, RowState = never, Key exte
/** Pass this in only if `item` also needs to be updated in the parent component. */
setItem?: React.Dispatch<React.SetStateAction<T>>
state?: State
hidden: boolean
initialRowState?: RowState
/** Pass this in only if `rowState` also needs to be updated in the parent component. */
setRowState?: React.Dispatch<React.SetStateAction<RowState>>
@ -92,6 +93,7 @@ export default function TableRow<T, State = never, RowState = never, Key extends
initialRowState,
setRowState: rawSetRowState,
columns,
hidden,
selected,
setSelected,
// This prop is unused here, but is useful for components wrapping this component.
@ -137,7 +139,7 @@ export default function TableRow<T, State = never, RowState = never, Key extends
setRowState,
}
return (
return hidden ? null : (
<tr
ref={tableRowRef}
tabIndex={-1}

View File

@ -71,7 +71,7 @@ export interface AssetNewFolderEvent extends AssetBaseEvent<AssetEventType.newFo
/** A signal to upload files. */
export interface AssetUploadFilesEvent extends AssetBaseEvent<AssetEventType.uploadFiles> {
files: Map<backendModule.FileId | backendModule.ProjectId, File>
files: Map<backendModule.AssetId, File>
}
/** A signal to create a secret. */

View File

@ -79,7 +79,7 @@ interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.
/** A signal that a file has been deleted. This must not be called before the request is
* finished. */
interface AssetListDeleteEvent extends AssetListBaseEvent<AssetListEventType.delete> {
id: backend.AssetId
key: backend.AssetId
}
/** Every possible type of asset list event. */

View File

@ -0,0 +1,15 @@
/** @file Utilities related to sorting. */
/** Sort direction. */
export enum SortDirection {
ascending = 'ascending',
descending = 'descending',
}
/** The next {@link SortDirection}, in the order they are cycled through when clicking a column
* header. */
export const NEXT_SORT_DIRECTION: Record<SortDirection | 'null', SortDirection | null> = {
null: SortDirection.ascending,
[SortDirection.ascending]: SortDirection.descending,
[SortDirection.descending]: null,
}