Skip to content

Commit

Permalink
WD-8395 - Add secrets table (#1689)
Browse files Browse the repository at this point in the history
* Add secrets table.
  • Loading branch information
huwshimi authored Jan 29, 2024
1 parent da7ba74 commit 91b9686
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 56 deletions.
13 changes: 3 additions & 10 deletions src/components/AuditLogsTable/AuditLogsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { ModularTable, Tooltip } from "@canonical/react-components";
import { ModularTable } from "@canonical/react-components";
import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Column } from "react-table";

import LoadingSpinner from "components/LoadingSpinner/LoadingSpinner";
import RelativeDate from "components/RelativeDate";
import type { EntityDetailsRoute } from "components/Routes/Routes";
import { formatFriendlyDateToNow } from "components/utils";
import { useQueryParams } from "hooks/useQueryParams";
import { actions as jujuActions } from "store/juju";
import {
Expand Down Expand Up @@ -97,14 +97,7 @@ const AuditLogsTable = () => {
return [];
}
const tableData = auditLogs.map((auditLogsEntry) => {
const time = (
<Tooltip
message={new Date(auditLogsEntry.time).toLocaleString()}
position="top-center"
>
{formatFriendlyDateToNow(auditLogsEntry.time)}
</Tooltip>
);
const time = <RelativeDate datetime={auditLogsEntry.time} />;
const user = getUserName(auditLogsEntry["user-tag"]);
const facadeName = auditLogsEntry["facade-name"];
const facadeMethod = auditLogsEntry["facade-method"];
Expand Down
21 changes: 21 additions & 0 deletions src/components/RelativeDate/RelativeDate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import RelativeDate from "./RelativeDate";

describe("RelativeDate", () => {
const yesterday = new Date(Date.now() - 60 * 1000 * 60 * 24);

it("displays a relative date", async () => {
render(<RelativeDate datetime={yesterday.toISOString()} />);
expect(screen.getByText("1 day ago")).toBeInTheDocument();
});

it("displays the tooltip if the content is truncated", async () => {
render(<RelativeDate datetime={yesterday.toISOString()} />);
const fullDate = yesterday.toLocaleString();
expect(screen.queryByText(fullDate)).not.toBeInTheDocument();
await userEvent.hover(screen.getByText("1 day ago"));
expect(screen.getByText(fullDate)).toBeInTheDocument();
});
});
20 changes: 20 additions & 0 deletions src/components/RelativeDate/RelativeDate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Tooltip } from "@canonical/react-components";

import { formatFriendlyDateToNow } from "components/utils";

type Props = {
datetime: string;
};

const RelativeDate = ({ datetime }: Props) => {
return (
<Tooltip
message={new Date(datetime).toLocaleString()}
position="top-center"
>
{formatFriendlyDateToNow(datetime)}
</Tooltip>
);
};

export default RelativeDate;
1 change: 1 addition & 0 deletions src/components/RelativeDate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./RelativeDate";
11 changes: 3 additions & 8 deletions src/pages/EntityDetails/Model/Logs/ActionLogs/ActionLogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
Icon,
Modal,
ModularTable,
Tooltip,
} from "@canonical/react-components";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
Expand All @@ -21,9 +20,10 @@ import type { Column, Row } from "react-table";
import FadeIn from "animations/FadeIn";
import CharmIcon from "components/CharmIcon/CharmIcon";
import LoadingSpinner from "components/LoadingSpinner/LoadingSpinner";
import RelativeDate from "components/RelativeDate";
import type { EntityDetailsRoute } from "components/Routes/Routes";
import Status from "components/Status";
import { copyToClipboard, formatFriendlyDateToNow } from "components/utils";
import { copyToClipboard } from "components/utils";
import { queryActionsList, queryOperationsList } from "juju/api";
import { getModelStatus, getModelUUID } from "store/juju/selectors";
import type { ModelData } from "store/juju/types";
Expand Down Expand Up @@ -371,12 +371,7 @@ export default function ActionLogs() {
completedDate.getFullYear() === 1 ? (
"Unknown"
) : (
<Tooltip
message={completedDate.toLocaleString()}
position="top-center"
>
{formatFriendlyDateToNow(actionData.completed)}
</Tooltip>
<RelativeDate datetime={actionData.completed} />
),
sortData: {
application: name,
Expand Down
28 changes: 12 additions & 16 deletions src/pages/EntityDetails/Model/Secrets/Secrets.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { screen, waitFor } from "@testing-library/react";
import configureStore from "redux-mock-store";

import { TestId as LoadingTestId } from "components/LoadingSpinner/LoadingSpinner";
import { actions as jujuActions } from "store/juju";
import type { RootState } from "store/store";
import { rootStateFactory } from "testing/factories";
Expand All @@ -17,15 +16,20 @@ import {
modelSecretsFactory,
} from "testing/factories/juju/juju";
import { renderComponent } from "testing/utils";
import urls from "urls";

import Secrets, { TestId } from "./Secrets";
import Secrets from "./Secrets";
import { TestId as SecretsTableTestId } from "./SecretsTable/SecretsTable";

const mockStore = configureStore<RootState, unknown>([]);

describe("Secrets", () => {
let state: RootState;
const path = "/models/:userName/:modelName/app/:appName";
const url = "/models/eggman@external/test-model/app/easyrsa";
const path = urls.model.index(null);
const url = urls.model.index({
userName: "eggman@external",
modelName: "test-model",
});

beforeEach(() => {
state = rootStateFactory.build({
Expand Down Expand Up @@ -54,16 +58,6 @@ describe("Secrets", () => {
});
});

it("displays the loading state", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
loading: true,
}),
});
renderComponent(<Secrets />, { state, path, url });
expect(screen.queryByTestId(LoadingTestId.LOADING)).toBeInTheDocument();
});

it("displays errors", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
Expand All @@ -77,9 +71,11 @@ describe("Secrets", () => {
).toHaveTextContent("failed to load");
});

it("displays a list of secrets", async () => {
it("displays a table of secrets", async () => {
renderComponent(<Secrets />, { state, path, url });
expect(screen.getByTestId(TestId.SECRETS_TABLE)).toBeInTheDocument();
expect(
screen.getByTestId(SecretsTableTestId.SECRETS_TABLE),
).toBeInTheDocument();
});

it("cleans up secrets when unmounted", async () => {
Expand Down
26 changes: 4 additions & 22 deletions src/pages/EntityDetails/Model/Secrets/Secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,19 @@ import type { ReactNode } from "react";
import { useEffect } from "react";
import { useParams } from "react-router-dom";

import LoadingSpinner from "components/LoadingSpinner";
import type { EntityDetailsRoute } from "components/Routes/Routes";
import { useListSecrets } from "juju/apiHooks";
import { actions as jujuActions } from "store/juju";
import {
getSecretsLoaded,
getSecretsLoading,
getModelSecrets,
getModelByUUID,
getModelUUIDFromList,
getSecretsErrors,
} from "store/juju/selectors";
import { useAppDispatch, useAppSelector } from "store/store";

import SecretsTable from "./SecretsTable";

export enum TestId {
SECRETS_TABLE = "secrets-table",
SECRETS_TAB = "secrets-tab",
}

Expand All @@ -29,16 +26,9 @@ const Secrets = () => {
const wsControllerURL = useAppSelector((state) =>
getModelByUUID(state, modelUUID),
)?.wsControllerURL;
const secrets = useAppSelector((state) => getModelSecrets(state, modelUUID));
const secretsErrors = useAppSelector((state) =>
getSecretsErrors(state, modelUUID),
);
const secretsLoaded = useAppSelector((state) =>
getSecretsLoaded(state, modelUUID),
);
const secretsLoading = useAppSelector((state) =>
getSecretsLoading(state, modelUUID),
);

useListSecrets(userName, modelName);

Expand All @@ -53,22 +43,14 @@ const Secrets = () => {
);

let content: ReactNode;
if (secretsLoading || !secretsLoaded) {
content = <LoadingSpinner />;
} else if (secretsErrors) {
if (secretsErrors) {
content = (
<Notification severity="negative" title="Error">
{secretsErrors}
</Notification>
);
} else {
content = (
<ul data-testid={TestId.SECRETS_TABLE}>
{secrets?.map((secret) => (
<li key={secret.uri}>{secret.label || secret.uri}</li>
))}
</ul>
);
content = <SecretsTable />;
}

return <div data-testid={TestId.SECRETS_TAB}>{content}</div>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { screen } from "@testing-library/react";

import { TestId as LoadingTestId } from "components/LoadingSpinner/LoadingSpinner";
import type { RootState } from "store/store";
import { rootStateFactory } from "testing/factories";
import {
configFactory,
generalStateFactory,
credentialFactory,
} from "testing/factories/general";
import {
modelListInfoFactory,
secretsStateFactory,
listSecretResultFactory,
modelSecretsFactory,
} from "testing/factories/juju/juju";
import { renderComponent } from "testing/utils";
import urls from "urls";

import SecretsTable, { TestId } from "./SecretsTable";

describe("SecretsTable", () => {
let state: RootState;
const path = urls.model.index(null);
const url = urls.model.index({
userName: "eggman@external",
modelName: "test-model",
});

beforeEach(() => {
state = rootStateFactory.build({
general: generalStateFactory.build({
credentials: {
"wss://example.com/api": credentialFactory.build(),
},
config: configFactory.build({
controllerAPIEndpoint: "wss://example.com/api",
}),
}),
juju: {
models: {
abc123: modelListInfoFactory.build({
wsControllerURL: "wss://example.com/api",
uuid: "abc123",
}),
},
secrets: secretsStateFactory.build({
abc123: modelSecretsFactory.build({
items: [
listSecretResultFactory.build({ label: "secret1" }),
listSecretResultFactory.build({ label: "secret2" }),
],
loaded: true,
}),
}),
},
});
});

it("displays the loading state", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
loading: true,
}),
});
renderComponent(<SecretsTable />, { state, path, url });
expect(screen.queryByTestId(LoadingTestId.LOADING)).toBeInTheDocument();
});

it("handles no secrets", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
loaded: true,
}),
});
renderComponent(<SecretsTable />, { state, path, url });
expect(screen.queryByTestId(TestId.SECRETS_TABLE)).toBeInTheDocument();
});

