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
19 changes: 17 additions & 2 deletions apps/dashboard/src/components/filters/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
export { FilterBar } from "./filter-bar";
export type { SerializedFilterStore } from "./filter-cookie";
export { getFilterCookie } from "./filter-cookie";
export { issueFilterDefs, issueSortOptions } from "./issue-filters";
export { pullFilterDefs, pullSortOptions } from "./pull-filters";
export {
issueFilterDefs,
issueSortOptions,
repoIssueFilterDefs,
} from "./issue-filters";
export {
pullFilterDefs,
pullSortOptions,
repoPullFilterDefs,
} from "./pull-filters";
export type { FilterableItem, ListFilterState } from "./use-list-filters";
export { applyFilters, useListFilters } from "./use-list-filters";
export {
applyRepoFilters,
getFilterValues,
parseFilterString,
repoListUrlParsers,
useRepoListFilters,
} from "./use-repo-list-filters";
63 changes: 63 additions & 0 deletions apps/dashboard/src/components/filters/issue-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,69 @@ export const issueFilterDefs: FilterDefinition[] = [
},
];

/** Filter defs for repo-scoped issue lists — static options, no repository filter. */
export const repoIssueFilterDefs: FilterDefinition[] = [
{
id: "status",
label: "Status",
icon: CircleIcon,
extractOptions: () => {
const colorMap: Record<string, string> = {
open: "text-green-500",
completed: "text-purple-500",
not_planned: "text-muted-foreground",
};
return [
{ value: "open", label: "Open" },
{ value: "completed", label: "Closed (completed)" },
{ value: "not_planned", label: "Closed (not planned)" },
].map((s) => ({
value: s.value,
label: s.label,
icon: createElement(IssuesIcon, {
size: 14,
className: colorMap[s.value],
}),
}));
},
match: (item, values) => {
const issue = asIssue(item);
if (issue.state === "closed") {
return issue.stateReason === "not_planned"
? values.has("not_planned")
: values.has("completed");
}
return values.has("open");
},
},
{
id: "author",
label: "Author",
icon: UserCircleIcon,
extractOptions: (items) => {
const authors = new Map<string, { login: string; avatarUrl: string }>();
for (const item of items) {
if (item.author && !authors.has(item.author.login)) {
authors.set(item.author.login, item.author);
}
}
return [...authors.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([login, author]) => ({
value: login,
label: login,
icon: createElement("img", {
src: author.avatarUrl,
alt: login,
className: "size-4 rounded-full",
}),
}));
},
match: (item, values) =>
item.author ? values.has(item.author.login) : false,
},
];

