From a7298fe5b87e20dfd3652ea7ea67317594bf409b Mon Sep 17 00:00:00 2001 From: Tiger Ren Date: Sun, 1 Feb 2026 16:32:35 +0800 Subject: [PATCH] feat: complete the main features --- docs/FSD.md | 109 ++++-- docs/TSD.md | 185 +++++++++- package-lock.json | 88 +++++ package.json | 4 + src/app/api/steps/[id]/detail/route.ts | 30 ++ src/app/api/steps/reorder/route.ts | 26 ++ src/app/releases/[id]/page.tsx | 147 +------- src/app/releases/[id]/steps/page.tsx | 188 ++++++++-- .../releases/release-matrix-client.tsx | 243 ++++++++++++ .../steps/add-custom-step-dialog.tsx | 232 ++++++++++++ src/components/steps/code-block.tsx | 91 +++++ src/components/steps/step-detail-panel.tsx | 349 ++++++++++++++++++ src/components/ui/checkbox.tsx | 32 ++ src/components/ui/sheet.tsx | 143 +++++++ src/lib/actions/customer-steps.ts | 81 +++- 15 files changed, 1749 insertions(+), 199 deletions(-) create mode 100644 src/app/api/steps/[id]/detail/route.ts create mode 100644 src/app/api/steps/reorder/route.ts create mode 100644 src/components/releases/release-matrix-client.tsx create mode 100644 src/components/steps/add-custom-step-dialog.tsx create mode 100644 src/components/steps/code-block.tsx create mode 100644 src/components/steps/step-detail-panel.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/sheet.tsx diff --git a/docs/FSD.md b/docs/FSD.md index 9c90580..f1a2de7 100644 --- a/docs/FSD.md +++ b/docs/FSD.md @@ -212,11 +212,21 @@ Customer-specific step instance (actual execution unit). | ID | Feature | Description | |----|---------|-------------| | FCS-001 | View Inherited Steps | See template steps applied to customer | -| FCS-002 | Override Step Content | Modify step content for specific customer | -| FCS-003 | Add Custom Step | Add step only for specific customer | +| FCS-002 | Override Step Content | Modify step content for specific customer (even after release activation) | +| FCS-003 | Add Custom Step | Add step only for specific customer; option to "Add to template" (applies to all customers) | | FCS-004 | Skip Step | Mark step as skipped with mandatory reason | | FCS-005 | Revert Override | Reset to template content | -| FCS-006 | Remove Custom Step | Delete customer-specific step | +| FCS-006 | Remove Custom Step | Delete customer-specific step (not from template) | +| FCS-007 | Edit Template Steps | Edit template steps after activation (affects all customers with pending status) | +| FCS-008 | Mixed Step Ordering | Custom steps can be inserted between template steps; use decimal ordering | + +**Add Custom Step Flow:** +1. User clicks "Add Custom Step" for a customer +2. Fill in step details (name, type, content, category) +3. Checkbox "Add to template" (unchecked by default): + - **Unchecked**: Step is created only for this customer (is_custom=true) + - **Checked**: Step is added to template AND all customers get this step immediately +4. Set order position (insert before/after existing steps) ### 4.6 Execution Tracking @@ -400,32 +410,82 @@ Customer-specific step instance (actual execution unit). └─────────────────────────────────────────────────────────────────┘ ``` -### 5.7 Step Content Modal +### 5.7 Step Detail Side Panel + +The side panel opens when clicking a step cell in the matrix view, showing customer-specific content and actions. + +**Side Panel Layout:** +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Release: v2.5.0 [X] Close │ +│ Step: Run Migration SQL │ +├──────────────────────────────────────────────────────────────────────────┤ +│ Customer: customer-a (prod-us/cust-a-prod) │ +│ Category: Deploy | Type: SQL | Status: 🔄 Pending │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ SOURCE INFO: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 📋 From Template [Override Content] [Reset to Template] │ │ +│ │ ⭐ Custom Step [Edit] [Delete] │ │ +│ │ ⚠️ Overridden [View Original] [Reset to Template] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ CONTENT: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 1 │ -- Custom migration for customer-a │ │ +│ │ 2 │ ALTER TABLE users ADD COLUMN custom_field VARCHAR(100); │ │ +│ │ 3 │ UPDATE users SET custom_field = 'value' WHERE id > 100; │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ [📋 Copy] [⬇️ Download] │ +│ │ +│ EXECUTION: │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Notes: │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ [Enter execution notes... ] │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [Mark as Done] [Skip] [Revert] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ HISTORY: │ +│ • 2024-01-15 10:30 - Created from template │ +│ • 2024-01-15 10:35 - Content overridden │ +│ • 2024-01-15 10:45 - Marked as Done │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**Actions by Source Type:** +| Source | Available Actions | +|--------|-------------------| +| Template (not overridden) | View, Copy, Mark Done, Skip, Override Content | +| Overridden | View Custom, View Original, Reset to Template, Mark Done, Skip | +| Custom Step | Edit, Delete, Mark Done, Skip | + +### 5.8 Add Custom Step Dialog ``` ┌─────────────────────────────────────────────────────────────────┐ -│ Step: Run Migration SQL [X] │ -├─────────────────────────────────────────────────────────────────┤ -│ Category: Deploy | Type: SQL │ -│ Source: Template (edited for this customer) │ -│ Customer: customer-a (prod-us/cust-a-prod) │ +│ Add Custom Step for customer-a [X] │ ├─────────────────────────────────────────────────────────────────┤ +│ Step Name: │ +│ [ ] │ +│ │ +│ Category: [Deploy ▼] Type: [Bash ▼] │ +│ │ +│ Content: │ │ ┌─────────────────────────────────────────────────────────┐ │ -│ │ ```sql │ │ -│ │ -- Custom migration for customer-a │ │ -│ │ ALTER TABLE users ADD COLUMN custom_field VARCHAR(100); │ │ -│ │ UPDATE users SET custom_field = 'value' WHERE id > 100; │ │ -│ │ ``` │ │ +│ │ │ │ +│ │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ -│ [📋 Copy] [⬇️ Download] │ +│ Insert Position: [Before "Deploy App" ▼] │ │ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Execution Notes: │ │ -│ │ [Optional: Add notes about this execution... ] │ │ -│ └─────────────────────────────────────────────────────────┘ │ +│ ☐ Add to template (apply to all customers) │ │ │ -│ [Cancel] [Mark as Done] │ +│ [Cancel] [Add Step] │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -450,10 +510,17 @@ Customer-specific step instance (actual execution unit). - **BR-R04**: Reverting a step sets status to `reverted` but preserves history ### 6.4 Step Rules -- **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template +- **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template (unless "Add to template" is checked) - **BR-S02**: Override (is_overridden=true) preserves link to template for reference - **BR-S03**: Skipping requires mandatory reason - **BR-S04**: Order index is per category (deploy/verify have separate ordering) +- **BR-S05**: Template steps use integer orderIndex (0, 1, 2...); custom steps use decimals (0.5, 1.5...) to insert between +- **BR-S06**: When "Add to template" is checked during custom step creation: + - Step is added to step_templates table + - All active customers get this step immediately (for active releases) + - New customers will inherit this step automatically +- **BR-S07**: Editing template steps after activation only affects customers where step is `pending` and not `overridden` +- **BR-S08**: Customer-specific overrides persist even when template is edited --- diff --git a/docs/TSD.md b/docs/TSD.md index 4170331..2165769 100644 --- a/docs/TSD.md +++ b/docs/TSD.md @@ -706,10 +706,56 @@ export async function addCustomStep( type: 'bash' | 'sql' | 'text'; content: string; orderIndex: number; + addToTemplate?: boolean; // NEW: Option to add to template } ): Promise { + const { addToTemplate, ...stepData } = data; + + // If addToTemplate is true, create template step first + let templateId: number | null = null; + + if (addToTemplate) { + const [template] = await db.insert(stepTemplates).values({ + ...stepData, + releaseId, + }).returning(); + templateId = template.id; + + // Add to ALL active customers (not just the current one) + const allCustomers = await db.query.customers.findMany({ + where: eq(customers.isActive, true), + }); + + const stepsToInsert = allCustomers.map(customer => ({ + releaseId, + customerId: customer.id, + templateId: template.id, + ...stepData, + status: 'pending' as const, + isCustom: false, // It's now a template step + isOverridden: false, + })); + + if (stepsToInsert.length > 0) { + await db.insert(customerSteps).values(stepsToInsert); + } + + // Return the step for the requested customer + const [step] = await db.query.customerSteps.findMany({ + where: and( + eq(customerSteps.releaseId, releaseId), + eq(customerSteps.customerId, customerId), + eq(customerSteps.templateId, templateId) + ), + }); + + revalidatePath(`/releases/${releaseId}`); + return step; + } + + // Original behavior: custom step for single customer const [step] = await db.insert(customerSteps).values({ - ...data, + ...stepData, releaseId, customerId, templateId: null, @@ -964,6 +1010,143 @@ export function StepCard({ step, ...actions }: StepCardProps) { } ``` +### 6.3 Step Detail Side Panel (NEW) + +```typescript +// components/steps/step-detail-panel.tsx + +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { CodeBlock } from './code-block'; + +interface StepDetailPanelProps { + step: CustomerStep & { template?: StepTemplate; customer: Customer }; + isOpen: boolean; + onClose: () => void; + onMarkDone: (id: number, notes?: string) => void; + onSkip: (id: number, reason: string) => void; + onRevert: (id: number, reason?: string) => void; + onOverride: (id: number, content: string) => void; + onResetToTemplate: (id: number) => void; + onEditCustom: (id: number, data: Partial) => void; + onDeleteCustom: (id: number) => void; +} + +export function StepDetailPanel({ step, isOpen, onClose, ...actions }: StepDetailPanelProps) { + // Side panel with: + // - Source info (template/custom/overridden) + // - Syntax-highlighted content + // - Action buttons based on source type + // - Execution notes input + // - History timeline +} +``` + +### 6.4 Add Custom Step Dialog (NEW) + +```typescript +// components/steps/add-custom-step-dialog.tsx + +interface AddCustomStepDialogProps { + releaseId: number; + customerId: number; + category: 'deploy' | 'verify'; + existingSteps: { id: number; name: string; orderIndex: number }[]; + isOpen: boolean; + onClose: () => void; + onAdd: (data: CustomStepInput & { addToTemplate: boolean; insertPosition: number }) => void; +} + +export function AddCustomStepDialog({ ...props }: AddCustomStepDialogProps) { + // Dialog with: + // - Name, type, content inputs + // - Insert position dropdown (before/after existing steps) + // - "Add to template" checkbox (unchecked by default) + // - Add/Cancel buttons +} +``` + +### 6.5 Draggable Step List (NEW) + +```typescript +// components/steps/draggable-step-list.tsx + +import { DndContext, useDraggable, useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; + +interface DraggableStepListProps { + steps: (StepTemplate | CustomerStep)[]; + onReorder: (orderedIds: number[]) => void; + renderStep: (step: StepTemplate | CustomerStep, index: number) => React.ReactNode; +} + +export function DraggableStepList({ steps, onReorder, renderStep }: DraggableStepListProps) { + // Drag-and-drop list using @dnd-kit + // Visual drag handle on each item + // Smooth animations + // Calls onReorder when drop completes +} +``` + +### 6.6 Code Block with Syntax Highlight (NEW) + +```typescript +// components/steps/code-block.tsx + +import Prism from 'prismjs'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-bash'; +import 'prismjs/themes/prism-tomorrow.css'; + +interface CodeBlockProps { + code: string; + type: 'bash' | 'sql' | 'text'; + showLineNumbers?: boolean; +} + +export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps) { + // Server-side or client-side syntax highlighting + // Copy to clipboard button + // Line numbers optional +} +``` + +--- + +## 6.7 API Endpoints (NEW) + +### Reorder Steps Endpoint + +```typescript +// app/api/steps/reorder/route.ts + +import { NextRequest } from 'next/server'; +import { reorderSteps } from '@/lib/actions/step-templates'; + +export async function POST(request: NextRequest) { + const { releaseId, category, orderedIds } = await request.json(); + await reorderSteps(releaseId, category, orderedIds); + return Response.json({ success: true }); +} +``` + +### Get Step Detail Endpoint + +```typescript +// app/api/steps/[id]/detail/route.ts + +import { NextRequest } from 'next/server'; +import { getStepWithDetails } from '@/lib/actions/customer-steps'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + const stepId = parseInt(params.id); + const step = await getStepWithDetails(stepId); + return Response.json(step); +} +``` + --- ## 7. Configuration diff --git a/package-lock.json b/package-lock.json index 6f02383..796a5d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "my-app", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -298,6 +302,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -2252,6 +2310,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", diff --git a/package.json b/package.json index 771cf04..29cea36 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/src/app/api/steps/[id]/detail/route.ts b/src/app/api/steps/[id]/detail/route.ts new file mode 100644 index 0000000..12d0146 --- /dev/null +++ b/src/app/api/steps/[id]/detail/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from 'next/server'; +import { getStepWithDetails } from '@/lib/actions/customer-steps'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const stepId = parseInt(id); + + if (isNaN(stepId)) { + return Response.json({ error: 'Invalid step ID' }, { status: 400 }); + } + + const step = await getStepWithDetails(stepId); + + if (!step) { + return Response.json({ error: 'Step not found' }, { status: 404 }); + } + + return Response.json(step); + } catch (error) { + console.error('Error fetching step details:', error); + return Response.json( + { error: 'Failed to fetch step details' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/steps/reorder/route.ts b/src/app/api/steps/reorder/route.ts new file mode 100644 index 0000000..2a38c59 --- /dev/null +++ b/src/app/api/steps/reorder/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server'; +import { reorderSteps } from '@/lib/actions/step-templates'; +import { StepCategory } from '@/lib/db/schema'; + +export async function POST(request: NextRequest) { + try { + const { releaseId, category, orderedIds } = await request.json(); + + if (!releaseId || !category || !orderedIds || !Array.isArray(orderedIds)) { + return Response.json( + { error: 'Missing required fields: releaseId, category, orderedIds' }, + { status: 400 } + ); + } + + await reorderSteps(releaseId, category as StepCategory, orderedIds); + + return Response.json({ success: true }); + } catch (error) { + console.error('Error reordering steps:', error); + return Response.json( + { error: 'Failed to reorder steps' }, + { status: 500 } + ); + } +} diff --git a/src/app/releases/[id]/page.tsx b/src/app/releases/[id]/page.tsx index 2bdab8f..a235fa9 100644 --- a/src/app/releases/[id]/page.tsx +++ b/src/app/releases/[id]/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import { notFound, redirect } from 'next/navigation'; -import { ArrowLeft, Edit, Play, Archive, Plus, CheckCircle, Circle, SkipForward, RotateCcw } from 'lucide-react'; +import { ArrowLeft, Edit, Play, Archive, Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator'; import { getReleaseById, activateRelease, archiveRelease } from '@/lib/actions/releases'; import { getReleaseStepsGroupedByCluster, getStepStats } from '@/lib/actions/customer-steps'; import { listCustomers } from '@/lib/actions/customers'; +import { ReleaseMatrixClient } from '@/components/releases/release-matrix-client'; interface ReleaseDetailPageProps { params: Promise<{ @@ -202,7 +203,7 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro - - ); } - -interface MatrixViewProps { - stepsByCluster: any; - category: 'deploy' | 'verify'; - releaseId: number; -} - -function MatrixView({ stepsByCluster, category, releaseId }: MatrixViewProps) { - const clusters = Object.values(stepsByCluster); - - if (clusters.length === 0) { - return ( - - - No customers found. Add customers to see the matrix view. - - - ); - } - - return ( -
- {clusters.map((clusterData: any) => { - const customers = Object.values(clusterData.customers); - - // Get all unique steps for this category - const allSteps = new Map(); - customers.forEach((customer: any) => { - customer.steps - .filter((s: any) => s.category === category) - .forEach((step: any) => { - if (!allSteps.has(step.name)) { - allSteps.set(step.name, step); - } - }); - }); - const steps = Array.from(allSteps.values()).sort((a: any, b: any) => a.orderIndex - b.orderIndex); - - if (steps.length === 0) return null; - - return ( - - - - - {clusterData.cluster?.name || 'Unknown Cluster'} - - - -
- - - - - {customers.map((customer: any) => ( - - ))} - - - - {steps.map((step: any, stepIndex: number) => ( - - - {customers.map((customer: any) => { - const customerStep = customer.steps.find( - (s: any) => s.name === step.name && s.category === category - ); - - if (!customerStep) return ; - - return ( - - ); - })} - - ))} - -
Step -
{customer.customer.name}
-
{customer.customer.namespace}
-
-
- {stepIndex + 1}. -
-

{step.name}

- {step.isOverridden && ( - custom - )} -
-
-
- -
-
-
-
- ); - })} -
- ); -} - -function StepStatusCell({ step, releaseId }: { step: any; releaseId: number }) { - const statusIcons = { - pending: , - done: , - skipped: , - reverted: , - }; - - async function markDone() { - 'use server'; - const { markStepDone } = await import('@/lib/actions/customer-steps'); - await markStepDone(step.id); - } - - async function skipStep(formData: FormData) { - 'use server'; - const { skipStep } = await import('@/lib/actions/customer-steps'); - await skipStep(step.id, formData.get('reason') as string); - } - - return ( -
-
- -
- {step.status === 'pending' && ( -
- - -
- )} -
- ); -} diff --git a/src/app/releases/[id]/steps/page.tsx b/src/app/releases/[id]/steps/page.tsx index f190910..7bac7ec 100644 --- a/src/app/releases/[id]/steps/page.tsx +++ b/src/app/releases/[id]/steps/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { ArrowLeft, Plus, GripVertical, Trash2 } from 'lucide-react'; +import { ArrowLeft, Plus, GripVertical, Trash2, FileText } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -15,7 +15,23 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { useEffect } from 'react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; interface Step { id: number; @@ -120,11 +136,25 @@ interface StepListProps { function StepList({ steps, category, releaseId, onUpdate }: StepListProps) { const [isDialogOpen, setIsDialogOpen] = useState(false); + const [localSteps, setLocalSteps] = useState(steps); + const [isReordering, setIsReordering] = useState(false); + + useEffect(() => { + setLocalSteps(steps); + }, [steps]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); async function handleAddStep(formData: FormData) { try { const response = await fetch('/api/steps', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ releaseId, category, @@ -153,6 +183,45 @@ function StepList({ steps, category, releaseId, onUpdate }: StepListProps) { } } + async function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + + if (over && active.id !== over.id) { + setIsReordering(true); + + const oldIndex = localSteps.findIndex((s) => s.id === active.id); + const newIndex = localSteps.findIndex((s) => s.id === over.id); + + const newSteps = arrayMove(localSteps, oldIndex, newIndex); + setLocalSteps(newSteps); + + // Send reorder request to server + try { + const response = await fetch('/api/steps/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + releaseId, + category, + orderedIds: newSteps.map(s => s.id), + }), + }); + + if (!response.ok) { + // Revert on error + setLocalSteps(steps); + alert('Failed to reorder steps'); + } + } catch (error) { + console.error('Reorder error:', error); + setLocalSteps(steps); + } finally { + setIsReordering(false); + onUpdate(); + } + } + } + return ( @@ -195,41 +264,94 @@ function StepList({ steps, category, releaseId, onUpdate }: StepListProps) { - {steps.length === 0 ? ( + {localSteps.length === 0 ? (

No {category} steps defined yet.

) : ( -
- {steps.map((step, index) => ( -
- - {index + 1}. -
-
- {step.name} - - {step.type} - -
- {step.description && ( -

{step.description}

- )} -
- + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {localSteps.map((step, index) => ( + handleDeleteStep(step.id)} + /> + ))}
- ))} -
+ + )} ); } + +interface SortableStepItemProps { + step: Step; + index: number; + onDelete: () => void; +} + +function SortableStepItem({ step, index, onDelete }: SortableStepItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: step.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : 1, + }; + + return ( +
+ + {index + 1}. +
+
+ + {step.name} + + {step.type} + +
+ {step.description && ( +

{step.description}

+ )} +
+ +
+ ); +} diff --git a/src/components/releases/release-matrix-client.tsx b/src/components/releases/release-matrix-client.tsx new file mode 100644 index 0000000..551bbfa --- /dev/null +++ b/src/components/releases/release-matrix-client.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, CheckCircle, Circle, SkipForward, RotateCcw, FileText, AlertCircle, Edit } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { StepDetailPanel } from '@/components/steps/step-detail-panel'; +import { AddCustomStepDialog } from '@/components/steps/add-custom-step-dialog'; + +interface ReleaseMatrixClientProps { + stepsByCluster: any; + category: 'deploy' | 'verify'; + releaseId: number; +} + +const statusIcons = { + pending: , + done: , + skipped: , + reverted: , +}; + +export function ReleaseMatrixClient({ stepsByCluster, category, releaseId }: ReleaseMatrixClientProps) { + const [selectedStep, setSelectedStep] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + const clusters = Object.values(stepsByCluster); + + const handleStepClick = (step: any, template: any = null) => { + setSelectedStep(step); + setSelectedTemplate(template); + setIsPanelOpen(true); + }; + + const handleActionComplete = () => { + setRefreshKey(prev => prev + 1); + setIsPanelOpen(false); + // Refresh the page to get updated data + window.location.reload(); + }; + + // Server actions wrapped in async functions + const markStepDone = async (id: number, notes?: string) => { + const { markStepDone } = await import('@/lib/actions/customer-steps'); + await markStepDone(id, notes); + handleActionComplete(); + }; + + const skipStep = async (id: number, reason: string) => { + const { skipStep } = await import('@/lib/actions/customer-steps'); + await skipStep(id, reason); + handleActionComplete(); + }; + + const markStepReverted = async (id: number, reason?: string) => { + const { markStepReverted } = await import('@/lib/actions/customer-steps'); + await markStepReverted(id, reason); + handleActionComplete(); + }; + + const overrideStepContent = async (id: number, content: string) => { + const { overrideStepContent } = await import('@/lib/actions/customer-steps'); + await overrideStepContent(id, content); + handleActionComplete(); + }; + + const resetToTemplate = async (id: number) => { + const { resetToTemplate } = await import('@/lib/actions/customer-steps'); + await resetToTemplate(id); + handleActionComplete(); + }; + + const editCustomStep = async (id: number, data: any) => { + const { editCustomStep } = await import('@/lib/actions/customer-steps'); + await editCustomStep(id, data); + handleActionComplete(); + }; + + const deleteCustomStep = async (id: number) => { + const { deleteCustomStep } = await import('@/lib/actions/customer-steps'); + await deleteCustomStep(id); + handleActionComplete(); + }; + + const addCustomStep = async (customerId: number, customerName: string, existingSteps: any[]) => { + // This will be handled by the dialog component + return async (data: any) => { + const { addCustomStep } = await import('@/lib/actions/customer-steps'); + await addCustomStep(releaseId, customerId, data); + handleActionComplete(); + }; + }; + + if (clusters.length === 0) { + return ( + + + No customers found. Add customers to see the matrix view. + + + ); + } + + return ( + <> +
+ {clusters.map((clusterData: any) => { + const customers = Object.values(clusterData.customers); + + // Get all unique steps for this category, sorted by orderIndex + const allSteps = new Map(); + customers.forEach((customer: any) => { + customer.steps + .filter((s: any) => s.category === category) + .forEach((step: any) => { + // Use a combination of name and templateId as key to handle custom steps + const key = step.templateId ? `template-${step.templateId}` : `custom-${step.id}`; + if (!allSteps.has(key)) { + allSteps.set(key, step); + } + }); + }); + + // Sort by orderIndex (handles decimals for mixed ordering) + const steps = Array.from(allSteps.values()).sort((a: any, b: any) => a.orderIndex - b.orderIndex); + + if (steps.length === 0) return null; + + return ( + + + + + {clusterData.cluster?.name || 'Unknown Cluster'} + + + +
+ + + + + {customers.map((customer: any) => ( + + ))} + + + + {steps.map((step: any, stepIndex: number) => ( + + + {customers.map((customer: any) => { + // Find the customer step - match by templateId for template steps, or by id for custom steps + const customerStep = customer.steps.find( + (s: any) => { + if (step.templateId) { + return s.templateId === step.templateId && s.category === category; + } + return s.id === step.id && s.category === category; + } + ); + + if (!customerStep) return ; + + return ( + + ); + })} + + ))} + +
Step +
{customer.customer.name}
+
{customer.customer.namespace}
+
+ ({ id: s.id, name: s.name, orderIndex: s.orderIndex }))} + onAdd={async (data) => { + const { addCustomStep } = await import('@/lib/actions/customer-steps'); + await addCustomStep(releaseId, customer.customer.id, data); + handleActionComplete(); + }} + /> +
+
+
+ {stepIndex + 1}. +
+

{step.name}

+
+ {step.isCustom && ( + + + custom + + )} + {step.isOverridden && ( + + + overridden + + )} +
+
+
+
+ +
+
+
+
+ ); + })} +
+ + setIsPanelOpen(false)} + onMarkDone={markStepDone} + onSkip={skipStep} + onRevert={markStepReverted} + onOverride={overrideStepContent} + onResetToTemplate={resetToTemplate} + onEditCustom={editCustomStep} + onDeleteCustom={deleteCustomStep} + /> + + ); +} diff --git a/src/components/steps/add-custom-step-dialog.tsx b/src/components/steps/add-custom-step-dialog.tsx new file mode 100644 index 0000000..29b18d4 --- /dev/null +++ b/src/components/steps/add-custom-step-dialog.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, FileText } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface ExistingStep { + id: number; + name: string; + orderIndex: number; +} + +interface AddCustomStepDialogProps { + releaseId: number; + customerId: number; + customerName: string; + category: 'deploy' | 'verify'; + existingSteps: ExistingStep[]; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; + onAdd: (data: { + name: string; + category: 'deploy' | 'verify'; + type: 'bash' | 'sql' | 'text'; + content: string; + orderIndex: number; + addToTemplate: boolean; + }) => Promise; +} + +export function AddCustomStepDialog({ + releaseId, + customerId, + customerName, + category, + existingSteps, + isOpen: controlledIsOpen, + onOpenChange: controlledOnOpenChange, + onAdd, +}: AddCustomStepDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [type, setType] = useState<'bash' | 'sql' | 'text'>('bash'); + const [content, setContent] = useState(''); + const [insertAfterId, setInsertAfterId] = useState(''); + const [addToTemplate, setAddToTemplate] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isControlled = controlledIsOpen !== undefined; + const openState = isControlled ? controlledIsOpen : isOpen; + const setOpenState = isControlled ? controlledOnOpenChange! : setIsOpen; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !content.trim()) return; + + setIsSubmitting(true); + + // Calculate orderIndex based on insert position + let orderIndex = existingSteps.length; + if (insertAfterId) { + const afterStep = existingSteps.find(s => s.id.toString() === insertAfterId); + if (afterStep) { + // Use decimal to insert between (e.g., if after step with index 1, use 1.5) + const nextStep = existingSteps.find(s => s.orderIndex > afterStep.orderIndex); + if (nextStep) { + orderIndex = (afterStep.orderIndex + nextStep.orderIndex) / 2; + } else { + orderIndex = afterStep.orderIndex + 1; + } + } + } else if (existingSteps.length > 0) { + // Insert at beginning - use negative decimal or 0.5 before first + const firstStep = existingSteps[0]; + orderIndex = firstStep.orderIndex / 2; + } + + await onAdd({ + name: name.trim(), + category, + type, + content: content.trim(), + orderIndex, + addToTemplate, + }); + + // Reset form + setName(''); + setType('bash'); + setContent(''); + setInsertAfterId(''); + setAddToTemplate(false); + setIsSubmitting(false); + setOpenState(false); + }; + + const typeOptions = [ + { value: 'bash', label: 'Bash Script' }, + { value: 'sql', label: 'SQL' }, + { value: 'text', label: 'Text/Instructions' }, + ]; + + return ( + + {!isControlled && ( + + + + )} + + + + + Add Custom Step for {customerName} + + + +
+
+ + setName(e.target.value)} + placeholder="e.g., Run Custom Migration" + required + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +