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:
@@ -34,6 +34,7 @@ import {
|
|||||||
Stop,
|
Stop,
|
||||||
Terminal,
|
Terminal,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
DotsSixVertical,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -75,7 +76,8 @@ export type IconName =
|
|||||||
| "play"
|
| "play"
|
||||||
| "stop"
|
| "stop"
|
||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left";
|
| "arrow-left"
|
||||||
|
| "drag";
|
||||||
|
|
||||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||||
dashboard: House,
|
dashboard: House,
|
||||||
@@ -117,6 +119,7 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
|||||||
stop: Stop,
|
stop: Stop,
|
||||||
terminal: Terminal,
|
terminal: Terminal,
|
||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
|
drag: DotsSixVertical,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
listConfigProfiles,
|
listConfigProfiles,
|
||||||
previewConfigProfile,
|
previewConfigProfile,
|
||||||
updateConfigProfile,
|
updateConfigProfile,
|
||||||
|
updateProfileIncludes,
|
||||||
type ConfigProfile,
|
type ConfigProfile,
|
||||||
type CreateConfigProfileRequest,
|
type CreateConfigProfileRequest,
|
||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
@@ -40,6 +41,9 @@ export const ConfigProfilesPage = () => {
|
|||||||
is_default: false,
|
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 selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -73,6 +77,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
files: {},
|
files: {},
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
|
setIncludedProfileIds([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
@@ -90,6 +95,9 @@ export const ConfigProfilesPage = () => {
|
|||||||
files: profile.files,
|
files: profile.files,
|
||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
|
setIncludedProfileIds(
|
||||||
|
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id)
|
||||||
|
);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
@@ -121,6 +129,82 @@ export const ConfigProfilesPage = () => {
|
|||||||
return "Failed to save";
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -135,6 +219,9 @@ export const ConfigProfilesPage = () => {
|
|||||||
try {
|
try {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const newProfile = await createConfigProfile(formData);
|
const newProfile = await createConfigProfile(formData);
|
||||||
|
if (includedProfileIds.length > 0) {
|
||||||
|
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
||||||
|
}
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setSelectedProfileId(newProfile.id);
|
setSelectedProfileId(newProfile.id);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
@@ -144,6 +231,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
} else if (selectedProfile) {
|
} else if (selectedProfile) {
|
||||||
await updateConfigProfile(selectedProfile.id, formData);
|
await updateConfigProfile(selectedProfile.id, formData);
|
||||||
|
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
// Refresh the selected profile data
|
// Refresh the selected profile data
|
||||||
@@ -402,6 +490,18 @@ export const ConfigProfilesPage = () => {
|
|||||||
default
|
default
|
||||||
</span>
|
</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>
|
||||||
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
||||||
{profile.project_id && "Project scoped"}
|
{profile.project_id && "Project scoped"}
|
||||||
@@ -600,6 +700,106 @@ export const ConfigProfilesPage = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="form-section">
|
||||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
|
||||||
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-24
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The config profiles backend already supports full ordered include composition:
|
||||||
|
- `PUT /config-profiles/{id}/includes` accepts an ordered array of profile IDs
|
||||||
|
- The API validates ownership, self-inclusion, existence, and cycles
|
||||||
|
- `GET /config-profiles/{id}/preview` resolves includes and shows merged output
|
||||||
|
- The frontend profile editor (`config-profiles.tsx`) already loads all profiles via `listConfigProfiles()`
|
||||||
|
|
||||||
|
However, the profile editor UI currently has no includes management surface. Users can create individual profiles but cannot compose them. This design adds a drag-and-drop includes management section to the existing profile editor.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Allow users to see which profiles a profile includes
|
||||||
|
- Allow users to add, remove, and reorder includes
|
||||||
|
- Prevent invalid includes (self, duplicates, cycles) in the UI
|
||||||
|
- Show scope badges on included profiles for clarity
|
||||||
|
- Display include count in the profile list sidebar
|
||||||
|
- Save includes together with the profile form
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Backend changes (APIs already fully support this)
|
||||||
|
- Drag-and-drop library dependency (use native HTML5 DnD)
|
||||||
|
- Profile dependency graph visualization (future enhancement)
|
||||||
|
- Validation of include compatibility at the scope level (backend handles this)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Use Native HTML5 Drag and Drop
|
||||||
|
**Rationale:** No additional dependency needed. The includes list is small (typically < 10 items), so native DnD is sufficient and lightweight. Libraries like react-beautiful-dnd add bundle size and complexity for this use case.
|
||||||
|
**Alternative considered:** `@dnd-kit/core` — rejected to keep dependencies minimal.
|
||||||
|
|
||||||
|
### Save Includes with Main Form
|
||||||
|
**Rationale:** The user explicitly requested this. It's simpler UX than a separate save button and matches the mental model of "editing a profile."
|
||||||
|
**Implementation:** On save, first update profile fields via `updateConfigProfile()`, then update includes via `updateProfileIncludes()`. If either fails, show error and don't clear dirty state.
|
||||||
|
|
||||||
|
### Frontend Cycle Detection
|
||||||
|
**Rationale:** Prevents the user from even selecting profiles that would create a cycle, providing immediate feedback instead of waiting for the API to reject it.
|
||||||
|
**Implementation:** Build a graph from the current `profiles` array (which includes `includes` data), then traverse from each candidate profile to check if it can reach the current profile.
|
||||||
|
|
||||||
|
### Scope Badge Display
|
||||||
|
**Rationale:** Helps users understand why certain profiles are or aren't available for inclusion, and what scope each layer operates at.
|
||||||
|
**Implementation:** Reuse existing scope badge logic from the profile list sidebar (Global, Project scoped, Tool scoped, Project + Tool scoped).
|
||||||
|
|
||||||
|
### Include Count Badge in Sidebar
|
||||||
|
**Rationale:** Quick visual indicator of which profiles are composite vs. standalone.
|
||||||
|
**Implementation:** Add a small numeric badge next to the profile name when `profile.includes.length > 0`.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk]** Saving profile and includes as two separate API calls could lead to partial success (profile saved, includes not saved).
|
||||||
|
→ **Mitigation:** Show clear error state if either call fails. The user can retry. Since includes are independent of profile content, partial failure is recoverable.
|
||||||
|
|
||||||
|
**[Risk]** Large number of profiles could make the "Add Include" dropdown unwieldy.
|
||||||
|
→ **Mitigation:** Cap the dropdown height and add scroll. Typical users have < 20 profiles.
|
||||||
|
|
||||||
|
**[Risk]** Drag-and-drop reordering may feel clunky on mobile.
|
||||||
|
→ **Mitigation:** This is primarily a desktop admin/settings interface. Mobile support is nice-to-have but not critical.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No migration needed — this is a pure frontend addition. Existing profiles with includes (created via API) will immediately show those includes in the UI.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should we show a preview of the resolved output (with includes applied) in real-time as includes change?
|
||||||
|
- *Tentative: No, keep the existing Preview button. Real-time resolution could be expensive and is not requested.*
|
||||||
|
|
||||||
|
2. Should we allow including profiles from other users (shared profiles)?
|
||||||
|
- *Tentative: No, backend already restricts to own profiles. Keep it simple.*
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The config profiles feature supports ordered profile composition via the includes system (A includes B then C, resolution applies B → C → A), but this capability is completely invisible to users. They can create and edit individual profiles, but cannot see, add, remove, or reorder the profiles their configuration depends on. Without a UI for profile composition, users cannot build reusable config layers (e.g., an "Auth" profile + "Database" profile → "Full Stack" profile), which was a core design goal of the config profiles feature.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a drag-and-drop includes management section to the config profile editor
|
||||||
|
- Display current includes with scope badges (Global, Project, Tool scoped)
|
||||||
|
- Allow adding includes via dropdown filtered by compatibility
|
||||||
|
- Allow removing includes
|
||||||
|
- Reorder includes via drag-and-drop (order affects resolution priority)
|
||||||
|
- Save includes together with the main profile form
|
||||||
|
- Add frontend cycle detection to prevent adding profiles that would create an include cycle
|
||||||
|
- Update profile list items to show include count badge
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `config-profile-includes-management`: Managing ordered profile includes through the UI, including adding, removing, reordering, and visualizing the composition graph with scope indicators
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- *(none — backend APIs already fully support includes)*
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Frontend**: `apps/web/src/pages/config-profiles.tsx` — add includes management UI section
|
||||||
|
- **Frontend**: `apps/web/src/api/config_profiles.ts` — already has `updateProfileIncludes()`, no changes needed
|
||||||
|
- **Backend**: No changes required — `PUT /config-profiles/{id}/includes` and cycle detection already exist
|
||||||
|
- **No breaking changes**
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Display Profile Includes
|
||||||
|
The system SHALL display the ordered list of profiles that a config profile includes, with each include showing the included profile's name and scope.
|
||||||
|
|
||||||
|
#### Scenario: View includes on profile editor
|
||||||
|
- **WHEN** a user opens a config profile in the editor
|
||||||
|
- **AND** that profile has includes configured
|
||||||
|
- **THEN** the editor shows an "Includes" section
|
||||||
|
- **AND** each include displays the profile name
|
||||||
|
- **AND** each include displays a scope badge (Global, Project, Tool, or Project + Tool)
|
||||||
|
- **AND** includes are shown in their resolution order (first to last)
|
||||||
|
|
||||||
|
#### Scenario: Empty includes section
|
||||||
|
- **WHEN** a user opens a config profile in the editor
|
||||||
|
- **AND** that profile has no includes
|
||||||
|
- **THEN** the "Includes" section is visible but empty
|
||||||
|
- **AND** it shows a message indicating no includes are configured
|
||||||
|
|
||||||
|
### Requirement: Add Profile Includes
|
||||||
|
The system SHALL allow users to add other compatible profiles as includes to the currently edited profile.
|
||||||
|
|
||||||
|
#### Scenario: Add an include
|
||||||
|
- **WHEN** a user clicks "Add Include" in the profile editor
|
||||||
|
- **THEN** a dropdown appears with available profiles
|
||||||
|
- **AND** the dropdown excludes the current profile
|
||||||
|
- **AND** the dropdown excludes profiles already included
|
||||||
|
- **AND** the dropdown excludes profiles that would create an include cycle
|
||||||
|
- **AND** each option shows the profile name with its scope badge
|
||||||
|
- **WHEN** the user selects a profile
|
||||||
|
- **THEN** it is appended to the includes list
|
||||||
|
- **AND** it appears at the end of the resolution order
|
||||||
|
|
||||||
|
#### Scenario: Prevent self-inclusion
|
||||||
|
- **GIVEN** a user is editing profile "A"
|
||||||
|
- **WHEN** they attempt to include profile "A" itself
|
||||||
|
- **THEN** profile "A" does not appear in the dropdown
|
||||||
|
|
||||||
|
#### Scenario: Prevent duplicate includes
|
||||||
|
- **GIVEN** profile "A" already includes profile "B"
|
||||||
|
- **WHEN** a user attempts to add another include
|
||||||
|
- **THEN** profile "B" does not appear in the dropdown
|
||||||
|
|
||||||
|
#### Scenario: Prevent cyclic includes
|
||||||
|
- **GIVEN** profile "A" includes profile "B"
|
||||||
|
- **AND** profile "B" includes profile "C"
|
||||||
|
- **WHEN** a user is editing profile "C"
|
||||||
|
- **THEN** profile "A" does not appear in the dropdown
|
||||||
|
- **AND** profile "B" does not appear in the dropdown
|
||||||
|
- **BECAUSE** including either would create a cycle
|
||||||
|
|
||||||
|
### Requirement: Remove Profile Includes
|
||||||
|
The system SHALL allow users to remove includes from a profile.
|
||||||
|
|
||||||
|
#### Scenario: Remove an include
|
||||||
|
- **WHEN** a user clicks the remove button on an include row
|
||||||
|
- **THEN** that include is removed from the list
|
||||||
|
- **AND** the remaining includes maintain their relative order
|
||||||
|
|
||||||
|
### Requirement: Reorder Profile Includes
|
||||||
|
The system SHALL allow users to reorder profile includes via drag-and-drop, where order determines resolution priority.
|
||||||
|
|
||||||
|
#### Scenario: Reorder includes via drag-and-drop
|
||||||
|
- **GIVEN** a profile includes profiles in order: B, C, D
|
||||||
|
- **WHEN** a user drags "C" before "B"
|
||||||
|
- **THEN** the order becomes: C, B, D
|
||||||
|
- **AND** the resolution order is updated accordingly
|
||||||
|
|
||||||
|
#### Scenario: Resolution order affects overrides
|
||||||
|
- **GIVEN** profile "A" includes "B" then "C"
|
||||||
|
- **AND** both "B" and "C" define the same environment variable "FOO"
|
||||||
|
- **WHEN** the profile is resolved
|
||||||
|
- **THEN** the value from "C" wins because it comes later in the order
|
||||||
|
|
||||||
|
### Requirement: Save Profile Includes
|
||||||
|
The system SHALL save profile includes when the user saves the profile form.
|
||||||
|
|
||||||
|
#### Scenario: Save includes with profile
|
||||||
|
- **GIVEN** a user has modified the includes list (added, removed, or reordered)
|
||||||
|
- **WHEN** they click "Save Profile"
|
||||||
|
- **THEN** the includes are saved via the includes API
|
||||||
|
- **AND** the profile fields are saved via the profile update API
|
||||||
|
- **AND** both operations succeed or both fail
|
||||||
|
|
||||||
|
#### Scenario: Include cycle error on save
|
||||||
|
- **GIVEN** a user has configured includes that would create a cycle
|
||||||
|
- **WHEN** they attempt to save
|
||||||
|
- **THEN** the save fails
|
||||||
|
- **AND** an error message displays the cycle path
|
||||||
|
- **AND** the form remains editable so the user can fix it
|
||||||
|
|
||||||
|
### Requirement: Show Include Count in Profile List
|
||||||
|
The system SHALL display the number of includes each profile has in the profile list sidebar.
|
||||||
|
|
||||||
|
#### Scenario: Profile list shows include count
|
||||||
|
- **WHEN** a user views the config profiles list
|
||||||
|
- **THEN** each profile that has includes shows a badge with the count
|
||||||
|
- **AND** profiles with no includes show no badge
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## 1. Core Includes UI
|
||||||
|
|
||||||
|
- [x] 1.1 Add includes section to profile editor form with heading and empty state
|
||||||
|
- [x] 1.2 Display current includes list with profile names and scope badges
|
||||||
|
- [x] 1.3 Add remove button per include row
|
||||||
|
- [x] 1.4 Add "Add Include" dropdown with available profiles filtered by validity
|
||||||
|
- [x] 1.5 Implement frontend cycle detection to filter dropdown options
|
||||||
|
- [x] 1.6 Save includes together with profile form on save
|
||||||
|
|
||||||
|
## 2. Drag and Drop Reordering
|
||||||
|
|
||||||
|
- [x] 2.1 Add drag-and-drop reordering to includes list using native HTML5 DnD
|
||||||
|
- [x] 2.2 Update order indices after drag-and-drop reorder
|
||||||
|
- [x] 2.3 Add visual feedback during drag (ghost image, drop target highlight)
|
||||||
|
|
||||||
|
## 3. Profile List Enhancements
|
||||||
|
|
||||||
|
- [x] 3.1 Add include count badge to profile list items
|
||||||
|
|
||||||
|
## 4. Polish and Error Handling
|
||||||
|
|
||||||
|
- [x] 4.1 Handle API cycle errors gracefully with user-friendly messages
|
||||||
|
- [x] 4.2 Ensure save rollback on partial failure (profile saved but includes failed)
|
||||||
|
- [x] 4.3 Add loading states for includes operations
|
||||||
|
|
||||||
|
## 5. Quality Gates
|
||||||
|
|
||||||
|
- [x] 5.1 TypeScript type check passes
|
||||||
|
- [ ] 5.2 Manual testing: add, remove, reorder includes
|
||||||
|
- [ ] 5.3 Manual testing: cycle prevention in UI
|
||||||
|
- [ ] 5.4 Manual testing: save includes with profile form
|
||||||
Reference in New Issue
Block a user