feat: #11 Add predefined PR table columns

Columns will automatically link, set text, add icons, etc.

Closes: #11
This commit is contained in:
Nathan Smith 2024-02-01 17:42:50 +00:00
parent c4ab669a54
commit 3913ec116b
13 changed files with 250 additions and 48 deletions

View file

@ -107,7 +107,7 @@
"infer_ticket": true,
"confirm_ticket": true,
"add_to_title": true,
"append_hashtag": false,
"append_hashtag": true,
"surround": "",
"title_position": "start"
},

18
package-lock.json generated
View file

@ -2334,15 +2334,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -2369,6 +2360,15 @@
"@codemirror/view": "^6.0.0"
}
},
"node_modules/obsidian/node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/octokit": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/octokit/-/octokit-3.1.2.tgz",

View file

@ -13,3 +13,23 @@ export type SearchRepoParams = RestEndpointMethodTypes["search"]["repos"]["param
export type SearchRepoResponse = RestEndpointMethodTypes["search"]["repos"]["response"]["data"];
export type SearchIssueParams = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"];
export type SearchIssueResponse = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["response"]["data"];
export function getSearchResultPRStatus(pr: SearchIssueResponse["items"][number]): IssueStatus {
if (pr.pull_request?.merged_at) {
return IssueStatus.Done;
} else if (pr.closed_at) {
return IssueStatus.Closed;
} else {
return IssueStatus.Open;
}
}
export function getPRStatus(pr: PullResponse): IssueStatus {
if (pr.merged) {
return IssueStatus.Done;
} else if (pr.closed_at) {
return IssueStatus.Closed;
} else {
return IssueStatus.Open;
}
}

View file

@ -12,6 +12,12 @@ export interface ParsedUrl {
commit?: string;
}
const apiRegex = /(https:\/\/)?api\.github\.com\/repos\//;
export function repoAPIToBrowserUrl(urlString: string): string {
return urlString.replace(apiRegex, "https://github.com/");
}
export function parseUrl(urlString: string): ParsedUrl {
// Some potential URLs:
// https://github.com/nathonius/alloy-theme

11
src/icon.ts Normal file
View file

@ -0,0 +1,11 @@
import { setIcon } from "obsidian";
import { IssueStatus } from "./github/response";
export function setPRIcon(icon: HTMLElement, status: IssueStatus) {
if (status !== IssueStatus.Closed) {
setIcon(icon, "git-pull-request-arrow");
} else {
setIcon(icon, "git-pull-request-closed");
}
icon.dataset.status = status;
}

View file

@ -1,20 +1,24 @@
import { getPRStatus } from "src/github/response";
import { getIssue, getPullRequest } from "../github/github";
import { parseUrl } from "../github/url-parse";
import { setIcon } from "obsidian";
import { setPRIcon } from "src/icon";
export async function createTag(href: string) {
const parsedUrl = parseUrl(href);
const container = createEl("a", { cls: "gh-link-inline-tag", href });
const container = createEl("a", { cls: "github-link-inline", href });
// Add icon
const icon = createTagSection(container).createSpan({ cls: "gh-link-inline-tag-icon" });
const icon = createTagSection(container).createSpan({
cls: ["github-link-status-icon", "github-link-inline-icon"],
});
setIcon(icon, "github");
// Add repo
if (parsedUrl.repo) {
createTagSection(container).createSpan({
cls: "gh-link-inline-tag-repo",
cls: "github-link-inline-repo",
text: parsedUrl.repo,
});
}
@ -22,7 +26,7 @@ export async function createTag(href: string) {
// fall back to org if no repo found
else if (parsedUrl.org) {
createTagSection(container).createSpan({
cls: "gh-link-inline-tag-org",
cls: "github-link-inline-org",
text: parsedUrl.org,
});
}
@ -39,7 +43,7 @@ export async function createTag(href: string) {
icon.dataset.status = issue.state;
}
createTagSection(container).createSpan({
cls: "gh-link-inline-tag-issue-title",
cls: "github-link-inline-issue-title",
text: issue.title,
});
}
@ -49,14 +53,10 @@ export async function createTag(href: string) {
if (parsedUrl.pr !== undefined) {
const pull = await getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr);
if (pull.title) {
setIcon(icon, "git-pull-request-arrow");
if (pull.merged) {
icon.dataset.status = "done";
} else {
icon.dataset.status = pull.state;
}
const status = getPRStatus(pull);
setPRIcon(icon, status);
createTagSection(container).createSpan({
cls: "gh-link-inline-tag-pr-title",
cls: "github-link-inline-pr-title",
text: pull.title,
});
}
@ -67,7 +67,7 @@ export async function createTag(href: string) {
}
function createTagSection(parent: HTMLElement): HTMLElement {
return parent.createDiv({ cls: "gh-link-inline-tag-section" });
return parent.createDiv({ cls: "github-link-inline-section" });
}
export async function InlineRenderer(el: HTMLElement) {

48
src/query/column/base.ts Normal file
View file

@ -0,0 +1,48 @@
import type { SearchIssueResponse } from "src/github/response";
import { DateFormat } from "src/util";
export interface ColumnGetter<T> {
header: string;
cell: (row: T, el: HTMLTableCellElement) => void | Promise<void>;
}
export type ColumnsMap<T> = Record<string, ColumnGetter<T>>;
export function DateCell(value: string | undefined | null, el: HTMLTableCellElement) {
el.classList.add("github-link-table-date");
if (!value) {
return;
}
const asDate = new Date(value);
if (isNaN(asDate.valueOf())) {
el.setText(value);
return;
}
// TODO: Allow formatting this date via options.
el.setText(DateFormat.DATE_SHORT.format(asDate));
}
/**
* Issue and PR columns share types, so some columns are shared
*/
export const CommonIssuePRColumns: ColumnsMap<SearchIssueResponse["items"][number]> = {
created: {
header: "Created",
cell: (row, el) => {
DateCell(row.created_at, el);
},
},
updated: {
header: "Updated",
cell: (row, el) => {
DateCell(row.updated_at, el);
},
},
closed: {
header: "Closed",
cell: (row, el) => {
DateCell(row.closed_at, el);
},
},
};

View file

@ -0,0 +1,4 @@
import type { SearchIssueResponse } from "src/github/response";
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
export const IssueColumns: ColumnsMap<SearchIssueResponse["items"][number]> = { ...CommonIssuePRColumns };

View file

@ -0,0 +1,45 @@
import { getSearchResultPRStatus, IssueStatus, type SearchIssueResponse } from "src/github/response";
import { parseUrl, repoAPIToBrowserUrl } from "src/github/url-parse";
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
import { setPRIcon } from "src/icon";
import { titleCase } from "src/util";
export const PullRequestColumns: ColumnsMap<SearchIssueResponse["items"][number]> = {
...CommonIssuePRColumns,
number: {
header: "PR",
cell: (row, el) => {
el.classList.add("github-link-table-pr");
el.createEl("a", { text: `#${row.number}`, href: row.html_url });
},
},
repo: {
header: "Repo",
cell: (row, el) => {
el.classList.add("github-link-table-repo");
const url = repoAPIToBrowserUrl(row.repository_url);
const parsed = parseUrl(url);
el.createEl("a", { text: parsed.repo, href: url });
},
},
author: {
header: "Author",
cell: (row, el) => {
const anchor = el.createEl("a", { cls: "github-link-table-author" });
if (row.user?.avatar_url) {
anchor.createEl("img", { cls: "github-link-table-avatar", attr: { src: row.user.avatar_url } });
}
anchor.createSpan({ text: row.user?.login });
},
},
status: {
header: "Status",
cell: (row, el) => {
const wrapper = el.createDiv({ cls: "github-link-table-status" });
const status = getSearchResultPRStatus(row);
const icon = wrapper.createSpan({ cls: "github-link-status-icon" });
setPRIcon(icon, status);
wrapper.createSpan({ text: status === IssueStatus.Done ? "Merged" : titleCase(status) });
},
},
};

4
src/query/column/repo.ts Normal file
View file

@ -0,0 +1,4 @@
import type { SearchRepoResponse } from "src/github/response";
import type { ColumnsMap } from "./base";
export const RepoColumns: ColumnsMap<SearchRepoResponse["items"][number]> = {};

View file

@ -1,28 +1,45 @@
import type { SearchIssueResponse, SearchRepoResponse } from "src/github/response";
import { getProp, titleCase } from "src/util";
import type { TableParams } from "./params";
import { QueryType, type TableParams } from "./params";
import { PullRequestColumns } from "./column/pull-request";
import { IssueColumns } from "./column/issue";
import { RepoColumns } from "./column/repo";
export function renderTable<T extends SearchIssueResponse | SearchRepoResponse>(
const columns = {
[QueryType.PullRequest]: PullRequestColumns,
[QueryType.Issue]: IssueColumns,
[QueryType.Repo]: RepoColumns,
};
export async function renderTable<T extends SearchIssueResponse | SearchRepoResponse>(
params: TableParams,
result: T,
el: HTMLElement,
) {
const table = el.createEl("table", { cls: "github-link-query-table" });
const table = el.createEl("table", { cls: "github-link-table" });
const thead = table.createEl("thead");
for (const col of params.columns) {
thead.createEl("th", { text: titleCase(col) });
const th = thead.createEl("th");
// Get predefined header if available
th.setText(columns[params.queryType][col]?.header ?? titleCase(col));
}
const tbody = table.createEl("tbody");
for (const row of result.items) {
const tr = tbody.createEl("tr");
for (const col of params.columns) {
const cell = tr.createEl("td");
const cellVal = getProp(row, col);
if (cellVal !== null) {
cell.setText(typeof cellVal === "string" ? cellVal : JSON.stringify(cellVal));
const renderer = columns[params.queryType][col];
if (renderer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderer.cell(row as any, cell);
} else {
cell.setText("");
const cellVal = getProp(row, col);
if (cellVal !== null) {
cell.setText(typeof cellVal === "string" ? cellVal : JSON.stringify(cellVal));
} else {
cell.setText("");
}
}
}
}

View file

@ -49,3 +49,18 @@ export function safeJSONParse<T>(value: string, props: Record<keyof T, boolean>)
return result as T;
}
// Reference for more formats: https://github.com/moment/luxon/blob/master/src/impl/formats.js
const n = "numeric";
// eslint-disable-next-line unused-imports/no-unused-vars
const s = "short";
// eslint-disable-next-line unused-imports/no-unused-vars
const l = "long";
export const DateFormat = {
DATE_SHORT: new Intl.DateTimeFormat(undefined, {
year: n,
month: n,
day: n,
}),
};

View file

@ -146,7 +146,7 @@ body.theme-dark {
font-size: 3em;
}
.gh-link-inline-tag {
.github-link-inline {
display: inline-flex;
background-color: var(--gh-color-canvas-default);
border-radius: 4px;
@ -158,12 +158,12 @@ body.theme-dark {
font-size: 0.75em;
}
.gh-link-inline-tag:hover {
.github-link-inline:hover {
color: var(--gh-color-fg-default);
text-decoration: none;
}
.gh-link-inline-tag-section {
.github-link-inline-section {
display: flex;
align-items: center;
justify-content: center;
@ -171,55 +171,87 @@ body.theme-dark {
overflow: hidden;
}
.gh-link-inline-tag-icon {
display: inline-flex;
.github-link-inline-icon {
padding: 2px 0;
}
.gh-link-inline-tag-icon > svg {
.github-link-inline-icon > svg {
width: var(--icon-s);
height: var(--icon-s);
stroke-width: var(--icon-s-stroke-width);
}
.gh-link-inline-tag-section:has(> .gh-link-inline-tag-icon) {
.github-link-inline-section:has(> .github-link-inline-icon) {
background-color: var(--gh-color-canvas-inset);
}
.gh-link-inline-tag-section:has(> .gh-link-inline-tag-icon[data-status="open"]) {
background-color: var(--gh-color-open-subtle);
.github-link-status-icon {
display: inline-flex;
}
.github-link-status-icon[data-status="open"] {
color: var(--gh-color-open-fg);
}
.github-link-status-icon[data-status="closed"] {
color: var(--gh-color-closed-fg);
}
.github-link-status-icon[data-status="done"] {
color: var(--gh-color-done-fg);
}
.github-link-inline-section:has(> .github-link-inline-icon[data-status="open"]) {
background-color: var(--gh-color-open-subtle);
border-color: var(--gh-color-open-emphasis);
}
.gh-link-inline-tag-section:has(> .gh-link-inline-tag-icon[data-status="closed"]) {
.github-link-inline-section:has(> .github-link-inline-icon[data-status="closed"]) {
background-color: var(--gh-color-closed-subtle);
color: var(--gh-color-closed-fg);
border-color: var(--gh-color-closed-emphasis);
}
.gh-link-inline-tag-section:has(> .gh-link-inline-tag-icon[data-status="done"]) {
.github-link-inline-section:has(> .github-link-inline-icon[data-status="done"]) {
background-color: var(--gh-color-done-subtle);
color: var(--gh-color-done-fg);
border-color: var(--gh-color-done-emphasis);
}
.gh-link-inline-tag-section:has(> .gh-link-inline-tag-repo) {
.github-link-inline-section:has(> .github-link-inline-repo) {
background-color: var(--gh-color-accent-subtle);
color: var(--gh-color-accent-fg);
border-color: var(--gh-color-accent-emphasis);
flex-shrink: 0;
}
.gh-link-inline-tag-repo {
.github-link-inline-repo {
text-wrap: nowrap;
line-height: var(--line-height-normal);
}
.gh-link-inline-tag-pr-title,
.gh-link-inline-tag-issue-title {
.github-link-inline-pr-title,
.github-link-inline-issue-title {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
line-height: var(--line-height-normal);
}
.github-link-table th {
font-weight: var(--font-bold);
}
.github-link-table a {
text-decoration: none;
}
.github-link-table a:hover {
text-decoration: underline;
}
.github-link-table-avatar {
width: max(24px, var(--icon-l));
height: max(24px, var(--icon-l));
}
.github-link-table-status,
.github-link-table-author {
display: flex;
gap: 6px;
align-items: center;
}