@eifi1/ui-kit 0.26.0 → 0.27.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 +15 -4
- package/dist/chart.d.ts +13 -9
- package/dist/components/account-chips.d.ts +1 -1
- package/dist/components/amount-input.d.ts +13 -9
- package/dist/components/button-group.d.ts +13 -9
- package/dist/components/calculator.d.ts +13 -9
- package/dist/components/confirm-dialog.d.ts +13 -9
- package/dist/components/copy-button.d.ts +10 -6
- package/dist/components/danger-confirm.d.ts +13 -9
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-labels.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +13 -9
- package/dist/components/file-button.d.ts +13 -9
- package/dist/components/file-dropzone.d.ts +13 -9
- package/dist/components/form-actions.d.ts +13 -9
- package/dist/components/iban-input.d.ts +13 -9
- package/dist/components/language-select.d.ts +13 -9
- package/dist/components/legal.d.ts +5 -2
- package/dist/components/legal.js +4 -10
- package/dist/components/legal.js.map +1 -1
- package/dist/components/money-field.d.ts +13 -9
- package/dist/components/number-field.d.ts +13 -9
- package/dist/components/number-input.d.ts +13 -9
- package/dist/components/numpad-sheet.d.ts +13 -9
- package/dist/components/phone-input.d.ts +13 -9
- package/dist/components/series-chart.d.ts +13 -9
- package/dist/components/settings-fields.d.ts +13 -9
- package/dist/components/share-card.d.ts +13 -9
- package/dist/components/text-link.d.ts +13 -9
- package/dist/components/time-input.d.ts +13 -9
- package/dist/components/ui.d.ts +10 -6
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-labels-Cdn1QS2k.d.ts → data-table-labels-DUELeXpa.d.ts} +5 -0
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-attachment.d.ts +13 -9
- package/dist/feedback/feedback-attachment.js +3 -3
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-capture.d.ts +71 -0
- package/dist/feedback/feedback-capture.js +194 -0
- package/dist/feedback/feedback-capture.js.map +1 -0
- package/dist/feedback/feedback-context.d.ts +71 -0
- package/dist/feedback/feedback-context.js +96 -0
- package/dist/feedback/feedback-context.js.map +1 -0
- package/dist/feedback/feedback-crash.d.ts +276 -0
- package/dist/feedback/feedback-crash.js +225 -0
- package/dist/feedback/feedback-crash.js.map +1 -0
- package/dist/feedback/feedback-dialog.d.ts +13 -9
- package/dist/feedback/feedback-dialog.js +5 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +13 -9
- package/dist/feedback/feedback-inbox.js +89 -49
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +71 -0
- package/dist/feedback/feedback-labels.js +48 -0
- package/dist/feedback/feedback-labels.js.map +1 -0
- package/dist/feedback/feedback-menu.d.ts +71 -0
- package/dist/feedback/feedback-menu.js +61 -0
- package/dist/feedback/feedback-menu.js.map +1 -0
- package/dist/feedback/feedback-record.d.ts +71 -0
- package/dist/feedback/feedback-record.js +104 -0
- package/dist/feedback/feedback-record.js.map +1 -0
- package/dist/feedback/feedback-row-detail.d.ts +71 -0
- package/dist/feedback/feedback-row-detail.js +437 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -0
- package/dist/feedback/feedback-status-undo.d.ts +71 -0
- package/dist/feedback/feedback-status-undo.js +40 -0
- package/dist/feedback/feedback-status-undo.js.map +1 -0
- package/dist/feedback/feedback-submit.d.ts +182 -0
- package/dist/feedback/feedback-submit.js +101 -0
- package/dist/feedback/feedback-submit.js.map +1 -0
- package/dist/feedback/feedback-swipe.d.ts +152 -0
- package/dist/feedback/feedback-swipe.js +73 -0
- package/dist/feedback/feedback-swipe.js.map +1 -0
- package/dist/feedback/feedback-table.d.ts +71 -0
- package/dist/feedback/feedback-table.js +357 -0
- package/dist/feedback/feedback-table.js.map +1 -0
- package/dist/feedback/feedback-thread.d.ts +13 -9
- package/dist/feedback.d.ts +17 -9
- package/dist/feedback.js +11 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +13 -9
- package/dist/i18n/defaults.d.ts +13 -9
- package/dist/i18n/defaults.js +16 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +13 -9
- package/dist/i18n/german.js +91 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +7 -3
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +13 -9
- package/dist/i18n/locales/de-CH.d.ts +13 -9
- package/dist/i18n/locales/en.d.ts +13 -9
- package/dist/i18n/locales/en.js +10 -1
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -9
- package/dist/i18n/locales/es.js +89 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -9
- package/dist/i18n/locales/fr.js +89 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -9
- package/dist/i18n/locales/hu.js +90 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -9
- package/dist/i18n/locales/it.js +89 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -9
- package/dist/i18n/locales/zh.js +89 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +16 -11
- package/dist/i18n/review.js +11 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/swiss.js +3 -1
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/{feedback-BXKH-ToU.d.ts → kit-labels-DQg82eHB.d.ts} +1275 -25
- package/dist/rhf/fields.d.ts +13 -9
- package/dist/rhf/form.d.ts +13 -9
- package/dist/rhf.d.ts +13 -9
- package/dist/shell/app-shell.d.ts +13 -9
- package/dist/shell/top-bar-brand.d.ts +13 -9
- package/dist/shell.d.ts +11 -11
- package/dist/wizard/stepper-nav.d.ts +13 -9
- package/dist/wizard.d.ts +13 -9
- package/package.json +6 -1
- package/src/components/data-table.tsx +5 -0
- package/src/components/legal.tsx +8 -7
- package/src/feedback/feedback-attachment.tsx +19 -3
- package/src/feedback/feedback-capture.ts +285 -0
- package/src/feedback/feedback-context.tsx +217 -0
- package/src/feedback/feedback-crash.ts +547 -0
- package/src/feedback/feedback-dialog.tsx +29 -6
- package/src/feedback/feedback-inbox.tsx +131 -27
- package/src/feedback/feedback-labels.ts +127 -0
- package/src/feedback/feedback-menu.tsx +132 -0
- package/src/feedback/feedback-record.ts +345 -0
- package/src/feedback/feedback-row-detail.tsx +825 -0
- package/src/feedback/feedback-status-undo.ts +111 -0
- package/src/feedback/feedback-submit.tsx +224 -0
- package/src/feedback/feedback-swipe.tsx +170 -0
- package/src/feedback/feedback-table.tsx +757 -0
- package/src/feedback.ts +13 -0
- package/src/i18n/defaults.ts +16 -0
- package/src/i18n/german.ts +98 -5
- package/src/i18n/kit-labels.tsx +20 -0
- package/src/i18n/locales/en.ts +15 -1
- package/src/i18n/locales/es.ts +96 -5
- package/src/i18n/locales/fr.ts +95 -5
- package/src/i18n/locales/hu.ts +94 -5
- package/src/i18n/locales/it.ts +97 -5
- package/src/i18n/locales/zh.ts +90 -5
- package/src/i18n/review.ts +14 -2
- package/src/i18n/swiss.ts +5 -1
- package/src/index.ts +13 -0
- package/tokens.css +4 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { toast } from "../components/toast";
|
|
3
|
+
import type { FeedbackStatus } from "./feedback-inbox";
|
|
4
|
+
import {
|
|
5
|
+
useFeedbackStatusLabels,
|
|
6
|
+
useFeedbackToastLabels,
|
|
7
|
+
type FeedbackStatusLabels,
|
|
8
|
+
type FeedbackToastLabels,
|
|
9
|
+
} from "./feedback-labels";
|
|
10
|
+
import type { FeedbackRecord } from "./feedback-record";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A status change that can be taken back — §4.5 of the feedback contract, lifted from
|
|
14
|
+
* keksdose's `changeStatusUndoably` (`frontend/src/features/feedback/feedback-page.tsx`,
|
|
15
|
+
* dev #587: *"Just clicked somewhere and a feedback row disappeared without knowing what
|
|
16
|
+
* I set as new status."*).
|
|
17
|
+
*
|
|
18
|
+
* An inbox is usually filtered by status, so a row whose new status falls outside the
|
|
19
|
+
* filter leaves the table the moment the change lands, with nothing on screen saying what
|
|
20
|
+
* it became. The toast says it, names the report, and offers the old status back for 8 s
|
|
21
|
+
* (the window keksdose's transactions Undo uses, feedback #184). Undo PATCHes the old
|
|
22
|
+
* status and confirms that too. kastlan and Kurvenschmiede had no Undo at all.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The one PATCH a status change is: `PATCH /feedback/{id}` with `{ status }`. */
|
|
26
|
+
export interface FeedbackStatusPatch {
|
|
27
|
+
id: number;
|
|
28
|
+
status: FeedbackStatus;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The page's status mutation. **TanStack Query's `mutate` fits as it stands** — pass
|
|
33
|
+
* `updateMutation.mutate` (stable across renders), not `mutateAsync`. A hand-written
|
|
34
|
+
* one sends the PATCH and calls `onSuccess` once it has landed, and only then.
|
|
35
|
+
*
|
|
36
|
+
* Failure is the mutation's own business: its `onError` toasts the server's `detail`,
|
|
37
|
+
* else `feedbackToast.updateFailed` (§4.4) — this hook adds no second error toast.
|
|
38
|
+
*/
|
|
39
|
+
export type FeedbackStatusMutate = (
|
|
40
|
+
patch: FeedbackStatusPatch,
|
|
41
|
+
callbacks: { onSuccess: () => void },
|
|
42
|
+
) => void;
|
|
43
|
+
|
|
44
|
+
/** What the change needs to know about the row: which one, what it is called in the
|
|
45
|
+
* toast, and where it stood (the status Undo goes back to). */
|
|
46
|
+
export type FeedbackStatusRow = Pick<FeedbackRecord, "id" | "title" | "status">;
|
|
47
|
+
|
|
48
|
+
/** Set `row` to `status`, with the Undo toast. A no-op when it is already there. */
|
|
49
|
+
export type FeedbackStatusChange = (row: FeedbackStatusRow, status: FeedbackStatus) => void;
|
|
50
|
+
|
|
51
|
+
export interface FeedbackStatusUndoOptions {
|
|
52
|
+
/** Over the `feedbackToast` namespace (the toast's words). */
|
|
53
|
+
labels?: Partial<FeedbackToastLabels>;
|
|
54
|
+
/** Over the `feedbackStatus` namespace (the status names inside them). */
|
|
55
|
+
statusLabels?: Partial<FeedbackStatusLabels>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How long the Undo stays reachable: 8 s, keksdose's window (feedback #184). */
|
|
59
|
+
export const FEEDBACK_UNDO_DURATION = 8000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The undoable status change — for the table's status cell, the row detail's pills and
|
|
63
|
+
* the phone swipes alike (§4.5, §7.10: swipes included, Marcel 2026-10-04 — keksdose's
|
|
64
|
+
* swipe commits go through its `changeStatusUndoably` too). Wire every one of them
|
|
65
|
+
* through the function this returns:
|
|
66
|
+
*
|
|
67
|
+
* ```tsx
|
|
68
|
+
* const update = useMutation({ mutationFn: api.updateFeedback, onError: … });
|
|
69
|
+
* const changeStatus = useFeedbackStatusUndo(update.mutate);
|
|
70
|
+
* <FeedbackStatusTransitions … onPick={(status) => changeStatus(row, status)} />
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Give it the PAGE's mutation, not one a row owns: TanStack only calls per-call
|
|
74
|
+
* callbacks while the observer that started the mutation is mounted, and a row the new
|
|
75
|
+
* status filters out unmounts before the PATCH answers — the toast would never come.
|
|
76
|
+
*
|
|
77
|
+
* The flow: PATCH `{ id, status }` → on success a success toast
|
|
78
|
+
* `statusChanged(newName, title)` with the action `statusUndo`, 8 s → Undo PATCHes
|
|
79
|
+
* `{ id, status: previous }` → on success `statusRestored(previousName, title)`.
|
|
80
|
+
*/
|
|
81
|
+
export function useFeedbackStatusUndo(
|
|
82
|
+
mutate: FeedbackStatusMutate,
|
|
83
|
+
{ labels, statusLabels }: FeedbackStatusUndoOptions = {},
|
|
84
|
+
): FeedbackStatusChange {
|
|
85
|
+
const words = useFeedbackToastLabels(labels);
|
|
86
|
+
const names = useFeedbackStatusLabels(statusLabels);
|
|
87
|
+
return useCallback<FeedbackStatusChange>(
|
|
88
|
+
(row, status) => {
|
|
89
|
+
const { id, title, status: previous } = row;
|
|
90
|
+
if (status === previous) return;
|
|
91
|
+
const nameOf = (value: FeedbackStatus) => names[value] ?? value;
|
|
92
|
+
mutate(
|
|
93
|
+
{ id, status },
|
|
94
|
+
{
|
|
95
|
+
onSuccess: () => {
|
|
96
|
+
toast.undo(words.statusChanged(nameOf(status), title), {
|
|
97
|
+
label: words.statusUndo,
|
|
98
|
+
duration: FEEDBACK_UNDO_DURATION,
|
|
99
|
+
onUndo: () =>
|
|
100
|
+
mutate(
|
|
101
|
+
{ id, status: previous },
|
|
102
|
+
{ onSuccess: () => void toast.success(words.statusRestored(nameOf(previous), title)) },
|
|
103
|
+
),
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
[mutate, words, names],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { toast } from "../components/toast";
|
|
4
|
+
import { DEFAULT_MAX_ATTACHMENTS } from "./feedback-attachment";
|
|
5
|
+
import { captureAppScreenshot } from "./feedback-capture";
|
|
6
|
+
import { FeedbackContextBox, feedbackContext, type FeedbackSubmitter } from "./feedback-context";
|
|
7
|
+
import { FeedbackDialog, type FeedbackMultipleSubmission } from "./feedback-dialog";
|
|
8
|
+
import type { FeedbackCategory } from "./feedback-inbox";
|
|
9
|
+
import { useFeedbackCategoryLabels, useFeedbackToastLabels } from "./feedback-labels";
|
|
10
|
+
import {
|
|
11
|
+
FEEDBACK_ATTACHMENT_ACCEPT,
|
|
12
|
+
FEEDBACK_PICKABLE_CATEGORIES,
|
|
13
|
+
type FeedbackContext,
|
|
14
|
+
type FeedbackEnvironment,
|
|
15
|
+
} from "./feedback-record";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `FeedbackCreate` — the body of `POST /feedback` (keksdose `schemas/feedback.py:25`, §3.4
|
|
19
|
+
* of docs/feedback-harmonization.md), as {@link useFeedbackSubmit} hands it to the app's
|
|
20
|
+
* `create`.
|
|
21
|
+
*/
|
|
22
|
+
export interface FeedbackCreatePayload {
|
|
23
|
+
/** Trimmed; never blank (the dialog will not send a subject of spaces). */
|
|
24
|
+
title: string;
|
|
25
|
+
/** `""` when the reporter wrote nothing or only whitespace — the server stores `""`. */
|
|
26
|
+
body: string;
|
|
27
|
+
/** Never `CRASH` — the server answers that with a 422 (§3.4). */
|
|
28
|
+
category: FeedbackCategory;
|
|
29
|
+
/** §3.2, built by `feedbackContext` from what the context box showed. */
|
|
30
|
+
context: FeedbackContext;
|
|
31
|
+
/** The captured screenshot's upload URL, or `null`. */
|
|
32
|
+
screenshot_url: string | null;
|
|
33
|
+
/** The picked and pasted files' upload URLs, in the order they were added — `[]` when
|
|
34
|
+
* none, as keksdose sends it. At most 5. */
|
|
35
|
+
attachment_urls: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseFeedbackSubmitOptions<R = unknown> {
|
|
39
|
+
/**
|
|
40
|
+
* `POST /feedback/attachments` for ONE file → its URL (§3.3) — the app's authed client.
|
|
41
|
+
* Every file goes up BEFORE the report is created, so a row never names a file that did
|
|
42
|
+
* not arrive (keksdose `use-feedback-dialog.tsx:89`); if any upload rejects, nothing is
|
|
43
|
+
* created and the failure toast says so.
|
|
44
|
+
*/
|
|
45
|
+
upload: (file: File) => Promise<string>;
|
|
46
|
+
/** `POST /feedback` with the payload. Whatever it resolves with reaches `onSubmitted`. */
|
|
47
|
+
create: (payload: FeedbackCreatePayload) => Promise<R>;
|
|
48
|
+
/** Who is filing — shown in the context box and sent in `context.user_*`. */
|
|
49
|
+
user?: FeedbackSubmitter | null;
|
|
50
|
+
/** `context.environment` — the app's own reading of which copy it is (keksdose
|
|
51
|
+
* `deployEnvironment()`). */
|
|
52
|
+
environment?: FeedbackEnvironment | (string & {});
|
|
53
|
+
/** `context.version` — the app build (`__APP_VERSION__`). */
|
|
54
|
+
version?: string;
|
|
55
|
+
/** The report was filed: after the "Thanks for the feedback!" toast and the close. The
|
|
56
|
+
* place to invalidate the app's feedback queries. */
|
|
57
|
+
onSubmitted?: (result: R) => void;
|
|
58
|
+
/**
|
|
59
|
+
* The failure toast's text from what `upload` or `create` rejected with — the app's own
|
|
60
|
+
* `extractApiErrorMessage(err, fallback)` (keksdose) / `apiErrorMessage` (Kurvenschmiede),
|
|
61
|
+
* so the server's `detail` wins (§3.7: a 413, a 415, keksdose's demo-session 403). Without
|
|
62
|
+
* it every failure says `feedbackToast.submitFailed`.
|
|
63
|
+
*/
|
|
64
|
+
errorMessage?: (error: unknown, fallback: string) => string;
|
|
65
|
+
/**
|
|
66
|
+
* The "Capture screenshot" button's capture. Default {@link captureAppScreenshot} of
|
|
67
|
+
* `#root`; another root is `() => captureAppScreenshot({ rootId: "app" })`. `false`
|
|
68
|
+
* hides the button — for an app that does not install the optional peer
|
|
69
|
+
* `modern-screenshot`. A rejection becomes the `captureFailed` toast.
|
|
70
|
+
*/
|
|
71
|
+
capture?: false | (() => Promise<File | null>);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FeedbackSubmit {
|
|
75
|
+
/**
|
|
76
|
+
* Open the dialog on `category` — a `FeedbackMenu` row's (§4.2: "default category when
|
|
77
|
+
* opened from a row: that row's"). Without one, or with `CRASH` (never pickable), it
|
|
78
|
+
* opens on `OTHER`, the server's own default (§3.4). Each open starts a fresh form,
|
|
79
|
+
* reads `location.href` for the context box and ticks "Attach current page URL" again.
|
|
80
|
+
*/
|
|
81
|
+
open: (category?: FeedbackCategory) => void;
|
|
82
|
+
/** Close it, as Cancel does. */
|
|
83
|
+
close: () => void;
|
|
84
|
+
/** The dialog — render it once, anywhere (it portals). `null` while closed. */
|
|
85
|
+
dialog: ReactNode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The feedback submit dialog, wired (0.27.0, docs/feedback-harmonization.md §4.2): the
|
|
90
|
+
* one call that replaces keksdose's `use-feedback-dialog.tsx`, kastlan's `FeedbackButton`
|
|
91
|
+
* and Kurvenschmiede's `use-feedback-dialog.tsx`.
|
|
92
|
+
*
|
|
93
|
+
* ```tsx
|
|
94
|
+
* const feedback = useFeedbackSubmit({
|
|
95
|
+
* upload: (file) => feedbackApi.uploadAttachment(file),
|
|
96
|
+
* create: (payload) => feedbackApi.create(payload),
|
|
97
|
+
* user, environment: deployEnvironment(), version: __APP_VERSION__,
|
|
98
|
+
* errorMessage: extractApiErrorMessage,
|
|
99
|
+
* onSubmitted: () => qc.invalidateQueries({ queryKey: queryKeys.feedback.all }),
|
|
100
|
+
* });
|
|
101
|
+
* <FeedbackMenu onFile={feedback.open} isAdmin={user?.role === "ADMIN"} />
|
|
102
|
+
* {feedback.dialog}
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* What it settles, so no app restates it:
|
|
106
|
+
*
|
|
107
|
+
* - `FeedbackDialog` in `attachments="multiple"` with `requireBody={false}` (§7.4): a
|
|
108
|
+
* subject alone is a report. One captured screenshot in its own slot plus up to 5
|
|
109
|
+
* picked or pasted files, `FEEDBACK_ATTACHMENT_ACCEPT` (images, PDF, text), 10 MB each.
|
|
110
|
+
* - The pickable categories — Bug, Idea, Question, Other, in the kit's words. `CRASH` is
|
|
111
|
+
* filed by the error boundary and is never offered (keksdose feedback #160).
|
|
112
|
+
* - The context box, built from the same values as the `context` it sends
|
|
113
|
+
* ({@link FeedbackContextBox} / `feedbackContext`), the URL read when the dialog OPENS.
|
|
114
|
+
* - Upload every file, then create (keksdose `use-feedback-dialog.tsx:89`): the
|
|
115
|
+
* screenshot to `screenshot_url`, the rest to `attachment_urls`, all in parallel.
|
|
116
|
+
* - The toasts, from `feedbackToast`: filed, failed (the server's `detail` through
|
|
117
|
+
* `errorMessage`), a refused file's type, size or count, a failed capture.
|
|
118
|
+
*
|
|
119
|
+
* What stays the app's: the API client, the user, the environment and version, and what
|
|
120
|
+
* to refresh afterwards. No TanStack in here — `create` is a plain promise, so the hook
|
|
121
|
+
* runs in any app; the app's mutation, if it wants one, lives inside `create`.
|
|
122
|
+
*
|
|
123
|
+
* A failure keeps the dialog open with the draft, as keksdose's did. A dialog closed and
|
|
124
|
+
* opened again while a send is still running is NOT closed by that send's success — the
|
|
125
|
+
* new draft is somebody's next report.
|
|
126
|
+
*/
|
|
127
|
+
export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions<R>): FeedbackSubmit {
|
|
128
|
+
const { upload, create, user, environment, version, onSubmitted, errorMessage, capture } = options;
|
|
129
|
+
const toastText = useFeedbackToastLabels();
|
|
130
|
+
const categoryLabels = useFeedbackCategoryLabels();
|
|
131
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
132
|
+
const [category, setCategory] = useState<FeedbackCategory>("OTHER");
|
|
133
|
+
const [url, setUrl] = useState("");
|
|
134
|
+
const [attachUrl, setAttachUrl] = useState(true);
|
|
135
|
+
const [submitting, setSubmitting] = useState(false);
|
|
136
|
+
// Which opening a send belongs to — see "closed and opened again" above.
|
|
137
|
+
const opening = useRef(0);
|
|
138
|
+
const inFlight = useRef(false);
|
|
139
|
+
|
|
140
|
+
const open = (next?: FeedbackCategory) => {
|
|
141
|
+
opening.current += 1;
|
|
142
|
+
setCategory(next && FEEDBACK_PICKABLE_CATEGORIES.includes(next) ? next : "OTHER");
|
|
143
|
+
setUrl(typeof window === "undefined" ? "" : window.location.href);
|
|
144
|
+
// Fresh every time (Kurvenschmiede use-feedback-dialog.tsx:80): a reporter who left the
|
|
145
|
+
// page out of one idea is not saying the bug report after it is unrelated too.
|
|
146
|
+
setAttachUrl(true);
|
|
147
|
+
setIsOpen(true);
|
|
148
|
+
};
|
|
149
|
+
const close = () => setIsOpen(false);
|
|
150
|
+
|
|
151
|
+
const submit = async (data: FeedbackMultipleSubmission) => {
|
|
152
|
+
if (inFlight.current) return;
|
|
153
|
+
inFlight.current = true;
|
|
154
|
+
setSubmitting(true);
|
|
155
|
+
const from = opening.current;
|
|
156
|
+
let result: R;
|
|
157
|
+
try {
|
|
158
|
+
const [screenshot_url, attachment_urls] = await Promise.all([
|
|
159
|
+
data.screenshot ? upload(data.screenshot) : Promise.resolve(null),
|
|
160
|
+
Promise.all(data.attachments.map((file) => upload(file))),
|
|
161
|
+
]);
|
|
162
|
+
result = await create({
|
|
163
|
+
title: data.title.trim(),
|
|
164
|
+
body: data.body.trim() ? data.body : "",
|
|
165
|
+
category: data.category as FeedbackCategory,
|
|
166
|
+
context: feedbackContext({ user, attachUrl, url, environment, version }),
|
|
167
|
+
screenshot_url,
|
|
168
|
+
attachment_urls,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
toast.error(errorMessage ? errorMessage(error, toastText.submitFailed) : toastText.submitFailed);
|
|
172
|
+
return;
|
|
173
|
+
} finally {
|
|
174
|
+
inFlight.current = false;
|
|
175
|
+
setSubmitting(false);
|
|
176
|
+
}
|
|
177
|
+
toast.success(toastText.submitted);
|
|
178
|
+
if (opening.current === from) setIsOpen(false);
|
|
179
|
+
onSubmitted?.(result);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onCaptureScreenshot =
|
|
183
|
+
capture === false
|
|
184
|
+
? undefined
|
|
185
|
+
: async () => {
|
|
186
|
+
try {
|
|
187
|
+
return await (capture ?? (() => captureAppScreenshot()))();
|
|
188
|
+
} catch {
|
|
189
|
+
toast.error(toastText.captureFailed);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const dialog = isOpen ? (
|
|
195
|
+
<FeedbackDialog
|
|
196
|
+
open
|
|
197
|
+
onClose={close}
|
|
198
|
+
categories={FEEDBACK_PICKABLE_CATEGORIES.map((value) => ({ value, label: categoryLabels[value] }))}
|
|
199
|
+
category={category}
|
|
200
|
+
onCategoryChange={(value) => setCategory(value as FeedbackCategory)}
|
|
201
|
+
submitting={submitting}
|
|
202
|
+
attachments="multiple"
|
|
203
|
+
maxAttachments={DEFAULT_MAX_ATTACHMENTS}
|
|
204
|
+
requireBody={false}
|
|
205
|
+
attachmentAccept={FEEDBACK_ATTACHMENT_ACCEPT}
|
|
206
|
+
onAttachmentError={(kind, info) =>
|
|
207
|
+
toast.error(
|
|
208
|
+
kind === "type"
|
|
209
|
+
? toastText.attachmentUnsupported
|
|
210
|
+
: kind === "size"
|
|
211
|
+
? toastText.attachmentTooLarge
|
|
212
|
+
: // More files at once than there was room for: the surplus was dropped, the
|
|
213
|
+
// rest kept. The count is how many fit, as keksdose says it.
|
|
214
|
+
toastText.attachmentTooMany(info.max ?? DEFAULT_MAX_ATTACHMENTS),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
onCaptureScreenshot={onCaptureScreenshot}
|
|
218
|
+
onSubmit={submit}
|
|
219
|
+
contextSlot={<FeedbackContextBox user={user} url={url} attachUrl={attachUrl} onAttachUrlChange={setAttachUrl} />}
|
|
220
|
+
/>
|
|
221
|
+
) : null;
|
|
222
|
+
|
|
223
|
+
return { open, close, dialog };
|
|
224
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Ban, Check, ChevronsRight } from "lucide-react";
|
|
2
|
+
import type { SwipeAction } from "../components/swipeable-row";
|
|
3
|
+
import type { MobileSwipeActions } from "../components/data-table";
|
|
4
|
+
import { nextFeedbackStatus, type FeedbackStatus } from "./feedback-inbox";
|
|
5
|
+
import type { FeedbackStatusLabels } from "./feedback-labels";
|
|
6
|
+
import type { FeedbackStatusChange, FeedbackStatusRow } from "./feedback-status-undo";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Swipes on the admin inbox's phone cards — §4.5 of the feedback contract, keksdose's
|
|
10
|
+
* (`feedback-page.tsx:990`, feedback #144 rework item 4: *"it is a queue, and triaging it
|
|
11
|
+
* one tap-into-the-row at a time is the slow way"*), for every app.
|
|
12
|
+
*
|
|
13
|
+
* - `advance` — one step along the chain (`nextFeedbackStatus`: OPEN → IN_PROGRESS →
|
|
14
|
+
* IN_EVALUATION → DONE), labelled with the status it goes to;
|
|
15
|
+
* - `done` — straight to DONE, for a row that plainly needs no stop on the way;
|
|
16
|
+
* - `wont_do` — dismiss as WONT_DO.
|
|
17
|
+
*
|
|
18
|
+
* The same transitions the status column offers, so nothing is reachable by gesture that
|
|
19
|
+
* is not reachable by tap. keksdose lets the user rebind them (Settings → Interaction,
|
|
20
|
+
* feedback #404) and keeps that storage; kastlan and Kurvenschmiede take the default.
|
|
21
|
+
*/
|
|
22
|
+
export const FEEDBACK_SWIPE_ACTIONS = ["advance", "done", "wont_do"] as const;
|
|
23
|
+
|
|
24
|
+
/** One of {@link FEEDBACK_SWIPE_ACTIONS}. */
|
|
25
|
+
export type FeedbackSwipeAction = (typeof FEEDBACK_SWIPE_ACTIONS)[number];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which swipe does what — the same shape as 0.26's `TranslationReviewSwipeBinding`: per
|
|
29
|
+
* LOGICAL side an ordered ladder, index 0 at the first threshold, index 1 at the longer
|
|
30
|
+
* drag. `end` is a drag toward the reading end (right in a left-to-right page), `start`
|
|
31
|
+
* the other way. keksdose stores PHYSICAL `right` / `left` ladders and hands
|
|
32
|
+
* `{ end: right, start: left }`, as its translations page does
|
|
33
|
+
* (`translations-page.tsx:280`).
|
|
34
|
+
*
|
|
35
|
+
* The ids are typed loosely on purpose: a stored binding carries keksdose's `"none"`
|
|
36
|
+
* fillers and may carry an id from another version or another surface — they are
|
|
37
|
+
* dropped (see {@link feedbackSwipePlan}), so it is passed in without a cast.
|
|
38
|
+
*/
|
|
39
|
+
export interface FeedbackSwipeBinding {
|
|
40
|
+
/** A drag toward the reading START, nearest threshold first. */
|
|
41
|
+
start?: readonly (FeedbackSwipeAction | (string & {}))[];
|
|
42
|
+
/** A drag toward the reading END, nearest threshold first. */
|
|
43
|
+
end?: readonly (FeedbackSwipeAction | (string & {}))[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** keksdose's shipped binding (`swipe-prefs-store.ts:99`): toward the end advance, then
|
|
47
|
+
* done on the longer drag; toward the start won't do. A settings page's reset goes here. */
|
|
48
|
+
export const DEFAULT_FEEDBACK_SWIPE: FeedbackSwipeBinding = Object.freeze({
|
|
49
|
+
start: Object.freeze(["wont_do"] as const),
|
|
50
|
+
end: Object.freeze(["advance", "done"] as const),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export interface FeedbackSwipeOptions {
|
|
54
|
+
/** What a swipe commits through: the undoable change from `useFeedbackStatusUndo`, the
|
|
55
|
+
* same one the status cell and the detail's pills use — so a swipe gets the same 8 s
|
|
56
|
+
* "Set to …" toast with Undo as a tap (§4.5, §7.10, Marcel 2026-10-04; keksdose's
|
|
57
|
+
* `changeStatusUndoably` on its swipes too). A swipe is the easiest status change to
|
|
58
|
+
* make by accident. The plan calls it and nothing else, so the page owns the PATCH. */
|
|
59
|
+
change: FeedbackStatusChange;
|
|
60
|
+
/** The status names the panels show — `useFeedbackStatusLabels()`. The `done` panel
|
|
61
|
+
* reads DONE's, `wont_do` WONT_DO's, `advance` the next status's. */
|
|
62
|
+
labels: FeedbackStatusLabels;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where an action takes a row, or null when it would do nothing from there. */
|
|
66
|
+
function targetOf(id: string, status: FeedbackStatus): FeedbackStatus | null {
|
|
67
|
+
switch (id) {
|
|
68
|
+
case "advance":
|
|
69
|
+
// Null at the chain's end (DONE) and off it (NEEDS_LIVE_TEST, POSTPONED, WONT_DO):
|
|
70
|
+
// "advance" has no meaning there, and a threshold that commits nothing is a trap.
|
|
71
|
+
return nextFeedbackStatus(status);
|
|
72
|
+
case "done":
|
|
73
|
+
return status === "DONE" ? null : "DONE";
|
|
74
|
+
case "wont_do":
|
|
75
|
+
return status === "WONT_DO" ? null : "WONT_DO";
|
|
76
|
+
default:
|
|
77
|
+
// "none", or an id this version does not know: an empty slot.
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One action's panel. Each fill carries its text colour (0.26): the fills turn pastel
|
|
83
|
+
* in dark mode, where SwipeableRow's default white text was unreadable. `--info` has no
|
|
84
|
+
* contrast token, so `advance` writes in the surface colour — white on sky in light
|
|
85
|
+
* mode, the dark page on the pale sky in dark (the translation list's reset does the
|
|
86
|
+
* same on its grey). */
|
|
87
|
+
function panel(
|
|
88
|
+
id: FeedbackSwipeAction,
|
|
89
|
+
target: FeedbackStatus,
|
|
90
|
+
row: FeedbackStatusRow,
|
|
91
|
+
{ change, labels }: FeedbackSwipeOptions,
|
|
92
|
+
): SwipeAction {
|
|
93
|
+
const onCommit = () => change(row, target);
|
|
94
|
+
switch (id) {
|
|
95
|
+
case "advance":
|
|
96
|
+
return {
|
|
97
|
+
onCommit,
|
|
98
|
+
label: labels[target],
|
|
99
|
+
icon: <ChevronsRight className="size-4" aria-hidden />,
|
|
100
|
+
className: "bg-[var(--info)] text-[var(--info-contrast)]",
|
|
101
|
+
armedClassName: "bg-[var(--info)] text-[var(--info-contrast)]",
|
|
102
|
+
};
|
|
103
|
+
case "done":
|
|
104
|
+
return {
|
|
105
|
+
onCommit,
|
|
106
|
+
label: labels.DONE,
|
|
107
|
+
icon: <Check className="size-4" aria-hidden />,
|
|
108
|
+
className: "bg-[var(--success)] text-[var(--success-contrast)]",
|
|
109
|
+
armedClassName: "bg-[var(--success)] text-[var(--success-contrast)]",
|
|
110
|
+
};
|
|
111
|
+
case "wont_do":
|
|
112
|
+
return {
|
|
113
|
+
onCommit,
|
|
114
|
+
label: labels.WONT_DO,
|
|
115
|
+
icon: <Ban className="size-4" aria-hidden />,
|
|
116
|
+
className: "bg-[var(--danger)] text-[var(--danger-contrast)]",
|
|
117
|
+
armedClassName: "bg-[var(--danger-hover)] text-[var(--danger-contrast)]",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The binding resolved against one row — what DataTable's `mobileSwipeActions` returns
|
|
124
|
+
* for it, or `null` when the row offers no swipe at all:
|
|
125
|
+
*
|
|
126
|
+
* ```tsx
|
|
127
|
+
* const changeStatus = useFeedbackStatusUndo(update.mutate);
|
|
128
|
+
* const statusLabels = useFeedbackStatusLabels();
|
|
129
|
+
* <DataTable
|
|
130
|
+
* mobileSwipeActions={isAdmin && !mine
|
|
131
|
+
* ? (row) => feedbackSwipePlan(DEFAULT_FEEDBACK_SWIPE, row, { change: changeStatus, labels: statusLabels })
|
|
132
|
+
* : undefined}
|
|
133
|
+
* />
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* Modelled on 0.26's `translationReviewSwipePlan` (keksdose's `resolveSwipePlan`): an
|
|
137
|
+
* action the row cannot take DROPS OUT and the ladder closes up behind it, so the second
|
|
138
|
+
* stage becomes the first instead of the side going dead behind an unreachable stage:
|
|
139
|
+
*
|
|
140
|
+
* - `advance` on DONE and off the chain (NEEDS_LIVE_TEST, POSTPONED, WONT_DO);
|
|
141
|
+
* - `done` on DONE, `wont_do` on WONT_DO;
|
|
142
|
+
* - `"none"` and any id this version does not know;
|
|
143
|
+
* - a later stage that lands where an earlier one on the same side already does — the id
|
|
144
|
+
* named twice, or `done` behind `advance` on IN_EVALUATION (both go to DONE): a longer
|
|
145
|
+
* drag to the same effect would arm a threshold that adds nothing.
|
|
146
|
+
*
|
|
147
|
+
* Only what the ROW decides. Whether the page may write at all — the admin inbox, not
|
|
148
|
+
* `/my-feedback`; no write lock — is the caller's, by passing no `mobileSwipeActions`.
|
|
149
|
+
*/
|
|
150
|
+
export function feedbackSwipePlan(
|
|
151
|
+
binding: FeedbackSwipeBinding,
|
|
152
|
+
row: FeedbackStatusRow,
|
|
153
|
+
options: FeedbackSwipeOptions,
|
|
154
|
+
): MobileSwipeActions | null {
|
|
155
|
+
const side = (ids: FeedbackSwipeBinding["start"]): SwipeAction[] => {
|
|
156
|
+
const reached = new Set<FeedbackStatus>();
|
|
157
|
+
const out: SwipeAction[] = [];
|
|
158
|
+
for (const id of ids ?? []) {
|
|
159
|
+
const target = targetOf(id, row.status);
|
|
160
|
+
if (target === null || reached.has(target)) continue;
|
|
161
|
+
reached.add(target);
|
|
162
|
+
out.push(panel(id as FeedbackSwipeAction, target, row, options));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
const start = side(binding.start);
|
|
167
|
+
const end = side(binding.end);
|
|
168
|
+
if (start.length === 0 && end.length === 0) return null;
|
|
169
|
+
return { start, end };
|
|
170
|
+
}
|