Feat: add persisted folder rename

This commit is contained in:
MangoPig
2026-06-24 17:07:08 +01:00
parent 268093d223
commit a5f0c41cba
7 changed files with 701 additions and 110 deletions
@@ -57,6 +57,8 @@ type PersistedProjectFolderRecord = {
type ProjectFoldersResponse = {
data?: {
folders?: PersistedProjectFolderRecord[];
renamedFolder?: PersistedProjectFolderRecord;
previousFolderId?: string;
};
error?: string;
message?: string;
@@ -67,6 +69,11 @@ type PendingProjectFolderDraft = {
depth: number;
};
type PendingProjectFolderRename = {
folderId: string;
depth: number;
};
type ProjectDragTarget = NavTreeDropTarget;
type ProjectDragState = {
@@ -173,6 +180,11 @@ const ProjectFolderBranch = (props: {
onPendingFolderNameChange: (value: string) => void;
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => void;
pendingFolderRename: PendingProjectFolderRename | null;
pendingFolderRenameName: string;
onPendingFolderRenameChange: (value: string) => void;
onSubmitPendingFolderRename: () => void;
onCancelPendingFolderRename: () => void;
dragState: ProjectDragState | null;
isTreeClickSuppressed: () => boolean;
}): JSX.Element => (
@@ -197,51 +209,65 @@ const ProjectFolderBranch = (props: {
if (node.kind === "folder") {
const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemFolder]: true,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
<Show
when={isRenaming()}
fallback={
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemFolder]: true,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
onPointerEnter={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
onPointerEnter={(event): void =>
props.onNodePointerMove(event, props.parentId, indexAccessor(), node)
}
>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
/>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
}
>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
<ProjectFolderDraftRow
depth={props.pendingFolderRename?.depth ?? props.depth}
value={props.pendingFolderRenameName}
onInput={props.onPendingFolderRenameChange}
onSubmit={props.onSubmitPendingFolderRename}
onCancel={props.onCancelPendingFolderRename}
/>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
</Show>
<Show when={!isCollapsed() && ((node.children?.length ?? 0) > 0 || props.pendingFolderDraft?.parentId === node.id)}>
<ProjectFolderBranch
@@ -261,6 +287,11 @@ const ProjectFolderBranch = (props: {
onPendingFolderNameChange={props.onPendingFolderNameChange}
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
pendingFolderRename={props.pendingFolderRename}
pendingFolderRenameName={props.pendingFolderRenameName}
onPendingFolderRenameChange={props.onPendingFolderRenameChange}
onSubmitPendingFolderRename={props.onSubmitPendingFolderRename}
onCancelPendingFolderRename={props.onCancelPendingFolderRename}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
@@ -331,6 +362,8 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
);
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingProjectFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingProjectFolderRename | null>(null);
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
const [dragState, setDragState] = createSignal<ProjectDragState | null>(null);
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
let rootRef: HTMLDivElement | undefined;
@@ -382,6 +415,8 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename(null);
setPendingFolderRenameName("");
setDragState(null);
};
@@ -607,10 +642,19 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
setCollapsedFolderIds((current) => current.filter((id) => id !== parentId));
}
setPendingFolderRename(null);
setPendingFolderRenameName("");
setPendingFolderName("");
setPendingFolderDraft({ parentId, depth });
};
const beginFolderRename = (folderId: string, label: string, depth: number): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename({ folderId, depth });
setPendingFolderRenameName(label);
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
@@ -688,11 +732,71 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
}
};
const submitPendingFolderRename = async (): Promise<void> => {
const draft = pendingFolderRename();
const name = pendingFolderRenameName().trim();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolderRename();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: draft.folderId,
name,
}),
});
const body = (await response.json()) as ProjectFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to rename project folder.");
}
setPersistedFolders(readPersistedFolders(body));
setPendingFolderRename(null);
setPendingFolderRenameName("");
const previousFolderId = body.data?.previousFolderId;
const renamedFolderId = body.data?.renamedFolder?.id;
if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) {
setCollapsedFolderIds((current) =>
current.map((id) => (id === previousFolderId ? renamedFolderId : id)),
);
}
} catch (error) {
console.error(error);
}
};
const cancelPendingFolder = (): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
};
const cancelPendingFolderRename = (): void => {
setPendingFolderRename(null);
setPendingFolderRenameName("");
};
const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => {
switch (action.id) {
case "new-folder":
@@ -715,6 +819,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
void deletePersistedFolder(target.id);
}
return;
case "rename-folder":
if (target.kind === "folder") {
beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0);
}
return;
default:
return;
}
@@ -887,6 +996,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
onPendingFolderNameChange={setPendingFolderName}
onSubmitPendingFolder={submitPendingFolder}
onCancelPendingFolder={cancelPendingFolder}
pendingFolderRename={pendingFolderRename()}
pendingFolderRenameName={pendingFolderRenameName()}
onPendingFolderRenameChange={setPendingFolderRenameName}
onSubmitPendingFolderRename={submitPendingFolderRename}
onCancelPendingFolderRename={cancelPendingFolderRename}
dragState={dragState()}
isTreeClickSuppressed={suppressNextTreeClick}
/>