@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.
Files changed (73) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  5. package/dist/esm/api/infra/trpc.d.ts +42 -21
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
  12. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
  13. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  17. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  19. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  23. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
  34. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  36. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  37. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  38. package/dist/index.css +269 -5
  39. package/package.json +4 -4
  40. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  41. package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
  42. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  43. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  44. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
  45. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  46. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  49. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  50. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  51. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  52. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  53. package/src/index.css +269 -5
  54. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
  55. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  56. package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
  57. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  58. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  59. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  60. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  61. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  62. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
  63. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  64. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
  65. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  66. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  67. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  68. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  69. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  70. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  71. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  72. /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
  73. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
@@ -0,0 +1,95 @@
1
+ import type {
2
+ ApprovalMethod,
3
+ Resource,
4
+ } from '@igstack/app-catalog-backend-core'
5
+
6
+ /**
7
+ * What the detail card's access tab says about itself.
8
+ *
9
+ * `route` names how you get in — the tab reads `Access — <route>` — and `chip`
10
+ * is the one number worth carrying next to it.
11
+ */
12
+ export interface AccessTabLabel {
13
+ route: string
14
+ chip?: string
15
+ }
16
+
17
+ /** Shown when nothing in the entry records a way in. */
18
+ export const ROUTE_UNDOCUMENTED = 'not documented'
19
+ /** Shown when the resource needs no request at all. */
20
+ export const ROUTE_FREE = 'Free'
21
+ /** A request with no named channel behind it. */
22
+ export const ROUTE_REQUEST = 'request'
23
+
24
+ /**
25
+ * Names the access route for a resource's tab.
26
+ *
27
+ * The point is that the tab answers "can I get in?" BEFORE it is opened. That
28
+ * is what lets every entry carry the same four tabs: an access tab holding one
29
+ * sentence saying the route is unknown is a dead end when you have to click to
30
+ * find out, and merely a fact you already read when the label says it.
31
+ *
32
+ * A named channel beats the step count, because the channel is the thing people
33
+ * recognise — most entries route through one or two of them, and "two steps"
34
+ * describes effort rather than destination. The count rides in `chip` instead.
35
+ */
36
+ export function accessTabLabel(
37
+ resource: Resource,
38
+ approvalMethods: ApprovalMethod[],
39
+ ): AccessTabLabel {
40
+ const request = resource.accessRequest
41
+ // Children carry their access as top-level fields rather than an
42
+ // `accessRequest`, and the detail card's access section already falls back to
43
+ // them. The tab has to agree with the section below it, or the label promises
44
+ // something the panel does not show.
45
+ const hasContactsOnly =
46
+ (resource.approverSlugs?.length ?? 0) > 0 ||
47
+ Boolean(resource.accessComments)
48
+
49
+ if (!request) {
50
+ return hasContactsOnly
51
+ ? { route: ROUTE_REQUEST, chip: undefined }
52
+ : { route: ROUTE_UNDOCUMENTED }
53
+ }
54
+
55
+ const method = approvalMethods.find(
56
+ (m) => m.slug === request.approvalMethodSlug,
57
+ )
58
+ const hasWrittenSteps = Boolean(request.requestPrompt || request.comments)
59
+ const isTwoStep = Boolean(hasWrittenSteps && request.postApprovalInstructions)
60
+
61
+ // Steps before roles: two-step is the rarer and more consequential shape, and
62
+ // an entry that is both (a named channel AND a follow-up) is exactly the one
63
+ // where "there is more to do after approval" is the thing worth knowing.
64
+ const roleCount = request.roles?.length ?? 0
65
+ const chip = isTwoStep
66
+ ? '2 steps'
67
+ : roleCount > 0
68
+ ? `${roleCount} role${roleCount === 1 ? '' : 's'}`
69
+ : undefined
70
+
71
+ if (method?.type === 'noAccessRequired') return { route: ROUTE_FREE, chip }
72
+
73
+ // Nothing here can name a channel: `custom` has none by definition, and an
74
+ // `approvalMethodSlug` that resolves to no method is a dangling reference —
75
+ // the section below renders an access box with nothing in it either way.
76
+ const cannotNameRoute = !method || method.type === 'custom'
77
+
78
+ // `unknown` is the honest "we did not find out". A route we cannot name AND
79
+ // cannot describe is the same dead end wearing a different slug: claiming
80
+ // "request" there would promise a way in that the panel does not show.
81
+ if (method?.type === 'unknown' || (cannotNameRoute && !hasWrittenSteps)) {
82
+ return { route: ROUTE_UNDOCUMENTED, chip }
83
+ }
84
+
85
+ // A named desk, bot or team. `shortName` exists because `displayName` is
86
+ // written for prose and the longest in use does not fit a tab. No `&& method`
87
+ // guard: `cannotNameRoute` is `!method || …`, so ruling it out already narrows
88
+ // `method` to present.
89
+ if (!cannotNameRoute) {
90
+ return { route: method.shortName ?? method.displayName, chip }
91
+ }
92
+
93
+ // Written steps with no channel to name them.
94
+ return { route: isTwoStep ? '2 steps' : ROUTE_REQUEST, chip }
95
+ }
@@ -1,3 +0,0 @@
1
- export declare function CommentsSection({ appSlug }: {
2
- appSlug: string;
3
- }): import("react/jsx-runtime").JSX.Element;
@@ -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
- })