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
15 changes: 14 additions & 1 deletion frontend/src/components/suggestions/AffectedProduct.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import type { SuggestionAffectedProduct } from "@/api/generated/models";
import {
useSuggestionListFilterHref,
withAffectedProductFilter,
} from "@/hooks/useSuggestionListFilters";
import { FilterShortcut } from "./FilterShortcut";

type Props = {
product: SuggestionAffectedProduct;
};

export function AffectedProduct({ product }: Props) {
const filterHref = useSuggestionListFilterHref();

return (
<div
className="row gap-small row-gap wrap"
title={product.cpes.length > 0 ? product.cpes.join("\n") : "No CPE info"}
>
<div>{product.name}</div>
<div className="row gap-small">
<div>{product.name}</div>
<FilterShortcut
href={filterHref(withAffectedProductFilter(product.name))}
label={`Filter suggestions by affected product '${product.name}'`}
/>
</div>
<ul className="row gap-small wrap">
{product.version_constraints.map(([op, ver]) => (
<li
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/components/suggestions/FilterShortcut.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.shortcut {
display: inline-flex;
align-self: center;
color: var(--gray);
opacity: 0;
transition: opacity 0.15s;
}

:hover > .shortcut,
.shortcut:focus-visible {
opacity: 1;
}

.shortcut:hover {
color: var(--nixos-blue);
}

/* Touch screens have no hover to reveal the shortcut. */
@media (hover: none) {
.shortcut {
opacity: 1;
}
}
30 changes: 30 additions & 0 deletions frontend/src/components/suggestions/FilterShortcut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { SearchIcon } from "lucide-preact";
import { Link } from "wouter-preact";
import styles from "./FilterShortcut.module.css";

type Props = {
href: string;
label: string;
};

/**
* Link to the suggestion list filtered by a value, revealed when hovering the element containing it.
*/
export function FilterShortcut({ href, label }: Props) {
return (
<Link
href={href}
aria-label={label}
className={styles.shortcut}
onClick={(e) => {
// The link sits inside tree items, which would otherwise handle the click as a selection.
e.stopPropagation();
// Scroll back to the top after the filter is applied as suggestions in the list will change.
// Also gives confirmation to the user by showing them the filters on top.
window.scrollTo({ top: 0 });
}}
>
<SearchIcon size="1em" />
</Link>
);
}
11 changes: 10 additions & 1 deletion frontend/src/components/suggestions/Package.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { PackageMinusIcon, PackagePlusIcon } from "lucide-preact";
import type { SuggestionPackage } from "@/api/generated/models";
import { ExternalLink } from "@/components/ui/ExternalLink";
import { usePackageMutation } from "@/hooks/usePackage";
import { useSuggestionListFilterHref, withPackageFilter } from "@/hooks/useSuggestionListFilters";
import { formatTime } from "@/utils/date";
import { FilterShortcut } from "./FilterShortcut";
import styles from "./Package.module.css";

type Props = {
Expand All @@ -21,6 +23,7 @@ function versionStatusClass(status: string | null): string {

export function Package({ attr, pkg, suggestionId, editable, isIgnored }: Props) {
const mutation = usePackageMutation(suggestionId);
const filterHref = useSuggestionListFilterHref();

function handleClick() {
mutation.mutate({
Expand All @@ -43,7 +46,13 @@ export function Package({ attr, pkg, suggestionId, editable, isIgnored }: Props)
</button>
)}
<div className="column">
<h3 className={`bold ${styles.packageTitle}`}>{attr}</h3>
<div className={`row gap-small ${styles.packageTitle}`}>
<h3 className="bold">{attr}</h3>
<FilterShortcut
href={filterHref(withPackageFilter(attr))}
label={`Filter suggestions by package '${attr}'`}
/>
</div>
<p className={`${styles.packageTitle}`}>{pkg.description}</p>
</div>

Expand Down
156 changes: 76 additions & 80 deletions frontend/src/hooks/useSuggestionListFilters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useSearchParams } from "wouter-preact";
import { useLocation, useSearchParams } from "wouter-preact";
import type { ListSuggestionsStatusItem } from "@/api/generated/models";
import { ListSuggestionsStatusItem as Status } from "@/api/generated/models";
import { paths } from "@/routes/paths";

/** URL-search-param-backed suggestion list filters. */
export type SuggestionListFilters = {
Expand Down Expand Up @@ -42,6 +43,71 @@ function parseFilters(searchParams: URLSearchParams): SuggestionListFilters {
};
}

/** A pure change to the URL search params. */
export type ParamsUpdate = (params: URLSearchParams) => URLSearchParams;

// An empty value removes the parameter.
const setParam =
(key: string, value: string): ParamsUpdate =>
(params) => {
const next = new URLSearchParams(params);
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
return next;
};

const pipe =
(...updates: ParamsUpdate[]): ParamsUpdate =>
(params) =>
updates.reduce((acc, update) => update(acc), params);

// Invalidate the current page: jump back to page 1.
const resetPage = setParam("page", "");

export const withPackageFilter = (packageFilter: string): ParamsUpdate =>
setParam("package", packageFilter.trim());

// The product and its version are written at once, since the version is meaningless without the product.
// Omitting the version drops one belonging to a previous product.
export const withAffectedProductFilter = (product: string, version = ""): ParamsUpdate => {
const trimmedProduct = product.trim();
return pipe(
setParam("affected_product", trimmedProduct),
setParam("affected_product_version", trimmedProduct ? version.trim() : ""),
);
};

const withStatuses =
(statuses: ListSuggestionsStatusItem[]): ParamsUpdate =>
(params) => {
const next = new URLSearchParams(params);
next.delete("status");
for (const status of statuses) next.append("status", status);
return next;
};

// Both bounds are written at once.
// It should be a single browser history entry.
const withCreatedAtRange = (after: string, before: string): ParamsUpdate =>
pipe(
setParam("created_at_after", parseDay(after)),
setParam("created_at_before", parseDay(before)),
);

/**
* Builds links to the suggestion list with a filter change applied.
* On the suggestion list, the other active filters are kept; elsewhere, the link starts from no filter.
*/
export function useSuggestionListFilterHref(): (update: ParamsUpdate) => string {
const [location] = useLocation();
const [searchParams] = useSearchParams();
const base = location === paths.suggestions ? searchParams : new URLSearchParams();
return (update) => `${paths.suggestions}?${pipe(update, resetPage)(base)}`;
}

export function useSuggestionListFilters(): {
filters: SuggestionListFilters;
setStatuses: (statuses: ListSuggestionsStatusItem[]) => void;
Expand All @@ -53,89 +119,19 @@ export function useSuggestionListFilters(): {
const [searchParams, setSearchParams] = useSearchParams();
const filters = parseFilters(searchParams);

// Any filter change invalidates the current page, so we jump back to page 1.
// Uses `replace` so typing in the package filter doesn't spam browser history.
const updateParams = (mutate: (next: URLSearchParams) => void) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
mutate(next);
next.delete("page");
return next;
},
{ replace: true },
);
};

const setStatuses = (statuses: ListSuggestionsStatusItem[]) => {
updateParams((next) => {
next.delete("status");
for (const status of statuses) next.append("status", status);
});
};

const setInIssueDraft = (inIssueDraft: boolean) => {
updateParams((next) => {
if (inIssueDraft) {
next.set("in_issue_draft", "true");
} else {
next.delete("in_issue_draft");
}
});
};

const setPackageFilter = (packageFilter: string) => {
updateParams((next) => {
const trimmed = packageFilter.trim();
if (trimmed) {
next.set("package", trimmed);
} else {
next.delete("package");
}
});
};

// The product and its version are written at once, since the version is meaningless without the product.
const setAffectedProductFilter = (product: string, version: string) => {
updateParams((next) => {
const trimmedProduct = product.trim();
const trimmedVersion = version.trim();
if (trimmedProduct) {
next.set("affected_product", trimmedProduct);
} else {
next.delete("affected_product");
}
if (trimmedProduct && trimmedVersion) {
next.set("affected_product_version", trimmedVersion);
} else {
next.delete("affected_product_version");
}
});
};

// Both bounds are written at once: a change of filtering mode often moves or drops both of them.
// It should be a single history entry.
const setCreatedAtRange = (after: string, before: string) => {
updateParams((next) => {
for (const [param, day] of [
["created_at_after", after],
["created_at_before", before],
]) {
if (DAY_PATTERN.test(day)) {
next.set(param, day);
} else {
next.delete(param);
}
}
});
const updateParams = (update: ParamsUpdate) => {
setSearchParams(pipe(update, resetPage), { replace: true });
};

return {
filters,
setStatuses,
setInIssueDraft,
setPackageFilter,
setAffectedProductFilter,
setCreatedAtRange,
setStatuses: (statuses) => updateParams(withStatuses(statuses)),
setInIssueDraft: (inIssueDraft) =>
updateParams(setParam("in_issue_draft", inIssueDraft ? "true" : "")),
setPackageFilter: (packageFilter) => updateParams(withPackageFilter(packageFilter)),
setAffectedProductFilter: (product, version) =>
updateParams(withAffectedProductFilter(product, version)),
setCreatedAtRange: (after, before) => updateParams(withCreatedAtRange(after, before)),
};
}
Loading
Loading