diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 4941dbbb4c..59a06d0495 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -13,6 +13,7 @@ import { renderWithProviders, screen, setupMockServer } from "@/testing/utils"; setupMockServer( authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler(), authResolvers.createSession.handler(), notificationResolvers.listNotifications.handler() ); diff --git a/src/app/api/query/auth.ts b/src/app/api/query/auth.ts index 0f663606af..d6e0b4f6fd 100644 --- a/src/app/api/query/auth.ts +++ b/src/app/api/query/auth.ts @@ -4,14 +4,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useDispatch } from "react-redux"; import { useWebsocketAwareQuery } from "@/app/api/query/base"; +import type { UserWithStatistics } from "@/app/api/query/users"; import { mutationOptionsWithHeaders, queryOptionsWithHeaders, } from "@/app/api/utils"; import type { - HandleOauthCallbackResponses, - HandleOauthCallbackErrors, - HandleOauthCallbackData, CompleteIntroData, CompleteIntroErrors, CompleteIntroResponses, @@ -24,48 +22,58 @@ import type { DeleteOauthProviderData, DeleteOauthProviderErrors, DeleteOauthProviderResponses, - GetMeWithSummaryData, - GetMeWithSummaryErrors, - GetMeWithSummaryResponses, + ExtendSessionData, + ExtendSessionErrors, + ExtendSessionResponses, + GetMeStatisticsData, + GetMeStatisticsError, + GetMeStatisticsErrors, + GetMeStatisticsResponses, GetOauthProviderData, GetOauthProviderErrors, GetOauthProviderResponses, + GetUserInfoData, + GetUserInfoError, + GetUserInfoErrors, + GetUserInfoResponses, + HandleOauthCallbackData, + HandleOauthCallbackErrors, + HandleOauthCallbackResponses, + InitiateAuthFlowData, + InitiateAuthFlowErrors, + InitiateAuthFlowResponses, LoginData, + LoginError, LoginErrors, LoginResponses, Options, - UpdateOauthProviderData, - UpdateOauthProviderErrors, - UpdateOauthProviderResponses, PreLoginData, - PreLoginResponses, PreLoginErrors, + PreLoginResponses, TokenResponse, - LoginError, - ExtendSessionResponses, - ExtendSessionErrors, - ExtendSessionData, - InitiateAuthFlowData, - InitiateAuthFlowResponses, - InitiateAuthFlowErrors, + UpdateOauthProviderData, + UpdateOauthProviderErrors, + UpdateOauthProviderResponses, } from "@/app/apiclient"; import { - deleteOauthProvider, - updateOauthProvider, - createOauthProvider, completeIntro, - getOauthProvider, - getMeWithSummary, - login, + createOauthProvider, createSession, - preLogin, + deleteOauthProvider, extendSession, - initiateAuthFlow, + getMeStatistics, + getOauthProvider, + getUserInfo, handleOauthCallback, + initiateAuthFlow, + login, + preLogin, + updateOauthProvider, } from "@/app/apiclient"; import { - getMeWithSummaryQueryKey, + getMeStatisticsQueryKey, getOauthProviderQueryKey, + getUserInfoQueryKey, handleOauthCallbackQueryKey, initiateAuthFlowQueryKey, } from "@/app/apiclient/@tanstack/react-query.gen"; @@ -236,24 +244,55 @@ export const useExtendSession = ( }); }; -export const useGetCurrentUser = (options?: Options) => { - return useWebsocketAwareQuery({ +export const useGetCurrentUser = ( + options?: Options +): { + data: UserWithStatistics | undefined; + isPending: boolean; + isSuccess: boolean; + isError: boolean; + error: GetUserInfoError | null; + statisticsError: GetMeStatisticsError | null; +} => { + const userInfo = useWebsocketAwareQuery({ ...queryOptionsWithHeaders< - GetMeWithSummaryResponses, - GetMeWithSummaryErrors, - GetMeWithSummaryData - >(options, getMeWithSummary, getMeWithSummaryQueryKey(options)), + GetUserInfoResponses, + GetUserInfoErrors, + GetUserInfoData + >(options, getUserInfo, getUserInfoQueryKey(options)), retry: false, // explicitly set retry to false }); + + const statistics = useWebsocketAwareQuery({ + ...queryOptionsWithHeaders< + GetMeStatisticsResponses, + GetMeStatisticsErrors, + GetMeStatisticsData + >({}, getMeStatistics, getMeStatisticsQueryKey()), + enabled: userInfo.isSuccess, + retry: false, + }); + + return { + ...userInfo, + data: userInfo.data + ? ({ + ...userInfo.data, + statistics: statistics.data, + } as UserWithStatistics) + : undefined, + error: userInfo.error, + statisticsError: statistics.error, + }; }; -export const useGetIsSuperUser = (options?: Options) => { +export const useGetIsSuperUser = (options?: Options) => { return useWebsocketAwareQuery({ ...queryOptionsWithHeaders< - GetMeWithSummaryResponses, - GetMeWithSummaryErrors, - GetMeWithSummaryData - >(options, getMeWithSummary, getMeWithSummaryQueryKey(options)), + GetUserInfoResponses, + GetUserInfoErrors, + GetUserInfoData + >(options, getUserInfo, getUserInfoQueryKey(options)), select: (data) => data.is_superuser, }); }; @@ -270,7 +309,7 @@ export const useCompleteIntro = ( >(mutationOptions, completeIntro), onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: getMeWithSummaryQueryKey(), + queryKey: getUserInfoQueryKey(), }); }, }); diff --git a/src/app/api/query/users.test.ts b/src/app/api/query/users.test.ts index 04ffa0777f..2cd5b54faa 100644 --- a/src/app/api/query/users.test.ts +++ b/src/app/api/query/users.test.ts @@ -5,9 +5,15 @@ import { useUpdateUser, useUserCount, useUsers, + useUsersStatistics, } from "@/app/api/query/users"; import type { UserCreateRequest, UserUpdateRequest } from "@/app/apiclient"; -import { mockUsers, usersResolvers } from "@/testing/resolvers/users"; +import { authResolvers } from "@/testing/resolvers/auth"; +import { + mockUsers, + mockUsersStatistics, + usersResolvers, +} from "@/testing/resolvers/users"; import { renderHookWithProviders, setupMockServer, @@ -16,10 +22,12 @@ import { const mockServer = setupMockServer( usersResolvers.listUsers.handler(), + usersResolvers.listUsersStatistics.handler(), usersResolvers.getUser.handler(), usersResolvers.createUser.handler(), usersResolvers.updateUser.handler(), - usersResolvers.deleteUser.handler() + usersResolvers.deleteUser.handler(), + authResolvers.getCurrentUser.handler() ); describe("useUsers", () => { @@ -32,6 +40,16 @@ describe("useUsers", () => { }); }); +describe("useUsersStatistics", () => { + it("should return users statistics data", async () => { + const { result } = renderHookWithProviders(() => useUsersStatistics()); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toMatchObject(mockUsersStatistics); + }); +}); + describe("useUserCount", () => { it("should return correct count", async () => { const { result } = renderHookWithProviders(() => useUserCount()); diff --git a/src/app/api/query/users.ts b/src/app/api/query/users.ts index cc4e728a43..8117986073 100644 --- a/src/app/api/query/users.ts +++ b/src/app/api/query/users.ts @@ -1,6 +1,8 @@ +import type { UseQueryResult } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useWebsocketAwareQuery } from "@/app/api/query/base"; +import type { WithHeaders } from "@/app/api/utils"; import { mutationOptionsWithHeaders, queryOptionsWithHeaders, @@ -15,43 +17,107 @@ import type { GetUserData, GetUserErrors, GetUserResponses, - ListUsersWithSummaryData, - ListUsersWithSummaryErrors, - ListUsersWithSummaryResponses, + ListUsersData, + ListUsersError, + ListUsersErrors, + ListUsersResponses, + ListUsersStatisticsData, + ListUsersStatisticsError, + ListUsersStatisticsErrors, + ListUsersStatisticsResponses, Options, UpdateUserData, UpdateUserErrors, UpdateUserResponses, + UserResponse, + UserStatisticsResponse, } from "@/app/apiclient"; import { - deleteUser, - updateUser, createUser, + deleteUser, getUser, - listUsersWithSummary, + listUsers, + listUsersStatistics, + updateUser, } from "@/app/apiclient"; import { getUserQueryKey, - listUsersWithSummaryQueryKey, + listUsersQueryKey, + listUsersStatisticsQueryKey, } from "@/app/apiclient/@tanstack/react-query.gen"; -export const useUsers = (options?: Options) => { - return useWebsocketAwareQuery( - queryOptionsWithHeaders< - ListUsersWithSummaryResponses, - ListUsersWithSummaryErrors, - ListUsersWithSummaryData - >(options, listUsersWithSummary, listUsersWithSummaryQueryKey(options)) +export type UserWithStatistics = WithHeaders & { + statistics: UserStatisticsResponse | undefined; +}; + +export type UseUsersResult = { + data: + | { + items: UserWithStatistics[]; + total: number; + } + | undefined; + isPending: UseQueryResult["isPending"]; + statisticsPending: UseQueryResult["isPending"]; + isSuccess: UseQueryResult["isSuccess"]; + isError: UseQueryResult["isError"]; + error: ListUsersError | null; + statisticsError: ListUsersStatisticsError | null; +}; + +export const useUsers = (options?: Options): UseUsersResult => { + const users = useWebsocketAwareQuery( + queryOptionsWithHeaders( + options, + listUsers, + listUsersQueryKey(options) + ) ); + const userIds = users.data?.items.map((user) => user.id) ?? []; + const statistics = useUsersStatistics({ + query: { id: userIds }, + }); + + return { + ...users, + statisticsPending: statistics.isPending, + data: users.data + ? { + ...users.data, + items: users.data.items.map((user) => ({ + ...user, + statistics: statistics.data?.items.find( + (stat) => stat.id === user.id + ), + })), + } + : undefined, + error: users.error, + statisticsError: statistics.error, + }; +}; + +export const useUsersStatistics = ( + options?: Options, + enabled?: boolean +) => { + return useWebsocketAwareQuery({ + ...queryOptionsWithHeaders< + ListUsersStatisticsResponses, + ListUsersStatisticsErrors, + ListUsersStatisticsData + >(options, listUsersStatistics, listUsersStatisticsQueryKey(options)), + enabled, + }); }; -export const useUserCount = (options?: Options) => { +export const useUserCount = (options?: Options) => { return useWebsocketAwareQuery({ ...queryOptionsWithHeaders< - ListUsersWithSummaryResponses, - ListUsersWithSummaryErrors, - ListUsersWithSummaryData - >(options, listUsersWithSummary, listUsersWithSummaryQueryKey(options)), + ListUsersResponses, + ListUsersErrors, + ListUsersData + >(options, listUsers, listUsersQueryKey(options)), select: (data) => data?.total ?? 0, }); }; @@ -76,7 +142,7 @@ export const useCreateUser = (mutationOptions?: Options) => { >(mutationOptions, createUser), onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: listUsersWithSummaryQueryKey(), + queryKey: listUsersQueryKey(), }); }, }); @@ -92,7 +158,7 @@ export const useUpdateUser = (mutationOptions?: Options) => { >(mutationOptions, updateUser), onSuccess: async () => { return queryClient.invalidateQueries({ - queryKey: listUsersWithSummaryQueryKey(), + queryKey: listUsersQueryKey(), }); }, }); @@ -108,7 +174,7 @@ export const useDeleteUser = (mutationOptions?: Options) => { >(mutationOptions, deleteUser), onSuccess: () => { return queryClient.invalidateQueries({ - queryKey: listUsersWithSummaryQueryKey(), + queryKey: listUsersQueryKey(), }); }, }); diff --git a/src/app/apiclient/@tanstack/react-query.gen.ts b/src/app/apiclient/@tanstack/react-query.gen.ts index 4e9df2c865..c2cb844d44 100644 --- a/src/app/apiclient/@tanstack/react-query.gen.ts +++ b/src/app/apiclient/@tanstack/react-query.gen.ts @@ -98,7 +98,7 @@ import { getFileByKey, getGroup, getMachinePowerParameters, - getMeWithSummary, + getMeStatistics, getNosInstaller, getNotification, getOauthProvider, @@ -166,7 +166,7 @@ import { listUsers, listUserSshkeys, listUserSslkeysStatistics, - listUsersWithSummary, + listUsersStatistics, listZones, listZonesWithStatistics, login, @@ -480,9 +480,9 @@ import type { GetMachinePowerParametersData, GetMachinePowerParametersError, GetMachinePowerParametersResponse, - GetMeWithSummaryData, - GetMeWithSummaryError, - GetMeWithSummaryResponse, + GetMeStatisticsData, + GetMeStatisticsError, + GetMeStatisticsResponse, GetNosInstallerData, GetNosInstallerError, GetNotificationData, @@ -683,9 +683,9 @@ import type { ListUserSslkeysStatisticsData, ListUserSslkeysStatisticsError, ListUserSslkeysStatisticsResponse, - ListUsersWithSummaryData, - ListUsersWithSummaryError, - ListUsersWithSummaryResponse, + ListUsersStatisticsData, + ListUsersStatisticsError, + ListUsersStatisticsResponse, ListZonesData, ListZonesError, ListZonesResponse, @@ -5401,26 +5401,24 @@ export const removeGroupMemberMutation = ( return mutationOptions; }; -export const getMeWithSummaryQueryKey = ( - options?: Options -) => createQueryKey("getMeWithSummary", options); +export const getMeStatisticsQueryKey = ( + options?: Options +) => createQueryKey("getMeStatistics", options); /** - * Get user with a summary. ONLY FOR INTERNAL USAGE. - * - * Get user with a summary. This endpoint is only for internal usage and might be changed or removed without notice. + * Get additional statistics for the logged-in user, e.g. machine count. */ -export const getMeWithSummaryOptions = ( - options?: Options +export const getMeStatisticsOptions = ( + options?: Options ) => queryOptions< - GetMeWithSummaryResponse, - GetMeWithSummaryError, - GetMeWithSummaryResponse, - ReturnType + GetMeStatisticsResponse, + GetMeStatisticsError, + GetMeStatisticsResponse, + ReturnType >({ queryFn: async ({ queryKey, signal }) => { - const { data } = await getMeWithSummary({ + const { data } = await getMeStatistics({ ...options, ...queryKey[0], signal, @@ -5428,7 +5426,7 @@ export const getMeWithSummaryOptions = ( }); return data; }, - queryKey: getMeWithSummaryQueryKey(options), + queryKey: getMeStatisticsQueryKey(options), }); export const getUserInfoQueryKey = (options?: Options) => @@ -5668,26 +5666,24 @@ export const changePasswordAdminMutation = ( return mutationOptions; }; -export const listUsersWithSummaryQueryKey = ( - options?: Options -) => createQueryKey("listUsersWithSummary", options); +export const listUsersStatisticsQueryKey = ( + options?: Options +) => createQueryKey("listUsersStatistics", options); /** - * List users with a summary. ONLY FOR INTERNAL USAGE. - * - * List users with a summary. This endpoint is only for internal usage and might be changed or removed without notice. + * List additional statistics of users, e.g. machine count. */ -export const listUsersWithSummaryOptions = ( - options?: Options +export const listUsersStatisticsOptions = ( + options?: Options ) => queryOptions< - ListUsersWithSummaryResponse, - ListUsersWithSummaryError, - ListUsersWithSummaryResponse, - ReturnType + ListUsersStatisticsResponse, + ListUsersStatisticsError, + ListUsersStatisticsResponse, + ReturnType >({ queryFn: async ({ queryKey, signal }) => { - const { data } = await listUsersWithSummary({ + const { data } = await listUsersStatistics({ ...options, ...queryKey[0], signal, @@ -5695,7 +5691,7 @@ export const listUsersWithSummaryOptions = ( }); return data; }, - queryKey: listUsersWithSummaryQueryKey(options), + queryKey: listUsersStatisticsQueryKey(options), }); export const listFabricVlansQueryKey = ( diff --git a/src/app/apiclient/sdk.gen.ts b/src/app/apiclient/sdk.gen.ts index 35ce689b67..d0c1621744 100644 --- a/src/app/apiclient/sdk.gen.ts +++ b/src/app/apiclient/sdk.gen.ts @@ -291,9 +291,9 @@ import type { GetMachinePowerParametersData, GetMachinePowerParametersErrors, GetMachinePowerParametersResponses, - GetMeWithSummaryData, - GetMeWithSummaryErrors, - GetMeWithSummaryResponses, + GetMeStatisticsData, + GetMeStatisticsErrors, + GetMeStatisticsResponses, GetNosInstallerData, GetNosInstallerErrors, GetNosInstallerResponses, @@ -495,9 +495,9 @@ import type { ListUserSslkeysStatisticsData, ListUserSslkeysStatisticsErrors, ListUserSslkeysStatisticsResponses, - ListUsersWithSummaryData, - ListUsersWithSummaryErrors, - ListUsersWithSummaryResponses, + ListUsersStatisticsData, + ListUsersStatisticsErrors, + ListUsersStatisticsResponses, ListZonesData, ListZonesErrors, ListZonesResponses, @@ -4460,19 +4460,17 @@ export const removeGroupMember = ( }; /** - * Get user with a summary. ONLY FOR INTERNAL USAGE. - * - * Get user with a summary. This endpoint is only for internal usage and might be changed or removed without notice. + * Get additional statistics for the logged-in user, e.g. machine count. */ -export const getMeWithSummary = ( - options?: Options +export const getMeStatistics = ( + options?: Options ) => { return (options?.client ?? client).get< - GetMeWithSummaryResponses, - GetMeWithSummaryErrors, + GetMeStatisticsResponses, + GetMeStatisticsErrors, ThrowOnError >({ - url: "/MAAS/a/v3/users/me_with_summary", + url: "/MAAS/a/v3/users/me:statistics", ...options, }); }; @@ -4674,16 +4672,14 @@ export const changePasswordAdmin = ( }; /** - * List users with a summary. ONLY FOR INTERNAL USAGE. - * - * List users with a summary. This endpoint is only for internal usage and might be changed or removed without notice. + * List additional statistics of users, e.g. machine count. */ -export const listUsersWithSummary = ( - options?: Options +export const listUsersStatistics = ( + options?: Options ) => { return (options?.client ?? client).get< - ListUsersWithSummaryResponses, - ListUsersWithSummaryErrors, + ListUsersStatisticsResponses, + ListUsersStatisticsErrors, ThrowOnError >({ security: [ @@ -4692,7 +4688,7 @@ export const listUsersWithSummary = ( type: "http", }, ], - url: "/MAAS/a/v3/users_with_summary", + url: "/MAAS/a/v3/users:statistics", ...options, }); }; diff --git a/src/app/apiclient/types.gen.ts b/src/app/apiclient/types.gen.ts index ce19a2fd67..55af4c19e7 100644 --- a/src/app/apiclient/types.gen.ts +++ b/src/app/apiclient/types.gen.ts @@ -4755,39 +4755,9 @@ export type UserResponse = { }; /** - * UserUpdateRequest + * UserStatisticsResponse */ -export type UserUpdateRequest = { - /** - * Username - */ - username: string; - /** - * Is Superuser - */ - is_superuser: boolean; - /** - * First Name - */ - first_name: string; - /** - * Last Name - */ - last_name: string; - /** - * Email - */ - email?: string; - /** - * Password - */ - password?: string; -}; - -/** - * UserWithSummaryResponse - */ -export type UserWithSummaryResponse = { +export type UserStatisticsResponse = { _links?: BaseHal; /** * Embedded @@ -4805,26 +4775,10 @@ export type UserWithSummaryResponse = { * Completed Intro */ completed_intro: boolean; - /** - * Email - */ - email?: string; /** * Is Local */ is_local: boolean; - /** - * Is Superuser - */ - is_superuser: boolean; - /** - * Last Name - */ - last_name?: string; - /** - * Last Login - */ - last_login?: string; /** * Machines Count */ @@ -4833,10 +4787,36 @@ export type UserWithSummaryResponse = { * Sshkeys Count */ sshkeys_count: number; +}; + +/** + * UserUpdateRequest + */ +export type UserUpdateRequest = { /** * Username */ username: string; + /** + * Is Superuser + */ + is_superuser: boolean; + /** + * First Name + */ + first_name: string; + /** + * Last Name + */ + last_name: string; + /** + * Email + */ + email?: string; + /** + * Password + */ + password?: string; }; /** @@ -4862,13 +4842,13 @@ export type UsersListResponse = { }; /** - * UsersWithSummaryListResponse + * UsersStatisticsListResponse */ -export type UsersWithSummaryListResponse = { +export type UsersStatisticsListResponse = { /** * Items */ - items: UserWithSummaryResponse[]; + items: UserStatisticsResponse[]; /** * Total */ @@ -11831,32 +11811,32 @@ export type RemoveGroupMemberResponses = { export type RemoveGroupMemberResponse = RemoveGroupMemberResponses[keyof RemoveGroupMemberResponses]; -export type GetMeWithSummaryData = { +export type GetMeStatisticsData = { body?: never; path?: never; query?: never; - url: "/MAAS/a/v3/users/me_with_summary"; + url: "/MAAS/a/v3/users/me:statistics"; }; -export type GetMeWithSummaryErrors = { +export type GetMeStatisticsErrors = { /** * Unprocessable Content */ 422: ValidationErrorBodyResponse; }; -export type GetMeWithSummaryError = - GetMeWithSummaryErrors[keyof GetMeWithSummaryErrors]; +export type GetMeStatisticsError = + GetMeStatisticsErrors[keyof GetMeStatisticsErrors]; -export type GetMeWithSummaryResponses = { +export type GetMeStatisticsResponses = { /** * Successful Response */ - 200: UserWithSummaryResponse; + 200: UserStatisticsResponse; }; -export type GetMeWithSummaryResponse = - GetMeWithSummaryResponses[keyof GetMeWithSummaryResponses]; +export type GetMeStatisticsResponse = + GetMeStatisticsResponses[keyof GetMeStatisticsResponses]; export type GetUserInfoData = { body?: never; @@ -11969,6 +11949,16 @@ export type ListUsersData = { * Size */ size?: number; + /** + * Id + * + * Filter by User ID + */ + id?: number[]; + /** + * Filter by username or email + */ + username_or_email?: string; }; url: "/MAAS/a/v3/users"; }; @@ -12183,7 +12173,7 @@ export type ChangePasswordAdminResponses = { export type ChangePasswordAdminResponse = ChangePasswordAdminResponses[keyof ChangePasswordAdminResponses]; -export type ListUsersWithSummaryData = { +export type ListUsersStatisticsData = { body?: never; path?: never; query?: { @@ -12195,33 +12185,39 @@ export type ListUsersWithSummaryData = { * Size */ size?: number; + /** + * Id + * + * Filter by User ID + */ + id?: number[]; /** * Filter by username or email */ username_or_email?: string; }; - url: "/MAAS/a/v3/users_with_summary"; + url: "/MAAS/a/v3/users:statistics"; }; -export type ListUsersWithSummaryErrors = { +export type ListUsersStatisticsErrors = { /** * Unprocessable Content */ 422: ValidationErrorBodyResponse; }; -export type ListUsersWithSummaryError = - ListUsersWithSummaryErrors[keyof ListUsersWithSummaryErrors]; +export type ListUsersStatisticsError = + ListUsersStatisticsErrors[keyof ListUsersStatisticsErrors]; -export type ListUsersWithSummaryResponses = { +export type ListUsersStatisticsResponses = { /** * Successful Response */ - 200: UsersWithSummaryListResponse; + 200: UsersStatisticsListResponse; }; -export type ListUsersWithSummaryResponse = - ListUsersWithSummaryResponses[keyof ListUsersWithSummaryResponses]; +export type ListUsersStatisticsResponse = + ListUsersStatisticsResponses[keyof ListUsersStatisticsResponses]; export type ListFabricVlansData = { body?: never; diff --git a/src/app/base/components/AppSideNavigation/AppSideNavItems/AppSideNavItems.tsx b/src/app/base/components/AppSideNavigation/AppSideNavItems/AppSideNavItems.tsx index aaae32f94e..9f30114943 100644 --- a/src/app/base/components/AppSideNavigation/AppSideNavItems/AppSideNavItems.tsx +++ b/src/app/base/components/AppSideNavigation/AppSideNavItems/AppSideNavItems.tsx @@ -8,12 +8,12 @@ import type { SideNavigationProps } from "../AppSideNavigation"; import type { NavGroup } from "../types"; import { isSelected } from "../utils"; -import type { UserWithSummaryResponse } from "@/app/apiclient"; +import type { UserResponse } from "@/app/apiclient"; import { useId } from "@/app/base/hooks/base"; import urls from "@/app/base/urls"; type Props = { - authUser: UserWithSummaryResponse | null; + authUser: UserResponse | null; groups: NavGroup[]; isAdmin: boolean; isAuthenticated: boolean; diff --git a/src/app/base/components/AppSideNavigation/AppSideNavigation.test.tsx b/src/app/base/components/AppSideNavigation/AppSideNavigation.test.tsx index 8a89a10f54..652f0a33ac 100644 --- a/src/app/base/components/AppSideNavigation/AppSideNavigation.test.tsx +++ b/src/app/base/components/AppSideNavigation/AppSideNavigation.test.tsx @@ -24,7 +24,14 @@ vi.mock("react-router", async () => { }; }); -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler( + factory.user({ is_superuser: true, id: 1 }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ id: 1, completed_intro: true }) + ) +); afterEach(() => { vi.resetModules(); @@ -108,9 +115,13 @@ describe("GlobalSideNav", () => { mockServer.use( authResolvers.getCurrentUser.handler( factory.user({ - completed_intro: false, username: "koala", }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ + completed_intro: false, + }) ) ); renderWithProviders(, { diff --git a/src/app/base/components/AppSideNavigation/AppSideNavigation.tsx b/src/app/base/components/AppSideNavigation/AppSideNavigation.tsx index 5e12b18b98..1bf8029706 100644 --- a/src/app/base/components/AppSideNavigation/AppSideNavigation.tsx +++ b/src/app/base/components/AppSideNavigation/AppSideNavigation.tsx @@ -20,7 +20,7 @@ import NavigationBanner from "./NavigationBanner"; import { navGroups } from "./constants"; import { useGetCurrentUser } from "@/app/api/query/auth"; -import type { UserWithSummaryResponse } from "@/app/apiclient"; +import type { UserResponse } from "@/app/apiclient"; import { useCompletedIntro, useCompletedUserIntro, @@ -36,7 +36,7 @@ import podSelectors from "@/app/store/pod/selectors"; import type { RootState } from "@/app/store/root/types"; export type SideNavigationProps = { - authUser: UserWithSummaryResponse; + authUser: UserResponse; filteredGroups: typeof navGroups; isAuthenticated: boolean; isCollapsed: boolean; diff --git a/src/app/base/hooks/intro.test.tsx b/src/app/base/hooks/intro.test.tsx index 7c63bca673..50e820547c 100644 --- a/src/app/base/hooks/intro.test.tsx +++ b/src/app/base/hooks/intro.test.tsx @@ -12,7 +12,12 @@ import { authResolvers } from "@/testing/resolvers/auth"; import { renderHookWithProviders, setupMockServer } from "@/testing/utils"; const mockStore = configureStore(); -setupMockServer(authResolvers.getCurrentUser.handler()); +setupMockServer( + authResolvers.getCurrentUser.handler(factory.user({ id: 1 })), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ id: 1, completed_intro: true }) + ) +); vi.mock("@/app/utils", async () => { const actual: object = await vi.importActual("@/app/utils"); diff --git a/src/app/base/hooks/intro.ts b/src/app/base/hooks/intro.ts index 262024ae58..aa515ddd9a 100644 --- a/src/app/base/hooks/intro.ts +++ b/src/app/base/hooks/intro.ts @@ -33,5 +33,5 @@ export const useCompletedUserIntro = (): boolean => { return true; } } - return !!user.data?.completed_intro; + return !!user.data?.statistics?.completed_intro; }; diff --git a/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx b/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx index 921cb983c8..bb77323d1b 100644 --- a/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx +++ b/src/app/controllers/views/ControllerDetails/ControllerConfiguration/ControllerConfiguration.test.tsx @@ -26,7 +26,8 @@ const route = urls.controllers.controller.index({ id: controller.system_id }); let state: ReturnType; setupMockServer( zoneResolvers.listZones.handler(), - authResolvers.getCurrentUser.handler() + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() ); describe("ControllerConfiguration", () => { diff --git a/src/app/domains/components/DomainSummary/DomainSummary.test.tsx b/src/app/domains/components/DomainSummary/DomainSummary.test.tsx index 961936aad0..569183f267 100644 --- a/src/app/domains/components/DomainSummary/DomainSummary.test.tsx +++ b/src/app/domains/components/DomainSummary/DomainSummary.test.tsx @@ -7,13 +7,16 @@ import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; import { - userEvent, - screen, renderWithProviders, + screen, setupMockServer, + userEvent, } from "@/testing/utils"; -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("DomainSummary", () => { it("render nothing if domain doesn't exist", () => { diff --git a/src/app/domains/components/ResourceRecordsTable/ResourceRecordsTable.test.tsx b/src/app/domains/components/ResourceRecordsTable/ResourceRecordsTable.test.tsx index 35b28b1164..c8e9128b95 100644 --- a/src/app/domains/components/ResourceRecordsTable/ResourceRecordsTable.test.tsx +++ b/src/app/domains/components/ResourceRecordsTable/ResourceRecordsTable.test.tsx @@ -12,7 +12,10 @@ import { within, } from "@/testing/utils"; -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("ResourceRecordsTable", () => { let items: DomainDetails; diff --git a/src/app/intro/components/IntroSection/IntroSection.test.tsx b/src/app/intro/components/IntroSection/IntroSection.test.tsx index 37a7e4199c..1a2496ce77 100644 --- a/src/app/intro/components/IntroSection/IntroSection.test.tsx +++ b/src/app/intro/components/IntroSection/IntroSection.test.tsx @@ -3,9 +3,12 @@ import IntroSection from "./IntroSection"; import urls from "@/app/base/urls"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; -import { screen, renderWithProviders, setupMockServer } from "@/testing/utils"; +import { renderWithProviders, screen, setupMockServer } from "@/testing/utils"; -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("IntroSection", () => { it("can display a loading spinner", () => { @@ -18,8 +21,9 @@ describe("IntroSection", () => { it("can redirect to close the intro", () => { mockServer.use( - authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: true }) + authResolvers.getCurrentUser.handler(factory.user()), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: true }) ) ); const { router } = renderWithProviders( @@ -32,7 +36,10 @@ describe("IntroSection", () => { it("redirects to the machine list for admins", () => { mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: true, is_superuser: true }) + factory.user({ is_superuser: true }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: true }) ) ); const { router } = renderWithProviders( @@ -45,7 +52,10 @@ describe("IntroSection", () => { it("redirects to the machine list for non-admins", () => { mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: true, is_superuser: false }) + factory.user({ is_superuser: false }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: true }) ) ); const { router } = renderWithProviders( diff --git a/src/app/intro/hooks.test.tsx b/src/app/intro/hooks.test.tsx index 0929e4c61a..c268a30cdd 100644 --- a/src/app/intro/hooks.test.tsx +++ b/src/app/intro/hooks.test.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from "react"; import { renderHook } from "@testing-library/react"; import { Provider } from "react-redux"; -import configureStore from "redux-mock-store"; import type { MockStoreEnhanced } from "redux-mock-store"; +import configureStore from "redux-mock-store"; import { useExitURL } from "./hooks"; @@ -15,7 +15,10 @@ import { authResolvers } from "@/testing/resolvers/auth"; import { setupMockServer } from "@/testing/utils"; const mockStore = configureStore(); -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); const generateWrapper = (store: MockStoreEnhanced) => diff --git a/src/app/intro/views/Intro.test.tsx b/src/app/intro/views/Intro.test.tsx index f6e5dbfc13..57e218c24f 100644 --- a/src/app/intro/views/Intro.test.tsx +++ b/src/app/intro/views/Intro.test.tsx @@ -8,13 +8,16 @@ import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; import { - screen, renderWithProviders, + screen, setupMockServer, waitForLoading, } from "@/testing/utils"; -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("Intro", () => { let state: RootState; @@ -38,7 +41,10 @@ describe("Intro", () => { it("displays a message if the user is not an admin", async () => { mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: false, is_superuser: false }) + factory.user({ id: 1, is_superuser: false }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ id: 1, completed_intro: false }) ) ); renderWithProviders(, { @@ -57,7 +63,10 @@ describe("Intro", () => { it("does not display a message if the user is an admin", async () => { mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: false, is_superuser: true }) + factory.user({ is_superuser: true }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: false }) ) ); const { router } = renderWithProviders(, { @@ -83,7 +92,10 @@ describe("Intro", () => { }); mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: true, is_superuser: true }) + factory.user({ is_superuser: true }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: true }) ) ); const { router } = renderWithProviders(, { @@ -110,8 +122,9 @@ describe("Intro", () => { items: [{ name: ConfigNames.COMPLETED_INTRO, value: true }], }); mockServer.use( - authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: false }) + authResolvers.getCurrentUser.handler(factory.user()), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: false }) ) ); const { router } = renderWithProviders(, { diff --git a/src/app/intro/views/Intro.tsx b/src/app/intro/views/Intro.tsx index baf6fe5473..b9d530d031 100644 --- a/src/app/intro/views/Intro.tsx +++ b/src/app/intro/views/Intro.tsx @@ -33,7 +33,7 @@ const Intro = (): ReactElement => { const user = useGetCurrentUser(); const showIncomplete = - !completedIntro && !user.data?.completed_intro && !user.data?.is_superuser; + !completedIntro && !completedUserIntro && !user.data?.is_superuser; useEffect(() => { if (!user.isPending && !configLoading && !showIncomplete) { diff --git a/src/app/intro/views/MaasIntro/MaasIntro.test.tsx b/src/app/intro/views/MaasIntro/MaasIntro.test.tsx index abe18ef594..d844b80998 100644 --- a/src/app/intro/views/MaasIntro/MaasIntro.test.tsx +++ b/src/app/intro/views/MaasIntro/MaasIntro.test.tsx @@ -11,15 +11,16 @@ import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; import { - userEvent, - screen, renderWithProviders, + screen, setupMockServer, + userEvent, } from "@/testing/utils"; setupMockServer( - authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: false, is_superuser: true }) + authResolvers.getCurrentUser.handler(factory.user({ is_superuser: true })), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: false }) ) ); diff --git a/src/app/intro/views/MaasIntro/MaasIntro.tsx b/src/app/intro/views/MaasIntro/MaasIntro.tsx index 7d1f80c578..2ac1d18aef 100644 --- a/src/app/intro/views/MaasIntro/MaasIntro.tsx +++ b/src/app/intro/views/MaasIntro/MaasIntro.tsx @@ -137,7 +137,7 @@ const MaasIntro = (): React.ReactElement => { { onConfirm={() => { dispatch(configActions.update({ completed_intro: true })); setCookie("skipsetupintro", "true"); - if (!user.data?.completed_intro) { + if (!user.data?.statistics?.completed_intro) { navigate({ pathname: urls.intro.user }); } else { navigate({ diff --git a/src/app/intro/views/MaasIntro/NameCard/NameCard.test.tsx b/src/app/intro/views/MaasIntro/NameCard/NameCard.test.tsx index cb6bbc60f5..e27bf9a6b7 100644 --- a/src/app/intro/views/MaasIntro/NameCard/NameCard.test.tsx +++ b/src/app/intro/views/MaasIntro/NameCard/NameCard.test.tsx @@ -9,13 +9,16 @@ import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; import { - userEvent, + renderWithProviders, screen, setupMockServer, - renderWithProviders, + userEvent, } from "@/testing/utils"; -setupMockServer(authResolvers.getCurrentUser.handler()); +setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("NameCard", () => { let state: RootState; diff --git a/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.test.tsx b/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.test.tsx index fe23ecd2e3..6649a405be 100644 --- a/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.test.tsx +++ b/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.test.tsx @@ -11,13 +11,16 @@ import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; import { - userEvent, - screen, renderWithProviders, + screen, setupMockServer, + userEvent, } from "@/testing/utils"; -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("MaasIntroSuccess", () => { let state: RootState; @@ -33,8 +36,9 @@ describe("MaasIntroSuccess", () => { it("links to the user intro if not yet completed", () => { mockServer.use( - authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: false }) + authResolvers.getCurrentUser.handler(factory.user()), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: false }) ) ); renderWithProviders(, { @@ -49,7 +53,10 @@ describe("MaasIntroSuccess", () => { it("links to the machine list if an admin that has completed the user intro", async () => { mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: true, is_superuser: true }) + factory.user({ is_superuser: true }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: true }) ) ); renderWithProviders(, { @@ -66,7 +73,10 @@ describe("MaasIntroSuccess", () => { it("links to the machine list if a non-admin that has completed the user intro", async () => { mockServer.use( authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: true, is_superuser: false }) + factory.user({ is_superuser: false }) + ), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ completed_intro: true }) ) ); diff --git a/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.tsx b/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.tsx index 8030f79071..6d8829db8f 100644 --- a/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.tsx +++ b/src/app/intro/views/MaasIntroSuccess/MaasIntroSuccess.tsx @@ -17,7 +17,9 @@ const MaasIntroSuccess = (): React.ReactElement => { const dispatch = useDispatch(); const user = useGetCurrentUser(); const exitURL = useExitURL(); - const continueLink = user.data?.completed_intro ? exitURL : urls.intro.user; + const continueLink = user.data?.statistics?.completed_intro + ? exitURL + : urls.intro.user; return ( diff --git a/src/app/intro/views/UserIntro/UserIntro.test.tsx b/src/app/intro/views/UserIntro/UserIntro.test.tsx index 01f38ed909..88f4effb30 100644 --- a/src/app/intro/views/UserIntro/UserIntro.test.tsx +++ b/src/app/intro/views/UserIntro/UserIntro.test.tsx @@ -8,17 +8,18 @@ import urls from "@/app/base/urls"; import { authResolvers } from "@/testing/resolvers/auth"; import { sshKeyResolvers } from "@/testing/resolvers/sshKeys"; import { - userEvent, - screen, - within, renderWithProviders, + screen, setupMockServer, + userEvent, waitForLoading, + within, } from "@/testing/utils"; const mockServer = setupMockServer( sshKeyResolvers.listSshKeys.handler(), authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler(), authResolvers.completeIntro.handler() ); diff --git a/src/app/intro/views/UserIntro/UserIntro.tsx b/src/app/intro/views/UserIntro/UserIntro.tsx index aecb1ef89c..bab9a35cf5 100644 --- a/src/app/intro/views/UserIntro/UserIntro.tsx +++ b/src/app/intro/views/UserIntro/UserIntro.tsx @@ -30,7 +30,7 @@ const UserIntro = (): React.ReactElement => { const sshkeys = data?.items || []; const hasSSHKeys = sshkeys.length > 0; const errorMessage = formatErrors( - user.isError ? user.error.message : undefined + user.isError ? user.error?.message : undefined ); return ( @@ -38,7 +38,7 @@ const UserIntro = (): React.ReactElement => { errors={errorMessage} loading={user.isPending || sshKeyLoading} shouldExitIntro={ - user.data?.completed_intro || + user.data?.statistics?.completed_intro || (completeIntro.isSuccess && markedIntroComplete) } windowTitle="User" diff --git a/src/app/login/RequireLogin/RequireLogin.test.tsx b/src/app/login/RequireLogin/RequireLogin.test.tsx index 196d75bd66..b3bd48d605 100644 --- a/src/app/login/RequireLogin/RequireLogin.test.tsx +++ b/src/app/login/RequireLogin/RequireLogin.test.tsx @@ -9,12 +9,12 @@ import urls from "@/app/base/urls"; import { WebSocketProvider } from "@/app/base/websocket-context"; import { ConfigNames } from "@/app/store/config/types"; import type { RootState } from "@/app/store/root/types"; +import * as factory from "@/testing/factories"; import { + configState as configStateFactory, rootState as rootStateFactory, statusState as statusStateFactory, - configState as configStateFactory, } from "@/testing/factories"; -import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; import { render, screen, setupMockServer, waitFor } from "@/testing/utils"; @@ -22,7 +22,10 @@ const mockStore = configureStore(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("RequireLogin", () => { let state: RootState; @@ -155,13 +158,15 @@ describe("RequireLogin", () => { }); it("redirects to the user intro page if user intro not completed", async () => { + const userId = 1; state.status.authenticated = true; state.config.items = [ factory.config({ name: ConfigNames.COMPLETED_INTRO, value: true }), ]; mockServer.use( - authResolvers.getCurrentUser.handler( - factory.user({ completed_intro: false }) + authResolvers.getCurrentUser.handler(factory.user({ id: userId })), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ id: userId, completed_intro: false }) ) ); diff --git a/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.test.tsx b/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.test.tsx index dcab0f647e..19e7014e14 100644 --- a/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.test.tsx +++ b/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.test.tsx @@ -12,7 +12,10 @@ import { setupMockServer, } from "@/testing/utils"; -const mockServer = setupMockServer(authResolvers.getCurrentUser.handler()); +const mockServer = setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("DeployFormFields", () => { let state: RootState; @@ -247,8 +250,12 @@ describe("DeployFormFields", () => { }); it("displays a warning if user has no SSH keys", async () => { + const userId = 1; mockServer.use( - authResolvers.getCurrentUser.handler(factory.user({ sshkeys_count: 0 })) + authResolvers.getCurrentUser.handler(factory.user({ id: userId })), + authResolvers.getMeStatistics.handler( + factory.userStatistics({ id: userId, sshkeys_count: 0 }) + ) ); renderWithProviders(, { state, diff --git a/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.tsx b/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.tsx index 760090f5a4..02ddbba860 100644 --- a/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.tsx +++ b/src/app/machines/components/MachineForms/MachineActionFormWrapper/DeployForm/DeployFormFields/DeployFormFields.tsx @@ -382,7 +382,7 @@ export const DeployFormFields = (): React.ReactElement => { /> - {user && user.data?.sshkeys_count === 0 && ( + {user && user.data?.statistics?.sshkeys_count === 0 && (

diff --git a/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.test.tsx b/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.test.tsx index f353702e35..47453cf850 100644 --- a/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.test.tsx +++ b/src/app/networkDiscovery/views/DiscoveriesList/DiscoveriesList.test.tsx @@ -5,15 +5,16 @@ import DiscoveriesList from "./DiscoveriesList"; import { authResolvers } from "@/testing/resolvers/auth"; import { networkDiscoveryResolvers } from "@/testing/resolvers/networkDiscovery"; import { - userEvent, - screen, renderWithProviders, + screen, setupMockServer, + userEvent, } from "@/testing/utils"; setupMockServer( networkDiscoveryResolvers.listNetworkDiscoveries.handler(), - authResolvers.getCurrentUser.handler() + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() ); describe("DiscoveriesList", () => { diff --git a/src/app/preferences/views/Details/Details.test.tsx b/src/app/preferences/views/Details/Details.test.tsx index a5530e3f3d..bef8652a85 100644 --- a/src/app/preferences/views/Details/Details.test.tsx +++ b/src/app/preferences/views/Details/Details.test.tsx @@ -18,6 +18,7 @@ import { setupMockServer( authResolvers.authenticate.handler(), authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler(), usersResolvers.getUser.handler(), usersResolvers.updateUser.handler() ); diff --git a/src/app/settings/views/Network/NetworkDiscoverySettings/NetworkDiscoverySettings.test.tsx b/src/app/settings/views/Network/NetworkDiscoverySettings/NetworkDiscoverySettings.test.tsx index 1af2376f14..d73ee33238 100644 --- a/src/app/settings/views/Network/NetworkDiscoverySettings/NetworkDiscoverySettings.test.tsx +++ b/src/app/settings/views/Network/NetworkDiscoverySettings/NetworkDiscoverySettings.test.tsx @@ -9,7 +9,8 @@ import { renderWithProviders, screen, setupMockServer } from "@/testing/utils"; const mockServer = setupMockServer( networkDiscoveryResolvers.listNetworkDiscoveries.handler(), - authResolvers.getCurrentUser.handler({ ...mockAuth, is_superuser: true }) + authResolvers.getCurrentUser.handler({ ...mockAuth, is_superuser: true }), + authResolvers.getMeStatistics.handler() ); describe("NetworkDiscoverySettings", () => { diff --git a/src/app/settings/views/Settings.test.tsx b/src/app/settings/views/Settings.test.tsx index 6bb6a33fa9..8853685825 100644 --- a/src/app/settings/views/Settings.test.tsx +++ b/src/app/settings/views/Settings.test.tsx @@ -2,9 +2,12 @@ import Settings from "./Settings"; import * as factory from "@/testing/factories"; import { authResolvers } from "@/testing/resolvers/auth"; -import { screen, renderWithProviders, setupMockServer } from "@/testing/utils"; +import { renderWithProviders, screen, setupMockServer } from "@/testing/utils"; -setupMockServer(authResolvers.getCurrentUser.handler()); +setupMockServer( + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() +); describe("Settings", () => { it("dispatches action to fetch config on load", () => { diff --git a/src/app/settings/views/UserManagement/views/Groups/components/AddMembers/AddMembers.tsx b/src/app/settings/views/UserManagement/views/Groups/components/AddMembers/AddMembers.tsx index 08ac5b51b0..88d99d1109 100644 --- a/src/app/settings/views/UserManagement/views/Groups/components/AddMembers/AddMembers.tsx +++ b/src/app/settings/views/UserManagement/views/Groups/components/AddMembers/AddMembers.tsx @@ -7,12 +7,9 @@ import pluralize from "pluralize"; import * as Yup from "yup"; import { useAddGroupMembers, useGroupMembers } from "@/app/api/query/groups"; +import type { UserWithStatistics } from "@/app/api/query/users"; import { useUsers } from "@/app/api/query/users"; -import type { - AddGroupMemberError, - UserGroupResponse, - UserWithSummaryResponse, -} from "@/app/apiclient"; +import type { AddGroupMemberError, UserGroupResponse } from "@/app/apiclient"; import FormikForm from "@/app/base/components/FormikForm"; import SearchBox from "@/app/base/components/SearchBox"; import usePagination from "@/app/base/hooks/usePagination/usePagination"; @@ -35,8 +32,8 @@ const AddMembersSchema = Yup.object().shape({ }); type AddMembersColumnDef = ColumnDef< - UserWithSummaryResponse, - Partial + UserWithStatistics, + Partial >; const AddMembers = ({ groupId }: AddMembersProps) => { diff --git a/src/app/settings/views/UserManagement/views/UsersList/UsersList.test.tsx b/src/app/settings/views/UserManagement/views/UsersList/UsersList.test.tsx index 32a734c459..53d7eacf2a 100644 --- a/src/app/settings/views/UserManagement/views/UsersList/UsersList.test.tsx +++ b/src/app/settings/views/UserManagement/views/UsersList/UsersList.test.tsx @@ -14,7 +14,8 @@ import { setupMockServer( usersResolvers.listUsers.handler(), usersResolvers.getUser.handler(), - authResolvers.getCurrentUser.handler() + authResolvers.getCurrentUser.handler(), + authResolvers.getMeStatistics.handler() ); describe("UsersList", () => { diff --git a/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.test.tsx b/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.test.tsx index efe7077165..74a9705748 100644 --- a/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.test.tsx +++ b/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.test.tsx @@ -23,7 +23,9 @@ import { const mockServer = setupMockServer( usersResolvers.listUsers.handler(), - usersResolvers.getUser.handler() + usersResolvers.getUser.handler(), + usersResolvers.listUsersStatistics.handler(), + authResolvers.getCurrentUser.handler() ); const { mockOpen } = await mockSidePanel(); diff --git a/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.tsx b/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.tsx index 7b04efd2d4..70cd6d31d4 100644 --- a/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.tsx +++ b/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/UsersTable.tsx @@ -20,7 +20,9 @@ const UsersTable = () => { query: { page: debouncedPage, size, username_or_email: searchText }, }); - const columns = useUsersTableColumns(); + const columns = useUsersTableColumns({ + statisticsPending: users.statisticsPending, + }); return (

diff --git a/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/useUsersTableColumns/useUsersTableColumns.tsx b/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/useUsersTableColumns/useUsersTableColumns.tsx index 6eceaee7c7..9b247d63a8 100644 --- a/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/useUsersTableColumns/useUsersTableColumns.tsx +++ b/src/app/settings/views/UserManagement/views/UsersList/components/UsersTable/useUsersTableColumns/useUsersTableColumns.tsx @@ -1,9 +1,10 @@ import { useMemo, useState } from "react"; +import { Spinner } from "@canonical/react-components"; import type { ColumnDef } from "@tanstack/react-table"; import { useGetCurrentUser } from "@/app/api/query/auth"; -import type { UserWithSummaryResponse } from "@/app/apiclient"; +import type { UserWithStatistics } from "@/app/api/query/users"; import TableActions from "@/app/base/components/TableActions"; import TableHeader from "@/app/base/components/TableHeader"; import TooltipButton from "@/app/base/components/TooltipButton"; @@ -15,11 +16,15 @@ import { } from "@/app/settings/views/UserManagement/views/UsersList/components"; type UsersColumnDef = ColumnDef< - UserWithSummaryResponse, - Partial + UserWithStatistics, + Partial >; -const useUsersTableColumns = (): UsersColumnDef[] => { +const useUsersTableColumns = ({ + statisticsPending, +}: { + statisticsPending: boolean; +}): UsersColumnDef[] => { const { openSidePanel } = useSidePanel(); const authUser = useGetCurrentUser(); const [isDisplayingUsername, setIsDisplayingUsername] = useState(true); @@ -74,6 +79,16 @@ const useUsersTableColumns = (): UsersColumnDef[] => { accessorKey: "machines_count", enableSorting: true, header: "Machines", + cell: ({ + row: { + original: { statistics }, + }, + }) => + statisticsPending ? ( + + ) : ( + statistics?.machines_count + ), }, { id: "is_local", @@ -82,10 +97,12 @@ const useUsersTableColumns = (): UsersColumnDef[] => { header: "Local", cell: ({ row: { - original: { is_local }, + original: { statistics }, }, }) => - is_local ? ( + statisticsPending ? ( + + ) : statistics?.is_local ? ( { accessorKey: "sshkeys_count", enableSorting: true, header: "MAAS keys", + cell: ({ + row: { + original: { statistics }, + }, + }) => + statisticsPending ? ( + + ) : ( + statistics?.sshkeys_count + ), }, { id: "actions", @@ -187,7 +214,7 @@ const useUsersTableColumns = (): UsersColumnDef[] => { }, }, ] as UsersColumnDef[], - [authUser.data?.id, isDisplayingUsername, openSidePanel] + [authUser.data?.id, isDisplayingUsername, openSidePanel, statisticsPending] ); }; diff --git a/src/app/store/notification/types/base.ts b/src/app/store/notification/types/base.ts index a42b8073e1..c8713a954f 100644 --- a/src/app/store/notification/types/base.ts +++ b/src/app/store/notification/types/base.ts @@ -1,13 +1,13 @@ import type { NotificationCategory, NotificationIdent } from "./enum"; -import type { UserWithSummaryResponse } from "@/app/apiclient"; +import type { UserResponse } from "@/app/apiclient"; import type { APIError } from "@/app/base/types"; import type { TimestampedModel } from "@/app/store/types/model"; import type { GenericState } from "@/app/store/types/state"; export type Notification = TimestampedModel & { ident: NotificationIdent | string; - user: UserWithSummaryResponse; + user: UserResponse; users: boolean; admins: boolean; message: string; diff --git a/src/setupTests.ts b/src/setupTests.ts index 640a8fa319..d27c6e3fd1 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -23,6 +23,19 @@ beforeAll(() => { }); }); +// Mock Web Animations API - not implemented in jsdom but used by +// @canonical/react-components ToastNotification/Animate +Element.prototype.animate = vi.fn().mockReturnValue({ + finished: Promise.resolve(), + cancel: vi.fn(), + finish: vi.fn(), + pause: vi.fn(), + play: vi.fn(), + reverse: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); + Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query) => ({ diff --git a/src/testing/factories/index.ts b/src/testing/factories/index.ts index 76748bad38..9b4cd6c22b 100644 --- a/src/testing/factories/index.ts +++ b/src/testing/factories/index.ts @@ -1,3 +1,103 @@ +export { config } from "./config"; +export { dhcpSnippet } from "./dhcpsnippet"; +export { discovery } from "./discovery"; +export { domain, domainDetails, domainResource } from "./domain"; +export { eventRecord, eventType } from "./event"; +export { fabric } from "./fabric"; +export { + architecture, + bondOptions, + certificateData, + certificateMetadata, + componentToDisable, + defaultMinHweKernel, + generatedCertificate, + hweKernel, + knownArchitecture, + knownBootArchitecture, + machineAction, + osInfo, + osInfoKernels, + osInfoOS, + pocketToDisable, + powerField, + powerFieldChoice, + powerType, + timestamp, + tlsCertificate, + version, +} from "./general"; +export { + availableImageFactory, + imageFactory, + imageStatisticsFactory, + imageStatusFactory, +} from "./image"; +export { imageSourceFactory } from "./imageSource"; +export { ipRange } from "./iprange"; +export { licenseKeys } from "./licensekeys"; +export { message } from "./message"; +export { modelRef } from "./model"; +export { nodeDevice } from "./nodedevice"; +export { + controller, + controllerDetails, + controllerVersionInfo, + controllerVersions, + controllerVlansHA, + device, + deviceDetails, + deviceInterface, + filterGroup, + machine, + machineDetails, + machineDevice, + machineEvent, + machineEventType, + machineInterface, + machineIpAddress, + machineNumaNode, + networkDiscoveredIP, + networkInterface, + networkLink, + nodeDisk, + nodeFilesystem, + nodePartition, + pod, + podDetails, + podMemoryResource, + podNetworkInterface, + podNuma, + podNumaCores, + podNumaGeneralMemory, + podNumaHugepageMemory, + podNumaMemory, + podPowerParameters, + podProject, + podResource, + podResources, + podStoragePool, + podStoragePoolResource, + podVM, + podVmCount, + testStatus, +} from "./nodes"; +export { notification } from "./notification"; +export { packageRepository } from "./packagerepository"; +export { reservedIp, reservedIpNodeSummary } from "./reservedip"; +export { resourcePool } from "./resourcepool"; +export { zonesGet } from "./response"; +export { script } from "./script"; +export { + partialScriptResult, + scriptResult, + scriptResultData, + scriptResultResult, +} from "./scriptResult"; +export { service } from "./service"; +export { space } from "./space"; +export { sshKey } from "./sshkey"; +export { sslKey } from "./sslkey"; export { architecturesState, bondOptionsState, @@ -20,15 +120,15 @@ export { fetchedAt, generalState, generatedCertificateState, - installTypeState, hweKernelsState, + installTypeState, + ipRangeState, knownArchitecturesState, knownBootArchitecturesState, - ipRangeState, licenseKeysState, locationState, - machineActionState, machineActionsState, + machineActionState, machineEventError, machineFilterGroup, machineState, @@ -36,8 +136,8 @@ export { machineStateCounts, machineStateDetails, machineStateDetailsItem, - machineStateListGroup, machineStateList, + machineStateListGroup, machineStateLists, machineStatus, machineStatuses, @@ -79,105 +179,6 @@ export { vmClusterState, vmClusterStatuses, } from "./state"; -export { config } from "./config"; -export { discovery } from "./discovery"; -export { domain, domainDetails, domainResource } from "./domain"; -export { eventRecord, eventType } from "./event"; -export { - controller, - controllerDetails, - controllerVersionInfo, - controllerVersions, - controllerVlansHA, - device, - deviceDetails, - deviceInterface, - filterGroup, - machine, - machineDetails, - machineDevice, - machineEvent, - machineEventType, - machineInterface, - machineIpAddress, - machineNumaNode, - networkDiscoveredIP, - networkInterface, - networkLink, - nodeDisk, - nodeFilesystem, - nodePartition, - pod, - podDetails, - podMemoryResource, - podNetworkInterface, - podNuma, - podNumaCores, - podNumaGeneralMemory, - podNumaHugepageMemory, - podNumaMemory, - podPowerParameters, - podProject, - podResource, - podResources, - podStoragePool, - podStoragePoolResource, - podVM, - podVmCount, - testStatus, -} from "./nodes"; -export { dhcpSnippet } from "./dhcpsnippet"; -export { fabric } from "./fabric"; -export { - imageFactory, - imageStatisticsFactory, - imageStatusFactory, - availableImageFactory, -} from "./image"; -export { imageSourceFactory } from "./imageSource"; -export { licenseKeys } from "./licensekeys"; -export { - architecture, - bondOptions, - certificateData, - certificateMetadata, - componentToDisable, - defaultMinHweKernel, - generatedCertificate, - hweKernel, - knownArchitecture, - knownBootArchitecture, - machineAction, - osInfo, - osInfoKernels, - osInfoOS, - pocketToDisable, - powerField, - powerFieldChoice, - powerType, - timestamp, - tlsCertificate, - version, -} from "./general"; -export { ipRange } from "./iprange"; -export { message } from "./message"; -export { modelRef } from "./model"; -export { nodeDevice } from "./nodedevice"; -export { notification } from "./notification"; -export { packageRepository } from "./packagerepository"; -export { reservedIp, reservedIpNodeSummary } from "./reservedip"; -export { resourcePool } from "./resourcepool"; -export { - partialScriptResult, - scriptResult, - scriptResultData, - scriptResultResult, -} from "./scriptResult"; -export { script } from "./script"; -export { service } from "./service"; -export { space } from "./space"; -export { sshKey } from "./sshkey"; -export { sslKey } from "./sslkey"; export { staticRoute } from "./staticroute"; export { subnet, @@ -194,7 +195,7 @@ export { } from "./subnet"; export { tag } from "./tag"; export { token } from "./token"; -export { user } from "./user"; +export { user, userStatistics } from "./user"; export { vlan, vlanDetails } from "./vlan"; export { virtualMachine, @@ -207,4 +208,3 @@ export { vmHost, } from "./vmcluster"; export { zone, zoneWithStatistics } from "./zone"; -export { zonesGet } from "./response"; diff --git a/src/testing/factories/user.ts b/src/testing/factories/user.ts index daa8ee0c1a..ef110a6f6d 100644 --- a/src/testing/factories/user.ts +++ b/src/testing/factories/user.ts @@ -1,17 +1,23 @@ import { define, random } from "cooky-cutter"; -import type { UserWithSummaryResponse } from "@/app/apiclient"; +import type { UserResponse, UserStatisticsResponse } from "@/app/apiclient"; import { timestamp } from "@/testing/factories/general"; -export const user = define({ +export const user = define({ id: random, - completed_intro: true, email: (i: number) => `email${i}@example.com`, - is_local: true, is_superuser: true, last_name: "MAAS", + first_name: "John", + date_joined: () => timestamp("Fri, 23 Oct. 2020 00:00:00"), last_login: () => timestamp("Fri, 23 Oct. 2020 00:00:00"), + username: (i: number) => `user${i}`, +}); + +export const userStatistics = define({ + id: random, + completed_intro: true, + is_local: true, machines_count: random, sshkeys_count: random, - username: (i: number) => `user${i}`, }); diff --git a/src/testing/resolvers/auth.ts b/src/testing/resolvers/auth.ts index 4ac113eff0..f716c93e1d 100644 --- a/src/testing/resolvers/auth.ts +++ b/src/testing/resolvers/auth.ts @@ -4,33 +4,39 @@ import { oAuthProviderFactory } from "../factories/auth"; import { BASE_URL } from "../utils"; import type { - PreLoginResponse, - PreLoginError, - CreateSessionError, - LoginResponse, CompleteIntroError, CreateOauthProviderError, - GetMeWithSummaryError, + CreateSessionError, + ExtendSessionError, + GetMeStatisticsError, GetOauthProviderError, + GetUserInfoError, + HandleOauthCallbackError, + HandleOauthCallbackResponse, + InitiateAuthFlowError, + InitiateAuthFlowResponse, LoginError, + LoginResponse, OAuthProviderResponse, + PreLoginError, + PreLoginResponse, UpdateOauthProviderError, UpdateUserError, - UserWithSummaryResponse, - ExtendSessionError, - InitiateAuthFlowResponse, - InitiateAuthFlowError, - HandleOauthCallbackError, - HandleOauthCallbackResponse, + UserResponse, + UserStatisticsResponse, } from "@/app/apiclient"; -import { user } from "@/testing/factories"; +import { user, userStatistics } from "@/testing/factories"; -const mockAuth: UserWithSummaryResponse = user({ +const mockAuth: UserResponse = user({ id: 1, email: "user1@example.com", username: "user1", }); +const mockAuthStatistics: UserStatisticsResponse = userStatistics({ + id: 1, +}); + const mockPreLoginResponse: PreLoginResponse = { is_authenticated: false, no_users: false, @@ -205,16 +211,29 @@ const authResolvers = { getCurrentUser: { resolved: false, handler: (data = mockAuth) => - http.get(`${BASE_URL}MAAS/a/v3/users/me_with_summary`, () => { + http.get(`${BASE_URL}MAAS/a/v3/users/me`, () => { authResolvers.getCurrentUser.resolved = true; return HttpResponse.json(data); }), - error: (error: GetMeWithSummaryError = mockAuthenticateError) => - http.get(`${BASE_URL}MAAS/a/v3/users/me_with_summary`, () => { + error: (error: GetUserInfoError = mockAuthenticateError) => + http.get(`${BASE_URL}MAAS/a/v3/users/me`, () => { authResolvers.getCurrentUser.resolved = true; return HttpResponse.json(error, { status: error.code }); }), }, + getMeStatistics: { + resolved: false, + handler: (data = mockAuthStatistics) => + http.get(`${BASE_URL}MAAS/a/v3/users/me:statistics`, () => { + authResolvers.getMeStatistics.resolved = true; + return HttpResponse.json(data); + }), + error: (error: GetMeStatisticsError = mockAuthenticateError) => + http.get(`${BASE_URL}MAAS/a/v3/users/me:statistics`, () => { + authResolvers.getMeStatistics.resolved = true; + return HttpResponse.json(error, { status: error.code }); + }), + }, completeIntro: { resolved: false, handler: () => diff --git a/src/testing/resolvers/users.ts b/src/testing/resolvers/users.ts index 1134a7dcd6..578c101e54 100644 --- a/src/testing/resolvers/users.ts +++ b/src/testing/resolvers/users.ts @@ -7,13 +7,17 @@ import type { DeleteUserError, GetUserError, ListUsersError, - ListUsersWithSummaryResponse, + ListUsersResponse, + ListUsersStatisticsError, + ListUsersStatisticsResponse, UpdateUserError, - UsersWithSummaryListResponse, } from "@/app/apiclient"; -import { user as userFactory } from "@/testing/factories"; +import { + user as userFactory, + userStatistics as userStatisticsFactory, +} from "@/testing/factories"; -const mockUsers: ListUsersWithSummaryResponse = { +const mockUsers: ListUsersResponse = { items: [ userFactory({ id: 1, @@ -34,12 +38,33 @@ const mockUsers: ListUsersWithSummaryResponse = { total: 3, }; +const mockUsersStatistics: ListUsersStatisticsResponse = { + items: [ + userStatisticsFactory({ + id: 1, + }), + userStatisticsFactory({ + id: 2, + }), + userStatisticsFactory({ + id: 3, + }), + ], + total: 3, +}; + const mockListUsersError: ListUsersError = { message: "Unauthorized", code: 401, kind: "Error", // This will always be 'Error' for every error response }; +const mockListUsersStatisticsError: ListUsersStatisticsError = { + message: "Unauthorized", + code: 401, + kind: "Error", +}; + const mockGetUserError: GetUserError = { message: "Not found", code: 404, @@ -61,17 +86,30 @@ const mockUpdateUserError: UpdateUserError = { const usersResolvers = { listUsers: { resolved: false, - handler: (data: UsersWithSummaryListResponse = mockUsers) => - http.get(`${BASE_URL}MAAS/a/v3/users_with_summary`, () => { + handler: (data: ListUsersResponse = mockUsers) => + http.get(`${BASE_URL}MAAS/a/v3/users`, () => { usersResolvers.listUsers.resolved = true; return HttpResponse.json(data); }), error: (error: ListUsersError = mockListUsersError) => - http.get(`${BASE_URL}MAAS/a/v3/users_with_summary`, () => { + http.get(`${BASE_URL}MAAS/a/v3/users`, () => { usersResolvers.listUsers.resolved = true; return HttpResponse.json(error, { status: error.code }); }), }, + listUsersStatistics: { + resolved: false, + handler: (data: ListUsersStatisticsResponse = mockUsersStatistics) => + http.get(`${BASE_URL}MAAS/a/v3/users\:statistics`, () => { + usersResolvers.listUsersStatistics.resolved = true; + return HttpResponse.json(data); + }), + error: (error: ListUsersStatisticsError = mockListUsersStatisticsError) => + http.get(`${BASE_URL}MAAS/a/v3/users\:statistics`, () => { + usersResolvers.listUsersStatistics.resolved = true; + return HttpResponse.json(error, { status: error.code }); + }), + }, getUser: { resolved: false, handler: () => @@ -130,4 +168,4 @@ const usersResolvers = { }, }; -export { usersResolvers, mockUsers }; +export { mockUsers, mockUsersStatistics, usersResolvers };