Skip to content

g1t/apps/web/app/lib/project-list.ts

214 lines8,706 bytesCodeBlame
1/**
2 * A workspace's Projects page, for a workspace with hundreds of them: what
3 * the address asks for (a search, filters, a sort, a view and a page), and
4 * the list that answers it. Read from and written to the address, so a
5 * filtered list can be shared, bookmarked and gone back to.
6 */
7
8import type { Project, ProjectEcosystem } from "@g1t/contracts";
9
10export type ProjectSort = "updated" | "name" | "active" | "pushed";
11export type ProjectView = "list" | "grid";
12
13export type ProjectQuery = {
14 /** Words in the name, slug or description. */
15 q: string;
16 visibility: "all" | "public" | "private";
17 kind: "all" | "app" | "library";
18 /** A language, from the manifests at the project's root. */
19 language: string | null;
20 /** Only projects with Deployments on. */
21 deployments: boolean;
22 /** Archived projects: left out (the default), shown too, or only them. */
23 archived: "hide" | "include" | "only";
24 sort: ProjectSort;
25 view: ProjectView;
26 /** 1 first. */
27 page: number;
28};
29
30/** Projects to a page. */
31export const PAGE_SIZE = 30;
32
33export const DEFAULT_QUERY: ProjectQuery = {
34 q: "",
35 visibility: "all",
36 kind: "all",
37 language: null,
38 deployments: false,
39 archived: "hide",
40 sort: "updated",
41 view: "list",
42 page: 1,
43};
44
45export const SORTS: { value: ProjectSort; label: string }[] = [
46 { value: "updated", label: "Recently updated" },
47 { value: "pushed", label: "Recently pushed" },
48 { value: "active", label: "Most active" },
49 { value: "name", label: "Name" },
50];
51
52/** The language a project's manifests say it is written in. */
53export const LANGUAGE_OF: Record<ProjectEcosystem, string> = {
54 npm: "JavaScript",
55 cargo: "Rust",
56 go: "Go",
57 composer: "PHP",
58 python: "Python",
59};
60
61/** What the list needs of a project, with whether its Deployments are on. */
62export type Listed = Pick<
63 Project,
64 "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity"
65> & { deploying: boolean };
66
67const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T =>
68 (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback;
69
70/** What the address asks for; anything it does not say, or says wrong, is the default. */
71export function readProjectQuery(params: URLSearchParams): ProjectQuery {
72 const page = Number.parseInt(params.get("page") ?? "", 10);
73 return {
74 q: (params.get("q") ?? "").trim().slice(0, 100),
75 visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"),
76 kind: one(params.get("kind"), ["all", "app", "library"] as const, "all"),
77 language: params.get("language")?.trim() || null,
78 deployments: params.get("deployments") === "on",
79 archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"),
80 sort: one(params.get("sort"), ["updated", "name", "active", "pushed"] as const, "updated"),
81 view: one(params.get("view"), ["list", "grid"] as const, "list"),
82 page: Number.isFinite(page) && page > 0 ? page : 1,
83 };
84}
85
86/**
87 * The address for `query` with `change` made, leaving out what is the
88 * default. Changing anything but the page goes back to the first page.
89 */
90export function projectQueryString(query: ProjectQuery, change: Partial<ProjectQuery> = {}): string {
91 const next = { ...query, ...change };
92 if (!("page" in change)) next.page = 1;
93 const params = new URLSearchParams();
94 if (next.q) params.set("q", next.q);
95 if (next.visibility !== "all") params.set("visibility", next.visibility);
96 if (next.kind !== "all") params.set("kind", next.kind);
97 if (next.language) params.set("language", next.language);
98 if (next.deployments) params.set("deployments", "on");
99 if (next.archived !== "hide") params.set("archived", next.archived);
100 if (next.sort !== "updated") params.set("sort", next.sort);
101 if (next.view !== "list") params.set("view", next.view);
102 if (next.page > 1) params.set("page", String(next.page));
103 const text = params.toString();
104 return text ? `?${text}` : "";
105}
106
107/** Whether anything narrows the list, beyond leaving archived projects out. */
108export function isFiltered(query: ProjectQuery): boolean {
109 return Boolean(
110 query.q || query.visibility !== "all" || query.kind !== "all" || query.language || query.deployments || query.archived !== "hide",
111 );
112}
113
114/** When it last changed: its own settings, or a push, whichever is later. */
115export function lastUpdated(project: Pick<Listed, "updatedAt" | "pushedAt">): string {
116 return project.pushedAt && project.pushedAt > project.updatedAt ? project.pushedAt : project.updatedAt;
117}
118
119function matches(project: Listed, words: string[]): boolean {
120 const text = `${project.name} ${project.slug} ${project.description ?? ""}`.toLowerCase();
121 return words.every((word) => text.includes(word));
122}
123
124/** The projects `query` asks for, in its order, before paging. */
125export function filterProjects(projects: Listed[], query: ProjectQuery): Listed[] {
126 const words = query.q.toLowerCase().split(/\s+/).filter(Boolean);
127 const shown = projects.filter(
128 (project) =>
129 (query.archived === "include" || (query.archived === "only") === project.archived) &&
130 (query.visibility === "all" || (query.visibility === "private") === project.private) &&
131 (query.kind === "all" || project.kind === query.kind) &&
132 (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) &&
133 (!query.deployments || project.deploying) &&
134 (words.length === 0 || matches(project, words)),
135 );
136 return sortProjects(shown, query.sort, words);
137}
138
139const byName = (a: Listed, b: Listed) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.slug.localeCompare(b.slug);
140
141/**
142 * In `sort`'s order, ties by name. While searching, a name that starts
143 * with what was typed comes first.
144 */
145export function sortProjects(projects: Listed[], sort: ProjectSort, words: string[] = []): Listed[] {
146 const first = words[0];
147 const leads = (project: Listed) =>
148 first != null && (project.name.toLowerCase().startsWith(first) || project.slug.startsWith(first)) ? 0 : 1;
149 const order: Record<ProjectSort, (a: Listed, b: Listed) => number> = {
150 name: byName,
151 updated: (a, b) => lastUpdated(b).localeCompare(lastUpdated(a)),
152 pushed: (a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""),
153 active: (a, b) => b.activity - a.activity,
154 };
155 return [...projects].sort((a, b) => leads(a) - leads(b) || order[sort](a, b) || byName(a, b));
156}
157
158/** One page of them, and where it sits among the rest. */
159export function pageOf<T>(list: T[], page: number, size = PAGE_SIZE): { items: T[]; page: number; pages: number; from: number; to: number } {
160 const pages = Math.max(1, Math.ceil(list.length / size));
161 const current = Math.min(Math.max(1, page), pages);
162 const start = (current - 1) * size;
163 const items = list.slice(start, start + size);
164 return { items, page: current, pages, from: items.length ? start + 1 : 0, to: start + items.length };
165}
166
167export type Facets = {
168 visibility: { public: number; private: number };
169 kind: { app: number; library: number };
170 languages: { name: string; count: number }[];
171 deploying: number;
172 archived: number;
173};
174
175/** How many of the workspace's projects each filter would show, before any is chosen. */
176export function facetsOf(projects: Listed[]): Facets {
177 const languages = new Map<string, number>();
178 const facets: Facets = { visibility: { public: 0, private: 0 }, kind: { app: 0, library: 0 }, languages: [], deploying: 0, archived: 0 };
179 for (const project of projects) {
180 facets.visibility[project.private ? "private" : "public"]++;
181 facets.kind[project.kind]++;
182 if (project.deploying) facets.deploying++;
183 if (project.archived) facets.archived++;
184 if (project.ecosystem) {
185 const language = LANGUAGE_OF[project.ecosystem];
186 languages.set(language, (languages.get(language) ?? 0) + 1);
187 }
188 }
189 facets.languages = [...languages].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
190 return facets;
191}
192
193/**
194 * The index of the row to move to from `at` by key, in a list of `count`
195 * rows: arrows and j/k move one, Home and End to either end. Null for any
196 * other key.
197 */
198export function nextRow(key: string, at: number, count: number): number | null {
199 if (count === 0) return null;
200 switch (key) {
201 case "ArrowDown":
202 case "j":
203 return Math.min(count - 1, at + 1);
204 case "ArrowUp":
205 case "k":
206 return Math.max(0, at - 1);
207 case "Home":
208 return 0;
209 case "End":
210 return count - 1;
211 default:
212 return null;
213 }
214}