diff --git a/static/js/brand-store/atoms/index.ts b/static/js/brand-store/atoms/index.ts index 378ea4836f..0dd8d35b22 100644 --- a/static/js/brand-store/atoms/index.ts +++ b/static/js/brand-store/atoms/index.ts @@ -1,5 +1,5 @@ import { atom } from "recoil"; -import type { Store, Model, Policy } from "../types/shared"; +import type { Store, Model, Policy, SigningKey } from "../types/shared"; const brandStoresState = atom({ key: "brandStores", @@ -24,15 +24,27 @@ const newModelState = atom({ }, }); -const policiesState = atom({ - key: "policies", +const policiesListState = atom({ + key: "policiesList", default: [] as Array, }); +const policiesListFilterState = atom({ + key: "policiesListFilter", + default: "" as string, +}); + +const signingKeysListState = atom({ + key: "signingKeysList", + default: [] as Array, +}); + export { brandStoresState, modelsListState, modelsListFilterState, newModelState, - policiesState, + policiesListState, + policiesListFilterState, + signingKeysListState, }; diff --git a/static/js/brand-store/components/Model/CreatePolicyForm.tsx b/static/js/brand-store/components/Model/CreatePolicyForm.tsx new file mode 100644 index 0000000000..889b94c321 --- /dev/null +++ b/static/js/brand-store/components/Model/CreatePolicyForm.tsx @@ -0,0 +1,172 @@ +import React, { useState } from "react"; +import { useRecoilState } from "recoil"; +import { useParams, Link, useNavigate } from "react-router-dom"; +import { useQuery, useMutation } from "react-query"; +import { Button } from "@canonical/react-components"; + +import { signingKeysListState } from "../../atoms"; + +import type { Query } from "../../types/shared"; + +type Props = { + setShowNotification: Function; + setShowErrorNotification: Function; + refetchPolicies: Function; +}; + +function CreatePolicyForm({ + setShowNotification, + setShowErrorNotification, + refetchPolicies, +}: Props) { + const getSigningKeys = async () => { + const response = await fetch(`/admin/store/${id}/signing-keys`); + + if (!response.ok) { + throw new Error("There was a problem fetching signing keys"); + } + + const signingKeysData = await response.json(); + + if (!signingKeysData.success) { + throw new Error(signingKeysData.message); + } + + setSigningKeys(signingKeysData.data); + }; + + const { id, model_id } = useParams(); + const navigate = useNavigate(); + const { isLoading, isError, error }: Query = useQuery( + "signingKeys", + getSigningKeys + ); + const [signingKeys, setSigningKeys] = useRecoilState(signingKeysListState); + const [selectedSigningKey, setSelectedSigningKey] = useState(""); + + const handleError = () => { + setShowErrorNotification(true); + navigate(`/admin/${id}/models/${model_id}/policies`); + setSelectedSigningKey(""); + setTimeout(() => { + setShowErrorNotification(false); + }, 5000); + }; + + const mutation = useMutation({ + mutationFn: (policySigningKey: string) => { + const formData = new FormData(); + + formData.set("csrf_token", window.CSRF_TOKEN); + formData.set("signing_key", policySigningKey); + + navigate(`/admin/${id}/models/${model_id}/policies`); + + return fetch(`/admin/store/${id}/models/${model_id}/policies`, { + method: "POST", + body: formData, + }); + }, + onSuccess: async (response) => { + if (!response.ok) { + handleError(); + throw new Error(`${response.status} ${response.statusText}`); + } + + const policiesData = await response.json(); + + if (!policiesData.success) { + throw new Error(policiesData.message); + } + + setShowNotification(true); + setSelectedSigningKey(""); + refetchPolicies(); + navigate(`/admin/${id}/models/${model_id}/policies`); + setTimeout(() => { + setShowNotification(false); + }, 5000); + }, + onError: () => { + handleError(); + throw new Error("Unable to create a new policy"); + }, + }); + + return ( +
{ + event.preventDefault(); + mutation.mutate(selectedSigningKey); + }} + style={{ height: "100%" }} + > +
+
+

Create new policy

+
+
+
+ {isLoading &&

Fetching signing keys...

} + {isError && error &&

Error: {error.message}

} + + + + + {signingKeys.length < 1 && ( +

+ No signing keys available, please{" "} + + create one + {" "} + first. +

+ )} +
+
+
+
+
+ + +
+
+
+
+ ); +} + +export default CreatePolicyForm; diff --git a/static/js/brand-store/components/Model/Policies.test.tsx b/static/js/brand-store/components/Model/Policies.test.tsx index 7cf1bdc889..6b7dc8ddee 100644 --- a/static/js/brand-store/components/Model/Policies.test.tsx +++ b/static/js/brand-store/components/Model/Policies.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { BrowserRouter } from "react-router-dom"; import { RecoilRoot } from "recoil"; +import { QueryClient, QueryClientProvider } from "react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -17,11 +18,22 @@ jest.mock("react-router-dom", () => { }; }); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + }, +}); + function renderComponent() { return render( - + + + ); diff --git a/static/js/brand-store/components/Model/Policies.tsx b/static/js/brand-store/components/Model/Policies.tsx index 6f1d9794ef..a9661b1a02 100644 --- a/static/js/brand-store/components/Model/Policies.tsx +++ b/static/js/brand-store/components/Model/Policies.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Link, useParams, @@ -6,33 +6,56 @@ import { useLocation, useNavigate, } from "react-router-dom"; -import { format } from "date-fns"; -import { - MainTable, - Row, - Col, - Button, - Input, - Icon, -} from "@canonical/react-components"; +import { useSetRecoilState } from "recoil"; +import { useQuery } from "react-query"; +import { Row, Col, Notification } from "@canonical/react-components"; import ModelNav from "./ModelNav"; +import PoliciesFilter from "./PoliciesFilter"; +import PoliciesTable from "./PoliciesTable"; +import CreatePolicyForm from "./CreatePolicyForm"; -import { getFilteredPolicies, isClosedPanel } from "../../utils"; +import { policiesListFilterState, policiesListState } from "../../atoms"; -import type { Policy } from "../../types/shared"; +import { isClosedPanel } from "../../utils"; -// This is temporary until the API is connected -import policiesData from "./policies-data"; +import type { Policy, Query } from "../../types/shared"; function Policies() { + const getPolicies = async () => { + setPoliciesList([]); + + const response = await fetch( + `/admin/store/${id}/models/${model_id}/policies` + ); + + if (!response.ok) { + throw new Error("There was a problem fetching policies"); + } + + const policiesData = await response.json(); + + if (!policiesData.success) { + throw new Error(policiesData.message); + } + + setPoliciesList(policiesData.data); + setFilter(searchParams.get("filter") || ""); + }; + const { id, model_id } = useParams(); const location = useLocation(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const policies = getFilteredPolicies( - policiesData.data, - searchParams.get("filter") + const { isLoading, isError, error, refetch }: Query = useQuery( + "policies", + getPolicies + ); + const setPoliciesList = useSetRecoilState>(policiesListState); + const setFilter = useSetRecoilState(policiesListFilterState); + const [searchParams] = useSearchParams(); + const [showNotification, setShowNotification] = useState(false); + const [showErrorNotification, setShowErrorNotification] = useState( + false ); return ( @@ -49,6 +72,30 @@ function Policies() {
+ {showNotification && ( +
+ { + setShowNotification(false); + }} + > + New policy created + +
+ )} + {showErrorNotification && ( +
+ { + setShowErrorNotification(false); + }} + > + Unable to create policy + +
+ )} -
- { - if (e.target.value) { - setSearchParams({ filter: e.target.value }); - } else { - setSearchParams(); - } - }} - /> - - -
+
- {policies.length > 0 && ( - { - return { - columns: [ - { - content: policy.revision, - }, - { - content: policy["signing-key-sha3-384"], - }, - { - content: format( - new Date(policy["created-at"]), - "dd/MM/yyyy" - ), - }, - ], - sortData: { - revision: policy.revision, - "created-at": policy["created-at"], - }, - }; - })} - /> - )} + {isLoading &&

Fetching policies...

} + {isError && error &&

Error: {error.message}

} +
@@ -153,49 +130,11 @@ function Policies() { isClosedPanel(location.pathname, "create") ? "is-collapsed" : "" }`} > -
-
-

Create new policy

-
-
-
-
- - -

- No signing keys available, please{" "} - - create one - {" "} - first. -

-
-
-
-
-
-
- - -
-
-
+ ); diff --git a/static/js/brand-store/components/Model/PoliciesFilter.tsx b/static/js/brand-store/components/Model/PoliciesFilter.tsx new file mode 100644 index 0000000000..151e0b1c1d --- /dev/null +++ b/static/js/brand-store/components/Model/PoliciesFilter.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useSearchParams } from "react-router-dom"; +import { useSetRecoilState } from "recoil"; +import { Button, Icon } from "@canonical/react-components"; + +import { policiesListFilterState } from "../../atoms"; + +function PoliciesFilter() { + const [searchParams, setSearchParams] = useSearchParams(); + const setFilter = useSetRecoilState(policiesListFilterState); + + return ( +
+ + { + if (e.target.value) { + setSearchParams({ filter: e.target.value }); + setFilter(e.target.value); + } else { + setSearchParams(); + setFilter(""); + } + }} + /> + + +
+ ); +} + +export default PoliciesFilter; diff --git a/static/js/brand-store/components/Model/PoliciesTable.tsx b/static/js/brand-store/components/Model/PoliciesTable.tsx new file mode 100644 index 0000000000..945939caa5 --- /dev/null +++ b/static/js/brand-store/components/Model/PoliciesTable.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { useRecoilValue } from "recoil"; +import { useParams, Link } from "react-router-dom"; +import { format } from "date-fns"; +import { MainTable } from "@canonical/react-components"; + +import { signingKeysListState } from "../../atoms"; +import { filteredPoliciesListState } from "../../selectors"; + +import type { Policy, SigningKey } from "../../types/shared"; + +function ModelsTable() { + const { id } = useParams(); + const policiesList = useRecoilValue>(filteredPoliciesListState); + const signingKeys = useRecoilValue>(signingKeysListState); + + return ( + { + return { + columns: [ + { + content: policy.revision, + }, + { + content: ( + + {policy["signing-key-name"]} + + ), + className: "u-align--right", + }, + { + content: format(new Date(policy["created-at"]), "dd/MM/yyyy"), + className: "u-align--right", + }, + { + content: policy["modified-at"] + ? format(new Date(policy["modified-at"]), "dd/MM/yyyy") + : "-", + className: "u-align--right", + }, + ], + sortData: { + revision: policy.revision, + name: policy["signing-key-name"], + "created-at": policy["created-at"], + "modified-at": policy["modified-at"], + }, + }; + })} + /> + ); +} + +export default ModelsTable; diff --git a/static/js/brand-store/components/Model/policies-data.js b/static/js/brand-store/components/Model/policies-data.js deleted file mode 100644 index 97e0dd88a8..0000000000 --- a/static/js/brand-store/components/Model/policies-data.js +++ /dev/null @@ -1,32 +0,0 @@ -const policiesData = { - success: true, - message: "The request has been successful", - data: [ - { - "created-at": "2023-07-05T15:11:21.922Z", - "created-by": "publisherId", - "model-name": "model-1", - revision: 1.7, - "signing-key-sha3-384": - "95d3bf18447370259bab86ccc650ed3fe9ffb3ec2f8d55b4beafc09efc38405ccecb9e3c0de86ae6f89466493fbf8bbe", - }, - { - "created-at": "2023-07-05T15:12:00.978Z", - "created-by": "publisherId", - "model-name": "model-2", - revision: 5.3, - "signing-key-sha3-384": - "5d81aace4895e5d9d8e4acec88c5cfbde4dade62cecfe6aa26167ad8127aaa5b866ae4d1786f6be1667e87be3b7abbe6", - }, - { - "created-at": "2023-07-05T15:12:47.701Z", - "created-by": "publisherId", - "model-name": "model-3", - revision: 43.2, - "signing-key-sha3-384": - "7a46d3f9afa14ce259726b5abf7c154b9ee20df97b80403792c9076551ae618bdfdf63dd2a57e81b4dd0c1a1b68940b6", - }, - ], -}; - -export default policiesData; diff --git a/static/js/brand-store/components/Models/CreateModelForm.tsx b/static/js/brand-store/components/Models/CreateModelForm.tsx index 41fb4ae874..eaf7016183 100644 --- a/static/js/brand-store/components/Models/CreateModelForm.tsx +++ b/static/js/brand-store/components/Models/CreateModelForm.tsx @@ -68,20 +68,24 @@ function CreateModelForm({ body: formData, }); }, - onSuccess: (response) => { + onSuccess: async (response) => { if (!response.ok) { handleError(); throw new Error(`${response.status} ${response.statusText}`); } - if (response.ok) { - setShowNotification(true); - setNewModel({ name: "", apiKey: "" }); - navigate(`/admin/${id}/models`); - setTimeout(() => { - setShowNotification(false); - }, 5000); + const modelsData = await response.json(); + + if (!modelsData.success) { + throw new Error(modelsData.message); } + + setShowNotification(true); + setNewModel({ name: "", apiKey: "" }); + navigate(`/admin/${id}/models`); + setTimeout(() => { + setShowNotification(false); + }, 5000); }, onError: () => { handleError(); diff --git a/static/js/brand-store/components/Models/Models.tsx b/static/js/brand-store/components/Models/Models.tsx index 20f756c5f6..72e472db69 100644 --- a/static/js/brand-store/components/Models/Models.tsx +++ b/static/js/brand-store/components/Models/Models.tsx @@ -13,7 +13,7 @@ import { Row, Col, Notification } from "@canonical/react-components"; import { modelsListFilterState, modelsListState, - policiesState, + policiesListState, } from "../../atoms"; import SectionNav from "../SectionNav"; @@ -62,7 +62,7 @@ function Models() { throw new Error(policyData.message); } - setPolicies([...policies, policyData.data]); + setPolicies([...policies, ...policyData.data]); }; const { id } = useParams(); @@ -70,7 +70,9 @@ function Models() { const navigate = useNavigate(); const { isLoading, isError, error }: Query = useQuery("models", getModels); const setModelsList = useSetRecoilState>(modelsListState); - const [policies, setPolicies] = useRecoilState>(policiesState); + const [policies, setPolicies] = useRecoilState>( + policiesListState + ); const setFilter = useSetRecoilState(modelsListFilterState); const [searchParams] = useSearchParams(); const [showNotification, setShowNotification] = useState(false); diff --git a/static/js/brand-store/selectors/index.ts b/static/js/brand-store/selectors/index.ts index e8ad81c13b..4e49108582 100644 --- a/static/js/brand-store/selectors/index.ts +++ b/static/js/brand-store/selectors/index.ts @@ -3,10 +3,12 @@ import { selector, selectorFamily } from "recoil"; import { modelsListFilterState, modelsListState, - policiesState, + policiesListState, + policiesListFilterState, + signingKeysListState, } from "../atoms"; -import { getFilteredModels } from "../utils"; +import { getFilteredModels, getFilteredPolicies } from "../utils"; import type { StoresSlice, @@ -15,6 +17,7 @@ import type { InvitesSlice, MembersSlice, Model, + Policy, } from "../types/shared"; // Redux selectors @@ -32,7 +35,7 @@ const filteredModelsListState = selector>({ get: ({ get }) => { const filter = get(modelsListFilterState); const models = get(modelsListState); - const policies = get(policiesState); + const policies = get(policiesListState); const modelsWithPolicies = models.map((model) => { const policy = policies.find( (policy) => policy["model-name"] === model.name @@ -56,6 +59,27 @@ const currentModelState = selectorFamily({ }, }); +const filteredPoliciesListState = selector>({ + key: "filteredPoliciesList", + get: ({ get }) => { + const filter = get(policiesListFilterState); + const policies = get(policiesListState); + const signingKeys = get(signingKeysListState); + const policiesWithKeys = policies.map((policy) => { + const signingKey = signingKeys.find( + (key) => key["sha3-384"] === policy["signing-key-sha3-384"] + ); + + return { + ...policy, + "signing-key-name": signingKey?.name, + "modified-at": signingKey?.["modified-at"], + }; + }); + return getFilteredPolicies(policiesWithKeys, filter); + }, +}); + export { brandStoresListSelector, currentStoreSelector, @@ -64,4 +88,5 @@ export { invitesSelector, filteredModelsListState, currentModelState, + filteredPoliciesListState, }; diff --git a/static/js/brand-store/types/shared.ts b/static/js/brand-store/types/shared.ts index 29487b78de..69ad20606c 100644 --- a/static/js/brand-store/types/shared.ts +++ b/static/js/brand-store/types/shared.ts @@ -124,12 +124,25 @@ export type Policy = { "model-name": string; revision: number; "signing-key-sha3-384": string; + "signing-key-name"?: string; + "modified-at"?: string; }; export type Query = { isLoading?: boolean; isError?: boolean; + refetch?: Function; error?: { message: string; } | null; }; + +export type SigningKey = { + name: string; + "created-at": string; + "created-by": string; + "modified-at": string; + "modified-by": string; + fingerprint: string; + "sha3-384": string; +}; diff --git a/static/js/brand-store/utils/getFilteredPolicies.ts b/static/js/brand-store/utils/getFilteredPolicies.ts index b3a0605c39..816cbdd5cd 100644 --- a/static/js/brand-store/utils/getFilteredPolicies.ts +++ b/static/js/brand-store/utils/getFilteredPolicies.ts @@ -10,7 +10,10 @@ function getFilteredPolicies( return policies.filter((policy: Policy) => { if ( + (policy["signing-key-name"] && + policy["signing-key-name"].includes(filterQuery)) || policy["created-at"].includes(filterQuery) || + (policy["modified-at"] && policy["modified-at"].includes(filterQuery)) || policy.revision.toString().includes(filterQuery) ) { return true;