Skip to content

Commit

Permalink
feat: add pagination to clients, schemas and identity lists in ui. Ad…
Browse files Browse the repository at this point in the history
…d identity creation form WD-10253

Signed-off-by: David Edler <[email protected]>
  • Loading branch information
edlerd committed Apr 17, 2024
1 parent 6cc4e95 commit e14b2aa
Show file tree
Hide file tree
Showing 19 changed files with 414 additions and 58 deletions.
2 changes: 1 addition & 1 deletion ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
browse to http://localhost:8411/ to reach iam-admin-ui.
16 changes: 10 additions & 6 deletions ui/src/api/client.tsx
Original file line number Diff line number Diff line change
@@ -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<Client[]> => {
export const fetchClients = (
page: string,
): Promise<PaginatedResponse<Client[]>> => {
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<Client[]>) => resolve(result.data))
.then((result: PaginatedResponse<Client[]>) => resolve(result))
.catch(reject);
});
};
Expand Down Expand Up @@ -34,7 +38,7 @@ export const createClient = (values: string): Promise<Client> => {

export const updateClient = (
clientId: string,
values: string
values: string,
): Promise<Client> => {
return new Promise((resolve, reject) => {
fetch(`${import.meta.env.VITE_API_URL}/clients/${clientId}`, {
Expand Down
12 changes: 7 additions & 5 deletions ui/src/api/identities.tsx
Original file line number Diff line number Diff line change
@@ -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<Identity[]> => {
export const fetchIdentities = (
page: string,
): Promise<PaginatedResponse<Identity[]>> => {
return new Promise((resolve, reject) => {
fetch("/api/v0/identities")
fetch(`/api/v0/identities?page_token=${page}&size=${PAGE_SIZE}`)
.then(handleResponse)
.then((result: ApiResponse<Identity[]>) => resolve(result.data))
.then((result: PaginatedResponse<Identity[]>) => resolve(result))
.catch(reject);
});
};
Expand Down
12 changes: 7 additions & 5 deletions ui/src/api/schema.tsx
Original file line number Diff line number Diff line change
@@ -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<Schema[]> => {
export const fetchSchemas = (
page: string,
): Promise<PaginatedResponse<Schema[]>> => {
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<Schema[]>) => resolve(result.data))
.then((result: PaginatedResponse<Schema[]>) => resolve(result))
.catch(reject);
});
};
Expand Down
7 changes: 7 additions & 0 deletions ui/src/components/IconLeft.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React, { FC } from "react";

const IconLeft: FC = () => {
return <i className="p-icon--chevron-down" style={{ rotate: "90deg" }} />;
};

export default IconLeft;
7 changes: 7 additions & 0 deletions ui/src/components/IconRight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React, { FC } from "react";

const IconRight: FC = () => {
return <i className="p-icon--chevron-down" style={{ rotate: "270deg" }} />;
};

export default IconRight;
55 changes: 55 additions & 0 deletions ui/src/components/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown[]>;
}

const Pagination: FC<Props> = ({ 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 && (
<Button onClick={() => setPage(first)} title="First page">
<IconLeft />
<IconLeft />
</Button>
)}
{prev && (
<Button onClick={() => setPage(prev)} title="Previous page">
<IconLeft />
</Button>
)}
{next && (
<Button onClick={() => setPage(next)} title="Next page">
<IconRight />
</Button>
)}
{last && (
<Button onClick={() => setPage(last)} title="Last page">
<IconRight />
<IconRight />
</Button>
)}
</>
);
};

export default Pagination;
3 changes: 3 additions & 0 deletions ui/src/components/Panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,6 +18,8 @@ const Panels = () => {
return <ClientCreate />;
case panels.clientEdit:
return <ClientEdit />;
case panels.identityCreate:
return <IdentityCreate />;
default:
return null;
}
Expand Down
30 changes: 18 additions & 12 deletions ui/src/pages/clients/ClientList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -35,16 +39,14 @@ const ClientList: FC = () => {
<NotificationConsumer />
<MainTable
className="u-table-layout--auto"
sortable
responsive
paginate={30}
headers={[
{ content: "Id", sortKey: "id" },
{ content: "Name", sortKey: "name" },
{ content: "Id" },
{ content: "Name" },
{ content: "Date" },
{ content: "Actions" },
]}
rows={clients.map((client) => {
rows={response?.data.map((client) => {
return {
columns: [
{
Expand Down Expand Up @@ -82,13 +84,17 @@ const ClientList: FC = () => {
"aria-label": "Actions",
},
],
sortData: {
id: client.client_id,
name: client.client_name.toLowerCase(),
},
};
})}
emptyStateMsg={
isLoading ? (
<Loader text="Loading clients..." />
) : (
"No data to display"
)
}
/>
<Pagination response={response} />
</Col>
</Row>
</div>
Expand Down
62 changes: 62 additions & 0 deletions ui/src/pages/identities/DeleteIdentityBtn.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<ConfirmationButton
className="u-no-margin--bottom"
loading={isLoading}
confirmationModalProps={{
title: "Confirm delete",
children: (
<p>
This will permanently delete identity <b>{identity.traits?.email}</b>.
</p>
),
confirmButtonLabel: "Delete identity",
onConfirm: handleDelete,
}}
title="Confirm delete"
>
Delete
</ConfirmationButton>
);
};

export default DeleteIdentityBtn;
95 changes: 95 additions & 0 deletions ui/src/pages/identities/IdentityCreate.tsx
Original file line number Diff line number Diff line change
@@ -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<IdentityFormTypes>({
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 (
<SidePanel hasError={false} loading={false} className="p-panel">
<ScrollableContainer dependencies={[]} belowId="panel-footer">
<SidePanel.Header>
<SidePanel.HeaderTitle>Add identity</SidePanel.HeaderTitle>
</SidePanel.Header>
<SidePanel.Content>
<Row>
<IdentityForm formik={formik} />
</Row>
</SidePanel.Content>
</ScrollableContainer>
<div id="panel-footer">
<SidePanel.Footer>
<Row className="u-align-text--right">
<Col size={12}>
<Button appearance="base" onClick={() => navigate("/identity")}>
Cancel
</Button>
<ActionButton
appearance="positive"
loading={formik.isSubmitting}
disabled={!formik.isValid}
onClick={submitForm}
>
Save
</ActionButton>
</Col>
</Row>
</SidePanel.Footer>
</div>
</SidePanel>
);
};

export default IdentityCreate;
Loading

0 comments on commit e14b2aa

Please sign in to comment.