From e14b2aafef1fcc0cce8ab10fae2312da61b71d73 Mon Sep 17 00:00:00 2001 From: David Edler Date: Fri, 12 Apr 2024 12:10:19 +0200 Subject: [PATCH] feat: add pagination to clients, schemas and identity lists in ui. Add identity creation form WD-10253 Signed-off-by: David Edler --- ui/README.md | 2 +- ui/src/api/client.tsx | 16 ++-- ui/src/api/identities.tsx | 12 ++- ui/src/api/schema.tsx | 12 ++- ui/src/components/IconLeft.tsx | 7 ++ ui/src/components/IconRight.tsx | 7 ++ ui/src/components/Pagination.tsx | 55 +++++++++++ ui/src/components/Panels.tsx | 3 + ui/src/pages/clients/ClientList.tsx | 30 +++--- ui/src/pages/identities/DeleteIdentityBtn.tsx | 62 ++++++++++++ ui/src/pages/identities/IdentityCreate.tsx | 95 +++++++++++++++++++ ui/src/pages/identities/IdentityForm.tsx | 54 +++++++++++ ui/src/pages/identities/IdentityList.tsx | 57 ++++++++--- ui/src/pages/providers/ProviderList.tsx | 1 - ui/src/pages/schemas/SchemaList.tsx | 31 +++--- ui/src/types/api.d.ts | 9 ++ ui/src/util/api.tsx | 2 + ui/src/util/usePagination.tsx | 11 +++ ui/src/util/usePanelParams.tsx | 6 ++ 19 files changed, 414 insertions(+), 58 deletions(-) create mode 100644 ui/src/components/IconLeft.tsx create mode 100644 ui/src/components/IconRight.tsx create mode 100644 ui/src/components/Pagination.tsx create mode 100644 ui/src/pages/identities/DeleteIdentityBtn.tsx create mode 100644 ui/src/pages/identities/IdentityCreate.tsx create mode 100644 ui/src/pages/identities/IdentityForm.tsx create mode 100644 ui/src/util/usePagination.tsx diff --git a/ui/README.md b/ui/README.md index 00d7a49d7..124ffd171 100644 --- a/ui/README.md +++ b/ui/README.md @@ -40,4 +40,4 @@ Install dotrun as described in https://github.com/canonical/dotrun#installation dotrun -browse to https://localhost:8411/ to reach iam-admin-ui. \ No newline at end of file +browse to http://localhost:8411/ to reach iam-admin-ui. \ No newline at end of file diff --git a/ui/src/api/client.tsx b/ui/src/api/client.tsx index 98085d26a..67b4f256a 100644 --- a/ui/src/api/client.tsx +++ b/ui/src/api/client.tsx @@ -1,12 +1,16 @@ import { Client } from "types/client"; -import { ApiResponse } from "types/api"; -import { handleResponse } from "util/api"; +import { ApiResponse, PaginatedResponse } from "types/api"; +import { handleResponse, PAGE_SIZE } from "util/api"; -export const fetchClients = (): Promise => { +export const fetchClients = ( + page: string, +): Promise> => { return new Promise((resolve, reject) => { - fetch(`${import.meta.env.VITE_API_URL}/clients`) + fetch( + `${import.meta.env.VITE_API_URL}/clients?page_token=${page}&size=${PAGE_SIZE}`, + ) .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) + .then((result: PaginatedResponse) => resolve(result)) .catch(reject); }); }; @@ -34,7 +38,7 @@ export const createClient = (values: string): Promise => { export const updateClient = ( clientId: string, - values: string + values: string, ): Promise => { return new Promise((resolve, reject) => { fetch(`${import.meta.env.VITE_API_URL}/clients/${clientId}`, { diff --git a/ui/src/api/identities.tsx b/ui/src/api/identities.tsx index 31577dabf..08200157a 100644 --- a/ui/src/api/identities.tsx +++ b/ui/src/api/identities.tsx @@ -1,12 +1,14 @@ -import { ApiResponse } from "types/api"; -import { handleResponse } from "util/api"; +import { ApiResponse, PaginatedResponse } from "types/api"; +import { handleResponse, PAGE_SIZE } from "util/api"; import { Identity } from "types/identity"; -export const fetchIdentities = (): Promise => { +export const fetchIdentities = ( + page: string, +): Promise> => { return new Promise((resolve, reject) => { - fetch("/api/v0/identities") + fetch(`/api/v0/identities?page_token=${page}&size=${PAGE_SIZE}`) .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) + .then((result: PaginatedResponse) => resolve(result)) .catch(reject); }); }; diff --git a/ui/src/api/schema.tsx b/ui/src/api/schema.tsx index dbdcb51d3..e07e6c36a 100644 --- a/ui/src/api/schema.tsx +++ b/ui/src/api/schema.tsx @@ -1,12 +1,14 @@ -import { ApiResponse } from "types/api"; -import { handleResponse } from "util/api"; +import { ApiResponse, PaginatedResponse } from "types/api"; +import { handleResponse, PAGE_SIZE } from "util/api"; import { Schema } from "types/schema"; -export const fetchSchemas = (): Promise => { +export const fetchSchemas = ( + page: string, +): Promise> => { return new Promise((resolve, reject) => { - fetch("/api/v0/schemas") + fetch(`/api/v0/schemas?page_token=${page}&page_size=${PAGE_SIZE}`) .then(handleResponse) - .then((result: ApiResponse) => resolve(result.data)) + .then((result: PaginatedResponse) => resolve(result)) .catch(reject); }); }; diff --git a/ui/src/components/IconLeft.tsx b/ui/src/components/IconLeft.tsx new file mode 100644 index 000000000..781e7554c --- /dev/null +++ b/ui/src/components/IconLeft.tsx @@ -0,0 +1,7 @@ +import React, { FC } from "react"; + +const IconLeft: FC = () => { + return ; +}; + +export default IconLeft; diff --git a/ui/src/components/IconRight.tsx b/ui/src/components/IconRight.tsx new file mode 100644 index 000000000..86211761d --- /dev/null +++ b/ui/src/components/IconRight.tsx @@ -0,0 +1,7 @@ +import React, { FC } from "react"; + +const IconRight: FC = () => { + return ; +}; + +export default IconRight; diff --git a/ui/src/components/Pagination.tsx b/ui/src/components/Pagination.tsx new file mode 100644 index 000000000..f9e6fd472 --- /dev/null +++ b/ui/src/components/Pagination.tsx @@ -0,0 +1,55 @@ +import React, { FC } from "react"; +import { PaginatedResponse } from "types/api"; +import { Button } from "@canonical/react-components"; +import { usePagination } from "util/usePagination"; +import IconLeft from "components/IconLeft"; +import IconRight from "components/IconRight"; + +interface Props { + response?: PaginatedResponse; +} + +const Pagination: FC = ({ response }) => { + const { page, setPage } = usePagination(); + const showFirstLink = page !== ""; + const isMissingNext = !response || !response._meta.next; + const isEmptyPage = response?.data.length === 0; + + if (!showFirstLink && (isMissingNext || isEmptyPage)) { + return null; + } + + const first = response?._meta.first ?? ""; + const last = response?._meta.last ?? ""; + const next = response?._meta.next ?? ""; + const prev = response?._meta.prev ?? ""; + + return ( + <> + {showFirstLink && ( + + )} + {prev && ( + + )} + {next && ( + + )} + {last && ( + + )} + + ); +}; + +export default Pagination; diff --git a/ui/src/components/Panels.tsx b/ui/src/components/Panels.tsx index 0aa38e33b..37afc252e 100644 --- a/ui/src/components/Panels.tsx +++ b/ui/src/components/Panels.tsx @@ -3,6 +3,7 @@ import usePanelParams, { panels } from "util/usePanelParams"; import ProviderEdit from "pages/providers/ProviderEdit"; import ClientCreate from "pages/clients/ClientCreate"; import ClientEdit from "pages/clients/ClientEdit"; +import IdentityCreate from "pages/identities/IdentityCreate"; const Panels = () => { const panelParams = usePanelParams(); @@ -17,6 +18,8 @@ const Panels = () => { return ; case panels.clientEdit: return ; + case panels.identityCreate: + return ; default: return null; } diff --git a/ui/src/pages/clients/ClientList.tsx b/ui/src/pages/clients/ClientList.tsx index 8204d2362..5859b1238 100644 --- a/ui/src/pages/clients/ClientList.tsx +++ b/ui/src/pages/clients/ClientList.tsx @@ -8,13 +8,17 @@ import { isoTimeToString } from "util/date"; import usePanelParams from "util/usePanelParams"; import EditClientBtn from "pages/clients/EditClientBtn"; import DeleteClientBtn from "pages/clients/DeleteClientBtn"; +import Loader from "components/Loader"; +import Pagination from "components/Pagination"; +import { usePagination } from "util/usePagination"; const ClientList: FC = () => { const panelParams = usePanelParams(); + const { page } = usePagination(); - const { data: clients = [] } = useQuery({ - queryKey: [queryKeys.clients], - queryFn: fetchClients, + const { data: response, isLoading } = useQuery({ + queryKey: [queryKeys.clients, page], + queryFn: () => fetchClients(page), }); return ( @@ -35,16 +39,14 @@ const ClientList: FC = () => { { + rows={response?.data.map((client) => { return { columns: [ { @@ -82,13 +84,17 @@ const ClientList: FC = () => { "aria-label": "Actions", }, ], - sortData: { - id: client.client_id, - name: client.client_name.toLowerCase(), - }, }; })} + emptyStateMsg={ + isLoading ? ( + + ) : ( + "No data to display" + ) + } /> + diff --git a/ui/src/pages/identities/DeleteIdentityBtn.tsx b/ui/src/pages/identities/DeleteIdentityBtn.tsx new file mode 100644 index 000000000..3530675f8 --- /dev/null +++ b/ui/src/pages/identities/DeleteIdentityBtn.tsx @@ -0,0 +1,62 @@ +import { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { queryKeys } from "util/queryKeys"; +import { useQueryClient } from "@tanstack/react-query"; +import { ConfirmationButton, useNotify } from "@canonical/react-components"; +import { Identity } from "types/identity"; +import { deleteIdentity } from "api/identities"; + +interface Props { + identity: Identity; +} + +const DeleteIdentityBtn: FC = ({ identity }) => { + const notify = useNotify(); + const queryClient = useQueryClient(); + const [isLoading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleDelete = () => { + setLoading(true); + deleteIdentity(identity.id) + .then(() => { + navigate( + "/identity", + notify.queue( + notify.success(`Identity ${identity.traits?.email} deleted.`), + ), + ); + }) + .catch((e) => { + notify.failure("Identity deletion failed", e); + }) + .finally(() => { + setLoading(false); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.identities], + }); + }); + }; + + return ( + + This will permanently delete identity {identity.traits?.email}. +

