350b393457
- Rework ToolWorkshopMobileView to support full desktop functionality: definition type selection (Compose/Dockerfile/Manifest), manifest editor, conditional port, startup command, readiness probe, required variables, and validation feedback. - Add ProfileMobileView and wire ProfilePage to render it on mobile. - Update useToolWorkshop hook to return boolean success from submit. - Add responsive CSS for mobile forms, edit views, and manifest editor. - Update project maps. Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed) Refs: openspec/changes/mobile-tool-profile-ui
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { extractErrorMessage } from "../utils/errors";
|
|
import {
|
|
createToolType,
|
|
deleteToolType,
|
|
listToolTypes,
|
|
updateToolType,
|
|
type CreateToolTypeRequest,
|
|
type ReadinessProbe,
|
|
type ToolType,
|
|
type UpdateToolTypeRequest,
|
|
} from "../api/tool-types";
|
|
import {
|
|
createToolDefinition,
|
|
getToolDefinition,
|
|
listToolDefinitions,
|
|
updateToolDefinition,
|
|
} from "../api/tool-definitions";
|
|
import type { ToolTypeFormState } from "../components/features/tool-workshop/ToolTypeEditorPanel";
|
|
|
|
type Status = "loading" | "ready" | "error";
|
|
|
|
const defaultForm: ToolTypeFormState = {
|
|
name: "",
|
|
display_name: "",
|
|
description: "",
|
|
category: "",
|
|
interface_type: "web",
|
|
requires_port: true,
|
|
default_port: "",
|
|
definition_type: "compose",
|
|
compose_template: "",
|
|
dockerfile_template: "",
|
|
readiness_command: "",
|
|
readiness_timeout: "30",
|
|
readiness_interval: "2",
|
|
required_variables: "",
|
|
startup_command: "",
|
|
};
|
|
|
|
export const useToolWorkshop = () => {
|
|
const [status, setStatus] = useState<Status>("loading");
|
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
const [baseDefinitions, setBaseDefinitions] = useState<
|
|
Awaited<ReturnType<typeof listToolDefinitions>>
|
|
>([]);
|
|
const [manifestData, setManifestData] = useState<Record<
|
|
string,
|
|
unknown
|
|
> | null>(null);
|
|
const [manifestDefinitionId, setManifestDefinitionId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selectedToolTypeId, setSelectedToolTypeId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [toolTypeForm, setToolTypeForm] = useState<ToolTypeFormState>(defaultForm);
|
|
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
|
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
|
|
|
const selectedToolType =
|
|
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
|
|
|
|
const loadData = useCallback(async () => {
|
|
setStatus("loading");
|
|
try {
|
|
const [types, defs] = await Promise.all([
|
|
listToolTypes(),
|
|
listToolDefinitions(),
|
|
]);
|
|
setToolTypes(types || []);
|
|
setBaseDefinitions((defs || []).filter((d) => d.is_base));
|
|
setStatus("ready");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadData();
|
|
}, [loadData]);
|
|
|
|
const resetToolTypeForm = () => {
|
|
setToolTypeForm(defaultForm);
|
|
setToolTypeError(null);
|
|
setToolTypeDirty(false);
|
|
setManifestData(null);
|
|
setManifestDefinitionId(null);
|
|
};
|
|
|
|
const populateToolTypeForm = async (toolType: ToolType) => {
|
|
setToolTypeForm({
|
|
name: toolType.name,
|
|
display_name: toolType.display_name,
|
|
description: toolType.description || "",
|
|
category: toolType.category || "",
|
|
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
|
|
requires_port: toolType.requires_port ?? true,
|
|
default_port: toolType.default_port?.toString() || "",
|
|
definition_type:
|
|
(toolType.definition_type as "compose" | "dockerfile" | "manifest") ||
|
|
"compose",
|
|
compose_template: toolType.compose_template || "",
|
|
dockerfile_template: toolType.dockerfile_template || "",
|
|
readiness_command: toolType.readiness_probe?.command || "",
|
|
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
|
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
|
required_variables: toolType.required_variables?.join(", ") || "",
|
|
startup_command: toolType.startup_command || "",
|
|
});
|
|
setToolTypeError(null);
|
|
setToolTypeDirty(false);
|
|
setManifestDefinitionId(toolType.manifest_id || null);
|
|
|
|
if (toolType.definition_type === "manifest" && toolType.manifest_id) {
|
|
try {
|
|
const defn = await getToolDefinition(toolType.manifest_id);
|
|
setManifestData(defn.manifest);
|
|
} catch {
|
|
setManifestData(null);
|
|
}
|
|
} else {
|
|
setManifestData(null);
|
|
}
|
|
};
|
|
|
|
const handleSelectToolType = (toolType: ToolType | null) => {
|
|
if (toolTypeDirty) {
|
|
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
|
return;
|
|
}
|
|
}
|
|
if (toolType) {
|
|
setSelectedToolTypeId(toolType.id);
|
|
setIsCreating(false);
|
|
void populateToolTypeForm(toolType);
|
|
} else {
|
|
setSelectedToolTypeId(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateNew = () => {
|
|
if (toolTypeDirty) {
|
|
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
|
return;
|
|
}
|
|
}
|
|
setSelectedToolTypeId(null);
|
|
setIsCreating(true);
|
|
resetToolTypeForm();
|
|
};
|
|
|
|
const handleToolTypeSubmit = async (e?: React.FormEvent): Promise<boolean> => {
|
|
e?.preventDefault();
|
|
setToolTypeError(null);
|
|
|
|
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
|
setToolTypeError("Name and display name are required");
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
toolTypeForm.requires_port &&
|
|
(!toolTypeForm.default_port.trim() ||
|
|
isNaN(Number(toolTypeForm.default_port)))
|
|
) {
|
|
setToolTypeError("Default port is required and must be a number");
|
|
return false;
|
|
}
|
|
|
|
if (toolTypeForm.definition_type !== "manifest") {
|
|
const template =
|
|
toolTypeForm.definition_type === "compose"
|
|
? toolTypeForm.compose_template
|
|
: toolTypeForm.dockerfile_template;
|
|
|
|
if (!template.trim()) {
|
|
setToolTypeError(
|
|
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
|
|
);
|
|
return false;
|
|
}
|
|
} else if (!manifestData) {
|
|
setToolTypeError(
|
|
"Manifest data is required for manifest definition type",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const variables = toolTypeForm.required_variables
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter((v) => v.length > 0);
|
|
|
|
const readinessProbe: ReadinessProbe | undefined =
|
|
toolTypeForm.readiness_command.trim()
|
|
? {
|
|
command: toolTypeForm.readiness_command.trim(),
|
|
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
|
|
interval: parseInt(toolTypeForm.readiness_interval) || 2,
|
|
}
|
|
: undefined;
|
|
|
|
const template =
|
|
toolTypeForm.definition_type === "compose"
|
|
? toolTypeForm.compose_template
|
|
: toolTypeForm.dockerfile_template;
|
|
|
|
try {
|
|
if (isCreating) {
|
|
let manifestId: string | undefined;
|
|
if (toolTypeForm.definition_type === "manifest" && manifestData) {
|
|
const manifestPayload = {
|
|
name: toolTypeForm.name.trim(),
|
|
display_name: toolTypeForm.display_name.trim(),
|
|
description: toolTypeForm.description.trim() || undefined,
|
|
category: toolTypeForm.category.trim() || undefined,
|
|
interface_type: toolTypeForm.interface_type,
|
|
base_image: (manifestData.base_image as string) || undefined,
|
|
base_definition_id:
|
|
(manifestData.base_definition_id as string) || undefined,
|
|
manifest: manifestData,
|
|
};
|
|
const newManifest = await createToolDefinition(manifestPayload);
|
|
manifestId = newManifest.id;
|
|
}
|
|
|
|
const input: CreateToolTypeRequest = {
|
|
name: toolTypeForm.name.trim(),
|
|
display_name: toolTypeForm.display_name.trim(),
|
|
description: toolTypeForm.description.trim() || undefined,
|
|
category: toolTypeForm.category.trim() || undefined,
|
|
interface_type: toolTypeForm.interface_type,
|
|
requires_port: toolTypeForm.requires_port,
|
|
default_port: toolTypeForm.requires_port
|
|
? Number(toolTypeForm.default_port)
|
|
: 0,
|
|
definition_type: toolTypeForm.definition_type,
|
|
manifest_id: manifestId,
|
|
compose_template:
|
|
toolTypeForm.definition_type === "compose" ? template : undefined,
|
|
dockerfile_template:
|
|
toolTypeForm.definition_type === "dockerfile"
|
|
? template
|
|
: undefined,
|
|
readiness_probe: readinessProbe,
|
|
required_variables: variables,
|
|
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
|
};
|
|
const newTool = await createToolType(input);
|
|
setIsCreating(false);
|
|
setSelectedToolTypeId(newTool.id);
|
|
setToolTypeDirty(false);
|
|
} else if (selectedToolType) {
|
|
let manifestId = selectedToolType.manifest_id || undefined;
|
|
if (toolTypeForm.definition_type === "manifest" && manifestData) {
|
|
if (manifestId) {
|
|
await updateToolDefinition(manifestId, {
|
|
display_name: toolTypeForm.display_name.trim(),
|
|
description: toolTypeForm.description.trim() || undefined,
|
|
category: toolTypeForm.category.trim() || undefined,
|
|
manifest: manifestData,
|
|
});
|
|
} else {
|
|
const manifestPayload = {
|
|
name: toolTypeForm.name.trim(),
|
|
display_name: toolTypeForm.display_name.trim(),
|
|
description: toolTypeForm.description.trim() || undefined,
|
|
category: toolTypeForm.category.trim() || undefined,
|
|
interface_type: toolTypeForm.interface_type,
|
|
base_image: (manifestData.base_image as string) || undefined,
|
|
base_definition_id:
|
|
(manifestData.base_definition_id as string) || undefined,
|
|
manifest: manifestData,
|
|
};
|
|
const newManifest = await createToolDefinition(manifestPayload);
|
|
manifestId = newManifest.id;
|
|
}
|
|
}
|
|
|
|
const input: UpdateToolTypeRequest = {
|
|
display_name: toolTypeForm.display_name.trim(),
|
|
description: toolTypeForm.description.trim() || undefined,
|
|
category: toolTypeForm.category.trim() || undefined,
|
|
interface_type: toolTypeForm.interface_type,
|
|
requires_port: toolTypeForm.requires_port,
|
|
default_port: toolTypeForm.requires_port
|
|
? Number(toolTypeForm.default_port)
|
|
: 0,
|
|
definition_type: toolTypeForm.definition_type,
|
|
manifest_id:
|
|
toolTypeForm.definition_type === "manifest"
|
|
? manifestId
|
|
: undefined,
|
|
compose_template:
|
|
toolTypeForm.definition_type === "compose" ? template : undefined,
|
|
dockerfile_template:
|
|
toolTypeForm.definition_type === "dockerfile"
|
|
? template
|
|
: undefined,
|
|
readiness_probe: readinessProbe,
|
|
required_variables: variables,
|
|
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
|
};
|
|
await updateToolType(selectedToolType.id, input);
|
|
setToolTypeDirty(false);
|
|
}
|
|
await loadData();
|
|
return true;
|
|
} catch (err) {
|
|
setToolTypeError(extractErrorMessage(err));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteToolType = async (id: string) => {
|
|
if (
|
|
!window.confirm(
|
|
"Delete this tool type? All associated configs will be removed.",
|
|
)
|
|
)
|
|
return;
|
|
try {
|
|
await deleteToolType(id);
|
|
if (selectedToolTypeId === id) {
|
|
setSelectedToolTypeId(null);
|
|
setIsCreating(false);
|
|
resetToolTypeForm();
|
|
}
|
|
await loadData();
|
|
} catch {
|
|
alert("Failed to delete tool type");
|
|
}
|
|
};
|
|
|
|
const handleFormChange = (changes: Partial<ToolTypeFormState>) => {
|
|
setToolTypeForm((prev) => ({ ...prev, ...changes }));
|
|
setToolTypeDirty(true);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (isCreating) {
|
|
resetToolTypeForm();
|
|
} else if (selectedToolType) {
|
|
void populateToolTypeForm(selectedToolType);
|
|
}
|
|
};
|
|
|
|
return {
|
|
status,
|
|
toolTypes,
|
|
baseDefinitions,
|
|
selectedToolType,
|
|
selectedToolTypeId,
|
|
isCreating,
|
|
toolTypeForm,
|
|
manifestData,
|
|
manifestDefinitionId,
|
|
toolTypeError,
|
|
toolTypeDirty,
|
|
loadData,
|
|
handleSelectToolType,
|
|
handleCreateNew,
|
|
handleToolTypeSubmit,
|
|
handleDeleteToolType,
|
|
handleFormChange,
|
|
handleReset,
|
|
setManifestData,
|
|
setToolTypeDirty,
|
|
};
|
|
};
|