diff --git a/CLAUDE.md b/CLAUDE.md index 04999fc..febe28a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,5 +14,6 @@ All relevant implementation specs can be found in `docs/specs`. These must be ma 3. Wait for confirmation before proceeding - After implementing changes, always run npm run build to ensure that builds are not broken. - use `npm run test:server` and `npm run test:client` from project root when attempting to test either. +- When asked to help write a pr description, always use the template from `.github/` and put it in `pr.md` When being asked to handle linear tasks, refer to `LINEAR.md` for general instructions. \ No newline at end of file diff --git a/client/src/components/pages/Teams/TeamDetail.test.tsx b/client/src/components/pages/Teams/TeamDetail.test.tsx index 6ba8d74..b485f90 100644 --- a/client/src/components/pages/Teams/TeamDetail.test.tsx +++ b/client/src/components/pages/Teams/TeamDetail.test.tsx @@ -45,10 +45,38 @@ jest.mock('./AlertMutes', () => { AlertMutes.displayName = 'AlertMutes'; return AlertMutes; }); -jest.mock('./ManifestStatusCard', () => { - const ManifestStatusCard = () =>
; - ManifestStatusCard.displayName = 'ManifestStatusCard'; - return ManifestStatusCard; +// Mock useManifestConfig +const mockLoadManifestConfig = jest.fn(); +const mockUseManifestConfig = jest.fn(); +jest.mock('../../../hooks/useManifestConfig', () => ({ + useManifestConfig: (...args: unknown[]) => mockUseManifestConfig(...args), +})); + +// Mock manifest sub-components +jest.mock('../Manifest/ManifestConfig', () => { + const ManifestConfig = () => ; + ManifestConfig.displayName = 'ManifestConfig'; + return ManifestConfig; +}); +jest.mock('../Manifest/ManifestSyncResult', () => { + const ManifestSyncResult = () => ; + ManifestSyncResult.displayName = 'ManifestSyncResult'; + return ManifestSyncResult; +}); +jest.mock('../Manifest/DriftReview', () => { + const DriftReview = () => ; + DriftReview.displayName = 'DriftReview'; + return DriftReview; +}); +jest.mock('../Manifest/SyncHistory', () => { + const SyncHistory = () => ; + SyncHistory.displayName = 'SyncHistory'; + return SyncHistory; +}); +jest.mock('../Manifest/ServiceKeyLookup', () => { + const ServiceKeyLookup = () => ; + ServiceKeyLookup.displayName = 'ServiceKeyLookup'; + return ServiceKeyLookup; }); function jsonResponse(data: unknown, status = 200) { @@ -97,6 +125,22 @@ beforeEach(() => { mockFetch.mockReset(); mockUseAuth.mockReset(); mockNavigate.mockReset(); + mockLoadManifestConfig.mockReset(); + mockUseManifestConfig.mockReturnValue({ + config: null, + isLoading: false, + error: null, + isSaving: false, + isSyncing: false, + syncResult: null, + loadConfig: mockLoadManifestConfig, + saveConfig: jest.fn(), + removeConfig: jest.fn(), + toggleEnabled: jest.fn(), + triggerSync: jest.fn(), + clearError: jest.fn(), + clearSyncResult: jest.fn(), + }); localStorage.clear(); }); @@ -703,7 +747,47 @@ describe('TeamDetail', () => { }); describe('manifests tab', () => { - it('renders ManifestStatusCard', async () => { + it('renders empty state when no manifest configured', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail('t1', false, 'manifests'); + + await waitFor(() => { + expect(screen.getByText(/No manifest configured/)).toBeInTheDocument(); + }); + }); + + it('renders manifest configuration when config exists', async () => { + mockUseManifestConfig.mockReturnValue({ + config: { + id: 'mc1', + team_id: 't1', + manifest_url: 'https://example.com/manifest.json', + is_enabled: 1, + sync_policy: null, + last_sync_at: '2024-06-01T00:00:00Z', + last_sync_status: 'success', + last_sync_error: null, + last_sync_summary: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + isLoading: false, + error: null, + isSaving: false, + isSyncing: false, + syncResult: null, + loadConfig: mockLoadManifestConfig, + saveConfig: jest.fn(), + removeConfig: jest.fn(), + toggleEnabled: jest.fn(), + triggerSync: jest.fn(), + clearError: jest.fn(), + clearSyncResult: jest.fn(), + }); + mockFetch .mockResolvedValueOnce(jsonResponse(mockTeam)) .mockResolvedValueOnce(jsonResponse([])); @@ -711,8 +795,12 @@ describe('TeamDetail', () => { renderTeamDetail('t1', false, 'manifests'); await waitFor(() => { - expect(screen.getByTestId('manifest-status-card')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-config')).toBeInTheDocument(); }); + expect(screen.getByTestId('manifest-sync-result')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-drift-review')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-sync-history')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-service-key-lookup')).toBeInTheDocument(); }); }); diff --git a/client/src/components/pages/Teams/TeamDetail.tsx b/client/src/components/pages/Teams/TeamDetail.tsx index 1b9bd72..96be7a0 100644 --- a/client/src/components/pages/Teams/TeamDetail.tsx +++ b/client/src/components/pages/Teams/TeamDetail.tsx @@ -3,7 +3,9 @@ import { useParams, Link } from 'react-router-dom'; import { ChevronLeft, Pencil, Trash2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { useTeamDetail, useTeamMembers } from '../../../hooks/useTeamDetail'; +import { useManifestConfig } from '../../../hooks/useManifestConfig'; import { parseContact } from '../../../utils/dependency'; +import { formatRelativeTime } from '../../../utils/formatting'; import Modal from '../../common/Modal'; import ConfirmDialog from '../../common/ConfirmDialog'; import { Tabs, TabList, Tab, TabPanel } from '../../common/Tabs'; @@ -13,8 +15,14 @@ import AlertRules from './AlertRules'; import AlertHistory from './AlertHistory'; import AlertMutes from './AlertMutes'; import TeamOverviewStats from './TeamOverviewStats'; -import ManifestStatusCard from './ManifestStatusCard'; +import ManifestConfig from '../Manifest/ManifestConfig'; +import ManifestSyncResult from '../Manifest/ManifestSyncResult'; +import DriftReview from '../Manifest/DriftReview'; +import SyncHistory from '../Manifest/SyncHistory'; +import ServiceKeyLookup from '../Manifest/ServiceKeyLookup'; import { useAlertChannels } from '../../../hooks/useAlertChannels'; +import cardStyles from '../../common/SummaryCards.module.css'; +import manifestStyles from '../Manifest/ManifestPage.module.css'; import styles from './Teams.module.css'; function TeamDetail() { @@ -30,6 +38,24 @@ function TeamDetail() { const { channels: alertChannels, loadChannels: loadAlertChannels } = useAlertChannels(id); + const { + config: manifestConfig, + isLoading: manifestLoading, + error: manifestError, + isSaving: manifestSaving, + isSyncing: manifestSyncing, + syncResult: manifestSyncResult, + loadConfig: loadManifestConfig, + saveConfig: saveManifestConfig, + removeConfig: removeManifestConfig, + toggleEnabled: toggleManifestEnabled, + triggerSync: triggerManifestSync, + clearError: clearManifestError, + clearSyncResult: clearManifestSyncResult, + } = useManifestConfig(id); + + const [isManifestCreating, setIsManifestCreating] = useState(false); + const { team, availableUsers, @@ -60,7 +86,8 @@ function TeamDetail() { useEffect(() => { loadTeam(); loadAlertChannels(); - }, [loadTeam, loadAlertChannels]); + loadManifestConfig(); + }, [loadTeam, loadAlertChannels, loadManifestConfig]); /* istanbul ignore next -- @preserve handleEditSuccess is triggered by TeamForm onSuccess inside a Modal. @@ -146,26 +173,6 @@ function TeamDetail() {{team.key}
- )}
- {team.description && (
- {team.description}
- )} - {team.contact && (() => { - const contactData = parseContact(team.contact); - if (!contactData) return null; - return ( -{team.key}
+ + No manifest configured for this team. A manifest lets you declaratively define services, aliases, + and associations using a JSON file. Changes are automatically synced and manual edits are detected as drift. +
+ {canManageAlerts && ( + + )} +