Files
patchmon.net/frontend/src/components/DashboardSettingsModal.jsx
2025-09-26 00:24:32 +01:00

366 lines
11 KiB
JavaScript

import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Eye,
EyeOff,
GripVertical,
RotateCcw,
Save,
Settings as SettingsIcon,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { dashboardPreferencesAPI } from "../utils/api";
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: card.cardId,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? "shadow-lg" : "shadow-sm"
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
{card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
({card.typeLabel})
</span>
) : null}
</div>
</div>
</div>
<button
type="button"
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
};
const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
enabled: isOpen,
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ["dashboardDefaultCards"],
queryFn: () =>
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
enabled: isOpen,
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(
["dashboardPreferences"],
response.data.preferences,
);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(["dashboardPreferences"]);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error("Failed to update dashboard preferences:", error);
},
});
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
const typeLabelFor = (cardId) => {
if (
[
"totalHosts",
"hostsNeedingUpdates",
"totalOutdatedPackages",
"securityUpdates",
"upToDateHosts",
"totalHostGroups",
"totalUsers",
"totalRepos",
].includes(cardId)
)
return "Top card";
if (cardId === "osDistribution") return "Pie chart";
if (cardId === "osDistributionBar") return "Bar chart";
if (cardId === "updateStatus") return "Pie chart";
if (cardId === "packagePriority") return "Pie chart";
if (cardId === "recentUsers") return "Table";
if (cardId === "recentCollection") return "Table";
if (cardId === "quickStats") return "Wide card";
return undefined;
};
// Merge user preferences with default cards
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId,
);
return {
...defaultCard,
enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
})
.sort((a, b) => a.order - b.order);
setCards(mergedCards);
}
}, [preferences, defaultCards]);
const handleDragEnd = (event) => {
const { active, over } = event;
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex((item) => item.cardId === active.id);
const newIndex = items.findIndex((item) => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values
return newItems.map((item, index) => ({
...item,
order: index,
}));
});
setHasChanges(true);
}
};
const handleToggle = (cardId) => {
setCards((prevCards) =>
prevCards.map((card) =>
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
),
);
setHasChanges(true);
};
const handleSave = () => {
const preferences = cards.map((card) => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order,
}));
updatePreferencesMutation.mutate(preferences);
};
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map((card) => ({
...card,
enabled: true,
order: card.order,
}));
setCards(resetCards);
setHasChanges(true);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<button
type="button"
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity cursor-default"
onClick={onClose}
aria-label="Close modal"
/>
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their
visibility. Drag cards to reorder them, and click the visibility
toggle to show/hide cards.
</p>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cards.map((card) => card.cardId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
type="button"
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
};
export default DashboardSettingsModal;