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 (
+
+ );
+};
+
+export default IdentityForm;
diff --git a/ui/src/pages/identities/IdentityList.tsx b/ui/src/pages/identities/IdentityList.tsx
index 5ee0a6550..adbb00ac4 100644
--- a/ui/src/pages/identities/IdentityList.tsx
+++ b/ui/src/pages/identities/IdentityList.tsx
@@ -1,15 +1,23 @@
import React, { FC } from "react";
-import { Col, MainTable, Row } from "@canonical/react-components";
+import { Button, Col, MainTable, Row } from "@canonical/react-components";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { NotificationConsumer } from "@canonical/react-components/dist/components/NotificationProvider/NotificationProvider";
import { fetchIdentities } from "api/identities";
import { isoTimeToString } from "util/date";
+import Loader from "components/Loader";
+import usePanelParams from "util/usePanelParams";
+import { usePagination } from "util/usePagination";
+import Pagination from "components/Pagination";
+import DeleteIdentityBtn from "pages/identities/DeleteIdentityBtn";
const IdentityList: FC = () => {
- const { data: identities = [] } = useQuery({
- queryKey: [queryKeys.identities],
- queryFn: fetchIdentities,
+ const panelParams = usePanelParams();
+ const { page } = usePagination();
+
+ const { data: response, isLoading } = useQuery({
+ queryKey: [queryKeys.identities, page],
+ queryFn: () => fetchIdentities(page),
});
return (
@@ -18,6 +26,14 @@ const IdentityList: FC = () => {
Identities
+
+
+
@@ -25,15 +41,14 @@ const IdentityList: FC = () => {
{
+ rows={response?.data.map((identity) => {
return {
columns: [
{
@@ -53,15 +68,27 @@ const IdentityList: FC = () => {
role: "rowheader",
"aria-label": "Created at",
},
+ {
+ content: (
+ <>
+
+ >
+ ),
+ role: "rowheader",
+ "aria-label": "Actions",
+ },
],
- sortData: {
- id: identity.id,
- schema: identity.schema_id,
- createdAt: identity.created_at,
- },
};
})}
+ emptyStateMsg={
+ isLoading ? (
+
+ ) : (
+ "No data to display"
+ )
+ }
/>
+
diff --git a/ui/src/pages/providers/ProviderList.tsx b/ui/src/pages/providers/ProviderList.tsx
index 50f9a8f2f..24bd92f69 100644
--- a/ui/src/pages/providers/ProviderList.tsx
+++ b/ui/src/pages/providers/ProviderList.tsx
@@ -41,7 +41,6 @@ const ProviderList: FC = () => {
className="u-table-layout--auto"
sortable
responsive
- paginate={30}
headers={[
{ content: "Name", sortKey: "id" },
{ content: "Provider", sortKey: "provider" },
diff --git a/ui/src/pages/schemas/SchemaList.tsx b/ui/src/pages/schemas/SchemaList.tsx
index 0aac97870..9ed92fcc8 100644
--- a/ui/src/pages/schemas/SchemaList.tsx
+++ b/ui/src/pages/schemas/SchemaList.tsx
@@ -4,11 +4,16 @@ import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { NotificationConsumer } from "@canonical/react-components/dist/components/NotificationProvider/NotificationProvider";
import { fetchSchemas } from "api/schema";
+import Loader from "components/Loader";
+import Pagination from "components/Pagination";
+import { usePagination } from "util/usePagination";
const SchemaList: FC = () => {
- const { data: schemas = [] } = useQuery({
- queryKey: [queryKeys.schemas],
- queryFn: fetchSchemas,
+ const { page } = usePagination();
+
+ const { data: response, isLoading } = useQuery({
+ queryKey: [queryKeys.schemas, page],
+ queryFn: () => fetchSchemas(page),
});
return (
@@ -24,14 +29,9 @@ const SchemaList: FC = () => {
{
+ headers={[{ content: "Id" }, { content: "Schema" }]}
+ rows={response?.data.map((schema) => {
return {
columns: [
{
@@ -45,12 +45,17 @@ const SchemaList: FC = () => {
"aria-label": "Name",
},
],
- sortData: {
- id: schema.id,
- },
};
})}
+ emptyStateMsg={
+ isLoading ? (
+
+ ) : (
+ "No data to display"
+ )
+ }
/>
+
diff --git a/ui/src/types/api.d.ts b/ui/src/types/api.d.ts
index db46b4cae..696ab59d3 100644
--- a/ui/src/types/api.d.ts
+++ b/ui/src/types/api.d.ts
@@ -4,6 +4,15 @@ export interface ApiResponse {
status: number;
}
+export type PaginatedResponse = {
+ _meta: {
+ first?: string;
+ last?: string;
+ next?: string;
+ prev?: string;
+ };
+} & ApiResponse;
+
export interface ErrorResponse {
error?: string;
message?: string;
diff --git a/ui/src/util/api.tsx b/ui/src/util/api.tsx
index 9a6056bcf..cc899b8f8 100644
--- a/ui/src/util/api.tsx
+++ b/ui/src/util/api.tsx
@@ -1,5 +1,7 @@
import { ErrorResponse } from "types/api";
+export const PAGE_SIZE = 50;
+
export const handleResponse = async (response: Response) => {
if (!response.ok) {
const result = (await response.json()) as ErrorResponse;
diff --git a/ui/src/util/usePagination.tsx b/ui/src/util/usePagination.tsx
new file mode 100644
index 000000000..dd8f85401
--- /dev/null
+++ b/ui/src/util/usePagination.tsx
@@ -0,0 +1,11 @@
+import { useSearchParams } from "react-router-dom";
+
+export const usePagination = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const page = searchParams.get("page") ?? "";
+ const setPage = (newPage: string) => {
+ setSearchParams({ ...searchParams, page: newPage });
+ };
+
+ return { page, setPage };
+};
diff --git a/ui/src/util/usePanelParams.tsx b/ui/src/util/usePanelParams.tsx
index 43893f52e..01561acbd 100644
--- a/ui/src/util/usePanelParams.tsx
+++ b/ui/src/util/usePanelParams.tsx
@@ -9,6 +9,7 @@ export interface PanelHelper {
openProviderEdit: (id: string) => void;
openClientCreate: () => void;
openClientEdit: (id: string) => void;
+ openIdentityCreate: () => void;
updatePanelParams: (key: string, value: string) => void;
}
@@ -17,6 +18,7 @@ export const panels = {
providerEdit: "provider-edit",
clientCreate: "client-create",
clientEdit: "client-edit",
+ identityCreate: "identity-create",
};
type ParamMap = Record;
@@ -69,6 +71,10 @@ const usePanelParams = (): PanelHelper => {
setPanelParams(panels.clientEdit, { id });
},
+ openIdentityCreate: () => {
+ setPanelParams(panels.identityCreate);
+ },
+
updatePanelParams: (key: string, value: string) => {
const newParams = new URLSearchParams(params);
newParams.set(key, value);