Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: access buttons link to groups when using rebac #1853

Merged
merged 1 commit into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/components/AccessButton/AccessButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import type { RootState } from "store/store";
import { configFactory, generalStateFactory } from "testing/factories/general";
import { rootStateFactory } from "testing/factories/root";
import { renderComponent } from "testing/utils";
import { rebacURLS } from "urls";

import AccessButton from "./AccessButton";

const iconClass = ".p-icon--share";

describe("AccessButton", () => {
let state: RootState;

beforeEach(() => {
state = rootStateFactory.build({
general: generalStateFactory.withConfig().build({
config: configFactory.build({
isJuju: true,
}),
}),
});
});

it("displays an access button with an icon", () => {
renderComponent(
<AccessButton displayIcon modelName="test-model">
Access
</AccessButton>,
{ state },
);
expect(document.querySelector(iconClass)).toBeInTheDocument();
});

it("displays an access button without an icon", () => {
renderComponent(
<AccessButton modelName="test-model">Access</AccessButton>,
{ state },
);
expect(document.querySelector(iconClass)).not.toBeInTheDocument();
});

it("can open the access panel with a model name", async () => {
const { router } = renderComponent(
<AccessButton modelName="test-model">Access</AccessButton>,
{ state },
);
await userEvent.click(screen.getByRole("button", { name: "Access" }));
expect(router?.state.location.search).toBe(
"?model=test-model&panel=share-model",
);
});

it("can open the access panel without a model name", async () => {
const { router } = renderComponent(<AccessButton>Access</AccessButton>, {
state,
});
await userEvent.click(screen.getByRole("button", { name: "Access" }));
expect(router?.state.location.search).toBe("?panel=share-model");
});

it("links to permissions when using JAAS", async () => {
state = rootStateFactory.build({
general: generalStateFactory.withConfig().build({
config: configFactory.build({
isJuju: false,
}),
}),
});
renderComponent(
<AccessButton modelName="test-model">Access</AccessButton>,
{ state },
);
expect(screen.getByRole("link")).toHaveAttribute(
"href",
rebacURLS.groups.index,
);
});
});
53 changes: 53 additions & 0 deletions src/components/AccessButton/AccessButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Button, Icon } from "@canonical/react-components";
import { Link } from "react-router";

import { useQueryParams } from "hooks/useQueryParams";
import { getIsJuju } from "store/general/selectors";
import { useAppSelector } from "store/store";
import { rebacURLS } from "urls";

import type { Props } from "./types";

const AccessButton = ({
children,
displayIcon,
modelName,
...props
}: Props) => {
const isJuju = useAppSelector(getIsJuju);
const [, setPanelQs] = useQueryParams<{
model: string | null;
panel: string | null;
}>({
model: null,
panel: null,
});
return (
<Button
{...props}
{...(isJuju
? {
onClick: (event) => {
event.stopPropagation();
setPanelQs(
{
model: modelName,
panel: "share-model",
},
{ replace: true },
);
},
}
: {
element: Link,
to: rebacURLS.groups.index,
})}
hasIcon={displayIcon}
>
{displayIcon ? <Icon name="share" /> : null}
<span>{children}</span>
</Button>
);
};

export default AccessButton;
1 change: 1 addition & 0 deletions src/components/AccessButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./AccessButton";
8 changes: 8 additions & 0 deletions src/components/AccessButton/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ButtonProps } from "@canonical/react-components";
import type { PropsWithChildren } from "react";

export type Props = {
displayIcon?: boolean;
modelName?: string;
} & PropsWithChildren &
Partial<ButtonProps>;
32 changes: 13 additions & 19 deletions src/components/ModelTableList/AccessButton/AccessButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { screen } from "@testing-library/react";

import { configFactory, generalStateFactory } from "testing/factories/general";
import { rootStateFactory } from "testing/factories/root";
import { renderComponent } from "testing/utils";

import AccessButton from "./AccessButton";
import { Label } from "./types";

describe("AccessButton", () => {
it("displays an access button", () => {
render(<AccessButton setPanelQs={vi.fn()} modelName="test-model" />);
const state = rootStateFactory.build({
general: generalStateFactory.withConfig().build({
config: configFactory.build({
isJuju: true,
}),
}),
});
renderComponent(<AccessButton modelName="test-model" />, { state });
expect(
screen.getByRole("button", { name: Label.ACCESS_BUTTON }),
).toBeInTheDocument();
});

it("can open the access panel", async () => {
const setPanelQs = vi.fn();
render(<AccessButton setPanelQs={setPanelQs} modelName="test-model" />);
await userEvent.click(
screen.getByRole("button", { name: Label.ACCESS_BUTTON }),
);
expect(setPanelQs).toHaveBeenCalledWith(
{
model: "test-model",
panel: "share-model",
},
{ replace: true },
);
});
});
21 changes: 4 additions & 17 deletions src/components/ModelTableList/AccessButton/AccessButton.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import type { SetParams } from "hooks/useQueryParams";
import BaseAccessButton from "components/AccessButton";

import { Label } from "./types";

type Props = {
modelName: string;
setPanelQs: SetParams<Record<string, unknown>>;
};

