Add TenantCustomTier system and fix BusinessEditModal feature loading
Backend: - Add TenantCustomTier model for per-tenant feature overrides - Update EntitlementService to check custom tier before plan features - Add custom_tier action on TenantViewSet (GET/PUT/DELETE) - Add Celery task for grace period management (30-day expiry) Frontend: - Add DynamicFeaturesEditor component for dynamic feature management - Fix BusinessEditModal to load features from plan defaults when no custom tier - Update limits (max_users, max_resources, etc.) to use featureValues - Remove outdated canonical feature check from FeaturePicker (removes warning icons) - Add useBillingPlans hook for accessing billing system data - Add custom tier API functions to platform.ts Features now follow consistent rules: - Load from plan defaults when no custom tier exists - Load from custom tier when one exists - Reset to plan defaults when plan changes - Save to custom tier on edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
293
frontend/src/hooks/__tests__/useFormValidation.test.ts
Normal file
293
frontend/src/hooks/__tests__/useFormValidation.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useFormValidation,
|
||||
required,
|
||||
email,
|
||||
minLength,
|
||||
maxLength,
|
||||
minValue,
|
||||
maxValue,
|
||||
pattern,
|
||||
url,
|
||||
matches,
|
||||
phone,
|
||||
} from '../useFormValidation';
|
||||
|
||||
describe('useFormValidation', () => {
|
||||
describe('hook functionality', () => {
|
||||
it('initializes with no errors', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
expect(result.current.errors).toEqual({});
|
||||
expect(result.current.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates form and returns errors', () => {
|
||||
const schema = {
|
||||
name: [required('Name is required')],
|
||||
};
|
||||
const { result } = renderHook(() => useFormValidation(schema));
|
||||
|
||||
act(() => {
|
||||
result.current.validateForm({ name: '' });
|
||||
});
|
||||
|
||||
expect(result.current.errors.name).toBe('Name is required');
|
||||
expect(result.current.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates single field', () => {
|
||||
const schema = {
|
||||
email: [email('Invalid email')],
|
||||
};
|
||||
const { result } = renderHook(() => useFormValidation(schema));
|
||||
|
||||
const error = result.current.validateField('email', 'invalid');
|
||||
expect(error).toBe('Invalid email');
|
||||
});
|
||||
|
||||
it('returns undefined for valid field', () => {
|
||||
const schema = {
|
||||
email: [email('Invalid email')],
|
||||
};
|
||||
const { result } = renderHook(() => useFormValidation(schema));
|
||||
|
||||
const error = result.current.validateField('email', 'test@example.com');
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets error manually', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Custom error');
|
||||
});
|
||||
|
||||
expect(result.current.errors.field).toBe('Custom error');
|
||||
});
|
||||
|
||||
it('clears single error', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Error');
|
||||
result.current.clearError('field');
|
||||
});
|
||||
|
||||
expect(result.current.errors.field).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears all errors', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field1', 'Error 1');
|
||||
result.current.setError('field2', 'Error 2');
|
||||
result.current.clearAllErrors();
|
||||
});
|
||||
|
||||
expect(result.current.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('getError returns correct error', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Test error');
|
||||
});
|
||||
|
||||
expect(result.current.getError('field')).toBe('Test error');
|
||||
});
|
||||
|
||||
it('hasError returns true when error exists', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
|
||||
act(() => {
|
||||
result.current.setError('field', 'Error');
|
||||
});
|
||||
|
||||
expect(result.current.hasError('field')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasError returns false when no error', () => {
|
||||
const { result } = renderHook(() => useFormValidation({}));
|
||||
expect(result.current.hasError('field')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required validator', () => {
|
||||
it('returns error for undefined', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator(undefined)).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns error for null', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator(null)).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns error for empty string', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator('')).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns error for empty array', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator([])).toBe('Required');
|
||||
});
|
||||
|
||||
it('returns undefined for valid value', () => {
|
||||
const validator = required('Required');
|
||||
expect(validator('value')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default message', () => {
|
||||
const validator = required();
|
||||
expect(validator('')).toBe('This field is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('email validator', () => {
|
||||
it('returns error for invalid email', () => {
|
||||
const validator = email('Invalid');
|
||||
expect(validator('notanemail')).toBe('Invalid');
|
||||
});
|
||||
|
||||
it('returns undefined for valid email', () => {
|
||||
const validator = email('Invalid');
|
||||
expect(validator('test@example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
const validator = email('Invalid');
|
||||
expect(validator('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('minLength validator', () => {
|
||||
it('returns error when too short', () => {
|
||||
const validator = minLength(5, 'Too short');
|
||||
expect(validator('ab')).toBe('Too short');
|
||||
});
|
||||
|
||||
it('returns undefined when long enough', () => {
|
||||
const validator = minLength(5, 'Too short');
|
||||
expect(validator('abcde')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default message', () => {
|
||||
const validator = minLength(5);
|
||||
expect(validator('ab')).toBe('Must be at least 5 characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength validator', () => {
|
||||
it('returns error when too long', () => {
|
||||
const validator = maxLength(3, 'Too long');
|
||||
expect(validator('abcd')).toBe('Too long');
|
||||
});
|
||||
|
||||
it('returns undefined when short enough', () => {
|
||||
const validator = maxLength(3, 'Too long');
|
||||
expect(validator('abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default message', () => {
|
||||
const validator = maxLength(3);
|
||||
expect(validator('abcd')).toBe('Must be at most 3 characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('minValue validator', () => {
|
||||
it('returns error when below min', () => {
|
||||
const validator = minValue(10, 'Too small');
|
||||
expect(validator(5)).toBe('Too small');
|
||||
});
|
||||
|
||||
it('returns undefined when at or above min', () => {
|
||||
const validator = minValue(10, 'Too small');
|
||||
expect(validator(10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for null/undefined', () => {
|
||||
const validator = minValue(10);
|
||||
expect(validator(undefined as unknown as number)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxValue validator', () => {
|
||||
it('returns error when above max', () => {
|
||||
const validator = maxValue(10, 'Too big');
|
||||
expect(validator(15)).toBe('Too big');
|
||||
});
|
||||
|
||||
it('returns undefined when at or below max', () => {
|
||||
const validator = maxValue(10, 'Too big');
|
||||
expect(validator(10)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern validator', () => {
|
||||
it('returns error when pattern does not match', () => {
|
||||
const validator = pattern(/^[a-z]+$/, 'Letters only');
|
||||
expect(validator('abc123')).toBe('Letters only');
|
||||
});
|
||||
|
||||
it('returns undefined when pattern matches', () => {
|
||||
const validator = pattern(/^[a-z]+$/, 'Letters only');
|
||||
expect(validator('abc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('url validator', () => {
|
||||
it('returns error for invalid URL', () => {
|
||||
const validator = url('Invalid URL');
|
||||
expect(validator('not-a-url')).toBe('Invalid URL');
|
||||
});
|
||||
|
||||
it('returns undefined for valid URL', () => {
|
||||
const validator = url('Invalid URL');
|
||||
expect(validator('https://example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
const validator = url('Invalid URL');
|
||||
expect(validator('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches validator', () => {
|
||||
it('returns error when fields do not match', () => {
|
||||
const validator = matches('password', 'Must match');
|
||||
expect(validator('abc', { password: 'xyz' })).toBe('Must match');
|
||||
});
|
||||
|
||||
it('returns undefined when fields match', () => {
|
||||
const validator = matches('password', 'Must match');
|
||||
expect(validator('abc', { password: 'abc' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no form data', () => {
|
||||
const validator = matches('password');
|
||||
expect(validator('abc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone validator', () => {
|
||||
it('returns error for invalid phone', () => {
|
||||
const validator = phone('Invalid phone');
|
||||
expect(validator('abc')).toBe('Invalid phone');
|
||||
});
|
||||
|
||||
it('returns undefined for valid phone', () => {
|
||||
const validator = phone('Invalid phone');
|
||||
// Use a phone format that matches the regex: /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/
|
||||
expect(validator('+15551234567')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
const validator = phone('Invalid phone');
|
||||
expect(validator('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
248
frontend/src/hooks/__tests__/useLocations.test.ts
Normal file
248
frontend/src/hooks/__tests__/useLocations.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
useLocations,
|
||||
useLocation,
|
||||
useCreateLocation,
|
||||
useUpdateLocation,
|
||||
useDeleteLocation,
|
||||
useSetPrimaryLocation,
|
||||
useSetLocationActive,
|
||||
} from '../useLocations';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
// Create wrapper
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useLocations hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useLocations', () => {
|
||||
it('fetches locations and returns data', async () => {
|
||||
const mockLocations = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
is_active: true,
|
||||
is_primary: true,
|
||||
display_order: 0,
|
||||
resource_count: 5,
|
||||
service_count: 10,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Branch Office',
|
||||
city: 'Boulder',
|
||||
state: 'CO',
|
||||
is_active: true,
|
||||
is_primary: false,
|
||||
display_order: 1,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocations });
|
||||
|
||||
const { result } = renderHook(() => useLocations(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/locations/');
|
||||
expect(result.current.data).toHaveLength(2);
|
||||
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
is_primary: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('fetches all locations when includeInactive is true', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
renderHook(() => useLocations({ includeInactive: true }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/locations/?include_inactive=true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLocation', () => {
|
||||
it('fetches a single location by id', async () => {
|
||||
const mockLocation = {
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
is_active: true,
|
||||
is_primary: true,
|
||||
display_order: 0,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocation });
|
||||
|
||||
const { result } = renderHook(() => useLocation(1), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/locations/1/');
|
||||
expect(result.current.data?.name).toBe('Main Office');
|
||||
});
|
||||
|
||||
it('does not fetch when id is undefined', async () => {
|
||||
renderHook(() => useLocation(undefined), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateLocation', () => {
|
||||
it('creates location with correct data', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
||||
|
||||
const { result } = renderHook(() => useCreateLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
name: 'New Location',
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/', {
|
||||
name: 'New Location',
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateLocation', () => {
|
||||
it('updates location with mapped fields', async () => {
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
||||
|
||||
const { result } = renderHook(() => useUpdateLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
updates: {
|
||||
name: 'Updated Office',
|
||||
city: 'Boulder',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/locations/1/', {
|
||||
name: 'Updated Office',
|
||||
city: 'Boulder',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteLocation', () => {
|
||||
it('deletes location by id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useDeleteLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/locations/1/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSetPrimaryLocation', () => {
|
||||
it('sets location as primary', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_primary: true } });
|
||||
|
||||
const { result } = renderHook(() => useSetPrimaryLocation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_primary/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSetLocationActive', () => {
|
||||
it('activates location', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: true } });
|
||||
|
||||
const { result } = renderHook(() => useSetLocationActive(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: 1, isActive: true });
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
|
||||
is_active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivates location', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: false } });
|
||||
|
||||
const { result } = renderHook(() => useSetLocationActive(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: 1, isActive: false });
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
|
||||
is_active: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,9 @@ describe('useResources hooks', () => {
|
||||
maxConcurrentEvents: 2,
|
||||
savedLaneCount: undefined,
|
||||
userCanEditSchedule: false,
|
||||
locationId: null,
|
||||
locationName: null,
|
||||
isMobile: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user