Skip to content
Open
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
21 changes: 5 additions & 16 deletions docs/DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,11 @@ This page contains links to the information for plugin developers.

See the [Developer Documentation](https://www.jenkins.io/doc/developer/).

### Manual Testing of the UI
There are no tests available for the UI part. Mainly the javascript code and the interaction between html elements in the jelly files needs manual testing.
After starting Jenkins locally via `mvn hpi:run` go to the `Manage and Assigne Roles` page.
Verify that following things work on `Manage Roles`:
1. Adding a global, item and agent role
2. Deleting a role by pressing on the red x deletes the role
3. Clicking on the pencil next to a pattern enables edit mode of the pattern (validate for both items and agents)
4. Pressing return key when in the input field of the pattern terminates edit mode and pattern has the new value
5. Pressing escape when in the input field of the pattern terminates edit mode and pattern has the old value
6. Clicking on the pencil next to the pattern disables edit mode of the pattern when it is enabled and pattern has new value
7. Clicking on the pattern opens a dialog box showing the matching items or agents
8. Hovering over the checkboxes properly highlights the row and column and shows a tooltip
9. Tooltips are properly formatted
10. Entering html as rolename is printed as plain text in the field and in the tooltips.
11. After changing the pattern, tooltips are properly updated
12. After pressing Save/Apply and page reload new data is there
### Testing the UI
The React pages (`Manage Roles`, `Permission Templates`) are covered by Vitest component
tests (`npm run test`) and Playwright end-to-end tests (`ManageRolesUITest`,
`PermissionTemplatesUITest`). The remaining jelly/vanilla-JS pages need manual testing.
After starting Jenkins locally via `mvn hpi:run` go to the `Manage and Assign Roles` page.

Verify that following things work on `Assign Roles`:
1. Adding a new user to global, item and agent role
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@biomejs/biome": "^2.5.4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
Expand Down
13 changes: 13 additions & 0 deletions src/main/frontend/common/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ async function ensureOk(response: Response): Promise<Response> {
return response;
}

export async function getJson<T>(url: string, params: Params): Promise<T> {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
query.append(key, String(value));
}
const response = await fetch(`${url}?${query}`, {
headers: { Accept: "application/json" },
});
await ensureOk(response);
return (await response.json()) as T;
}

export async function postForm(url: string, params: Params): Promise<void> {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
Expand Down
50 changes: 49 additions & 1 deletion src/main/frontend/common/api/strategy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { postForm } from "./client.ts";
import type { RoleTypeKey } from "../types/role.ts";
import { getJson, postForm } from "./client.ts";

export interface MatchingJobs {
matchingJobs: string[];
itemCount: number;
}

export interface MatchingAgents {
matchingAgents: string[];
agentCount: number;
}

/**
* Client for the role-strategy REST endpoints exposed on
Expand All @@ -18,6 +29,25 @@ export interface StrategyClient {
overwrite: boolean,
): Promise<void>;
removeTemplates(templateNames: string[]): Promise<void>;
/**
* Create or update a role. Pass {@code overwrite: true} when editing an
* existing role; its assignments are kept. A bound {@code template} takes
* precedence over {@code permissionIds} (item roles only).
*/
addRole(
type: RoleTypeKey,
roleName: string,
permissionIds: string[],
overwrite: boolean,
pattern?: string,
template?: string,
): Promise<void>;
removeRoles(type: RoleTypeKey, roleNames: string[]): Promise<void>;
getMatchingJobs(pattern: string, maxJobs: number): Promise<MatchingJobs>;
getMatchingAgents(
pattern: string,
maxAgents: number,
): Promise<MatchingAgents>;
}

export function createStrategyClient(baseUrl: string): StrategyClient {
Expand All @@ -34,5 +64,23 @@ export function createStrategyClient(baseUrl: string): StrategyClient {
postForm(url("removeTemplates"), {
names: templateNames.join(","),
}),
addRole: (type, roleName, permissionIds, overwrite, pattern, template) =>
postForm(url("addRole"), {
type,
roleName,
permissionIds: permissionIds.join(","),
overwrite: String(overwrite),
pattern,
template,
}),
removeRoles: (type, roleNames) =>
postForm(url("removeRoles"), {
type,
roleNames: roleNames.join(","),
}),
getMatchingJobs: (pattern, maxJobs) =>
getJson(url("getMatchingJobs"), { pattern, maxJobs }),
getMatchingAgents: (pattern, maxAgents) =>
getJson(url("getMatchingAgents"), { pattern, maxAgents }),
};
}
39 changes: 39 additions & 0 deletions src/main/frontend/common/api/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiError } from "./client.ts";

