tenantpilot/components/policy-explorer/PolicyDetailSheet.tsx
Ahmed Darrazi aa598452e9
All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
feat(policy-explorer-v2): Phase 6 - Enhanced Detail View
Implemented Tasks T038-T046:
- T038: Created useCopyToClipboard hook with toast notifications
- T039: Skipped (unit tests - optional)
- T040: Added copy button for Policy ID field
- T041: Added copy button for Setting Name field
- T042: Added tabs for Details and Raw JSON views
- T043: Implemented Raw JSON tab with syntax highlighting
- T044: Created getIntunePortalLink utility (8 policy types)
- T045: Added Open in Intune button with URL construction
- T046: Fallback to copy Policy ID if URL unavailable

Files Created:
- lib/hooks/useCopyToClipboard.ts (65 lines)
- lib/utils/policy-table-helpers.ts (127 lines)

Files Updated:
- components/policy-explorer/PolicyDetailSheet.tsx (enhanced with tabs, copy buttons, Intune links)

Features:
- Copy-to-clipboard for all fields with visual feedback
- Two-tab interface: Details (enhanced fields) and Raw JSON (full object)
- Deep linking to Intune portal by policy type
- Clipboard API with document.execCommand fallback
- Toast notifications for user feedback
2025-12-10 00:40:09 +01:00

303 lines
9.6 KiB
TypeScript

'use client';
import { useState } from 'react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { PolicySettingSearchResult } from '@/lib/actions/policySettings';
import { PolicyTypeBadge } from './PolicyTypeBadge';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import { Copy, ExternalLink, Check } from 'lucide-react';
import { useCopyToClipboard } from '@/lib/hooks/useCopyToClipboard';
import { getIntunePortalLink } from '@/lib/utils/policy-table-helpers';
interface PolicyDetailSheetProps {
policy: PolicySettingSearchResult | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function isJsonString(str: string): boolean {
if (!str || typeof str !== 'string') return false;
const trimmed = str.trim();
return trimmed.startsWith('{') || trimmed.startsWith('[');
}
function formatJson(value: string): string {
try {
const parsed = JSON.parse(value);
return JSON.stringify(parsed, null, 2);
} catch {
return value;
}
}
export function PolicyDetailSheet({
policy,
open,
onOpenChange,
}: PolicyDetailSheetProps) {
const [activeTab, setActiveTab] = useState<'details' | 'raw'>('details');
const { copy, isCopied } = useCopyToClipboard();
if (!policy) return null;
const isJson = isJsonString(policy.settingValue);
const displayValue = isJson
? formatJson(policy.settingValue)
: policy.settingValue;
const intuneUrl = getIntunePortalLink(policy.policyType, policy.graphPolicyId);
const handleCopyField = (value: string, fieldName: string) => {
copy(value, `${fieldName} copied to clipboard`);
};
const handleOpenInIntune = () => {
if (intuneUrl) {
window.open(intuneUrl, '_blank', 'noopener,noreferrer');
} else {
// Fallback: copy policy ID
copy(policy.graphPolicyId, 'Policy ID copied to clipboard');
}
};
// Generate raw JSON for the entire policy object
const rawJson = JSON.stringify(policy, null, 2);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[600px] sm:max-w-[600px] overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center justify-between">
<span className="truncate">{policy.settingName}</span>
{intuneUrl && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenInIntune}
className="ml-2"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</SheetTitle>
<SheetDescription>
Policy Setting Details
</SheetDescription>
</SheetHeader>
{/* Tabs */}
<div className="flex gap-2 mt-4 border-b">
<button
onClick={() => setActiveTab('details')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'details'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Details
</button>
<button
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Raw JSON
</button>
</div>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="mt-6 space-y-6">
{/* Policy Name */}
<div>
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-medium text-muted-foreground">
Policy Name
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyField(policy.policyName, 'Policy Name')}
className="h-7 px-2"
>
{isCopied ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<p className="text-sm">{policy.policyName}</p>
</div>
{/* Policy Type */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Policy Type
</h3>
<PolicyTypeBadge type={policy.policyType} />
</div>
{/* Setting Name */}
<div>
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-medium text-muted-foreground">
Setting Name
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyField(policy.settingName, 'Setting Name')}
className="h-7 px-2"
>
{isCopied ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<p className="text-sm font-mono">{policy.settingName}</p>
</div>
{/* Setting Value */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-muted-foreground">
Setting Value
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyField(policy.settingValue, 'Setting Value')}
className="h-7 px-2"
>
{isCopied ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
{isJson ? (
<pre className="text-xs bg-muted p-4 rounded-md overflow-x-auto max-h-96 overflow-y-auto">
<code>{displayValue}</code>
</pre>
) : (
<p className="text-sm whitespace-pre-wrap break-words">
{displayValue}
</p>
)}
</div>
{/* Graph Policy ID */}
<div>
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-medium text-muted-foreground">
Graph Policy ID
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyField(policy.graphPolicyId, 'Policy ID')}
className="h-7 px-2"
>
{isCopied ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<p className="text-sm font-mono text-muted-foreground">
{policy.graphPolicyId}
</p>
</div>
{/* Last Synced */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Last Synced
</h3>
<p className="text-sm">
{formatDistanceToNow(new Date(policy.lastSyncedAt), {
addSuffix: true,
locale: de,
})}
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(policy.lastSyncedAt).toLocaleString('de-DE')}
</p>
</div>
{/* Open in Intune Button */}
<div className="pt-4 border-t">
{intuneUrl ? (
<Button
variant="default"
onClick={handleOpenInIntune}
className="w-full"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Intune Portal
</Button>
) : (
<Button
variant="outline"
onClick={() => handleCopyField(policy.graphPolicyId, 'Policy ID')}
className="w-full"
>
<Copy className="mr-2 h-4 w-4" />
Copy Policy ID
</Button>
)}
</div>
</div>
)}
{/* Raw JSON Tab */}
{activeTab === 'raw' && (
<div className="mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">Complete Policy Object</h3>
<Button
variant="outline"
size="sm"
onClick={() => copy(rawJson, 'Raw JSON copied to clipboard')}
>
{isCopied ? (
<>
<Check className="mr-2 h-4 w-4 text-green-600" />
Copied
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
Copy All
</>
)}
</Button>
</div>
<pre className="text-xs bg-muted p-4 rounded-md overflow-x-auto max-h-[600px] overflow-y-auto">
<code className="language-json">{rawJson}</code>
</pre>
</div>
)}
</SheetContent>
</Sheet>
);
}