Skip to content

Commit 2f844a7

Browse files
feat: add settings to rekor search ui (#68)
Signed-off-by: Carlos Feria <[email protected]>
1 parent 1191f4d commit 2f844a7

File tree

5 files changed

+248
-11
lines changed

5 files changed

+248
-11
lines changed
Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
1-
import React, { Fragment } from "react";
1+
import React, { useState } from "react";
22

33
import { RekorClientProvider } from "@app/api/context";
4-
import { Content, PageSection } from "@patternfly/react-core";
4+
import { Button, Content, PageSection, Split, SplitItem } from "@patternfly/react-core";
5+
import { CogIcon } from "@patternfly/react-icons";
56

67
import { Explorer } from "./components/Explorer";
8+
import { Settings } from "./components/Settings";
79

810
export const RekorSearch: React.FC = () => {
11+
const [settingsOpen, setSettingsOpen] = useState(false);
12+
913
return (
10-
<Fragment>
14+
<RekorClientProvider>
1115
<PageSection variant="default">
12-
<Content>
13-
<h1>Rekor Search</h1>
14-
<p>Search the Rekor public transparency log.</p>
15-
</Content>
16+
<Split>
17+
<SplitItem isFilled>
18+
<Content>
19+
<h1>Rekor Search</h1>
20+
<p>Search the Rekor public transparency log.</p>
21+
</Content>
22+
</SplitItem>
23+
<SplitItem>
24+
<Button variant="plain" icon={<CogIcon />} onClick={() => setSettingsOpen(true)} />
25+
</SplitItem>
26+
</Split>
1627
</PageSection>
1728
<PageSection variant="secondary" isFilled>
18-
<RekorClientProvider>
19-
<Explorer />
20-
</RekorClientProvider>
29+
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
30+
<Explorer />
2131
</PageSection>
22-
</Fragment>
32+
</RekorClientProvider>
2333
);
2434
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
vi.mock("../../../api/context", () => ({
2+
useRekorBaseUrl: vi.fn(),
3+
}));
4+
5+
import { render, screen } from "@testing-library/react";
6+
import { Settings } from "./Settings";
7+
import { useRekorBaseUrl } from "../../../api/context";
8+
import type { Mock } from "vitest";
9+
10+
describe("Settings Component", () => {
11+
const mockOnClose = vi.fn();
12+
const mockSetBaseUrl = vi.fn();
13+
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
// mock initial state & updater function returned by useRekorBaseUrl
17+
(useRekorBaseUrl as Mock).mockReturnValue(["https://initial.rekor.domain", mockSetBaseUrl]);
18+
});
19+
20+
afterEach(() => {
21+
vi.restoreAllMocks();
22+
});
23+
24+
it("renders correctly with initial context value", () => {
25+
render(<Settings open={true} onClose={mockOnClose} />);
26+
expect(screen.getByLabelText("override rekor endpoint")).toHaveValue("https://initial.rekor.domain");
27+
expect(screen.getByText("Confirm")).toBeInTheDocument();
28+
expect(screen.getByText("Cancel")).toBeInTheDocument();
29+
});
30+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { useRekorBaseUrl } from "@app/api/context";
2+
import {
3+
Button,
4+
Form,
5+
FormGroup,
6+
FormHelperText,
7+
HelperText,
8+
HelperTextItem,
9+
Modal,
10+
ModalBody,
11+
ModalFooter,
12+
ModalHeader,
13+
ModalVariant,
14+
Popover,
15+
TextInput,
16+
ValidatedOptions,
17+
} from "@patternfly/react-core";
18+
import { ExclamationCircleIcon, HelpIcon } from "@patternfly/react-icons";
19+
import styles from "@patternfly/react-styles/css/components/Form/form";
20+
import { type FormEvent, useCallback, useState } from "react";
21+
import { validateUrl } from "../utils/validateUrl";
22+
23+
export function Settings({ open, onClose }: { open: boolean; onClose: () => void }) {
24+
const [baseUrl, setBaseUrl] = useRekorBaseUrl();
25+
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
26+
const [showValidation, setShowValidation] = useState(false);
27+
28+
const handleChangeBaseUrl = useCallback((e: FormEvent<HTMLInputElement>) => {
29+
if (e.currentTarget.value.length === 0) {
30+
setLocalBaseUrl(undefined);
31+
} else {
32+
setLocalBaseUrl(e.currentTarget.value);
33+
}
34+
}, []);
35+
36+
const handleClose = useCallback(() => {
37+
setLocalBaseUrl(baseUrl);
38+
setShowValidation(false);
39+
onClose();
40+
}, [baseUrl, onClose]);
41+
42+
const onSave = useCallback(() => {
43+
if (!validateUrl(localBaseUrl)) {
44+
setShowValidation(true);
45+
return;
46+
} else {
47+
setBaseUrl(localBaseUrl);
48+
setShowValidation(false);
49+
}
50+
51+
onClose();
52+
}, [localBaseUrl, onClose, setBaseUrl]);
53+
54+
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
55+
e.preventDefault();
56+
onSave();
57+
};
58+
59+
return (
60+
<Modal variant={ModalVariant.small} isOpen={open} onClose={handleClose} data-testid="settings-modal">
61+
<ModalHeader>Settings</ModalHeader>
62+
<ModalBody>
63+
<Form id="settings-form" onSubmit={handleSubmit}>
64+
<FormGroup
65+
label="Override Rekor Endpoint"
66+
labelHelp={
67+
<Popover bodyContent={"Specify your private Rekor endpoint URL."}>
68+
<Button
69+
variant="plain"
70+
type="button"
71+
aria-label="More info for endpoint field"
72+
onClick={(e) => e.preventDefault()}
73+
aria-describedby="form-group-label-info"
74+
data-testid={"rekor-endpoint-help-button"}
75+
className={styles.formGroupLabelHelp}
76+
>
77+
<HelpIcon />
78+
</Button>
79+
</Popover>
80+
}
81+
isRequired
82+
fieldId="rekor-endpoint-override"
83+
>
84+
<TextInput
85+
value={localBaseUrl ?? baseUrl}
86+
type="text"
87+
onChange={handleChangeBaseUrl}
88+
placeholder={baseUrl ?? "https://private.rekor.example.com"}
89+
label={"name"}
90+
aria-label="override rekor endpoint"
91+
id={"rekor-endpoint-override"}
92+
validated={showValidation ? ValidatedOptions.error : undefined}
93+
aria-invalid={showValidation}
94+
data-testid={"rekor-endpoint-override"}
95+
isRequired
96+
/>
97+
{showValidation && (
98+
<FormHelperText>
99+
<HelperText>
100+
<HelperTextItem icon={<ExclamationCircleIcon />} variant={"error"}>
101+
To continue, specify an endpoint in https://xxxx format
102+
</HelperTextItem>
103+
</HelperText>
104+
</FormHelperText>
105+
)}
106+
</FormGroup>
107+
<button type="submit" style={{ display: "none" }}></button>
108+
</Form>
109+
</ModalBody>
110+
<ModalFooter>
111+
<Button key="confirm" variant="primary" onClick={onSave} data-testid={"settings-confirm-button"}>
112+
Confirm
113+
</Button>
114+
<Button key="cancel" variant="link" onClick={handleClose} data-testid={"settings-close-button"}>
115+
Cancel
116+
</Button>
117+
,
118+
</ModalFooter>
119+
</Modal>
120+
);
121+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { isAcceptedProtocol, isValidUrl, validateUrl } from "./validateUrl";
2+
3+
describe("URL Validation Tests", () => {
4+
describe("Individual Function Tests", () => {
5+
it("isAcceptedProtocol: should check for https protocols", () => {
6+
expect(isAcceptedProtocol("http://example.com")).toBe(false);
7+
expect(isAcceptedProtocol("example.com")).toBe(false);
8+
expect(isAcceptedProtocol("www.example.com")).toBe(false);
9+
expect(isAcceptedProtocol("ftp://example.com")).toBe(false);
10+
expect(isAcceptedProtocol("http://rekor")).toBe(false);
11+
expect(isAcceptedProtocol("https://example.com")).toBe(true);
12+
});
13+
14+
it("isValidUrl: http(s) protocol, valid characters, and tld", () => {
15+
expect(isValidUrl("http://rekor")).toBe(true);
16+
expect(isValidUrl("https://rekor")).toBe(true);
17+
expect(isValidUrl("https://rekor🦩")).toBe(false);
18+
expect(isValidUrl("https://rekor-example")).toBe(true);
19+
expect(isValidUrl("https://rekor-example.com")).toBe(true);
20+
expect(isValidUrl("https://")).toBe(false);
21+
expect(isValidUrl("https://₮∌⎛")).toBe(false);
22+
expect(isValidUrl("https://😝")).toBe(false);
23+
});
24+
});
25+
26+
describe("validateUrl: Composite Function Tests", () => {
27+
it("should return true for valid URLs with correct protocol", () => {
28+
expect(validateUrl("https://example.com")).toBe(true);
29+
});
30+
31+
it("should return false for valid URLs with incorrect protocol", () => {
32+
expect(validateUrl("ftp://example.com")).toBe(false);
33+
});
34+
35+
it("should return false for invalid URLs", () => {
36+
expect(validateUrl("justastring")).toBe(false);
37+
});
38+
});
39+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export function validateUrl(url?: string): boolean {
2+
if (!url) return false;
3+
return isAcceptedProtocol(url) && isValidUrl(url);
4+
}
5+
6+
/**
7+
* Checks if the given URL is using an accepted protocol.
8+
* @param url The URL to validate.
9+
* @returns True if the URL is valid, false otherwise.
10+
*/
11+
export function isAcceptedProtocol(url: string): boolean {
12+
try {
13+
const parsedUrl = new URL(url);
14+
return ["https:"].includes(parsedUrl.protocol);
15+
} catch (_error) {
16+
return false;
17+
}
18+
}
19+
20+
/**
21+
* Checks if the given string is a valid URL, based on:
22+
* 1) http(s) protocol; 2) valid alphanumeric & special chars;
23+
* 3) combined length of subdomain & domain must be between 2 and 256
24+
* https://regex101.com/r/ecDRn6/1
25+
* @param url The URL to validate.
26+
* @returns True if the URL is valid, false otherwise.
27+
*/
28+
export const isValidUrl = (url: string): boolean => {
29+
/* eslint-disable no-useless-escape */
30+
const regexVal = /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&\/=]*)$/gm;
31+
32+
try {
33+
return regexVal.test(url);
34+
} catch (_error) {
35+
return false;
36+
}
37+
};

0 commit comments

Comments
 (0)