@open-kingdom/shared-frontend-feature-user-management 0.0.2-9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.babelrc +12 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +538 -0
  6. package/dist/lib/components/invitations/AcceptInvitation.component.d.ts +7 -0
  7. package/dist/lib/components/invitations/AcceptInvitation.component.d.ts.map +1 -0
  8. package/dist/lib/components/invitations/InviteUserModal.component.d.ts +7 -0
  9. package/dist/lib/components/invitations/InviteUserModal.component.d.ts.map +1 -0
  10. package/dist/lib/components/invitations/index.d.ts +3 -0
  11. package/dist/lib/components/invitations/index.d.ts.map +1 -0
  12. package/dist/lib/components/shared/ConfirmDialog.component.d.ts +12 -0
  13. package/dist/lib/components/shared/ConfirmDialog.component.d.ts.map +1 -0
  14. package/dist/lib/components/shared/FormField.component.d.ts +10 -0
  15. package/dist/lib/components/shared/FormField.component.d.ts.map +1 -0
  16. package/dist/lib/components/shared/ModalOverlay.component.d.ts +9 -0
  17. package/dist/lib/components/shared/ModalOverlay.component.d.ts.map +1 -0
  18. package/dist/lib/components/shared/RoleBadge.component.d.ts +7 -0
  19. package/dist/lib/components/shared/RoleBadge.component.d.ts.map +1 -0
  20. package/dist/lib/components/shared/StatusCard.component.d.ts +10 -0
  21. package/dist/lib/components/shared/StatusCard.component.d.ts.map +1 -0
  22. package/dist/lib/components/users/UserList.component.d.ts +6 -0
  23. package/dist/lib/components/users/UserList.component.d.ts.map +1 -0
  24. package/dist/lib/styles.d.ts +8 -0
  25. package/dist/lib/styles.d.ts.map +1 -0
  26. package/dist/lib/types.d.ts +9 -0
  27. package/dist/lib/types.d.ts.map +1 -0
  28. package/jest.config.cts +14 -0
  29. package/package.json +27 -0
  30. package/src/index.ts +4 -0
  31. package/src/lib/components/invitations/AcceptInvitation.component.spec.tsx +154 -0
  32. package/src/lib/components/invitations/AcceptInvitation.component.tsx +197 -0
  33. package/src/lib/components/invitations/InviteUserModal.component.spec.tsx +79 -0
  34. package/src/lib/components/invitations/InviteUserModal.component.tsx +121 -0
  35. package/src/lib/components/invitations/index.ts +2 -0
  36. package/src/lib/components/shared/ConfirmDialog.component.spec.tsx +45 -0
  37. package/src/lib/components/shared/ConfirmDialog.component.tsx +58 -0
  38. package/src/lib/components/shared/FormField.component.spec.tsx +50 -0
  39. package/src/lib/components/shared/FormField.component.tsx +34 -0
  40. package/src/lib/components/shared/ModalOverlay.component.spec.tsx +81 -0
  41. package/src/lib/components/shared/ModalOverlay.component.tsx +45 -0
  42. package/src/lib/components/shared/RoleBadge.component.spec.tsx +20 -0
  43. package/src/lib/components/shared/RoleBadge.component.tsx +25 -0
  44. package/src/lib/components/shared/StatusCard.component.spec.tsx +44 -0
  45. package/src/lib/components/shared/StatusCard.component.tsx +47 -0
  46. package/src/lib/components/users/UserList.component.spec.tsx +216 -0
  47. package/src/lib/components/users/UserList.component.tsx +153 -0
  48. package/src/lib/styles.ts +19 -0
  49. package/src/lib/types.ts +9 -0
  50. package/tsconfig.json +13 -0
  51. package/tsconfig.lib.json +47 -0
  52. package/tsconfig.spec.json +27 -0
  53. package/vite.config.mts +58 -0
