98d4393387
- Rewrite ConfigProfilesMobileView to match desktop functionality: full detail view with all fields, preview action showing resolved profile, and edit view with project/tool selects, includes, environment variables, runtime hints, files, mounts with nested files, and git mounts. - Update useConfigProfiles.handleSubmit to return boolean success. - Update ConfigProfilesPage to pass required state and callbacks. - Add mobile-specific CSS for config profile forms, includes, mount/file cards, and preview panels. - Allow MobileDetailView to render extra children. Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed) Refs: openspec/changes/mobile-config-profiles-ui
434 lines
11 KiB
TypeScript
434 lines
11 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { extractErrorMessage } from "../utils/errors";
|
|
import {
|
|
createConfigProfile,
|
|
deleteConfigProfile,
|
|
listConfigProfiles,
|
|
previewConfigProfile,
|
|
updateConfigProfile,
|
|
updateProfileIncludes,
|
|
type ConfigProfile,
|
|
type CreateConfigProfileRequest,
|
|
type ResolvedProfile,
|
|
} from "../api/config-profiles";
|
|
import { listProjects } from "../api/projects";
|
|
import { listToolTypes, type ToolType } from "../api/tool-types";
|
|
import type { ProjectWithRepos } from "../types";
|
|
|
|
type Status = "loading" | "ready" | "error";
|
|
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
|
|
|
const defaultForm: CreateConfigProfileRequest = {
|
|
name: "",
|
|
description: "",
|
|
env_vars: {},
|
|
runtime_hints: {},
|
|
mounts: [],
|
|
git_mounts: [],
|
|
files: {},
|
|
is_default: false,
|
|
};
|
|
|
|
export const useConfigProfiles = () => {
|
|
const [status, setStatus] = useState<Status>("loading");
|
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
|
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
|
|
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
|
|
|
const loadData = useCallback(async () => {
|
|
setStatus("loading");
|
|
try {
|
|
const [profs, projs, types] = await Promise.all([
|
|
listConfigProfiles(),
|
|
listProjects(),
|
|
listToolTypes(),
|
|
]);
|
|
setProfiles(profs || []);
|
|
setProjects(projs || []);
|
|
setToolTypes(types || []);
|
|
setStatus("ready");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadData();
|
|
}, [loadData]);
|
|
|
|
const resetForm = () => {
|
|
setFormData(defaultForm);
|
|
setIncludedProfileIds([]);
|
|
setError(null);
|
|
setSaveStatus("idle");
|
|
setPreviewData(null);
|
|
};
|
|
|
|
const populateForm = (profile: ConfigProfile) => {
|
|
setFormData({
|
|
name: profile.name,
|
|
description: profile.description || undefined,
|
|
project_id: profile.project_id || undefined,
|
|
tool_type_id: profile.tool_type_id || undefined,
|
|
env_vars: profile.env_vars,
|
|
runtime_hints: profile.runtime_hints,
|
|
mounts: profile.mounts,
|
|
git_mounts: profile.git_mounts || [],
|
|
files: profile.files,
|
|
is_default: profile.is_default,
|
|
});
|
|
setIncludedProfileIds(
|
|
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
|
);
|
|
setError(null);
|
|
setSaveStatus("idle");
|
|
setPreviewData(null);
|
|
};
|
|
|
|
const handleSelectProfile = (profile: ConfigProfile | null) => {
|
|
if (profile) {
|
|
setSelectedProfileId(profile.id);
|
|
setIsCreating(false);
|
|
populateForm(profile);
|
|
} else {
|
|
setSelectedProfileId(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateNew = () => {
|
|
setSelectedProfileId(null);
|
|
setIsCreating(true);
|
|
resetForm();
|
|
};
|
|
|
|
const getIncludedProfile = (id: string): ConfigProfile | undefined =>
|
|
profiles.find((p) => p.id === id);
|
|
|
|
const getScopeLabel = (profile: ConfigProfile): string => {
|
|
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
|
|
if (profile.project_id) return "Project";
|
|
if (profile.tool_type_id) return "Tool";
|
|
return "Global";
|
|
};
|
|
|
|
const wouldCreateCycle = (
|
|
profileId: string,
|
|
targetId: string,
|
|
visited = new Set<string>(),
|
|
): boolean => {
|
|
if (visited.has(targetId)) return true;
|
|
const target = getIncludedProfile(targetId);
|
|
if (!target) return false;
|
|
const nextVisited = new Set(visited);
|
|
nextVisited.add(targetId);
|
|
for (const inc of target.includes) {
|
|
if (
|
|
inc.included_profile_id === profileId ||
|
|
wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const availableProfilesForInclude = (): ConfigProfile[] => {
|
|
const currentId = selectedProfile?.id;
|
|
if (!currentId) return [];
|
|
return profiles.filter((p) => {
|
|
if (p.id === currentId) return false;
|
|
if (includedProfileIds.includes(p.id)) return false;
|
|
if (wouldCreateCycle(currentId, p.id)) return false;
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const addInclude = (profileId: string) => {
|
|
setIncludedProfileIds((prev) => [...prev, profileId]);
|
|
};
|
|
|
|
const removeInclude = (index: number) => {
|
|
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
e.dataTransfer.effectAllowed = "move";
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragOverIndex(index);
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
setDragOverIndex(null);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
|
e.preventDefault();
|
|
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
|
|
if (dragIndex === dropIndex) {
|
|
setDragOverIndex(null);
|
|
return;
|
|
}
|
|
setIncludedProfileIds((prev) => {
|
|
const newOrder = [...prev];
|
|
const [removed] = newOrder.splice(dragIndex, 1);
|
|
newOrder.splice(dropIndex, 0, removed);
|
|
return newOrder;
|
|
});
|
|
setDragOverIndex(null);
|
|
};
|
|
|
|
const handleSubmit = async (e?: React.FormEvent): Promise<boolean> => {
|
|
e?.preventDefault();
|
|
setError(null);
|
|
setSaveStatus("saving");
|
|
|
|
if (!formData.name?.trim()) {
|
|
setError("Name is required");
|
|
setSaveStatus("error");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (isCreating) {
|
|
const newProfile = await createConfigProfile(formData);
|
|
if (includedProfileIds.length > 0) {
|
|
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
|
}
|
|
setIsCreating(false);
|
|
setSelectedProfileId(newProfile.id);
|
|
setSaveStatus("saved");
|
|
await loadData();
|
|
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
|
if (refreshed) populateForm(refreshed);
|
|
} else if (selectedProfile) {
|
|
await updateConfigProfile(selectedProfile.id, formData);
|
|
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
|
setSaveStatus("saved");
|
|
await loadData();
|
|
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
|
if (refreshed) populateForm(refreshed);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
setError(extractErrorMessage(err));
|
|
setSaveStatus("error");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
|
try {
|
|
await deleteConfigProfile(id);
|
|
if (selectedProfileId === id) {
|
|
setSelectedProfileId(null);
|
|
setIsCreating(false);
|
|
resetForm();
|
|
}
|
|
await loadData();
|
|
} catch {
|
|
alert("Failed to delete config profile");
|
|
}
|
|
};
|
|
|
|
const handlePreview = async (id: string) => {
|
|
try {
|
|
setPreviewingId(id);
|
|
const data = await previewConfigProfile(id);
|
|
setPreviewData(data);
|
|
} catch {
|
|
setError("Failed to preview config profile");
|
|
} finally {
|
|
setPreviewingId(null);
|
|
}
|
|
};
|
|
|
|
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
|
|
key: K,
|
|
value: CreateConfigProfileRequest[K],
|
|
) => {
|
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const addEnvVar = () => {
|
|
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
|
|
setFormData((prev) => {
|
|
const envVars = { ...prev.env_vars };
|
|
if (oldKey !== newKey) delete envVars[oldKey];
|
|
envVars[newKey] = value;
|
|
return { ...prev, env_vars: envVars };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const removeEnvVar = (key: string) => {
|
|
setFormData((prev) => {
|
|
const envVars = { ...prev.env_vars };
|
|
delete envVars[key];
|
|
return { ...prev, env_vars: envVars };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const addFile = () => {
|
|
setFormData((prev) => ({ ...prev, files: { ...prev.files, "": "" } }));
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const updateFile = (oldPath: string, newPath: string, content: string) => {
|
|
setFormData((prev) => {
|
|
const files = { ...prev.files };
|
|
if (oldPath !== newPath) delete files[oldPath];
|
|
files[newPath] = content;
|
|
return { ...prev, files };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const removeFile = (path: string) => {
|
|
setFormData((prev) => {
|
|
const files = { ...prev.files };
|
|
delete files[path];
|
|
return { ...prev, files };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const addMount = () => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
|
|
}));
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
|
setFormData((prev) => {
|
|
const mounts = [...(prev.mounts || [])];
|
|
mounts[index] = { ...mounts[index], ...updates };
|
|
return { ...prev, mounts };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const removeMount = (index: number) => {
|
|
setFormData((prev) => {
|
|
const mounts = [...(prev.mounts || [])];
|
|
mounts.splice(index, 1);
|
|
return { ...prev, mounts };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const addMountFile = (mountIndex: number) => {
|
|
setFormData((prev) => {
|
|
const mounts = [...(prev.mounts || [])];
|
|
mounts[mountIndex] = {
|
|
...mounts[mountIndex],
|
|
files: { ...mounts[mountIndex].files, "": "" },
|
|
};
|
|
return { ...prev, mounts };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const updateMountFile = (
|
|
mountIndex: number,
|
|
oldPath: string,
|
|
newPath: string,
|
|
content: string,
|
|
) => {
|
|
setFormData((prev) => {
|
|
const mounts = [...(prev.mounts || [])];
|
|
const files = { ...mounts[mountIndex].files };
|
|
if (oldPath !== newPath) delete files[oldPath];
|
|
files[newPath] = content;
|
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
|
return { ...prev, mounts };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
const removeMountFile = (mountIndex: number, path: string) => {
|
|
setFormData((prev) => {
|
|
const mounts = [...(prev.mounts || [])];
|
|
const files = { ...mounts[mountIndex].files };
|
|
delete files[path];
|
|
mounts[mountIndex] = { ...mounts[mountIndex], files };
|
|
return { ...prev, mounts };
|
|
});
|
|
setSaveStatus("idle");
|
|
};
|
|
|
|
return {
|
|
status,
|
|
profiles,
|
|
projects,
|
|
toolTypes,
|
|
selectedProfile,
|
|
selectedProfileId,
|
|
isCreating,
|
|
saveStatus,
|
|
error,
|
|
previewData,
|
|
previewingId,
|
|
formData,
|
|
includedProfileIds,
|
|
dragOverIndex,
|
|
loadData,
|
|
handleSelectProfile,
|
|
handleCreateNew,
|
|
handleSubmit,
|
|
handleDelete,
|
|
handlePreview,
|
|
updateFormField,
|
|
addEnvVar,
|
|
updateEnvVar,
|
|
removeEnvVar,
|
|
addFile,
|
|
updateFile,
|
|
removeFile,
|
|
addMount,
|
|
updateMount,
|
|
removeMount,
|
|
addMountFile,
|
|
updateMountFile,
|
|
removeMountFile,
|
|
getIncludedProfile,
|
|
getScopeLabel,
|
|
availableProfilesForInclude,
|
|
addInclude,
|
|
removeInclude,
|
|
handleDragStart,
|
|
handleDragOver,
|
|
handleDragLeave,
|
|
handleDrop,
|
|
setPreviewData,
|
|
setFormData,
|
|
setSaveStatus,
|
|
setIncludedProfileIds,
|
|
populateForm,
|
|
};
|
|
};
|