@eifi1/ui-kit 0.37.0 → 0.38.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.
- package/README.md +11 -4
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-comment-thread.d.ts +87 -0
- package/dist/feedback/feedback-comment-thread.js +170 -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-labels.d.ts +11 -0
- package/dist/feedback/feedback-labels.js +4 -1
- package/dist/feedback/feedback-labels.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 +53 -0
- package/dist/feedback/feedback-public-labels.js +27 -0
- package/dist/feedback/feedback-public-labels.js.map +1 -0
- package/dist/feedback/feedback-public-list.d.ts +139 -0
- package/dist/feedback/feedback-public-list.js +299 -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-context.d.ts +7 -0
- package/dist/feedback/feedback-replies-context.js +7 -0
- package/dist/feedback/feedback-replies-context.js.map +1 -0
- 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 +47 -10
- package/dist/feedback/feedback-row-detail.js +51 -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 +49 -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 +69 -3
- 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 +85 -3
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +69 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +69 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +69 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +69 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +69 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +5 -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 +277 -0
- package/src/feedback/feedback-dialog-labels.ts +1 -0
- package/src/feedback/feedback-dialog.tsx +30 -2
- package/src/feedback/feedback-labels.ts +14 -0
- package/src/feedback/feedback-menu-labels.ts +1 -0
- package/src/feedback/feedback-menu.tsx +29 -3
- package/src/feedback/feedback-public-labels.ts +80 -0
- package/src/feedback/feedback-public-list.tsx +478 -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-context.ts +9 -0
- 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 +115 -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 +123 -0
- package/src/feedback.ts +10 -0
- package/src/i18n/defaults.ts +12 -0
- package/src/i18n/german.ts +66 -0
- package/src/i18n/kit-labels.tsx +16 -0
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +66 -0
- package/src/i18n/locales/fr.ts +66 -0
- package/src/i18n/locales/hu.ts +66 -0
- package/src/i18n/locales/it.ts +66 -0
- package/src/i18n/locales/zh.ts +66 -0
- package/src/i18n/review.ts +5 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FEEDBACK_CATEGORY_ORDER } from "./feedback-vocabulary";
|
|
2
2
|
import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
|
|
3
|
+
import type { FeedbackReply } from "./feedback-public-types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* One feedback report as the three apps send it, and the facts the kit reads off it.
|
|
@@ -102,7 +103,9 @@ export interface FeedbackRecord {
|
|
|
102
103
|
/** Up to 5 other files; `null` = none. Optional because a server older than the
|
|
103
104
|
* column sends no key at all (keksdose dev #578 met exactly that). */
|
|
104
105
|
attachment_urls?: string[] | null;
|
|
105
|
-
/** The team's answer; `null` = none written.
|
|
106
|
+
/** The team's answer; `null` = none written. **Frozen since 0.38** (§9.7): answered
|
|
107
|
+
* as stored for one release, written by nothing, then dropped — the answer is
|
|
108
|
+
* {@link FeedbackRecord.replies} now. */
|
|
106
109
|
outcome: string | null;
|
|
107
110
|
/** Set on ENTERING `DONE` / `WONT_DO`, cleared on leaving them. ISO 8601. */
|
|
108
111
|
resolved_at: string | null;
|
|
@@ -110,6 +113,24 @@ export interface FeedbackRecord {
|
|
|
110
113
|
created_at: string;
|
|
111
114
|
/** ISO 8601. */
|
|
112
115
|
updated_at: string;
|
|
116
|
+
// ── Round 0.38 (§9.3): all optional, so an app without public parts sends none. ──
|
|
117
|
+
/** The admin: the full name; the author: their own; `null`: none to show. */
|
|
118
|
+
author_name?: string | null;
|
|
119
|
+
/** The viewer filed it. */
|
|
120
|
+
mine?: boolean | null;
|
|
121
|
+
/** Visible to the app's other users (§9.3); never on a CRASH. */
|
|
122
|
+
public?: boolean | null;
|
|
123
|
+
/** Made public by the 0.38 migration: the author may switch it off at any status. */
|
|
124
|
+
filed_private?: boolean | null;
|
|
125
|
+
/** People who want this (the author's own vote included, §9.6). */
|
|
126
|
+
votes?: number | null;
|
|
127
|
+
/** The viewer's vote. */
|
|
128
|
+
voted?: boolean | null;
|
|
129
|
+
/** Comments (an admin's count includes hidden ones). */
|
|
130
|
+
comment_count?: number | null;
|
|
131
|
+
/** The admin's answers, oldest first (§9.7). Absent: a server before 0.38 — read
|
|
132
|
+
* `outcome` instead. */
|
|
133
|
+
replies?: FeedbackReply[] | null;
|
|
113
134
|
}
|
|
114
135
|
|
|
115
136
|
/* ── Constants (§3.4, §3.5, §4.3) ─────────────────────────────────────────── */
|
|
@@ -195,6 +216,23 @@ export const FEEDBACK_AWAITING_STATUSES: readonly FeedbackStatus[] = Object.free
|
|
|
195
216
|
"NEEDS_LIVE_TEST",
|
|
196
217
|
] as const);
|
|
197
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The **decided** statuses (§9.7, decision 13): `DONE`, `WONT_DO`, `POSTPONED`. A
|
|
221
|
+
* status-only move into one tells the author nothing (a status alone sends no
|
|
222
|
+
* notification), so the row detail nudges the admin toward a reply until one with that
|
|
223
|
+
* `status_to` exists.
|
|
224
|
+
*/
|
|
225
|
+
export const FEEDBACK_DECIDED_STATUSES: readonly FeedbackStatus[] = Object.freeze([
|
|
226
|
+
"DONE",
|
|
227
|
+
"WONT_DO",
|
|
228
|
+
"POSTPONED",
|
|
229
|
+
] as const);
|
|
230
|
+
|
|
231
|
+
/** A comment's longest body after trimming (§9.8). */
|
|
232
|
+
export const FEEDBACK_COMMENT_MAX = 4000;
|
|
233
|
+
/** A reply's longest body after trimming (§9.7). */
|
|
234
|
+
export const FEEDBACK_REPLY_MAX = 8000;
|
|
235
|
+
|
|
198
236
|
/* ── The body (§3.4) ──────────────────────────────────────────────────────── */
|
|
199
237
|
|
|
200
238
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `true` inside `FeedbackRowDetail`'s Comments section (0.38.1, Kurvenschmiede): the
|
|
5
|
+
* detail's own Replies section draws the report's replies already, so a
|
|
6
|
+
* `FeedbackCommentThread` there draws the comments alone — whatever `replies` it was
|
|
7
|
+
* handed. Internal: not re-exported.
|
|
8
|
+
*/
|
|
9
|
+
export const FeedbackRepliesDrawnAboveContext = createContext(false);
|
|
@@ -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,10 @@ 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 { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
|
|
46
|
+
import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* One report, opened — the panel a feedback table unfolds under its row (§4.4 of
|
|
@@ -69,9 +73,12 @@ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
|
|
|
69
73
|
*
|
|
70
74
|
* The sections, in the contract's order: **Subject** (the category and the title, §8.1;
|
|
71
75
|
* `showSubject={false}` leaves it out), **Description** (the body; the author's
|
|
72
|
-
* Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **
|
|
73
|
-
*
|
|
74
|
-
*
|
|
76
|
+
* Edit while OPEN / READY / IN_PROGRESS), **URL**, **Attachment**, **Replies** (0.38,
|
|
77
|
+
* §9.7: the timeline, the admin's reply composer with its status picker and the nudge,
|
|
78
|
+
* Rework, Open page — on a row without `replies`, a server before 0.38, the frozen
|
|
79
|
+
* **Outcome** read-only in its place), **Comments** (0.38: the app's
|
|
80
|
+
* `FeedbackCommentThread`, when passed), **Send for rework** (only while reworking) and
|
|
81
|
+
* **Status** (with the **Visible to others** switch when `onPublicChange` is passed).
|
|
75
82
|
*
|
|
76
83
|
* The app keeps its data and its API: the row it renders, the PATCH (`onUpdate`), the
|
|
77
84
|
* upload (`onUpload`), the authenticated GET (`fetcher`) and who is looking (`canEdit`,
|
|
@@ -116,16 +123,22 @@ export interface FeedbackDetailLabels {
|
|
|
116
123
|
/** The toast when that download failed. de-CH: "Der Anhang konnte nicht
|
|
117
124
|
* heruntergeladen werden." */
|
|
118
125
|
downloadFailed: string;
|
|
119
|
-
/** Section 4's heading.
|
|
126
|
+
/** Section 4's heading on a row without `replies` (a server before 0.38), where the
|
|
127
|
+
* frozen outcome is shown read-only. de-CH: "Ergebnis" */
|
|
120
128
|
outcome: string;
|
|
129
|
+
/** Section 4's heading since 0.38: the replies' timeline (§9.7). de-CH: "Antworten" */
|
|
130
|
+
replies: string;
|
|
121
131
|
/** On section 4's heading line once the row is settled, given the formatted
|
|
122
132
|
* `resolved_at` date. de-CH: "Erledigt am: {{date}}" */
|
|
123
133
|
resolvedAt: (date: string) => string;
|
|
124
|
-
/** The admin's button while there is no outcome.
|
|
134
|
+
/** The admin's button while there is no outcome. **Unused since 0.38** (the outcome is
|
|
135
|
+
* frozen, §9.7; a reply is written instead); kept for one release.
|
|
136
|
+
* de-CH: "Ergebnis hinzufügen" */
|
|
125
137
|
outcomeAdd: string;
|
|
126
|
-
/** The same button once there is one.
|
|
138
|
+
/** The same button once there is one. Unused since 0.38, as `outcomeAdd`.
|
|
139
|
+
* de-CH: "Aktualisieren" */
|
|
127
140
|
outcomeUpdate: string;
|
|
128
|
-
/** The line above the outcome editor. de-CH: "Was wurde umgesetzt, entschieden oder
|
|
141
|
+
/** The line above the outcome editor. Unused since 0.38, as `outcomeAdd`. de-CH: "Was wurde umgesetzt, entschieden oder
|
|
129
142
|
* warum nicht." */
|
|
130
143
|
outcomePlaceholder: string;
|
|
131
144
|
/** The button that opens section 5 on an answered row. de-CH: "Nacharbeit" */
|
|
@@ -314,7 +327,8 @@ export interface FeedbackRowAccess {
|
|
|
314
327
|
/** "Edit description": the author, while the row is OPEN, READY or IN_PROGRESS — on either
|
|
315
328
|
* page (keksdose `canAuthorEdit`, `feedback-page.tsx:398`, is not gated on `mine`). */
|
|
316
329
|
canEditDescription: boolean;
|
|
317
|
-
/** The
|
|
330
|
+
/** The admin on the inbox (`canEdit`): since 0.38 the reply composer (the outcome
|
|
331
|
+
* itself is frozen, §9.7). */
|
|
318
332
|
canEditOutcome: boolean;
|
|
319
333
|
/** "Rework": the admin or the author, on an answered row
|
|
320
334
|
* ({@link FEEDBACK_REWORKABLE_STATUSES}) — §3.4, anyone else gets the server's 403. */
|
|
@@ -356,9 +370,14 @@ export function feedbackRowAccess(
|
|
|
356
370
|
export interface FeedbackDetailPatch {
|
|
357
371
|
id: number;
|
|
358
372
|
body?: string;
|
|
359
|
-
/** `null` clears it — a real edit.
|
|
373
|
+
/** `null` clears it — a real edit. **Frozen since 0.38** (§9.7): server-kit 0.11
|
|
374
|
+
* answers 422; the detail no longer sends it (a reply is written instead). */
|
|
360
375
|
outcome?: string | null;
|
|
361
376
|
status?: FeedbackStatus;
|
|
377
|
+
/** 0.38.1: the **Visible to others** flip (§9.3) — what `onPublicChange`'s
|
|
378
|
+
* `PATCH /feedback/{id} {"public": next}` carries, alone, so an app can send it
|
|
379
|
+
* through the same `onUpdate` mutation. */
|
|
380
|
+
public?: boolean;
|
|
362
381
|
}
|
|
363
382
|
|
|
364
383
|
/**
|
|
@@ -564,6 +583,27 @@ export interface FeedbackRowDetailProps {
|
|
|
564
583
|
* beside the detail.
|
|
565
584
|
*/
|
|
566
585
|
showSubject?: boolean;
|
|
586
|
+
/**
|
|
587
|
+
* 0.38: `POST /feedback/{id}/replies` (§9.7) — the body and the status picked
|
|
588
|
+
* (`undefined`: "No change"). With `canEdit` and a row that carries `replies`, the
|
|
589
|
+
* reply composer stands under the timeline. Return the promise: the box clears when it
|
|
590
|
+
* resolves. A failure is the app's to toast — the server's `detail`, else
|
|
591
|
+
* `feedbackToast.replyFailed` (0.38.1).
|
|
592
|
+
*/
|
|
593
|
+
onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
|
|
594
|
+
/** 0.38: the reply is in flight. */
|
|
595
|
+
replying?: boolean;
|
|
596
|
+
/** 0.38: the report's comments — the app's `FeedbackCommentThread` — in a section of
|
|
597
|
+
* their own under the replies. A thread here draws the comments alone (0.38.1): the
|
|
598
|
+
* Replies section above has the replies, so whatever `replies` the thread is handed
|
|
599
|
+
* are dropped, and nothing is drawn twice. */
|
|
600
|
+
comments?: ReactNode;
|
|
601
|
+
/** 0.38: `PATCH /feedback/{id} {"public": next}` — draws the **Visible to others**
|
|
602
|
+
* switch (`FeedbackPublicSwitch`) in the Status section, for the admin (`canEdit`)
|
|
603
|
+
* and, within §9.3's window, the author. The page's own mutation fits:
|
|
604
|
+
* `onPublicChange={(next) => update.mutate({ id: row.id, public: next })}` —
|
|
605
|
+
* {@link FeedbackDetailPatch}'s `public` (0.38.1). */
|
|
606
|
+
onPublicChange?: (next: boolean) => void;
|
|
567
607
|
labels?: LabelOverride<FeedbackDetailLabels>;
|
|
568
608
|
className?: string;
|
|
569
609
|
}
|
|
@@ -605,6 +645,10 @@ export function FeedbackRowDetail({
|
|
|
605
645
|
renderDate,
|
|
606
646
|
commit,
|
|
607
647
|
showSubject = true,
|
|
648
|
+
onReply,
|
|
649
|
+
replying = false,
|
|
650
|
+
comments,
|
|
651
|
+
onPublicChange,
|
|
608
652
|
labels: labelsProp,
|
|
609
653
|
className,
|
|
610
654
|
}: FeedbackRowDetailProps) {
|
|
@@ -616,7 +660,6 @@ export function FeedbackRowDetail({
|
|
|
616
660
|
const ownStatusChange = useFeedbackStatusUndo(onUpdate);
|
|
617
661
|
const changeStatus = statusChange ?? ownStatusChange;
|
|
618
662
|
const [editingBody, setEditingBody] = useState(false);
|
|
619
|
-
const [editingOutcome, setEditingOutcome] = useState(false);
|
|
620
663
|
const [reworking, setReworking] = useState(false);
|
|
621
664
|
|
|
622
665
|
const access = feedbackRowAccess(row, { canEdit, viewerId });
|
|
@@ -640,17 +683,10 @@ export function FeedbackRowDetail({
|
|
|
640
683
|
onUpdate({ id: row.id, body: replaceDescription(row.body, value) }, { onSuccess: () => setEditingBody(false) });
|
|
641
684
|
};
|
|
642
685
|
|
|
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;
|
|
686
|
+
// 0.38 (§9.7): the replies replace the outcome; a row without them is a server before
|
|
687
|
+
// 0.38, whose frozen outcome is shown as it is, read-only.
|
|
688
|
+
const replies = row.replies;
|
|
689
|
+
const commentLabels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS);
|
|
654
690
|
const showRework = access.canRework && reworking;
|
|
655
691
|
|
|
656
692
|
return (
|
|
@@ -741,7 +777,7 @@ export function FeedbackRowDetail({
|
|
|
741
777
|
)}
|
|
742
778
|
|
|
743
779
|
<FeedbackDetailSection
|
|
744
|
-
title={labels.outcome}
|
|
780
|
+
title={replies ? labels.replies : labels.outcome}
|
|
745
781
|
// When the row entered DONE / WONT_DO (keksdose feedback #96).
|
|
746
782
|
action={
|
|
747
783
|
row.resolved_at ? (
|
|
@@ -749,60 +785,54 @@ export function FeedbackRowDetail({
|
|
|
749
785
|
) : undefined
|
|
750
786
|
}
|
|
751
787
|
>
|
|
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">
|
|
788
|
+
<div className="space-y-3">
|
|
789
|
+
{replies ? (
|
|
790
|
+
<FeedbackReplyTimeline replies={replies} />
|
|
791
|
+
) : (
|
|
766
792
|
<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
|
-
)}
|
|
793
|
+
)}
|
|
794
|
+
{replies && access.canEditOutcome && onReply && (
|
|
795
|
+
<FeedbackReplyComposer
|
|
796
|
+
key={row.id}
|
|
797
|
+
status={row.status}
|
|
798
|
+
onReply={onReply}
|
|
799
|
+
pending={replying}
|
|
800
|
+
suggestedStatus={feedbackReplyNudge(row)}
|
|
801
|
+
commit={commit}
|
|
802
|
+
/>
|
|
803
|
+
)}
|
|
804
|
+
{((access.canRework && !reworking) || pageHref) && (
|
|
805
|
+
<div className="flex flex-wrap gap-2">
|
|
806
|
+
{access.canRework && !reworking && (
|
|
807
|
+
<Button
|
|
808
|
+
type="button"
|
|
809
|
+
variant="secondary"
|
|
810
|
+
onClick={(event) => {
|
|
811
|
+
event.stopPropagation();
|
|
812
|
+
setReworking(true);
|
|
813
|
+
}}
|
|
814
|
+
>
|
|
815
|
+
<RotateCcw className="size-3.5" aria-hidden /> {labels.rework}
|
|
816
|
+
</Button>
|
|
817
|
+
)}
|
|
818
|
+
{pageHref && (
|
|
819
|
+
// A real href (keksdose dev #451): middle-click and ⌘/Ctrl-click are
|
|
820
|
+
// the browser's, and only a link has them.
|
|
821
|
+
<Button href={pageHref} variant="secondary" onClick={(event) => event.stopPropagation()}>
|
|
822
|
+
<ExternalLink className="size-3.5" aria-hidden /> {labels.openPage}
|
|
823
|
+
</Button>
|
|
824
|
+
)}
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
</div>
|
|
804
828
|
</FeedbackDetailSection>
|
|
805
829
|
|
|
830
|
+
{comments != null && (
|
|
831
|
+
<FeedbackDetailSection title={commentLabels.comments}>
|
|
832
|
+
<FeedbackRepliesDrawnAboveContext.Provider value>{comments}</FeedbackRepliesDrawnAboveContext.Provider>
|
|
833
|
+
</FeedbackDetailSection>
|
|
834
|
+
)}
|
|
835
|
+
|
|
806
836
|
{showRework && (
|
|
807
837
|
<FeedbackReworkSection
|
|
808
838
|
row={row}
|
|
@@ -831,6 +861,17 @@ export function FeedbackRowDetail({
|
|
|
831
861
|
onPick={(status) => changeStatus(row, status)}
|
|
832
862
|
variant="pill"
|
|
833
863
|
/>
|
|
864
|
+
{onPublicChange && (
|
|
865
|
+
<FeedbackPublicSwitch
|
|
866
|
+
row={row}
|
|
867
|
+
isAdmin={canEdit}
|
|
868
|
+
viewerId={viewerId}
|
|
869
|
+
onChange={onPublicChange}
|
|
870
|
+
pending={pending}
|
|
871
|
+
commit={commit}
|
|
872
|
+
className="mt-3"
|
|
873
|
+
/>
|
|
874
|
+
)}
|
|
834
875
|
</FeedbackDetailSection>
|
|
835
876
|
</FeedbackDetail>
|
|
836
877
|
);
|
|
@@ -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} />}
|