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:
poduck
2025-12-12 21:00:54 -05:00
parent d25c578e59
commit b384d9912a
183 changed files with 47627 additions and 3955 deletions

View 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();
});
});
});

View 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,
});
});
});
});

View File

@@ -67,6 +67,9 @@ describe('useResources hooks', () => {
maxConcurrentEvents: 2,
savedLaneCount: undefined,
userCanEditSchedule: false,
locationId: null,
locationName: null,
isMobile: false,
});
});