mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Merge pull request #3356 from cryppadotta/pap-1331-inbox-ux
feat: polish inbox and issue list workflows
This commit is contained in:
commit
45ebecab5a
36 changed files with 1695 additions and 390 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
filterInboxIssues,
|
||||
getArchivedInboxSearchIssues,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
|
|
@ -24,13 +25,16 @@ import {
|
|||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxFilterPreferences,
|
||||
loadInboxIssueColumns,
|
||||
loadLastInboxTab,
|
||||
matchesInboxIssueSearch,
|
||||
normalizeInboxIssueColumns,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxFilterPreferences,
|
||||
saveInboxIssueColumns,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
|
|
@ -548,6 +552,65 @@ describe("inbox helpers", () => {
|
|||
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("matches workspace names when inbox issue search includes workspace labels", () => {
|
||||
const issue = makeIssue("workspace", false);
|
||||
issue.projectId = "project-1";
|
||||
issue.projectWorkspaceId = "project-workspace-1";
|
||||
issue.executionWorkspaceId = "execution-workspace-1";
|
||||
|
||||
expect(matchesInboxIssueSearch(
|
||||
issue,
|
||||
"feature",
|
||||
{
|
||||
isolatedWorkspacesEnabled: true,
|
||||
executionWorkspaceById: new Map([
|
||||
["execution-workspace-1", { name: "Feature Branch", mode: "isolated_workspace" as const, projectWorkspaceId: "project-workspace-1" }],
|
||||
]),
|
||||
projectWorkspaceById: new Map([
|
||||
["project-workspace-1", { name: "Primary workspace" }],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-2"]]),
|
||||
},
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns archived search matches that are not already visible in the inbox", () => {
|
||||
const visibleIssue = makeIssue("visible", false);
|
||||
visibleIssue.title = "Alpha visible task";
|
||||
|
||||
const archivedMatch = makeIssue("archived-match", false);
|
||||
archivedMatch.title = "Alpha archived task";
|
||||
|
||||
const archivedMiss = makeIssue("archived-miss", false);
|
||||
archivedMiss.title = "Different task";
|
||||
|
||||
expect(
|
||||
getArchivedInboxSearchIssues({
|
||||
visibleIssues: [visibleIssue],
|
||||
searchableIssues: [visibleIssue, archivedMatch, archivedMiss],
|
||||
query: "alpha",
|
||||
}).map((issue) => issue.id),
|
||||
).toEqual(["archived-match"]);
|
||||
});
|
||||
|
||||
it("sorts archived search matches by most recent activity", () => {
|
||||
const older = makeIssue("older", false);
|
||||
older.title = "Alpha older";
|
||||
older.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
|
||||
const newer = makeIssue("newer", false);
|
||||
newer.title = "Alpha newer";
|
||||
newer.lastActivityAt = new Date("2026-03-11T03:00:00.000Z");
|
||||
|
||||
expect(
|
||||
getArchivedInboxSearchIssues({
|
||||
visibleIssues: [],
|
||||
searchableIssues: [older, newer],
|
||||
query: "alpha",
|
||||
}).map((issue) => issue.id),
|
||||
).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("defaults the remembered inbox tab to mine and persists all", () => {
|
||||
localStorage.clear();
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
|
|
@ -556,6 +619,92 @@ describe("inbox helpers", () => {
|
|||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("persists inbox filters per company", () => {
|
||||
saveInboxFilterPreferences("company-1", {
|
||||
allCategoryFilter: "approvals",
|
||||
allApprovalFilter: "resolved",
|
||||
issueFilters: {
|
||||
statuses: ["todo"],
|
||||
priorities: ["high"],
|
||||
assignees: ["agent-1"],
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
saveInboxFilterPreferences("company-2", {
|
||||
allCategoryFilter: "failed_runs",
|
||||
allApprovalFilter: "actionable",
|
||||
issueFilters: {
|
||||
statuses: ["done"],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadInboxFilterPreferences("company-1")).toEqual({
|
||||
allCategoryFilter: "approvals",
|
||||
allApprovalFilter: "resolved",
|
||||
issueFilters: {
|
||||
statuses: ["todo"],
|
||||
priorities: ["high"],
|
||||
assignees: ["agent-1"],
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
expect(loadInboxFilterPreferences("company-2")).toEqual({
|
||||
allCategoryFilter: "failed_runs",
|
||||
allApprovalFilter: "actionable",
|
||||
issueFilters: {
|
||||
statuses: ["done"],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid inbox filter storage back to safe defaults", () => {
|
||||
localStorage.setItem("paperclip:inbox:filters:company-1", JSON.stringify({
|
||||
allCategoryFilter: "bogus",
|
||||
allApprovalFilter: "bogus",
|
||||
issueFilters: {
|
||||
statuses: ["todo", 123],
|
||||
priorities: "high",
|
||||
assignees: ["agent-1"],
|
||||
labels: null,
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1", false],
|
||||
showRoutineExecutions: "yes",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(loadInboxFilterPreferences("company-1")).toEqual({
|
||||
allCategoryFilter: "everything",
|
||||
allApprovalFilter: "all",
|
||||
issueFilters: {
|
||||
statuses: ["todo"],
|
||||
priorities: [],
|
||||
assignees: ["agent-1"],
|
||||
labels: [],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps nesting enabled on desktop when the saved preference is on", () => {
|
||||
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import type {
|
|||
Issue,
|
||||
JoinRequest,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "./issue-filters";
|
||||
|
||||
export const RECENT_ISSUES_LIMIT = 100;
|
||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
|
|
@ -16,12 +20,34 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
|||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
|
||||
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
|
||||
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "issues_i_touched"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export type InboxWorkItemGroupBy = "none" | "type";
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||
export const inboxIssueColumns = [
|
||||
"status",
|
||||
"id",
|
||||
"assignee",
|
||||
"project",
|
||||
"workspace",
|
||||
"parent",
|
||||
"labels",
|
||||
"updated",
|
||||
] as const;
|
||||
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||
export interface InboxFilterPreferences {
|
||||
allCategoryFilter: InboxCategoryFilter;
|
||||
allApprovalFilter: InboxApprovalFilter;
|
||||
issueFilters: IssueFilterState;
|
||||
}
|
||||
export type InboxWorkItem =
|
||||
| {
|
||||
kind: "issue";
|
||||
|
|
@ -59,6 +85,104 @@ export interface InboxWorkItemGroup {
|
|||
items: InboxWorkItem[];
|
||||
}
|
||||
|
||||
const defaultInboxFilterPreferences: InboxFilterPreferences = {
|
||||
allCategoryFilter: "everything",
|
||||
allApprovalFilter: "all",
|
||||
issueFilters: defaultIssueFilterState,
|
||||
};
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
||||
if (!value || typeof value !== "object") return { ...defaultIssueFilterState };
|
||||
const candidate = value as Partial<Record<keyof IssueFilterState, unknown>>;
|
||||
return {
|
||||
statuses: normalizeStringArray(candidate.statuses),
|
||||
priorities: normalizeStringArray(candidate.priorities),
|
||||
assignees: normalizeStringArray(candidate.assignees),
|
||||
labels: normalizeStringArray(candidate.labels),
|
||||
projects: normalizeStringArray(candidate.projects),
|
||||
workspaces: normalizeStringArray(candidate.workspaces),
|
||||
showRoutineExecutions: candidate.showRoutineExecutions === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter {
|
||||
return value === "issues_i_touched"
|
||||
|| value === "join_requests"
|
||||
|| value === "approvals"
|
||||
|| value === "failed_runs"
|
||||
|| value === "alerts"
|
||||
? value
|
||||
: "everything";
|
||||
}
|
||||
|
||||
function normalizeInboxApprovalFilter(value: unknown): InboxApprovalFilter {
|
||||
return value === "actionable" || value === "resolved" ? value : "all";
|
||||
}
|
||||
|
||||
function getInboxFilterPreferencesStorageKey(companyId: string | null | undefined): string | null {
|
||||
if (!companyId) return null;
|
||||
return `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}`;
|
||||
}
|
||||
|
||||
export function loadInboxFilterPreferences(
|
||||
companyId: string | null | undefined,
|
||||
): InboxFilterPreferences {
|
||||
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
|
||||
if (!storageKey) {
|
||||
return {
|
||||
...defaultInboxFilterPreferences,
|
||||
issueFilters: { ...defaultIssueFilterState },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return {
|
||||
...defaultInboxFilterPreferences,
|
||||
issueFilters: { ...defaultIssueFilterState },
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter),
|
||||
allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter),
|
||||
issueFilters: normalizeIssueFilterState(parsed.issueFilters),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...defaultInboxFilterPreferences,
|
||||
issueFilters: { ...defaultIssueFilterState },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInboxFilterPreferences(
|
||||
companyId: string | null | undefined,
|
||||
preferences: InboxFilterPreferences,
|
||||
) {
|
||||
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter),
|
||||
allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter),
|
||||
issueFilters: normalizeIssueFilterState(preferences.issueFilters),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDismissedInboxAlerts(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
|
|
@ -174,6 +298,78 @@ export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolea
|
|||
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
|
||||
}
|
||||
|
||||
export function matchesInboxIssueSearch(
|
||||
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
query: string,
|
||||
{
|
||||
isolatedWorkspacesEnabled = false,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}: {
|
||||
isolatedWorkspacesEnabled?: boolean;
|
||||
executionWorkspaceById?: ReadonlyMap<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>;
|
||||
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
} = {},
|
||||
): boolean {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return true;
|
||||
if (issue.title.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (issue.identifier?.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (issue.description?.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (!isolatedWorkspacesEnabled) return false;
|
||||
|
||||
const workspaceName = resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
return workspaceName?.toLowerCase().includes(normalizedQuery) ?? false;
|
||||
}
|
||||
|
||||
export function getArchivedInboxSearchIssues({
|
||||
visibleIssues,
|
||||
searchableIssues,
|
||||
query,
|
||||
isolatedWorkspacesEnabled = false,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}: {
|
||||
visibleIssues: Issue[];
|
||||
searchableIssues: Issue[];
|
||||
query: string;
|
||||
isolatedWorkspacesEnabled?: boolean;
|
||||
executionWorkspaceById?: ReadonlyMap<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>;
|
||||
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
}): Issue[] {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
const visibleIssueIds = new Set(visibleIssues.map((issue) => issue.id));
|
||||
return searchableIssues
|
||||
.filter((issue) => !visibleIssueIds.has(issue.id))
|
||||
.filter((issue) =>
|
||||
matchesInboxIssueSearch(issue, normalizedQuery, {
|
||||
isolatedWorkspacesEnabled,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}),
|
||||
)
|
||||
.sort(sortIssuesByMostRecentActivity);
|
||||
}
|
||||
|
||||
export function resolveIssueWorkspaceName(
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type IssueFilterState = {
|
|||
assignees: string[];
|
||||
labels: string[];
|
||||
projects: string[];
|
||||
workspaces: string[];
|
||||
showRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ export const defaultIssueFilterState: IssueFilterState = {
|
|||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
};
|
||||
|
||||
|
|
@ -43,6 +45,12 @@ export function toggleIssueFilterValue(values: string[], value: string): string[
|
|||
return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value];
|
||||
}
|
||||
|
||||
export function resolveIssueFilterWorkspaceId(
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectWorkspaceId">,
|
||||
): string | null {
|
||||
return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null;
|
||||
}
|
||||
|
||||
export function applyIssueFilters(
|
||||
issues: Issue[],
|
||||
state: IssueFilterState,
|
||||
|
|
@ -71,6 +79,12 @@ export function applyIssueFilters(
|
|||
if (state.projects.length > 0) {
|
||||
result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId));
|
||||
}
|
||||
if (state.workspaces.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
const workspaceId = resolveIssueFilterWorkspaceId(issue);
|
||||
return workspaceId != null && state.workspaces.includes(workspaceId);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +98,7 @@ export function countActiveIssueFilters(
|
|||
if (state.assignees.length > 0) count += 1;
|
||||
if (state.labels.length > 0) count += 1;
|
||||
if (state.projects.length > 0) count += 1;
|
||||
if (state.workspaces.length > 0) count += 1;
|
||||
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
findPageSearchShortcutTarget,
|
||||
focusPageSearchShortcutTarget,
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
resolveIssueDetailGoKeyAction,
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "./keyboardShortcuts";
|
||||
|
||||
describe("keyboardShortcuts helpers", () => {
|
||||
|
|
@ -40,6 +44,72 @@ describe("keyboardShortcuts helpers", () => {
|
|||
|
||||
expect(hasBlockingShortcutDialog(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the visible page search shortcut target", () => {
|
||||
const root = document.createElement("div");
|
||||
const hidden = document.createElement("input");
|
||||
hidden.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(hidden, "getClientRects").mockReturnValue([] as unknown as DOMRectList);
|
||||
|
||||
const visible = document.createElement("input");
|
||||
visible.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(visible, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
||||
|
||||
root.append(hidden, visible);
|
||||
document.body.appendChild(root);
|
||||
|
||||
expect(findPageSearchShortcutTarget(root)).toBe(visible);
|
||||
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it("focuses and selects the page search shortcut target", () => {
|
||||
const root = document.createElement("div");
|
||||
const input = document.createElement("input");
|
||||
input.value = "existing query";
|
||||
input.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(input, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
||||
root.appendChild(input);
|
||||
document.body.appendChild(root);
|
||||
|
||||
expect(focusPageSearchShortcutTarget(root)).toBe(true);
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe(input.value.length);
|
||||
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it("blurs page search on a plain Enter press", () => {
|
||||
expect(shouldBlurPageSearchOnEnter({
|
||||
key: "Enter",
|
||||
isComposing: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps focus while composing with an IME", () => {
|
||||
expect(shouldBlurPageSearchOnEnter({
|
||||
key: "Enter",
|
||||
isComposing: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("blurs page search on Escape when the field is already empty", () => {
|
||||
expect(shouldBlurPageSearchOnEscape({
|
||||
key: "Escape",
|
||||
isComposing: false,
|
||||
currentValue: "",
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps focus on the first Escape while the field still has text", () => {
|
||||
expect(shouldBlurPageSearchOnEscape({
|
||||
key: "Escape",
|
||||
isComposing: false,
|
||||
currentValue: "query",
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("archives only the first clean y press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
|||
"[role='combobox']",
|
||||
].join(", ");
|
||||
|
||||
const PAGE_SEARCH_SHORTCUT_SELECTOR = "[data-page-search-target='true']";
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
|
|
@ -23,6 +24,56 @@ export function hasBlockingShortcutDialog(root: ParentNode = document): boolean
|
|||
return !!root.querySelector("[role='dialog'][aria-modal='true']");
|
||||
}
|
||||
|
||||
function isVisibleShortcutTarget(element: HTMLElement): boolean {
|
||||
if (!element.isConnected) return false;
|
||||
if ("disabled" in element && typeof element.disabled === "boolean" && element.disabled) return false;
|
||||
if (element.closest("[hidden], [aria-hidden='true'], [inert]")) return false;
|
||||
if (element.closest("[role='dialog'][aria-modal='true']")) return false;
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
|
||||
return element.getClientRects().length > 0 || element === document.activeElement;
|
||||
}
|
||||
|
||||
export function findPageSearchShortcutTarget(root: ParentNode = document): HTMLElement | null {
|
||||
const candidates = Array.from(root.querySelectorAll<HTMLElement>(PAGE_SEARCH_SHORTCUT_SELECTOR));
|
||||
return candidates.find((candidate) => isVisibleShortcutTarget(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export function focusPageSearchShortcutTarget(root: ParentNode = document): boolean {
|
||||
const target = findPageSearchShortcutTarget(root);
|
||||
if (!target) return false;
|
||||
|
||||
target.focus();
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
target.select();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldBlurPageSearchOnEnter({
|
||||
key,
|
||||
isComposing,
|
||||
}: {
|
||||
key: string;
|
||||
isComposing: boolean;
|
||||
}): boolean {
|
||||
return key === "Enter" && !isComposing;
|
||||
}
|
||||
|
||||
export function shouldBlurPageSearchOnEscape({
|
||||
key,
|
||||
isComposing,
|
||||
currentValue,
|
||||
}: {
|
||||
key: string;
|
||||
isComposing: boolean;
|
||||
currentValue: string;
|
||||
}): boolean {
|
||||
return key === "Escape" && !isComposing && currentValue.length === 0;
|
||||
}
|
||||
|
||||
export function isModifierOnlyKey(key: string): boolean {
|
||||
return MODIFIER_ONLY_KEYS.has(key);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,19 @@ import * as React from "react";
|
|||
import * as RouterDom from "react-router-dom";
|
||||
import type { NavigateOptions, To } from "react-router-dom";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
|
||||
import {
|
||||
applyCompanyPrefix,
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
} from "@/lib/company-routes";
|
||||
|
||||
function parseIssuePathIdFromPath(pathname: string | null | undefined): string | null {
|
||||
if (!pathname) return null;
|
||||
const match = pathname.match(/(?:^|\/)issues\/([^/?#]+)/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function resolveTo(to: To, companyPrefix: string | null): To {
|
||||
if (typeof to === "string") {
|
||||
return applyCompanyPrefix(to, companyPrefix);
|
||||
|
|
@ -40,10 +47,23 @@ function useActiveCompanyPrefix(): string | null {
|
|||
|
||||
export * from "react-router-dom";
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
|
||||
function CompanyLink({ to, ...props }, ref) {
|
||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||
disableIssueQuicklook?: boolean;
|
||||
};
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
||||
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
|
||||
const companyPrefix = useActiveCompanyPrefix();
|
||||
return <RouterDom.Link ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
|
||||
const resolvedTo = resolveTo(to, companyPrefix);
|
||||
const issuePathId = disableIssueQuicklook
|
||||
? null
|
||||
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||
|
||||
if (issuePathId) {
|
||||
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
|
||||
}
|
||||
|
||||
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue