@eifi1/ui-kit 0.37.0 → 0.38.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/README.md +11 -4
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-comment-thread.d.ts +80 -0
- package/dist/feedback/feedback-comment-thread.js +162 -0
- package/dist/feedback/feedback-comment-thread.js.map +1 -0
- package/dist/feedback/feedback-dialog-labels.js +2 -1
- package/dist/feedback/feedback-dialog-labels.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +13 -0
- package/dist/feedback/feedback-dialog.js +12 -4
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-menu-labels.js +2 -1
- package/dist/feedback/feedback-menu-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +17 -3
- package/dist/feedback/feedback-menu.js +11 -1
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/feedback/feedback-public-labels.d.ts +50 -0
- package/dist/feedback/feedback-public-labels.js +26 -0
- package/dist/feedback/feedback-public-labels.js.map +1 -0
- package/dist/feedback/feedback-public-list.d.ts +118 -0
- package/dist/feedback/feedback-public-list.js +248 -0
- package/dist/feedback/feedback-public-list.js.map +1 -0
- package/dist/feedback/feedback-public-switch.d.ts +51 -0
- package/dist/feedback/feedback-public-switch.js +50 -0
- package/dist/feedback/feedback-public-switch.js.map +1 -0
- package/dist/feedback/feedback-public-types.d.ts +75 -0
- package/dist/feedback/feedback-public-types.js +2 -0
- package/dist/feedback/feedback-public-types.js.map +1 -0
- package/dist/feedback/feedback-record.d.ts +32 -1
- package/dist/feedback/feedback-record.js +10 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-replies.d.ts +97 -0
- package/dist/feedback/feedback-replies.js +114 -0
- package/dist/feedback/feedback-replies.js.map +1 -0
- package/dist/feedback/feedback-row-detail-labels.js +1 -0
- package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +38 -10
- package/dist/feedback/feedback-row-detail.js +50 -50
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.d.ts +3 -1
- package/dist/feedback/feedback-submit-dialog.js +6 -1
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-submit.d.ts +8 -0
- package/dist/feedback/feedback-submit.js +3 -2
- package/dist/feedback/feedback-submit.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +15 -5
- package/dist/feedback/feedback-table.js +46 -5
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback/feedback-thread-parts-labels.d.ts +6 -0
- package/dist/feedback/feedback-thread-parts-labels.js +36 -0
- package/dist/feedback/feedback-thread-parts-labels.js.map +1 -0
- package/dist/feedback/feedback-thread.d.ts +8 -0
- package/dist/feedback/feedback-thread.js +7 -2
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback/feedback-vote-button.d.ts +48 -0
- package/dist/feedback/feedback-vote-button.js +72 -0
- package/dist/feedback/feedback-vote-button.js.map +1 -0
- package/dist/feedback.d.ts +6 -0
- package/dist/feedback.js +6 -0
- package/dist/feedback.js.map +1 -1
- package/dist/i18n/defaults.js +12 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +64 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +16 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +79 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +64 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +64 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +64 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +64 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +64 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +4 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/notifications/feedback-notification-types-labels.d.ts +15 -0
- package/dist/notifications/feedback-notification-types-labels.js +11 -0
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -0
- package/dist/notifications/feedback-notification-types.d.ts +28 -0
- package/dist/notifications/feedback-notification-types.js +21 -0
- package/dist/notifications/feedback-notification-types.js.map +1 -0
- package/package.json +1 -1
- package/src/feedback/feedback-comment-thread.tsx +255 -0
- package/src/feedback/feedback-dialog-labels.ts +1 -0
- package/src/feedback/feedback-dialog.tsx +30 -2
- package/src/feedback/feedback-menu-labels.ts +1 -0
- package/src/feedback/feedback-menu.tsx +29 -3
- package/src/feedback/feedback-public-labels.ts +76 -0
- package/src/feedback/feedback-public-list.tsx +395 -0
- package/src/feedback/feedback-public-switch.tsx +102 -0
- package/src/feedback/feedback-public-types.ts +82 -0
- package/src/feedback/feedback-record.ts +39 -1
- package/src/feedback/feedback-replies.tsx +235 -0
- package/src/feedback/feedback-row-detail-labels.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +103 -74
- package/src/feedback/feedback-submit-dialog.tsx +9 -0
- package/src/feedback/feedback-submit.tsx +10 -1
- package/src/feedback/feedback-table.tsx +73 -6
- package/src/feedback/feedback-thread-parts-labels.ts +41 -0
- package/src/feedback/feedback-thread.tsx +18 -1
- package/src/feedback/feedback-vote-button.tsx +122 -0
- package/src/feedback.ts +10 -0
- package/src/i18n/defaults.ts +12 -0
- package/src/i18n/german.ts +62 -0
- package/src/i18n/kit-labels.tsx +16 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +62 -0
- package/src/i18n/locales/fr.ts +62 -0
- package/src/i18n/locales/hu.ts +62 -0
- package/src/i18n/locales/it.ts +62 -0
- package/src/i18n/locales/zh.ts +62 -0
- package/src/i18n/review.ts +4 -0
- package/src/index.ts +9 -0
- package/src/notifications/feedback-notification-types-labels.ts +24 -0
- package/src/notifications/feedback-notification-types.ts +46 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { BellOff } from "lucide-react";
|
|
3
|
+
import { Select } from "../components/select";
|
|
4
|
+
import type { CommitScope } from "../components/write-lock";
|
|
5
|
+
import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { formatDate } from "../lib/format";
|
|
8
|
+
import { FeedbackStatusBadge, selectableFeedbackStatuses, type FeedbackStatus } from "./feedback-inbox";
|
|
9
|
+
import { useFeedbackStatusLabels } from "./feedback-labels";
|
|
10
|
+
import type { FeedbackReply } from "./feedback-public-types";
|
|
11
|
+
import { FEEDBACK_DECIDED_STATUSES, FEEDBACK_REPLY_MAX } from "./feedback-record";
|
|
12
|
+
import { ChatComposer } from "./feedback-thread";
|
|
13
|
+
import { DEFAULT_FEEDBACK_REPLIES_LABELS } from "./feedback-thread-parts-labels";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The admin's answers as a list (docs/feedback-harmonization.md §9.7, decision 5): each
|
|
17
|
+
* reply its own row — who, when, what, and the status it moved the report to — where
|
|
18
|
+
* 0.37 had one `outcome` field that every answer overwrote. {@link FeedbackReplyTimeline}
|
|
19
|
+
* draws them; {@link FeedbackReplyComposer} writes the next one, with the status picker
|
|
20
|
+
* beside it (decision 13: a reply notifies the author, a status alone does not, so the
|
|
21
|
+
* status belongs with the words).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/* ── Labels ───────────────────────────────────────────────────────────────── */
|
|
25
|
+
|
|
26
|
+
/** The `feedbackReplies` namespace of `<UiKitProvider labels>`. */
|
|
27
|
+
export interface FeedbackRepliesLabels {
|
|
28
|
+
/** The timeline's accessible name. de-CH: "Antworten" */
|
|
29
|
+
replies: string;
|
|
30
|
+
/** No reply yet. de-CH: "Noch keine Antwort" */
|
|
31
|
+
empty: string;
|
|
32
|
+
/** A reply without an author — a loop's, a migrated outcome, an erased admin (§9.7).
|
|
33
|
+
* The app's word for its admin; never "the team". de-CH: "Admin" */
|
|
34
|
+
admin: string;
|
|
35
|
+
/** Read before a reply's status chip ("→ Done"). de-CH: "Status geändert auf" */
|
|
36
|
+
statusChanged: string;
|
|
37
|
+
/** The reply box's accessible name. de-CH: "Antwort schreiben" */
|
|
38
|
+
field: string;
|
|
39
|
+
/** de-CH: "Antwort schreiben…" */
|
|
40
|
+
placeholder: string;
|
|
41
|
+
/** The send button. de-CH: "Antworten" */
|
|
42
|
+
reply: string;
|
|
43
|
+
/** The status picker's accessible name. de-CH: "Status mit der Antwort" */
|
|
44
|
+
status: string;
|
|
45
|
+
/** The picker's first option: the reply changes no status. de-CH: "Keine Änderung" */
|
|
46
|
+
noChange: string;
|
|
47
|
+
/** Above the composer when a decided status was set without a reply (§9.7, B2).
|
|
48
|
+
* de-CH: "Die meldende Person wurde nicht benachrichtigt. Schreiben Sie eine Antwort." */
|
|
49
|
+
nudge: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { DEFAULT_FEEDBACK_REPLIES_LABELS };
|
|
53
|
+
|
|
54
|
+
/** `feedbackReplies` resolved: English, then `<UiKitProvider labels>`, then `labels`. */
|
|
55
|
+
export function useFeedbackRepliesLabels(labels?: LabelOverride<FeedbackRepliesLabels>): FeedbackRepliesLabels {
|
|
56
|
+
return useKitLabels("feedbackReplies", DEFAULT_FEEDBACK_REPLIES_LABELS, labels);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The status a row detail should nudge toward (§9.7, B2), or `undefined`: the row's
|
|
61
|
+
* status when it is decided ({@link FEEDBACK_DECIDED_STATUSES}) and no reply carries it
|
|
62
|
+
* as its `status_to` — a status-only move from the table, a swipe or the pills told the
|
|
63
|
+
* author nothing. `undefined` too without `replies` (a server before 0.38).
|
|
64
|
+
*/
|
|
65
|
+
export function feedbackReplyNudge(row: {
|
|
66
|
+
status: FeedbackStatus;
|
|
67
|
+
replies?: readonly Pick<FeedbackReply, "status_to">[] | null;
|
|
68
|
+
}): FeedbackStatus | undefined {
|
|
69
|
+
if (!row.replies || !FEEDBACK_DECIDED_STATUSES.includes(row.status)) return undefined;
|
|
70
|
+
return row.replies.some((reply) => reply.status_to === row.status) ? undefined : row.status;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ── The timeline ─────────────────────────────────────────────────────────── */
|
|
74
|
+
|
|
75
|
+
export interface FeedbackReplyTimelineProps {
|
|
76
|
+
/** Oldest first, as the server sends them. */
|
|
77
|
+
replies: readonly FeedbackReply[];
|
|
78
|
+
/** How a reply's time reads. Default: the date and time in the provider's locale. */
|
|
79
|
+
renderDate?: (iso: string) => string;
|
|
80
|
+
/** Tag bodies and names `data-private` (a host's screen-share blur). Default true. */
|
|
81
|
+
redact?: boolean;
|
|
82
|
+
labels?: LabelOverride<FeedbackRepliesLabels>;
|
|
83
|
+
className?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The replies, oldest first, down a rule: each with its author (`null` → "Admin"), its
|
|
88
|
+
* time, the status it set ("→ Done"; the admin view adds where from) and its text. No
|
|
89
|
+
* edit and no delete (§9.7): a correction is a new reply.
|
|
90
|
+
*/
|
|
91
|
+
export function FeedbackReplyTimeline({
|
|
92
|
+
replies,
|
|
93
|
+
renderDate,
|
|
94
|
+
redact = true,
|
|
95
|
+
labels: labelsProp,
|
|
96
|
+
className,
|
|
97
|
+
}: FeedbackReplyTimelineProps) {
|
|
98
|
+
const labels = useFeedbackRepliesLabels(labelsProp);
|
|
99
|
+
const locale = useKitLocale();
|
|
100
|
+
const priv = redact ? "" : undefined;
|
|
101
|
+
if (replies.length === 0) {
|
|
102
|
+
return <p className={cn("text-sm text-placeholder", className)}>{labels.empty}</p>;
|
|
103
|
+
}
|
|
104
|
+
const dateText = (iso: string) =>
|
|
105
|
+
renderDate ? renderDate(iso) : formatDate(iso, { dateStyle: "medium", timeStyle: "short" }, { locale });
|
|
106
|
+
return (
|
|
107
|
+
<ol aria-label={labels.replies} className={cn("space-y-3 border-s-2 border-subtle ps-3", className)}>
|
|
108
|
+
{replies.map((reply) => (
|
|
109
|
+
<li key={reply.id} data-feedback-reply={reply.id} className="min-w-0 text-sm">
|
|
110
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-muted">
|
|
111
|
+
<span data-private={priv} className="font-medium text-secondary">
|
|
112
|
+
{reply.author_name ?? labels.admin}
|
|
113
|
+
</span>
|
|
114
|
+
<time dateTime={reply.created_at} className="tabular-nums">
|
|
115
|
+
{dateText(reply.created_at)}
|
|
116
|
+
</time>
|
|
117
|
+
{reply.status_to && (
|
|
118
|
+
<span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
|
|
119
|
+
<span className="sr-only">{labels.statusChanged}</span>
|
|
120
|
+
{reply.status_from && reply.status_from !== reply.status_to && (
|
|
121
|
+
<>
|
|
122
|
+
<FeedbackStatusBadge status={reply.status_from} />
|
|
123
|
+
<span aria-hidden>→</span>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
{!reply.status_from && <span aria-hidden>→</span>}
|
|
127
|
+
<FeedbackStatusBadge status={reply.status_to} />
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
<p data-private={priv} dir="auto" className="mt-0.5 whitespace-pre-wrap break-words text-start text-primary">
|
|
132
|
+
{reply.body}
|
|
133
|
+
</p>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ol>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ── The composer ─────────────────────────────────────────────────────────── */
|
|
141
|
+
|
|
142
|
+
export interface FeedbackReplyComposerProps {
|
|
143
|
+
/** The report's status now — what "No change" keeps. */
|
|
144
|
+
status: FeedbackStatus;
|
|
145
|
+
/**
|
|
146
|
+
* `POST /feedback/{id}/replies` — the body (1–{@link FEEDBACK_REPLY_MAX} after
|
|
147
|
+
* trimming) and the status picked, `undefined` for "No change". Return the promise:
|
|
148
|
+
* the box clears when it resolves, and keeps everything on a rejection.
|
|
149
|
+
*/
|
|
150
|
+
onReply: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
|
|
151
|
+
/** The reply is in flight. */
|
|
152
|
+
pending?: boolean;
|
|
153
|
+
/** Preselect this status and say the author has not been told (§9.7, the nudge) —
|
|
154
|
+
* {@link feedbackReplyNudge}. */
|
|
155
|
+
suggestedStatus?: FeedbackStatus;
|
|
156
|
+
/** Reply COMMITS — see ChatComposer's `commit`. */
|
|
157
|
+
commit?: CommitScope;
|
|
158
|
+
labels?: LabelOverride<FeedbackRepliesLabels>;
|
|
159
|
+
className?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The admin's reply box (§9.7): the text, the **status** to set with it ("No change"
|
|
164
|
+
* first, then every status — {@link selectableFeedbackStatuses}), and **Reply**. A
|
|
165
|
+
* {@link ChatComposer} underneath: Ctrl/⌘+Enter sends, the count runs to 8 000, a draft
|
|
166
|
+
* typed while a reply is in flight stays.
|
|
167
|
+
*
|
|
168
|
+
* With `suggestedStatus` the line "The author has not been told. Add a reply." stands
|
|
169
|
+
* above it and that status is picked, so the reply that answers it records the status
|
|
170
|
+
* and the nudge goes away.
|
|
171
|
+
*/
|
|
172
|
+
export function FeedbackReplyComposer({
|
|
173
|
+
status,
|
|
174
|
+
onReply,
|
|
175
|
+
pending = false,
|
|
176
|
+
suggestedStatus,
|
|
177
|
+
commit,
|
|
178
|
+
labels: labelsProp,
|
|
179
|
+
className,
|
|
180
|
+
}: FeedbackReplyComposerProps) {
|
|
181
|
+
const labels = useFeedbackRepliesLabels(labelsProp);
|
|
182
|
+
const statusNames = useFeedbackStatusLabels();
|
|
183
|
+
// The pick, valid for the suggestion it was made under: a new suggestion (another
|
|
184
|
+
// status set from the table) picks itself again.
|
|
185
|
+
const [pick, setPick] = useState<{ under: FeedbackStatus | undefined; value: FeedbackStatus | "" }>({
|
|
186
|
+
under: suggestedStatus,
|
|
187
|
+
value: suggestedStatus ?? "",
|
|
188
|
+
});
|
|
189
|
+
const picked = pick.under === suggestedStatus ? pick.value : (suggestedStatus ?? "");
|
|
190
|
+
|
|
191
|
+
const send = (body: string) => {
|
|
192
|
+
const sent = picked;
|
|
193
|
+
return Promise.resolve(onReply(body, sent || undefined)).then(() => {
|
|
194
|
+
setPick((current) => (current.value === sent ? { under: suggestedStatus, value: "" } : current));
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div className={cn("space-y-2", className)} data-feedback-reply-composer="">
|
|
200
|
+
{suggestedStatus && (
|
|
201
|
+
<p className="flex items-start gap-1.5 text-xs text-secondary" data-feedback-nudge={suggestedStatus}>
|
|
202
|
+
<BellOff className="mt-0.5 size-3.5 shrink-0 text-warning" aria-hidden />
|
|
203
|
+
<span>{labels.nudge}</span>
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
<ChatComposer
|
|
207
|
+
onSend={send}
|
|
208
|
+
pending={pending}
|
|
209
|
+
commit={commit}
|
|
210
|
+
maxLength={FEEDBACK_REPLY_MAX}
|
|
211
|
+
showCount
|
|
212
|
+
labels={{ field: labels.field, placeholder: labels.placeholder, send: labels.reply }}
|
|
213
|
+
attachmentSlot={
|
|
214
|
+
<Select
|
|
215
|
+
size="sm"
|
|
216
|
+
aria-label={labels.status}
|
|
217
|
+
value={picked}
|
|
218
|
+
disabled={pending}
|
|
219
|
+
onChange={(event) =>
|
|
220
|
+
setPick({ under: suggestedStatus, value: event.target.value as FeedbackStatus | "" })
|
|
221
|
+
}
|
|
222
|
+
data-feedback-reply-status-pick=""
|
|
223
|
+
>
|
|
224
|
+
<option value="">{labels.noChange}</option>
|
|
225
|
+
{selectableFeedbackStatuses(status).map((value) => (
|
|
226
|
+
<option key={value} value={value}>
|
|
227
|
+
{statusNames[value]}
|
|
228
|
+
</option>
|
|
229
|
+
))}
|
|
230
|
+
</Select>
|
|
231
|
+
}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -18,6 +18,7 @@ export const DEFAULT_FEEDBACK_DETAIL_LABELS: FeedbackDetailLabels = {
|
|
|
18
18
|
download: (name) => `Download ${name}`,
|
|
19
19
|
downloadFailed: "The attachment could not be downloaded.",
|
|
20
20
|
outcome: "Outcome",
|
|
21
|
+
replies: "Replies",
|
|
21
22
|
resolvedAt: (date) => `Resolved: ${date}`,
|
|
22
23
|
outcomeAdd: "Add outcome",
|
|
23
24
|
outcomeUpdate: "Update",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import type { MouseEvent } from "react";
|
|
2
|
+
import type { MouseEvent, ReactNode } from "react";
|
|
3
3
|
import { Download, ExternalLink, FileText, RotateCcw } from "lucide-react";
|
|
4
4
|
import { AuthedImage } from "../components/authed-image";
|
|
5
5
|
import { CopyButton } from "../components/copy-button";
|
|
@@ -40,6 +40,9 @@ import { useFeedbackStatusUndo } from "./feedback-status-undo";
|
|
|
40
40
|
import type { FeedbackStatusChange } from "./feedback-status-undo";
|
|
41
41
|
import type { CommitScope } from "../components/write-lock";
|
|
42
42
|
import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
|
|
43
|
+
import { FeedbackReplyComposer, FeedbackReplyTimeline, feedbackReplyNudge } from "./feedback-replies";
|
|
44
|
+
import { FeedbackPublicSwitch } from "./feedback-public-switch";
|
|
45
|
+
import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* One report, opened — the panel a feedback table unfolds under its row (§4.4 of
|
|
@@ -69,9 +72,12 @@ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
|
|
|
69
72
|
*
|
|
70
73
|
* The sections, in the contract's order: **Subject** (the category and the title, §8.1;
|
|
71
74
|
* `showSubject={false}` leaves it out), **Description** (the body; the author's
|
|
72
|
-
* Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
+
* Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **Replies** (0.38,
|
|
76
|
+
* §9.7: the timeline, the admin's reply composer with its status picker and the nudge,
|
|
77
|
+
* Rework, Open page — on a row without `replies`, a server before 0.38, the frozen
|
|
78
|
+
* **Outcome** read-only in its place), **Comments** (0.38: the app's
|
|
79
|
+
* `FeedbackCommentThread`, when passed), **Send for rework** (only while reworking) and
|
|
80
|
+
* **Status** (with the **Visible to others** switch when `onPublicChange` is passed).
|
|
75
81
|
*
|
|
76
82
|
* The app keeps its data and its API: the row it renders, the PATCH (`onUpdate`), the
|
|
77
83
|
* upload (`onUpload`), the authenticated GET (`fetcher`) and who is looking (`canEdit`,
|
|
@@ -116,16 +122,22 @@ export interface FeedbackDetailLabels {
|
|
|
116
122
|
/** The toast when that download failed. de-CH: "Der Anhang konnte nicht
|
|
117
123
|
* heruntergeladen werden." */
|
|
118
124
|
downloadFailed: string;
|
|
119
|
-
/** Section 4's heading.
|
|
125
|
+
/** Section 4's heading on a row without `replies` (a server before 0.38), where the
|
|
126
|
+
* frozen outcome is shown read-only. de-CH: "Ergebnis" */
|
|
120
127
|
outcome: string;
|
|
128
|
+
/** Section 4's heading since 0.38: the replies' timeline (§9.7). de-CH: "Antworten" */
|
|
129
|
+
replies: string;
|
|
121
130
|
/** On section 4's heading line once the row is settled, given the formatted
|
|
122
131
|
* `resolved_at` date. de-CH: "Erledigt am: {{date}}" */
|
|
123
132
|
resolvedAt: (date: string) => string;
|
|
124
|
-
/** The admin's button while there is no outcome.
|
|
133
|
+
/** The admin's button while there is no outcome. **Unused since 0.38** (the outcome is
|
|
134
|
+
* frozen, §9.7; a reply is written instead); kept for one release.
|
|
135
|
+
* de-CH: "Ergebnis hinzufügen" */
|
|
125
136
|
outcomeAdd: string;
|
|
126
|
-
/** The same button once there is one.
|
|
137
|
+
/** The same button once there is one. Unused since 0.38, as `outcomeAdd`.
|
|
138
|
+
* de-CH: "Aktualisieren" */
|
|
127
139
|
outcomeUpdate: string;
|
|
128
|
-
/** The line above the outcome editor. de-CH: "Was wurde umgesetzt, entschieden oder
|
|
140
|
+
/** The line above the outcome editor. Unused since 0.38, as `outcomeAdd`. de-CH: "Was wurde umgesetzt, entschieden oder
|
|
129
141
|
* warum nicht." */
|
|
130
142
|
outcomePlaceholder: string;
|
|
131
143
|
/** The button that opens section 5 on an answered row. de-CH: "Nacharbeit" */
|
|
@@ -314,7 +326,8 @@ export interface FeedbackRowAccess {
|
|
|
314
326
|
/** "Edit description": the author, while the row is OPEN, READY or IN_PROGRESS — on either
|
|
315
327
|
* page (keksdose `canAuthorEdit`, `feedback-page.tsx:398`, is not gated on `mine`). */
|
|
316
328
|
canEditDescription: boolean;
|
|
317
|
-
/** The
|
|
329
|
+
/** The admin on the inbox (`canEdit`): since 0.38 the reply composer (the outcome
|
|
330
|
+
* itself is frozen, §9.7). */
|
|
318
331
|
canEditOutcome: boolean;
|
|
319
332
|
/** "Rework": the admin or the author, on an answered row
|
|
320
333
|
* ({@link FEEDBACK_REWORKABLE_STATUSES}) — §3.4, anyone else gets the server's 403. */
|
|
@@ -356,7 +369,8 @@ export function feedbackRowAccess(
|
|
|
356
369
|
export interface FeedbackDetailPatch {
|
|
357
370
|
id: number;
|
|
358
371
|
body?: string;
|
|
359
|
-
/** `null` clears it — a real edit.
|
|
372
|
+
/** `null` clears it — a real edit. **Frozen since 0.38** (§9.7): server-kit 0.11
|
|
373
|
+
* answers 422; the detail no longer sends it (a reply is written instead). */
|
|
360
374
|
outcome?: string | null;
|
|
361
375
|
status?: FeedbackStatus;
|
|
362
376
|
}
|
|
@@ -564,6 +578,22 @@ export interface FeedbackRowDetailProps {
|
|
|
564
578
|
* beside the detail.
|
|
565
579
|
*/
|
|
566
580
|
showSubject?: boolean;
|
|
581
|
+
/**
|
|
582
|
+
* 0.38: `POST /feedback/{id}/replies` (§9.7) — the body and the status picked
|
|
583
|
+
* (`undefined`: "No change"). With `canEdit` and a row that carries `replies`, the
|
|
584
|
+
* reply composer stands under the timeline. Return the promise: the box clears when it
|
|
585
|
+
* resolves.
|
|
586
|
+
*/
|
|
587
|
+
onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
|
|
588
|
+
/** 0.38: the reply is in flight. */
|
|
589
|
+
replying?: boolean;
|
|
590
|
+
/** 0.38: the report's comments — the app's `FeedbackCommentThread` — in a section of
|
|
591
|
+
* their own under the replies. */
|
|
592
|
+
comments?: ReactNode;
|
|
593
|
+
/** 0.38: `PATCH /feedback/{id} {"public": next}` — draws the **Visible to others**
|
|
594
|
+
* switch (`FeedbackPublicSwitch`) in the Status section, for the admin (`canEdit`)
|
|
595
|
+
* and, within §9.3's window, the author. */
|
|
596
|
+
onPublicChange?: (next: boolean) => void;
|
|
567
597
|
labels?: LabelOverride<FeedbackDetailLabels>;
|
|
568
598
|
className?: string;
|
|
569
599
|
}
|
|
@@ -605,6 +635,10 @@ export function FeedbackRowDetail({
|
|
|
605
635
|
renderDate,
|
|
606
636
|
commit,
|
|
607
637
|
showSubject = true,
|
|
638
|
+
onReply,
|
|
639
|
+
replying = false,
|
|
640
|
+
comments,
|
|
641
|
+
onPublicChange,
|
|
608
642
|
labels: labelsProp,
|
|
609
643
|
className,
|
|
610
644
|
}: FeedbackRowDetailProps) {
|
|
@@ -616,7 +650,6 @@ export function FeedbackRowDetail({
|
|
|
616
650
|
const ownStatusChange = useFeedbackStatusUndo(onUpdate);
|
|
617
651
|
const changeStatus = statusChange ?? ownStatusChange;
|
|
618
652
|
const [editingBody, setEditingBody] = useState(false);
|
|
619
|
-
const [editingOutcome, setEditingOutcome] = useState(false);
|
|
620
653
|
const [reworking, setReworking] = useState(false);
|
|
621
654
|
|
|
622
655
|
const access = feedbackRowAccess(row, { canEdit, viewerId });
|
|
@@ -640,17 +673,10 @@ export function FeedbackRowDetail({
|
|
|
640
673
|
onUpdate({ id: row.id, body: replaceDescription(row.body, value) }, { onSuccess: () => setEditingBody(false) });
|
|
641
674
|
};
|
|
642
675
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
setEditingOutcome(false);
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
onUpdate({ id: row.id, outcome: next }, { onSuccess: () => setEditingOutcome(false) });
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
const showOutcomeEditor = access.canEditOutcome && editingOutcome;
|
|
676
|
+
// 0.38 (§9.7): the replies replace the outcome; a row without them is a server before
|
|
677
|
+
// 0.38, whose frozen outcome is shown as it is, read-only.
|
|
678
|
+
const replies = row.replies;
|
|
679
|
+
const commentLabels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS);
|
|
654
680
|
const showRework = access.canRework && reworking;
|
|
655
681
|
|
|
656
682
|
return (
|
|
@@ -741,7 +767,7 @@ export function FeedbackRowDetail({
|
|
|
741
767
|
)}
|
|
742
768
|
|
|
743
769
|
<FeedbackDetailSection
|
|
744
|
-
title={labels.outcome}
|
|
770
|
+
title={replies ? labels.replies : labels.outcome}
|
|
745
771
|
// When the row entered DONE / WONT_DO (keksdose feedback #96).
|
|
746
772
|
action={
|
|
747
773
|
row.resolved_at ? (
|
|
@@ -749,60 +775,52 @@ export function FeedbackRowDetail({
|
|
|
749
775
|
) : undefined
|
|
750
776
|
}
|
|
751
777
|
>
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
pending={pending}
|
|
757
|
-
commit={commit}
|
|
758
|
-
onSave={saveOutcome}
|
|
759
|
-
onCancel={() => setEditingOutcome(false)}
|
|
760
|
-
saveLabel={labels.save}
|
|
761
|
-
cancelLabel={labels.cancel}
|
|
762
|
-
placeholder={labels.outcomePlaceholder}
|
|
763
|
-
/>
|
|
764
|
-
) : (
|
|
765
|
-
<div className="space-y-2">
|
|
778
|
+
<div className="space-y-3">
|
|
779
|
+
{replies ? (
|
|
780
|
+
<FeedbackReplyTimeline replies={replies} />
|
|
781
|
+
) : (
|
|
766
782
|
<FeedbackProse>{row.outcome || undefined}</FeedbackProse>
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
<
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
</div>
|
|
803
|
-
)}
|
|
783
|
+
)}
|
|
784
|
+
{replies && access.canEditOutcome && onReply && (
|
|
785
|
+
<FeedbackReplyComposer
|
|
786
|
+
key={row.id}
|
|
787
|
+
status={row.status}
|
|
788
|
+
onReply={onReply}
|
|
789
|
+
pending={replying}
|
|
790
|
+
suggestedStatus={feedbackReplyNudge(row)}
|
|
791
|
+
commit={commit}
|
|
792
|
+
/>
|
|
793
|
+
)}
|
|
794
|
+
{((access.canRework && !reworking) || pageHref) && (
|
|
795
|
+
<div className="flex flex-wrap gap-2">
|
|
796
|
+
{access.canRework && !reworking && (
|
|
797
|
+
<Button
|
|
798
|
+
type="button"
|
|
799
|
+
variant="secondary"
|
|
800
|
+
onClick={(event) => {
|
|
801
|
+
event.stopPropagation();
|
|
802
|
+
setReworking(true);
|
|
803
|
+
}}
|
|
804
|
+
>
|
|
805
|
+
<RotateCcw className="size-3.5" aria-hidden /> {labels.rework}
|
|
806
|
+
</Button>
|
|
807
|
+
)}
|
|
808
|
+
{pageHref && (
|
|
809
|
+
// A real href (keksdose dev #451): middle-click and ⌘/Ctrl-click are
|
|
810
|
+
// the browser's, and only a link has them.
|
|
811
|
+
<Button href={pageHref} variant="secondary" onClick={(event) => event.stopPropagation()}>
|
|
812
|
+
<ExternalLink className="size-3.5" aria-hidden /> {labels.openPage}
|
|
813
|
+
</Button>
|
|
814
|
+
)}
|
|
815
|
+
</div>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
804
818
|
</FeedbackDetailSection>
|
|
805
819
|
|
|
820
|
+
{comments != null && (
|
|
821
|
+
<FeedbackDetailSection title={commentLabels.comments}>{comments}</FeedbackDetailSection>
|
|
822
|
+
)}
|
|
823
|
+
|
|
806
824
|
{showRework && (
|
|
807
825
|
<FeedbackReworkSection
|
|
808
826
|
row={row}
|
|
@@ -831,6 +849,17 @@ export function FeedbackRowDetail({
|
|
|
831
849
|
onPick={(status) => changeStatus(row, status)}
|
|
832
850
|
variant="pill"
|
|
833
851
|
/>
|
|
852
|
+
{onPublicChange && (
|
|
853
|
+
<FeedbackPublicSwitch
|
|
854
|
+
row={row}
|
|
855
|
+
isAdmin={canEdit}
|
|
856
|
+
viewerId={viewerId}
|
|
857
|
+
onChange={onPublicChange}
|
|
858
|
+
pending={pending}
|
|
859
|
+
commit={commit}
|
|
860
|
+
className="mt-3"
|
|
861
|
+
/>
|
|
862
|
+
)}
|
|
834
863
|
</FeedbackDetailSection>
|
|
835
864
|
</FeedbackDetail>
|
|
836
865
|
);
|
|
@@ -2,6 +2,8 @@ import { toast } from "../components/toast";
|
|
|
2
2
|
import { captureAppScreenshot } from "./feedback-capture";
|
|
3
3
|
import { FeedbackContextBox, feedbackContext, type FeedbackSubmitter } from "./feedback-context";
|
|
4
4
|
import { FeedbackDialog, type FeedbackMultipleSubmission } from "./feedback-dialog";
|
|
5
|
+
import { DEFAULT_FEEDBACK_DIALOG_LABELS } from "./feedback-dialog-labels";
|
|
6
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
7
|
import { useFeedbackCategoryLabels, useFeedbackToastLabels } from "./feedback-labels";
|
|
6
8
|
import {
|
|
7
9
|
DEFAULT_MAX_ATTACHMENTS,
|
|
@@ -45,6 +47,8 @@ export interface FeedbackSubmitDialogProps {
|
|
|
45
47
|
version?: string;
|
|
46
48
|
/** `UseFeedbackSubmitOptions.capture`, as the app passed it. */
|
|
47
49
|
capture?: false | (() => Promise<File | null>);
|
|
50
|
+
/** `UseFeedbackSubmitOptions.publicNotice` (0.38): the line under the body. */
|
|
51
|
+
publicNotice?: boolean;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** `FeedbackDialog` as `useFeedbackSubmit` settles it — see the hook for what it settles. */
|
|
@@ -61,8 +65,10 @@ export function FeedbackSubmitDialog({
|
|
|
61
65
|
environment,
|
|
62
66
|
version,
|
|
63
67
|
capture,
|
|
68
|
+
publicNotice = false,
|
|
64
69
|
}: FeedbackSubmitDialogProps) {
|
|
65
70
|
const toastText = useFeedbackToastLabels();
|
|
71
|
+
const dialogText = useKitLabels("feedbackDialog", DEFAULT_FEEDBACK_DIALOG_LABELS);
|
|
66
72
|
const categoryLabels = useFeedbackCategoryLabels();
|
|
67
73
|
|
|
68
74
|
const onCaptureScreenshot =
|
|
@@ -101,6 +107,9 @@ export function FeedbackSubmitDialog({
|
|
|
101
107
|
)
|
|
102
108
|
}
|
|
103
109
|
onCaptureScreenshot={onCaptureScreenshot}
|
|
110
|
+
publicNotice={
|
|
111
|
+
publicNotice ? (dialogText.publicNotice ?? DEFAULT_FEEDBACK_DIALOG_LABELS.publicNotice) : undefined
|
|
112
|
+
}
|
|
104
113
|
// The context is read off the very values the box below shows (§3.2).
|
|
105
114
|
onSubmit={(data) => onSubmit(data, feedbackContext({ user, attachUrl, url, environment, version }))}
|
|
106
115
|
contextSlot={<FeedbackContextBox user={user} url={url} attachUrl={attachUrl} onAttachUrlChange={onAttachUrlChange} />}
|
|
@@ -74,6 +74,14 @@ export interface UseFeedbackSubmitOptions<R = unknown> {
|
|
|
74
74
|
* `modern-screenshot`. A rejection becomes the `captureFailed` toast.
|
|
75
75
|
*/
|
|
76
76
|
capture?: false | (() => Promise<File | null>);
|
|
77
|
+
/**
|
|
78
|
+
* The app's reports are public (0.38, docs/feedback-harmonization.md §9.2 decision 6):
|
|
79
|
+
* `true` shows **"Other users will see the title and text (never your screenshots or
|
|
80
|
+
* files)"** (`feedbackDialog.publicNotice`) under the body. keksdose and Kurvenschmiede
|
|
81
|
+
* pass it — for a viewer who passes the app's `public_viewer` — and kastlan, whose
|
|
82
|
+
* public parts are off, does not. Default `false`.
|
|
83
|
+
*/
|
|
84
|
+
publicNotice?: boolean;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
export interface FeedbackSubmit {
|
|
@@ -134,7 +142,7 @@ export interface FeedbackSubmit {
|
|
|
134
142
|
* new draft is somebody's next report.
|
|
135
143
|
*/
|
|
136
144
|
export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions<R>): FeedbackSubmit {
|
|
137
|
-
const { upload, create, user, environment, version, onSubmitted, errorMessage, capture } = options;
|
|
145
|
+
const { upload, create, user, environment, version, onSubmitted, errorMessage, capture, publicNotice } = options;
|
|
138
146
|
const toastText = useFeedbackToastLabels();
|
|
139
147
|
const [isOpen, setIsOpen] = useState(false);
|
|
140
148
|
const [category, setCategory] = useState<FeedbackCategory>("OTHER");
|
|
@@ -206,6 +214,7 @@ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions
|
|
|
206
214
|
environment={environment}
|
|
207
215
|
version={version}
|
|
208
216
|
capture={capture}
|
|
217
|
+
publicNotice={publicNotice}
|
|
209
218
|
/>
|
|
210
219
|
</Suspense>
|
|
211
220
|
) : null;
|