Refactor: add stable folder layout foundation

This commit is contained in:
MangoPig
2026-06-25 21:53:29 +01:00
parent da1b210865
commit 24d1e472a2
7 changed files with 743 additions and 110 deletions
@@ -36,6 +36,7 @@ type ProjectSelectorProps = {
type ProjectFolderNode = {
kind: "folder";
id: string;
path: string;
label: string;
meta?: string;
children: ProjectTreeNode[];
@@ -50,6 +51,7 @@ type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
type PersistedProjectFolderRecord = {
id: string;
path: string;
label: string;
children: PersistedProjectFolderRecord[];
};
@@ -60,6 +62,7 @@ type ProjectFoldersResponse = {
renamedFolder?: PersistedProjectFolderRecord;
movedFolder?: PersistedProjectFolderRecord;
previousFolderId?: string;
previousFolderPath?: string;
};
error?: string;
message?: string;
@@ -91,6 +94,7 @@ const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecor
folders.map((folder) => ({
kind: "folder",
id: folder.id,
path: folder.path,
label: folder.label,
children: buildPersistedFolderNodes(folder.children ?? []),
}));
@@ -578,18 +582,39 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const currentNodes = projectTreeNodes();
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const persistedParentId = nextDragState.dropTarget.parentId;
const canPersistMove = isUuidString(selectedProject().id);
const persistedParentLocation = persistedParentId
? findTreeNodeLocation(currentNodes, persistedParentId, projectTreeAdapter)
const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null;
const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter);
const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const persistedParentLocation = previewLocation?.parentId
? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter)
: null;
const persistedParentFolderPath =
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null;
const previewSiblings = previewLocation?.parentId
? persistedParentLocation?.node.kind === "folder"
? persistedParentLocation.node.children
: []
: previewNodes;
const targetIndex = previewLocation
? previewSiblings
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
: 0;
if (
canPersistMove &&
draggedLocation?.node.kind === "folder" &&
(persistedParentId === null || persistedParentLocation?.node.kind === "folder")
draggedFolderPath &&
(!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")
) {
void movePersistedFolder(draggedLocation.node.id, persistedParentId);
void movePersistedFolder(
draggedFolderPath,
persistedParentFolderPath,
draggedLocation.node.id,
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
targetIndex,
);
} else {
setProjectTreeNodes((current) =>
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter),
@@ -674,6 +699,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
setPendingFolderRenameName(label);
};
const resolveFolderPath = (folderId: string): string | null => {
const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter);
return location && location.node.kind === "folder" ? location.node.path : null;
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
@@ -694,6 +724,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
if (draft.parentId && !parentFolderPath) {
cancelPendingFolder();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "POST",
@@ -703,7 +739,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
},
body: JSON.stringify({
name,
parentFolderId: draft.parentId,
parentFolderId: parentFolderPath,
}),
});
@@ -727,9 +763,14 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
const folderPath = resolveFolderPath(folderId);
if (!folderPath) {
return;
}
try {
const response = await fetch(
`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`,
`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderPath)}`,
{
method: "DELETE",
headers: {
@@ -751,9 +792,15 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
}
};
const movePersistedFolder = async (folderId: string, parentFolderId: string | null): Promise<void> => {
const movePersistedFolder = async (
folderPath: string,
parentFolderPath: string | null,
folderNodeId: string,
parentNodeId: string | null,
targetIndex: number,
): Promise<void> => {
const projectId = selectedProject().id;
if (!folderId || !isUuidString(projectId)) {
if (!folderPath || !folderNodeId || !isUuidString(projectId)) {
return;
}
@@ -765,8 +812,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId,
parentFolderId,
folderId: folderPath,
folderNodeId,
parentFolderId: parentFolderPath,
parentNodeId,
targetIndex,
}),
});
@@ -777,14 +827,6 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
}
setPersistedFolders(readPersistedFolders(body));
const previousFolderId = body.data?.previousFolderId;
const movedFolderId = body.data?.movedFolder?.id;
if (previousFolderId && movedFolderId && previousFolderId !== movedFolderId) {
setCollapsedFolderIds((current) =>
current.map((id) => (id === previousFolderId ? movedFolderId : id)),
);
}
} catch (error) {
console.error(error);
}
@@ -810,6 +852,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
const folderPath = resolveFolderPath(draft.folderId);
if (!folderPath) {
cancelPendingFolderRename();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "PATCH",
@@ -818,7 +866,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: draft.folderId,
folderId: folderPath,
name,
}),
});
@@ -832,14 +880,6 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
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);
}