diff --git a/ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx b/ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx
new file mode 100644
index 000000000..3fdc6b752
--- /dev/null
+++ b/ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx
@@ -0,0 +1,132 @@
+import { screen, waitFor } from "@testing-library/dom";
+import { faker } from "@faker-js/faker";
+import userEvent from "@testing-library/user-event";
+import { Location } from "react-router-dom";
+import MockAdapter from "axios-mock-adapter";
+import * as reactQuery from "@tanstack/react-query";
+
+import { renderComponent } from "test/utils";
+import { urls } from "urls";
+import { axiosInstance } from "api/axios";
+
+import ClientCreate from "./ClientCreate";
+import { ClientFormLabel } from "../ClientForm";
+import { Label } from "./types";
+import { initialValues } from "./ClientCreate";
+import {
+ NotificationProvider,
+ NotificationConsumer,
+} from "@canonical/react-components";
+import { queryKeys } from "util/queryKeys";
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual("@tanstack/react-query");
+ return {
+ ...actual,
+ useQueryClient: vi.fn(),
+ };
+});
+
+const mock = new MockAdapter(axiosInstance);
+
+beforeEach(() => {
+ mock.reset();
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries: vi.fn(),
+ } as unknown as reactQuery.QueryClient);
+ mock.onPost("/clients").reply(200);
+});
+
+test("can cancel", async () => {
+ let location: Location | null = null;
+ renderComponent(, {
+ url: "/",
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ });
+ await userEvent.click(screen.getByRole("button", { name: Label.CANCEL }));
+ expect((location as Location | null)?.pathname).toBe(urls.clients.index);
+});
+
+test("calls the API on submit", async () => {
+ const values = {
+ client_name: faker.word.sample(),
+ };
+ renderComponent();
+ const input = screen.getByRole("textbox", { name: ClientFormLabel.NAME });
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, values.client_name);
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(mock.history.post[0].url).toBe("/clients");
+ expect(mock.history.post[0].data).toBe(
+ JSON.stringify({
+ ...initialValues,
+ ...values,
+ }),
+ );
+});
+
+test("handles API success", async () => {
+ let location: Location | null = null;
+ const invalidateQueries = vi.fn();
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries,
+ } as unknown as reactQuery.QueryClient);
+ mock.onPost("/clients").reply(200, {
+ data: { client_id: "client1", client_secret: "secret1" },
+ });
+ const values = {
+ client_name: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ {
+ url: "/",
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ },
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ClientFormLabel.NAME }),
+ values.client_name,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ await waitFor(() =>
+ expect(invalidateQueries).toHaveBeenCalledWith({
+ queryKey: [queryKeys.clients],
+ }),
+ );
+ expect(document.querySelector(".p-notification--positive")).toHaveTextContent(
+ "Client created. Id: client1 Secret: secret1",
+ ),
+ expect((location as Location | null)?.pathname).toBe(urls.clients.index);
+});
+
+test("handles API failure", async () => {
+ mock.onPost("/clients").reply(400, {
+ message: "oops",
+ });
+ const values = {
+ client_name: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ClientFormLabel.NAME }),
+ values.client_name,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(document.querySelector(".p-notification--negative")).toHaveTextContent(
+ `${Label.ERROR}oops`,
+ );
+});
diff --git a/ui/src/pages/clients/ClientCreate/ClientCreate.tsx b/ui/src/pages/clients/ClientCreate/ClientCreate.tsx
index 8aed12a08..eb1640fb4 100644
--- a/ui/src/pages/clients/ClientCreate/ClientCreate.tsx
+++ b/ui/src/pages/clients/ClientCreate/ClientCreate.tsx
@@ -17,6 +17,17 @@ import SidePanel from "components/SidePanel";
import ScrollableContainer from "components/ScrollableContainer";
import { TestId } from "./test-types";
import { testId } from "test/utils";
+import { Label } from "./types";
+
+export const initialValues = {
+ client_uri: "",
+ client_name: "grafana",
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code", "id_token"],
+ scope: "openid offline_access email",
+ redirect_uris: ["http://localhost:2345/login/generic_oauth"],
+ request_object_signing_alg: "RS256",
+};
const ClientCreate: FC = () => {
const navigate = useNavigate();
@@ -28,15 +39,7 @@ const ClientCreate: FC = () => {
});
const formik = useFormik({
- initialValues: {
- client_uri: "",
- client_name: "grafana",
- grant_types: ["authorization_code", "refresh_token"],
- response_types: ["code", "id_token"],
- scope: "openid offline_access email",
- redirect_uris: ["http://localhost:2345/login/generic_oauth"],
- request_object_signing_alg: "RS256",
- },
+ initialValues,
validationSchema: ClientCreateSchema,
onSubmit: (values) => {
createClient(JSON.stringify(values))
@@ -47,9 +50,13 @@ const ClientCreate: FC = () => {
const msg = `Client created. Id: ${result.client_id} Secret: ${result.client_secret}`;
navigate("/client", notify.queue(notify.success(msg)));
})
- .catch((e) => {
+ .catch((error: unknown) => {
formik.setSubmitting(false);
- notify.failure("Client creation failed", e);
+ notify.failure(
+ Label.ERROR,
+ error instanceof Error ? error : null,
+ typeof error === "string" ? error : null,
+ );
});
},
});
@@ -80,7 +87,7 @@ const ClientCreate: FC = () => {
{
disabled={!formik.isValid}
onClick={submitForm}
>
- Save
+ {Label.SUBMIT}
diff --git a/ui/src/pages/clients/ClientCreate/types.ts b/ui/src/pages/clients/ClientCreate/types.ts
new file mode 100644
index 000000000..c523ed4d5
--- /dev/null
+++ b/ui/src/pages/clients/ClientCreate/types.ts
@@ -0,0 +1,5 @@
+export enum Label {
+ CANCEL = "Cancel",
+ ERROR = "Client creation failed",
+ SUBMIT = "Save",
+}
diff --git a/ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx b/ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx
new file mode 100644
index 000000000..5aa3048be
--- /dev/null
+++ b/ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx
@@ -0,0 +1,143 @@
+import { screen, waitFor } from "@testing-library/dom";
+import { faker } from "@faker-js/faker";
+import userEvent from "@testing-library/user-event";
+import { Location } from "react-router-dom";
+import MockAdapter from "axios-mock-adapter";
+import * as reactQuery from "@tanstack/react-query";
+
+import { renderComponent } from "test/utils";
+import { urls } from "urls";
+import { axiosInstance } from "api/axios";
+
+import ClientEdit from "./ClientEdit";
+import { ClientFormLabel } from "../ClientForm";
+import { Label } from "./types";
+import {
+ NotificationProvider,
+ NotificationConsumer,
+} from "@canonical/react-components";
+import { queryKeys } from "util/queryKeys";
+import { mockClient } from "test/mocks/clients";
+import { Client } from "types/client";
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual("@tanstack/react-query");
+ return {
+ ...actual,
+ useQueryClient: vi.fn(),
+ };
+});
+
+const mock = new MockAdapter(axiosInstance);
+
+let client: Client;
+
+beforeEach(() => {
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries: vi.fn(),
+ } as unknown as reactQuery.QueryClient);
+ mock.reset();
+ client = mockClient();
+ mock.onGet(`/clients/${client.client_id}`).reply(200, { data: client });
+ mock.onPut(`/clients/${client.client_id}`).reply(200);
+});
+
+test("can cancel", async () => {
+ let location: Location | null = null;
+ renderComponent(, {
+ url: `/?id=${client.client_id}`,
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ });
+ await userEvent.click(screen.getByRole("button", { name: Label.CANCEL }));
+ expect((location as Location | null)?.pathname).toBe(urls.clients.index);
+});
+
+test("calls the API on submit", async () => {
+ const values = {
+ client_name: faker.word.sample(),
+ };
+ renderComponent(, {
+ url: `/?id=${client.client_id}`,
+ });
+ const input = screen.getByRole("textbox", { name: ClientFormLabel.NAME });
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, values.client_name);
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(mock.history.put[0].url).toBe(`/clients/${client.client_id}`);
+ expect(JSON.parse(mock.history.put[0].data as string)).toMatchObject({
+ client_uri: client.client_uri,
+ grant_types: client.grant_types,
+ response_types: client.response_types,
+ scope: client.scope,
+ redirect_uris: client.redirect_uris,
+ request_object_signing_alg: client.request_object_signing_alg,
+ ...values,
+ });
+});
+
+test("handles API success", async () => {
+ let location: Location | null = null;
+ const invalidateQueries = vi.fn();
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries,
+ } as unknown as reactQuery.QueryClient);
+ mock.onPut(`/clients/${client.client_id}`).reply(200, {
+ data: { client_id: "client1", client_secret: "secret1" },
+ });
+ const values = {
+ client_name: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ {
+ url: `/?id=${client.client_id}`,
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ },
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ClientFormLabel.NAME }),
+ values.client_name,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ await waitFor(() =>
+ expect(invalidateQueries).toHaveBeenCalledWith({
+ queryKey: [queryKeys.clients],
+ }),
+ );
+ expect(document.querySelector(".p-notification--positive")).toHaveTextContent(
+ Label.SUCCESS,
+ ),
+ expect((location as Location | null)?.pathname).toBe(urls.clients.index);
+});
+
+test("handles API failure", async () => {
+ mock.onPut(`/clients/${client.client_id}`).reply(400, {
+ message: "oops",
+ });
+ const values = {
+ client_name: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ { url: `/?id=${client.client_id}` },
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ClientFormLabel.NAME }),
+ values.client_name,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(document.querySelector(".p-notification--negative")).toHaveTextContent(
+ `${Label.ERROR}oops`,
+ );
+});
diff --git a/ui/src/pages/clients/ClientEdit/ClientEdit.tsx b/ui/src/pages/clients/ClientEdit/ClientEdit.tsx
index d59e835f2..faa396e00 100644
--- a/ui/src/pages/clients/ClientEdit/ClientEdit.tsx
+++ b/ui/src/pages/clients/ClientEdit/ClientEdit.tsx
@@ -18,6 +18,7 @@ import SidePanel from "components/SidePanel";
import ScrollableContainer from "components/ScrollableContainer";
import { TestId } from "./test-types";
import { testId } from "test/utils";
+import { Label } from "./types";
const ClientEdit: FC = () => {
const navigate = useNavigate();
@@ -26,13 +27,9 @@ const ClientEdit: FC = () => {
const panelParams = usePanelParams();
const clientId = panelParams.id;
- if (!clientId) {
- return;
- }
-
const { data } = useQuery({
queryKey: [queryKeys.clients, clientId],
- queryFn: () => fetchClient(clientId),
+ queryFn: () => (clientId ? fetchClient(clientId) : null),
});
const client = data?.data;
@@ -42,13 +39,13 @@ const ClientEdit: FC = () => {
const formik = useFormik({
initialValues: {
- client_uri: client?.client_uri,
- client_name: client?.client_name,
- grant_types: client?.grant_types,
- response_types: client?.response_types,
- scope: client?.scope,
- redirect_uris: client?.redirect_uris,
- request_object_signing_alg: client?.request_object_signing_alg,
+ client_uri: client?.client_uri || "",
+ client_name: client?.client_name || "",
+ grant_types: client?.grant_types || [],
+ response_types: client?.response_types || [],
+ scope: client?.scope || "",
+ redirect_uris: client?.redirect_uris || [],
+ request_object_signing_alg: client?.request_object_signing_alg || "",
},
enableReinitialize: true,
validationSchema: ClientEditSchema,
@@ -58,11 +55,15 @@ const ClientEdit: FC = () => {
void queryClient.invalidateQueries({
queryKey: [queryKeys.clients],
});
- navigate("/client", notify.queue(notify.success("Client updated")));
+ navigate("/client", notify.queue(notify.success(Label.SUCCESS)));
})
- .catch((e) => {
+ .catch((error: unknown) => {
formik.setSubmitting(false);
- notify.failure("Client update failed", e);
+ notify.failure(
+ Label.ERROR,
+ error instanceof Error ? error : null,
+ typeof error === "string" ? error : null,
+ );
});
},
});
@@ -97,7 +98,7 @@ const ClientEdit: FC = () => {
className="u-no-margin--bottom u-sv2"
onClick={() => navigate("/client")}
>
- Cancel
+ {Label.CANCEL}
{
disabled={!formik.isValid}
onClick={submitForm}
>
- Update
+ {Label.SUBMIT}
diff --git a/ui/src/pages/clients/ClientEdit/types.ts b/ui/src/pages/clients/ClientEdit/types.ts
new file mode 100644
index 000000000..9687f6d6e
--- /dev/null
+++ b/ui/src/pages/clients/ClientEdit/types.ts
@@ -0,0 +1,6 @@
+export enum Label {
+ CANCEL = "Cancel",
+ ERROR = "Client update failed",
+ SUBMIT = "Update",
+ SUCCESS = "Client updated",
+}
diff --git a/ui/src/pages/clients/ClientForm/index.ts b/ui/src/pages/clients/ClientForm/index.ts
index 8d4991efb..ceb42a504 100644
--- a/ui/src/pages/clients/ClientForm/index.ts
+++ b/ui/src/pages/clients/ClientForm/index.ts
@@ -1,2 +1,3 @@
export { default } from "./ClientForm";
export type { ClientFormTypes } from "./ClientForm";
+export { Label as ClientFormLabel } from "./types";