feat(policy-explorer-v2): Phase 6 - Enhanced Detail View
All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
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
This commit is contained in:
parent
4288bc7884
commit
aa598452e9
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -7,10 +8,15 @@ import {
|
||||
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;
|
||||
@ -38,6 +44,9 @@ export function PolicyDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PolicyDetailSheetProps) {
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'raw'>('details');
|
||||
const { copy, isCopied } = useCopyToClipboard();
|
||||
|
||||
if (!policy) return null;
|
||||
|
||||
const isJson = isJsonString(policy.settingValue);
|
||||
@ -45,22 +54,92 @@ export function PolicyDetailSheet({
|
||||
? 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>{policy.settingName}</SheetTitle>
|
||||
<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>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
<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>
|
||||
|
||||
@ -74,17 +153,45 @@ export function PolicyDetailSheet({
|
||||
|
||||
{/* Setting Name */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
<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>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
<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>
|
||||
@ -96,6 +203,30 @@ export function PolicyDetailSheet({
|
||||
)}
|
||||
</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">
|
||||
@ -111,7 +242,60 @@ export function PolicyDetailSheet({
|
||||
{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>
|
||||
);
|
||||
|
||||
70
lib/hooks/useCopyToClipboard.ts
Normal file
70
lib/hooks/useCopyToClipboard.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* useCopyToClipboard Hook
|
||||
*
|
||||
* Wrapper for Clipboard API with success/error handling and toast notifications.
|
||||
*
|
||||
* Features:
|
||||
* - Copy text to clipboard
|
||||
* - Success/error state tracking
|
||||
* - Automatic toast notifications
|
||||
* - Fallback for older browsers
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CopyToClipboardResult {
|
||||
copy: (text: string, successMessage?: string) => Promise<void>;
|
||||
isCopied: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(): CopyToClipboardResult {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const copy = async (text: string, successMessage: string = 'Copied to clipboard') => {
|
||||
// Reset state
|
||||
setIsCopied(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Modern Clipboard API
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback for older browsers or non-secure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
textArea.remove();
|
||||
|
||||
if (!successful) {
|
||||
throw new Error('Copy command failed');
|
||||
}
|
||||
}
|
||||
|
||||
setIsCopied(true);
|
||||
toast.success(successMessage);
|
||||
|
||||
// Reset isCopied after 2 seconds
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
const copyError = err instanceof Error ? err : new Error('Failed to copy');
|
||||
setError(copyError);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
console.error('Copy error:', copyError);
|
||||
}
|
||||
};
|
||||
|
||||
return { copy, isCopied, error };
|
||||
}
|
||||
115
lib/utils/policy-table-helpers.ts
Normal file
115
lib/utils/policy-table-helpers.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Policy Table Helper Functions
|
||||
*
|
||||
* Utilities for formatting, sorting, and generating links for policy data.
|
||||
*/
|
||||
|
||||
import type { PolicySettingRow } from '@/lib/types/policy-table';
|
||||
|
||||
/**
|
||||
* Generate Intune Portal URL for a policy based on its type and ID
|
||||
* @param policyType The type of policy (e.g., 'deviceConfiguration')
|
||||
* @param graphPolicyId The Microsoft Graph policy ID
|
||||
* @returns Intune Portal URL or null if URL construction fails
|
||||
*/
|
||||
export function getIntunePortalLink(
|
||||
policyType: string,
|
||||
graphPolicyId: string
|
||||
): string | null {
|
||||
if (!graphPolicyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = 'https://intune.microsoft.com';
|
||||
|
||||
// Map policy types to Intune portal paths
|
||||
const policyTypeUrls: Record<string, string> = {
|
||||
// Device Configuration
|
||||
deviceConfiguration: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceConfigProfilesMenu/~/properties/policyId/${graphPolicyId}`,
|
||||
|
||||
// Compliance Policies
|
||||
compliancePolicy: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceCompliancePoliciesMenu/~/properties/policyId/${graphPolicyId}`,
|
||||
|
||||
// Device Management Scripts
|
||||
deviceManagementScript: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceManagementScriptsMenu/~/properties/scriptId/${graphPolicyId}`,
|
||||
|
||||
// Windows Update for Business
|
||||
windowsUpdateForBusiness: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/UpdateRingsMenu/~/properties/policyId/${graphPolicyId}`,
|
||||
|
||||
// iOS Update Configuration
|
||||
iosUpdateConfiguration: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/iOSUpdateConfigurationsMenu/~/properties/policyId/${graphPolicyId}`,
|
||||
|
||||
// Settings Catalog
|
||||
settingsCatalog: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/ConfigurationPolicyMenu/~/properties/policyId/${graphPolicyId}`,
|
||||
|
||||
// Endpoint Protection
|
||||
endpointProtection: `${baseUrl}/#view/Microsoft_Intune_Workflows/SecurityBaselinesSummaryMenu/~/properties/templateId/${graphPolicyId}`,
|
||||
|
||||
// macOS Extensions
|
||||
macOSExtensionsConfiguration: `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceConfigProfilesMenu/~/properties/policyId/${graphPolicyId}`,
|
||||
};
|
||||
|
||||
// Check if we have a known URL pattern
|
||||
if (policyType in policyTypeUrls) {
|
||||
return policyTypeUrls[policyType];
|
||||
}
|
||||
|
||||
// Fallback: Generic device settings URL
|
||||
return `${baseUrl}/#view/Microsoft_Intune_DeviceSettings/DeviceSettingsMenu`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format policy type for display (convert camelCase to Title Case)
|
||||
* @param policyType Policy type in camelCase (e.g., 'deviceConfiguration')
|
||||
* @returns Formatted title (e.g., 'Device Configuration')
|
||||
*/
|
||||
export function formatPolicyType(policyType: string): string {
|
||||
// Insert spaces before capital letters
|
||||
const withSpaces = policyType.replace(/([A-Z])/g, ' $1');
|
||||
|
||||
// Capitalize first letter and trim
|
||||
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate long text with ellipsis
|
||||
* @param text Text to truncate
|
||||
* @param maxLength Maximum length before truncation
|
||||
* @returns Truncated text with ellipsis if needed
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number = 100): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display (relative or absolute)
|
||||
* @param date Date to format
|
||||
* @param relative Whether to show relative time (e.g., "2 hours ago")
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function formatDate(date: Date | string, relative: boolean = false): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (relative) {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - dateObj.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
return `${diffWeeks} week${diffWeeks > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
return dateObj.toLocaleString();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user