@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +42 -21
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
- package/dist/index.css +269 -5
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
- package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
- package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
- package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
- package/src/index.css +269 -5
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
- package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
- package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
- package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
- /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useTRPC } from "../../../../api/infra/trpc.js";
|
|
5
|
+
import { Button } from "../../../../ui/button.js";
|
|
6
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../../../ui/dialog.js";
|
|
7
|
+
import { cn } from "../../../../lib/utils.js";
|
|
8
|
+
const FIELD_CLASSES = "placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
|
|
9
|
+
const MAX_SUBJECT = 120;
|
|
10
|
+
function RequestAppDialog(props) {
|
|
11
|
+
if (!props.open) return null;
|
|
12
|
+
return /* @__PURE__ */ jsx(RequestAppDialogBody, { ...props });
|
|
13
|
+
}
|
|
14
|
+
function RequestAppDialogBody({
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
initialSubject = ""
|
|
18
|
+
}) {
|
|
19
|
+
const trpc = useTRPC();
|
|
20
|
+
const queryClient = useQueryClient();
|
|
21
|
+
const [subject, setSubject] = useState(initialSubject);
|
|
22
|
+
const [body, setBody] = useState("");
|
|
23
|
+
const [sent, setSent] = useState(false);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (open) {
|
|
26
|
+
setSubject(initialSubject);
|
|
27
|
+
setBody("");
|
|
28
|
+
setSent(false);
|
|
29
|
+
}
|
|
30
|
+
}, [open, initialSubject]);
|
|
31
|
+
const add = useMutation(
|
|
32
|
+
trpc.feedback.add.mutationOptions({
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
setSent(true);
|
|
35
|
+
void queryClient.invalidateQueries({
|
|
36
|
+
queryKey: trpc.feedback.mine.queryKey()
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
const trimmed = subject.trim();
|
|
42
|
+
const send = () => {
|
|
43
|
+
if (!trimmed) return;
|
|
44
|
+
add.mutate({
|
|
45
|
+
subject: trimmed.slice(0, MAX_SUBJECT),
|
|
46
|
+
...body.trim() ? { body: body.trim() } : {}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-[480px]", children: [
|
|
50
|
+
/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: sent ? "Thanks — that is logged" : "Ask for an app to be added" }) }),
|
|
51
|
+
sent ? /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
52
|
+
/* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: "A maintainer will look at it. You can see what happened to it under your own requests." }),
|
|
53
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", onClick: () => onOpenChange(false), children: "Close" })
|
|
54
|
+
] }) : /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
55
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
|
|
56
|
+
/* @__PURE__ */ jsx("label", { htmlFor: "request-app-name", className: "text-sm font-medium", children: "What are you looking for?" }),
|
|
57
|
+
/* @__PURE__ */ jsx(
|
|
58
|
+
"input",
|
|
59
|
+
{
|
|
60
|
+
id: "request-app-name",
|
|
61
|
+
autoFocus: true,
|
|
62
|
+
className: cn(FIELD_CLASSES),
|
|
63
|
+
placeholder: "Name of the tool, or what it does",
|
|
64
|
+
maxLength: MAX_SUBJECT,
|
|
65
|
+
value: subject,
|
|
66
|
+
onChange: (event) => setSubject(event.target.value)
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
] }),
|
|
70
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
|
|
71
|
+
/* @__PURE__ */ jsxs(
|
|
72
|
+
"label",
|
|
73
|
+
{
|
|
74
|
+
htmlFor: "request-app-detail",
|
|
75
|
+
className: "text-sm font-medium",
|
|
76
|
+
children: [
|
|
77
|
+
"Anything that would help us find it",
|
|
78
|
+
" ",
|
|
79
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground font-normal", children: "(optional)" })
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
/* @__PURE__ */ jsx(
|
|
84
|
+
"textarea",
|
|
85
|
+
{
|
|
86
|
+
id: "request-app-detail",
|
|
87
|
+
rows: 3,
|
|
88
|
+
className: cn(FIELD_CLASSES),
|
|
89
|
+
placeholder: "A link, the team that owns it, what you needed it for",
|
|
90
|
+
value: body,
|
|
91
|
+
onChange: (event) => setBody(event.target.value)
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
] }),
|
|
95
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [
|
|
96
|
+
/* @__PURE__ */ jsx(
|
|
97
|
+
Button,
|
|
98
|
+
{
|
|
99
|
+
type: "button",
|
|
100
|
+
size: "sm",
|
|
101
|
+
disabled: !trimmed || add.isPending,
|
|
102
|
+
onClick: send,
|
|
103
|
+
children: add.isPending ? "Sending…" : "Send request"
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Sent under a nickname. No login needed." })
|
|
107
|
+
] }),
|
|
108
|
+
add.error && /* @__PURE__ */ jsx("p", { className: "text-destructive text-xs", children: add.error instanceof Error ? add.error.message : "Something went wrong" })
|
|
109
|
+
] })
|
|
110
|
+
] }) });
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
RequestAppDialog
|
|
114
|
+
};
|
|
115
|
+
//# sourceMappingURL=RequestAppDialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RequestAppDialog.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx"],"sourcesContent":["import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { useEffect, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/ui/dialog'\nimport { cn } from '~/lib/utils'\n\n/**\n * Asking for something that is not in the catalog yet.\n *\n * The search box is where people discover the gap — they type a tool's name, get\n * nothing, and until now had nowhere to go. A zero-result search is the single most\n * informative moment in the catalog: someone has told us exactly what they expected to\n * find. Throwing that away and showing only \"No results\" wastes it.\n *\n * This is a catalog-level ask rather than feedback on an entry: `feedback.add` with a\n * `subject` and NO `resourceSlug`. The server already distinguishes the two that way,\n * and the tracker lists both.\n */\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nconst MAX_SUBJECT = 120\n\ninterface RequestAppDialogProps {\n open: boolean\n onOpenChange: (open: boolean) => void\n /** What they searched for. Pre-fills the name, because they just typed it. */\n initialSubject?: string\n}\n\n/**\n * Mounts nothing until it is opened, which is what keeps `useTRPC()` out of the\n * closed path. The catalog grid is rendered bare in several tests with no\n * TRPCProvider, and a hook cannot be called conditionally — so the gate has to be a\n * component boundary rather than an `if` inside one.\n */\nexport function RequestAppDialog(props: RequestAppDialogProps) {\n if (!props.open) return null\n return <RequestAppDialogBody {...props} />\n}\n\nfunction RequestAppDialogBody({\n open,\n onOpenChange,\n initialSubject = '',\n}: RequestAppDialogProps) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const [subject, setSubject] = useState(initialSubject)\n const [body, setBody] = useState('')\n const [sent, setSent] = useState(false)\n\n // Re-seed when opened for a different search. Keyed on the dialog opening rather\n // than on every keystroke, so it never overwrites something half-typed.\n useEffect(() => {\n if (open) {\n setSubject(initialSubject)\n setBody('')\n setSent(false)\n }\n }, [open, initialSubject])\n\n const add = useMutation(\n trpc.feedback.add.mutationOptions({\n onSuccess: () => {\n setSent(true)\n // The tracker counts this, so its badge has to notice.\n void queryClient.invalidateQueries({\n queryKey: trpc.feedback.mine.queryKey(),\n })\n },\n }),\n )\n\n const trimmed = subject.trim()\n const send = () => {\n if (!trimmed) return\n add.mutate({\n subject: trimmed.slice(0, MAX_SUBJECT),\n ...(body.trim() ? { body: body.trim() } : {}),\n })\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent className=\"sm:max-w-[480px]\">\n <DialogHeader>\n <DialogTitle>\n {sent ? 'Thanks — that is logged' : 'Ask for an app to be added'}\n </DialogTitle>\n </DialogHeader>\n\n {sent ? (\n <div className=\"space-y-3\">\n <p className=\"text-muted-foreground text-sm\">\n A maintainer will look at it. You can see what happened to it\n under your own requests.\n </p>\n <Button type=\"button\" size=\"sm\" onClick={() => onOpenChange(false)}>\n Close\n </Button>\n </div>\n ) : (\n <div className=\"space-y-3\">\n <div className=\"space-y-1.5\">\n <label htmlFor=\"request-app-name\" className=\"text-sm font-medium\">\n What are you looking for?\n </label>\n <input\n id=\"request-app-name\"\n autoFocus\n className={cn(FIELD_CLASSES)}\n placeholder=\"Name of the tool, or what it does\"\n maxLength={MAX_SUBJECT}\n value={subject}\n onChange={(event) => setSubject(event.target.value)}\n />\n </div>\n\n <div className=\"space-y-1.5\">\n <label\n htmlFor=\"request-app-detail\"\n className=\"text-sm font-medium\"\n >\n Anything that would help us find it{' '}\n <span className=\"text-muted-foreground font-normal\">\n (optional)\n </span>\n </label>\n <textarea\n id=\"request-app-detail\"\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"A link, the team that owns it, what you needed it for\"\n value={body}\n onChange={(event) => setBody(event.target.value)}\n />\n </div>\n\n <div className=\"flex flex-wrap items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!trimmed || add.isPending}\n onClick={send}\n >\n {add.isPending ? 'Sending…' : 'Send request'}\n </Button>\n <span className=\"text-muted-foreground text-xs\">\n Sent under a nickname. No login needed.\n </span>\n </div>\n\n {add.error && (\n <p className=\"text-destructive text-xs\">\n {add.error instanceof Error\n ? add.error.message\n : 'Something went wrong'}\n </p>\n )}\n </div>\n )}\n </DialogContent>\n </Dialog>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -13,6 +13,7 @@ function AccessPrerequisiteChain({
|
|
|
13
13
|
return /* @__PURE__ */ jsxs(
|
|
14
14
|
"div",
|
|
15
15
|
{
|
|
16
|
+
"data-access-section": "",
|
|
16
17
|
className: "mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4",
|
|
17
18
|
role: "note",
|
|
18
19
|
"aria-label": "Access requires prerequisite steps",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AccessPrerequisiteChain.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowRight, KeyRound } from 'lucide-react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { getAccessPrerequisiteChain } from '../../utils/resolveHelpers'\n\n/**\n * Two-step (prerequisite) access banner (#38 item D). When the viewed resource\n * is a child whose ancestor(s) carry an access policy, the domain model says\n * the user must obtain the parent's access FIRST. That chain is invisible\n * otherwise, so surface it explicitly as ordered steps:\n *\n * Step 1 — get access to <parent> (e.g. AWS Console via Support Portal)\n * Step 2 — get access to THIS resource (contact its owner / access contacts)\n *\n * Renders nothing for root resources or children with no access-bearing parent.\n * `onOpenParent` lets the user jump to the parent's full access instructions.\n */\nexport function AccessPrerequisiteChain({\n resource,\n onOpenParent,\n}: {\n resource: Resource\n onOpenParent?: (parent: Resource) => void\n}) {\n const { resources } = useAppCatalogContext()\n const chain = getAccessPrerequisiteChain(resources, resource)\n if (chain.length === 0) return null\n\n // Steps = each prerequisite ancestor (root→nearest), then this resource last.\n const steps = [...chain, resource]\n\n return (\n <div\n className=\"mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4\"\n role=\"note\"\n aria-label=\"Access requires prerequisite steps\"\n >\n <h3 className=\"mb-1 flex items-center gap-1.5 text-sm font-semibold text-foreground\">\n <KeyRound className=\"size-4 text-primary\" />\n Two-step access\n </h3>\n <p className=\"mb-3 text-xs text-muted-foreground\">\n This resource is nested — you need access to its parent first, then to\n the resource itself.\n </p>\n <ol className=\"space-y-2\">\n {steps.map((r, i) => {\n const isLast = i === steps.length - 1\n return (\n <li key={r.slug} className=\"flex items-start gap-2.5 text-sm\">\n <span className=\"mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground\">\n {i + 1}\n </span>\n <span className=\"min-w-0\">\n <span className=\"text-muted-foreground\">Get access to </span>\n {isLast ? (\n <span className=\"font-semibold\">this resource</span>\n ) : onOpenParent ? (\n <button\n type=\"button\"\n onClick={() => onOpenParent(r)}\n className=\"inline-flex items-center gap-1 font-semibold text-primary hover:underline\"\n >\n {r.displayName}\n <ArrowRight className=\"size-3\" />\n </button>\n ) : (\n <span className=\"font-semibold\">{r.displayName}</span>\n )}\n {!isLast && (\n <span className=\"text-muted-foreground\">\n {' '}\n (see its access instructions)\n </span>\n )}\n {isLast && (\n <span className=\"text-muted-foreground\">\n {' '}\n — details below.\n </span>\n )}\n </span>\n </li>\n )\n })}\n </ol>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;AAiBO,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AACF,GAGG;AACD,QAAM,EAAE,UAAA,IAAc,qBAAA;AACtB,QAAM,QAAQ,2BAA2B,WAAW,QAAQ;AAC5D,MAAI,MAAM,WAAW,EAAG,QAAO;AAG/B,QAAM,QAAQ,CAAC,GAAG,OAAO,QAAQ;AAEjC,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,
|
|
1
|
+
{"version":3,"file":"AccessPrerequisiteChain.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowRight, KeyRound } from 'lucide-react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { getAccessPrerequisiteChain } from '../../utils/resolveHelpers'\n\n/**\n * Two-step (prerequisite) access banner (#38 item D). When the viewed resource\n * is a child whose ancestor(s) carry an access policy, the domain model says\n * the user must obtain the parent's access FIRST. That chain is invisible\n * otherwise, so surface it explicitly as ordered steps:\n *\n * Step 1 — get access to <parent> (e.g. AWS Console via Support Portal)\n * Step 2 — get access to THIS resource (contact its owner / access contacts)\n *\n * Renders nothing for root resources or children with no access-bearing parent.\n * `onOpenParent` lets the user jump to the parent's full access instructions.\n */\nexport function AccessPrerequisiteChain({\n resource,\n onOpenParent,\n}: {\n resource: Resource\n onOpenParent?: (parent: Resource) => void\n}) {\n const { resources } = useAppCatalogContext()\n const chain = getAccessPrerequisiteChain(resources, resource)\n if (chain.length === 0) return null\n\n // Steps = each prerequisite ancestor (root→nearest), then this resource last.\n const steps = [...chain, resource]\n\n return (\n <div\n // Part of the access story, so it carries the same anchor the instructions\n // do — a reader (and a test) wants the whole chain, not half of it.\n data-access-section=\"\"\n className=\"mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4\"\n role=\"note\"\n aria-label=\"Access requires prerequisite steps\"\n >\n <h3 className=\"mb-1 flex items-center gap-1.5 text-sm font-semibold text-foreground\">\n <KeyRound className=\"size-4 text-primary\" />\n Two-step access\n </h3>\n <p className=\"mb-3 text-xs text-muted-foreground\">\n This resource is nested — you need access to its parent first, then to\n the resource itself.\n </p>\n <ol className=\"space-y-2\">\n {steps.map((r, i) => {\n const isLast = i === steps.length - 1\n return (\n <li key={r.slug} className=\"flex items-start gap-2.5 text-sm\">\n <span className=\"mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground\">\n {i + 1}\n </span>\n <span className=\"min-w-0\">\n <span className=\"text-muted-foreground\">Get access to </span>\n {isLast ? (\n <span className=\"font-semibold\">this resource</span>\n ) : onOpenParent ? (\n <button\n type=\"button\"\n onClick={() => onOpenParent(r)}\n className=\"inline-flex items-center gap-1 font-semibold text-primary hover:underline\"\n >\n {r.displayName}\n <ArrowRight className=\"size-3\" />\n </button>\n ) : (\n <span className=\"font-semibold\">{r.displayName}</span>\n )}\n {!isLast && (\n <span className=\"text-muted-foreground\">\n {' '}\n (see its access instructions)\n </span>\n )}\n {isLast && (\n <span className=\"text-muted-foreground\">\n {' '}\n — details below.\n </span>\n )}\n </span>\n </li>\n )\n })}\n </ol>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;AAiBO,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AACF,GAGG;AACD,QAAM,EAAE,UAAA,IAAc,qBAAA;AACtB,QAAM,QAAQ,2BAA2B,WAAW,QAAQ;AAC5D,MAAI,MAAM,WAAW,EAAG,QAAO;AAG/B,QAAM,QAAQ,CAAC,GAAG,OAAO,QAAQ;AAEjC,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MAGC,uBAAoB;AAAA,MACpB,WAAU;AAAA,MACV,MAAK;AAAA,MACL,cAAW;AAAA,MAEX,UAAA;AAAA,QAAA,qBAAC,MAAA,EAAG,WAAU,wEACZ,UAAA;AAAA,UAAA,oBAAC,UAAA,EAAS,WAAU,sBAAA,CAAsB;AAAA,UAAE;AAAA,QAAA,GAE9C;AAAA,QACA,oBAAC,KAAA,EAAE,WAAU,sCAAqC,UAAA,+FAGlD;AAAA,QACA,oBAAC,QAAG,WAAU,aACX,gBAAM,IAAI,CAAC,GAAG,MAAM;AACnB,gBAAM,SAAS,MAAM,MAAM,SAAS;AACpC,iBACE,qBAAC,MAAA,EAAgB,WAAU,oCACzB,UAAA;AAAA,YAAA,oBAAC,QAAA,EAAK,WAAU,wHACb,UAAA,IAAI,GACP;AAAA,YACA,qBAAC,QAAA,EAAK,WAAU,WACd,UAAA;AAAA,cAAA,oBAAC,QAAA,EAAK,WAAU,yBAAwB,UAAA,kBAAc;AAAA,cACrD,SACC,oBAAC,QAAA,EAAK,WAAU,iBAAgB,UAAA,gBAAA,CAAa,IAC3C,eACF;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS,MAAM,aAAa,CAAC;AAAA,kBAC7B,WAAU;AAAA,kBAET,UAAA;AAAA,oBAAA,EAAE;AAAA,oBACH,oBAAC,YAAA,EAAW,WAAU,SAAA,CAAS;AAAA,kBAAA;AAAA,gBAAA;AAAA,cAAA,IAGjC,oBAAC,QAAA,EAAK,WAAU,iBAAiB,YAAE,aAAY;AAAA,cAEhD,CAAC,UACA,qBAAC,QAAA,EAAK,WAAU,yBACb,UAAA;AAAA,gBAAA;AAAA,gBAAI;AAAA,cAAA,GAEP;AAAA,cAED,UACC,qBAAC,QAAA,EAAK,WAAU,yBACb,UAAA;AAAA,gBAAA;AAAA,gBAAI;AAAA,cAAA,EAAA,CAEP;AAAA,YAAA,EAAA,CAEJ;AAAA,UAAA,EAAA,GAhCO,EAAE,IAiCX;AAAA,QAEJ,CAAC,EAAA,CACH;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAGN;"}
|
|
@@ -113,10 +113,17 @@ function CopyButton({
|
|
|
113
113
|
const UNDOCUMENTED_WITH_CONTACT = "Access process not documented yet — contact the owner below to find out.";
|
|
114
114
|
const UNDOCUMENTED_WITHOUT_CONTACT = "Access process not documented yet, and no owner is listed for this app.";
|
|
115
115
|
function AccessBox({ children }) {
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
return (
|
|
117
|
+
// No frame and no heading. Both existed to mark this out as THE thing on a long
|
|
118
|
+
// scrolling card; now it is its own tab, so the tab's label already says what
|
|
119
|
+
// this is and the panel edge already bounds it. A box inside a box, under a
|
|
120
|
+
// heading that repeats the tab you just clicked, is two statements of the same
|
|
121
|
+
// fact and ink the reader has to look past.
|
|
122
|
+
// A stable anchor for tests, because neither of the old handles survives: the
|
|
123
|
+
// heading is gone, and a sub-resource page has no tab panel to read either.
|
|
124
|
+
// Text and structure both move; this does not.
|
|
125
|
+
/* @__PURE__ */ jsx("div", { "data-access-section": "", className: "space-y-4", children })
|
|
126
|
+
);
|
|
120
127
|
}
|
|
121
128
|
function AccessRequestSection({
|
|
122
129
|
app,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AccessRequestSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessRequestSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport {\n Bot,\n Check,\n Copy,\n ExternalLink,\n MoreHorizontal,\n Settings,\n Users,\n} from 'lucide-react'\nimport type { ReactNode } from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { MarkdownText } from './MarkdownText'\nimport { PersonOrGroupBadge } from './PersonBadge'\n\n// Constants\nconst COPY_FEEDBACK_DURATION = 2000\n\ninterface AccessRequestSectionProps {\n app: Resource\n approvalMethods: AppApprovalMethod[]\n}\n\n// Helper function for approval method icons\nfunction getApprovalMethodIcon(\n type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',\n) {\n switch (type) {\n case 'service':\n return <Bot className=\"size-5 text-primary\" />\n case 'personTeam':\n return <Users className=\"size-5 text-primary\" />\n case 'custom':\n case 'noAccessRequired':\n case 'unknown':\n return <Settings className=\"size-5 text-primary\" />\n }\n}\n\n/** Roles shown before the rest are folded behind the table's last row. */\nconst VISIBLE_ROLES = 5\n\n/**\n * The roles table. A long list (dozens of roles) pushed the approval steps off\n * screen, so beyond {@link VISIBLE_ROLES} the rest sit behind a toggle.\n */\nfunction RolesTable({\n roles,\n}: {\n roles: NonNullable<AppAccessRequest['roles']>\n}) {\n const [expanded, setExpanded] = useState(false)\n const collapsible = roles.length > VISIBLE_ROLES\n const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles\n\n return (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Available Roles</h4>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"whitespace-nowrap\">Role</TableHead>\n <TableHead>Description</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {shown.map((role, idx) => (\n <TableRow key={`${role.displayName}-${idx}`}>\n <TableCell className=\"font-medium whitespace-nowrap\">\n {role.displayName}\n </TableCell>\n {/* `adminNotes` is provisioning-only and never shown to\n the requester — only `description` belongs here. */}\n <TableCell className=\"text-sm text-muted-foreground\">\n {role.description || '—'}\n </TableCell>\n </TableRow>\n ))}\n {/* The control is the table's last row, not a link under the\n table: a table whose border closes under its last data row\n reads as the complete list, and a standalone link below it\n went unnoticed. Same shape the search results use for their\n hidden sub-resources. */}\n {collapsible && (\n <TableRow>\n <TableCell colSpan={2} className=\"p-0\">\n {/* A real <button> filling the cell: the whole row is\n clickable, and focus plus Enter/Space come from the\n platform rather than from a key handler on a <tr>. */}\n <button\n type=\"button\"\n aria-expanded={expanded}\n onClick={() => setExpanded((v) => !v)}\n // The focus ring is drawn INSIDE the border box: the\n // table's container is `overflow-x-auto`, which clips an\n // outline drawn outside it, and this button's edges are\n // flush with that clip box on three sides.\n className=\"flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n >\n <MoreHorizontal className=\"size-4 shrink-0\" />\n {expanded\n ? 'Show fewer roles'\n : `Show all (${roles.length}) roles`}\n </button>\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n\n/**\n * Custom hook for handling copy-to-clipboard functionality with feedback\n * Includes proper cleanup to prevent memory leaks\n */\nfunction useCopyToClipboard() {\n const [copiedId, setCopiedId] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n const copyToClipboard = useCallback(async (text: string, id: string) => {\n // Clear existing timeout\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n\n try {\n await navigator.clipboard.writeText(text)\n setCopiedId(id)\n setError(null)\n\n // Set new timeout with cleanup\n timeoutRef.current = setTimeout(() => {\n setCopiedId(null)\n }, COPY_FEEDBACK_DURATION)\n } catch (err) {\n console.error('Failed to copy to clipboard:', err)\n setError('Failed to copy')\n\n // Clear error after duration\n timeoutRef.current = setTimeout(() => {\n setError(null)\n }, COPY_FEEDBACK_DURATION)\n }\n }, [])\n\n return { copiedId, error, copyToClipboard }\n}\n\n/**\n * Reusable copy button component with accessibility\n */\ninterface CopyButtonProps {\n onCopy: () => void\n isCopied: boolean\n ariaLabel: string\n className?: string\n}\n\nfunction CopyButton({\n onCopy,\n isCopied,\n ariaLabel,\n className,\n}: CopyButtonProps) {\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onCopy}\n className={className}\n aria-label={ariaLabel}\n title={isCopied ? 'Copied!' : 'Copy to clipboard'}\n >\n {isCopied ? (\n <>\n <Check className=\"h-3.5 w-3.5 text-green-600\" />\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n Copied to clipboard\n </span>\n </>\n ) : (\n <Copy className=\"h-3.5 w-3.5\" />\n )}\n </Button>\n )\n}\n\n/**\n * Wording for an entry whose access route is not written down. Which one is\n * honest depends on whether a contact is actually rendered for the reader.\n */\nconst UNDOCUMENTED_WITH_CONTACT =\n 'Access process not documented yet — contact the owner below to find out.'\nconst UNDOCUMENTED_WITHOUT_CONTACT =\n 'Access process not documented yet, and no owner is listed for this app.'\n\n/**\n * The frame every access state renders into, including the empty one — an\n * entry with no access data still gets the heading, because a missing box\n * reads as \"nothing to do here\" rather than \"we have not documented this\".\n */\nfunction AccessBox({ children }: { children: ReactNode }) {\n return (\n <div className=\"mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4 space-y-4\">\n <h3 className=\"text-sm font-semibold text-foreground\">\n How to get access\n </h3>\n {children}\n </div>\n )\n}\n\nexport function AccessRequestSection({\n app,\n approvalMethods,\n}: AccessRequestSectionProps) {\n const { copiedId, copyToClipboard } = useCopyToClipboard()\n\n // Sub-resources carry their access data as top-level fields (`approverSlugs`,\n // `accessComments`, `ownerPersonSlug`) rather than in an `accessRequest`.\n // Without this fallback the whole section rendered nothing for them: the\n // person was told a request is needed and then shown no way to make one.\n const accessRequest = useMemo((): AppAccessRequest | undefined => {\n if (app.accessRequest) return app.accessRequest\n const approverSlugs = [\n ...new Set([\n ...(app.approverSlugs ?? []),\n ...(app.ownerPersonSlug ? [app.ownerPersonSlug] : []),\n ]),\n ]\n if (approverSlugs.length === 0 && !app.accessComments) return undefined\n // No approval method to name — the approvers below ARE the route.\n return {\n approvalMethodSlug: '',\n approverSlugs,\n comments: app.accessComments,\n }\n }, [\n app.accessRequest,\n app.approverSlugs,\n app.ownerPersonSlug,\n app.accessComments,\n ])\n\n const approvalMethod = approvalMethods.find(\n (m) => m.slug === accessRequest?.approvalMethodSlug,\n )\n\n // Read through a plain binding rather than depending on\n // `accessRequest?.requestPrompt` directly: the React Compiler cannot preserve a\n // manual memo whose dependency is an optional-chained member expression, so it\n // drops this useCallback and the button gets a new handler every render.\n const requestPrompt = accessRequest?.requestPrompt\n const handleCopyPrompt = useCallback(() => {\n if (requestPrompt) {\n copyToClipboard(requestPrompt, 'prompt')\n }\n }, [requestPrompt, copyToClipboard])\n\n // Nothing is written down anywhere: no `accessRequest`, no approvers, no\n // owner, no comments. This used to `return null`, so the card showed no\n // \"How to get access\" box at all and the reader could not tell an\n // undocumented entry from one that needs no request (#181). Keep the box and\n // say plainly that we do not know.\n if (!accessRequest) {\n return (\n <AccessBox>\n <p className=\"text-sm text-muted-foreground\">\n {UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n </AccessBox>\n )\n }\n\n // Access UX (#31): some methods carry no clickable target — `noAccessRequired`\n // (open), `unknown` (undocumented), and bare `custom` with no comments. The\n // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead\n // surface an explicit, actionable line so the user is never stranded:\n // - open → \"No request needed — just open it.\"\n // - unknown / undocumented custom → route them to the owner (shown below).\n const isOpen = approvalMethod?.type === 'noAccessRequired'\n const hasWrittenSteps = Boolean(\n accessRequest.requestPrompt || accessRequest.comments,\n )\n const isUndocumented =\n approvalMethod?.type === 'unknown' ||\n (approvalMethod?.type === 'custom' && !hasWrittenSteps)\n // \"the owner below\" is either the Approvers block in this box or the owner\n // block further down the detail page, and BOTH are conditional — so an entry\n // with neither was telling the reader to go and contact nobody.\n const hasContact =\n (accessRequest.approverSlugs?.length ?? 0) > 0 ||\n Boolean(app.ownerPersonSlug)\n // Two-step: has a primary request action AND follow-up post-approval steps\n const isTwoStep = Boolean(\n hasWrittenSteps && accessRequest.postApprovalInstructions,\n )\n\n return (\n <AccessBox>\n {isOpen && (\n <p className=\"text-sm font-medium text-primary\">\n ✓ Open to everyone — no request needed. Just open it.\n </p>\n )}\n\n {isUndocumented && (\n <p className=\"text-sm text-muted-foreground\">\n {hasContact\n ? UNDOCUMENTED_WITH_CONTACT\n : UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n )}\n\n {isTwoStep && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 1\n </span>\n <span className=\"text-xs text-muted-foreground\">Request access</span>\n </div>\n )}\n\n {/* Approval Method */}\n {approvalMethod && approvalMethod.type !== 'custom' && (\n <div className=\"flex items-center gap-2\">\n {approvalMethod.type === 'service' && approvalMethod.config.url ? (\n <>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 hover:text-primary transition-colors\"\n title={approvalMethod.config.url}\n >\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </a>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n title={approvalMethod.config.url}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n </>\n ) : (\n <>\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </>\n )}\n </div>\n )}\n\n {/* Request Prompt - Inline */}\n {accessRequest.requestPrompt && (\n <div className=\"text-sm inline-flex items-center gap-2\">\n <span className=\"whitespace-nowrap shrink-0\">Request Prompt:</span>\n <span className=\"prose prose-sm inline [&>*]:inline [&>*]:m-0\">\n <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>\n </span>\n <CopyButton\n onCopy={handleCopyPrompt}\n isCopied={copiedId === 'prompt'}\n ariaLabel=\"Copy request prompt\"\n className=\"h-6 w-6 p-0 shrink-0\"\n />\n </div>\n )}\n\n {/* Comments */}\n {accessRequest.comments && (\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n\n {accessRequest.roles && accessRequest.roles.length > 0 && (\n <RolesTable roles={accessRequest.roles} />\n )}\n\n {/* Approvers */}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Approvers</h4>\n <div className=\"flex flex-wrap gap-2\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n </div>\n )}\n\n {/* Post-Approval Instructions */}\n {accessRequest.postApprovalInstructions &&\n (isTwoStep ? (\n // Two-step apps: show Step 2 prominently, always expanded\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 2\n </span>\n <span className=\"text-xs text-muted-foreground\">\n After approval\n </span>\n </div>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </div>\n ) : (\n // Single-step apps: keep collapsible accordion (secondary info)\n <Accordion type=\"single\" collapsible>\n <AccordionItem\n value=\"post-approval\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Post-Approval Instructions\n </AccordionTrigger>\n <AccordionContent className=\"pb-3\">\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n ))}\n\n {/* Documentation URLs — box level: they cover the whole process,\n not one step, so they come after Step 2 */}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Documentation</h4>\n <div className=\"flex flex-col gap-1.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5\"\n >\n {urlObj.label || urlObj.url}\n <ExternalLink className=\"size-3\" />\n </a>\n ))}\n </div>\n </div>\n )}\n </AccessBox>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AccessRequestSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessRequestSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport {\n Bot,\n Check,\n Copy,\n ExternalLink,\n MoreHorizontal,\n Settings,\n Users,\n} from 'lucide-react'\nimport type { ReactNode } from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { MarkdownText } from './MarkdownText'\nimport { PersonOrGroupBadge } from './PersonBadge'\n\n// Constants\nconst COPY_FEEDBACK_DURATION = 2000\n\ninterface AccessRequestSectionProps {\n app: Resource\n approvalMethods: AppApprovalMethod[]\n}\n\n// Helper function for approval method icons\nfunction getApprovalMethodIcon(\n type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',\n) {\n switch (type) {\n case 'service':\n return <Bot className=\"size-5 text-primary\" />\n case 'personTeam':\n return <Users className=\"size-5 text-primary\" />\n case 'custom':\n case 'noAccessRequired':\n case 'unknown':\n return <Settings className=\"size-5 text-primary\" />\n }\n}\n\n/** Roles shown before the rest are folded behind the table's last row. */\nconst VISIBLE_ROLES = 5\n\n/**\n * The roles table. A long list (dozens of roles) pushed the approval steps off\n * screen, so beyond {@link VISIBLE_ROLES} the rest sit behind a toggle.\n */\nfunction RolesTable({\n roles,\n}: {\n roles: NonNullable<AppAccessRequest['roles']>\n}) {\n const [expanded, setExpanded] = useState(false)\n const collapsible = roles.length > VISIBLE_ROLES\n const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles\n\n return (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Available Roles</h4>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"whitespace-nowrap\">Role</TableHead>\n <TableHead>Description</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {shown.map((role, idx) => (\n <TableRow key={`${role.displayName}-${idx}`}>\n <TableCell className=\"font-medium whitespace-nowrap\">\n {role.displayName}\n </TableCell>\n {/* `adminNotes` is provisioning-only and never shown to\n the requester — only `description` belongs here. */}\n <TableCell className=\"text-sm text-muted-foreground\">\n {role.description || '—'}\n </TableCell>\n </TableRow>\n ))}\n {/* The control is the table's last row, not a link under the\n table: a table whose border closes under its last data row\n reads as the complete list, and a standalone link below it\n went unnoticed. Same shape the search results use for their\n hidden sub-resources. */}\n {collapsible && (\n <TableRow>\n <TableCell colSpan={2} className=\"p-0\">\n {/* A real <button> filling the cell: the whole row is\n clickable, and focus plus Enter/Space come from the\n platform rather than from a key handler on a <tr>. */}\n <button\n type=\"button\"\n aria-expanded={expanded}\n onClick={() => setExpanded((v) => !v)}\n // The focus ring is drawn INSIDE the border box: the\n // table's container is `overflow-x-auto`, which clips an\n // outline drawn outside it, and this button's edges are\n // flush with that clip box on three sides.\n className=\"flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n >\n <MoreHorizontal className=\"size-4 shrink-0\" />\n {expanded\n ? 'Show fewer roles'\n : `Show all (${roles.length}) roles`}\n </button>\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n\n/**\n * Custom hook for handling copy-to-clipboard functionality with feedback\n * Includes proper cleanup to prevent memory leaks\n */\nfunction useCopyToClipboard() {\n const [copiedId, setCopiedId] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n const copyToClipboard = useCallback(async (text: string, id: string) => {\n // Clear existing timeout\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n\n try {\n await navigator.clipboard.writeText(text)\n setCopiedId(id)\n setError(null)\n\n // Set new timeout with cleanup\n timeoutRef.current = setTimeout(() => {\n setCopiedId(null)\n }, COPY_FEEDBACK_DURATION)\n } catch (err) {\n console.error('Failed to copy to clipboard:', err)\n setError('Failed to copy')\n\n // Clear error after duration\n timeoutRef.current = setTimeout(() => {\n setError(null)\n }, COPY_FEEDBACK_DURATION)\n }\n }, [])\n\n return { copiedId, error, copyToClipboard }\n}\n\n/**\n * Reusable copy button component with accessibility\n */\ninterface CopyButtonProps {\n onCopy: () => void\n isCopied: boolean\n ariaLabel: string\n className?: string\n}\n\nfunction CopyButton({\n onCopy,\n isCopied,\n ariaLabel,\n className,\n}: CopyButtonProps) {\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onCopy}\n className={className}\n aria-label={ariaLabel}\n title={isCopied ? 'Copied!' : 'Copy to clipboard'}\n >\n {isCopied ? (\n <>\n <Check className=\"h-3.5 w-3.5 text-green-600\" />\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n Copied to clipboard\n </span>\n </>\n ) : (\n <Copy className=\"h-3.5 w-3.5\" />\n )}\n </Button>\n )\n}\n\n/**\n * Wording for an entry whose access route is not written down. Which one is\n * honest depends on whether a contact is actually rendered for the reader.\n */\nconst UNDOCUMENTED_WITH_CONTACT =\n 'Access process not documented yet — contact the owner below to find out.'\nconst UNDOCUMENTED_WITHOUT_CONTACT =\n 'Access process not documented yet, and no owner is listed for this app.'\n\n/**\n * The frame every access state renders into, including the empty one — an\n * entry with no access data still gets the heading, because a missing box\n * reads as \"nothing to do here\" rather than \"we have not documented this\".\n */\nfunction AccessBox({ children }: { children: ReactNode }) {\n return (\n // No frame and no heading. Both existed to mark this out as THE thing on a long\n // scrolling card; now it is its own tab, so the tab's label already says what\n // this is and the panel edge already bounds it. A box inside a box, under a\n // heading that repeats the tab you just clicked, is two statements of the same\n // fact and ink the reader has to look past.\n // A stable anchor for tests, because neither of the old handles survives: the\n // heading is gone, and a sub-resource page has no tab panel to read either.\n // Text and structure both move; this does not.\n <div data-access-section=\"\" className=\"space-y-4\">\n {children}\n </div>\n )\n}\n\nexport function AccessRequestSection({\n app,\n approvalMethods,\n}: AccessRequestSectionProps) {\n const { copiedId, copyToClipboard } = useCopyToClipboard()\n\n // Sub-resources carry their access data as top-level fields (`approverSlugs`,\n // `accessComments`, `ownerPersonSlug`) rather than in an `accessRequest`.\n // Without this fallback the whole section rendered nothing for them: the\n // person was told a request is needed and then shown no way to make one.\n const accessRequest = useMemo((): AppAccessRequest | undefined => {\n if (app.accessRequest) return app.accessRequest\n const approverSlugs = [\n ...new Set([\n ...(app.approverSlugs ?? []),\n ...(app.ownerPersonSlug ? [app.ownerPersonSlug] : []),\n ]),\n ]\n if (approverSlugs.length === 0 && !app.accessComments) return undefined\n // No approval method to name — the approvers below ARE the route.\n return {\n approvalMethodSlug: '',\n approverSlugs,\n comments: app.accessComments,\n }\n }, [\n app.accessRequest,\n app.approverSlugs,\n app.ownerPersonSlug,\n app.accessComments,\n ])\n\n const approvalMethod = approvalMethods.find(\n (m) => m.slug === accessRequest?.approvalMethodSlug,\n )\n\n // Read through a plain binding rather than depending on\n // `accessRequest?.requestPrompt` directly: the React Compiler cannot preserve a\n // manual memo whose dependency is an optional-chained member expression, so it\n // drops this useCallback and the button gets a new handler every render.\n const requestPrompt = accessRequest?.requestPrompt\n const handleCopyPrompt = useCallback(() => {\n if (requestPrompt) {\n copyToClipboard(requestPrompt, 'prompt')\n }\n }, [requestPrompt, copyToClipboard])\n\n // Nothing is written down anywhere: no `accessRequest`, no approvers, no\n // owner, no comments. This used to `return null`, so the card showed no\n // \"How to get access\" box at all and the reader could not tell an\n // undocumented entry from one that needs no request (#181). Keep the box and\n // say plainly that we do not know.\n if (!accessRequest) {\n return (\n <AccessBox>\n <p className=\"text-sm text-muted-foreground\">\n {UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n </AccessBox>\n )\n }\n\n // Access UX (#31): some methods carry no clickable target — `noAccessRequired`\n // (open), `unknown` (undocumented), and bare `custom` with no comments. The\n // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead\n // surface an explicit, actionable line so the user is never stranded:\n // - open → \"No request needed — just open it.\"\n // - unknown / undocumented custom → route them to the owner (shown below).\n const isOpen = approvalMethod?.type === 'noAccessRequired'\n const hasWrittenSteps = Boolean(\n accessRequest.requestPrompt || accessRequest.comments,\n )\n const isUndocumented =\n approvalMethod?.type === 'unknown' ||\n (approvalMethod?.type === 'custom' && !hasWrittenSteps)\n // \"the owner below\" is either the Approvers block in this box or the owner\n // block further down the detail page, and BOTH are conditional — so an entry\n // with neither was telling the reader to go and contact nobody.\n const hasContact =\n (accessRequest.approverSlugs?.length ?? 0) > 0 ||\n Boolean(app.ownerPersonSlug)\n // Two-step: has a primary request action AND follow-up post-approval steps\n const isTwoStep = Boolean(\n hasWrittenSteps && accessRequest.postApprovalInstructions,\n )\n\n return (\n <AccessBox>\n {isOpen && (\n <p className=\"text-sm font-medium text-primary\">\n ✓ Open to everyone — no request needed. Just open it.\n </p>\n )}\n\n {isUndocumented && (\n <p className=\"text-sm text-muted-foreground\">\n {hasContact\n ? UNDOCUMENTED_WITH_CONTACT\n : UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n )}\n\n {isTwoStep && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 1\n </span>\n <span className=\"text-xs text-muted-foreground\">Request access</span>\n </div>\n )}\n\n {/* Approval Method */}\n {approvalMethod && approvalMethod.type !== 'custom' && (\n <div className=\"flex items-center gap-2\">\n {approvalMethod.type === 'service' && approvalMethod.config.url ? (\n <>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 hover:text-primary transition-colors\"\n title={approvalMethod.config.url}\n >\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </a>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n title={approvalMethod.config.url}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n </>\n ) : (\n <>\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </>\n )}\n </div>\n )}\n\n {/* Request Prompt - Inline */}\n {accessRequest.requestPrompt && (\n <div className=\"text-sm inline-flex items-center gap-2\">\n <span className=\"whitespace-nowrap shrink-0\">Request Prompt:</span>\n <span className=\"prose prose-sm inline [&>*]:inline [&>*]:m-0\">\n <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>\n </span>\n <CopyButton\n onCopy={handleCopyPrompt}\n isCopied={copiedId === 'prompt'}\n ariaLabel=\"Copy request prompt\"\n className=\"h-6 w-6 p-0 shrink-0\"\n />\n </div>\n )}\n\n {/* Comments */}\n {accessRequest.comments && (\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n\n {accessRequest.roles && accessRequest.roles.length > 0 && (\n <RolesTable roles={accessRequest.roles} />\n )}\n\n {/* Approvers */}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Approvers</h4>\n <div className=\"flex flex-wrap gap-2\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n </div>\n )}\n\n {/* Post-Approval Instructions */}\n {accessRequest.postApprovalInstructions &&\n (isTwoStep ? (\n // Two-step apps: show Step 2 prominently, always expanded\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 2\n </span>\n <span className=\"text-xs text-muted-foreground\">\n After approval\n </span>\n </div>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </div>\n ) : (\n // Single-step apps: keep collapsible accordion (secondary info)\n <Accordion type=\"single\" collapsible>\n <AccordionItem\n value=\"post-approval\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Post-Approval Instructions\n </AccordionTrigger>\n <AccordionContent className=\"pb-3\">\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n ))}\n\n {/* Documentation URLs — box level: they cover the whole process,\n not one step, so they come after Step 2 */}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Documentation</h4>\n <div className=\"flex flex-col gap-1.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5\"\n >\n {urlObj.label || urlObj.url}\n <ExternalLink className=\"size-3\" />\n </a>\n ))}\n </div>\n </div>\n )}\n </AccessBox>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -24,12 +24,10 @@ const JUMP = [
|
|
|
24
24
|
"min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]",
|
|
25
25
|
SEGMENT_FOCUS
|
|
26
26
|
].join(" ");
|
|
27
|
-
const LEAP = "origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]";
|
|
28
27
|
const LEAVE_FILL = [
|
|
29
28
|
"bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground",
|
|
30
29
|
"hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]"
|
|
31
30
|
].join(" ");
|
|
32
|
-
const QUIET_FILL = "border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground";
|
|
33
31
|
const JUMP_FILL = [
|
|
34
32
|
LEAVE_FILL,
|
|
35
33
|
"active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]",
|
|
@@ -230,9 +228,7 @@ function QuickJumpBar({ app }) {
|
|
|
230
228
|
);
|
|
231
229
|
}
|
|
232
230
|
export {
|
|
233
|
-
LEAP,
|
|
234
231
|
LEAVE_FILL,
|
|
235
|
-
QUIET_FILL,
|
|
236
232
|
QuickJumpBar,
|
|
237
233
|
hasQuickJumps
|
|
238
234
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/** Whether the open button should stand down for this app's Quick Jump. */\nexport function hasQuickJumps(app: Resource): boolean {\n return groupQuickJumps(app).some((group) => group.jumps.length > 0)\n}\n\n/**\n * One horizontal inset and one icon gap for every segment AND for the open\n * button, so the two seams either side of the field measure the same.\n *\n * Vertical padding is deliberately absent: height comes from a `min-h` on each\n * segment. Borrowing it from a sibling through `items-stretch` is what left the\n * picker a 20.5px tap target once it wrapped onto its own line, and what made the\n * row 33.5px on an app with jumps but 32px on an app without.\n */\nconst SEGMENT =\n 'inline-flex items-center gap-1.5 px-3 text-sm whitespace-nowrap'\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/**\n * The destination picker. Sized to the app's WIDEST short title (see the ghost\n * below), not to a constant: a constant was simultaneously 100px too narrow for\n * the long titles and three quarters empty on an app with one short one. Widest,\n * rather than current, is what keeps the field and Jump from sliding sideways\n * when the destination changes — the only thing the constant was protecting.\n *\n * Its own full-width line below `sm`, where the three segments together do not\n * fit in a phone's panel at all. It carries the short title — the full one is in\n * the menu, where you are choosing between systems.\n */\nconst PICKER = [\n SEGMENT,\n // `calc(var(--radius)_-_1px)` is the shell's radius less its own border, so the\n // inner corners are concentric with the outer ones. Written out rather than\n // interpolated from a constant: Tailwind reads these class names out of the\n // source text, and a template hole is not a class name.\n 'w-full min-h-10 shrink-0 rounded-t-[calc(var(--radius)_-_1px)] border-b border-border bg-muted text-left font-semibold',\n 'sm:w-auto sm:min-h-8 sm:max-w-[min(20rem,100%)] sm:rounded-t-none sm:rounded-l-[calc(var(--radius)_-_1px)] sm:border-b-0',\n].join(' ')\n\nconst JUMP = [\n SEGMENT,\n 'min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as a free-standing button does it: it lifts onto its toes on\n * `:hover` and crouches on `:active`. Deliberately NOT an on-click animation —\n * these links open a new tab, which takes focus the same instant, so a keyframe\n * fired on click runs in a backgrounded tab and the user only sees it on the way\n * back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n *\n * This belongs to the open button ALONE. Jump is welded into a bordered frame,\n * where the same lift covered the shell's top border along its whole width and\n * the crouch opened 3.5px of card above it while overhanging the bottom. A\n * segment presses inward instead — see `JUMP_FILL`.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for a button that leaves for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA. 90%\n * measured 4.56:1, which clears AA by 0.06 at a size where bold does not count\n * as large text; 85% measures 5.19:1.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]',\n].join(' ')\n\n/**\n * The open button when the app also has a Quick Jump. A row holds exactly ONE\n * solid brand fill and it belongs on the submit — the one element in a\n * field-shaped control that has to announce \"this acts on what you typed\".\n * Sharing the fill made the two read as a single four-part blob.\n *\n * The button keeps its primacy through position and its self-describing label,\n * not through being the loudest thing in the row. The branch is decided by the\n * app's data, never by the input's value: the hierarchy must not flicker while\n * the user types.\n *\n * The border is not decoration. `--secondary` and the picker's `--muted` sit\n * 0.015 apart in lightness, so without it the button and the shell's first\n * segment read as one continuous panel straight across the gap — the same\n * failure again, in grey.\n */\nexport const QUIET_FILL =\n 'border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground'\n\n/** Jump's fill: the leave fill, pressed inward rather than crouching. */\nconst JUMP_FILL = [\n LEAVE_FILL,\n 'active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]',\n 'active:shadow-[inset_0_2px_3px_rgb(0_0_0/0.22)]',\n 'transition-[background-color,box-shadow] duration-150 ease-out',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n const hintId = React.useId()\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // Opening a card is a deliberate act, and this field is the one thing in the\n // header you act on by typing — so it takes the caret, and you can open an app\n // and enter an id without reaching for the mouse. Keyed on the app's jumps, so\n // it re-applies when you move to another app inside the same mounted panel.\n //\n // `preventScroll`, or focusing an element inside the scrollable panel scrolls\n // the panel to it. Not on a coarse pointer: there focus pops the virtual\n // keyboard over half of what you just opened.\n React.useEffect(() => {\n if (window.matchMedia('(pointer: coarse)').matches) return\n inputRef.current?.focus({ preventScroll: true })\n }, [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // What the picker is sized to. Longest by characters rather than by measured\n // text: close enough at one weight and one size, and it costs no layout pass.\n const widest = jumps\n .map(quickJumpShortTitle)\n .reduce((longest, title) =>\n title.length > longest.length ? title : longest,\n )\n\n return (\n <div\n className={cn(\n 'group flex w-full max-w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // Any segment, not just the field: the picker and Jump are focusable\n // too, and a shell that stays unlit for two of its three ends reads as\n // if tabbing had left the control.\n 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/25',\n 'sm:w-auto',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* The shown label and an invisible copy of the widest one share a\n single grid cell, so the column is as wide as the widest and the\n segment cannot change width when the destination changes.\n\n The cap has to be on this box, not only on the segment: a\n `max-width` on a flex item does not clamp that item's\n max-content contribution, so the shell's own width would still\n be computed from the uncapped label and leave dead card-coloured\n space after Jump. */}\n <span className=\"grid min-w-0 max-w-[296px]\">\n {/* Caret inside the shown cell, immediately after the label — as\n a sibling of the whole box it would be stranded at the far\n edge on every destination but the widest, where it reads as\n the field's boundary and leaves the picker looking like a wide\n empty select. The ghost reserves its width instead. */}\n <span className=\"flex min-w-0 items-center gap-1.5 [grid-area:1/1]\">\n <span className=\"truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-foreground/70\" />\n </span>\n {/* Omitted when the widest destination is the one on screen: the\n shown cell already measures that wide, and a second copy of\n the same words is one a text query has to disambiguate. */}\n {widest !== quickJumpShortTitle(selected) && (\n <span\n aria-hidden\n // 16px caret + the 6px gap it sits behind.\n className=\"invisible h-0 pr-[22px] whitespace-nowrap [grid-area:1/1]\"\n >\n {widest}\n </span>\n )}\n </span>\n </DropdownMenuTrigger>\n {/* Portalled, never narrower than the trigger, and sized to its\n content beyond that: the trigger ellipsises, the menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n sideOffset={6}\n className=\"max-w-[min(90vw,34rem)] min-w-[var(--radix-dropdown-menu-trigger-width)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"px-2 py-1 text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n // Concentric with the menu: 8px inside a 12px box with\n // 4px of padding.\n className=\"rounded-[8px] pl-7 whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n {/* The field owns a positioning context so the hint below can be anchored\n to it. The input is taken out of flow inside it: an `<input>` keeps an\n intrinsic 20-character width that wins inside an auto-width flex row,\n and the field's width is a design decision, not a font metric. */}\n <div\n className={cn(\n 'relative flex min-h-8 w-[170px] min-w-[120px] flex-[1_1_170px] border-border',\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump. It must NOT grow from `sm` up: the shell is auto-width\n // there, so growing fills nothing but does hand the field the\n // picker's clamped-away max-content overflow.\n 'sm:flex-[0_1_170px] sm:border-l',\n )}\n >\n <input\n ref={inputRef}\n type=\"text\"\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n // Bare identity, not \"Enter <identity>\": the verb costs ~40px of a\n // 170px field that the longest identifiers need.\n placeholder={selected.identity}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n 'absolute inset-0 h-full w-full bg-transparent px-3 font-mono text-sm font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor — which is\n why it hangs off the field's own box rather than off a hard-coded\n picker width that stopped being true once the picker was sized to its\n content. The field lights up at the same time.\n\n Not `aria-hidden`: it is the dormant button's description, which is\n also why that button no longer carries a `title` saying the same\n thing in different words. */}\n <p\n id={hintId}\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'group-has-[.qj-dormant:hover]:translate-y-0 group-has-[.qj-dormant:hover]:opacity-100',\n 'group-has-[.qj-dormant:focus-visible]:translate-y-0 group-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, JUMP_FILL)}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n aria-describedby={hintId}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/12 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/** Whether the open button should stand down for this app's Quick Jump. */\nexport function hasQuickJumps(app: Resource): boolean {\n return groupQuickJumps(app).some((group) => group.jumps.length > 0)\n}\n\n/**\n * One horizontal inset and one icon gap for every segment AND for the open\n * button, so the two seams either side of the field measure the same.\n *\n * Vertical padding is deliberately absent: height comes from a `min-h` on each\n * segment. Borrowing it from a sibling through `items-stretch` is what left the\n * picker a 20.5px tap target once it wrapped onto its own line, and what made the\n * row 33.5px on an app with jumps but 32px on an app without.\n */\nconst SEGMENT =\n 'inline-flex items-center gap-1.5 px-3 text-sm whitespace-nowrap'\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/**\n * The destination picker. Sized to the app's WIDEST short title (see the ghost\n * below), not to a constant: a constant was simultaneously 100px too narrow for\n * the long titles and three quarters empty on an app with one short one. Widest,\n * rather than current, is what keeps the field and Jump from sliding sideways\n * when the destination changes — the only thing the constant was protecting.\n *\n * Its own full-width line below `sm`, where the three segments together do not\n * fit in a phone's panel at all. It carries the short title — the full one is in\n * the menu, where you are choosing between systems.\n */\nconst PICKER = [\n SEGMENT,\n // `calc(var(--radius)_-_1px)` is the shell's radius less its own border, so the\n // inner corners are concentric with the outer ones. Written out rather than\n // interpolated from a constant: Tailwind reads these class names out of the\n // source text, and a template hole is not a class name.\n 'w-full min-h-10 shrink-0 rounded-t-[calc(var(--radius)_-_1px)] border-b border-border bg-muted text-left font-semibold',\n 'sm:w-auto sm:min-h-8 sm:max-w-[min(20rem,100%)] sm:rounded-t-none sm:rounded-l-[calc(var(--radius)_-_1px)] sm:border-b-0',\n].join(' ')\n\nconst JUMP = [\n SEGMENT,\n 'min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as a free-standing button does it: it lifts onto its toes on\n * `:hover` and crouches on `:active`. Deliberately NOT an on-click animation —\n * these links open a new tab, which takes focus the same instant, so a keyframe\n * fired on click runs in a backgrounded tab and the user only sees it on the way\n * back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n *\n * This belongs to the open button ALONE. Jump is welded into a bordered frame,\n * where the same lift covered the shell's top border along its whole width and\n * the crouch opened 3.5px of card above it while overhanging the bottom. A\n * segment presses inward instead — see `JUMP_FILL`.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for a button that leaves for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA. 90%\n * measured 4.56:1, which clears AA by 0.06 at a size where bold does not count\n * as large text; 85% measures 5.19:1.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]',\n].join(' ')\n\n/**\n * The open button when the app also has a Quick Jump. A row holds exactly ONE\n * solid brand fill and it belongs on the submit — the one element in a\n * field-shaped control that has to announce \"this acts on what you typed\".\n * Sharing the fill made the two read as a single four-part blob.\n *\n * The button keeps its primacy through position and its self-describing label,\n * not through being the loudest thing in the row. The branch is decided by the\n * app's data, never by the input's value: the hierarchy must not flicker while\n * the user types.\n *\n * The border is not decoration. `--secondary` and the picker's `--muted` sit\n * 0.015 apart in lightness, so without it the button and the shell's first\n * segment read as one continuous panel straight across the gap — the same\n * failure again, in grey.\n */\nexport const QUIET_FILL =\n 'border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground'\n\n/** Jump's fill: the leave fill, pressed inward rather than crouching. */\nconst JUMP_FILL = [\n LEAVE_FILL,\n 'active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]',\n 'active:shadow-[inset_0_2px_3px_rgb(0_0_0/0.22)]',\n 'transition-[background-color,box-shadow] duration-150 ease-out',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n const hintId = React.useId()\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // Opening a card is a deliberate act, and this field is the one thing in the\n // header you act on by typing — so it takes the caret, and you can open an app\n // and enter an id without reaching for the mouse. Keyed on the app's jumps, so\n // it re-applies when you move to another app inside the same mounted panel.\n //\n // `preventScroll`, or focusing an element inside the scrollable panel scrolls\n // the panel to it. Not on a coarse pointer: there focus pops the virtual\n // keyboard over half of what you just opened.\n React.useEffect(() => {\n if (window.matchMedia('(pointer: coarse)').matches) return\n inputRef.current?.focus({ preventScroll: true })\n }, [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // What the picker is sized to. Longest by characters rather than by measured\n // text: close enough at one weight and one size, and it costs no layout pass.\n const widest = jumps\n .map(quickJumpShortTitle)\n .reduce((longest, title) =>\n title.length > longest.length ? title : longest,\n )\n\n return (\n <div\n className={cn(\n 'group flex w-full max-w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // Any segment, not just the field: the picker and Jump are focusable\n // too, and a shell that stays unlit for two of its three ends reads as\n // if tabbing had left the control.\n 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/25',\n 'sm:w-auto',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* The shown label and an invisible copy of the widest one share a\n single grid cell, so the column is as wide as the widest and the\n segment cannot change width when the destination changes.\n\n The cap has to be on this box, not only on the segment: a\n `max-width` on a flex item does not clamp that item's\n max-content contribution, so the shell's own width would still\n be computed from the uncapped label and leave dead card-coloured\n space after Jump. */}\n <span className=\"grid min-w-0 max-w-[296px]\">\n {/* Caret inside the shown cell, immediately after the label — as\n a sibling of the whole box it would be stranded at the far\n edge on every destination but the widest, where it reads as\n the field's boundary and leaves the picker looking like a wide\n empty select. The ghost reserves its width instead. */}\n <span className=\"flex min-w-0 items-center gap-1.5 [grid-area:1/1]\">\n <span className=\"truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-foreground/70\" />\n </span>\n {/* Omitted when the widest destination is the one on screen: the\n shown cell already measures that wide, and a second copy of\n the same words is one a text query has to disambiguate. */}\n {widest !== quickJumpShortTitle(selected) && (\n <span\n aria-hidden\n // 16px caret + the 6px gap it sits behind.\n className=\"invisible h-0 pr-[22px] whitespace-nowrap [grid-area:1/1]\"\n >\n {widest}\n </span>\n )}\n </span>\n </DropdownMenuTrigger>\n {/* Portalled, never narrower than the trigger, and sized to its\n content beyond that: the trigger ellipsises, the menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n sideOffset={6}\n className=\"max-w-[min(90vw,34rem)] min-w-[var(--radix-dropdown-menu-trigger-width)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"px-2 py-1 text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n // Concentric with the menu: 8px inside a 12px box with\n // 4px of padding.\n className=\"rounded-[8px] pl-7 whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n {/* The field owns a positioning context so the hint below can be anchored\n to it. The input is taken out of flow inside it: an `<input>` keeps an\n intrinsic 20-character width that wins inside an auto-width flex row,\n and the field's width is a design decision, not a font metric. */}\n <div\n className={cn(\n 'relative flex min-h-8 w-[170px] min-w-[120px] flex-[1_1_170px] border-border',\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump. It must NOT grow from `sm` up: the shell is auto-width\n // there, so growing fills nothing but does hand the field the\n // picker's clamped-away max-content overflow.\n 'sm:flex-[0_1_170px] sm:border-l',\n )}\n >\n <input\n ref={inputRef}\n type=\"text\"\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n // Bare identity, not \"Enter <identity>\": the verb costs ~40px of a\n // 170px field that the longest identifiers need.\n placeholder={selected.identity}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n 'absolute inset-0 h-full w-full bg-transparent px-3 font-mono text-sm font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor — which is\n why it hangs off the field's own box rather than off a hard-coded\n picker width that stopped being true once the picker was sized to its\n content. The field lights up at the same time.\n\n Not `aria-hidden`: it is the dormant button's description, which is\n also why that button no longer carries a `title` saying the same\n thing in different words. */}\n <p\n id={hintId}\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'group-has-[.qj-dormant:hover]:translate-y-0 group-has-[.qj-dormant:hover]:opacity-100',\n 'group-has-[.qj-dormant:focus-visible]:translate-y-0 group-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, JUMP_FILL)}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n aria-describedby={hintId}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/12 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
@@ -64,7 +64,10 @@ function SubResourceDetailPanel({
|
|
|
64
64
|
}
|
|
65
65
|
)
|
|
66
66
|
] }),
|
|
67
|
-
hasAnyAccess &&
|
|
67
|
+
hasAnyAccess && // The whole two-step region carries the anchor: the step labels naming
|
|
68
|
+
// the parent live out here, not inside the nested AccessRequestSections,
|
|
69
|
+
// so anchoring only those dropped the parent from the chain.
|
|
70
|
+
/* @__PURE__ */ jsxs("div", { "data-access-section": "", className: "mt-6 space-y-6", children: [
|
|
68
71
|
/* @__PURE__ */ jsx("h3", { className: "text-sm font-medium", children: "How to get access" }),
|
|
69
72
|
hasTwoStepAccess ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
70
73
|
/* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubResourceDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx"],"sourcesContent":["import type {\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { ArrowLeft, ExternalLink } from 'lucide-react'\nimport { Button } from '~/ui/button'\nimport { Badge } from '~/ui/badge'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { AccessRequestSection } from './AccessRequestSection'\n\ninterface SubResourceDetailPanelProps {\n subResource: Resource\n parent: Resource\n approvalMethods: AppApprovalMethod[]\n onBack: () => void\n}\n\nfunction StepBadge({ step }: { step: number }) {\n return (\n <span className=\"inline-flex items-center justify-center size-6 rounded-full bg-primary text-primary-foreground text-xs font-semibold shrink-0\">\n {step}\n </span>\n )\n}\n\nexport function SubResourceDetailPanel({\n subResource,\n parent,\n approvalMethods,\n onBack,\n}: SubResourceDetailPanelProps) {\n // Mirrors AccessRequestSection's fallback: a sub-resource usually documents\n // access through top-level `approverSlugs`/`accessComments`, not through an\n // `accessRequest`. Only counting the latter hid its step of the chain.\n const hasAccessInfo = (r: Resource) =>\n !!r.accessRequest ||\n (r.approverSlugs?.length ?? 0) > 0 ||\n !!r.ownerPersonSlug ||\n !!r.accessComments\n\n const hasParentAccess = hasAccessInfo(parent)\n const hasSubAccess = hasAccessInfo(subResource)\n const hasTwoStepAccess = hasParentAccess && hasSubAccess\n const hasAnyAccess = hasParentAccess || hasSubAccess\n\n return (\n <div className=\"flex h-full flex-col p-6\">\n {/* Back navigation */}\n <div className=\"mb-4\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={onBack}\n className=\"gap-1.5 text-muted-foreground hover:text-foreground -ml-2\"\n aria-label={`Back to ${parent.displayName}`}\n >\n <ArrowLeft className=\"size-4\" />\n <span>{parent.displayName}</span>\n </Button>\n </div>\n\n {/* Sub-resource title */}\n <div className=\"border-b pb-4\">\n <div className=\"flex items-center gap-2 flex-wrap\">\n <h2 className=\"text-xl font-semibold\">{subResource.displayName}</h2>\n {subResource.tier && (\n <Badge variant=\"outline\" className=\"text-xs\">\n {subResource.tier}\n </Badge>\n )}\n </div>\n {subResource.description && (\n <p className=\"text-sm text-muted-foreground mt-1\">\n {subResource.description}\n </p>\n )}\n {(subResource.aliases ?? []).length > 0 && (\n <p className=\"text-xs text-muted-foreground mt-1\">\n Also known as: {(subResource.aliases ?? []).join(', ')}\n </p>\n )}\n {/* A child's own URL is what the parent's generic one cannot be -- the\n console of THIS account. Keyed on `appUrl`, so every sub-resource\n carrying one is launchable, not only cloud accounts. Deliberately no\n fallback to the parent's URL: landing on the wrong account is the bug\n this exists to fix. */}\n {subResource.appUrl && (\n <a\n href={subResource.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"mt-3 inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors\"\n title={`Open \\u2192 ${displayUrl(subResource.appUrl)}`}\n aria-label={`Open ${subResource.displayName} in a new tab (${displayUrl(subResource.appUrl)})`}\n >\n <ExternalLink className=\"size-3.5 shrink-0\" />\n Open{' '}\n <span className=\"max-w-[240px] truncate text-primary-foreground/70 text-xs font-normal\">\n {displayUrl(subResource.appUrl)}\n </span>\n </a>\n )}\n </div>\n\n {/* Two-step access section */}\n {hasAnyAccess && (\n <div className=\"mt-6 space-y-6\">\n <h3 className=\"text-sm font-medium\">How to get access</h3>\n\n {hasTwoStepAccess ? (\n <>\n {/* Step 1: Parent access */}\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <StepBadge step={1} />\n <span className=\"text-sm font-medium text-muted-foreground\">\n {parent.displayName}\n </span>\n </div>\n <div className=\"pl-8\">\n <AccessRequestSection\n app={parent}\n approvalMethods={approvalMethods}\n />\n </div>\n </div>\n\n {/* Step 2: Sub-resource access */}\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <StepBadge step={2} />\n <span className=\"text-sm font-medium text-muted-foreground\">\n {subResource.displayName}\n </span>\n </div>\n <div className=\"pl-8\">\n <AccessRequestSection\n app={subResource}\n approvalMethods={approvalMethods}\n />\n </div>\n </div>\n </>\n ) : (\n /* Single-step: only one of them has access info */\n <AccessRequestSection\n app={hasParentAccess ? parent : subResource}\n approvalMethods={approvalMethods}\n />\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;;AAiBA,SAAS,UAAU,EAAE,QAA0B;AAC7C,SACE,oBAAC,QAAA,EAAK,WAAU,iIACb,UAAA,MACH;AAEJ;AAEO,SAAS,uBAAuB;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAgC;AAI9B,QAAM,gBAAgB,CAAC,MAAA;;AACrB,YAAC,CAAC,EAAE,oBACH,OAAE,kBAAF,mBAAiB,WAAU,KAAK,KACjC,CAAC,CAAC,EAAE,mBACJ,CAAC,CAAC,EAAE;AAAA;AAEN,QAAM,kBAAkB,cAAc,MAAM;AAC5C,QAAM,eAAe,cAAc,WAAW;AAC9C,QAAM,mBAAmB,mBAAmB;AAC5C,QAAM,eAAe,mBAAmB;AAExC,SACE,qBAAC,OAAA,EAAI,WAAU,4BAEb,UAAA;AAAA,IAAA,oBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS;AAAA,QACT,WAAU;AAAA,QACV,cAAY,WAAW,OAAO,WAAW;AAAA,QAEzC,UAAA;AAAA,UAAA,oBAAC,WAAA,EAAU,WAAU,SAAA,CAAS;AAAA,UAC9B,oBAAC,QAAA,EAAM,UAAA,OAAO,YAAA,CAAY;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA,GAE9B;AAAA,IAGA,qBAAC,OAAA,EAAI,WAAU,iBACb,UAAA;AAAA,MAAA,qBAAC,OAAA,EAAI,WAAU,qCACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,yBAAyB,UAAA,YAAY,aAAY;AAAA,QAC9D,YAAY,QACX,oBAAC,OAAA,EAAM,SAAQ,WAAU,WAAU,WAChC,UAAA,YAAY,KAAA,CACf;AAAA,MAAA,GAEJ;AAAA,MACC,YAAY,eACX,oBAAC,OAAE,WAAU,sCACV,sBAAY,aACf;AAAA,OAEA,YAAY,WAAW,CAAA,GAAI,SAAS,KACpC,qBAAC,KAAA,EAAE,WAAU,sCAAqC,UAAA;AAAA,QAAA;AAAA,SAC/B,YAAY,WAAW,CAAA,GAAI,KAAK,IAAI;AAAA,MAAA,GACvD;AAAA,MAOD,YAAY,UACX;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAM,YAAY;AAAA,UAClB,QAAO;AAAA,UACP,KAAI;AAAA,UACJ,WAAU;AAAA,UACV,OAAO,UAAe,WAAW,YAAY,MAAM,CAAC;AAAA,UACpD,cAAY,QAAQ,YAAY,WAAW,kBAAkB,WAAW,YAAY,MAAM,CAAC;AAAA,UAE3F,UAAA;AAAA,YAAA,oBAAC,cAAA,EAAa,WAAU,oBAAA,CAAoB;AAAA,YAAE;AAAA,YACzC;AAAA,gCACJ,QAAA,EAAK,WAAU,yEACb,UAAA,WAAW,YAAY,MAAM,EAAA,CAChC;AAAA,UAAA;AAAA,QAAA;AAAA,MAAA;AAAA,IACF,GAEJ;AAAA,IAGC,
|
|
1
|
+
{"version":3,"file":"SubResourceDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx"],"sourcesContent":["import type {\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { ArrowLeft, ExternalLink } from 'lucide-react'\nimport { Button } from '~/ui/button'\nimport { Badge } from '~/ui/badge'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { AccessRequestSection } from './AccessRequestSection'\n\ninterface SubResourceDetailPanelProps {\n subResource: Resource\n parent: Resource\n approvalMethods: AppApprovalMethod[]\n onBack: () => void\n}\n\nfunction StepBadge({ step }: { step: number }) {\n return (\n <span className=\"inline-flex items-center justify-center size-6 rounded-full bg-primary text-primary-foreground text-xs font-semibold shrink-0\">\n {step}\n </span>\n )\n}\n\nexport function SubResourceDetailPanel({\n subResource,\n parent,\n approvalMethods,\n onBack,\n}: SubResourceDetailPanelProps) {\n // Mirrors AccessRequestSection's fallback: a sub-resource usually documents\n // access through top-level `approverSlugs`/`accessComments`, not through an\n // `accessRequest`. Only counting the latter hid its step of the chain.\n const hasAccessInfo = (r: Resource) =>\n !!r.accessRequest ||\n (r.approverSlugs?.length ?? 0) > 0 ||\n !!r.ownerPersonSlug ||\n !!r.accessComments\n\n const hasParentAccess = hasAccessInfo(parent)\n const hasSubAccess = hasAccessInfo(subResource)\n const hasTwoStepAccess = hasParentAccess && hasSubAccess\n const hasAnyAccess = hasParentAccess || hasSubAccess\n\n return (\n <div className=\"flex h-full flex-col p-6\">\n {/* Back navigation */}\n <div className=\"mb-4\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={onBack}\n className=\"gap-1.5 text-muted-foreground hover:text-foreground -ml-2\"\n aria-label={`Back to ${parent.displayName}`}\n >\n <ArrowLeft className=\"size-4\" />\n <span>{parent.displayName}</span>\n </Button>\n </div>\n\n {/* Sub-resource title */}\n <div className=\"border-b pb-4\">\n <div className=\"flex items-center gap-2 flex-wrap\">\n <h2 className=\"text-xl font-semibold\">{subResource.displayName}</h2>\n {subResource.tier && (\n <Badge variant=\"outline\" className=\"text-xs\">\n {subResource.tier}\n </Badge>\n )}\n </div>\n {subResource.description && (\n <p className=\"text-sm text-muted-foreground mt-1\">\n {subResource.description}\n </p>\n )}\n {(subResource.aliases ?? []).length > 0 && (\n <p className=\"text-xs text-muted-foreground mt-1\">\n Also known as: {(subResource.aliases ?? []).join(', ')}\n </p>\n )}\n {/* A child's own URL is what the parent's generic one cannot be -- the\n console of THIS account. Keyed on `appUrl`, so every sub-resource\n carrying one is launchable, not only cloud accounts. Deliberately no\n fallback to the parent's URL: landing on the wrong account is the bug\n this exists to fix. */}\n {subResource.appUrl && (\n <a\n href={subResource.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"mt-3 inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors\"\n title={`Open \\u2192 ${displayUrl(subResource.appUrl)}`}\n aria-label={`Open ${subResource.displayName} in a new tab (${displayUrl(subResource.appUrl)})`}\n >\n <ExternalLink className=\"size-3.5 shrink-0\" />\n Open{' '}\n <span className=\"max-w-[240px] truncate text-primary-foreground/70 text-xs font-normal\">\n {displayUrl(subResource.appUrl)}\n </span>\n </a>\n )}\n </div>\n\n {/* Two-step access section */}\n {hasAnyAccess && (\n // The whole two-step region carries the anchor: the step labels naming\n // the parent live out here, not inside the nested AccessRequestSections,\n // so anchoring only those dropped the parent from the chain.\n <div data-access-section=\"\" className=\"mt-6 space-y-6\">\n <h3 className=\"text-sm font-medium\">How to get access</h3>\n\n {hasTwoStepAccess ? (\n <>\n {/* Step 1: Parent access */}\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <StepBadge step={1} />\n <span className=\"text-sm font-medium text-muted-foreground\">\n {parent.displayName}\n </span>\n </div>\n <div className=\"pl-8\">\n <AccessRequestSection\n app={parent}\n approvalMethods={approvalMethods}\n />\n </div>\n </div>\n\n {/* Step 2: Sub-resource access */}\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <StepBadge step={2} />\n <span className=\"text-sm font-medium text-muted-foreground\">\n {subResource.displayName}\n </span>\n </div>\n <div className=\"pl-8\">\n <AccessRequestSection\n app={subResource}\n approvalMethods={approvalMethods}\n />\n </div>\n </div>\n </>\n ) : (\n /* Single-step: only one of them has access info */\n <AccessRequestSection\n app={hasParentAccess ? parent : subResource}\n approvalMethods={approvalMethods}\n />\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|