export interface PatternValidation {
ok: boolean;
message?: string;
}

/**
* Validate a role pattern against the descriptor's {@code checkPattern}
* endpoint, which responds with a Jenkins FormValidation HTML fragment.
*/
export async function checkPattern(
checkPatternUrl: string,
value: string,
): Promise<PatternValidation> {
const body = new URLSearchParams({ value });
const headers = crumb.wrap({
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
});
const response = await fetch(checkPatternUrl, {
method: "POST",
headers,
body,
});
if (!response.ok) {
throw new ApiError(
`${response.status} ${response.statusText}`,
response.status,
await response.text(),
);
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, "text/html");
const error = doc.querySelector(".error");
if (error) {
return { ok: false, message: error.textContent?.trim() || undefined };
}
return { ok: true };
}
19 changes: 18 additions & 1 deletion src/main/frontend/common/components/AppBarButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import { useEffect } from "react";
/**
* Attach a click handler to a Jelly-rendered app-bar button identified by `id`.
* The button lives outside the React mount node but in the same document.
* `options.visible` toggles the button, e.g. when only some tabs of a page are
* editable; Jelly still gates whether the button is rendered at all.
*/
export function useAppBarButton(id: string, handler: () => void) {
export function useAppBarButton(
id: string,
handler: () => void,
options?: { visible?: boolean },
) {
const visible = options?.visible ?? true;

useEffect(() => {
const node = document.getElementById(id);
if (!node) return;
Expand All @@ -15,4 +23,13 @@ export function useAppBarButton(id: string, handler: () => void) {
node.addEventListener("click", onClick);
return () => node.removeEventListener("click", onClick);
}, [id, handler]);

useEffect(() => {
const node = document.getElementById(id);
if (!node) return;
node.hidden = !visible;
return () => {
node.hidden = false;
};
}, [id, visible]);
}
4 changes: 4 additions & 0 deletions src/main/frontend/common/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export function Card({

const toggle = () => setExpanded((v) => !v);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
// Only react to keys aimed at the header itself. Key events from focused
// buttons inside it (badges, actions) bubble up here, and preventDefault
// would swallow the native click those buttons synthesize on Enter/Space.
if (e.target !== e.currentTarget) return;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accessibility issue I noticed that the view matching jobs couldn't be triggered via keyboard

if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
Expand Down
2 changes: 1 addition & 1 deletion src/main/frontend/common/components/PermissionGroups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function PermissionGroups({
return (
<label
key={p.id}
className="rsp-perm__item"
className={`rsp-perm__item${isImplied ? " rsp-perm__item--implied" : ""}`}
data-permission-id={p.id}
>
<input
Expand Down
63 changes: 63 additions & 0 deletions src/main/frontend/common/components/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type KeyboardEvent, useRef } from "react";

export interface TabDef {
key: string;
label: string;
}

interface TabsProps {
tabs: TabDef[];
activeKey: string;
onSelect: (key: string) => void;
/** id of the tabpanel element every tab controls. */
panelId: string;
}

/**
* ARIA tablist with roving focus and arrow-key navigation. Jenkins core has no
* reusable tab markup outside jelly, so this brings its own `rsp-tabs` styling.
*/
export function Tabs({ tabs, activeKey, onSelect, panelId }: TabsProps) {
const listRef = useRef<HTMLDivElement | null>(null);

const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const index = tabs.findIndex((t) => t.key === activeKey);
const delta = e.key === "ArrowRight" ? 1 : -1;
const next = tabs[(index + delta + tabs.length) % tabs.length];
onSelect(next.key);
listRef.current
?.querySelector<HTMLElement>(`[data-tab-key="${next.key}"]`)
?.focus();
};

return (
<div
ref={listRef}
className="rsp-tabs"
role="tablist"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => {
const active = tab.key === activeKey;
return (
<button
key={tab.key}
type="button"
role="tab"
id={`rsp-tab-${tab.key}`}
data-tab-key={tab.key}
aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
className={`jenkins-button ${active ? "" : " jenkins-button--tertiary"}`}
onClick={() => onSelect(tab.key)}
>
{tab.label}
</button>
);
})}
</div>
);
}
Loading