| 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 | |
| 7 | import { |
| 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. */ |
| 15 | export type CategoryFilter = ConnectorCategory | "all"; |
| 16 | |
| 17 | /** What is connected, by connector id: how it is doing, for its card. */ |
| 18 | export 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 | |
| 27 | export type Directory = { |
| 28 | connected: ConnectorView[]; |
| 29 | available: ConnectorView[]; |
| 30 | soon: ConnectorView[]; |
| 31 | }; |
| 32 | |
| 33 | /** The category a `?category=` asks for, or all of them. */ |
| 34 | export 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. */ |
| 39 | export 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. */ |
| 44 | function 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. */ |
| 54 | export 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 | */ |
| 67 | export 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. */ |
| 83 | export 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. */ |
| 91 | export 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. */ |
| 97 | export 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"). */ |
| 105 | export 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. */ |
| 114 | const PALETTE = ["#8b8ff0", "#5b8def", "#5eead4", "#4ade80", "#facc15", "#fb923c", "#f87171", "#f472b6", "#c084fc", "#a1a1aa", "#38bdf8", "#a3e635"]; |
| 115 | |
| 116 | export 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. */ |
| 123 | export 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 | } |