const AccessButton = ({ modelName, setPanelQs }: Props) => {
const AccessButton = (props: Props) => {
return (
<button
onClick={(event) => {
event.stopPropagation();
setPanelQs(
{
model: modelName,
panel: "share-model",
},
{ replace: true },
);
}}
className="model-access p-button--neutral is-dense"
>
<BaseAccessButton {...props} dense className="model-access">
{Label.ACCESS_BUTTON}
</button>
</BaseAccessButton>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe("CloudGroup", () => {
it("model access button is present in cloud group", () => {
state.general = generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down
11 changes: 1 addition & 10 deletions src/components/ModelTableList/CloudGroup/CloudGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useSelector } from "react-redux";
import ModelDetailsLink from "components/ModelDetailsLink";
import Status from "components/Status";
import TruncatedTooltip from "components/TruncatedTooltip";
import { useQueryParams } from "hooks/useQueryParams";
import {
getActiveUsers,
getControllerData,
Expand Down Expand Up @@ -43,11 +42,6 @@ export default function CloudGroup({ filters }: Props) {
getGroupedByCloudAndFilteredModelData(filters),
);

const [, setPanelQs] = useQueryParams({
model: null,
panel: null,
});

const cloudTables: ReactNode[] = [];
for (const cloud in groupedAndFilteredData) {
const cloudModels: MainTableRow[] = [];
Expand Down Expand Up @@ -127,10 +121,7 @@ export default function CloudGroup({ filters }: Props) {
<>
{model?.info
? canAdministerModel(activeUser, model.info.users) && (
<AccessButton
setPanelQs={setPanelQs}
modelName={model.info.name}
/>
<AccessButton modelName={model.info.name} />
)
: null}
<span className="model-access-alt">{lastUpdated}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("OwnerGroup", () => {
it("model access button is present in owners group", () => {
state.general = generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down
10 changes: 1 addition & 9 deletions src/components/ModelTableList/OwnerGroup/OwnerGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useSelector } from "react-redux";
import ModelDetailsLink from "components/ModelDetailsLink";
import Status from "components/Status";
import TruncatedTooltip from "components/TruncatedTooltip";
import { useQueryParams } from "hooks/useQueryParams";
import {
getActiveUsers,
getControllerData,
Expand Down Expand Up @@ -39,10 +38,6 @@ export default function OwnerGroup({ filters }: Props) {
const groupedAndFilteredData = useSelector(
getGroupedByOwnerAndFilteredModelData(filters),
);
const [, setPanelQs] = useQueryParams({
model: null,
panel: null,
});
const activeUsers = useSelector(getActiveUsers);
const controllers = useSelector(getControllerData);

Expand Down Expand Up @@ -117,10 +112,7 @@ export default function OwnerGroup({ filters }: Props) {
<>
{model.info
? canAdministerModel(activeUser, model.info.users) && (
<AccessButton
setPanelQs={setPanelQs}
modelName={model.info.name}
/>
<AccessButton modelName={model.info.name} />
)
: null}
<span className="model-access-alt">{lastUpdated}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("StatusGroup", () => {
it("model access button is present in status group", () => {
state.general = generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down
13 changes: 1 addition & 12 deletions src/components/ModelTableList/StatusGroup/StatusGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { useSelector } from "react-redux";

import ModelDetailsLink from "components/ModelDetailsLink";
import TruncatedTooltip from "components/TruncatedTooltip";
import type { SetParams } from "hooks/useQueryParams";
import { useQueryParams } from "hooks/useQueryParams";
import {
getActiveUsers,
getControllerData,
Expand Down Expand Up @@ -59,7 +57,6 @@ const generateModelNameCell = (model: ModelData, groupLabel: string) => {
*/
function generateModelTableDataByStatus(
groupedModels: Record<Status, ModelData[]>,
setPanelQs: SetParams<Record<string, unknown>>,
activeUsers: Record<string, string>,
controllers: Controllers | null,
) {
Expand Down Expand Up @@ -135,10 +132,7 @@ function generateModelTableDataByStatus(
content: (
<>
{canAdministerModel(activeUser, model?.info?.users) && (
<AccessButton
setPanelQs={setPanelQs}
modelName={model.model.name}
/>
<AccessButton modelName={model.model.name} />
)}
<span className="model-access-alt">{lastUpdated}</span>
</>
Expand Down Expand Up @@ -171,16 +165,11 @@ export default function StatusGroup({ filters }: { filters: Filters }) {
getGroupedByStatusAndFilteredModelData(filters),
);
const controllers = useSelector(getControllerData);
const [, setPanelQs] = useQueryParams({
model: null,
panel: null,
});
const activeUsers = useSelector(getActiveUsers);

const { blockedRows, alertRows, runningRows } =
generateModelTableDataByStatus(
groupedAndFilteredData,
setPanelQs,
activeUsers,
controllers,
);
Expand Down
5 changes: 1 addition & 4 deletions src/components/PrimaryNav/PrimaryNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
SideNavigationText,
} from "@canonical/react-components";
import type { NavItem } from "@canonical/react-components/dist/components/SideNavigation/SideNavigation";
import { urls as generateReBACURLS } from "@canonical/rebac-admin";
import type { HTMLProps, ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
Expand All @@ -32,12 +31,10 @@ import {
} from "store/juju/selectors";
import type { Controllers } from "store/juju/types";
import { useAppSelector } from "store/store";
import urls, { externalURLs } from "urls";
import urls, { externalURLs, rebacURLS } from "urls";

import { Label } from "./types";

const rebacURLS = generateReBACURLS(urls.permissions);

const useControllersLink = () => {
const controllers: Controllers | null = useSelector(getControllerData);
const authenticationRequired =
Expand Down
4 changes: 4 additions & 0 deletions src/pages/EntityDetails/Model/Model.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe("Model", () => {
state = rootStateFactory.build({
general: generalStateFactory.build({
config: configFactory.build({
isJuju: true,
controllerAPIEndpoint: "wss://jimm.jujucharms.com/api",
}),
controllerConnections: {
Expand Down Expand Up @@ -346,6 +347,9 @@ describe("Model", () => {
});

it("can display the audit logs table", async () => {
if (state.general.config) {
state.general.config.isJuju = false;
}
state.juju.modelWatcherData = {
abc123: modelWatcherModelDataFactory.build({
applications: {
Expand Down
Loading