Check asset name uniqueness (#8018)

- Implements frontend component of https://github.com/enso-org/cloud-v2/issues/702
- Ensures that the new name is not already present in a sibling in the directory
- Only compares between directories when renaming a directory
- Only compares between files/projects/connectors when renaming one of those

# Important Notes
- This has not been implemented for connectors and files as currently there is no backend endpoint to rename those.
- Secrets are also not implemented yet, AFAIK, so there is no behavior related to secrets.
This commit is contained in:
somebody1234 2023-10-11 20:17:33 +10:00 committed by GitHub
parent 826127d8ff
commit 94d3a05905
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 92 additions and 28 deletions

View File

@ -141,6 +141,10 @@ export interface AssetsTableState {
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
assetEvents: assetEventModule.AssetEvent[]
dispatchAssetEvent: (event: assetEventModule.AssetEvent) => void
topLevelAssets: Readonly<React.MutableRefObject<assetTreeNode.AssetTreeNode[]>>
nodeMap: Readonly<
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>>
>
doToggleDirectoryExpansion: (
directoryId: backendModule.DirectoryId,
key: backendModule.AssetId,
@ -228,21 +232,18 @@ export default function AssetsTable(props: AssetsTableProps) {
const [sortColumn, setSortColumn] = React.useState<columnModule.SortableColumn | null>(null)
const [sortDirection, setSortDirection] = React.useState<sorting.SortDirection | null>(null)
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<backendModule.AssetId>())
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
const [, setQueuedAssetEvents] = React.useState<assetEventModule.AssetEvent[]>([])
const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName)
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
const assetTreeRef = React.useRef<assetTreeNode.AssetTreeNode[]>([])
const nodeMapRef = React.useRef<
ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>
>(new Map<backendModule.AssetId, assetTreeNode.AssetTreeNode>())
const rootDirectoryId = React.useMemo(
() => backend.rootDirectoryId(organization),
[backend, organization]
)
const nodeMap = React.useMemo(
() =>
new Map(
assetTreeNode.assetTreePreorderTraversal(assetTree).map(asset => [asset.key, asset])
),
[assetTree]
)
const filter = React.useMemo(() => {
if (query === '') {
return null
@ -302,6 +303,13 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [loadingProjectManagerDidFail, backend.type])
React.useEffect(() => {
assetTreeRef.current = assetTree
nodeMapRef.current = new Map(
assetTreeNode.assetTreePreorderTraversal(assetTree).map(asset => [asset.key, asset])
)
}, [assetTree])
React.useEffect(() => {
if (isLoading) {
setNameOfProjectToImmediatelyOpen(initialProjectName)
@ -502,7 +510,7 @@ export default function AssetsTable(props: AssetsTableProps) {
)
const doToggleDirectoryExpansion = React.useCallback(
(directoryId: backendModule.DirectoryId, key: backendModule.AssetId, title?: string) => {
const directory = nodeMap.get(key)
const directory = nodeMapRef.current.get(key)
if (directory?.children != null) {
const abortController = directoryListAbortControllersRef.current.get(directoryId)
if (abortController != null) {
@ -606,21 +614,22 @@ export default function AssetsTable(props: AssetsTableProps) {
})()
}
},
[category, nodeMap, backend]
[category, nodeMapRef, backend]
)
const getNewProjectName = React.useCallback(
(templateId: string | null, parentKey: backendModule.DirectoryId | null) => {
const prefix = `${templateId ?? 'New_Project'}_`
const projectNameTemplate = new RegExp(`^${prefix}(?<projectIndex>\\d+)$`)
const siblings = parentKey == null ? assetTree : nodeMap.get(parentKey)?.children ?? []
const siblings =
parentKey == null ? assetTree : nodeMapRef.current.get(parentKey)?.children ?? []
const projectIndices = siblings
.filter(node => backendModule.assetIsProject(node.item))
.map(node => projectNameTemplate.exec(node.item.title)?.groups?.projectIndex)
.map(maybeIndex => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
return `${prefix}${Math.max(0, ...projectIndices) + 1}`
},
[assetTree, nodeMap]
[assetTree, nodeMapRef]
)
hooks.useEventHandler(assetListEvents, event => {
@ -629,7 +638,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const siblings =
event.parentKey == null
? assetTree
: nodeMap.get(event.parentKey)?.children ?? []
: nodeMapRef.current.get(event.parentKey)?.children ?? []
const directoryIndices = siblings
.filter(node => backendModule.assetIsDirectory(node.item))
.map(node => DIRECTORY_NAME_REGEX.exec(node.item.title))
@ -650,7 +659,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (
event.parentId != null &&
event.parentKey != null &&
nodeMap.get(event.parentKey)?.children == null
nodeMapRef.current.get(event.parentKey)?.children == null
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
@ -701,7 +710,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (
event.parentId != null &&
event.parentKey != null &&
nodeMap.get(event.parentKey)?.children == null
nodeMapRef.current.get(event.parentKey)?.children == null
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
@ -767,7 +776,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (
event.parentId != null &&
event.parentKey != null &&
nodeMap.get(event.parentKey)?.children == null
nodeMapRef.current.get(event.parentKey)?.children == null
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
@ -829,7 +838,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (
event.parentId != null &&
event.parentKey != null &&
nodeMap.get(event.parentKey)?.children == null
nodeMapRef.current.get(event.parentKey)?.children == null
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
@ -884,7 +893,7 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case assetListEventModule.AssetListEventType.closeFolder: {
if (nodeMap.get(event.key)?.children != null) {
if (nodeMapRef.current.get(event.key)?.children != null) {
doToggleDirectoryExpansion(event.id, event.key)
}
break
@ -928,6 +937,8 @@ export default function AssetsTable(props: AssetsTableProps) {
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
topLevelAssets: assetTreeRef,
nodeMap: nodeMapRef,
doToggleDirectoryExpansion,
doOpenManually,
doOpenIde,
@ -1015,7 +1026,8 @@ export default function AssetsTable(props: AssetsTableProps) {
backend.type === backendModule.BackendType.local ||
(organization != null &&
Array.from(innerSelectedKeys, key => {
const userPermissions = nodeMap.get(key)?.item.permissions
const userPermissions =
nodeMapRef.current.get(key)?.item.permissions
const selfPermission = userPermissions?.find(
permission =>
permission.user.user_email === organization.email

View File

@ -40,6 +40,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
numberOfSelectedItems,
assetEvents,
dispatchAssetListEvent,
topLevelAssets,
nodeMap,
doToggleDirectoryExpansion,
},
rowState,
@ -182,6 +184,20 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
)}
<EditableSpan
editable={rowState.isEditingName}
checkSubmittable={newTitle =>
(item.directoryKey != null
? nodeMap.current.get(item.directoryKey)?.children ?? []
: topLevelAssets.current
).every(
child =>
// All siblings,
child.key === item.key ||
// that are directories,
!backendModule.assetIsDirectory(child.item) ||
// must have a different name.
child.item.title !== newTitle
)
}
onSubmit={async newTitle => {
setRowState(oldRowState => ({
...oldRowState,

View File

@ -17,6 +17,7 @@ type EditableSpanPassthroughProps = JSX.IntrinsicElements['input'] & JSX.Intrins
/** Props for an {@link EditableSpan}. */
export interface EditableSpanProps extends Omit<EditableSpanPassthroughProps, 'onSubmit'> {
editable?: boolean
checkSubmittable?: (value: string) => boolean
onSubmit: (value: string) => void
onCancel: () => void
inputPattern?: string
@ -28,6 +29,7 @@ export interface EditableSpanProps extends Omit<EditableSpanPassthroughProps, 'o
export default function EditableSpan(props: EditableSpanProps) {
const {
editable = false,
checkSubmittable,
children,
onSubmit,
onCancel,
@ -36,9 +38,15 @@ export default function EditableSpan(props: EditableSpanProps) {
...passthroughProps
} = props
const { shortcuts } = shortcutsProvider.useShortcuts()
const [isSubmittable, setIsSubmittable] = React.useState(true)
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true)
// This effect MUST only run on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
React.useEffect(() => {
if (editable) {
return shortcuts.registerKeyboardHandlers({
@ -58,8 +66,10 @@ export default function EditableSpan(props: EditableSpanProps) {
className="flex grow"
onSubmit={event => {
event.preventDefault()
if (inputRef.current != null) {
onSubmit(inputRef.current.value)
if (isSubmittable) {
if (inputRef.current != null) {
onSubmit(inputRef.current.value)
}
}
}}
>
@ -70,14 +80,24 @@ export default function EditableSpan(props: EditableSpanProps) {
size={1}
defaultValue={children}
onBlur={event => event.currentTarget.form?.requestSubmit()}
{...(inputPattern != null ? { pattern: inputPattern } : {})}
{...(inputTitle != null ? { title: inputTitle } : {})}
{...(inputPattern == null ? {} : { pattern: inputPattern })}
{...(inputTitle == null ? {} : { title: inputTitle })}
{...(checkSubmittable == null
? {}
: {
onInput: event => {
setIsSubmittable(checkSubmittable(event.currentTarget.value))
},
})}
{...passthroughProps}
/>
<button type="submit" className="mx-0.5">
<img src={TickIcon} />
</button>
{isSubmittable && (
<button type="submit" className="mx-0.5">
<img src={TickIcon} />
</button>
)}
<button
type="button"
className="mx-0.5"
onClick={event => {
event.stopPropagation()

View File

@ -46,6 +46,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
topLevelAssets,
nodeMap,
doOpenManually,
doOpenIde,
doCloseIde,
@ -288,6 +290,20 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
)}
<EditableSpan
editable={rowState.isEditingName}
checkSubmittable={newTitle =>
(item.directoryKey != null
? nodeMap.current.get(item.directoryKey)?.children ?? []
: topLevelAssets.current
).every(
child =>
// All siblings,
child.key === item.key ||
// that are not directories,
backendModule.assetIsDirectory(child.item) ||
// must have a different name.
child.item.title !== newTitle
)
}
onSubmit={async newTitle => {
setRowState(oldRowState => ({
...oldRowState,