@igstack/app-catalog-frontend-core 6.9.0 → 6.10.1

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 (80) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
  5. package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +1 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +237 -218
  10. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
  12. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
  13. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
  15. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
  17. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
  18. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
  24. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
  25. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  26. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
  33. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
  36. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
  37. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
  38. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
  39. package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
  40. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  41. package/dist/esm/modules/extensions/index.d.ts +1 -1
  42. package/dist/esm/modules/extensions/index.js.map +1 -1
  43. package/dist/index.css +501 -49
  44. package/package.json +4 -4
  45. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
  46. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
  49. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
  50. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
  51. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
  52. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
  53. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
  54. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
  55. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
  56. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
  58. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
  59. package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
  60. package/src/index.css +501 -49
  61. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +292 -289
  62. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
  63. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
  64. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
  65. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
  66. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
  67. package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
  68. package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
  69. package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
  70. package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
  71. package/src/modules/extensions/features/resourceDetail.ts +28 -0
  72. package/src/modules/extensions/index.ts +2 -0
  73. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
  74. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
  75. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
  76. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
  77. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
  78. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
  79. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
  80. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
@@ -0,0 +1,254 @@
1
+ import type {
2
+ Claim,
3
+ FeedbackView as Note,
4
+ } from '@igstack/app-catalog-backend-core'
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
6
+ import React from 'react'
7
+ import { useTRPC } from '~/api/infra/trpc'
8
+ import { cn } from '~/lib/utils'
9
+ import { Button } from '~/ui/button'
10
+ import { formatRelativeTime } from '../../utils/formatRelativeTime'
11
+ import { attachmentUrl } from './communityDraft'
12
+ import { formatCommunityError } from './communityError'
13
+ import { composeComments } from './composeComments'
14
+ import type { CommentEntry } from './composeComments'
15
+
16
+ /** Same shape the input primitive uses, minus the fixed height. */
17
+ const FIELD_CLASSES =
18
+ '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'
19
+
20
+ const BADGES = {
21
+ pending: { text: 'Pending', className: 'is-pending' },
22
+ used: { text: 'Used as source', className: 'is-used' },
23
+ } as const
24
+
25
+ /**
26
+ * Screenshots sent with a note: a thumbnail strip under the words. The words
27
+ * are the substance and a screenshot is the evidence, so it should be
28
+ * glanceable and openable, not dominate the row.
29
+ */
30
+ function AttachmentStrip({ ids }: { ids: string[] }) {
31
+ if (ids.length === 0) return null
32
+ return (
33
+ <ul className="community-shots">
34
+ {ids.map((id) => (
35
+ <li key={id}>
36
+ <a href={attachmentUrl(id)} target="_blank" rel="noreferrer">
37
+ <img
38
+ src={attachmentUrl(id)}
39
+ alt="Attached screenshot"
40
+ loading="lazy"
41
+ />
42
+ </a>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ )
47
+ }
48
+
49
+ /** Who, when and where it stands, on one small line above the words. */
50
+ function Meta({ entry, edited }: { entry: CommentEntry; edited: boolean }) {
51
+ const badge = entry.badge && BADGES[entry.badge]
52
+ return (
53
+ <p className="comment__meta">
54
+ {entry.who && (
55
+ <>
56
+ <span className="comment__who">{entry.who}</span>
57
+ {' · '}
58
+ </>
59
+ )}
60
+ <time dateTime={entry.at} title={entry.at}>
61
+ {formatRelativeTime(entry.at)}
62
+ </time>
63
+ {edited && ' (edited)'}
64
+ {badge && (
65
+ <span
66
+ className={cn('comment__badge', badge.className)}
67
+ data-badge={entry.badge}
68
+ >
69
+ {badge.text}
70
+ </span>
71
+ )}
72
+ </p>
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Everything already said about the entry, under the input: one feed, newest
78
+ * first, one entry per visitor note. See `composeComments` for how claims fold
79
+ * into the notes they came from.
80
+ *
81
+ * Nobody logs in, so the server attributes each note to a pseudonym from an
82
+ * httpOnly cookie, and that cookie is what authorises editing (for an hour) or
83
+ * withdrawing (until someone reviews it). `canEditUntil` arrives as an absolute
84
+ * instant so a skewed client clock cannot show a control the server would refuse.
85
+ */
86
+ export function CommentsFeed({
87
+ appSlug,
88
+ claims,
89
+ labelledBy,
90
+ }: {
91
+ appSlug: string
92
+ claims: Claim[]
93
+ /** Id of the heading that names the list. */
94
+ labelledBy: string
95
+ }) {
96
+ const trpc = useTRPC()
97
+ const queryClient = useQueryClient()
98
+ const listOptions = trpc.feedback.list.queryOptions({ resourceSlug: appSlug })
99
+ const { data, error, isPending } = useQuery(listOptions)
100
+ const [editingId, setEditingId] = React.useState<string | null>(null)
101
+ const [editDraft, setEditDraft] = React.useState('')
102
+
103
+ const refresh = () =>
104
+ queryClient.invalidateQueries({ queryKey: listOptions.queryKey })
105
+ const edit = useMutation(
106
+ trpc.feedback.edit.mutationOptions({
107
+ onSuccess: () => {
108
+ setEditingId(null)
109
+ void refresh()
110
+ },
111
+ }),
112
+ )
113
+ const dismiss = useMutation(
114
+ trpc.feedback.dismiss.mutationOptions({ onSuccess: () => void refresh() }),
115
+ )
116
+ const saveEdit = (id: string) => {
117
+ const body = editDraft.trim()
118
+ if (body) edit.mutate({ id, body })
119
+ }
120
+
121
+ const entries =
122
+ data || error ? composeComments(data?.items ?? [], claims) : []
123
+ const failure = error ?? edit.error ?? dismiss.error
124
+
125
+ const noteBody = (note: Note) => {
126
+ const canEdit =
127
+ note.isMine &&
128
+ !!note.canEditUntil &&
129
+ Date.parse(note.canEditUntil) > Date.now()
130
+ // Withdrawing stays available for as long as nobody has reviewed it.
131
+ const canDismiss = note.isMine && note.status === null
132
+ const editing = editingId === note.id
133
+ return (
134
+ <>
135
+ {editing ? (
136
+ <div className="mt-1 space-y-2">
137
+ <textarea
138
+ autoFocus
139
+ rows={3}
140
+ className={FIELD_CLASSES}
141
+ aria-label="Edit your note"
142
+ value={editDraft}
143
+ onChange={(e) => {
144
+ setEditDraft(e.target.value)
145
+ if (edit.isError) edit.reset()
146
+ }}
147
+ onKeyDown={(e) => {
148
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
149
+ e.preventDefault()
150
+ saveEdit(note.id)
151
+ }
152
+ }}
153
+ />
154
+ <div className="flex gap-2">
155
+ <Button
156
+ type="button"
157
+ size="sm"
158
+ disabled={!editDraft.trim() || edit.isPending}
159
+ onClick={() => saveEdit(note.id)}
160
+ >
161
+ Save
162
+ </Button>
163
+ <Button
164
+ type="button"
165
+ size="sm"
166
+ variant="ghost"
167
+ onClick={() => setEditingId(null)}
168
+ >
169
+ Cancel
170
+ </Button>
171
+ </div>
172
+ </div>
173
+ ) : (
174
+ <p
175
+ data-feedback-body=""
176
+ className={cn('comment__text', !note.body && 'is-empty')}
177
+ >
178
+ {note.body ??
179
+ (note.attachmentIds.length > 0
180
+ ? 'Screenshot only — no words added.'
181
+ : 'Flagged as out of date — no detail given.')}
182
+ </p>
183
+ )}
184
+ <AttachmentStrip ids={note.attachmentIds} />
185
+ {note.reviewerReply && (
186
+ <p className="comment__reply">↳ {note.reviewerReply}</p>
187
+ )}
188
+ {!editing && (canEdit || canDismiss) && (
189
+ <p className="comment__actions">
190
+ {canEdit && (
191
+ <button
192
+ type="button"
193
+ onClick={() => {
194
+ setEditingId(note.id)
195
+ setEditDraft(note.body ?? '')
196
+ }}
197
+ >
198
+ Edit
199
+ </button>
200
+ )}
201
+ {canDismiss && (
202
+ <button
203
+ type="button"
204
+ className="is-destructive"
205
+ onClick={() => dismiss.mutate({ id: note.id })}
206
+ >
207
+ Dismiss
208
+ </button>
209
+ )}
210
+ </p>
211
+ )}
212
+ </>
213
+ )
214
+ }
215
+
216
+ return (
217
+ <>
218
+ {isPending ? (
219
+ <div
220
+ className="comments-skeleton"
221
+ aria-busy="true"
222
+ aria-labelledby={labelledBy}
223
+ >
224
+ <span className="sr-only" role="status">
225
+ Loading comments…
226
+ </span>
227
+ <div className="comments-skeleton__item" />
228
+ <div className="comments-skeleton__item" />
229
+ <div className="comments-skeleton__item" />
230
+ </div>
231
+ ) : entries.length > 0 ? (
232
+ <ul className="comments" aria-labelledby={labelledBy}>
233
+ {entries.map((entry) => (
234
+ <li key={entry.key} className="comment">
235
+ <Meta entry={entry} edited={!!entry.note?.editedAt} />
236
+ {entry.note ? (
237
+ noteBody(entry.note)
238
+ ) : (
239
+ <p className="comment__text">{entry.text}</p>
240
+ )}
241
+ </li>
242
+ ))}
243
+ </ul>
244
+ ) : (
245
+ <p className="comments-empty">
246
+ No comments yet. Be the first to share what you know.
247
+ </p>
248
+ )}
249
+ {failure && (
250
+ <p className="know-more-error">{formatCommunityError(failure)}</p>
251
+ )}
252
+ </>
253
+ )
254
+ }
@@ -0,0 +1,102 @@
1
+ import React from 'react'
2
+ import { useDismissable } from '~/modules/dismiss/DismissStack'
3
+ import { ResourceDetailFeedbackNote, useSlotFilled } from '~/modules/extensions'
4
+
5
+ /**
6
+ * The `?` beside "Comments": what the input takes, and what becomes of a note.
7
+ * Folded away so the input stays one line; Esc and a click outside close it.
8
+ * While closed it is not a dismiss layer, so Esc still reaches the card.
9
+ */
10
+ export function CommentsHelp({
11
+ appSlug,
12
+ appName,
13
+ }: {
14
+ appSlug: string
15
+ appName: string
16
+ }) {
17
+ const [open, setOpen] = React.useState(false)
18
+ const buttonRef = React.useRef<HTMLButtonElement>(null)
19
+ const popoverRef = React.useRef<HTMLDivElement>(null)
20
+ const id = React.useId()
21
+ const hasPluginNote = useSlotFilled('resourceDetailFeedbackNote')
22
+
23
+ useDismissable(
24
+ popoverRef,
25
+ () => {
26
+ setOpen(false)
27
+ buttonRef.current?.focus()
28
+ },
29
+ { enabled: open },
30
+ )
31
+
32
+ React.useEffect(() => {
33
+ if (!open) return
34
+ const onPointerDown = (e: PointerEvent) => {
35
+ const target = e.target as Node
36
+ if (
37
+ popoverRef.current?.contains(target) ||
38
+ buttonRef.current?.contains(target)
39
+ )
40
+ return
41
+ setOpen(false)
42
+ }
43
+ document.addEventListener('pointerdown', onPointerDown)
44
+ return () => document.removeEventListener('pointerdown', onPointerDown)
45
+ }, [open])
46
+
47
+ return (
48
+ <>
49
+ <button
50
+ ref={buttonRef}
51
+ type="button"
52
+ className="comments-help-button"
53
+ aria-label="How comments work"
54
+ aria-expanded={open}
55
+ aria-controls={id}
56
+ onClick={() => setOpen((v) => !v)}
57
+ >
58
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
59
+ <circle
60
+ cx="8"
61
+ cy="8"
62
+ r="6.75"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ strokeWidth="1.25"
66
+ />
67
+ <path
68
+ d="M6.25 6.3a1.8 1.8 0 0 1 3.5.55c0 1.2-1.75 1.45-1.75 2.6"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ strokeWidth="1.25"
72
+ strokeLinecap="round"
73
+ />
74
+ <circle cx="8" cy="11.4" r="0.8" fill="currentColor" />
75
+ </svg>
76
+ </button>
77
+ <div ref={popoverRef} id={id} className="comments-help" hidden={!open}>
78
+ <p className="comments-help__title">How comments work</p>
79
+ <ul>
80
+ <li>
81
+ Paste a link — a wiki page, a doc or a ticket that explains
82
+ something about this app.
83
+ </li>
84
+ <li>
85
+ Fix something — "Access is approved by your team lead, not IT."
86
+ </li>
87
+ <li>Ask a question — "Can contractors get access?"</li>
88
+ <li>Attach a screenshot — paste it, drop it or use the paperclip.</li>
89
+ </ul>
90
+ <p className="comments-help__note">
91
+ <ResourceDetailFeedbackNote appSlug={appSlug} appName={appName} />
92
+ {!hasPluginNote && (
93
+ <>
94
+ Every note is reviewed. Facts that check out update the card and
95
+ are marked Used as source.
96
+ </>
97
+ )}
98
+ </p>
99
+ </div>
100
+ </>
101
+ )
102
+ }