mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 08:52:58 +03:00
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:
parent
1d67667792
commit
f3f2f06bd9
3
app/ide-desktop/lib/assets/sort_ascending.svg
Normal file
3
app/ide-desktop/lib/assets/sort_ascending.svg
Normal 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 |
3
app/ide-desktop/lib/assets/sort_descending.svg
Normal file
3
app/ide-desktop/lib/assets/sort_descending.svg
Normal 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 |
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
)
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
)
|
||||
|
@ -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 &&
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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 =>
|
||||
|
@ -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}
|
||||
|
@ -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. */
|
||||
|
@ -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. */
|
||||
|
@ -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,
|
||||
}
|
Loading…
Reference in New Issue
Block a user