Skip to content
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
2 changes: 1 addition & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{
"label": "Run Jenkins",
"type": "shell",
"command": "mvn hpi:run -Dskip.npm -P quick-build",
"command": "mvn hpi:run -Djava.awt.headless=true -Dskip.npm -P quick-build",
"options": {
"env": {
// Do not wait for debugger to connect (suspend=n), unlike mvnDebug
Expand Down
25 changes: 25 additions & 0 deletions src/main/frontend/common/components/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useId } from "react";

export default function Checkbox({
label,
value,
setValue,
}: {
label: string;
value: boolean;
setValue: (e: boolean) => void;
}) {
const id = useId();
return (
<div className="jenkins-checkbox">
<input
type="checkbox"
id={id}
name={id}
checked={value}
onChange={(e) => setValue(e.target.checked)}
/>
<label htmlFor={id}>{label}</label>
</div>
);
}
8 changes: 5 additions & 3 deletions src/main/frontend/common/components/dropdown-portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { createPortal } from "react-dom";

interface DropdownPortalProps {
children: ReactNode;
container: HTMLElement | null;
}

export default function DropdownPortal({ children }: DropdownPortalProps) {
const container = document.getElementById("console-pipeline-overflow-root");

export default function DropdownPortal({
children,
container,
}: DropdownPortalProps) {
if (!container) {
console.error("DropdownPortal: Target container not found!");
return null;
Expand Down
19 changes: 13 additions & 6 deletions src/main/frontend/common/components/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import Tooltip from "./tooltip.tsx";
*/
export default function Dropdown({
items,
tooltip = "More actions",
disabled,
className,
icon,
}: DropdownProps) {
const [visible, setVisible] = useState(false);
const show = () => setVisible(true);
const hide = () => setVisible(false);

return (
<Tooltip content={"More actions"}>
<Tooltip content={tooltip}>
<Tippy
visible={visible}
onClickOutside={hide}
Expand Down Expand Up @@ -66,11 +68,14 @@ export default function Dropdown({
disabled={disabled}
onClick={visible ? hide : show}
>
<div className="jenkins-overflow-button__ellipsis">
<span />
<span />
<span />
</div>
<span className={"jenkins-visually-hidden"}>{tooltip}</span>
{icon || (
<div className="jenkins-overflow-button__ellipsis">
<span />
<span />
<span />
</div>
)}
</button>
</Tippy>
</Tooltip>
Expand All @@ -90,8 +95,10 @@ export const DefaultDropdownProps: TippyProps = {

interface DropdownProps {
items: (DropdownItem | ReactElement | "separator")[];
tooltip?: string;
disabled?: boolean;
className?: string;
icon?: ReactNode;
}

interface DropdownItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const CONSOLE = (
);

export const SETTINGS = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden>
<path
fill="none"
stroke="currentColor"
Expand Down
8 changes: 6 additions & 2 deletions src/main/frontend/common/i18n/i18n-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export const I18NProvider: FunctionComponent<I18NProviderProps> = ({
);
};

export function useMessages() {
return useContext(I18NContext);
export function useMessages(): Messages {
const messages = useContext(I18NContext);
if (!messages) {
throw new Error("useI18N must be used within an I18NProvider");
}
return messages;
}
6 changes: 6 additions & 0 deletions src/main/frontend/common/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export enum LocalizedMessageKey {
start = "node.start",
end = "node.end",
changesSummary = "changes.summary",
settings = "settings",
showNames = "settings.showStageName",
showDuration = "settings.showStageDuration",
consoleNewTab = "console.newTab",
}

Expand All @@ -97,6 +100,9 @@ const DEFAULT_MESSAGES: ResourceBundle = {
[LocalizedMessageKey.start]: "Start",
[LocalizedMessageKey.end]: "End",
[LocalizedMessageKey.changesSummary]: "{0} {0,choice,1#change|1<changes}",
[LocalizedMessageKey.settings]: "Settings",
[LocalizedMessageKey.showNames]: "Show stage names",
[LocalizedMessageKey.showDuration]: "Show stage duration",
[LocalizedMessageKey.consoleNewTab]: "View step as plain text",
};

Expand Down
90 changes: 90 additions & 0 deletions src/main/frontend/common/user/user-preferences-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";

interface PipelineGraphViewPreferences {
showNames: boolean;
setShowNames: (val: boolean) => void;
showDurations: boolean;
setShowDurations: (val: boolean) => void;
}

const defaultPreferences = {
showNames: false,
showDurations: false,
};

const UserPreferencesContext = createContext<
PipelineGraphViewPreferences | undefined
>(undefined);

const makeKey = (setting: string) => `pgv-graph-view.${setting}`;

const loadFromLocalStorage = <T,>(key: string, fallback: T): T => {
if (typeof window === "undefined") {
return fallback;
}
try {
const value = window.localStorage.getItem(key);
if (value !== null) {
if (typeof fallback === "boolean") {
return (value === "true") as typeof fallback;
}
return value as unknown as T;
}
} catch (e) {
console.error(`Error loading localStorage key "${key}"`, e);
}
return fallback;
};

export const UserPreferencesProvider = ({
children,
}: {
children: ReactNode;
}) => {
const stageNamesKey = makeKey("stageNames");
const stageDurationsKey = makeKey("stageDurations");

const [showNames, setShowNames] = useState<boolean>(
loadFromLocalStorage(stageNamesKey, defaultPreferences.showNames),
);
const [showDurations, setShowDurations] = useState<boolean>(
loadFromLocalStorage(stageDurationsKey, defaultPreferences.showDurations),
);

useEffect(() => {
window.localStorage.setItem(stageNamesKey, String(showNames));
}, [showNames]);

useEffect(() => {
window.localStorage.setItem(stageDurationsKey, String(showDurations));
}, [showDurations]);

return (
<UserPreferencesContext.Provider
value={{
showNames,
setShowNames,
showDurations,
setShowDurations,
}}
>
{children}
</UserPreferencesContext.Provider>
);
};

export const useUserPreferences = (): PipelineGraphViewPreferences => {
const context = useContext(UserPreferencesContext);
if (!context) {
throw new Error(
"useMonitorPreferences must be used within a UserPreferencesProvider",
);
}
return context;
};
24 changes: 23 additions & 1 deletion src/main/frontend/multi-pipeline-graph-view/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,38 @@ import {
LocaleProvider,
ResourceBundleName,
} from "../common/i18n/index.ts";
import { UserPermissionsProvider } from "../common/user/user-permission-provider.tsx";
import { UserPreferencesProvider } from "../common/user/user-preferences-provider.tsx";
import { MultiPipelineGraph } from "./multi-pipeline-graph/main/MultiPipelineGraph.tsx";
import OverflowDropdown from "./multi-pipeline-graph/main/overfow-dropdown.tsx";
import SettingsButton from "./multi-pipeline-graph/main/settings-button.tsx";

const App: FunctionComponent = () => {
const locale = document.getElementById("multiple-pipeline-root")!.dataset
.userLocale!;
const settings = document.getElementById("pgv-settings");
const overflow = document.getElementById("multiple-pipeline-overflow-root");
if (!settings && !overflow) {
throw new Error("Failed to find the 'settings/overflow' element");
}
if (settings && overflow) {
throw new Error(
"Only one of the 'settings/overflow' elements should be defined",
);
}
return (
<div>
<LocaleProvider locale={locale}>
<I18NProvider bundles={[ResourceBundleName.messages]}>
<MultiPipelineGraph />
<UserPreferencesProvider>
{settings && <SettingsButton buttonPortal={settings} />}
{overflow && (
<UserPermissionsProvider>
<OverflowDropdown buttonPortal={overflow} />
</UserPermissionsProvider>
)}
<MultiPipelineGraph />
</UserPreferencesProvider>
</I18NProvider>
</LocaleProvider>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LocalizedMessageKey,
} from "../../../common/i18n/index.ts";
import useRunPoller from "../../../common/tree-api.ts";
import { useUserPreferences } from "../../../common/user/user-preferences-provider.tsx";
import { time, Total } from "../../../common/utils/timings.tsx";
import { PipelineGraph } from "../../../pipeline-graph-view/pipeline-graph/main/PipelineGraph.tsx";
import {
Expand All @@ -21,11 +22,6 @@ export default function SingleRun({ run, currentJobPath }: SingleRunProps) {
currentRunPath: currentJobPath + run.id + "/",
});

const layout: LayoutInfo = {
...defaultLayout,
nodeSpacingH: 45,
Copy link
Member Author

Choose a reason for hiding this comment

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

needs to be re-added behind a setting, possibly we can calculate it based on longest stage name too so we don't have to lose all the value by shortening it

};

function Changes() {
if (run.changesCount === 0) {
return;
Expand All @@ -43,10 +39,28 @@ export default function SingleRun({ run, currentJobPath }: SingleRunProps) {
);
}

const { showNames, showDurations } = useUserPreferences();

function getLayout() {
const layout: LayoutInfo = { ...defaultLayout };

if (!showNames && !showDurations) {
layout.nodeSpacingH = 45;
} else {
layout.nodeSpacingH = 90;
}

return layout;
}

function getCompactLayout() {
return !showNames && !showDurations ? "pgv-single-run--compact" : "";
}

return (
<div className="pgv-single-run">
<div className={`pgv-single-run ${getCompactLayout()}`}>
<div>
<a href={currentJobPath + run.id} className="pgw-user-specified-text">
<a href={currentJobPath + run.id} className="pgv-user-specified-text">
<StatusIcon status={run.result} />
{run.displayName}
<span>
Expand All @@ -55,7 +69,11 @@ export default function SingleRun({ run, currentJobPath }: SingleRunProps) {
</span>
</a>
</div>
<PipelineGraph stages={runInfo?.stages || []} layout={layout} collapsed />
<PipelineGraph
stages={runInfo?.stages || []}
layout={getLayout()}
collapsed
/>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Checkbox from "../../../common/components/checkbox.tsx";
import Dropdown from "../../../common/components/dropdown.tsx";
import DropdownPortal from "../../../common/components/dropdown-portal.tsx";
import { SETTINGS } from "../../../common/components/symbols.tsx";
import { LocalizedMessageKey, useMessages } from "../../../common/i18n";
import { useUserPermissions } from "../../../common/user/user-permission-provider.tsx";
import { useUserPreferences } from "../../../common/user/user-preferences-provider.tsx";

interface OverflowDropdownProps {
buttonPortal: HTMLElement;
}

export default function OverflowDropdown({
buttonPortal,
}: OverflowDropdownProps) {
const { showNames, setShowNames, showDurations, setShowDurations } =
useUserPreferences();
const { canConfigure } = useUserPermissions();
const messages = useMessages();
return (
<DropdownPortal container={buttonPortal}>
<Dropdown
className={"jenkins-card__reveal"}
items={[
<div className={"pgv-dropdown-checkboxes"} key={"settings-options"}>
<Checkbox
label={messages.format(LocalizedMessageKey.showNames)}
value={showNames}
setValue={setShowNames}
/>
<Checkbox
label={messages.format(LocalizedMessageKey.showDuration)}
value={showDurations}
setValue={setShowDurations}
/>
</div>,
canConfigure ? "separator" : <></>,
canConfigure ? (
{
text: "Configure",
icon: SETTINGS,
href: `../configure`,
}
) : (
<></>
),
]}
/>
</DropdownPortal>
);
}
Loading