export const issueSortOptions: SortOption[] = [
{
id: "updated",
Expand Down
65 changes: 65 additions & 0 deletions apps/dashboard/src/components/filters/pull-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,71 @@ export const pullFilterDefs: FilterDefinition[] = [
},
];

/** Filter defs for repo-scoped pull lists — static options, no repository filter. */
export const repoPullFilterDefs: FilterDefinition[] = [
{
id: "status",
label: "Status",
icon: CircleIcon,
extractOptions: () => {
const colorMap: Record<string, string> = {
open: "text-green-500",
draft: "text-muted-foreground",
merged: "text-purple-500",
closed: "text-red-500",
};
return (
[
{ value: "open", label: "Open", icon: GitPullRequestIcon },
{ value: "draft", label: "Draft", icon: GitPullRequestDraftIcon },
{ value: "merged", label: "Merged", icon: GitMergeIcon },
{ value: "closed", label: "Closed", icon: GitPullRequestClosedIcon },
] as const
).map((s) => ({
value: s.value,
label: s.label,
icon: createElement(s.icon, {
size: 14,
className: colorMap[s.value],
}),
}));
},
match: (item, values) => {
const pull = asPull(item);
if (pull.mergedAt) return values.has("merged");
if (pull.state === "closed") return values.has("closed");
if (pull.isDraft) return values.has("draft");
return values.has("open");
},
},
{
id: "author",
label: "Author",
icon: UserCircleIcon,
extractOptions: (items) => {
const authors = new Map<string, { login: string; avatarUrl: string }>();
for (const item of items) {
if (item.author && !authors.has(item.author.login)) {
authors.set(item.author.login, item.author);
}
}
return [...authors.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([login, author]) => ({
value: login,
label: login,
icon: createElement("img", {
src: author.avatarUrl,
alt: login,
className: "size-4 rounded-full",
}),
}));
},
match: (item, values) =>
item.author ? values.has(item.author.login) : false,
},
];

export const pullSortOptions: SortOption[] = [
{
id: "updated",
Expand Down
216 changes: 216 additions & 0 deletions apps/dashboard/src/components/filters/use-repo-list-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useCallback, useMemo } from "react";
import type {
ActiveFilter,
FilterableItem,
FilterDefinition,
FilterOption,
SortOption,
} from "./use-list-filters";

// ── URL parsers ──────────────────────────────────────────────────────────

export const repoListUrlParsers = {
q: parseAsString
.withDefault("")
.withOptions({ history: "replace", throttleMs: 300 }),
sort: parseAsString.withOptions({ history: "push", throttleMs: 300 }),
page: parseAsInteger
.withDefault(1)
.withOptions({ history: "push", throttleMs: 300 }),
filters: parseAsString.withOptions({ history: "push", throttleMs: 300 }),
};

// ── Compact filter serialization ─────────────────────────────────────────
// URL format: "state:open,closed|author:user1,user2"

export function parseFilterString(raw: string | null): ActiveFilter[] {
if (!raw) return [];
return raw
.split("|")
.filter(Boolean)
.map((segment) => {
const colonIdx = segment.indexOf(":");
if (colonIdx === -1) return null;
const fieldId = segment.slice(0, colonIdx);
const values = segment
.slice(colonIdx + 1)
.split(",")
.filter(Boolean);
return values.length > 0 ? { fieldId, values: new Set(values) } : null;
})
.filter((f): f is ActiveFilter => f !== null);
}

function serializeFilters(filters: ActiveFilter[]): string | null {
const valid = filters.filter((f) => f.values.size > 0);
if (valid.length === 0) return null;
return valid.map((f) => `${f.fieldId}:${[...f.values].join(",")}`).join("|");
}

// ── Hook ─────────────────────────────────────────────────────────────────

export function useRepoListFilters<T extends FilterableItem>({
filterDefs,
sortOptions,
defaultSortId,
items,
}: {
filterDefs: FilterDefinition[];
sortOptions: SortOption[];
defaultSortId: string;
/** Loaded items for the current page (used by extractOptions for data-driven filters like author). */
items: T[];
}) {
const [params, setParams] = useQueryStates(repoListUrlParsers);

const activeFilters = useMemo(
() => parseFilterString(params.filters),
[params.filters],
);

const sortId =
sortOptions.some((o) => o.id === params.sort) && params.sort
? params.sort
: defaultSortId;

const availableOptions = useMemo(() => {
const map = new Map<string, FilterOption[]>();
for (const def of filterDefs) {
map.set(def.id, def.extractOptions(items));
}
return map;
}, [items, filterDefs]);

const setSearchQuery = useCallback(
(q: string) => {
void setParams({ q, page: 1 });
},
[setParams],
);

const setSortId = useCallback(
(sort: string) => {
void setParams({ sort, page: 1 });
},
[setParams],
);

const addFilter = useCallback(
(fieldId: string, value: string) => {
const current = parseFilterString(params.filters);
const existing = current.find((f) => f.fieldId === fieldId);
let next: ActiveFilter[];
if (existing) {
next = current.map((f) =>
f.fieldId === fieldId
? { ...f, values: new Set([...f.values, value]) }
: f,
);
} else {
next = [...current, { fieldId, values: new Set([value]) }];
}
void setParams({ filters: serializeFilters(next), page: 1 });
},
[params.filters, setParams],
);

const removeFilterValue = useCallback(
(fieldId: string, value: string) => {
const current = parseFilterString(params.filters);
const next = current
.map((f) => {
if (f.fieldId !== fieldId) return f;
const values = new Set(f.values);
values.delete(value);
return { ...f, values };
})
.filter((f) => f.values.size > 0);
void setParams({ filters: serializeFilters(next), page: 1 });
},
[params.filters, setParams],
);

const removeFilter = useCallback(
(fieldId: string) => {
const current = parseFilterString(params.filters);
const next = current.filter((f) => f.fieldId !== fieldId);
void setParams({ filters: serializeFilters(next), page: 1 });
},
[params.filters, setParams],
);

const clearAllFilters = useCallback(() => {
void setParams({ q: "", filters: null, page: 1 });
}, [setParams]);

const hasActiveFilters = activeFilters.length > 0 || params.q.length > 0;

const setPage = useCallback(
(page: number) => {
void setParams({ page });
},
[setParams],
);

return {
// ListFilterState-compatible shape (reuses FilterBar as-is)
searchQuery: params.q,
setSearchQuery,
activeFilters,
sortId,
setSortId,
availableOptions,
addFilter,
removeFilterValue,
removeFilter,
clearAllFilters,
hasActiveFilters,
sortOptions,
filterDefs,
// Pagination (repo-list specific)
page: params.page,
setPage,
};
}

// ── Helpers ──────────────────────────────────────────────────────────────

/** Read selected values for a specific filter field. */
export function getFilterValues(
activeFilters: ActiveFilter[],
fieldId: string,
): Set<string> {
return activeFilters.find((f) => f.fieldId === fieldId)?.values ?? new Set();
}

/** Apply client-side filters (search, match, sort) to items from the current page. */
export function applyRepoFilters<T extends FilterableItem>(
items: T[],
state: ReturnType<typeof useRepoListFilters<T>>,
): T[] {
const query = state.searchQuery.toLowerCase().trim();
const sortFn =
state.sortOptions.find((s) => s.id === state.sortId)?.compare ??
state.sortOptions[0].compare;

let result: T[] = items;

if (query) {
result = result.filter(
(item) =>
item.title.toLowerCase().includes(query) ||
item.repository.fullName.toLowerCase().includes(query) ||
(item.author?.login.toLowerCase().includes(query) ?? false),
);
}

for (const filter of state.activeFilters) {
if (filter.values.size === 0) continue;
const def = state.filterDefs.find((d) => d.id === filter.fieldId);
if (!def) continue;
result = result.filter((item) => def.match(item, filter.values));
}

return [...result].sort(sortFn);
}
Loading
Loading