From d9c10a3fc89a43789f9cbc5bbb00e6c49176da73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 13:19:42 +0200 Subject: [PATCH 01/36] Change: Split targets query hooks into two modules Use dedicated modules for single and multiple entity query hooks. --- .../hooks/use-query/__tests__/Ticket.test.tsx | 214 ++++++++++++++++++ .../use-query/__tests__/Tickets.test.tsx | 201 +--------------- src/web/hooks/use-query/ticket.ts | 120 ++++++++++ src/web/hooks/use-query/tickets.ts | 115 +--------- src/web/pages/tickets/TicketComponent.tsx | 2 +- src/web/pages/tickets/TicketDetailsPage.tsx | 2 +- 6 files changed, 339 insertions(+), 315 deletions(-) create mode 100644 src/web/hooks/use-query/__tests__/Ticket.test.tsx create mode 100644 src/web/hooks/use-query/ticket.ts diff --git a/src/web/hooks/use-query/__tests__/Ticket.test.tsx b/src/web/hooks/use-query/__tests__/Ticket.test.tsx new file mode 100644 index 0000000000..a863b0bea2 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/Ticket.test.tsx @@ -0,0 +1,214 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import Ticket, {TICKET_STATUS} from 'gmp/models/ticket'; +import {createSession} from 'gmp/testing'; +import { + useCloneTicket, + useCreateTicket, + useDeleteTicket, + useDownloadTicket, + useGetTicket, + useSaveTicket, +} from 'web/hooks/use-query/ticket'; + +const ticket = Ticket.fromElement({ + _id: 'ticket-1', + name: 'Test Ticket', + status: TICKET_STATUS.open, + assigned_to: {user: {_id: 'u1', name: 'admin'}}, + open_time: '2024-01-10T08:00:00Z', + open_note: 'Ticket opened', +}); + +const SingleTicketComponent = ({id}: {id: string}) => { + const {data, isLoading, isError} = useGetTicket({id}); + + if (isLoading) return
Loading...
; + if (isError) return
Error
; + if (!data) return
No data
; + + return ( +
+ {data.name} + {data.id} +
+ ); +}; + +const createGmp = ({token}: {token?: string} = {token: 'test-token'}) => ({ + session: createSession({token}), + settings: {}, + ticket: { + get: testing.fn().mockResolvedValue({data: ticket}), + create: testing.fn().mockResolvedValue({data: {id: 'ticket-3'}}), + save: testing.fn().mockResolvedValue({data: {id: 'ticket-1'}}), + clone: testing.fn().mockResolvedValue({data: {id: 'ticket-3'}}), + delete: testing.fn().mockResolvedValue(undefined), + export: testing.fn().mockResolvedValue({data: 'ticket-content'}), + }, +}); + +describe('useGetTicket', () => { + test('should fetch a single ticket', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + render(); + + await waitFor(() => { + expect(screen.getByTestId('ticket-name')).toHaveTextContent( + 'Test Ticket', + ); + }); + + expect(gmp.ticket.get).toHaveBeenCalledWith({id: 'ticket-1'}); + expect(screen.getByTestId('ticket-id')).toHaveTextContent('ticket-1'); + }); + + test('should show loading state initially', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + render(); + + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); + + test('should not fetch a ticket when the ID is empty', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + render(); + + expect(screen.getByTestId('no-data')).toBeInTheDocument(); + expect(gmp.ticket.get).not.toHaveBeenCalled(); + }); + + test('should not fetch a ticket without a session token', () => { + const gmp = createGmp({token: undefined}); + const {render} = rendererWith({gmp, router: true}); + + render(); + + expect(gmp.ticket.get).not.toHaveBeenCalled(); + }); + + test('should show an error when fetching a ticket fails', async () => { + const gmp = createGmp(); + gmp.ticket.get.mockRejectedValue(new Error('Request failed')); + const {render} = rendererWith({gmp, router: true}); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('error')).toBeInTheDocument(); + }); + }); +}); + +describe('ticket mutation hooks', () => { + test('should create a ticket', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + const input = {resultId: 'result-1', userId: 'user-1', note: 'Note'}; + + const TestComponent = () => { + const mutation = useCreateTicket(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => { + expect(gmp.ticket.create).toHaveBeenCalledWith(input); + }); + }); + + test('should save a ticket', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + const input = { + id: 'ticket-1', + status: TICKET_STATUS.fixed, + userId: 'user-1', + fixedNote: 'Fixed', + }; + + const TestComponent = () => { + const mutation = useSaveTicket(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + + await waitFor(() => { + expect(gmp.ticket.save).toHaveBeenCalledWith(input); + }); + }); + + test('should clone a ticket', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCloneTicket(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + + await waitFor(() => { + expect(gmp.ticket.clone).toHaveBeenCalledWith({id: 'ticket-1'}); + }); + }); + + test('should delete a ticket', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeleteTicket(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => { + expect(gmp.ticket.delete).toHaveBeenCalledWith({id: 'ticket-1'}); + }); + }); + + test('should download a ticket', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDownloadTicket(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Download'})); + + await waitFor(() => { + expect(gmp.ticket.export).toHaveBeenCalledWith({id: 'ticket-1'}); + }); + }); +}); diff --git a/src/web/hooks/use-query/__tests__/Tickets.test.tsx b/src/web/hooks/use-query/__tests__/Tickets.test.tsx index 7f15e3e4e1..07349a97a7 100644 --- a/src/web/hooks/use-query/__tests__/Tickets.test.tsx +++ b/src/web/hooks/use-query/__tests__/Tickets.test.tsx @@ -4,21 +4,13 @@ */ import {describe, test, expect, testing} from '@gsa/testing'; -import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import {rendererWith, screen, waitFor} from 'web/testing'; import CollectionCounts from 'gmp/collection/collection-counts'; import type FilterType from 'gmp/models/filter/filter-type'; import QueryFilter from 'gmp/models/filter/query-filter'; import Ticket, {TICKET_STATUS} from 'gmp/models/ticket'; import {createSession} from 'gmp/testing'; -import { - useCloneTicket, - useCreateTicket, - useDeleteTicket, - useDownloadTicket, - useGetTicket, - useGetTickets, - useSaveTicket, -} from 'web/hooks/use-query/tickets'; +import {useGetTickets} from 'web/hooks/use-query/tickets'; const ticket = Ticket.fromElement({ _id: 'ticket-1', @@ -40,27 +32,6 @@ const ticket2 = Ticket.fromElement({ const filter = QueryFilter.fromString('name~test'); -const SingleTicketComponent = ({id}: {id: string}) => { - const {data, isLoading, isError} = useGetTicket({id}); - - if (isLoading) { - return
Loading...
; - } - if (isError) { - return
Error
; - } - if (!data) { - return
No data
; - } - - return ( -
- {data.name} - {data.id} -
- ); -}; - const TicketListComponent = ({filter}: {filter?: FilterType}) => { const {data, isLoading, isError} = useGetTickets({filter}); @@ -88,14 +59,6 @@ const TicketListComponent = ({filter}: {filter?: FilterType}) => { const createGmp = ({token}: {token?: string} = {token: 'test-token'}) => ({ session: createSession({token}), settings: {}, - ticket: { - get: testing.fn().mockResolvedValue({data: ticket}), - create: testing.fn().mockResolvedValue({data: {id: 'ticket-3'}}), - save: testing.fn().mockResolvedValue({data: {id: 'ticket-1'}}), - clone: testing.fn().mockResolvedValue({data: {id: 'ticket-3'}}), - delete: testing.fn().mockResolvedValue(undefined), - export: testing.fn().mockResolvedValue({data: 'ticket-content'}), - }, tickets: { get: testing.fn().mockResolvedValue({ data: [ticket, ticket2], @@ -107,62 +70,6 @@ const createGmp = ({token}: {token?: string} = {token: 'test-token'}) => ({ }, }); -describe('useGetTicket', () => { - test('should fetch a single ticket', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - render(); - - await waitFor(() => { - expect(screen.getByTestId('ticket-name')).toHaveTextContent( - 'Test Ticket', - ); - }); - - expect(gmp.ticket.get).toHaveBeenCalledWith({id: 'ticket-1'}); - expect(screen.getByTestId('ticket-id')).toHaveTextContent('ticket-1'); - }); - - test('should show loading state initially', () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - render(); - - expect(screen.getByTestId('loading')).toBeInTheDocument(); - }); - - test('should not fetch a ticket when the ID is empty', () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - render(); - - expect(screen.getByTestId('no-data')).toBeInTheDocument(); - expect(gmp.ticket.get).not.toHaveBeenCalled(); - }); - - test('should not fetch a ticket without a session token', () => { - const gmp = createGmp({token: undefined}); - const {render} = rendererWith({gmp, router: true}); - - render(); - - expect(gmp.ticket.get).not.toHaveBeenCalled(); - }); - - test('should show an error when fetching a ticket fails', async () => { - const gmp = createGmp(); - gmp.ticket.get.mockRejectedValue(new Error('Request failed')); - const {render} = rendererWith({gmp, router: true}); - - render(); - - await waitFor(() => { - expect(screen.getByTestId('error')).toBeInTheDocument(); - }); - }); -}); - describe('useGetTickets', () => { test('should fetch a list of tickets', async () => { const gmp = createGmp(); @@ -199,107 +106,3 @@ describe('useGetTickets', () => { }); }); }); - -describe('ticket mutation hooks', () => { - test('should create a ticket', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - const input = {resultId: 'result-1', userId: 'user-1', note: 'Note'}; - - const TestComponent = () => { - const mutation = useCreateTicket(); - return ; - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Create'})); - - await waitFor(() => { - expect(gmp.ticket.create).toHaveBeenCalledWith(input); - }); - }); - - test('should save a ticket', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - const input = { - id: 'ticket-1', - status: TICKET_STATUS.fixed, - userId: 'user-1', - fixedNote: 'Fixed', - }; - - const TestComponent = () => { - const mutation = useSaveTicket(); - return ; - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(gmp.ticket.save).toHaveBeenCalledWith(input); - }); - }); - - test('should clone a ticket', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useCloneTicket(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Clone'})); - - await waitFor(() => { - expect(gmp.ticket.clone).toHaveBeenCalledWith({id: 'ticket-1'}); - }); - }); - - test('should delete a ticket', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useDeleteTicket(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Delete'})); - - await waitFor(() => { - expect(gmp.ticket.delete).toHaveBeenCalledWith({id: 'ticket-1'}); - }); - }); - - test('should download a ticket', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useDownloadTicket(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Download'})); - - await waitFor(() => { - expect(gmp.ticket.export).toHaveBeenCalledWith({id: 'ticket-1'}); - }); - }); -}); diff --git a/src/web/hooks/use-query/ticket.ts b/src/web/hooks/use-query/ticket.ts new file mode 100644 index 0000000000..784bd73d20 --- /dev/null +++ b/src/web/hooks/use-query/ticket.ts @@ -0,0 +1,120 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type EntityActionData} from 'gmp/commands/entity'; +import type Response from 'gmp/http/response'; +import { + type default as Ticket, + type TicketStatusValue, +} from 'gmp/models/ticket'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntity from 'web/queries/useGetEntity'; +import useGmpMutation from 'web/queries/useGmpMutation'; + +interface UseGetTicketParams { + id: string; +} + +interface UseTicketMutationCallbacks { + onSuccess?: (response: TResponse) => void; + onError?: (error: Error) => void; +} + +interface TicketCreateInput { + resultId: string; + userId: string; + note?: string; +} + +interface TicketSaveInput { + id: string; + openNote?: string; + fixedNote?: string; + closedNote?: string; + status: TicketStatusValue; + userId: string; +} + +export const useGetTicket = ({id}: UseGetTicketParams) => { + const gmp = useGmp(); + return useGetEntity({ + gmpMethod: gmp.ticket.get.bind(gmp.ticket), + queryId: 'get_ticket', + id, + }); +}; + +export const useCreateTicket = ({ + onSuccess, + onError, +}: UseTicketMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.ticket.create(data); + return response.data; + }, + invalidateQueryIds: ['get_tickets'], + onSuccess, + onError, + }); +}; + +export const useSaveTicket = ({ + onSuccess, + onError, +}: UseTicketMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.ticket.save(data); + return response.data; + }, + invalidateQueryIds: ['get_tickets', 'get_ticket'], + onSuccess, + onError, + }); +}; + +export const useCloneTicket = ({ + onSuccess, + onError, +}: UseTicketMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation<{id: string}, EntityActionData>({ + gmpMethod: async ({id}: {id: string}) => { + const response = await gmp.ticket.clone({id}); + return response.data; + }, + invalidateQueryIds: ['get_tickets'], + onSuccess, + onError, + }); +}; + +export const useDeleteTicket = ({ + onSuccess, + onError, +}: UseTicketMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation<{id: string}, void>({ + gmpMethod: ({id}: {id: string}) => gmp.ticket.delete({id}), + invalidateQueryIds: ['get_tickets', 'get_ticket'], + onSuccess, + onError, + }); +}; + +export const useDownloadTicket = ({ + onSuccess, + onError, +}: UseTicketMutationCallbacks> = {}) => { + const gmp = useGmp(); + return useGmpMutation<{id: string}, Response>({ + gmpMethod: ({id}: {id: string}) => gmp.ticket.export({id}), + onSuccess, + onError, + }); +}; diff --git a/src/web/hooks/use-query/tickets.ts b/src/web/hooks/use-query/tickets.ts index d0a45fc3dc..e2aa36deba 100644 --- a/src/web/hooks/use-query/tickets.ts +++ b/src/web/hooks/use-query/tickets.ts @@ -3,46 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {type EntityActionData} from 'gmp/commands/entity'; -import type Response from 'gmp/http/response'; import {type FilterType} from 'gmp/models/filter'; -import { - type default as Ticket, - type TicketStatusValue, -} from 'gmp/models/ticket'; +import type Ticket from 'gmp/models/ticket'; import useGmp from 'web/hooks/useGmp'; import useGetEntities from 'web/queries/useGetEntities'; -import useGetEntity from 'web/queries/useGetEntity'; -import useGmpMutation from 'web/queries/useGmpMutation'; interface UseGetTicketsParams { filter?: FilterType; } -interface UseGetTicketParams { - id: string; -} - -interface UseTicketMutationCallbacks { - onSuccess?: (response: TResponse) => void; - onError?: (error: Error) => void; -} - -interface TicketCreateInput { - resultId: string; - userId: string; - note?: string; -} - -interface TicketSaveInput { - id: string; - openNote?: string; - fixedNote?: string; - closedNote?: string; - status: TicketStatusValue; - userId: string; -} - export const useGetTickets = ({filter}: UseGetTicketsParams = {}) => { const gmp = useGmp(); return useGetEntities({ @@ -52,85 +21,3 @@ export const useGetTickets = ({filter}: UseGetTicketsParams = {}) => { keepPreviousData: true, }); }; - -export const useGetTicket = ({id}: UseGetTicketParams) => { - const gmp = useGmp(); - return useGetEntity({ - gmpMethod: gmp.ticket.get.bind(gmp.ticket), - queryId: 'get_ticket', - id, - }); -}; - -export const useCreateTicket = ({ - onSuccess, - onError, -}: UseTicketMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation({ - gmpMethod: async data => { - const response = await gmp.ticket.create(data); - return response.data; - }, - invalidateQueryIds: ['get_tickets'], - onSuccess, - onError, - }); -}; - -export const useSaveTicket = ({ - onSuccess, - onError, -}: UseTicketMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation({ - gmpMethod: async data => { - const response = await gmp.ticket.save(data); - return response.data; - }, - invalidateQueryIds: ['get_tickets', 'get_ticket'], - onSuccess, - onError, - }); -}; - -export const useCloneTicket = ({ - onSuccess, - onError, -}: UseTicketMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation<{id: string}, EntityActionData>({ - gmpMethod: async ({id}: {id: string}) => { - const response = await gmp.ticket.clone({id}); - return response.data; - }, - invalidateQueryIds: ['get_tickets'], - onSuccess, - onError, - }); -}; - -export const useDeleteTicket = ({ - onSuccess, - onError, -}: UseTicketMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation<{id: string}, void>({ - gmpMethod: ({id}: {id: string}) => gmp.ticket.delete({id}), - invalidateQueryIds: ['get_tickets', 'get_ticket'], - onSuccess, - onError, - }); -}; - -export const useDownloadTicket = ({ - onSuccess, - onError, -}: UseTicketMutationCallbacks> = {}) => { - const gmp = useGmp(); - return useGmpMutation<{id: string}, Response>({ - gmpMethod: ({id}: {id: string}) => gmp.ticket.export({id}), - onSuccess, - onError, - }); -}; diff --git a/src/web/pages/tickets/TicketComponent.tsx b/src/web/pages/tickets/TicketComponent.tsx index d5e0c6bbbc..7c10d27678 100644 --- a/src/web/pages/tickets/TicketComponent.tsx +++ b/src/web/pages/tickets/TicketComponent.tsx @@ -21,7 +21,7 @@ import { useCreateTicket, useDeleteTicket, useSaveTicket, -} from 'web/hooks/use-query/tickets'; +} from 'web/hooks/use-query/ticket'; import {useGetUsers} from 'web/hooks/use-query/users'; import useGmp from 'web/hooks/useGmp'; import useTranslation from 'web/hooks/useTranslation'; diff --git a/src/web/pages/tickets/TicketDetailsPage.tsx b/src/web/pages/tickets/TicketDetailsPage.tsx index 70c57b778f..671216d305 100644 --- a/src/web/pages/tickets/TicketDetailsPage.tsx +++ b/src/web/pages/tickets/TicketDetailsPage.tsx @@ -45,7 +45,7 @@ import DeleteIcon from 'web/entity/icon/DeleteIcon'; import EditIcon from 'web/entity/icon/EditIcon'; import {goToDetails, goToList} from 'web/entity/navigation'; import EntityTags from 'web/entity/Tags'; -import {useGetTicket} from 'web/hooks/use-query/tickets'; +import {useGetTicket} from 'web/hooks/use-query/ticket'; import useTranslation from 'web/hooks/useTranslation'; import TicketComponent from 'web/pages/tickets/TicketComponent'; import TicketDetails from 'web/pages/tickets/TicketDetails'; From b9c267e614deae7879576d35d6fde07f2d703c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 13:30:28 +0200 Subject: [PATCH 02/36] Change: Split users query hooks into two modules Use dedicated modules for single and multiple entity query hooks. --- .../hooks/use-query/__tests__/User.test.tsx | 260 ++++++++++++++++ .../hooks/use-query/__tests__/Users.test.tsx | 289 +----------------- src/web/hooks/use-query/user.ts | 125 ++++++++ src/web/hooks/use-query/users.ts | 111 ------- src/web/pages/users/UserComponent.tsx | 2 +- src/web/pages/users/UserDetailsPage.tsx | 2 +- 6 files changed, 389 insertions(+), 400 deletions(-) create mode 100644 src/web/hooks/use-query/__tests__/User.test.tsx create mode 100644 src/web/hooks/use-query/user.ts diff --git a/src/web/hooks/use-query/__tests__/User.test.tsx b/src/web/hooks/use-query/__tests__/User.test.tsx new file mode 100644 index 0000000000..b350a5b08d --- /dev/null +++ b/src/web/hooks/use-query/__tests__/User.test.tsx @@ -0,0 +1,260 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import User from 'gmp/models/user'; +import {createSession} from 'gmp/testing'; +import { + useCloneUser, + useCreateUser, + useDeleteUser, + useDownloadUser, + useGetUser, + useSaveUser, +} from 'web/hooks/use-query/user'; + +const userData = { + accessHosts: ['localhost', '127.0.0.1'], + authMethod: 'password', + comment: 'comment', + groupIds: ['group-id'], + hostsAllow: '0', + name: 'user', + password: 'password', + roleIds: ['role-id'], +}; + +const user = new User({id: 'user-id', name: 'user'}); + +const createGmp = ( + userMethods: Record = {}, + token = 'test-token', +) => ({ + session: createSession({token}), + settings: {}, + user: { + get: testing.fn().mockResolvedValue({data: user}), + create: testing.fn().mockResolvedValue({data: {id: 'created'}}), + save: testing.fn().mockResolvedValue({data: {id: 'saved'}}), + clone: testing.fn().mockResolvedValue({data: {id: 'cloned'}}), + delete: testing.fn().mockResolvedValue(undefined), + export: testing.fn().mockResolvedValue({data: 'user-content'}), + ...userMethods, + }, +}); + +describe('useGetUser', () => { + test('should fetch a user by ID', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetUser({id: 'user-id'}); + return
{data?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('user'); + }); + + expect(gmp.user.get).toHaveBeenCalledWith({id: 'user-id'}); + }); + + test('should not fetch a user without a session token', () => { + const gmp = createGmp({}, ''); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetUser({id: 'user-id'}); + return
; + }; + + render(); + + expect(gmp.user.get).not.toHaveBeenCalled(); + }); + + test('should not fetch a user when the ID is empty', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetUser({id: ''}); + return
; + }; + + render(); + + expect(gmp.user.get).not.toHaveBeenCalled(); + }); +}); + +describe('useCreateUser', () => { + test('forwards access_hosts arrays when creating a user', async () => { + const create = testing.fn().mockResolvedValue({data: {id: 'created'}}); + const gmp = createGmp({create}); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreateUser(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => expect(create).toHaveBeenCalledWith(userData)); + }); + + test('calls onSuccess with created user data', async () => { + const onSuccess = testing.fn(); + const gmp = createGmp({onSuccess: undefined}); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreateUser({onSuccess}); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith({id: 'created'}), + ); + }); + + test('calls onError when creating a user fails', async () => { + const error = new Error('Create failed'); + const onError = testing.fn(); + const gmp = createGmp({create: testing.fn().mockRejectedValue(error)}); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreateUser({onError}); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); +}); + +describe('useSaveUser', () => { + test('derives oldName when saving', async () => { + const save = testing.fn().mockResolvedValue({data: {id: 'saved'}}); + const gmp = createGmp({save}); + const {render} = rendererWith({gmp, router: true}); + const input = {...userData, id: 'user-id'}; + + const TestComponent = () => { + const mutation = useSaveUser(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith({...input, oldName: userData.name}); + }); + }); + + test('preserves an explicit oldName when saving', async () => { + const save = testing.fn().mockResolvedValue({data: {id: 'saved'}}); + const gmp = createGmp({save}); + const {render} = rendererWith({gmp, router: true}); + const input = {...userData, id: 'user-id', oldName: 'previous-user'}; + + const TestComponent = () => { + const mutation = useSaveUser(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + + await waitFor(() => expect(save).toHaveBeenCalledWith(input)); + }); + + test('calls callbacks when saving fails', async () => { + const error = new Error('Save failed'); + const onError = testing.fn(); + const save = testing.fn().mockRejectedValue(error); + const gmp = createGmp({save}); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useSaveUser({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); +}); + +describe('remaining user mutation hooks', () => { + test('should clone a user', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + const TestComponent = () => { + const mutation = useCloneUser(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + await waitFor(() => + expect(gmp.user.clone).toHaveBeenCalledWith({id: 'user-id'}), + ); + }); + + test('should delete a user and forward the inheritor ID', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + const input = {id: 'user-id', inheritorId: 'inheritor-id'}; + const TestComponent = () => { + const mutation = useDeleteUser(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + await waitFor(() => expect(gmp.user.delete).toHaveBeenCalledWith(input)); + }); + + test('should download a user', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + const TestComponent = () => { + const mutation = useDownloadUser(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Download'})); + await waitFor(() => + expect(gmp.user.export).toHaveBeenCalledWith({id: 'user-id'}), + ); + }); +}); diff --git a/src/web/hooks/use-query/__tests__/Users.test.tsx b/src/web/hooks/use-query/__tests__/Users.test.tsx index bd9bb7a08e..912881bf6e 100644 --- a/src/web/hooks/use-query/__tests__/Users.test.tsx +++ b/src/web/hooks/use-query/__tests__/Users.test.tsx @@ -12,13 +12,7 @@ import {createSession} from 'gmp/testing'; import { useBulkDeleteUsers, useBulkExportUsers, - useCloneUser, - useCreateUser, - useDeleteUser, - useDownloadUser, - useGetUser, useGetUsers, - useSaveUser, } from 'web/hooks/use-query/users'; const userData = { @@ -41,15 +35,7 @@ const createGmp = ( ) => ({ session: createSession({token}), settings: {}, - user: { - get: testing.fn().mockResolvedValue({data: user}), - create: testing.fn().mockResolvedValue({data: {id: 'created'}}), - save: testing.fn().mockResolvedValue({data: {id: 'saved'}}), - clone: testing.fn().mockResolvedValue({data: {id: 'cloned'}}), - delete: testing.fn().mockResolvedValue(undefined), - export: testing.fn().mockResolvedValue({data: 'user-content'}), - ...userMethods, - }, + user: userMethods, users: { get: testing.fn().mockResolvedValue({ data: [user], @@ -64,7 +50,7 @@ const createGmp = ( }, }); -describe('user query hooks', () => { +describe('useGetUsers', () => { test('should fetch users with a filter', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); @@ -83,24 +69,6 @@ describe('user query hooks', () => { expect(gmp.users.get).toHaveBeenCalledWith({filter: userFilter}); }); - test('should fetch a user by ID', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const {data} = useGetUser({id: 'user-id'}); - return
{data?.name}
; - }; - - render(); - - await waitFor(() => { - expect(screen.getByTestId('user')).toHaveTextContent('user'); - }); - - expect(gmp.user.get).toHaveBeenCalledWith({id: 'user-id'}); - }); - test('should not fetch users without a session token', () => { const gmp = createGmp({}, ''); const {render} = rendererWith({gmp, router: true}); @@ -115,238 +83,6 @@ describe('user query hooks', () => { expect(gmp.users.get).not.toHaveBeenCalled(); }); - test('should not fetch a user when the ID is empty', () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - useGetUser({id: ''}); - return
; - }; - - render(); - - expect(gmp.user.get).not.toHaveBeenCalled(); - }); -}); - -describe('useCreateUser', () => { - test('forwards access_hosts arrays when creating a user', async () => { - const create = testing.fn().mockResolvedValue({data: {id: 'created'}}); - const gmp = createGmp({create}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useCreateUser(); - return ; - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Create'})); - - await waitFor(() => { - expect(create).toHaveBeenCalledWith(userData); - }); - }); - test('calls onSuccess with created user data', async () => { - const create = testing.fn().mockResolvedValue({data: {id: 'created'}}); - const onSuccess = testing.fn(); - const gmp = createGmp({create}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useCreateUser({onSuccess}); - return ; - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Create'})); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledWith({id: 'created'}); - }); - }); - - test('calls onError when creating a user fails', async () => { - const error = new Error('Create failed'); - const create = testing.fn().mockRejectedValue(error); - const onError = testing.fn(); - const gmp = createGmp({create}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useCreateUser({onError}); - return ; - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Create'})); - - await waitFor(() => { - expect(onError).toHaveBeenCalled(); - expect(onError.mock.calls[0][0]).toBe(error); - }); - }); -}); - -describe('useSaveUser', () => { - test('forwards accessHosts arrays and derives oldName when saving', async () => { - const save = testing.fn().mockResolvedValue({data: {id: 'saved'}}); - const gmp = createGmp({save}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useSaveUser(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(save).toHaveBeenCalledWith({ - ...userData, - id: 'user-id', - oldName: userData.name, - }); - }); - }); - - test('preserves an explicit oldName when saving', async () => { - const save = testing.fn().mockResolvedValue({data: {id: 'saved'}}); - const gmp = createGmp({save}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useSaveUser(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(save).toHaveBeenCalledWith({ - ...userData, - id: 'user-id', - oldName: 'previous-user', - }); - }); - }); - - test('calls onSuccess with saved user data', async () => { - const save = testing.fn().mockResolvedValue({data: {id: 'saved'}}); - const onSuccess = testing.fn(); - const gmp = createGmp({save}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useSaveUser({onSuccess}); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledWith({id: 'saved'}); - }); - }); - - test('calls onError when saving a user fails', async () => { - const error = new Error('Save failed'); - const save = testing.fn().mockRejectedValue(error); - const onError = testing.fn(); - const gmp = createGmp({save}); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useSaveUser({onError}); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Save'})); - - await waitFor(() => { - expect(onError).toHaveBeenCalled(); - expect(onError.mock.calls[0][0]).toBe(error); - }); - }); -}); - -describe('remaining user mutation hooks', () => { - test('should clone a user', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useCloneUser(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Clone'})); - - await waitFor(() => { - expect(gmp.user.clone).toHaveBeenCalledWith({id: 'user-id'}); - }); - }); - - test('should delete a user and forward the inheritor ID', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useDeleteUser(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Delete'})); - - await waitFor(() => { - expect(gmp.user.delete).toHaveBeenCalledWith({ - id: 'user-id', - inheritorId: 'inheritor-id', - }); - }); - }); - test('should bulk delete users with options', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); @@ -368,27 +104,6 @@ describe('remaining user mutation hooks', () => { }); }); - test('should download a user', async () => { - const gmp = createGmp(); - const {render} = rendererWith({gmp, router: true}); - - const TestComponent = () => { - const mutation = useDownloadUser(); - return ( - - ); - }; - - render(); - fireEvent.click(screen.getByRole('button', {name: 'Download'})); - - await waitFor(() => { - expect(gmp.user.export).toHaveBeenCalledWith({id: 'user-id'}); - }); - }); - test('should bulk export users by entity list and filter', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); diff --git a/src/web/hooks/use-query/user.ts b/src/web/hooks/use-query/user.ts new file mode 100644 index 0000000000..6e357556f1 --- /dev/null +++ b/src/web/hooks/use-query/user.ts @@ -0,0 +1,125 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type EntityActionData} from 'gmp/commands/entity'; +import type Response from 'gmp/http/response'; +import type User from 'gmp/models/user'; +import useGmp from 'web/hooks/useGmp'; +import {type RefetchIntervalFn} from 'web/queries/helpers'; +import useGetEntity from 'web/queries/useGetEntity'; +import useGmpMutation from 'web/queries/useGmpMutation'; + +interface UseGetUserParams { + id: string; + refetchInterval?: RefetchIntervalFn; +} + +interface UseUserMutationCallbacks { + onSuccess?: (response: TResponse) => void; + onError?: (error: Error) => void; +} + +interface UserCreateInput { + accessHosts: string[]; + authMethod: string; + comment: string; + groupIds: string[]; + hostsAllow: string; + name: string; + password: string; + roleIds: string[]; +} + +interface UserSaveInput extends UserCreateInput { + id: string; + oldName?: string; +} + +export const useGetUser = ({id, refetchInterval}: UseGetUserParams) => { + const gmp = useGmp(); + return useGetEntity({ + gmpMethod: gmp.user.get.bind(gmp.user), + queryId: 'get_user', + id, + refetchInterval, + }); +}; + +export const useCreateUser = ({ + onSuccess, + onError, +}: UseUserMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.user.create(data); + return response.data; + }, + invalidateQueryIds: ['get_users'], + onSuccess, + onError, + }); +}; + +export const useSaveUser = ({ + onSuccess, + onError, +}: UseUserMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.user.save({ + ...data, + oldName: data.oldName ?? data.name, + }); + return response.data; + }, + invalidateQueryIds: ['get_users', 'get_user'], + onSuccess, + onError, + }); +}; + +export const useCloneUser = ({ + onSuccess, + onError, +}: UseUserMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation<{id: string}, EntityActionData>({ + gmpMethod: async ({id}: {id: string}) => { + const response = await gmp.user.clone({id}); + return response.data; + }, + invalidateQueryIds: ['get_users'], + onSuccess, + onError, + }); +}; + +export const useDeleteUser = ({ + onSuccess, + onError, +}: UseUserMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation<{id: string; inheritorId?: string}, void>({ + gmpMethod: ({id, inheritorId}: {id: string; inheritorId?: string}) => + gmp.user.delete({id, inheritorId: inheritorId ?? ''}), + invalidateQueryIds: ['get_users', 'get_user'], + onSuccess, + onError, + }); +}; + +export const useDownloadUser = ({ + onSuccess, + onError, +}: UseUserMutationCallbacks> = {}) => { + const gmp = useGmp(); + return useGmpMutation<{id: string}, Response>({ + gmpMethod: (entity: {id: string}) => gmp.user.export(entity), + onSuccess, + onError, + }); +}; diff --git a/src/web/hooks/use-query/users.ts b/src/web/hooks/use-query/users.ts index a5de2994f7..4c608935bf 100644 --- a/src/web/hooks/use-query/users.ts +++ b/src/web/hooks/use-query/users.ts @@ -3,15 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {type EntityActionData} from 'gmp/commands/entity'; import type Response from 'gmp/http/response'; import {type FilterType} from 'gmp/models/filter'; import {isFilterType} from 'gmp/models/filter/utils'; import type User from 'gmp/models/user'; import useGmp from 'web/hooks/useGmp'; -import {type RefetchIntervalFn} from 'web/queries/helpers'; import useGetEntities from 'web/queries/useGetEntities'; -import useGetEntity from 'web/queries/useGetEntity'; import useGmpMutation from 'web/queries/useGmpMutation'; interface UseGetUsersParams { @@ -19,32 +16,11 @@ interface UseGetUsersParams { enabled?: boolean; } -interface UseGetUserParams { - id: string; - refetchInterval?: RefetchIntervalFn; -} - interface UseUserMutationCallbacks { onSuccess?: (response: TResponse) => void; onError?: (error: Error) => void; } -interface UserCreateInput { - accessHosts: string[]; - authMethod: string; - comment: string; - groupIds: string[]; - hostsAllow: string; - name: string; - password: string; - roleIds: string[]; -} - -interface UserSaveInput extends UserCreateInput { - id: string; - oldName?: string; -} - interface BulkDeleteUsersInput { users: User[]; options?: {inheritor_id?: string}; @@ -66,81 +42,6 @@ export const useGetUsers = ({ }); }; -export const useGetUser = ({id, refetchInterval}: UseGetUserParams) => { - const gmp = useGmp(); - return useGetEntity({ - gmpMethod: gmp.user.get.bind(gmp.user), - queryId: 'get_user', - id, - refetchInterval, - }); -}; - -export const useCreateUser = ({ - onSuccess, - onError, -}: UseUserMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation({ - gmpMethod: async data => { - const response = await gmp.user.create(data); - return response.data; - }, - invalidateQueryIds: ['get_users'], - onSuccess, - onError, - }); -}; - -export const useSaveUser = ({ - onSuccess, - onError, -}: UseUserMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation({ - gmpMethod: async data => { - const response = await gmp.user.save({ - ...data, - oldName: data.oldName ?? data.name, - }); - return response.data; - }, - invalidateQueryIds: ['get_users', 'get_user'], - onSuccess, - onError, - }); -}; - -export const useCloneUser = ({ - onSuccess, - onError, -}: UseUserMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation<{id: string}, EntityActionData>({ - gmpMethod: async ({id}: {id: string}) => { - const response = await gmp.user.clone({id}); - return response.data; - }, - invalidateQueryIds: ['get_users'], - onSuccess, - onError, - }); -}; - -export const useDeleteUser = ({ - onSuccess, - onError, -}: UseUserMutationCallbacks = {}) => { - const gmp = useGmp(); - return useGmpMutation<{id: string; inheritorId?: string}, void>({ - gmpMethod: ({id, inheritorId}: {id: string; inheritorId?: string}) => - gmp.user.delete({id, inheritorId: inheritorId ?? ''}), - invalidateQueryIds: ['get_users', 'get_user'], - onSuccess, - onError, - }); -}; - export const useBulkDeleteUsers = ({ onSuccess, onError, @@ -154,18 +55,6 @@ export const useBulkDeleteUsers = ({ }); }; -export const useDownloadUser = ({ - onSuccess, - onError, -}: UseUserMutationCallbacks> = {}) => { - const gmp = useGmp(); - return useGmpMutation<{id: string}, Response>({ - gmpMethod: (entity: {id: string}) => gmp.user.export(entity), - onSuccess, - onError, - }); -}; - export const useBulkExportUsers = ({ onSuccess, onError, diff --git a/src/web/pages/users/UserComponent.tsx b/src/web/pages/users/UserComponent.tsx index 45d83b09ea..bcfd94300d 100644 --- a/src/web/pages/users/UserComponent.tsx +++ b/src/web/pages/users/UserComponent.tsx @@ -16,7 +16,7 @@ import { useCreateUser, useDeleteUser, useSaveUser, -} from 'web/hooks/use-query/users'; +} from 'web/hooks/use-query/user'; import useGmp from 'web/hooks/useGmp'; import useTranslation from 'web/hooks/useTranslation'; import UserDialog, {type UserDialogSaveData} from 'web/pages/users/UsersDialog'; diff --git a/src/web/pages/users/UserDetailsPage.tsx b/src/web/pages/users/UserDetailsPage.tsx index ef37e4ed9e..b4524f12e8 100644 --- a/src/web/pages/users/UserDetailsPage.tsx +++ b/src/web/pages/users/UserDetailsPage.tsx @@ -38,7 +38,7 @@ import EditIcon from 'web/entity/icon/EditIcon'; import {goToDetails, goToList} from 'web/entity/navigation'; import EntityTags from 'web/entity/Tags'; import {useGetPermissions} from 'web/hooks/use-query/permissions'; -import {useGetUser} from 'web/hooks/use-query/users'; +import {useGetUser} from 'web/hooks/use-query/user'; import useTranslation from 'web/hooks/useTranslation'; import UserComponent from 'web/pages/users/UserComponent'; import UserDetails from 'web/pages/users/UserDetails'; From c4a60c1f4321f29140930c2c17309470c8328a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 13:44:02 +0200 Subject: [PATCH 03/36] Change: Add Ticket clone and delete success messages to query hooks Ensure when cloning and deleting tickets via their query hooks a notification message is displayed. --- .../hooks/use-query/__tests__/Ticket.test.tsx | 84 +++++++++++++++++-- src/web/hooks/use-query/ticket.ts | 23 +++-- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/web/hooks/use-query/__tests__/Ticket.test.tsx b/src/web/hooks/use-query/__tests__/Ticket.test.tsx index a863b0bea2..bec30ab764 100644 --- a/src/web/hooks/use-query/__tests__/Ticket.test.tsx +++ b/src/web/hooks/use-query/__tests__/Ticket.test.tsx @@ -151,14 +151,21 @@ describe('ticket mutation hooks', () => { }); }); - test('should clone a ticket', async () => { + test('should clone a ticket with its name and call onSuccess', async () => { const gmp = createGmp(); + const onSuccess = testing.fn(); const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - const mutation = useCloneTicket(); + const mutation = useCloneTicket({onSuccess}); return ( - + ); }; @@ -166,18 +173,50 @@ describe('ticket mutation hooks', () => { fireEvent.click(screen.getByRole('button', {name: 'Clone'})); await waitFor(() => { - expect(gmp.ticket.clone).toHaveBeenCalledWith({id: 'ticket-1'}); + expect(gmp.ticket.clone).toHaveBeenCalledWith({ + id: 'ticket-1', + name: 'Cloned Ticket', + }); + expect(onSuccess).toHaveBeenCalledWith({id: 'ticket-3'}); + expect( + screen.getByText('Cloned Ticket cloned successfully.'), + ).toBeInTheDocument(); }); }); - test('should delete a ticket', async () => { + test('should call onError when cloning a ticket fails', async () => { + const error = new Error('Clone failed'); const gmp = createGmp(); + gmp.ticket.clone.mockRejectedValue(error); + const onError = testing.fn(); const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - const mutation = useDeleteTicket(); + const mutation = useCloneTicket({onError}); return ( - + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); + + test('should delete a ticket with its name and call onSuccess', async () => { + const gmp = createGmp(); + const onSuccess = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeleteTicket({onSuccess}); + return ( + ); @@ -187,10 +226,39 @@ describe('ticket mutation hooks', () => { fireEvent.click(screen.getByRole('button', {name: 'Delete'})); await waitFor(() => { - expect(gmp.ticket.delete).toHaveBeenCalledWith({id: 'ticket-1'}); + expect(gmp.ticket.delete).toHaveBeenCalledWith({ + id: 'ticket-1', + name: 'Deleted Ticket', + }); + expect(onSuccess).toHaveBeenCalledWith(undefined); + expect( + screen.getByText('Deleted Ticket deleted successfully.'), + ).toBeInTheDocument(); }); }); + test('should call onError when deleting a ticket fails', async () => { + const error = new Error('Delete failed'); + const gmp = createGmp(); + gmp.ticket.delete.mockRejectedValue(error); + const onError = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeleteTicket({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); + test('should download a ticket', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); diff --git a/src/web/hooks/use-query/ticket.ts b/src/web/hooks/use-query/ticket.ts index 784bd73d20..de7b94069a 100644 --- a/src/web/hooks/use-query/ticket.ts +++ b/src/web/hooks/use-query/ticket.ts @@ -5,6 +5,7 @@ import {type EntityActionData} from 'gmp/commands/entity'; import type Response from 'gmp/http/response'; +import _ from 'gmp/locale'; import { type default as Ticket, type TicketStatusValue, @@ -17,6 +18,10 @@ interface UseGetTicketParams { id: string; } +interface UseModifyTicketParams extends UseGetTicketParams { + name?: string; +} + interface UseTicketMutationCallbacks { onSuccess?: (response: TResponse) => void; onError?: (error: Error) => void; @@ -83,12 +88,16 @@ export const useCloneTicket = ({ onError, }: UseTicketMutationCallbacks = {}) => { const gmp = useGmp(); - return useGmpMutation<{id: string}, EntityActionData>({ - gmpMethod: async ({id}: {id: string}) => { - const response = await gmp.ticket.clone({id}); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.ticket.clone(data); return response.data; }, invalidateQueryIds: ['get_tickets'], + successMessage: (_data, entity) => + _('{{- name}} cloned successfully.', { + name: entity.name as string, + }), onSuccess, onError, }); @@ -99,9 +108,13 @@ export const useDeleteTicket = ({ onError, }: UseTicketMutationCallbacks = {}) => { const gmp = useGmp(); - return useGmpMutation<{id: string}, void>({ - gmpMethod: ({id}: {id: string}) => gmp.ticket.delete({id}), + return useGmpMutation({ + gmpMethod: data => gmp.ticket.delete(data), invalidateQueryIds: ['get_tickets', 'get_ticket'], + successMessage: (_data, entity) => + _('{{- name}} deleted successfully.', { + name: entity.name as string, + }), onSuccess, onError, }); From 559cca432beb6d5a3337c91954bacf222aec7f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 13:52:28 +0200 Subject: [PATCH 04/36] test: Update Group and Role query hook tests Use dedicated describe blocks for each hook and use real model instances in the tests. --- .../hooks/use-query/__tests__/Groups.test.tsx | 40 ++++++++++++++++--- .../hooks/use-query/__tests__/Roles.test.tsx | 40 ++++++++++++++++--- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/web/hooks/use-query/__tests__/Groups.test.tsx b/src/web/hooks/use-query/__tests__/Groups.test.tsx index 7b4f839651..a4c576d023 100644 --- a/src/web/hooks/use-query/__tests__/Groups.test.tsx +++ b/src/web/hooks/use-query/__tests__/Groups.test.tsx @@ -7,11 +7,12 @@ import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; import CollectionCounts from 'gmp/collection/collection-counts'; import QueryFilter from 'gmp/models/filter/query-filter'; +import Group from 'gmp/models/group'; import {createSession} from 'gmp/testing'; import {useGetAllGroups, useGetGroups} from 'web/hooks/use-query/groups'; const filter = QueryFilter.fromString('name~group'); -const groups = [{id: 'group-1', name: 'Group 1'}]; +const groups = [new Group({id: 'group-1', name: 'Group 1'})]; const createGmp = () => { const response = { @@ -32,7 +33,7 @@ const createGmp = () => { }; }; -describe('group query hooks', () => { +describe('useGetGroups', () => { test('should fetch groups with a filter', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); @@ -52,6 +53,37 @@ describe('group query hooks', () => { expect(gmp.groups.getAll).not.toHaveBeenCalled(); }); + test('should not fetch groups when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetGroups({enabled: false}); + return
; + }; + + render(); + + expect(gmp.groups.get).not.toHaveBeenCalled(); + }); + + test('should not fetch groups without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetGroups({filter}); + return
; + }; + + render(); + + expect(gmp.groups.get).not.toHaveBeenCalled(); + }); +}); + +describe('useGetAllGroups', () => { test('should fetch all groups with a filter', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); @@ -76,14 +108,12 @@ describe('group query hooks', () => { const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - useGetGroups({enabled: false}); useGetAllGroups({enabled: false}); return
; }; render(); - expect(gmp.groups.get).not.toHaveBeenCalled(); expect(gmp.groups.getAll).not.toHaveBeenCalled(); }); @@ -93,14 +123,12 @@ describe('group query hooks', () => { const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - useGetGroups({filter}); useGetAllGroups({filter}); return
; }; render(); - expect(gmp.groups.get).not.toHaveBeenCalled(); expect(gmp.groups.getAll).not.toHaveBeenCalled(); }); }); diff --git a/src/web/hooks/use-query/__tests__/Roles.test.tsx b/src/web/hooks/use-query/__tests__/Roles.test.tsx index bcad03a074..c40814fb2b 100644 --- a/src/web/hooks/use-query/__tests__/Roles.test.tsx +++ b/src/web/hooks/use-query/__tests__/Roles.test.tsx @@ -7,11 +7,12 @@ import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; import CollectionCounts from 'gmp/collection/collection-counts'; import QueryFilter from 'gmp/models/filter/query-filter'; +import Role from 'gmp/models/role'; import {createSession} from 'gmp/testing'; import {useGetAllRoles, useGetRoles} from 'web/hooks/use-query/roles'; const filter = QueryFilter.fromString('name~role'); -const roles = [{id: 'role-1', name: 'Role 1'}]; +const roles = [new Role({id: 'role-1', name: 'Role 1'})]; const createGmp = () => { const response = { @@ -32,7 +33,7 @@ const createGmp = () => { }; }; -describe('role query hooks', () => { +describe('useGetRoles', () => { test('should fetch roles with a filter', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); @@ -52,6 +53,37 @@ describe('role query hooks', () => { expect(gmp.roles.getAll).not.toHaveBeenCalled(); }); + test('should not fetch roles when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetRoles({enabled: false}); + return
; + }; + + render(); + + expect(gmp.roles.get).not.toHaveBeenCalled(); + }); + + test('should not fetch roles without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetRoles({filter}); + return
; + }; + + render(); + + expect(gmp.roles.get).not.toHaveBeenCalled(); + }); +}); + +describe('useGetAllRoles', () => { test('should fetch all roles with a filter', async () => { const gmp = createGmp(); const {render} = rendererWith({gmp, router: true}); @@ -76,14 +108,12 @@ describe('role query hooks', () => { const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - useGetRoles({enabled: false}); useGetAllRoles({enabled: false}); return
; }; render(); - expect(gmp.roles.get).not.toHaveBeenCalled(); expect(gmp.roles.getAll).not.toHaveBeenCalled(); }); @@ -93,14 +123,12 @@ describe('role query hooks', () => { const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - useGetRoles({filter}); useGetAllRoles({filter}); return
; }; render(); - expect(gmp.roles.get).not.toHaveBeenCalled(); expect(gmp.roles.getAll).not.toHaveBeenCalled(); }); }); From 60da0ac1acc410d5fd1aa68ec91edae7858d5230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 14:08:12 +0200 Subject: [PATCH 05/36] Change: Add User clone and delete success messages to query hooks Ensure when cloning and deleting users via their query hooks a notification message is displayed. --- .../hooks/use-query/__tests__/User.test.tsx | 86 +++++++++++++++++-- src/web/hooks/use-query/user.ts | 26 +++++- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/web/hooks/use-query/__tests__/User.test.tsx b/src/web/hooks/use-query/__tests__/User.test.tsx index b350a5b08d..15c3837242 100644 --- a/src/web/hooks/use-query/__tests__/User.test.tsx +++ b/src/web/hooks/use-query/__tests__/User.test.tsx @@ -208,11 +208,45 @@ describe('useSaveUser', () => { }); describe('remaining user mutation hooks', () => { - test('should clone a user', async () => { + test('should clone a user with its name and call onSuccess', async () => { const gmp = createGmp(); + const onSuccess = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + const TestComponent = () => { + const mutation = useCloneUser({onSuccess}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + + await waitFor(() => { + expect(gmp.user.clone).toHaveBeenCalledWith({id: 'user-id'}); + expect(onSuccess).toHaveBeenCalledWith({id: 'cloned'}); + expect( + screen.getByText('Cloned User cloned successfully.'), + ).toBeInTheDocument(); + }); + }); + + test('should call onError when cloning a user fails', async () => { + const error = new Error('Clone failed'); + const gmp = createGmp({clone: testing.fn().mockRejectedValue(error)}); + const onError = testing.fn(); const {render} = rendererWith({gmp, router: true}); const TestComponent = () => { - const mutation = useCloneUser(); + const mutation = useCloneUser({onError}); return ( ); @@ -220,23 +254,57 @@ describe('remaining user mutation hooks', () => { render(); fireEvent.click(screen.getByRole('button', {name: 'Clone'})); - await waitFor(() => - expect(gmp.user.clone).toHaveBeenCalledWith({id: 'user-id'}), - ); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); }); - test('should delete a user and forward the inheritor ID', async () => { + test('should delete a user with its name and forward the inheritor ID', async () => { const gmp = createGmp(); + const onSuccess = testing.fn(); const {render} = rendererWith({gmp, router: true}); - const input = {id: 'user-id', inheritorId: 'inheritor-id'}; + const input = { + id: 'user-id', + name: 'Deleted User', + inheritorId: 'inheritor-id', + }; const TestComponent = () => { - const mutation = useDeleteUser(); + const mutation = useDeleteUser({onSuccess}); return ; }; render(); fireEvent.click(screen.getByRole('button', {name: 'Delete'})); - await waitFor(() => expect(gmp.user.delete).toHaveBeenCalledWith(input)); + + await waitFor(() => { + expect(gmp.user.delete).toHaveBeenCalledWith({ + id: 'user-id', + inheritorId: 'inheritor-id', + }); + expect(onSuccess).toHaveBeenCalledWith(undefined); + expect( + screen.getByText('Deleted User deleted successfully.'), + ).toBeInTheDocument(); + }); + }); + + test('should call onError when deleting a user fails', async () => { + const error = new Error('Delete failed'); + const gmp = createGmp({ + delete: testing.fn().mockRejectedValue(error), + }); + const onError = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + const TestComponent = () => { + const mutation = useDeleteUser({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); }); test('should download a user', async () => { diff --git a/src/web/hooks/use-query/user.ts b/src/web/hooks/use-query/user.ts index 6e357556f1..ce827cb37c 100644 --- a/src/web/hooks/use-query/user.ts +++ b/src/web/hooks/use-query/user.ts @@ -5,6 +5,7 @@ import {type EntityActionData} from 'gmp/commands/entity'; import type Response from 'gmp/http/response'; +import _ from 'gmp/locale'; import type User from 'gmp/models/user'; import useGmp from 'web/hooks/useGmp'; import {type RefetchIntervalFn} from 'web/queries/helpers'; @@ -16,6 +17,15 @@ interface UseGetUserParams { refetchInterval?: RefetchIntervalFn; } +interface UseModifyUserParams { + id: string; + name?: string; +} + +interface UseDeleteUserParams extends UseModifyUserParams { + inheritorId?: string; +} + interface UseUserMutationCallbacks { onSuccess?: (response: TResponse) => void; onError?: (error: Error) => void; @@ -87,12 +97,16 @@ export const useCloneUser = ({ onError, }: UseUserMutationCallbacks = {}) => { const gmp = useGmp(); - return useGmpMutation<{id: string}, EntityActionData>({ - gmpMethod: async ({id}: {id: string}) => { + return useGmpMutation({ + gmpMethod: async ({id}: UseModifyUserParams) => { const response = await gmp.user.clone({id}); return response.data; }, invalidateQueryIds: ['get_users'], + successMessage: (_data, entity) => + _('{{- name}} cloned successfully.', { + name: entity.name as string, + }), onSuccess, onError, }); @@ -103,10 +117,14 @@ export const useDeleteUser = ({ onError, }: UseUserMutationCallbacks = {}) => { const gmp = useGmp(); - return useGmpMutation<{id: string; inheritorId?: string}, void>({ - gmpMethod: ({id, inheritorId}: {id: string; inheritorId?: string}) => + return useGmpMutation({ + gmpMethod: ({id, inheritorId}: UseDeleteUserParams) => gmp.user.delete({id, inheritorId: inheritorId ?? ''}), invalidateQueryIds: ['get_users', 'get_user'], + successMessage: (_data, entity) => + _('{{- name}} deleted successfully.', { + name: entity.name as string, + }), onSuccess, onError, }); From 8254800f0c30eaf589a9e74791022bb12b10a974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 14:12:40 +0200 Subject: [PATCH 06/36] Add query hooks for Target Allow to query Target data via use query hooks. --- src/gmp/commands/target.ts | 2 +- .../hooks/use-query/__tests__/Target.test.tsx | 201 ++++++++++++++++++ .../use-query/__tests__/Targets.test.tsx | 134 ++++++++++++ src/web/hooks/use-query/target.ts | 106 +++++++++ src/web/hooks/use-query/targets.ts | 45 ++++ 5 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/web/hooks/use-query/__tests__/Target.test.tsx create mode 100644 src/web/hooks/use-query/__tests__/Targets.test.tsx create mode 100644 src/web/hooks/use-query/target.ts create mode 100644 src/web/hooks/use-query/targets.ts diff --git a/src/gmp/commands/target.ts b/src/gmp/commands/target.ts index 1971f78f23..a0c5b53d06 100644 --- a/src/gmp/commands/target.ts +++ b/src/gmp/commands/target.ts @@ -16,7 +16,7 @@ import {UNSET_VALUE} from 'web/utils/Render'; export type TargetSource = 'manual' | 'file' | 'asset_hosts'; export type TargetExcludeSource = 'manual' | 'file'; -interface TargetCommandCreateParams { +export interface TargetCommandCreateParams { aliveTests?: AliveTest[]; allowSimultaneousIPs?: boolean; comment?: string; diff --git a/src/web/hooks/use-query/__tests__/Target.test.tsx b/src/web/hooks/use-query/__tests__/Target.test.tsx new file mode 100644 index 0000000000..668b9ae249 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/Target.test.tsx @@ -0,0 +1,201 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import Target from 'gmp/models/target'; +import {createSession} from 'gmp/testing'; +import { + useCloneTarget, + useDeleteTarget, + useGetTarget, +} from 'web/hooks/use-query/target'; + +const target = new Target({id: 'target-1', name: 'Target 1'}); + +const createGmp = ({token}: {token?: string} = {token: 'test-token'}) => ({ + session: createSession({token}), + settings: {}, + target: { + get: testing.fn().mockResolvedValue({data: target}), + clone: testing.fn().mockResolvedValue({data: {id: 'target-2'}}), + delete: testing.fn().mockResolvedValue(undefined), + }, +}); + +describe('useGetTarget', () => { + test('should fetch a target by ID', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetTarget({id: target.id}); + return
{data?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('target')).toHaveTextContent('Target 1'); + }); + + expect(gmp.target.get).toHaveBeenCalledWith({id: target.id}); + }); + + test('should not fetch when the ID is empty', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetTarget({id: ''}); + return
; + }; + + render(); + + expect(gmp.target.get).not.toHaveBeenCalled(); + }); + + test('should not fetch without a session token', () => { + const gmp = createGmp({token: undefined}); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetTarget({id: target.id}); + return
; + }; + + render(); + + expect(gmp.target.get).not.toHaveBeenCalled(); + }); + + test('should show an error when fetching a target fails', async () => { + const gmp = createGmp(); + gmp.target.get.mockRejectedValue(new Error('Request failed')); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {isError} = useGetTarget({id: target.id}); + return
{isError ? 'Error' : ''}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('Error'); + }); + }); +}); + +describe('target mutation hooks', () => { + test('should clone a target with its name and call onSuccess', async () => { + const gmp = createGmp(); + const onSuccess = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCloneTarget({onSuccess}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + + await waitFor(() => { + expect(gmp.target.clone).toHaveBeenCalledWith({ + id: 'target-1', + name: 'Cloned Target', + }); + expect(onSuccess).toHaveBeenCalledWith({id: 'target-2'}); + expect( + screen.getByText('Cloned Target cloned successfully.'), + ).toBeInTheDocument(); + }); + }); + + test('should call onError when cloning a target fails', async () => { + const error = new Error('Clone failed'); + const gmp = createGmp(); + gmp.target.clone.mockRejectedValue(error); + const onError = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCloneTarget({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); + + test('should delete a target with its name and call onSuccess', async () => { + const gmp = createGmp(); + const onSuccess = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeleteTarget({onSuccess}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => { + expect(gmp.target.delete).toHaveBeenCalledWith({ + id: 'target-1', + name: 'Deleted Target', + }); + expect(onSuccess).toHaveBeenCalledWith(undefined); + expect( + screen.getByText('Deleted Target deleted successfully.'), + ).toBeInTheDocument(); + }); + }); + + test('should call onError when deleting a target fails', async () => { + const error = new Error('Delete failed'); + const gmp = createGmp(); + gmp.target.delete.mockRejectedValue(error); + const onError = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeleteTarget({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); +}); diff --git a/src/web/hooks/use-query/__tests__/Targets.test.tsx b/src/web/hooks/use-query/__tests__/Targets.test.tsx new file mode 100644 index 0000000000..dbd88f5482 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/Targets.test.tsx @@ -0,0 +1,134 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, screen, waitFor} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import Target from 'gmp/models/target'; +import {createSession} from 'gmp/testing'; +import {useGetAllTargets, useGetTargets} from 'web/hooks/use-query/targets'; + +const filter = QueryFilter.fromString('name~target'); +const targets = [new Target({id: 'target-1', name: 'Target 1'})]; + +const createGmp = () => { + const response = { + data: targets, + meta: { + filter, + counts: new CollectionCounts({all: 1, filtered: 1, length: 1}), + }, + }; + + return { + session: createSession({token: 'test-token'}), + settings: {}, + targets: { + get: testing.fn().mockResolvedValue(response), + getAll: testing.fn().mockResolvedValue(response), + }, + }; +}; + +describe('useGetTargets', () => { + test('should fetch targets with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetTargets({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('target')).toHaveTextContent('Target 1'); + }); + + expect(gmp.targets.get).toHaveBeenCalledWith({filter}); + expect(gmp.targets.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch targets when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetTargets({enabled: false}); + return
; + }; + + render(); + + expect(gmp.targets.get).not.toHaveBeenCalled(); + }); + + test('should not fetch targets without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetTargets({filter}); + return
; + }; + + render(); + + expect(gmp.targets.get).not.toHaveBeenCalled(); + }); +}); + +describe('useGetAllTargets', () => { + test('should fetch all targets with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetAllTargets({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('target')).toHaveTextContent('Target 1'); + }); + + expect(gmp.targets.getAll).toHaveBeenCalledWith({filter}); + expect(gmp.targets.get).not.toHaveBeenCalled(); + }); + + test('should not fetch targets when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetAllTargets({enabled: false}); + return
; + }; + + render(); + + expect(gmp.targets.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch targets without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetAllTargets({filter}); + return
; + }; + + render(); + + expect(gmp.targets.getAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/hooks/use-query/target.ts b/src/web/hooks/use-query/target.ts new file mode 100644 index 0000000000..428c0a81e1 --- /dev/null +++ b/src/web/hooks/use-query/target.ts @@ -0,0 +1,106 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type EntityActionData} from 'gmp/commands/entity'; +import { + type TargetCommandCreateParams, + type TargetCommandSaveParams, +} from 'gmp/commands/target'; +import _ from 'gmp/locale'; +import type Target from 'gmp/models/target'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntity from 'web/queries/useGetEntity'; +import useGmpMutation from 'web/queries/useGmpMutation'; + +interface UseTargetMutationCallbacks { + onSuccess?: (response: TResponse) => void; + onError?: (error: Error) => void; +} + +interface UseGetTargetParams { + id: string; +} + +interface UseModifyTargetParams extends UseGetTargetParams { + name?: string; +} + +export const useGetTarget = ({id}: UseGetTargetParams) => { + const gmp = useGmp(); + return useGetEntity({ + gmpMethod: (data: UseGetTargetParams) => gmp.target.get(data), + queryId: 'get_targets', + id, + }); +}; + +export const useCreateTarget = ({ + onSuccess, + onError, +}: UseTargetMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.target.create(data); + return response.data; + }, + invalidateQueryIds: ['get_targets'], + onSuccess, + onError, + }); +}; + +export const useSaveTarget = ({ + onSuccess, + onError, +}: UseTargetMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.target.save(data); + return response.data; + }, + invalidateQueryIds: ['get_targets', 'get_target'], + onSuccess, + onError, + }); +}; + +export const useCloneTarget = ({ + onSuccess, + onError, +}: UseTargetMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async ({id}: UseModifyTargetParams) => { + const response = await gmp.target.clone({id}); + return response.data; + }, + invalidateQueryIds: ['get_targets'], + successMessage: (_data, entity) => + _('{{- name}} cloned successfully.', { + name: entity.name as string, + }), + onSuccess, + onError, + }); +}; + +export const useDeleteTarget = ({ + onSuccess, + onError, +}: UseTargetMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: ({id}: UseModifyTargetParams) => gmp.target.delete({id}), + invalidateQueryIds: ['get_targets', 'get_target'], + successMessage: (_data, entity) => + _('{{- name}} deleted successfully.', { + name: entity.name as string, + }), + onSuccess, + onError, + }); +}; diff --git a/src/web/hooks/use-query/targets.ts b/src/web/hooks/use-query/targets.ts new file mode 100644 index 0000000000..b227b64fb2 --- /dev/null +++ b/src/web/hooks/use-query/targets.ts @@ -0,0 +1,45 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type FilterType} from 'gmp/models/filter'; +import type Target from 'gmp/models/target'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntities from 'web/queries/useGetEntities'; + +interface UseGetTasksParams { + enabled?: boolean; + filter?: FilterType; + staleTime?: number; +} + +export const useGetTargets = ({ + enabled, + filter, + staleTime, +}: UseGetTasksParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.targets.get.bind(gmp.targets), + queryId: 'get_targets', + enabled, + filter, + staleTime, + }); +}; + +export const useGetAllTargets = ({ + enabled, + filter, + staleTime, +}: UseGetTasksParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.targets.getAll.bind(gmp.targets), + queryId: 'get_targets', + enabled, + filter, + staleTime, + }); +}; From 2784fcd77fb703a9a2a2c7c600a85aae5ddecf64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 14:13:47 +0200 Subject: [PATCH 07/36] Change: Allow to pass a response or the data directly to goToDetails As query hooks might not return a response object and instead the data directly the goToDetails function needs to support this use case. --- src/web/entity/navigation.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/web/entity/navigation.ts b/src/web/entity/navigation.ts index 86e5b39dd8..93c4ab1cd6 100644 --- a/src/web/entity/navigation.ts +++ b/src/web/entity/navigation.ts @@ -7,21 +7,23 @@ import {type EntityType} from 'gmp/utils/entity-type'; import {isDefined, isFunction} from 'gmp/utils/identity'; import {entityListURL, entityURL, type EntityListType} from 'web/route-paths'; -export type NavigateFunc = (path: string) => void; +type NavigateFunc = (path: string) => void; -export interface NavigateObj { +interface NavigateObj { navigate: NavigateFunc; } -export type Navigate = NavigateFunc | NavigateObj; +type Navigate = NavigateFunc | NavigateObj; -export interface GotoDetailsObj { - data: { - id: string; - }; +interface GotoDetailsData { + id: string; } -export type GotoDetailsFunc = ({data}: GotoDetailsObj) => void; +interface GotoDetailsObj { + data: GotoDetailsData; +} + +export type GotoDetailsFunc = (data: GotoDetailsObj | GotoDetailsData) => void; export type GotoListFunc = () => void; /** @@ -47,7 +49,8 @@ export const goToDetails = ( throw new Error('navigate function is required for goToDetails'); } } - return ({data}) => navigate(entityURL(type, String(data.id))); + return data => + navigate(entityURL(type, String('id' in data ? data.id : data.data.id))); }; /** From 0359edc60a6b6162e68b79c499f69098bb39ceba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 5 Oct 2026 14:17:26 +0200 Subject: [PATCH 08/36] Change: Use new target query hooks in TargetComponent Refactor TargetComponent to use the new query hooks for target. --- src/web/pages/policies/PoliciesComponent.tsx | 3 +- src/web/pages/targets/TargetComponent.tsx | 93 ++++++++++---------- src/web/pages/tasks/TaskComponent.tsx | 5 +- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/src/web/pages/policies/PoliciesComponent.tsx b/src/web/pages/policies/PoliciesComponent.tsx index b3d028d084..98ff2af39d 100644 --- a/src/web/pages/policies/PoliciesComponent.tsx +++ b/src/web/pages/policies/PoliciesComponent.tsx @@ -5,6 +5,7 @@ import React, {useState, useCallback, useEffect} from 'react'; import {useDispatch} from 'react-redux'; +import {type EntityActionData} from 'gmp/commands/entity'; import {type NvtFamily} from 'gmp/commands/nvt-families'; import { type ScanConfigFamilyNvt, @@ -285,7 +286,7 @@ const PolicyComponent = ({ setScheduleId(data.id); }; - const handleTargetCreated = ({data}) => { + const handleTargetCreated = (data: EntityActionData) => { fetchTargets(); setTargetId(data.id); }; diff --git a/src/web/pages/targets/TargetComponent.tsx b/src/web/pages/targets/TargetComponent.tsx index fcf15e4e35..909636cb20 100644 --- a/src/web/pages/targets/TargetComponent.tsx +++ b/src/web/pages/targets/TargetComponent.tsx @@ -4,6 +4,7 @@ */ import {useState, useRef} from 'react'; +import {type EntityActionData} from 'gmp/commands/entity'; import { type TargetCommandSaveParams, type TargetExcludeSource, @@ -22,19 +23,15 @@ import { } from 'gmp/models/target'; import {first} from 'gmp/utils/array'; import {isDefined} from 'gmp/utils/identity'; -import useEntityClone, { - type EntityCloneResponse, -} from 'web/entity/hooks/useEntityClone'; -import useEntityCreate, { - type EntityCreateResponse, -} from 'web/entity/hooks/useEntityCreate'; -import useEntityDelete from 'web/entity/hooks/useEntityDelete'; import useEntityDownload, { type OnDownloadedFunc, } from 'web/entity/hooks/useEntityDownload'; -import useEntitySave, { - type EntitySaveResponse, -} from 'web/entity/hooks/useEntitySave'; +import { + useCloneTarget, + useCreateTarget, + useDeleteTarget, + useSaveTarget, +} from 'web/hooks/use-query/target'; import useGmp from 'web/hooks/useGmp'; import useTranslation from 'web/hooks/useTranslation'; import CredentialDialog, { @@ -66,15 +63,15 @@ interface TargetComponentRenderProps { interface TargetComponentProps { children: (props: TargetComponentRenderProps) => React.ReactNode; - onCloned?: (response: EntityCloneResponse) => void; + onCloned?: (data: EntityActionData) => void; onCloneError?: (error: Error) => void; - onCreated?: (response: EntityCreateResponse) => void; + onCreated?: (data: EntityActionData) => void; onCreateError?: (error: Error) => void; onDeleted?: () => void; onDeleteError?: (error: Error) => void; onDownloaded?: OnDownloadedFunc; onDownloadError?: (error: Error) => void; - onSaved?: (response: EntitySaveResponse) => void; + onSaved?: (data: EntityActionData) => void; onSaveError?: (error: Error) => void; } @@ -339,33 +336,45 @@ const TargetComponent = ({ setKrb5CredentialId(krb5CredentialId ?? UNSET_VALUE); }; - const handleEntityClone = useEntityClone( - entity => gmp.target.clone(entity), - { - onCloned, - onCloneError, - }, - ); + const cloneTarget = useCloneTarget({ + onSuccess: onCloned, + onError: onCloneError, + }); - const handleEntitySave = useEntitySave( - data => gmp.target.save(data), - { - onSaved, - onSaveError, - }, - ); + const handleTargetClone = async (entity: Target) => { + await cloneTarget.mutateAsync(entity); + }; - const handleEntityCreate = useEntityCreate( - data => gmp.target.create(data), - { - onCreated, - onCreateError, - }, - ); + const saveTarget = useSaveTarget({ + onSuccess: onSaved, + onError: onSaveError, + }); + + const handleTargetSave = async (data: TargetCommandSaveParams) => { + await saveTarget.mutateAsync(data); + }; + + const createTarget = useCreateTarget({ + onSuccess: onCreated, + onError: onCreateError, + }); + + const handleTargetCreate = async (data: TargetDialogData) => { + await createTarget.mutateAsync(data); + }; + + const deleteTarget = useDeleteTarget({ + onSuccess: onDeleted, + onError: onDeleteError, + }); + + const handleTargetDelete = async (entity: Target) => { + await deleteTarget.mutateAsync(entity); + }; const handleSaveClick = async (data: TargetDialogData) => { const promise = isDefined(data.id) - ? handleEntitySave( + ? handleTargetSave( data.inUse ? { id: data.id as string, @@ -378,7 +387,7 @@ const TargetComponent = ({ id: data.id as string, }, ) - : handleEntityCreate(data); + : handleTargetCreate(data); await promise; closeTargetDialog(); }; @@ -391,20 +400,12 @@ const TargetComponent = ({ }, ); - const handleEntityDelete = useEntityDelete( - entity => gmp.target.delete(entity), - { - onDeleted, - onDeleteError, - }, - ); - return ( <> {children({ - clone: handleEntityClone, + clone: handleTargetClone, create: openCreateTargetDialog, - delete: handleEntityDelete, + delete: handleTargetDelete, download: handleEntityDownload, edit: openTargetDialog, })} diff --git a/src/web/pages/tasks/TaskComponent.tsx b/src/web/pages/tasks/TaskComponent.tsx index c26df7e757..a3ac73456c 100644 --- a/src/web/pages/tasks/TaskComponent.tsx +++ b/src/web/pages/tasks/TaskComponent.tsx @@ -5,6 +5,7 @@ import React, {useState, useEffect, useCallback} from 'react'; import {useDispatch} from 'react-redux'; +import {type EntityActionData} from 'gmp/commands/entity'; import type Rejection from 'gmp/http/rejection'; import type AgentGroup from 'gmp/models/agent-group'; import date, {type Date} from 'gmp/models/date'; @@ -486,9 +487,7 @@ const TaskComponent = ({ setScheduleId(data.id); }; - const handleTargetCreated = (resp: {data: {id?: string}}) => { - const {data} = resp; - + const handleTargetCreated = (data: EntityActionData) => { fetchTargets(); setTargetId(data.id); From b274ff6a7955edcfd20f8106cfda564a9e990539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 6 Oct 2026 10:52:39 +0200 Subject: [PATCH 09/36] Change deletePortRange command to just delete the port range Before the changed port list was re-loaded after a port range was deleted. This is completely unnecessary and also created several additional requests when a user deleted several port ranges in the dialog. --- src/gmp/commands/__tests__/port-list.test.ts | 8 +++----- src/gmp/commands/port-list.ts | 15 +++++---------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/gmp/commands/__tests__/port-list.test.ts b/src/gmp/commands/__tests__/port-list.test.ts index db67b84d17..f7aa7dfa9f 100644 --- a/src/gmp/commands/__tests__/port-list.test.ts +++ b/src/gmp/commands/__tests__/port-list.test.ts @@ -8,7 +8,6 @@ import PortListCommand, {FROM_FILE} from 'gmp/commands/port-list'; import { createHttp, createActionResultResponse, - createHttpMany, createEntityResponse, } from 'gmp/commands/testing'; @@ -95,14 +94,13 @@ describe('PortListCommand tests', () => { action: 'delete_port_range', id: '12345', }); - const entityResponse = createEntityResponse('port_list', {id: '324'}); - const http = createHttpMany([response, entityResponse]); + const http = createHttp(response); const command = new PortListCommand(http); const result = await command.deletePortRange({ id: '12345', - portListId: '67890', }); - expect(result.data.id).toEqual('324'); + expect(result).toBeUndefined(); + expect(http.request).toHaveBeenCalledTimes(1); }); test('should allow to get a port list', async () => { diff --git a/src/gmp/commands/port-list.ts b/src/gmp/commands/port-list.ts index 9a95d87312..64108e6fcb 100644 --- a/src/gmp/commands/port-list.ts +++ b/src/gmp/commands/port-list.ts @@ -26,19 +26,18 @@ export interface PortListCommandSaveParams { comment?: string; } -interface PortListCommandCreatePortRangeParams { +export interface PortListCommandCreatePortRangeParams { portListId: string; portRangeStart: number; portRangeEnd: number; portType: string; } -interface PortListCommandDeletePortRangeParams { +export interface PortListCommandDeletePortRangeParams { id: string; - portListId: string; } -interface PortListCommandImportParams { +export interface PortListCommandImportParams { xmlFile?: File; } @@ -101,16 +100,12 @@ class PortListCommand extends EntityCommand { }); } - async deletePortRange({ - id, - portListId, - }: PortListCommandDeletePortRangeParams) { - await this.httpPostWithTransform({ + async deletePortRange({id}: PortListCommandDeletePortRangeParams) { + await this.action({ cmd: 'delete_port_range', port_range_id: id, no_redirect: 1, }); - return await this.get({id: portListId}); } import({xmlFile}: PortListCommandImportParams) { From db2e1221a605a793d78ff0dc6af76d1a15495685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 6 Oct 2026 11:13:12 +0200 Subject: [PATCH 10/36] Add query hooks for port list handling Allow to query and mutate port lists via query hooks. --- .../use-query/__tests__/PortList.test.tsx | 246 ++++++++++++++++++ .../use-query/__tests__/PortLists.test.tsx | 137 ++++++++++ src/web/hooks/use-query/port-list.ts | 146 +++++++++++ src/web/hooks/use-query/port-lists.ts | 45 ++++ 4 files changed, 574 insertions(+) create mode 100644 src/web/hooks/use-query/__tests__/PortList.test.tsx create mode 100644 src/web/hooks/use-query/__tests__/PortLists.test.tsx create mode 100644 src/web/hooks/use-query/port-list.ts create mode 100644 src/web/hooks/use-query/port-lists.ts diff --git a/src/web/hooks/use-query/__tests__/PortList.test.tsx b/src/web/hooks/use-query/__tests__/PortList.test.tsx new file mode 100644 index 0000000000..bbded3475d --- /dev/null +++ b/src/web/hooks/use-query/__tests__/PortList.test.tsx @@ -0,0 +1,246 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import {createSession} from 'gmp/testing'; +import { + useClonePortList, + useCreatePortList, + useCreatePortRange, + useDeletePortList, + useDeletePortRange, + useImportPortList, + useSavePortList, +} from 'web/hooks/use-query/port-list'; + +const createGmp = () => ({ + session: createSession({token: 'test-token'}), + settings: {}, + portlist: { + create: testing.fn().mockResolvedValue({data: {id: 'port-list-1'}}), + save: testing.fn().mockResolvedValue({data: {id: 'port-list-1'}}), + clone: testing.fn().mockResolvedValue({data: {id: 'port-list-2'}}), + delete: testing.fn().mockResolvedValue(undefined), + import: testing.fn().mockResolvedValue({data: {id: 'port-list-1'}}), + createPortRange: testing.fn().mockResolvedValue({ + data: {id: 'port-range-1'}, + }), + deletePortRange: testing.fn().mockResolvedValue(undefined), + }, +}); + +describe('useCreatePortList', () => { + test('should create a port list', async () => { + const gmp = createGmp(); + const input = { + name: 'Port List 1', + comment: 'Port list comment', + portRange: '1-1024', + }; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreatePortList(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => { + expect(gmp.portlist.create).toHaveBeenCalledWith(input); + }); + }); + + test('should call onSuccess with the created port list data', async () => { + const gmp = createGmp(); + const onSuccess = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreatePortList({onSuccess}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith({id: 'port-list-1'}); + }); + }); + + test('should call onError when creating a port list fails', async () => { + const error = new Error('Create failed'); + const gmp = createGmp(); + gmp.portlist.create.mockRejectedValue(error); + const onError = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreatePortList({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); +}); + +describe('useSavePortList', () => { + test('should save a port list', async () => { + const gmp = createGmp(); + const input = {id: 'port-list-1', name: 'Saved Port List'}; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useSavePortList(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + + await waitFor(() => { + expect(gmp.portlist.save).toHaveBeenCalledWith(input); + }); + }); +}); + +describe('useClonePortList', () => { + test('should clone a port list by ID', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useClonePortList(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Clone'})); + + await waitFor(() => { + expect(gmp.portlist.clone).toHaveBeenCalledWith({id: 'port-list-1'}); + }); + }); +}); + +describe('useDeletePortList', () => { + test('should delete a port list by ID', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeletePortList(); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => { + expect(gmp.portlist.delete).toHaveBeenCalledWith({id: 'port-list-1'}); + }); + }); +}); + +describe('useImportPortList', () => { + test('should import a port list', async () => { + const gmp = createGmp(); + const input = {xmlFile: new File(['port list'], 'port-list.xml')}; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useImportPortList(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Import'})); + + await waitFor(() => { + expect(gmp.portlist.import).toHaveBeenCalledWith(input); + }); + }); +}); + +describe('useCreatePortRange', () => { + test('should create a port range', async () => { + const gmp = createGmp(); + const input = { + portListId: 'port-list-1', + portRangeStart: 1, + portRangeEnd: 1024, + portType: 'tcp', + }; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreatePortRange(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => { + expect(gmp.portlist.createPortRange).toHaveBeenCalledWith(input); + }); + }); +}); + +describe('useDeletePortRange', () => { + test('should delete a port range', async () => { + const gmp = createGmp(); + const input = {id: 'port-range-1', portListId: 'port-list-1'}; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useDeletePortRange(); + return ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => { + expect(gmp.portlist.deletePortRange).toHaveBeenCalledWith({ + id: 'port-range-1', + }); + }); + }); +}); diff --git a/src/web/hooks/use-query/__tests__/PortLists.test.tsx b/src/web/hooks/use-query/__tests__/PortLists.test.tsx new file mode 100644 index 0000000000..31607284f6 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/PortLists.test.tsx @@ -0,0 +1,137 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, screen, waitFor} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import PortList from 'gmp/models/port-list'; +import {createSession} from 'gmp/testing'; +import { + useGetAllPortLists, + useGetPortLists, +} from 'web/hooks/use-query/port-lists'; + +const filter = QueryFilter.fromString('name~port-list'); +const portLists = [new PortList({id: 'port-list-1', name: 'Port List 1'})]; + +const createGmp = () => { + const response = { + data: portLists, + meta: { + filter, + counts: new CollectionCounts({all: 1, filtered: 1, length: 1}), + }, + }; + + return { + session: createSession({token: 'test-token'}), + settings: {}, + portlists: { + get: testing.fn().mockResolvedValue(response), + getAll: testing.fn().mockResolvedValue(response), + }, + }; +}; + +describe('useGetPortLists', () => { + test('should fetch port lists with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetPortLists({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('port-list')).toHaveTextContent('Port List 1'); + }); + + expect(gmp.portlists.get).toHaveBeenCalledWith({filter}); + expect(gmp.portlists.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch port lists when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetPortLists({enabled: false}); + return
; + }; + + render(); + + expect(gmp.portlists.get).not.toHaveBeenCalled(); + }); + + test('should not fetch port lists without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetPortLists({filter}); + return
; + }; + + render(); + + expect(gmp.portlists.get).not.toHaveBeenCalled(); + }); +}); + +describe('useGetAllPortLists', () => { + test('should fetch all port lists with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetAllPortLists({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('port-list')).toHaveTextContent('Port List 1'); + }); + + expect(gmp.portlists.getAll).toHaveBeenCalledWith({filter}); + expect(gmp.portlists.get).not.toHaveBeenCalled(); + }); + + test('should not fetch port lists when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetAllPortLists({enabled: false}); + return
; + }; + + render(); + + expect(gmp.portlists.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch port lists without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetAllPortLists({filter}); + return
; + }; + + render(); + + expect(gmp.portlists.getAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/hooks/use-query/port-list.ts b/src/web/hooks/use-query/port-list.ts new file mode 100644 index 0000000000..43ae48b116 --- /dev/null +++ b/src/web/hooks/use-query/port-list.ts @@ -0,0 +1,146 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type EntityActionData} from 'gmp/commands/entity'; +import { + type PortListCommandImportParams, + type PortListCommandCreateParams, + type PortListCommandSaveParams, + type PortListCommandCreatePortRangeParams, + type PortListCommandDeletePortRangeParams, +} from 'gmp/commands/port-list'; +import _ from 'gmp/locale'; +import useGmp from 'web/hooks/useGmp'; +import useGmpMutation from 'web/queries/useGmpMutation'; + +interface UsePortListMutationCallbacks { + onSuccess?: (response: TResponse) => void; + onError?: (error: Error) => void; +} + +interface UseGetPortListParams { + id: string; +} + +interface UseModifyPortListParams extends UseGetPortListParams { + name?: string; +} + +export const useCreatePortList = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.portlist.create(data); + return response.data; + }, + invalidateQueryIds: ['get_port_lists'], + onSuccess, + onError, + }); +}; + +export const useSavePortList = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.portlist.save(data); + return response.data; + }, + invalidateQueryIds: ['get_port_lists', 'get_port_list'], + onSuccess, + onError, + }); +}; + +export const useClonePortList = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async ({id}: UseModifyPortListParams) => { + const response = await gmp.portlist.clone({id}); + return response.data; + }, + invalidateQueryIds: ['get_port_lists'], + successMessage: (_data, entity) => + _('{{- name}} cloned successfully.', { + name: entity.name as string, + }), + onSuccess, + onError, + }); +}; + +export const useDeletePortList = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: ({id}: UseModifyPortListParams) => gmp.portlist.delete({id}), + invalidateQueryIds: ['get_port_lists', 'get_port_list'], + successMessage: (_data, entity) => + _('{{- name}} deleted successfully.', { + name: entity.name as string, + }), + onSuccess, + onError, + }); +}; + +export const useImportPortList = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.portlist.import(data); + return response.data; + }, + invalidateQueryIds: ['get_port_lists'], + onSuccess, + onError, + }); +}; + +export const useCreatePortRange = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation( + { + gmpMethod: async data => { + const response = await gmp.portlist.createPortRange(data); + return response.data; + }, + invalidateQueryIds: ['get_port_lists', 'get_port_list'], + onSuccess, + onError, + }, + ); +}; + +export const useDeletePortRange = ({ + onSuccess, + onError, +}: UsePortListMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async ({id}: PortListCommandDeletePortRangeParams) => + gmp.portlist.deletePortRange({id}), + invalidateQueryIds: ['get_port_lists', 'get_port_list'], + onSuccess, + onError, + }); +}; diff --git a/src/web/hooks/use-query/port-lists.ts b/src/web/hooks/use-query/port-lists.ts new file mode 100644 index 0000000000..e4178ca36f --- /dev/null +++ b/src/web/hooks/use-query/port-lists.ts @@ -0,0 +1,45 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type FilterType} from 'gmp/models/filter'; +import type PortList from 'gmp/models/port-list'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntities from 'web/queries/useGetEntities'; + +interface UseGetPortListsParams { + enabled?: boolean; + filter?: FilterType; + staleTime?: number; +} + +export const useGetPortLists = ({ + enabled, + filter, + staleTime, +}: UseGetPortListsParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.portlists.get.bind(gmp.portlists), + queryId: 'get_port_lists', + enabled, + filter, + staleTime, + }); +}; + +export const useGetAllPortLists = ({ + enabled, + filter, + staleTime, +}: UseGetPortListsParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.portlists.getAll.bind(gmp.portlists), + queryId: 'get_port_lists', + enabled, + filter, + staleTime, + }); +}; From dee5670ccf06291d688e4e7a61346dd18f8bfb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 6 Oct 2026 11:15:21 +0200 Subject: [PATCH 11/36] Change: Adapt PortListComponent to use new port list query hooks Use the new port list query hooks within the PortListComponent. --- src/web/pages/portlists/PortListComponent.tsx | 117 ++++++++++-------- src/web/pages/portlists/PortListDialog.tsx | 4 +- .../__tests__/PortListComponent.test.tsx | 31 +++-- 3 files changed, 84 insertions(+), 68 deletions(-) diff --git a/src/web/pages/portlists/PortListComponent.tsx b/src/web/pages/portlists/PortListComponent.tsx index d6b32ec828..1c5a4a36eb 100644 --- a/src/web/pages/portlists/PortListComponent.tsx +++ b/src/web/pages/portlists/PortListComponent.tsx @@ -5,7 +5,7 @@ import React, {useState} from 'react'; import { - type EntityActionResponse, + type EntityActionData, type EntityCommandParams, } from 'gmp/commands/entity'; import { @@ -19,23 +19,22 @@ import { } from 'gmp/models/port-list'; import {isDefined} from 'gmp/utils/identity'; import {shorten} from 'gmp/utils/string'; -import useEntityClone, { - type EntityCloneResponse, -} from 'web/entity/hooks/useEntityClone'; -import useEntityCreate, { - type EntityCreateResponse, -} from 'web/entity/hooks/useEntityCreate'; -import useEntityDelete from 'web/entity/hooks/useEntityDelete'; import useEntityDownload, { type OnDownloadedFunc, } from 'web/entity/hooks/useEntityDownload'; -import useEntitySave, { - type EntitySaveResponse, -} from 'web/entity/hooks/useEntitySave'; +import { + useClonePortList, + useCreatePortList, + useCreatePortRange, + useDeletePortList, + useDeletePortRange, + useImportPortList, + useSavePortList, +} from 'web/hooks/use-query/port-list'; import useGmp from 'web/hooks/useGmp'; import useTranslation from 'web/hooks/useTranslation'; import PortListsDialog, { - type SavePortListData, + type PortListDialogSaveData, } from 'web/pages/portlists/PortListDialog'; import ImportPortListDialog, { type PortListImportDialogState, @@ -65,17 +64,17 @@ interface PortListComponentRenderProps { interface PortListComponentProps { children: (props: PortListComponentRenderProps) => React.ReactNode; onCloneError?: (error: Error) => void; - onCloned?: (response: EntityCloneResponse) => void; + onCloned?: (response: EntityActionData) => void; onCreateError?: (error: Error) => void; - onCreated?: (response: EntityCreateResponse) => void; + onCreated?: (response: EntityActionData) => void; onDeleteError?: (error: Error) => void; onDeleted?: () => void; onDownloadError?: (error: Error) => void; onDownloaded?: OnDownloadedFunc; onImportError?: (error: Error) => void; - onImported?: (response: EntityActionResponse) => void; + onImported?: (response: EntityActionData) => void; onSaveError?: (error: Error) => void; - onSaved?: (response: EntitySaveResponse) => void; + onSaved?: (response: EntityActionData) => void; } const PortListComponent = ({ @@ -106,27 +105,43 @@ const PortListComponent = ({ const [createdPortRanges, setCreatedPortRanges] = useState([]); const [deletedPortRanges, setDeletedPortRanges] = useState([]); - const handleSave = useEntitySave( - data => gmp.portlist.save(data), - { - onSaveError, - onSaved, - }, - ); - const handleCreate = useEntityCreate( - data => gmp.portlist.create(data), - { - onCreated, - onCreateError, - }, - ); - const handleClone = useEntityClone( - entity => gmp.portlist.clone(entity), - { - onCloned, - onCloneError, - }, - ); + const savePortListMutation = useSavePortList({ + onSuccess: onSaved, + onError: onSaveError, + }); + const savePortList = async (data: PortListCommandSaveParams) => + await savePortListMutation.mutateAsync(data); + + const createPortListMutation = useCreatePortList({ + onSuccess: onCreated, + onError: onCreateError, + }); + const createPortList = async (data: PortListCommandCreateParams) => + await createPortListMutation.mutateAsync(data); + + const clonePortListMutation = useClonePortList({ + onSuccess: onCloned, + onError: onCloneError, + }); + const clonePortList = async (data: PortList) => + await clonePortListMutation.mutateAsync(data); + + const deletePortListMutation = useDeletePortList({ + onSuccess: onDeleted, + onError: onDeleteError, + }); + const deletePortList = async (data: PortList) => + await deletePortListMutation.mutateAsync(data); + + const importPortListMutation = useImportPortList({ + onSuccess: onImported, + onError: onImportError, + }); + + const createPortRangeMutation = useCreatePortRange(); + + const deletePortRangeMutation = useDeletePortRange(); + const handleDownload = useEntityDownload( entity => gmp.portlist.export(entity), { @@ -134,13 +149,6 @@ const PortListComponent = ({ onDownloaded, }, ); - const handleDelete = useEntityDelete( - entity => gmp.portlist.delete(entity), - { - onDeleteError, - onDeleted, - }, - ); const openPortListDialog = async (entity?: PortList) => { if (entity) { @@ -199,9 +207,8 @@ const PortListComponent = ({ }; const handleDeletePortRange = async (range: PortRange) => { - await gmp.portlist.deletePortRange({ + await deletePortRangeMutation.mutateAsync({ id: range.id as string, - portListId: range.portListId, }); }; @@ -211,13 +218,13 @@ const PortListComponent = ({ portRangeEnd: number; portType: ProtocolType; }) => { - const response = await gmp.portlist.createPortRange(data); - return response.data.id; + const response = await createPortRangeMutation.mutateAsync(data); + return response.id; }; const handleImportPortList = async (data: PortListImportDialogState) => { try { - const response = await gmp.portlist.import(data); + const response = await importPortListMutation.mutateAsync(data); if (isDefined(onImported)) { onImported(response); } @@ -229,7 +236,9 @@ const PortListComponent = ({ } }; - const handleSavePortList = async (data: SavePortListData) => { + const handleSavePortList = async ( + data: PortListDialogSaveData, + ) => { if (isDefined(data.id)) { // save existing port list try { @@ -270,13 +279,13 @@ const PortListComponent = ({ } throw error; } - await handleSave({ + await savePortList({ id: data.id, name: data.name, comment: data.comment, }); } else { - await handleCreate(data); + await createPortList(data); } closePortListDialog(); }; @@ -360,9 +369,9 @@ const PortListComponent = ({ return ( <> {children({ - clone: handleClone, + clone: clonePortList, download: handleDownload, - delete: handleDelete, + delete: deletePortList, create: openPortListDialog, edit: openPortListDialog, import: openImportDialog, diff --git a/src/web/pages/portlists/PortListDialog.tsx b/src/web/pages/portlists/PortListDialog.tsx index 24f74eec99..51de15e74b 100644 --- a/src/web/pages/portlists/PortListDialog.tsx +++ b/src/web/pages/portlists/PortListDialog.tsx @@ -20,7 +20,7 @@ import PortRangesTable, { type PortRange, } from 'web/pages/portlists/PortRangesTable'; -export interface SavePortListData { +export interface PortListDialogSaveData { id?: string; comment: string; file?: File; @@ -41,7 +41,7 @@ interface PortListsDialogProps { title?: string; onClose?: () => void; onNewPortRangeClick?: () => void; - onSave?: (data: SavePortListData) => void | Promise; + onSave?: (data: PortListDialogSaveData) => void | Promise; onTmpDeletePortRange?: (portRange: TPortRange) => void; } diff --git a/src/web/pages/portlists/__tests__/PortListComponent.test.tsx b/src/web/pages/portlists/__tests__/PortListComponent.test.tsx index 79cef1b04d..a3d8726070 100644 --- a/src/web/pages/portlists/__tests__/PortListComponent.test.tsx +++ b/src/web/pages/portlists/__tests__/PortListComponent.test.tsx @@ -26,11 +26,11 @@ const currentSettings = testing .mockResolvedValue(currentSettingsDefaultResponse); const createGmp = ({ getPortListResponse = defaultGetPortListResponse, - createPortListResponse = {id: '123'}, + createPortListResponse = {data: {id: '123'}}, // oxlint-disable-next-line typescript/no-useless-default-assignment deletePortListResponse = undefined, - savePortListResponse = {id: '123'}, - clonePortListResponse = {id: '123'}, + savePortListResponse = {data: {id: '123'}}, + clonePortListResponse = {data: {id: '123'}}, createPortRangeResponse = {id: '1234'}, getPortList = testing.fn().mockResolvedValue(getPortListResponse), createPortList = testing.fn().mockResolvedValue(createPortListResponse), @@ -181,8 +181,8 @@ describe('PortListComponent tests', () => { const saveButton = screen.getDialogSaveButton(); fireEvent.click(saveButton); await wait(); - expect(screen.queryByText('New Port List')).not.toBeInTheDocument(); - expect(onCreateError).toHaveBeenCalledWith(error); + expect(screen.getByText('New Port List')).toBeInTheDocument(); + expect(onCreateError.mock.calls[0][0]).toBe(error); expect(onCreated).not.toHaveBeenCalled(); }); @@ -345,7 +345,6 @@ describe('PortListComponent tests', () => { expect(deletePortRange).toHaveBeenCalledExactlyOnceWith({ id: portList.portRanges[0].id, - portListId: portList.id, }); expect(onSaveError).not.toHaveBeenCalled(); expect(gmp.portlist.save).toHaveBeenCalledExactlyOnceWith({ @@ -390,9 +389,9 @@ describe('PortListComponent tests', () => { fireEvent.click(saveButton); await wait(); - expect(screen.queryByText('Edit Port List foo')).not.toBeInTheDocument(); + expect(screen.getByText('Edit Port List foo')).toBeInTheDocument(); expect(onSaved).not.toHaveBeenCalled(); - expect(onSaveError).toHaveBeenCalledExactlyOnceWith(error); + expect(onSaveError.mock.calls[0][0]).toBe(error); }); test('should show error in dialog if saving a port list fails', async () => { @@ -469,7 +468,11 @@ describe('PortListComponent tests', () => { {({clone}) => ( ; + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => { + expect(gmp.credential.create).toHaveBeenCalledWith(input); + }); + }); + + test('should call onSuccess with the created credential data', async () => { + const gmp = createGmp(); + const onSuccess = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreateCredential({onSuccess}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith({id: 'credential-1'}); + }); + }); + + test('should call onError when creating a credential fails', async () => { + const error = new Error('Create failed'); + const gmp = createGmp(); + gmp.credential.create.mockRejectedValue(error); + const onError = testing.fn(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const mutation = useCreateCredential({onError}); + return ( + + ); + }; + + render(); + fireEvent.click(screen.getByRole('button', {name: 'Create'})); + + await waitFor(() => expect(onError.mock.calls[0][0]).toBe(error)); + }); +}); diff --git a/src/web/hooks/use-query/__tests__/Credentials.test.tsx b/src/web/hooks/use-query/__tests__/Credentials.test.tsx new file mode 100644 index 0000000000..c4f43af5e6 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/Credentials.test.tsx @@ -0,0 +1,149 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, screen, waitFor} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import Credential, { + USERNAME_PASSWORD_CREDENTIAL_TYPE, +} from 'gmp/models/credential'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import {createSession} from 'gmp/testing'; +import { + useGetAllCredentials, + useGetCredentials, +} from 'web/hooks/use-query/credentials'; + +const filter = QueryFilter.fromString('name~credential'); +const credentials = [ + new Credential({ + id: 'credential-1', + name: 'Credential 1', + credentialType: USERNAME_PASSWORD_CREDENTIAL_TYPE, + }), +]; + +const createGmp = () => { + const response = { + data: credentials, + meta: { + filter, + counts: new CollectionCounts({all: 1, filtered: 1, length: 1}), + }, + }; + + return { + session: createSession({token: 'test-token'}), + settings: {}, + credentials: { + get: testing.fn().mockResolvedValue(response), + getAll: testing.fn().mockResolvedValue(response), + }, + }; +}; + +describe('useGetCredentials', () => { + test('should fetch credentials with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetCredentials({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('credential')).toHaveTextContent( + 'Credential 1', + ); + }); + + expect(gmp.credentials.get).toHaveBeenCalledWith({filter}); + expect(gmp.credentials.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch credentials when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetCredentials({enabled: false}); + return
; + }; + + render(); + + expect(gmp.credentials.get).not.toHaveBeenCalled(); + }); + + test('should not fetch credentials without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetCredentials({filter}); + return
; + }; + + render(); + + expect(gmp.credentials.get).not.toHaveBeenCalled(); + }); +}); + +describe('useGetAllCredentials', () => { + test('should fetch all credentials with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetAllCredentials({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('credential')).toHaveTextContent( + 'Credential 1', + ); + }); + + expect(gmp.credentials.getAll).toHaveBeenCalledWith({filter}); + expect(gmp.credentials.get).not.toHaveBeenCalled(); + }); + + test('should not fetch credentials when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetAllCredentials({enabled: false}); + return
; + }; + + render(); + + expect(gmp.credentials.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch credentials without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetAllCredentials({filter}); + return
; + }; + + render(); + + expect(gmp.credentials.getAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/hooks/use-query/credential.ts b/src/web/hooks/use-query/credential.ts new file mode 100644 index 0000000000..0324b551ae --- /dev/null +++ b/src/web/hooks/use-query/credential.ts @@ -0,0 +1,30 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type CredentialCommandCreateParams} from 'gmp/commands/credential'; +import {type EntityActionData} from 'gmp/commands/entity'; +import useGmp from 'web/hooks/useGmp'; +import useGmpMutation from 'web/queries/useGmpMutation'; + +interface UseCredentialMutationCallbacks { + onSuccess?: (response: TResponse) => void; + onError?: (error: Error) => void; +} + +export const useCreateCredential = ({ + onSuccess, + onError, +}: UseCredentialMutationCallbacks = {}) => { + const gmp = useGmp(); + return useGmpMutation({ + gmpMethod: async data => { + const response = await gmp.credential.create(data); + return response.data; + }, + invalidateQueryIds: ['get_credentials'], + onSuccess, + onError, + }); +}; diff --git a/src/web/hooks/use-query/credentials.ts b/src/web/hooks/use-query/credentials.ts new file mode 100644 index 0000000000..18e722174e --- /dev/null +++ b/src/web/hooks/use-query/credentials.ts @@ -0,0 +1,45 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type Credential from 'gmp/models/credential'; +import {type FilterType} from 'gmp/models/filter'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntities from 'web/queries/useGetEntities'; + +interface UseGetCredentialsParams { + enabled?: boolean; + filter?: FilterType; + staleTime?: number; +} + +export const useGetCredentials = ({ + enabled, + filter, + staleTime, +}: UseGetCredentialsParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.credentials.get.bind(gmp.credentials), + queryId: 'get_credentials', + enabled, + filter, + staleTime, + }); +}; + +export const useGetAllCredentials = ({ + enabled, + filter, + staleTime, +}: UseGetCredentialsParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.credentials.getAll.bind(gmp.credentials), + queryId: 'get_credentials', + enabled, + filter, + staleTime, + }); +}; From 0383de3543a377bb7cabf4fd79f7082c4db21631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 6 Oct 2026 11:18:34 +0200 Subject: [PATCH 13/36] Change: Use new port list and credential query hooks Update TargetComponent and TargetDialog to use the new port list and credential query hooks internally instead of directly querying the gmp API. --- src/web/pages/targets/TargetComponent.tsx | 81 ++++++++----------- src/web/pages/targets/TargetDialog.tsx | 12 ++- .../__tests__/TargetComponent.test.tsx | 52 ++++++------ .../targets/__tests__/TargetDialog.test.tsx | 50 +++++++----- 4 files changed, 98 insertions(+), 97 deletions(-) diff --git a/src/web/pages/targets/TargetComponent.tsx b/src/web/pages/targets/TargetComponent.tsx index 909636cb20..5002af6604 100644 --- a/src/web/pages/targets/TargetComponent.tsx +++ b/src/web/pages/targets/TargetComponent.tsx @@ -10,12 +10,9 @@ import { type TargetExcludeSource, type TargetSource, } from 'gmp/commands/target'; -import { - type default as Credential, - type CredentialType, -} from 'gmp/models/credential'; +import {type CredentialType} from 'gmp/models/credential'; import type Filter from 'gmp/models/filter'; -import type PortList from 'gmp/models/port-list'; +import {type PortRange} from 'gmp/models/port-list'; import { type default as Target, type AliveTest, @@ -26,6 +23,8 @@ import {isDefined} from 'gmp/utils/identity'; import useEntityDownload, { type OnDownloadedFunc, } from 'web/entity/hooks/useEntityDownload'; +import {useCreateCredential} from 'web/hooks/use-query/credential'; +import {useCreatePortList} from 'web/hooks/use-query/port-list'; import { useCloneTarget, useCreateTarget, @@ -37,7 +36,9 @@ import useTranslation from 'web/hooks/useTranslation'; import CredentialDialog, { type CredentialDialogState, } from 'web/pages/credentials/CredentialDialog'; -import PortListDialog from 'web/pages/portlists/PortListDialog'; +import PortListDialog, { + type PortListDialogSaveData, +} from 'web/pages/portlists/PortListDialog'; import TargetDialog, { DEFAULT_PORT_LIST_ID, type NewCredentialData, @@ -104,14 +105,12 @@ const TargetComponent = ({ useState(false); const [credentialsTitle, setCredentialsTitle] = useState(''); const [credentialTypes, setCredentialTypes] = useState([]); - const [credentials, setCredentials] = useState([]); const [portListDialogVisible, setPortListDialogVisible] = useState(false); const [targetDialogVisible, setTargetDialogVisible] = useState(false); const [targetTitle, setTargetTitle] = useState(''); const [portListId, setPortListId] = useState( DEFAULT_PORT_LIST_ID, ); - const [portLists, setPortLists] = useState([]); const [portListsTitle, setPortListsTitle] = useState(''); const [port, setPort] = useState(undefined); const [aliveTests, setAliveTests] = useState([ @@ -158,19 +157,8 @@ const TargetComponent = ({ const [hostsCount, setHostsCount] = useState(undefined); const [hostsFilter, setHostsFilter] = useState(undefined); - const loadCredentials = async () => { - const response = await gmp.credentials.getAll(); - setCredentials(response.data); - }; - - const loadPortLists = async () => { - const response = await gmp.portlists.getAll(); - setPortLists(response.data); - }; - - const loadAll = async () => { - await Promise.all([loadCredentials(), loadPortLists()]); - }; + const createPortListMutation = useCreatePortList(); + const createCredentialMutation = useCreateCredential(); const openCredentialsDialog = ({ idField, @@ -257,7 +245,6 @@ const TargetComponent = ({ } setHostsCount(hostsCount); setHostsFilter(hostsFilter); - await loadAll(); setTargetDialogVisible(true); }; @@ -274,10 +261,9 @@ const TargetComponent = ({ }; const handleCreateCredential = async (data: CredentialDialogState) => { - const response = await gmp.credential.create(data); - const credentialId = response.data.id; + const response = await createCredentialMutation.mutateAsync(data); + const credentialId = response.id; closeCredentialsDialog(); - await loadCredentials(); if (idFieldRef.current === 'sshElevateCredentialId') { setSshElevateCredentialId(credentialId); } else if (idFieldRef.current === 'sshCredentialId') { @@ -293,11 +279,12 @@ const TargetComponent = ({ } }; - const handleCreatePortList = async data => { - const response = await gmp.portlist.create(data); - setPortListId(response.data.id); + const handleCreatePortList = async ( + data: PortListDialogSaveData, + ) => { + const response = await createPortListMutation.mutateAsync(data); + setPortListId(response.id); closePortListDialog(); - await loadPortLists(); }; const handlePortListChange = (portListId: string | undefined) => { @@ -336,45 +323,43 @@ const TargetComponent = ({ setKrb5CredentialId(krb5CredentialId ?? UNSET_VALUE); }; - const cloneTarget = useCloneTarget({ + const cloneTargetMutation = useCloneTarget({ onSuccess: onCloned, onError: onCloneError, }); - const handleTargetClone = async (entity: Target) => { - await cloneTarget.mutateAsync(entity); + const cloneTarget = async (entity: Target) => { + await cloneTargetMutation.mutateAsync(entity); }; - const saveTarget = useSaveTarget({ + const saveTargetMutation = useSaveTarget({ onSuccess: onSaved, onError: onSaveError, }); - - const handleTargetSave = async (data: TargetCommandSaveParams) => { - await saveTarget.mutateAsync(data); + const saveTarget = async (data: TargetCommandSaveParams) => { + await saveTargetMutation.mutateAsync(data); }; - const createTarget = useCreateTarget({ + const createTargetMutation = useCreateTarget({ onSuccess: onCreated, onError: onCreateError, }); - - const handleTargetCreate = async (data: TargetDialogData) => { - await createTarget.mutateAsync(data); + const createTarget = async (data: TargetDialogData) => { + await createTargetMutation.mutateAsync(data); }; - const deleteTarget = useDeleteTarget({ + const deleteTargetMutation = useDeleteTarget({ onSuccess: onDeleted, onError: onDeleteError, }); - const handleTargetDelete = async (entity: Target) => { - await deleteTarget.mutateAsync(entity); + const deleteTarget = async (entity: Target) => { + await deleteTargetMutation.mutateAsync(entity); }; const handleSaveClick = async (data: TargetDialogData) => { const promise = isDefined(data.id) - ? handleTargetSave( + ? saveTarget( data.inUse ? { id: data.id as string, @@ -387,7 +372,7 @@ const TargetComponent = ({ id: data.id as string, }, ) - : handleTargetCreate(data); + : createTarget(data); await promise; closeTargetDialog(); }; @@ -403,9 +388,9 @@ const TargetComponent = ({ return ( <> {children({ - clone: handleTargetClone, + clone: cloneTarget, create: openCreateTargetDialog, - delete: handleTargetDelete, + delete: deleteTarget, download: handleEntityDownload, edit: openTargetDialog, })} @@ -414,7 +399,6 @@ const TargetComponent = ({ aliveTests={aliveTests} allowSimultaneousIPs={allowSimultaneousIPs} comment={comment} - credentials={credentials} esxiCredentialId={esxiCredentialId} excludeHosts={excludeHosts} hosts={hosts} @@ -426,7 +410,6 @@ const TargetComponent = ({ name={name} port={port} portListId={portListId} - portLists={portLists} reverseLookupOnly={reverseLookupOnly} reverseLookupUnify={reverseLookupUnify} smbCredentialId={smbCredentialId} diff --git a/src/web/pages/targets/TargetDialog.tsx b/src/web/pages/targets/TargetDialog.tsx index 75faa8cb87..1ee94646ae 100644 --- a/src/web/pages/targets/TargetDialog.tsx +++ b/src/web/pages/targets/TargetDialog.tsx @@ -49,6 +49,8 @@ import TextField from 'web/components/form/TextField'; import YesNoRadio from 'web/components/form/YesNoRadio'; import {InfoIcon, NewIcon} from 'web/components/icon'; import Row from 'web/components/layout/Row'; +import {useGetAllCredentials} from 'web/hooks/use-query/credentials'; +import {useGetAllPortLists} from 'web/hooks/use-query/port-lists'; import useCapabilities from 'web/hooks/useCapabilities'; import useFeatures from 'web/hooks/useFeatures'; import useGmp from 'web/hooks/useGmp'; @@ -111,7 +113,6 @@ interface TargetDialogProps { aliveTests?: AliveTest[]; allowSimultaneousIPs?: boolean; comment?: string; - credentials?: Credential[]; esxiCredentialId?: string; excludeHosts?: string[]; hosts?: string[]; @@ -122,7 +123,6 @@ interface TargetDialogProps { name?: string; port?: number; portListId?: string; - portLists?: PortList[]; reverseLookupOnly?: boolean; reverseLookupUnify?: boolean; smbCredentialId?: string; @@ -165,7 +165,6 @@ const TargetDialog = ({ aliveTests: initialAliveTests = [SCAN_CONFIG_DEFAULT], allowSimultaneousIPs = true, comment = '', - credentials = [], esxiCredentialId, excludeHosts = [], hosts = [], @@ -176,7 +175,6 @@ const TargetDialog = ({ name, port = DEFAULT_PORT, portListId = DEFAULT_PORT_LIST_ID, - portLists = DEFAULT_PORT_LISTS, reverseLookupOnly = false, reverseLookupUnify = false, smbCredentialId, @@ -206,6 +204,11 @@ const TargetDialog = ({ const enableKrb5 = gmp.settings.enableKrb5; const hasPermissionToCreateCredential = capabilities.mayCreate('credential'); const [aliveTests, setAliveTests] = useState(initialAliveTests); + const portListQuery = useGetAllPortLists(); + const credentialsQuery = useGetAllCredentials(); + + const portLists = portListQuery.data?.entities ?? DEFAULT_PORT_LISTS; + const credentials = credentialsQuery.data?.entities ?? []; // Feature flag configuration const isCredentialStoresEnabled = features.featureEnabled( @@ -544,6 +547,7 @@ const TargetDialog = ({