+ ), + confirmButtonLabel: "Delete identity", + onConfirm: handleDelete, + }} + title="Confirm delete" + > + Delete +
+ ); +}; + +export default DeleteIdentityBtn; diff --git a/ui/src/pages/identities/IdentityCreate.tsx b/ui/src/pages/identities/IdentityCreate.tsx new file mode 100644 index 000000000..b559245d8 --- /dev/null +++ b/ui/src/pages/identities/IdentityCreate.tsx @@ -0,0 +1,95 @@ +import React, { FC } from "react"; +import { + ActionButton, + Button, + Col, + Row, + useNotify, +} from "@canonical/react-components"; +import { useFormik } from "formik"; +import * as Yup from "yup"; +import { queryKeys } from "util/queryKeys"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import IdentityForm, { IdentityFormTypes } from "pages/identities/IdentityForm"; +import { createIdentity } from "api/identities"; +import SidePanel from "components/SidePanel"; +import ScrollableContainer from "components/ScrollableContainer"; + +const IdentityCreate: FC = () => { + const navigate = useNavigate(); + const notify = useNotify(); + const queryClient = useQueryClient(); + + const IdentityCreateSchema = Yup.object().shape({ + email: Yup.string().required("This field is required"), + schemaId: Yup.string().required("This field is required"), + }); + + const formik = useFormik({ + initialValues: { + email: "", + schemaId: "", + }, + validationSchema: IdentityCreateSchema, + validateOnMount: true, + onSubmit: (values) => { + const identity = { + schema_id: values.schemaId, + traits: { email: values.email }, + }; + createIdentity(JSON.stringify(identity)) + .then(() => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.identities], + }); + const msg = `Identity created.`; + navigate("/identity", notify.queue(notify.success(msg))); + }) + .catch((e) => { + formik.setSubmitting(false); + notify.failure("Identity creation failed", e); + }); + }, + }); + + const submitForm = () => { + void formik.submitForm(); + }; + + return ( + + + + Add identity + + + + + + + + + + ); +}; + +export default IdentityCreate; diff --git a/ui/src/pages/identities/IdentityForm.tsx b/ui/src/pages/identities/IdentityForm.tsx new file mode 100644 index 000000000..237b33b1d --- /dev/null +++ b/ui/src/pages/identities/IdentityForm.tsx @@ -0,0 +1,54 @@ +import React, { FC } from "react"; +import { Form, Input, Select } from "@canonical/react-components"; +import { FormikProps } from "formik"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { fetchSchemas } from "api/schema"; + +export interface IdentityFormTypes { + schemaId: string; + email: string; +} + +interface Props { + formik: FormikProps; +} + +const IdentityForm: FC = ({ formik }) => { + const { data } = useQuery({ + queryKey: [queryKeys.schemas], + queryFn: () => fetchSchemas(""), + }); + + const schemaOptions = + data?.data.map((schema) => ({ + label: schema.id, + value: schema.id, + })) ?? []; + + return ( +
+ +