Add staff permission controls for editing staff and customers

- Add can_edit_staff and can_edit_customers dangerous permissions
- Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions
- Link Edit Others' Schedules and Edit Own Schedule permissions
- Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email)
- Add permission checks to CustomerViewSet (update, partial_update, verify_email)
- Fix CustomerViewSet permission key mismatch (can_access_customers)
- Hide Edit/Verify buttons on Staff and Customers pages without permission
- Make dangerous permissions section more visually distinct (darker red)
- Fix StaffDashboard links to use correct paths (/dashboard/my-schedule)
- Disable settings sub-permissions when Access Settings is unchecked

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-29 17:38:48 -05:00
parent d7700a68fd
commit 47657e7076
105 changed files with 29709 additions and 873 deletions

View File

@@ -0,0 +1,576 @@
/**
* Unit tests for WidgetConfigModal component
*
* Tests cover:
* - Component rendering and visibility
* - Modal open/close behavior
* - Widget list display
* - Widget toggle functionality
* - Active widget highlighting
* - Reset layout functionality
* - Widget icons display
* - Internationalization (i18n)
* - Accessibility
* - Backdrop click handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import WidgetConfigModal from '../WidgetConfigModal';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'dashboard.configureWidgets': 'Configure Widgets',
'dashboard.configureWidgetsDescription': 'Choose which widgets to display on your dashboard',
'dashboard.resetToDefault': 'Reset to Default',
'dashboard.done': 'Done',
// Widget titles
'dashboard.widgetTitles.appointmentsMetric': 'Total Appointments',
'dashboard.widgetTitles.customersMetric': 'Active Customers',
'dashboard.widgetTitles.servicesMetric': 'Services',
'dashboard.widgetTitles.resourcesMetric': 'Resources',
'dashboard.widgetTitles.revenueChart': 'Revenue',
'dashboard.widgetTitles.appointmentsChart': 'Appointments Trend',
'dashboard.widgetTitles.openTickets': 'Open Tickets',
'dashboard.widgetTitles.recentActivity': 'Recent Activity',
'dashboard.widgetTitles.capacityUtilization': 'Capacity Utilization',
'dashboard.widgetTitles.noShowRate': 'No-Show Rate',
'dashboard.widgetTitles.customerBreakdown': 'New vs Returning',
// Widget descriptions
'dashboard.widgetDescriptions.appointmentsMetric': 'Shows appointment count with weekly and monthly growth',
'dashboard.widgetDescriptions.customersMetric': 'Shows customer count with weekly and monthly growth',
'dashboard.widgetDescriptions.servicesMetric': 'Shows number of services offered',
'dashboard.widgetDescriptions.resourcesMetric': 'Shows number of resources available',
'dashboard.widgetDescriptions.revenueChart': 'Weekly revenue bar chart',
'dashboard.widgetDescriptions.appointmentsChart': 'Weekly appointments line chart',
'dashboard.widgetDescriptions.openTickets': 'Shows open support tickets requiring attention',
'dashboard.widgetDescriptions.recentActivity': 'Timeline of recent business events',
'dashboard.widgetDescriptions.capacityUtilization': 'Shows how booked your resources are this week',
'dashboard.widgetDescriptions.noShowRate': 'Percentage of appointments marked as no-show',
'dashboard.widgetDescriptions.customerBreakdown': 'Customer breakdown this month',
};
return translations[key] || key;
},
}),
}));
describe('WidgetConfigModal', () => {
const mockOnClose = vi.fn();
const mockOnToggleWidget = vi.fn();
const mockOnResetLayout = vi.fn();
const defaultProps = {
isOpen: true,
onClose: mockOnClose,
activeWidgets: ['appointments-metric', 'customers-metric', 'revenue-chart'],
onToggleWidget: mockOnToggleWidget,
onResetLayout: mockOnResetLayout,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Modal Visibility', () => {
it('should render when isOpen is true', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Configure Widgets')).toBeInTheDocument();
});
it('should not render when isOpen is false', () => {
render(<WidgetConfigModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Configure Widgets')).not.toBeInTheDocument();
});
it('should return null when not open', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} isOpen={false} />);
expect(container.firstChild).toBeNull();
});
});
describe('Modal Header', () => {
it('should render modal title', () => {
render(<WidgetConfigModal {...defaultProps} />);
const title = screen.getByText('Configure Widgets');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-lg', 'font-semibold');
});
it('should render close button in header', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Close button (X icon) should be present
const closeButtons = container.querySelectorAll('button');
expect(closeButtons.length).toBeGreaterThan(0);
});
it('should call onClose when header close button is clicked', async () => {
const user = userEvent.setup();
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Find the X button in header
const buttons = container.querySelectorAll('button');
const closeButton = Array.from(buttons).find(btn =>
btn.querySelector('svg')
) as HTMLElement;
if (closeButton) {
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
}
});
});
describe('Modal Content', () => {
it('should render description text', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Choose which widgets to display on your dashboard')).toBeInTheDocument();
});
it('should render all widget options', () => {
render(<WidgetConfigModal {...defaultProps} />);
// Check for widget titles
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
expect(screen.getByText('Active Customers')).toBeInTheDocument();
expect(screen.getByText('Services')).toBeInTheDocument();
expect(screen.getByText('Resources')).toBeInTheDocument();
expect(screen.getByText('Revenue')).toBeInTheDocument();
expect(screen.getByText('Appointments Trend')).toBeInTheDocument();
expect(screen.getByText('Open Tickets')).toBeInTheDocument();
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
expect(screen.getByText('Capacity Utilization')).toBeInTheDocument();
expect(screen.getByText('No-Show Rate')).toBeInTheDocument();
expect(screen.getByText('New vs Returning')).toBeInTheDocument();
});
it('should render widget descriptions', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Shows appointment count with weekly and monthly growth')).toBeInTheDocument();
expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument();
});
it('should render widget icons', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Should have multiple SVG icons
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(10); // At least one per widget
});
});
describe('Widget Selection', () => {
it('should highlight active widgets', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Active widgets should have brand-500 border
const activeWidgets = container.querySelectorAll('.border-brand-500');
expect(activeWidgets.length).toBe(defaultProps.activeWidgets.length);
});
it('should show checkmark on active widgets', () => {
render(<WidgetConfigModal {...defaultProps} />);
// Check icons should be present for active widgets
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const svgs = container.querySelectorAll('svg');
// Should have check icons (hard to test exact count due to other icons)
expect(svgs.length).toBeGreaterThan(0);
});
it('should not highlight inactive widgets', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Inactive widgets should have gray border
const inactiveWidgets = container.querySelectorAll('.border-gray-200');
expect(inactiveWidgets.length).toBeGreaterThan(0);
});
it('should call onToggleWidget when widget is clicked', async () => {
const user = userEvent.setup();
render(<WidgetConfigModal {...defaultProps} />);
const widget = screen.getByText('Total Appointments').closest('button');
expect(widget).toBeInTheDocument();
if (widget) {
await user.click(widget);
expect(mockOnToggleWidget).toHaveBeenCalledWith('appointments-metric');
}
});
it('should call onToggleWidget with correct widget ID', async () => {
const user = userEvent.setup();
render(<WidgetConfigModal {...defaultProps} />);
const revenueWidget = screen.getByText('Revenue').closest('button');
if (revenueWidget) {
await user.click(revenueWidget);
expect(mockOnToggleWidget).toHaveBeenCalledWith('revenue-chart');
}
const ticketsWidget = screen.getByText('Open Tickets').closest('button');
if (ticketsWidget) {
await user.click(ticketsWidget);
expect(mockOnToggleWidget).toHaveBeenCalledWith('open-tickets');
}
});
it('should allow toggling multiple widgets', async () => {
const user = userEvent.setup();
render(<WidgetConfigModal {...defaultProps} />);
const widget1 = screen.getByText('Services').closest('button');
const widget2 = screen.getByText('Resources').closest('button');
if (widget1) await user.click(widget1);
if (widget2) await user.click(widget2);
expect(mockOnToggleWidget).toHaveBeenCalledTimes(2);
});
});
describe('Active Widget Styling', () => {
it('should apply active styling to selected widgets', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Active widgets should have brand colors
const brandBg = container.querySelectorAll('.bg-brand-50');
expect(brandBg.length).toBeGreaterThan(0);
});
it('should apply inactive styling to unselected widgets', () => {
const { container } = render(
<WidgetConfigModal
{...defaultProps}
activeWidgets={['appointments-metric']} // Only one active
/>
);
// Many widgets should have gray styling
const grayBorders = container.querySelectorAll('.border-gray-200');
expect(grayBorders.length).toBeGreaterThan(5);
});
it('should apply different icon colors for active vs inactive', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Active widgets should have brand icon colors
const brandIcons = container.querySelectorAll('.text-brand-600');
expect(brandIcons.length).toBeGreaterThan(0);
// Inactive widgets should have gray icon colors
const grayIcons = container.querySelectorAll('.text-gray-500');
expect(grayIcons.length).toBeGreaterThan(0);
});
});
describe('Modal Footer', () => {
it('should render reset button', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
});
it('should render done button', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Done')).toBeInTheDocument();
});
it('should call onResetLayout when reset button is clicked', async () => {
const user = userEvent.setup();
render(<WidgetConfigModal {...defaultProps} />);
const resetButton = screen.getByText('Reset to Default');
await user.click(resetButton);
expect(mockOnResetLayout).toHaveBeenCalledTimes(1);
});
it('should call onClose when done button is clicked', async () => {
const user = userEvent.setup();
render(<WidgetConfigModal {...defaultProps} />);
const doneButton = screen.getByText('Done');
await user.click(doneButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Backdrop Interaction', () => {
it('should render backdrop', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Backdrop div with bg-black/50
const backdrop = container.querySelector('.bg-black\\/50');
expect(backdrop).toBeInTheDocument();
});
it('should call onClose when backdrop is clicked', async () => {
const user = userEvent.setup();
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const backdrop = container.querySelector('.bg-black\\/50') as HTMLElement;
expect(backdrop).toBeInTheDocument();
if (backdrop) {
await user.click(backdrop);
expect(mockOnClose).toHaveBeenCalledTimes(1);
}
});
it('should not call onClose when modal content is clicked', async () => {
const user = userEvent.setup();
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Click on modal content, not backdrop
const modalContent = container.querySelector('.bg-white') as HTMLElement;
if (modalContent) {
await user.click(modalContent);
expect(mockOnClose).not.toHaveBeenCalled();
}
});
});
describe('Widget Grid Layout', () => {
it('should display widgets in a grid', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
// Grid container should exist
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
expect(grid).toHaveClass('grid-cols-1', 'sm:grid-cols-2');
});
it('should render all 11 widgets', () => {
render(<WidgetConfigModal {...defaultProps} />);
// Count widget buttons
const widgetButtons = screen.getAllByRole('button');
// Should have 11 widget buttons + 2 footer buttons + 1 close button = 14 total
expect(widgetButtons.length).toBeGreaterThanOrEqual(11);
});
});
describe('Styling', () => {
it('should apply modal container styles', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const modal = container.querySelector('.bg-white');
expect(modal).toHaveClass(
'bg-white',
'rounded-xl',
'shadow-xl',
'max-w-2xl',
'w-full'
);
});
it('should apply dark mode styles', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const modal = container.querySelector('.dark\\:bg-gray-800');
expect(modal).toBeInTheDocument();
});
it('should make modal scrollable', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const scrollableContent = container.querySelector('.overflow-y-auto');
expect(scrollableContent).toBeInTheDocument();
});
it('should apply max height to modal', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const modal = container.querySelector('.max-h-\\[80vh\\]');
expect(modal).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have semantic HTML structure', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const headings = container.querySelectorAll('h2');
expect(headings.length).toBeGreaterThan(0);
});
it('should have accessible buttons', () => {
render(<WidgetConfigModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('should have clear button text', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
});
it('should have descriptive widget names', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
});
});
describe('Widget Descriptions', () => {
it('should show description for each widget', () => {
render(<WidgetConfigModal {...defaultProps} />);
// Check a few widget descriptions
expect(screen.getByText('Shows appointment count with weekly and monthly growth')).toBeInTheDocument();
expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument();
expect(screen.getByText('Shows how booked your resources are this week')).toBeInTheDocument();
});
it('should display descriptions in smaller text', () => {
const { container } = render(<WidgetConfigModal {...defaultProps} />);
const descriptions = container.querySelectorAll('.text-xs');
expect(descriptions.length).toBeGreaterThan(0);
});
});
describe('Edge Cases', () => {
it('should handle empty activeWidgets array', () => {
render(<WidgetConfigModal {...defaultProps} activeWidgets={[]} />);
// Should still render all widgets, just none selected
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
// No checkmarks should be visible
const { container } = render(<WidgetConfigModal {...defaultProps} activeWidgets={[]} />);
const activeWidgets = container.querySelectorAll('.border-brand-500');
expect(activeWidgets.length).toBe(0);
});
it('should handle all widgets active', () => {
const allWidgets = [
'appointments-metric',
'customers-metric',
'services-metric',
'resources-metric',
'revenue-chart',
'appointments-chart',
'open-tickets',
'recent-activity',
'capacity-utilization',
'no-show-rate',
'customer-breakdown',
];
const { container } = render(
<WidgetConfigModal {...defaultProps} activeWidgets={allWidgets} />
);
// All widgets should have active styling
const activeWidgets = container.querySelectorAll('.border-brand-500');
expect(activeWidgets.length).toBe(11);
});
it('should handle rapid widget toggling', async () => {
const user = userEvent.setup();
render(<WidgetConfigModal {...defaultProps} />);
const widget = screen.getByText('Services').closest('button');
if (widget) {
await user.click(widget);
await user.click(widget);
await user.click(widget);
expect(mockOnToggleWidget).toHaveBeenCalledTimes(3);
}
});
});
describe('Internationalization', () => {
it('should use translations for modal title', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Configure Widgets')).toBeInTheDocument();
});
it('should use translations for widget titles', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
});
it('should use translations for widget descriptions', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument();
});
it('should use translations for buttons', () => {
render(<WidgetConfigModal {...defaultProps} />);
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render correctly with all props', () => {
const handleClose = vi.fn();
const handleToggle = vi.fn();
const handleReset = vi.fn();
render(
<WidgetConfigModal
isOpen={true}
onClose={handleClose}
activeWidgets={['appointments-metric', 'revenue-chart']}
onToggleWidget={handleToggle}
onResetLayout={handleReset}
/>
);
expect(screen.getByText('Configure Widgets')).toBeInTheDocument();
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
});
it('should support complete user workflow', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
const handleToggle = vi.fn();
const handleReset = vi.fn();
render(
<WidgetConfigModal
isOpen={true}
onClose={handleClose}
activeWidgets={['appointments-metric']}
onToggleWidget={handleToggle}
onResetLayout={handleReset}
/>
);
// User toggles a widget
const widget = screen.getByText('Revenue').closest('button');
if (widget) await user.click(widget);
expect(handleToggle).toHaveBeenCalledWith('revenue-chart');
// User resets layout
const resetButton = screen.getByText('Reset to Default');
await user.click(resetButton);
expect(handleReset).toHaveBeenCalledTimes(1);
// User closes modal
const doneButton = screen.getByText('Done');
await user.click(doneButton);
expect(handleClose).toHaveBeenCalledTimes(1);
});
});
});