@@ -0,0 +1,45 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { ConfirmDialog } from './ConfirmDialog.component';
4
+
5
+ describe('ConfirmDialog', () => {
6
+ const defaultProps = {
7
+ isOpen: true,
8
+ title: 'Delete User',
9
+ message: 'Are you sure?',
10
+ confirmLabel: 'Delete',
11
+ onConfirm: jest.fn(),
12
+ onCancel: jest.fn(),
13
+ };
14
+
15
+ it('shows the confirmation message when opened', () => {
16
+ render(<ConfirmDialog {...defaultProps} />);
17
+ expect(screen.getByText('Delete User')).toBeInTheDocument();
18
+ expect(screen.getByText('Are you sure?')).toBeInTheDocument();
19
+ });
20
+
21
+ it('hides the dialog when closed', () => {
22
+ render(<ConfirmDialog {...defaultProps} isOpen={false} />);
23
+ expect(screen.queryByText('Delete User')).not.toBeInTheDocument();
24
+ });
25
+
26
+ it('proceeds with the action when confirmed', () => {
27
+ const onConfirm = jest.fn();
28
+ render(<ConfirmDialog {...defaultProps} onConfirm={onConfirm} />);
29
+ fireEvent.click(screen.getByText('Delete'));
30
+ expect(onConfirm).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it('cancels when clicking Cancel', () => {
34
+ const onCancel = jest.fn();
35
+ render(<ConfirmDialog {...defaultProps} onCancel={onCancel} />);
36
+ fireEvent.click(screen.getByText('Cancel'));
37
+ expect(onCancel).toHaveBeenCalledTimes(1);
38
+ });
39
+
40
+ it('prevents interaction while the action is in progress', () => {
41
+ render(<ConfirmDialog {...defaultProps} isLoading={true} />);
42
+ expect(screen.getByText('Delete')).toBeDisabled();
43
+ expect(screen.getByText('Cancel')).toBeDisabled();
44
+ });
45
+ });
@@ -0,0 +1,58 @@
1
+ import { ModalOverlay } from './ModalOverlay.component';
2
+ import { buttonSecondaryStyles, buttonDangerStyles } from '../../styles';
3
+
4
+ interface ConfirmDialogProps {
5
+ isOpen: boolean;
6
+ title: string;
7
+ message: string;
8
+ confirmLabel?: string;
9
+ onConfirm: () => void;
10
+ onCancel: () => void;
11
+ isLoading?: boolean;
12
+ }
13
+
14
+ export function ConfirmDialog({
15
+ isOpen,
16
+ title,
17
+ message,
18
+ confirmLabel = 'Confirm',
19
+ onConfirm,
20
+ onCancel,
21
+ isLoading = false,
22
+ }: ConfirmDialogProps) {
23
+ const titleId = 'confirm-dialog-title';
24
+
25
+ return (
26
+ <ModalOverlay isOpen={isOpen} onClose={onCancel} ariaLabelledBy={titleId}>
27
+ <h3
28
+ id={titleId}
29
+ className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
30
+ >
31
+ {title}
32
+ </h3>
33
+ <p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
34
+ {message}
35
+ </p>
36
+ <div className="mt-6 flex justify-end gap-3">
37
+ <button
38
+ type="button"
39
+ data-testid="confirm-cancel-btn"
40
+ onClick={onCancel}
41
+ disabled={isLoading}
42
+ className={buttonSecondaryStyles}
43
+ >
44
+ Cancel
45
+ </button>
46
+ <button
47
+ type="button"
48
+ data-testid="confirm-btn"
49
+ onClick={onConfirm}
50
+ disabled={isLoading}
51
+ className={buttonDangerStyles}
52
+ >
53
+ {confirmLabel}
54
+ </button>
55
+ </div>
56
+ </ModalOverlay>
57
+ );
58
+ }
@@ -0,0 +1,50 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { FormField } from './FormField.component';
4
+
5
+ describe('FormField', () => {
6
+ it('displays the field label', () => {
7
+ render(
8
+ <FormField label="Email" htmlFor="email">
9
+ <input id="email" type="email" />
10
+ </FormField>
11
+ );
12
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
13
+ });
14
+
15
+ it('marks required fields with an asterisk', () => {
16
+ render(
17
+ <FormField label="Email" htmlFor="email" required>
18
+ <input id="email" type="email" />
19
+ </FormField>
20
+ );
21
+ expect(screen.getByText('*')).toBeInTheDocument();
22
+ });
23
+
24
+ it('does not mark optional fields', () => {
25
+ render(
26
+ <FormField label="Name" htmlFor="name">
27
+ <input id="name" type="text" />
28
+ </FormField>
29
+ );
30
+ expect(screen.queryByText('*')).not.toBeInTheDocument();
31
+ });
32
+
33
+ it('shows an error message when validation fails', () => {
34
+ render(
35
+ <FormField label="Email" htmlFor="email" error="Invalid email">
36
+ <input id="email" type="email" />
37
+ </FormField>
38
+ );
39
+ expect(screen.getByText('Invalid email')).toBeInTheDocument();
40
+ });
41
+
42
+ it('hides the error message when the field is valid', () => {
43
+ render(
44
+ <FormField label="Email" htmlFor="email">
45
+ <input id="email" type="email" />
46
+ </FormField>
47
+ );
48
+ expect(screen.queryByText('Invalid email')).not.toBeInTheDocument();
49
+ });
50
+ });
@@ -0,0 +1,34 @@
1
+ import { labelStyles } from '../../styles';
2
+
3
+ interface FormFieldProps {
4
+ label: string;
5
+ htmlFor: string;
6
+ required?: boolean;
7
+ error?: string;
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ export function FormField({
12
+ label,
13
+ htmlFor,
14
+ required,
15
+ error,
16
+ children,
17
+ }: FormFieldProps) {
18
+ return (
19
+ <div>
20
+ <label htmlFor={htmlFor} className={labelStyles}>
21
+ {label} {required && <span className="text-error-500">*</span>}
22
+ </label>
23
+ {children}
24
+ {error && (
25
+ <p
26
+ data-testid="field-error"
27
+ className="mt-1 text-sm text-error-600 dark:text-error-400"
28
+ >
29
+ {error}
30
+ </p>
31
+ )}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,81 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { ModalOverlay } from './ModalOverlay.component';
4
+
5
+ describe('ModalOverlay', () => {
6
+ it('shows the content when opened', () => {
7
+ render(
8
+ <ModalOverlay
9
+ isOpen={true}
10
+ onClose={jest.fn()}
11
+ ariaLabelledBy="test-title"
12
+ >
13
+ <p>Modal Content</p>
14
+ </ModalOverlay>
15
+ );
16
+ expect(screen.getByText('Modal Content')).toBeInTheDocument();
17
+ });
18
+
19
+ it('hides the content when closed', () => {
20
+ render(
21
+ <ModalOverlay
22
+ isOpen={false}
23
+ onClose={jest.fn()}
24
+ ariaLabelledBy="test-title"
25
+ >
26
+ <p>Modal Content</p>
27
+ </ModalOverlay>
28
+ );
29
+ expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
30
+ });
31
+
32
+ it('is announced as a dialog for screen readers', () => {
33
+ render(
34
+ <ModalOverlay
35
+ isOpen={true}
36
+ onClose={jest.fn()}
37
+ ariaLabelledBy="test-title"
38
+ >
39
+ <p>Content</p>
40
+ </ModalOverlay>
41
+ );
42
+ const dialog = screen.getByRole('dialog');
43
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
44
+ expect(dialog).toHaveAttribute('aria-labelledby', 'test-title');
45
+ });
46
+
47
+ it('closes when pressing Escape', () => {
48
+ const onClose = jest.fn();
49
+ render(
50
+ <ModalOverlay isOpen={true} onClose={onClose} ariaLabelledBy="test-title">
51
+ <p>Content</p>
52
+ </ModalOverlay>
53
+ );
54
+ fireEvent.keyDown(document, { key: 'Escape' });
55
+ expect(onClose).toHaveBeenCalled();
56
+ });
57
+
58
+ it('closes when clicking outside', () => {
59
+ const onClose = jest.fn();
60
+ render(
61
+ <ModalOverlay isOpen={true} onClose={onClose} ariaLabelledBy="test-title">
62
+ <p>Content</p>
63
+ </ModalOverlay>
64
+ );
65
+ const backdrop = screen.getByRole('dialog').parentElement;
66
+ expect(backdrop).toBeTruthy();
67
+ fireEvent.click(backdrop as HTMLElement);
68
+ expect(onClose).toHaveBeenCalled();
69
+ });
70
+
71
+ it('stays open when clicking inside', () => {
72
+ const onClose = jest.fn();
73
+ render(
74
+ <ModalOverlay isOpen={true} onClose={onClose} ariaLabelledBy="test-title">
75
+ <p>Content</p>
76
+ </ModalOverlay>
77
+ );
78
+ fireEvent.click(screen.getByText('Content'));
79
+ expect(onClose).not.toHaveBeenCalled();
80
+ });
81
+ });
@@ -0,0 +1,45 @@
1
+ import { useCallback } from 'react';
2
+ import { useKeyboardEvent } from '@react-hookz/web';
3
+
4
+ interface ModalOverlayProps {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ ariaLabelledBy: string;
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ export function ModalOverlay({
12
+ isOpen,
13
+ onClose,
14
+ ariaLabelledBy,
15
+ children,
16
+ }: ModalOverlayProps) {
17
+ useKeyboardEvent('Escape', () => {
18
+ if (isOpen) onClose();
19
+ });
20
+
21
+ const dialogRef = useCallback((node: HTMLDivElement | null) => {
22
+ node?.focus();
23
+ }, []);
24
+
25
+ if (!isOpen) return null;
26
+
27
+ return (
28
+ <div
29
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
30
+ onClick={onClose}
31
+ >
32
+ <div
33
+ ref={dialogRef}
34
+ className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-800"
35
+ role="dialog"
36
+ aria-modal="true"
37
+ aria-labelledby={ariaLabelledBy}
38
+ tabIndex={-1}
39
+ onClick={(e) => e.stopPropagation()}
40
+ >
41
+ {children}
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,20 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { RoleBadge } from './RoleBadge.component';
4
+
5
+ describe('RoleBadge', () => {
6
+ it('displays "Admin" for admin users', () => {
7
+ render(<RoleBadge role="admin" />);
8
+ expect(screen.getByText('Admin')).toBeInTheDocument();
9
+ });
10
+
11
+ it('displays "User" for regular users', () => {
12
+ render(<RoleBadge role="user" />);
13
+ expect(screen.getByText('User')).toBeInTheDocument();
14
+ });
15
+
16
+ it('displays "Guest" for guest users', () => {
17
+ render(<RoleBadge role="guest" />);
18
+ expect(screen.getByText('Guest')).toBeInTheDocument();
19
+ });
20
+ });
@@ -0,0 +1,25 @@
1
+ import type { Role } from '../../types';
2
+
3
+ const roleStyles: Record<Role, string> = {
4
+ admin:
5
+ 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
6
+ user: 'bg-primary-100 text-primary-800 dark:bg-primary-900/30 dark:text-primary-300',
7
+ guest:
8
+ 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300',
9
+ };
10
+
11
+ interface RoleBadgeProps {
12
+ role: Role;
13
+ }
14
+
15
+ export function RoleBadge({ role }: RoleBadgeProps) {
16
+ if (!role) return null;
17
+ const label = role.charAt(0).toUpperCase() + role.slice(1);
18
+ return (
19
+ <span
20
+ className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${roleStyles[role]}`}
21
+ >
22
+ {label}
23
+ </span>
24
+ );
25
+ }
@@ -0,0 +1,44 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { StatusCard } from './StatusCard.component';
4
+
5
+ describe('StatusCard', () => {
6
+ it('shows a loading message', () => {
7
+ render(<StatusCard variant="loading" message="Loading..." />);
8
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
9
+ });
10
+
11
+ it('announces errors to screen readers', () => {
12
+ render(
13
+ <StatusCard
14
+ variant="error"
15
+ title="Error"
16
+ message="Something went wrong"
17
+ />
18
+ );
19
+ expect(screen.getByRole('alert')).toBeInTheDocument();
20
+ expect(screen.getByText('Error')).toBeInTheDocument();
21
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
22
+ });
23
+
24
+ it('shows a success message', () => {
25
+ render(<StatusCard variant="success" title="Done" message="All good" />);
26
+ expect(screen.getByText('Done')).toBeInTheDocument();
27
+ expect(screen.getByText('All good')).toBeInTheDocument();
28
+ });
29
+
30
+ it('displays additional content like links', () => {
31
+ render(
32
+ <StatusCard variant="success" title="Done" message="All good">
33
+ <a href="/login">Go to login</a>
34
+ </StatusCard>
35
+ );
36
+ expect(screen.getByText('Go to login')).toBeInTheDocument();
37
+ });
38
+
39
+ it('shows only the message when no title is given', () => {
40
+ render(<StatusCard variant="error" message="Something went wrong" />);
41
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
42
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
43
+ });
44
+ });
@@ -0,0 +1,47 @@
1
+ import { cardStyles, bodyTextStyles } from '../../styles';
2
+
3
+ type StatusVariant = 'loading' | 'error' | 'success';
4
+
5
+ interface StatusCardProps {
6
+ variant: StatusVariant;
7
+ title?: string;
8
+ message: string;
9
+ children?: React.ReactNode;
10
+ }
11
+
12
+ const headingStyles: Record<Exclude<StatusVariant, 'loading'>, string> = {
13
+ error: 'text-xl font-bold text-error-700 dark:text-error-400',
14
+ success: 'text-xl font-bold text-success-700 dark:text-success-400',
15
+ };
16
+
17
+ export function StatusCard({
18
+ variant,
19
+ title,
20
+ message,
21
+ children,
22
+ }: StatusCardProps) {
23
+ if (variant === 'loading') {
24
+ return (
25
+ <div className={cardStyles}>
26
+ <p className={`text-center ${bodyTextStyles}`}>{message}</p>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ return (
32
+ <div
33
+ className={cardStyles}
34
+ role={variant === 'error' ? 'alert' : undefined}
35
+ >
36
+ {title && (
37
+ <h2 data-testid="status-card-title" className={headingStyles[variant]}>
38
+ {title}
39
+ </h2>
40
+ )}
41
+ <p data-testid="status-card-message" className={`mt-2 ${bodyTextStyles}`}>
42
+ {message}
43
+ </p>
44
+ {children}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,216 @@
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { Provider } from 'react-redux';
4
+ import { configureStore } from '@reduxjs/toolkit';
5
+ import type { ColDef } from 'ag-grid-community';
6
+ import { UserList } from './UserList.component';
7
+
8
+ const mockFindAllQuery = jest.fn();
9
+ const mockDeleteUser = jest.fn();
10
+
11
+ jest.mock('@open-kingdom/shared-frontend-data-access-api-client', () => ({
12
+ useUsersControllerFindAllQuery: () => mockFindAllQuery(),
13
+ useUsersControllerDeleteMutation: () => [
14
+ mockDeleteUser,
15
+ { isLoading: false },
16
+ ],
17
+ useInvitationsControllerInviteMutation: () => [
18
+ jest.fn(),
19
+ { isLoading: false, error: null },
20
+ ],
21
+ }));
22
+
23
+ jest.mock('@open-kingdom/shared-frontend-data-access-notifications', () => ({
24
+ showSuccessNotification: jest.fn((msg: string) => ({
25
+ type: 'notify',
26
+ payload: msg,
27
+ })),
28
+ }));
29
+
30
+ jest.mock('@open-kingdom/shared-frontend-ui-theme', () => ({
31
+ __esModule: true,
32
+ useTheme: () => ({ theme: {}, mode: 'light' }),
33
+ }));
34
+
35
+ let capturedColumnDefs: ColDef[] = [];
36
+ jest.mock('@open-kingdom/shared-frontend-ui-datagrid', () => ({
37
+ __esModule: true,
38
+ DataGrid: ({
39
+ rowData,
40
+ loading,
41
+ columnDefs,
42
+ }: {
43
+ rowData: Record<string, unknown>[];
44
+ loading: boolean;
45
+ columnDefs: ColDef[];
46
+ }) => {
47
+ capturedColumnDefs = columnDefs;
48
+ const rows = rowData ?? [];
49
+ return (
50
+ <div data-testid="data-grid">
51
+ {loading && <span>Loading...</span>}
52
+ {!loading &&
53
+ rows.map((row, i) => (
54
+ <div key={i} data-testid="grid-row">
55
+ {columnDefs.map((col, j) => {
56
+ const value = col.valueGetter
57
+ ? (col.valueGetter as CallableFunction)({ data: row })
58
+ : (row as Record<string, unknown>)[col.field as string];
59
+ const rendered = col.cellRenderer
60
+ ? (col.cellRenderer as CallableFunction)({ data: row })
61
+ : value;
62
+ return <span key={j}>{rendered}</span>;
63
+ })}
64
+ </div>
65
+ ))}
66
+ </div>
67
+ );
68
+ },
69
+ }));
70
+
71
+ jest.mock('../shared/ConfirmDialog.component', () => ({
72
+ __esModule: true,
73
+ ConfirmDialog: ({
74
+ isOpen,
75
+ title,
76
+ onConfirm,
77
+ onCancel,
78
+ }: {
79
+ isOpen: boolean;
80
+ title: string;
81
+ message: string;
82
+ confirmLabel: string;
83
+ onConfirm: () => void;
84
+ onCancel: () => void;
85
+ isLoading: boolean;
86
+ }) =>
87
+ isOpen ? (
88
+ <div data-testid="confirm-dialog">
89
+ <span>{title}</span>
90
+ <button onClick={onConfirm}>Confirm</button>
91
+ <button onClick={onCancel}>Cancel</button>
92
+ </div>
93
+ ) : null,
94
+ }));
95
+
96
+ jest.mock('../shared/RoleBadge.component', () => ({
97
+ __esModule: true,
98
+ RoleBadge: ({ role }: { role: string }) => (
99
+ <span data-testid="role-badge">{role}</span>
100
+ ),
101
+ }));
102
+
103
+ jest.mock('../invitations', () => ({
104
+ __esModule: true,
105
+ InviteUserModal: () => null,
106
+ }));
107
+
108
+ const store = configureStore({ reducer: {} });
109
+
110
+ function renderWithProviders(ui: React.ReactElement) {
111
+ return render(<Provider store={store}>{ui}</Provider>);
112
+ }
113
+
114
+ const mockUsers = [
115
+ {
116
+ id: 1,
117
+ email: 'admin@test.com',
118
+ firstName: 'Admin',
119
+ lastName: 'User',
120
+ role: 'admin',
121
+ },
122
+ {
123
+ id: 2,
124
+ email: 'guest@test.com',
125
+ firstName: null,
126
+ lastName: null,
127
+ role: 'guest',
128
+ },
129
+ ];
130
+
131
+ describe('UserList', () => {
132
+ beforeEach(() => {
133
+ mockDeleteUser.mockReset();
134
+ mockDeleteUser.mockReturnValue({ unwrap: () => Promise.resolve() });
135
+ capturedColumnDefs = [];
136
+ });
137
+
138
+ it('shows a loading indicator while fetching users', () => {
139
+ mockFindAllQuery.mockReturnValue({
140
+ data: undefined,
141
+ isLoading: true,
142
+ error: null,
143
+ refetch: jest.fn(),
144
+ });
145
+ renderWithProviders(<UserList />);
146
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
147
+ });
148
+
149
+ it('shows the user list with an invite button', () => {
150
+ mockFindAllQuery.mockReturnValue({
151
+ data: mockUsers,
152
+ isLoading: false,
153
+ error: null,
154
+ refetch: jest.fn(),
155
+ });
156
+ renderWithProviders(<UserList />);
157
+ expect(screen.getByText('Users')).toBeInTheDocument();
158
+ expect(screen.getByText('Invite User')).toBeInTheDocument();
159
+ });
160
+
161
+ it('shows an error with a retry option when loading fails', () => {
162
+ mockFindAllQuery.mockReturnValue({
163
+ data: undefined,
164
+ isLoading: false,
165
+ error: { status: 500 },
166
+ refetch: jest.fn(),
167
+ });
168
+ renderWithProviders(<UserList />);
169
+ expect(screen.getByText('Failed to load users.')).toBeInTheDocument();
170
+ expect(screen.getByText('Try again')).toBeInTheDocument();
171
+ });
172
+
173
+ it('retries loading users when clicking "Try again"', () => {
174
+ const refetch = jest.fn();
175
+ mockFindAllQuery.mockReturnValue({
176
+ data: undefined,
177
+ isLoading: false,
178
+ error: { status: 500 },
179
+ refetch,
180
+ });
181
+ renderWithProviders(<UserList />);
182
+ fireEvent.click(screen.getByText('Try again'));
183
+ expect(refetch).toHaveBeenCalled();
184
+ });
185
+
186
+ it('asks for confirmation before deleting a user', async () => {
187
+ mockFindAllQuery.mockReturnValue({
188
+ data: mockUsers,
189
+ isLoading: false,
190
+ error: null,
191
+ refetch: jest.fn(),
192
+ });
193
+ renderWithProviders(<UserList currentUserId={1} />);
194
+
195
+ const actionsCol = capturedColumnDefs.find(
196
+ (c) => c.headerName === 'Actions'
197
+ );
198
+ const { container } = render(
199
+ <Provider store={store}>
200
+ {(actionsCol?.cellRenderer as CallableFunction)({ data: mockUsers[1] })}
201
+ </Provider>
202
+ );
203
+ const deleteBtn = container.querySelector('button');
204
+ expect(deleteBtn).toBeTruthy();
205
+ fireEvent.click(deleteBtn as HTMLElement);
206
+
207
+ await waitFor(() => {
208
+ expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument();
209
+ });
210
+
211
+ fireEvent.click(screen.getByText('Confirm'));
212
+ await waitFor(() => {
213
+ expect(mockDeleteUser).toHaveBeenCalledWith({ id: 2 });
214
+ });
215
+ });
216
+ });