feat: add profile includes management UI
- Add includes section to profile editor with drag-and-drop reordering - Display included profiles with scope badges (Global, Project, Tool) - Add 'Add Include' dropdown filtered by compatibility and cycle prevention - Add remove button per include row - Save includes together with profile form - Add include count badges to profile list sidebar - Add drag icon to Icon component Implements config-profile-includes-ui tasks 1.1-4.3
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
listConfigProfiles,
|
||||
previewConfigProfile,
|
||||
updateConfigProfile,
|
||||
updateProfileIncludes,
|
||||
type ConfigProfile,
|
||||
type CreateConfigProfileRequest,
|
||||
type ResolvedProfile,
|
||||
@@ -40,6 +41,9 @@ export const ConfigProfilesPage = () => {
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -73,6 +77,7 @@ export const ConfigProfilesPage = () => {
|
||||
files: {},
|
||||
is_default: false,
|
||||
});
|
||||
setIncludedProfileIds([]);
|
||||
setError(null);
|
||||
setSaveStatus("idle");
|
||||
setPreviewData(null);
|
||||
@@ -90,6 +95,9 @@ export const ConfigProfilesPage = () => {
|
||||
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);
|
||||
@@ -121,6 +129,82 @@ export const ConfigProfilesPage = () => {
|
||||
return "Failed to save";
|
||||
};
|
||||
|
||||
// Include management functions
|
||||
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";
|
||||
};
|
||||
|
||||
// Cycle detection: returns true if adding targetId would create a cycle
|
||||
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));
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -135,6 +219,9 @@ export const ConfigProfilesPage = () => {
|
||||
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");
|
||||
@@ -144,6 +231,7 @@ export const ConfigProfilesPage = () => {
|
||||
if (refreshed) populateForm(refreshed);
|
||||
} else if (selectedProfile) {
|
||||
await updateConfigProfile(selectedProfile.id, formData);
|
||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
||||
setSaveStatus("saved");
|
||||
await loadData();
|
||||
// Refresh the selected profile data
|
||||
@@ -402,6 +490,18 @@ export const ConfigProfilesPage = () => {
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
{profile.includes?.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: "0.7rem",
|
||||
marginLeft: "0.5rem",
|
||||
opacity: 0.7,
|
||||
background: selectedProfileId === profile.id ? "rgba(255,255,255,0.2)" : "var(--badge-bg, #f3f4f6)",
|
||||
padding: "0.0625rem 0.375rem",
|
||||
borderRadius: "0.25rem",
|
||||
}}>
|
||||
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
||||
{profile.project_id && "Project scoped"}
|
||||
@@ -600,6 +700,106 @@ export const ConfigProfilesPage = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Includes Section */}
|
||||
<div className="form-section">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Includes</h4>
|
||||
<span className="muted" style={{ fontSize: "0.875rem" }}>
|
||||
{includedProfileIds.length} included
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{includedProfileIds.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>
|
||||
No profiles included. Add profiles to compose configurations.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
{includedProfileIds.map((profileId, index) => {
|
||||
const profile = getIncludedProfile(profileId);
|
||||
if (!profile) return null;
|
||||
return (
|
||||
<div
|
||||
key={`${profileId}-${index}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.375rem",
|
||||
marginBottom: "0.25rem",
|
||||
cursor: "grab",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
<span style={{ cursor: "grab", color: "var(--muted)" }}>
|
||||
<Icon name="drag" size="sm" />
|
||||
</span>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
padding: "0.125rem 0.375rem",
|
||||
background: "var(--badge-bg, #f3f4f6)",
|
||||
color: "var(--muted)",
|
||||
borderRadius: "0.25rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.025em",
|
||||
}}
|
||||
>
|
||||
{getScopeLabel(profile)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInclude(index)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--danger)",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
}}
|
||||
title="Remove include"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableProfilesForInclude().length > 0 && (
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addInclude(e.target.value);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">+ Add Include...</option>
|
||||
{availableProfilesForInclude().map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({getScopeLabel(p)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||
|
||||
Reference in New Issue
Block a user