diff --git a/src/ui/components/ResponsiveTable/index.tsx b/src/ui/components/ResponsiveTable/index.tsx new file mode 100644 index 00000000..34b6076c --- /dev/null +++ b/src/ui/components/ResponsiveTable/index.tsx @@ -0,0 +1,275 @@ +import { + Table, + Card, + Text, + SimpleGrid, + Stack, + UnstyledButton, + Center, + Box, + type MantineSpacing, +} from "@mantine/core"; +import { + IconChevronUp, + IconChevronDown, + IconSelector, +} from "@tabler/icons-react"; +import React, { useState } from "react"; + +// Types +export interface Column { + key: string; + label: string; + sortable?: boolean; + render: (item: T) => React.ReactNode; + mobileLabel?: string; // Optional different label for mobile + hideMobileLabel?: boolean; // Hide label in mobile card view + mobileLabelStyle?: React.CSSProperties; // Custom styles for mobile label + cardColumn?: number; // Which column this field should appear in (1 or 2), defaults to auto-flow + isPrimaryColumn?: boolean; // Bold and emphasize this column in mobile cards +} + +export interface ResponsiveTableProps { + data: T[]; + columns: Column[]; + keyExtractor: (item: T) => string; + onSort?: (key: string) => void; + sortBy?: string | null; + sortReversed?: boolean; + testIdPrefix?: string; + onRowClick?: (item: T) => void; + mobileBreakpoint?: number; // px value, defaults to 768 + mobileLabelStyle?: React.CSSProperties; // Default style for all mobile labels + padding?: MantineSpacing; + mobileColumns?: + | number + | { base?: number; xs?: number; sm?: number; md?: number }; // Grid columns for mobile cards + cardColumns?: number | { base?: number; xs?: number; sm?: number }; // Columns inside each card + testId?: string; // Table data-testId +} + +interface ThProps { + children: React.ReactNode; + reversed: boolean; + sorted: boolean; + onSort: () => void; +} + +function Th({ children, reversed, sorted, onSort }: ThProps) { + const Icon = sorted + ? reversed + ? IconChevronUp + : IconChevronDown + : IconSelector; + + return ( + + + + {children} + +
+ +
+
+
+ ); +} + +export function ResponsiveTable({ + data, + columns, + keyExtractor, + onSort, + sortBy = null, + sortReversed = false, + testIdPrefix, + onRowClick, + mobileBreakpoint = 768, + mobileLabelStyle = { + fontSize: "0.875rem", + color: "#868e96", + fontWeight: 600, + marginBottom: "4px", + }, + padding, + mobileColumns = { base: 1, sm: 2 }, + cardColumns = { base: 1, xs: 2 }, + testId, +}: ResponsiveTableProps) { + const realPadding = padding || "sm"; + const [isMobile, setIsMobile] = useState( + window.innerWidth < mobileBreakpoint, + ); + + React.useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < mobileBreakpoint); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [mobileBreakpoint]); + + const handleSort = (key: string) => { + if (onSort) { + onSort(key); + } + }; + + // Desktop table view + if (!isMobile) { + return ( + + + + {columns.map((column) => { + if (column.sortable && onSort) { + return ( + + ); + } + return ( + + + {column.label} + + + ); + })} + + + + {data.map((item) => { + const key = keyExtractor(item); + return ( + onRowClick(item) : undefined} + data-testid={ + testIdPrefix ? `${testIdPrefix}-${key}` : undefined + } + > + {columns.map((column) => ( + + {column.render(item)} + + ))} + + ); + })} + +
handleSort(column.key)} + > + {column.label} +
+ ); + } + + // Mobile card view with responsive grid + return ( + + {data.map((item) => { + const key = keyExtractor(item); + return ( + onRowClick(item) : undefined} + data-testid={testIdPrefix ? `${testIdPrefix}-${key}` : undefined} + > + + {columns.map((column) => { + const mobileLabel = column.mobileLabel || column.label; + const showLabel = !column.hideMobileLabel; + const labelStyle = column.mobileLabelStyle || mobileLabelStyle; + const isPrimary = column.isPrimaryColumn; + + return ( + + {showLabel && ( + + {mobileLabel} + + )} + + {column.render(item)} + + + ); + })} + + + ); + })} + + ); +} + +// Hook for sorting logic +export function useTableSort(initialSortBy: string | null = null): { + sortBy: string | null; + reversedSort: boolean; + handleSort: (field: string) => void; + sortData: (data: T[], sortFn: (a: T, b: T, sortBy: string) => number) => T[]; +} { + const [sortBy, setSortBy] = useState(initialSortBy); + const [reversedSort, setReversedSort] = useState(false); + + const handleSort = (field: string) => { + if (sortBy === field) { + setReversedSort((r) => !r); + } else { + setSortBy(field); + setReversedSort(false); + } + }; + + const sortData = ( + data: T[], + sortFn: (a: T, b: T, sortBy: string) => number, + ): T[] => { + if (!sortBy) { + return data; + } + + return [...data].sort((a, b) => { + const comparison = sortFn(a, b, sortBy); + return reversedSort ? -comparison : comparison; + }); + }; + + return { sortBy, reversedSort, handleSort, sortData }; +} diff --git a/src/ui/pages/apiKeys/ManageKeysTable.test.tsx b/src/ui/pages/apiKeys/ManageKeysTable.test.tsx index 0a489017..6bec2c1e 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.test.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.test.tsx @@ -99,7 +99,7 @@ describe("OrgApiKeyTable Tests", () => { }); it("renders the table headers correctly", async () => { - getApiKeys.mockResolvedValue([]); + getApiKeys.mockResolvedValue(mockApiKeys); await renderComponent(); expect(screen.getByText("Key ID")).toBeInTheDocument(); @@ -139,7 +139,11 @@ describe("OrgApiKeyTable Tests", () => { await renderComponent(); await waitFor(() => { - expect(screen.getByText("No API keys found.")).toBeInTheDocument(); + expect( + screen.getByText( + `No API keys found. Click "Create API Key" to get started.`, + ), + ).toBeInTheDocument(); }); }); @@ -172,8 +176,8 @@ describe("OrgApiKeyTable Tests", () => { const checkboxes = screen.getAllByRole("checkbox"); expect(checkboxes.length).toBeGreaterThan(1); // Header + rows - // Select first row - await user.click(checkboxes[1]); // First row checkbox (index 0 is header) + // Select first data row (skip the header checkbox at index 0) + await user.click(checkboxes[1]); // Delete button should appear with count expect(screen.getByText(/Delete 1 API Key/)).toBeInTheDocument(); @@ -185,7 +189,7 @@ describe("OrgApiKeyTable Tests", () => { expect(screen.queryByText(/Delete 1 API Key/)).not.toBeInTheDocument(); }); - it("allows selecting all rows with header checkbox", async () => { + it("allows selecting all rows with Select All button", async () => { getApiKeys.mockResolvedValue(mockApiKeys); await renderComponent(); const user = userEvent.setup(); @@ -195,22 +199,24 @@ describe("OrgApiKeyTable Tests", () => { expect(screen.getByText("acmuiuc_key123")).toBeInTheDocument(); }); - // Check that header checkbox exists - const headerCheckbox = screen.getAllByRole("checkbox")[0]; // Header checkbox - expect(headerCheckbox).toBeInTheDocument(); + // Find and click the "Select All" button + const selectAllButton = screen.getByRole("button", { name: /Select All/i }); + expect(selectAllButton).toBeInTheDocument(); - // Click header checkbox await act(async () => { - await user.click(headerCheckbox); + await user.click(selectAllButton); }); // Delete button should show count of all rows const deleteButton = await screen.findByText(/Delete 2 API Keys/); expect(deleteButton).toBeInTheDocument(); - // Uncheck all + // Click "Deselect All" button + const deselectAllButton = screen.getByRole("button", { + name: /Deselect All/i, + }); await act(async () => { - await user.click(headerCheckbox); + await user.click(deselectAllButton); }); // Delete button should be gone diff --git a/src/ui/pages/apiKeys/ManageKeysTable.tsx b/src/ui/pages/apiKeys/ManageKeysTable.tsx index f2ee41c8..f8762aef 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.tsx @@ -2,7 +2,6 @@ import { Alert, Badge, Button, - Center, Checkbox, Code, CopyButton, @@ -12,7 +11,7 @@ import { Modal, MultiSelect, Skeleton, - Table, + Stack, Text, TextInput, } from "@mantine/core"; @@ -38,6 +37,7 @@ import dayjs from "dayjs"; import { AppRoles } from "@common/roles"; import { BlurredTextDisplay } from "../../components/BlurredTextDisplay"; import * as z from "zod/v4"; +import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; const HumanFriendlyDate = ({ date }: { date: number }) => { return ( @@ -51,6 +51,10 @@ interface OrgApiKeyTableProps { createApiKey: (data: ApiKeyPostBody) => Promise<{ apiKey: string }>; } +interface DisplayApiKey extends ApiKeyMaskedEntry { + isSelected: boolean; +} + export const OrgApiKeyTable: React.FC = ({ getApiKeys, deleteApiKeys, @@ -61,10 +65,8 @@ export const OrgApiKeyTable: React.FC = ({ const [isLoading, setIsLoading] = useState(true); const [createModalOpen, setCreateModalOpen] = useState(false); const [createdKey, setCreatedKey] = useState(null); - // New state for delete confirmation modal const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [idsToDelete, setIdsToDelete] = useState([]); - // New state for view permissions modal const [viewPermissionsModalOpen, setViewPermissionsModalOpen] = useState(false); const [selectedKeyForPermissions, setSelectedKeyForPermissions] = @@ -107,18 +109,15 @@ export const OrgApiKeyTable: React.FC = ({ icon: , }); } finally { - // Close the modal after deletion attempt setDeleteModalOpen(false); } }; - // New function to open the delete confirmation modal const confirmDelete = (ids: string[]) => { setIdsToDelete(ids); setDeleteModalOpen(true); }; - // New function to open the view permissions modal const openViewPermissionsModal = (key: ApiKeyMaskedEntry) => { setSelectedKeyForPermissions(key); setViewPermissionsModalOpen(true); @@ -143,61 +142,120 @@ export const OrgApiKeyTable: React.FC = ({ } }; - const createRow = (entry: ApiKeyMaskedEntry) => ( - - + const handleSelectRow = (keyId: string, checked: boolean) => { + setSelected( + checked ? [...selected, keyId] : selected.filter((id) => id !== keyId), + ); + }; + + const handleSelectAll = () => { + if (!apiKeys) { + return; + } + if (selected.length === apiKeys.length) { + setSelected([]); + } else { + setSelected(apiKeys.map((k) => k.keyId)); + } + }; + + // Create Form State + const [roles, setRoles] = useState([]); + const [description, setDescription] = useState(""); + const [expiresAt, setExpiresAt] = useState(null); + const [policyDocument, setPolicyDocument] = useState(""); + + const displayApiKeys: DisplayApiKey[] = apiKeys + ? apiKeys.map((key) => ({ + ...key, + isSelected: selected.includes(key.keyId), + })) + : []; + + // Define columns for API keys table + const apiKeyColumns: Column[] = [ + { + key: "select", + label: "Select", + hideMobileLabel: true, + render: (key) => ( - setSelected( - event.currentTarget.checked - ? [...selected, entry.keyId] - : selected.filter((id) => id !== entry.keyId), - ) + handleSelectRow(key.keyId, event.currentTarget.checked) } /> - - - acmuiuc_{entry.keyId} - - {entry.description} - - {entry.owner === userData?.email ? "You" : entry.owner} - - - - - - {entry.expiresAt ? ( - + ), + }, + { + key: "keyId", + label: "Key ID", + isPrimaryColumn: true, + render: (key) => acmuiuc_{key.keyId}, + }, + { + key: "description", + label: "Description", + render: (key) => ( + + {key.description} + + ), + }, + { + key: "owner", + label: "Owner", + render: (key) => ( + + {key.owner === userData?.email ? "You" : key.owner} + + ), + }, + { + key: "created", + label: "Created", + render: (key) => , + }, + { + key: "expires", + label: "Expires", + render: (key) => + key.expiresAt ? ( + ) : ( Never - )} - - - - - - - - ); + ), + }, + { + key: "permissions", + label: "Permissions", + hideMobileLabel: true, + render: (key) => ( + + ), + }, + ]; - // --- Create Form State --- - const [roles, setRoles] = useState([]); - const [description, setDescription] = useState(""); - const [expiresAt, setExpiresAt] = useState(null); - const [policyDocument, setPolicyDocument] = useState(""); + const skeletonRows = Array.from({ length: 3 }).map((_, index) => ( + + )); return ( - <> - + + + {selected.length > 0 && ( - + )} - + ); }; diff --git a/src/ui/pages/events/ManageEvent.page.tsx b/src/ui/pages/events/ManageEvent.page.tsx index e68de70f..7d294943 100644 --- a/src/ui/pages/events/ManageEvent.page.tsx +++ b/src/ui/pages/events/ManageEvent.page.tsx @@ -40,6 +40,8 @@ import { import { zod4Resolver as zodResolver } from "mantine-form-zod-resolver"; import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen"; +import { useAuth } from "@ui/components/AuthContext"; +import { getPrimarySuggestedOrg } from "@ui/util"; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } @@ -108,6 +110,8 @@ export const ManageEventPage: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const navigate = useNavigate(); const api = useApi("core"); + const { orgRoles } = useAuth(); + const userPrimaryOrg = getPrimarySuggestedOrg(orgRoles); const { eventId } = useParams(); @@ -172,7 +176,7 @@ export const ManageEventPage: React.FC = () => { end: new Date(startDate + 3.6e6), location: "ACM Room (Siebel CS 1104)", locationLink: "https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8", - host: "ACM", + host: userPrimaryOrg, featured: false, repeats: undefined, repeatEnds: undefined, @@ -407,6 +411,7 @@ export const ManageEventPage: React.FC = () => { ({ value: option, label: capitalizeFirstLetter(option), diff --git a/src/ui/pages/events/ViewEvents.page.tsx b/src/ui/pages/events/ViewEvents.page.tsx index e06779e6..9d0c7b39 100644 --- a/src/ui/pages/events/ViewEvents.page.tsx +++ b/src/ui/pages/events/ViewEvents.page.tsx @@ -1,7 +1,6 @@ import { Text, Button, - Table, Modal, Group, ButtonGroup, @@ -16,13 +15,14 @@ import { IconPlus, IconTrash } from "@tabler/icons-react"; import dayjs from "dayjs"; import React, { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import * as z from "zod/v4"; import { capitalizeFirstLetter } from "./ManageEvent.page.js"; import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen"; import { AuthGuard } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles.js"; +import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import * as z from "zod/v4"; const repeatOptions = ["weekly", "biweekly"] as const; @@ -61,7 +61,7 @@ export const ViewEventsPage: React.FC = () => { useState(null); const navigate = useNavigate(); - // Use useMemo to sort and filter events only when dependencies change + // Sorted events const sortedUpcomingEvents = useMemo(() => { return eventList .filter((event: EventGetResponse) => event.upcoming) @@ -71,22 +71,19 @@ export const ViewEventsPage: React.FC = () => { ); }, [eventList]); - // Use useMemo to sort and filter previous events only when dependencies change const sortedPreviousEvents = useMemo(() => { return eventList .filter((event: EventGetResponse) => !event.upcoming) .sort((a: EventGetResponse, b: EventGetResponse) => { - // For repeating events, compare by repeatEnds date first (if available) if (a.repeatEnds && b.repeatEnds) { return ( new Date(b.repeatEnds).getTime() - new Date(a.repeatEnds).getTime() ); } else if (a.repeatEnds) { - return -1; // a has repeatEnds, b doesn't, so a comes first + return -1; } else if (b.repeatEnds) { - return 1; // b has repeatEnds, a doesn't, so b comes first + return 1; } - // Otherwise sort by start date in reverse order (newest first) return new Date(b.start).getTime() - new Date(a.start).getTime(); }); }, [eventList]); @@ -94,7 +91,6 @@ export const ViewEventsPage: React.FC = () => { useEffect(() => { const getEvents = async () => { try { - // Setting ts lets us tell cloudfront I want fresh data const response = await api.get(`/api/v1/events?ts=${Date.now()}`); const upcomingEvents = await api.get( `/api/v1/events?upcomingOnly=true&ts=${Date.now()}`, @@ -151,51 +147,81 @@ export const ViewEventsPage: React.FC = () => { } }; - const renderEvent = (event: EventGetResponse) => { - return ( - - + // Define columns for ResponsiveTable + const columns: Column[] = [ + { + key: "title", + label: "Title", + isPrimaryColumn: true, + render: (event) => ( + <> {event.title}{" "} {event.featured ? Featured : null} - - {dayjs(event.start).format("MMM D YYYY hh:mm A")} - - {event.end ? dayjs(event.end).format("MMM D YYYY hh:mm A") : "N/A"} - - - {event.locationLink ? ( - - {event.location} - - ) : ( - event.location - )} - - {event.host} - {capitalizeFirstLetter(event.repeats || "Never")} - - - - - - - - ); - }; + + ), + }, + { + key: "start", + label: "Start", + render: (event) => dayjs(event.start).format("MMM D YYYY hh:mm A"), + }, + { + key: "end", + label: "End", + render: (event) => + event.end ? dayjs(event.end).format("MMM D YYYY hh:mm A") : "N/A", + }, + { + key: "location", + label: "Location", + render: (event) => + event.locationLink ? ( + + {event.location} + + ) : ( + event.location + ), + }, + { + key: "host", + label: "Host", + render: (event) => event.host, + }, + { + key: "repeats", + label: "Repeats", + render: (event) => capitalizeFirstLetter(event.repeats || "Never"), + }, + { + key: "actions", + label: "Actions", + hideMobileLabel: true, + render: (event) => ( + + + + + ), + }, + ]; if (eventList.length === 0) { return ; @@ -245,41 +271,31 @@ export const ViewEventsPage: React.FC = () => { navigate("/events/add"); }} > - New Calendar Event + Create Event - - - - - Title - Start - End - Location - Host - Repeats - Actions - - - {sortedUpcomingEvents.map(renderEvent)} -
+ event.id} + testIdPrefix="event-row" + testId="events-table" + /> + {showPrevious && ( <> - - - {showPrevious && sortedPreviousEvents.map(renderEvent)} - -
+ event.id} + testIdPrefix="event-previous-row" + testId="previous-events-table" + /> )} diff --git a/src/ui/pages/iam/GroupMemberManagement.test.tsx b/src/ui/pages/iam/GroupMemberManagement.test.tsx index 2a5babc9..e0c554b4 100644 --- a/src/ui/pages/iam/GroupMemberManagement.test.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { render, screen, act } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { vi } from "vitest"; diff --git a/src/ui/pages/iam/GroupMemberManagement.tsx b/src/ui/pages/iam/GroupMemberManagement.tsx index 11db075d..005c223e 100644 --- a/src/ui/pages/iam/GroupMemberManagement.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.tsx @@ -3,7 +3,6 @@ import { Avatar, Badge, Group, - Table, Text, Button, TextInput, @@ -11,15 +10,18 @@ import { Skeleton, Pagination, Select, + Stack, } from "@mantine/core"; import { IconUserPlus, IconTrash, IconSearch, IconAlertCircle, + IconDeviceFloppy, } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; import { GroupMemberGetResponse, EntraActionResponse } from "@common/types/iam"; +import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; interface GroupMemberManagementProps { fetchMembers: () => Promise; @@ -29,7 +31,15 @@ interface GroupMemberManagementProps { ) => Promise; } +interface DisplayMember { + name: string; + email: string; + isNew: boolean; + isQueuedForRemoval: boolean; +} + const PER_PAGE_OPTIONS = ["10", "20", "50", "100"].sort(); + const GroupMemberManagement: React.FC = ({ fetchMembers, updateMembers, @@ -86,15 +96,21 @@ const GroupMemberManagement: React.FC = ({ }); } }; + const handleUndoRemoveMember = (email: string) => { setToRemove((prev) => prev.filter((x) => x !== email)); }; + const handleRemoveMember = (email: string) => { if (!toRemove.includes(email)) { setToRemove((prev) => [...prev, email]); } }; + const handleCancelAdd = (email: string) => { + setToAdd((prev) => prev.filter((item) => item !== email)); + }; + const handleSaveChanges = async () => { setIsLoading(true); try { @@ -135,12 +151,17 @@ const GroupMemberManagement: React.FC = ({ }; const { paginatedMembers, totalPages } = useMemo(() => { - const combinedList = [ - ...members.map((member) => ({ ...member, isNew: false })), + const combinedList: DisplayMember[] = [ + ...members.map((member) => ({ + ...member, + isNew: false, + isQueuedForRemoval: toRemove.includes(member.email), + })), ...toAdd.map((email) => ({ name: email.split("@")[0], email, isNew: true, + isQueuedForRemoval: false, })), ]; @@ -158,110 +179,118 @@ const GroupMemberManagement: React.FC = ({ ); return { paginatedMembers: paginated, totalPages: total }; - }, [members, toAdd, searchQuery, activePage, itemsPerPage]); + }, [members, toAdd, toRemove, searchQuery, activePage, itemsPerPage]); - const rows = paginatedMembers.map((member) => { - if (member.isNew) { - return ( - - - - -
- - {member.name} - - - {member.email} - -
-
-
- + // Define columns for members table + const memberColumns: Column[] = [ + { + key: "member", + label: "Member", + isPrimaryColumn: true, + render: (member) => ( + + +
+ + {member.name} + + + {member.email} + +
+
+ ), + }, + { + key: "status", + label: "Status", + render: (member) => { + if (member.isQueuedForRemoval) { + return ( + + Queued for removal + + ); + } + if (member.isNew) { + return ( Queued for addition -
- + ); + } + return ( + + Active + + ); + }, + }, + { + key: "actions", + label: "Actions", + hideMobileLabel: true, + render: (member) => { + if (member.isQueuedForRemoval) { + return ( - -
- ); - } - - return ( - - - - -
- - {member.name} - - - {member.email} - -
-
-
- - {toRemove.includes(member.email) ? ( - - Queued for removal - - ) : ( - - Active - - )} - - - {toRemove.includes(member.email) ? ( + ); + } + if (member.isNew) { + return ( - ) : ( - - )} - -
- ); - }); + ); + } + return ( + + ); + }, + }, + ]; const skeletonRows = Array.from({ length: 5 }).map((_, index) => ( - - - - - + )); return ( -
+ = ({ style={{ width: "150px" }} /> - - - - Member - Status - Actions - - - - {isLoading ? ( - skeletonRows - ) : rows.length > 0 ? ( - rows - ) : ( - - - - No members found. - - - - )} - -
+ + {isLoading ? ( + {skeletonRows} + ) : paginatedMembers.length > 0 ? ( + member.email} + testIdPrefix="member-row" + cardColumns={{ base: 1, xs: 2 }} + /> + ) : ( + + No members found. + + )} {totalPages > 1 && ( )} + setEmailToAdd(e.currentTarget.value)} placeholder="Enter email to add" label="Add New Member" /> + @@ -342,6 +363,7 @@ const GroupMemberManagement: React.FC = ({ opened={confirmationModal} onClose={() => setConfirmationModal(false)} title="Confirm Changes" + centered >
{toAdd.length > 0 && ( @@ -382,7 +404,7 @@ const GroupMemberManagement: React.FC = ({
-
+ ); }; diff --git a/src/ui/pages/iam/ManageIam.page.tsx b/src/ui/pages/iam/ManageIam.page.tsx index 7290cfac..e1e4a227 100644 --- a/src/ui/pages/iam/ManageIam.page.tsx +++ b/src/ui/pages/iam/ManageIam.page.tsx @@ -45,7 +45,7 @@ export const ManageIamPage = () => { }; fetchGroups(); - }, [api]); // Dependency array ensures this runs once + }, [api]); const handleInviteSubmit = async (emailList: string[]) => { try { @@ -68,7 +68,7 @@ export const ManageIamPage = () => { const getGroupMembers = async (groupId: string | null) => { if (!groupId) { return []; - } // Do not fetch if no group is selected + } try { const response = await api.get(`/api/v1/iam/groups/${groupId}`); const data = response.data as GroupMemberGetResponse; @@ -122,9 +122,16 @@ export const ManageIamPage = () => { validRoles: [AppRoles.IAM_ADMIN, AppRoles.IAM_INVITE_ONLY], }} > - Manage Authentication - - + + Manage Authentication + + + + + Add Users to Entra ID Tenant + + + { = ({ getLogs }) => { > + {showUtcTime @@ -304,69 +357,33 @@ export const LogRenderer: React.FC = ({ getLogs }) => { {loading ? ( - + ) : logs && logs.length > 0 ? ( <> - - - - Timestamp - Actor - Action - Target - Request ID - - - - {currentLogs.map((log, index) => ( - - - - {formatTimestamp(log.createdAt)} - - - - {getRelativeTime(log.createdAt)} - - - - - - {log.actor} - - - {log.message} - - - - {selectedModule === Modules.AUDIT_LOG && - Object.values(Modules).includes(log.target as Modules) - ? ModulesToHumanName[log.target as Modules] - : log.target} - - - - {log.requestId} - - - - ))} - -
+ {currentLogs.length > 0 ? ( + ({ + ...log, + _index: index, + }))} + columns={logsColumns} + keyExtractor={(log) => `${log.requestId}-${log._index}`} + testIdPrefix="log-row" + cardColumns={{ base: 1, sm: 2 }} + /> + ) : ( + + No logs match your search criteria. + + )}
{/* Pagination Controls */} - - + + Items per page: