@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.4.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.
Files changed (61) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  4. package/dist/esm/api/infra/trpc.d.ts +42 -21
  5. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  10. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  11. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  13. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  15. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  17. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +88 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  24. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  28. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  29. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  30. package/dist/index.css +269 -5
  31. package/package.json +4 -4
  32. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  33. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  34. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  35. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +169 -0
  36. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  37. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  38. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  39. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  40. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  41. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  42. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  43. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  44. package/src/index.css +269 -5
  45. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  46. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  47. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  48. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  49. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  50. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  51. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +188 -0
  52. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  53. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  54. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  55. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  56. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  58. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  59. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  60. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.emptyState.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
  61. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.subResourceTab.test.d.ts} +0 -0
@@ -1,228 +0,0 @@
1
- import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
- import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query";
3
- import { useState } from "react";
4
- import { useTRPC } from "../../../../api/infra/trpc.js";
5
- import { Button } from "../../../../ui/button.js";
6
- import { cn } from "../../../../lib/utils.js";
7
- import { formatRelativeTime } from "../../utils/formatRelativeTime.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
- function errorMessage(error) {
10
- return error instanceof Error ? error.message : "Something went wrong";
11
- }
12
- const STATUS_LABELS = {
13
- applied: {
14
- text: "✓ Applied — thank you!",
15
- className: "comment-chip-applied",
16
- title: "This comment improved the entry — the catalog now carries what it said."
17
- },
18
- acknowledged: {
19
- text: "Seen",
20
- className: "comment-chip-seen",
21
- title: "A maintainer read this comment and left the entry as it was."
22
- }
23
- };
24
- function StatusChip({ status }) {
25
- const label = STATUS_LABELS[status];
26
- return /* @__PURE__ */ jsx("span", { className: cn("comment-chip", label.className), title: label.title, children: label.text });
27
- }
28
- function ReviewerReply({ reply }) {
29
- return /* @__PURE__ */ jsxs("div", { className: "border-primary/45 bg-muted/60 mt-1.5 rounded-r-lg border-l-2 px-2.5 py-1.5", children: [
30
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground font-medium", children: "Catalog team" }),
31
- /* @__PURE__ */ jsx("p", { className: "mt-0.5 whitespace-pre-wrap", children: reply })
32
- ] });
33
- }
34
- function isSubmitChord(event) {
35
- return event.key === "Enter" && (event.metaKey || event.ctrlKey);
36
- }
37
- function CommentsSection({ appSlug }) {
38
- const trpc = useTRPC();
39
- const queryClient = useQueryClient();
40
- const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug });
41
- const { data: comments, isLoading, error } = useQuery(listOptions);
42
- const [draft, setDraft] = useState("");
43
- const [editingId, setEditingId] = useState(null);
44
- const [editDraft, setEditDraft] = useState("");
45
- const [composerOpen, setComposerOpen] = useState(false);
46
- const refresh = () => queryClient.invalidateQueries({ queryKey: listOptions.queryKey });
47
- const add = useMutation(
48
- trpc.comments.add.mutationOptions({
49
- onSuccess: () => {
50
- setDraft("");
51
- void refresh();
52
- }
53
- })
54
- );
55
- const edit = useMutation(
56
- trpc.comments.edit.mutationOptions({
57
- onSuccess: () => {
58
- setEditingId(null);
59
- void refresh();
60
- }
61
- })
62
- );
63
- const remove = useMutation(
64
- trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() })
65
- );
66
- const post = () => {
67
- const body = draft.trim();
68
- if (body) add.mutate({ resourceSlug: appSlug, body });
69
- };
70
- const saveEdit = (id) => {
71
- const body = editDraft.trim();
72
- if (body) edit.mutate({ id, body });
73
- };
74
- const failure = error ?? add.error ?? edit.error ?? remove.error;
75
- const hasComments = !!comments && comments.length > 0;
76
- const hasLabelled = !!(comments == null ? void 0 : comments.some((comment) => comment.status));
77
- return /* @__PURE__ */ jsxs("div", { className: "mt-6", children: [
78
- /* @__PURE__ */ jsxs("h3", { className: "mb-2 text-sm font-medium", children: [
79
- "Comments",
80
- comments && comments.length > 0 && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground ml-1 font-normal", children: [
81
- "(",
82
- comments.length,
83
- ")"
84
- ] })
85
- ] }),
86
- isLoading ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: "Loading comments…" }) : comments && comments.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "space-y-3", children: comments.map((comment) => {
87
- const canEdit = comment.isMine && !!comment.canEditUntil && Date.parse(comment.canEditUntil) > Date.now();
88
- return /* @__PURE__ */ jsxs("li", { className: "text-xs", children: [
89
- /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground flex items-center gap-2", children: [
90
- /* @__PURE__ */ jsx("span", { className: "text-foreground font-medium", children: comment.authorName }),
91
- /* @__PURE__ */ jsx("span", { children: formatRelativeTime(comment.createdAt) }),
92
- comment.editedAt && /* @__PURE__ */ jsx("span", { children: "(edited)" }),
93
- comment.status && /* @__PURE__ */ jsx(StatusChip, { status: comment.status }),
94
- canEdit && editingId !== comment.id && /* @__PURE__ */ jsxs(Fragment, { children: [
95
- /* @__PURE__ */ jsx(
96
- "button",
97
- {
98
- type: "button",
99
- className: "hover:text-foreground underline",
100
- onClick: () => {
101
- setEditingId(comment.id);
102
- setEditDraft(comment.body);
103
- },
104
- children: "Edit"
105
- }
106
- ),
107
- /* @__PURE__ */ jsx(
108
- "button",
109
- {
110
- type: "button",
111
- className: "hover:text-destructive underline",
112
- onClick: () => remove.mutate({ id: comment.id }),
113
- children: "Delete"
114
- }
115
- )
116
- ] })
117
- ] }),
118
- editingId === comment.id ? /* @__PURE__ */ jsxs("div", { className: "mt-1 space-y-2", children: [
119
- /* @__PURE__ */ jsx(
120
- "textarea",
121
- {
122
- autoFocus: true,
123
- rows: 3,
124
- className: cn(FIELD_CLASSES),
125
- value: editDraft,
126
- onChange: (event) => setEditDraft(event.target.value),
127
- onKeyDown: (event) => {
128
- if (isSubmitChord(event)) {
129
- event.preventDefault();
130
- saveEdit(comment.id);
131
- }
132
- }
133
- }
134
- ),
135
- /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
136
- /* @__PURE__ */ jsx(
137
- Button,
138
- {
139
- type: "button",
140
- size: "sm",
141
- disabled: !editDraft.trim() || edit.isPending,
142
- onClick: () => saveEdit(comment.id),
143
- children: "Save"
144
- }
145
- ),
146
- /* @__PURE__ */ jsx(
147
- Button,
148
- {
149
- type: "button",
150
- size: "sm",
151
- variant: "ghost",
152
- onClick: () => setEditingId(null),
153
- children: "Cancel"
154
- }
155
- )
156
- ] })
157
- ] }) : /* @__PURE__ */ jsx("p", { className: "mt-0.5 whitespace-pre-wrap", children: comment.body }),
158
- comment.reviewerReply && /* @__PURE__ */ jsx(ReviewerReply, { reply: comment.reviewerReply })
159
- ] }, comment.id);
160
- }) }) : null,
161
- hasLabelled && /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground border-border mt-3 border-t pt-2.5 text-[0.6875rem] leading-relaxed", children: [
162
- /* @__PURE__ */ jsx(StatusChip, { status: "applied" }),
163
- " means the comment improved this entry — the catalog now carries what it said.",
164
- " ",
165
- /* @__PURE__ */ jsx(StatusChip, { status: "acknowledged" }),
166
- " means a maintainer read it and left the entry as it was."
167
- ] }),
168
- !isLoading && !hasComments && /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-xs", children: [
169
- "No comments yet.",
170
- " ",
171
- !composerOpen && /* @__PURE__ */ jsx(
172
- "button",
173
- {
174
- type: "button",
175
- className: "hover:text-foreground underline",
176
- onClick: () => setComposerOpen(true),
177
- children: "Be the first."
178
- }
179
- )
180
- ] }),
181
- (hasComments || composerOpen) && /* @__PURE__ */ jsxs(
182
- "div",
183
- {
184
- className: cn(
185
- "mt-3 space-y-2",
186
- composerOpen && "animate-in fade-in slide-in-from-top-2 duration-200"
187
- ),
188
- children: [
189
- /* @__PURE__ */ jsx(
190
- "textarea",
191
- {
192
- autoFocus: composerOpen,
193
- rows: 3,
194
- className: cn(FIELD_CLASSES),
195
- placeholder: "Leave a comment…",
196
- value: draft,
197
- onChange: (event) => setDraft(event.target.value),
198
- onKeyDown: (event) => {
199
- if (isSubmitChord(event)) {
200
- event.preventDefault();
201
- post();
202
- }
203
- }
204
- }
205
- ),
206
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
207
- /* @__PURE__ */ jsx(
208
- Button,
209
- {
210
- type: "button",
211
- size: "sm",
212
- disabled: !draft.trim() || add.isPending,
213
- onClick: post,
214
- children: add.isPending ? "Posting…" : "Post"
215
- }
216
- ),
217
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Posted under a nickname. You can edit or delete it for one hour." })
218
- ] })
219
- ]
220
- }
221
- ),
222
- failure && /* @__PURE__ */ jsx("p", { className: "text-destructive mt-2 text-xs", children: errorMessage(failure) })
223
- ] });
224
- }
225
- export {
226
- CommentsSection
227
- };
228
- //# sourceMappingURL=CommentsSection.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"CommentsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommentsSection.tsx"],"sourcesContent":["import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { cn } from '~/lib/utils'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/**\n * Feedback on one app, from anyone browsing the catalog.\n *\n * Nobody has to log in to leave a comment, so the server attributes each one to a\n * pseudonym derived from an httpOnly cookie (\"Curious Ferret\"). That cookie is also\n * the only thing that authorises editing or deleting, and only for the first hour —\n * `canEditUntil` arrives from the server as an absolute instant so a skewed client\n * clock cannot show controls the server would refuse.\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\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : 'Something went wrong'\n}\n\n/**\n * What a maintainer did with a comment, as the reader sees it.\n *\n * `applied` is the point of the whole thing: a comment that improved the entry\n * gets public credit, so the next person can see that commenting changes the\n * catalog. Nobody sets these from the UI — the review tooling writes them.\n */\nconst STATUS_LABELS = {\n applied: {\n text: '✓ Applied — thank you!',\n className: 'comment-chip-applied',\n title:\n 'This comment improved the entry — the catalog now carries what it said.',\n },\n acknowledged: {\n text: 'Seen',\n className: 'comment-chip-seen',\n title: 'A maintainer read this comment and left the entry as it was.',\n },\n} as const\n\nfunction StatusChip({ status }: { status: 'applied' | 'acknowledged' }) {\n const label = STATUS_LABELS[status]\n return (\n <span className={cn('comment-chip', label.className)} title={label.title}>\n {label.text}\n </span>\n )\n}\n\n/** The maintainers' answer, attributed to the team rather than a pseudonym. */\nfunction ReviewerReply({ reply }: { reply: string }) {\n return (\n <div className=\"border-primary/45 bg-muted/60 mt-1.5 rounded-r-lg border-l-2 px-2.5 py-1.5\">\n <span className=\"text-muted-foreground font-medium\">Catalog team</span>\n <p className=\"mt-0.5 whitespace-pre-wrap\">{reply}</p>\n </div>\n )\n}\n\n/** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */\nfunction isSubmitChord(event: React.KeyboardEvent): boolean {\n return event.key === 'Enter' && (event.metaKey || event.ctrlKey)\n}\n\nexport function CommentsSection({ appSlug }: { appSlug: string }) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug })\n const { data: comments, isLoading, error } = useQuery(listOptions)\n\n const [draft, setDraft] = useState('')\n const [editingId, setEditingId] = useState<string | null>(null)\n const [editDraft, setEditDraft] = useState('')\n /**\n * Most apps have no comments, and an empty form under \"no comments yet\" is just\n * noise. So the composer stays behind the invitation until someone accepts it —\n * which is also what earns the caret: focus follows the click, never the mount.\n */\n const [composerOpen, setComposerOpen] = useState(false)\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n\n const add = useMutation(\n trpc.comments.add.mutationOptions({\n onSuccess: () => {\n setDraft('')\n void refresh()\n },\n }),\n )\n const edit = useMutation(\n trpc.comments.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const remove = useMutation(\n trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() }),\n )\n\n const post = () => {\n const body = draft.trim()\n if (body) add.mutate({ resourceSlug: appSlug, body })\n }\n\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n // One line for every failed action: three mutations and one query, one place to look.\n const failure = error ?? add.error ?? edit.error ?? remove.error\n const hasComments = !!comments && comments.length > 0\n const hasLabelled = !!comments?.some((comment) => comment.status)\n\n return (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Comments\n {comments && comments.length > 0 && (\n <span className=\"text-muted-foreground ml-1 font-normal\">\n ({comments.length})\n </span>\n )}\n </h3>\n\n {isLoading ? (\n <p className=\"text-muted-foreground text-xs\">Loading comments…</p>\n ) : comments && comments.length > 0 ? (\n <ul className=\"space-y-3\">\n {comments.map((comment) => {\n const canEdit =\n comment.isMine &&\n !!comment.canEditUntil &&\n Date.parse(comment.canEditUntil) > Date.now()\n\n return (\n <li key={comment.id} className=\"text-xs\">\n <div className=\"text-muted-foreground flex items-center gap-2\">\n <span className=\"text-foreground font-medium\">\n {comment.authorName}\n </span>\n <span>{formatRelativeTime(comment.createdAt)}</span>\n {comment.editedAt && <span>(edited)</span>}\n {comment.status && <StatusChip status={comment.status} />}\n {canEdit && editingId !== comment.id && (\n <>\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => {\n setEditingId(comment.id)\n setEditDraft(comment.body)\n }}\n >\n Edit\n </button>\n <button\n type=\"button\"\n className=\"hover:text-destructive underline\"\n onClick={() => remove.mutate({ id: comment.id })}\n >\n Delete\n </button>\n </>\n )}\n </div>\n\n {editingId === comment.id ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={cn(FIELD_CLASSES)}\n value={editDraft}\n onChange={(event) => setEditDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n saveEdit(comment.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(comment.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : (\n <p className=\"mt-0.5 whitespace-pre-wrap\">{comment.body}</p>\n )}\n\n {comment.reviewerReply && (\n <ReviewerReply reply={comment.reviewerReply} />\n )}\n </li>\n )\n })}\n </ul>\n ) : null}\n\n {/* A chip nobody can decode motivates nobody — but the legend is noise on\n an entry where no comment has been reviewed yet, so it comes and goes\n with the chips. */}\n {hasLabelled && (\n <p className=\"text-muted-foreground border-border mt-3 border-t pt-2.5 text-[0.6875rem] leading-relaxed\">\n <StatusChip status=\"applied\" /> means the comment improved this entry\n — the catalog now carries what it said.{' '}\n <StatusChip status=\"acknowledged\" /> means a maintainer read it and\n left the entry as it was.\n </p>\n )}\n\n {!isLoading && !hasComments && (\n <p className=\"text-muted-foreground text-xs\">\n No comments yet.{' '}\n {!composerOpen && (\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => setComposerOpen(true)}\n >\n Be the first.\n </button>\n )}\n </p>\n )}\n\n {(hasComments || composerOpen) && (\n <div\n className={cn(\n 'mt-3 space-y-2',\n composerOpen &&\n 'animate-in fade-in slide-in-from-top-2 duration-200',\n )}\n >\n <textarea\n autoFocus={composerOpen}\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"Leave a comment…\"\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n post()\n }\n }}\n />\n <div className=\"flex items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!draft.trim() || add.isPending}\n onClick={post}\n >\n {add.isPending ? 'Posting…' : 'Post'}\n </Button>\n <span className=\"text-muted-foreground text-xs\">\n Posted under a nickname. You can edit or delete it for one hour.\n </span>\n </div>\n </div>\n )}\n\n {/* Outside the composer: a failed list query leaves nothing to collapse into. */}\n {failure && (\n <p className=\"text-destructive mt-2 text-xs\">{errorMessage(failure)}</p>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;;;AAkBA,MAAM,gBACJ;AAEF,SAAS,aAAa,OAAwB;AAC5C,SAAO,iBAAiB,QAAQ,MAAM,UAAU;AAClD;AASA,MAAM,gBAAgB;AAAA,EACpB,SAAS;AAAA,IACP,MAAM;AAAA,IACN,WAAW;AAAA,IACX,OACE;AAAA,EAAA;AAAA,EAEJ,cAAc;AAAA,IACZ,MAAM;AAAA,IACN,WAAW;AAAA,IACX,OAAO;AAAA,EAAA;AAEX;AAEA,SAAS,WAAW,EAAE,UAAkD;AACtE,QAAM,QAAQ,cAAc,MAAM;AAClC,SACE,oBAAC,QAAA,EAAK,WAAW,GAAG,gBAAgB,MAAM,SAAS,GAAG,OAAO,MAAM,OAChE,UAAA,MAAM,MACT;AAEJ;AAGA,SAAS,cAAc,EAAE,SAA4B;AACnD,SACE,qBAAC,OAAA,EAAI,WAAU,8EACb,UAAA;AAAA,IAAA,oBAAC,QAAA,EAAK,WAAU,qCAAoC,UAAA,gBAAY;AAAA,IAChE,oBAAC,KAAA,EAAE,WAAU,8BAA8B,UAAA,MAAA,CAAM;AAAA,EAAA,GACnD;AAEJ;AAGA,SAAS,cAAc,OAAqC;AAC1D,SAAO,MAAM,QAAQ,YAAY,MAAM,WAAW,MAAM;AAC1D;AAEO,SAAS,gBAAgB,EAAE,WAAgC;AAChE,QAAM,OAAO,QAAA;AACb,QAAM,cAAc,eAAA;AACpB,QAAM,cAAc,KAAK,SAAS,KAAK,aAAa,EAAE,cAAc,SAAS;AAC7E,QAAM,EAAE,MAAM,UAAU,WAAW,MAAA,IAAU,SAAS,WAAW;AAEjE,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,EAAE;AACrC,QAAM,CAAC,WAAW,YAAY,IAAI,SAAwB,IAAI;AAC9D,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,EAAE;AAM7C,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AAEtD,QAAM,UAAU,MACd,YAAY,kBAAkB,EAAE,UAAU,YAAY,UAAU;AAElE,QAAM,MAAM;AAAA,IACV,KAAK,SAAS,IAAI,gBAAgB;AAAA,MAChC,WAAW,MAAM;AACf,iBAAS,EAAE;AACX,aAAK,QAAA;AAAA,MACP;AAAA,IAAA,CACD;AAAA,EAAA;AAEH,QAAM,OAAO;AAAA,IACX,KAAK,SAAS,KAAK,gBAAgB;AAAA,MACjC,WAAW,MAAM;AACf,qBAAa,IAAI;AACjB,aAAK,QAAA;AAAA,MACP;AAAA,IAAA,CACD;AAAA,EAAA;AAEH,QAAM,SAAS;AAAA,IACb,KAAK,SAAS,OAAO,gBAAgB,EAAE,WAAW,MAAM,KAAK,UAAQ,CAAG;AAAA,EAAA;AAG1E,QAAM,OAAO,MAAM;AACjB,UAAM,OAAO,MAAM,KAAA;AACnB,QAAI,KAAM,KAAI,OAAO,EAAE,cAAc,SAAS,MAAM;AAAA,EACtD;AAEA,QAAM,WAAW,CAAC,OAAe;AAC/B,UAAM,OAAO,UAAU,KAAA;AACvB,QAAI,KAAM,MAAK,OAAO,EAAE,IAAI,MAAM;AAAA,EACpC;AAGA,QAAM,UAAU,SAAS,IAAI,SAAS,KAAK,SAAS,OAAO;AAC3D,QAAM,cAAc,CAAC,CAAC,YAAY,SAAS,SAAS;AACpD,QAAM,cAAc,CAAC,EAAC,qCAAU,KAAK,CAAC,YAAY,QAAQ;AAE1D,SACE,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,IAAA,qBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA;AAAA,MAAA;AAAA,MAEtC,YAAY,SAAS,SAAS,KAC7B,qBAAC,QAAA,EAAK,WAAU,0CAAyC,UAAA;AAAA,QAAA;AAAA,QACrD,SAAS;AAAA,QAAO;AAAA,MAAA,EAAA,CACpB;AAAA,IAAA,GAEJ;AAAA,IAEC,YACC,oBAAC,KAAA,EAAE,WAAU,iCAAgC,UAAA,oBAAA,CAAiB,IAC5D,YAAY,SAAS,SAAS,wBAC/B,MAAA,EAAG,WAAU,aACX,UAAA,SAAS,IAAI,CAAC,YAAY;AACzB,YAAM,UACJ,QAAQ,UACR,CAAC,CAAC,QAAQ,gBACV,KAAK,MAAM,QAAQ,YAAY,IAAI,KAAK,IAAA;AAE1C,aACE,qBAAC,MAAA,EAAoB,WAAU,WAC7B,UAAA;AAAA,QAAA,qBAAC,OAAA,EAAI,WAAU,iDACb,UAAA;AAAA,UAAA,oBAAC,QAAA,EAAK,WAAU,+BACb,UAAA,QAAQ,YACX;AAAA,UACA,oBAAC,QAAA,EAAM,UAAA,mBAAmB,QAAQ,SAAS,GAAE;AAAA,UAC5C,QAAQ,YAAY,oBAAC,QAAA,EAAK,UAAA,YAAQ;AAAA,UAClC,QAAQ,UAAU,oBAAC,YAAA,EAAW,QAAQ,QAAQ,QAAQ;AAAA,UACtD,WAAW,cAAc,QAAQ,MAChC,qBAAA,UAAA,EACE,UAAA;AAAA,YAAA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,MAAK;AAAA,gBACL,WAAU;AAAA,gBACV,SAAS,MAAM;AACb,+BAAa,QAAQ,EAAE;AACvB,+BAAa,QAAQ,IAAI;AAAA,gBAC3B;AAAA,gBACD,UAAA;AAAA,cAAA;AAAA,YAAA;AAAA,YAGD;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,MAAK;AAAA,gBACL,WAAU;AAAA,gBACV,SAAS,MAAM,OAAO,OAAO,EAAE,IAAI,QAAQ,IAAI;AAAA,gBAChD,UAAA;AAAA,cAAA;AAAA,YAAA;AAAA,UAED,EAAA,CACF;AAAA,QAAA,GAEJ;AAAA,QAEC,cAAc,QAAQ,KACrB,qBAAC,OAAA,EAAI,WAAU,kBACb,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,WAAS;AAAA,cACT,MAAM;AAAA,cACN,WAAW,GAAG,aAAa;AAAA,cAC3B,OAAO;AAAA,cACP,UAAU,CAAC,UAAU,aAAa,MAAM,OAAO,KAAK;AAAA,cACpD,WAAW,CAAC,UAAU;AACpB,oBAAI,cAAc,KAAK,GAAG;AACxB,wBAAM,eAAA;AACN,2BAAS,QAAQ,EAAE;AAAA,gBACrB;AAAA,cACF;AAAA,YAAA;AAAA,UAAA;AAAA,UAEF,qBAAC,OAAA,EAAI,WAAU,cACb,UAAA;AAAA,YAAA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,MAAK;AAAA,gBACL,MAAK;AAAA,gBACL,UAAU,CAAC,UAAU,KAAA,KAAU,KAAK;AAAA,gBACpC,SAAS,MAAM,SAAS,QAAQ,EAAE;AAAA,gBACnC,UAAA;AAAA,cAAA;AAAA,YAAA;AAAA,YAGD;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,MAAK;AAAA,gBACL,MAAK;AAAA,gBACL,SAAQ;AAAA,gBACR,SAAS,MAAM,aAAa,IAAI;AAAA,gBACjC,UAAA;AAAA,cAAA;AAAA,YAAA;AAAA,UAED,EAAA,CACF;AAAA,QAAA,GACF,IAEA,oBAAC,KAAA,EAAE,WAAU,8BAA8B,kBAAQ,MAAK;AAAA,QAGzD,QAAQ,iBACP,oBAAC,eAAA,EAAc,OAAO,QAAQ,cAAA,CAAe;AAAA,MAAA,EAAA,GAtExC,QAAQ,EAwEjB;AAAA,IAEJ,CAAC,GACH,IACE;AAAA,IAKH,eACC,qBAAC,KAAA,EAAE,WAAU,6FACX,UAAA;AAAA,MAAA,oBAAC,YAAA,EAAW,QAAO,UAAA,CAAU;AAAA,MAAE;AAAA,MACS;AAAA,MACxC,oBAAC,YAAA,EAAW,QAAO,eAAA,CAAe;AAAA,MAAE;AAAA,IAAA,GAEtC;AAAA,IAGD,CAAC,aAAa,CAAC,eACd,qBAAC,KAAA,EAAE,WAAU,iCAAgC,UAAA;AAAA,MAAA;AAAA,MAC1B;AAAA,MAChB,CAAC,gBACA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,SAAS,MAAM,gBAAgB,IAAI;AAAA,UACpC,UAAA;AAAA,QAAA;AAAA,MAAA;AAAA,IAED,GAEJ;AAAA,KAGA,eAAe,iBACf;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,gBACE;AAAA,QAAA;AAAA,QAGJ,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,WAAW;AAAA,cACX,MAAM;AAAA,cACN,WAAW,GAAG,aAAa;AAAA,cAC3B,aAAY;AAAA,cACZ,OAAO;AAAA,cACP,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,cAChD,WAAW,CAAC,UAAU;AACpB,oBAAI,cAAc,KAAK,GAAG;AACxB,wBAAM,eAAA;AACN,uBAAA;AAAA,gBACF;AAAA,cACF;AAAA,YAAA;AAAA,UAAA;AAAA,UAEF,qBAAC,OAAA,EAAI,WAAU,2BACb,UAAA;AAAA,YAAA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,MAAK;AAAA,gBACL,MAAK;AAAA,gBACL,UAAU,CAAC,MAAM,KAAA,KAAU,IAAI;AAAA,gBAC/B,SAAS;AAAA,gBAER,UAAA,IAAI,YAAY,aAAa;AAAA,cAAA;AAAA,YAAA;AAAA,YAEhC,oBAAC,QAAA,EAAK,WAAU,iCAAgC,UAAA,mEAAA,CAEhD;AAAA,UAAA,EAAA,CACF;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA;AAAA,IAKH,WACC,oBAAC,KAAA,EAAE,WAAU,iCAAiC,UAAA,aAAa,OAAO,EAAA,CAAE;AAAA,EAAA,GAExE;AAEJ;"}
@@ -1,64 +0,0 @@
1
- import { render, screen } from '@testing-library/react'
2
- import userEvent from '@testing-library/user-event'
3
- import { describe, expect, it, vi } from 'vitest'
4
- import '@testing-library/jest-dom/vitest'
5
-
6
- // The section only needs a comment list here; the whole tRPC/react-query stack is
7
- // stubbed down to that one query so the empty state is the only thing under test.
8
- const state = vi.hoisted(() => ({
9
- comments: [] as unknown[],
10
- }))
11
-
12
- vi.mock('@tanstack/react-query', () => ({
13
- useQuery: vi.fn(() => ({ data: state.comments, isLoading: false })),
14
- useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
15
- useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
16
- }))
17
- vi.mock('~/api/infra/trpc', () => ({
18
- useTRPC: vi.fn(() => ({
19
- comments: {
20
- list: { queryOptions: () => ({ queryKey: ['comments'] }) },
21
- add: { mutationOptions: (o: unknown) => o },
22
- edit: { mutationOptions: (o: unknown) => o },
23
- remove: { mutationOptions: (o: unknown) => o },
24
- },
25
- })),
26
- }))
27
-
28
- const { CommentsSection } =
29
- await import('~/modules/appCatalog/ui/detail/CommentsSection')
30
-
31
- describe('CommentsSection — empty state', () => {
32
- it('keeps the composer behind "Be the first." until it is clicked', async () => {
33
- state.comments = []
34
- render(<CommentsSection appSlug="example-app" />)
35
-
36
- const invite = screen.getByRole('button', { name: 'Be the first.' })
37
- expect(screen.queryByPlaceholderText('Leave a comment…')).toBeNull()
38
-
39
- await userEvent.click(invite)
40
-
41
- const field = screen.getByPlaceholderText('Leave a comment…')
42
- expect(field).toBeInTheDocument()
43
- expect(field).toHaveFocus()
44
- // One affordance at a time: the invite hides itself once it has done its job.
45
- expect(screen.queryByRole('button', { name: 'Be the first.' })).toBeNull()
46
- })
47
-
48
- it('shows the composer straight away once comments exist, without stealing focus', () => {
49
- state.comments = [
50
- {
51
- id: 'c1',
52
- authorName: 'Curious Ferret',
53
- body: 'Useful.',
54
- createdAt: new Date().toISOString(),
55
- },
56
- ]
57
- render(<CommentsSection appSlug="example-app" />)
58
-
59
- const field = screen.getByPlaceholderText('Leave a comment…')
60
- expect(field).toBeInTheDocument()
61
- expect(field).not.toHaveFocus()
62
- expect(screen.queryByRole('button', { name: 'Be the first.' })).toBeNull()
63
- })
64
- })
@@ -1,93 +0,0 @@
1
- import { render, screen } from '@testing-library/react'
2
- import { describe, expect, it, vi } from 'vitest'
3
- import '@testing-library/jest-dom/vitest'
4
-
5
- // Same stub shape as the empty-state test: only the list query matters here.
6
- const state = vi.hoisted(() => ({
7
- comments: [] as unknown[],
8
- }))
9
-
10
- vi.mock('@tanstack/react-query', () => ({
11
- useQuery: vi.fn(() => ({ data: state.comments, isLoading: false })),
12
- useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
13
- useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
14
- }))
15
- vi.mock('~/api/infra/trpc', () => ({
16
- useTRPC: vi.fn(() => ({
17
- comments: {
18
- list: { queryOptions: () => ({ queryKey: ['comments'] }) },
19
- add: { mutationOptions: (o: unknown) => o },
20
- edit: { mutationOptions: (o: unknown) => o },
21
- remove: { mutationOptions: (o: unknown) => o },
22
- },
23
- })),
24
- }))
25
-
26
- const { CommentsSection } =
27
- await import('~/modules/appCatalog/ui/detail/CommentsSection')
28
-
29
- const base = {
30
- id: 'c1',
31
- authorName: 'Merry Mole',
32
- body: 'This portal only covers one business unit.',
33
- createdAt: new Date().toISOString(),
34
- editedAt: null,
35
- isMine: false,
36
- canEditUntil: null,
37
- status: null as string | null,
38
- reviewerReply: null as string | null,
39
- }
40
-
41
- /** The chip text, minus the legend's two sample chips. */
42
- function chips(name: RegExp) {
43
- return screen.getAllByText(name)
44
- }
45
-
46
- describe('CommentsSection — review status', () => {
47
- it('credits an applied comment and explains the label', () => {
48
- state.comments = [{ ...base, status: 'applied' }]
49
- render(<CommentsSection appSlug="example-app" />)
50
-
51
- // One on the comment, one in the legend.
52
- expect(chips(/Applied — thank you!/)).toHaveLength(2)
53
- expect(
54
- screen.getByText(/means the comment improved this entry/),
55
- ).toBeInTheDocument()
56
- })
57
-
58
- it('shows the quieter label for a comment that changed nothing', () => {
59
- state.comments = [{ ...base, status: 'acknowledged' }]
60
- render(<CommentsSection appSlug="example-app" />)
61
-
62
- expect(chips(/^Seen$/)).toHaveLength(2)
63
- expect(screen.getByText(/left the entry as it was/)).toBeInTheDocument()
64
- })
65
-
66
- it('renders the maintainer reply under the comment', () => {
67
- state.comments = [
68
- {
69
- ...base,
70
- status: 'applied',
71
- reviewerReply: 'Folded into Background — thank you.',
72
- },
73
- ]
74
- render(<CommentsSection appSlug="example-app" />)
75
-
76
- expect(screen.getByText('Catalog team')).toBeInTheDocument()
77
- expect(
78
- screen.getByText('Folded into Background — thank you.'),
79
- ).toBeInTheDocument()
80
- })
81
-
82
- it('stays silent on an unreviewed comment — no chip, no legend', () => {
83
- state.comments = [base]
84
- render(<CommentsSection appSlug="example-app" />)
85
-
86
- expect(screen.queryByText(/Applied — thank you!/)).toBeNull()
87
- expect(screen.queryByText(/^Seen$/)).toBeNull()
88
- expect(
89
- screen.queryByText(/means the comment improved this entry/),
90
- ).toBeNull()
91
- expect(screen.queryByText('Catalog team')).toBeNull()
92
- })
93
- })