it("should display secrets", async () => {
renderComponent(<SecretsTable />, { state, path, url });
expect(screen.getByRole("cell", { name: "secret1" })).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "secret2" })).toBeInTheDocument();
});

it("should remove the prefix from the id", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
items: [listSecretResultFactory.build({ uri: "secret:aabbccdd" })],
loaded: true,
}),
});
renderComponent(<SecretsTable />, { state, path, url });
expect(screen.getByRole("cell", { name: "aabbccdd" })).toBeInTheDocument();
});

it("displays 'Model' instead of UUID", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
items: [listSecretResultFactory.build({ "owner-tag": "model-abc123" })],
loaded: true,
}),
});
renderComponent(<SecretsTable />, { state, path, url });
expect(screen.getByRole("cell", { name: "Model" })).toBeInTheDocument();
});

it("does not change application owners", async () => {
state.juju.secrets = secretsStateFactory.build({
abc123: modelSecretsFactory.build({
items: [
listSecretResultFactory.build({ "owner-tag": "application-def456" }),
],
loaded: true,
}),
});
renderComponent(<SecretsTable />, { state, path, url });
expect(
screen.queryByRole("cell", { name: "Model" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("cell", { name: "application-def456" }),
).toBeInTheDocument();
});
});
Loading

0 comments on commit 91b9686

Please sign in to comment.