Skip to content
143 linesCodeBlameRaw
1/**
2 * The integrations directory: the connector catalog (@g1t/contracts/connectors)
3 * filtered, searched and sorted into what a workspace, or a person, has
4 * connected, can connect now, and will be able to soon.
5 */
6
7import {
8 CONNECTOR_CATEGORIES,
9 type ConnectorCategory,
10 type ConnectorScope,
11 type ConnectorView,
12} from "@g1t/contracts/connectors";
13
14/** "all", or one category: what the directory's navigation selects. */
15export type CategoryFilter = ConnectorCategory | "all";
16
17/** What is connected, by connector id: how it is doing, for its card. */
18export type ConnectedState = {
19 /** A line about it: an account, how many connections. */
20 detail: string | null;
21 /** Something is wrong with it, said plainly. */
22 problem: string | null;
23 /** Where it is managed, when that is not its setup page. */
24 manage: string | null;
25};
26
27export type Directory = {
28 connected: ConnectorView[];
29 available: ConnectorView[];
30 soon: ConnectorView[];
31};
32
33/** The category a `?category=` asks for, or all of them. */
34export function categoryFilter(value: string | null | undefined): CategoryFilter {
35 return CONNECTOR_CATEGORIES.some((category) => category.id === value) ? (value as ConnectorCategory) : "all";
36}
37
38/** A category's title. */
39export function categoryTitle(id: ConnectorCategory): string {
40 return CONNECTOR_CATEGORIES.find((category) => category.id === id)?.title ?? id;
41}
42
43/** Lowercased, without accents or punctuation, for matching. */
44function fold(text: string): string {
45 return text
46 .normalize("NFKD")
47 .replace(/[̀-ͯ]/g, "")
48 .toLowerCase()
49 .replace(/[^a-z0-9]+/g, " ")
50 .trim();
51}
52
53/** Whether a connector answers a search: every word in its name, description, category, tags or keywords. */
54export function matchesQuery(view: ConnectorView, query: string): boolean {
55 const words = fold(query).split(" ").filter(Boolean);
56 if (words.length === 0) return true;
57 const haystack = fold([view.name, view.description, categoryTitle(view.category), ...view.capabilities, ...view.keywords].join(" "));
58 const compact = haystack.replace(/ /g, "");
59 return words.every((word) => haystack.includes(word) || compact.includes(word));
60}
61
62/**
63 * The directory for one category and search: what is connected first, then
64 * what can be connected now, then what is coming. Search spans every
65 * category, so a match in another one is never hidden.
66 */
67export function arrange(
68 views: ConnectorView[],
69 { category, query, connected }: { category: CategoryFilter; query: string; connected: ReadonlySet<string> },
70): Directory {
71 const searching = query.trim().length > 0;
72 const shown = views.filter(
73 (view) => (searching || category === "all" || view.category === category) && matchesQuery(view, query),
74 );
75 return {
76 connected: shown.filter((view) => connected.has(view.id)),
77 available: shown.filter((view) => view.status === "available" && !connected.has(view.id)),
78 soon: shown.filter((view) => view.status === "soon" && !connected.has(view.id)),
79 };
80}
81
82/** How many connectors each category has, for its row in the navigation; `all` is every one. */
83export function categoryCounts(views: ConnectorView[]): Record<CategoryFilter, number> {
84 const counts = { all: views.length } as Record<CategoryFilter, number>;
85 for (const category of CONNECTOR_CATEGORIES) counts[category.id] = 0;
86 for (const view of views) counts[view.category] += 1;
87 return counts;
88}
89
90/** The categories with something in them for `views`, in catalog order. */
91export function categoriesWith(views: ConnectorView[]): { id: ConnectorCategory; title: string }[] {
92 const present = new Set(views.map((view) => view.category));
93 return CONNECTOR_CATEGORIES.filter((category) => present.has(category.id));
94}
95
96/** Connectors grouped under their categories, in catalog order, leaving out empty ones. */
97export function groupByCategory(views: ConnectorView[]): { id: ConnectorCategory; title: string; views: ConnectorView[] }[] {
98 return CONNECTOR_CATEGORIES.map((category) => ({
99 ...category,
100 views: views.filter((view) => view.category === category.id),
101 })).filter((group) => group.views.length > 0);
102}
103
104/** The letters on a connector's tile: two words' initials, or a name's capitals ("PagerDuty" → "PD"). */
105export function monogram(name: string): string {
106 const words = name.split(/[\s.\-/]+/).filter((word) => /[A-Za-z0-9]/.test(word));
107 if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
108 const word = words[0] ?? name;
109 const capitals = word.slice(1).match(/[A-Z0-9]/);
110 return capitals ? (word[0]! + capitals[0]).toUpperCase() : word.slice(0, 1).toUpperCase();
111}
112
113/** A tile's colour, always the same for the same connector. */
114const PALETTE = ["#8b8ff0", "#5b8def", "#5eead4", "#4ade80", "#facc15", "#fb923c", "#f87171", "#f472b6", "#c084fc", "#a1a1aa", "#38bdf8", "#a3e635"];
115
116export function tileColor(id: string): string {
117 let hash = 0;
118 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
119 return PALETTE[hash % PALETTE.length]!;
120}
121
122/** Where a request for a connector goes: an email to g1t, filled in. */
123export function askHref({
124 connector,
125 scope,
126 workspace,
127 address,
128}: {
129 connector: Pick<ConnectorView, "id" | "name">;
130 scope: ConnectorScope;
131 workspace: string | null;
132 address: string;
133}): string {
134 const subject = `Integration request: ${connector.name}`;
135 const body = [
136 `I would like g1t to connect to ${connector.name} (${connector.id}).`,
137 scope === "workspace" && workspace ? `For the workspace: ${workspace}` : "For my own account.",
138 "",
139 "What we would use it for:",
140 "",
141 ].join("\n");
142 return `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
143}