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);
}
@@ -52,6 +52,7 @@ type WorkspaceDragState = {
type PersistedWorkspaceFolderRecord = {
id: string;
path: string;
label: string;
children?: PersistedWorkspaceFolderRecord[];
};
@@ -62,6 +63,7 @@ type WorkspaceFoldersResponse = {
renamedFolder?: PersistedWorkspaceFolderRecord;
movedFolder?: PersistedWorkspaceFolderRecord;
previousFolderId?: string;
previousFolderPath?: string;
};
error?: string;
message?: string;
@@ -81,6 +83,7 @@ const buildPersistedWorkspaceFolderNodes = (
): WorkspaceTreeNode[] =>
folders.map((folder) => ({
id: folder.id,
path: folder.path,
label: folder.label,
kind: "folder",
icon: Folder,
@@ -532,18 +535,38 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const currentNodes = workspaceTreeNodes();
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
const persistedParentId = nextDragState.dropTarget.parentId;
const canPersistMove = isUuidString(activeProject()?.id ?? "");
const persistedParentLocation = persistedParentId
? findTreeNodeLocation(currentNodes, persistedParentId, workspaceTreeAdapter)
const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path ?? null : null;
const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter);
const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter);
const persistedParentLocation = previewLocation?.parentId
? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter)
: null;
const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path ?? null : 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 {
setWorkspaceTreeNodes((current) =>
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
@@ -598,6 +621,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
setPendingFolderRenameName(label);
};
const resolveFolderPath = (folderId: string): string | null => {
const location = findTreeNodeLocation(workspaceTreeNodes(), folderId, workspaceTreeAdapter);
return location?.node.kind === "folder" ? location.node.path ?? null : null;
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
@@ -618,6 +646,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): 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}/tree/folders`, {
method: "POST",
@@ -627,7 +661,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
},
body: JSON.stringify({
name,
parentFolderId: draft.parentId,
parentFolderId: parentFolderPath,
}),
});
@@ -647,13 +681,17 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const deletePersistedFolder = async (folderId: string): Promise<void> => {
const projectId = activeProject()?.id ?? "";
const folderPath = resolveFolderPath(folderId);
if (!folderId || !projectId || !isUuidString(projectId)) {
return;
}
if (!folderPath) {
return;
}
try {
const response = await fetch(
`${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`,
`${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderPath)}`,
{
method: "DELETE",
headers: {
@@ -675,9 +713,15 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): 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 = activeProject()?.id ?? "";
if (!folderId || !projectId || !isUuidString(projectId)) {
if (!folderPath || !folderNodeId || !projectId || !isUuidString(projectId)) {
return;
}
@@ -689,8 +733,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId,
parentFolderId,
folderId: folderPath,
folderNodeId,
parentFolderId: parentFolderPath,
parentNodeId,
targetIndex,
}),
});
@@ -701,14 +748,6 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
}
setPersistedFolders(readPersistedWorkspaceFolders(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);
}
@@ -734,6 +773,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
return;
}
const folderPath = resolveFolderPath(draft.folderId);
if (!folderPath) {
cancelPendingFolderRename();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
method: "PATCH",
@@ -742,7 +787,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: draft.folderId,
folderId: folderPath,
name,
}),
});
@@ -756,14 +801,6 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
setPersistedFolders(readPersistedWorkspaceFolders(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);
}
@@ -129,6 +129,7 @@ export type WorkspaceStaticItem = SidebarItem & {
export type WorkspaceFolderNode = {
id: string;
path?: string;
label: string;
kind: "folder";
icon: ShellIcon;
@@ -21,6 +21,29 @@ type BootstrapSubmissionState = {
error: string;
};
type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
type AdminForm = {
displayName: string;
email: string;
password: string;
};
type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{
id: "instance",
@@ -44,37 +67,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
},
];
const defaultInstanceForm = {
const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
} as const;
};
const defaultModeForm = {
const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
} as const;
};
const defaultAdminForm = {
const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
} as const;
};
const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
} as const;
};
const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
} as const;
};
const defaultStructureForm = {
const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
} as const;
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
@@ -148,10 +171,10 @@ type WorkspaceHomeProps = {
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm });
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
@@ -363,7 +386,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
<button
type="button"
class={styles.primaryButton}
onClick={(): void => {
setIsWizardOpen(true);
}}
>
Open bootstrap wizard
</button>
</div>
@@ -384,7 +413,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</h2>
</div>
<Show when={canDismissWizard()}>
<button type="button" class={styles.wizardCloseButton} onClick={(): void => setIsWizardOpen(false)}>
<button
type="button"
class={styles.wizardCloseButton}
onClick={(): void => {
setIsWizardOpen(false);
}}
>
Close
</button>
</Show>
@@ -433,14 +468,24 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Protocol</span>
<select value={instanceForm.protocol} onInput={(event): void => setInstanceForm("protocol", event.currentTarget.value)}>
<select
value={instanceForm.protocol}
onInput={(event): void =>
setInstanceForm("protocol", event.currentTarget.value as InstanceForm["protocol"])
}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Access</span>
<select value={instanceForm.access} onInput={(event): void => setInstanceForm("access", event.currentTarget.value)}>
<select
value={instanceForm.access}
onInput={(event): void =>
setInstanceForm("access", event.currentTarget.value as InstanceForm["access"])
}
>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
@@ -461,7 +506,10 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Mode</span>
<select value={modeForm.mode} onInput={(event): void => setModeForm("mode", event.currentTarget.value)}>
<select
value={modeForm.mode}
onInput={(event): void => setModeForm("mode", event.currentTarget.value as ModeForm["mode"])}
>
<option value="personal">personal</option>
<option value="organizational">organizational</option>
</select>
@@ -553,7 +601,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
type="button"
class={styles.secondaryButton}
disabled={isFirstStep()}
onClick={(): void => setCurrentStepIndex((index) => Math.max(index - 1, 0))}
onClick={(): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
}}
>
Back
</button>