@eifi1/ui-kit 0.24.1 → 0.25.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 +6 -6
- package/dist/chart.d.ts +2 -2
- package/dist/components/account-chips.d.ts +1 -1
- package/dist/components/amount-input.d.ts +2 -2
- package/dist/components/button-group.d.ts +2 -2
- package/dist/components/calculator.d.ts +2 -2
- package/dist/components/column-mapper.d.ts +28 -9
- package/dist/components/column-mapper.js +63 -77
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +2 -2
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/danger-confirm.d.ts +2 -2
- 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-pagination.js +6 -1
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +7 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -2
- package/dist/components/file-button.d.ts +2 -2
- package/dist/components/file-dropzone.d.ts +2 -2
- package/dist/components/form-actions.d.ts +2 -2
- package/dist/components/iban-input.d.ts +2 -2
- package/dist/components/language-select.d.ts +2 -2
- package/dist/components/money-field.d.ts +2 -2
- package/dist/components/number-field.d.ts +2 -2
- package/dist/components/number-input.d.ts +2 -2
- package/dist/components/numpad-sheet.d.ts +2 -2
- package/dist/components/phone-input.d.ts +2 -2
- package/dist/components/series-chart.d.ts +2 -2
- package/dist/components/settings-fields.d.ts +2 -2
- package/dist/components/share-card.d.ts +2 -2
- package/dist/components/swipeable-row.js +1 -1
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table.d.ts +35 -1
- package/dist/components/table.js +4 -0
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +2 -2
- package/dist/components/time-input.d.ts +2 -2
- package/dist/components/tooltip.d.ts +103 -14
- package/dist/components/tooltip.js +194 -57
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +9 -1
- package/dist/components/translation-review-editor.js +11 -1
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +10 -0
- package/dist/components/translation-review-labels.js +5 -1
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +145 -8
- package/dist/components/translation-review.js +372 -91
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/ui.d.ts +2 -2
- package/dist/components/ui.js +55 -18
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-labels-B7OdnM0S.d.ts → data-table-labels-Cdn1QS2k.d.ts} +21 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -2
- package/dist/feedback/feedback-dialog.d.ts +2 -2
- package/dist/feedback/feedback-dialog.js +6 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +2 -2
- package/dist/feedback/feedback-inbox.js +2 -0
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +2 -2
- package/dist/{feedback-DOwPu-Il.d.ts → feedback-BXKH-ToU.d.ts} +32 -3
- package/dist/feedback.d.ts +2 -2
- package/dist/hooks/use-body-scroll-lock.js +16 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +2 -2
- package/dist/hooks/use-search-param-state.d.ts +58 -3
- package/dist/hooks/use-search-param-state.js +107 -35
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.d.ts +2 -2
- package/dist/i18n/german.d.ts +2 -2
- package/dist/i18n/german.js +8 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/languages.d.ts +2 -2
- package/dist/i18n/locales/de-CH.d.ts +2 -2
- package/dist/i18n/locales/en.d.ts +2 -2
- package/dist/i18n/locales/en.js +5 -1
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -2
- package/dist/i18n/locales/es.js +7 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -2
- package/dist/i18n/locales/fr.js +6 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -2
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -2
- package/dist/i18n/locales/it.js +6 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -2
- package/dist/i18n/locales/zh.js +6 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +2 -2
- package/dist/i18n/review.js +11 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/strip-fade.d.ts +84 -5
- package/dist/lib/strip-fade.js +109 -11
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/lib/translation-review.d.ts +45 -1
- package/dist/lib/translation-review.js +38 -1
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/rhf/fields.d.ts +2 -2
- package/dist/rhf/form.d.ts +2 -2
- package/dist/rhf.d.ts +2 -2
- package/dist/shell/app-shell.d.ts +2 -2
- package/dist/shell/top-bar-brand.d.ts +2 -2
- package/dist/shell.d.ts +2 -2
- package/dist/wizard/stepper-nav.d.ts +2 -2
- package/dist/wizard.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/column-mapper.tsx +120 -113
- package/src/components/data-table-pagination.tsx +10 -1
- package/src/components/data-table.tsx +34 -1
- package/src/components/swipeable-row.tsx +1 -1
- package/src/components/table.tsx +39 -0
- package/src/components/tooltip.tsx +487 -104
- package/src/components/translation-review-editor.tsx +21 -1
- package/src/components/translation-review-labels.ts +17 -0
- package/src/components/translation-review.tsx +657 -109
- package/src/components/ui.tsx +104 -4
- package/src/feedback/feedback-dialog.tsx +21 -0
- package/src/feedback/feedback-inbox.tsx +19 -2
- package/src/hooks/use-body-scroll-lock.ts +25 -0
- package/src/hooks/use-search-param-state.ts +283 -41
- package/src/i18n/german.ts +10 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +9 -0
- package/src/i18n/locales/fr.ts +9 -0
- package/src/i18n/locales/hu.ts +11 -0
- package/src/i18n/locales/it.ts +9 -0
- package/src/i18n/locales/zh.ts +8 -0
- package/src/i18n/review.ts +11 -0
- package/src/index.ts +12 -0
- package/src/lib/strip-fade.ts +220 -15
- package/src/lib/translation-review.ts +85 -0
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
1
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import { Check, ListChecks, RotateCcw } from "lucide-react";
|
|
3
|
+
import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
|
|
4
4
|
|
|
5
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
5
6
|
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
6
7
|
import { cn } from "../lib/cn";
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_TRANSLATION_REVIEW_FILTER,
|
|
9
10
|
REVIEW_STATUSES,
|
|
10
11
|
filterTranslationRows,
|
|
12
|
+
groupTranslationRows,
|
|
11
13
|
keyInAreas,
|
|
14
|
+
reviewUndo,
|
|
12
15
|
reviewWrite,
|
|
13
16
|
summariseRows,
|
|
17
|
+
unreviewedRows,
|
|
14
18
|
} from "../lib/translation-review";
|
|
15
19
|
import type {
|
|
16
20
|
ReviewStatus,
|
|
@@ -18,20 +22,24 @@ import type {
|
|
|
18
22
|
TranslationReviewKey,
|
|
19
23
|
TranslationReviewWrite,
|
|
20
24
|
TranslationRow,
|
|
25
|
+
TranslationRowGroup,
|
|
21
26
|
} from "../lib/translation-review";
|
|
22
27
|
import { AlertBanner } from "./alert-banner";
|
|
23
28
|
import { BulkActionBar } from "./bulk-action-bar";
|
|
24
29
|
import { Checkbox } from "./checkbox";
|
|
25
30
|
import { Chip } from "./chip";
|
|
26
31
|
import { DataTable } from "./data-table";
|
|
27
|
-
import type { DataTableColumn } from "./data-table";
|
|
32
|
+
import type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } from "./data-table";
|
|
33
|
+
import { Disclosure } from "./disclosure";
|
|
28
34
|
import { SearchField } from "./search-field";
|
|
35
|
+
import { toast } from "./toast";
|
|
29
36
|
import { ToggleGroup } from "./toggle-group";
|
|
30
37
|
import { TranslationReviewEditor } from "./translation-review-editor";
|
|
31
38
|
import { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from "./translation-review-labels";
|
|
32
39
|
import type { TranslationReviewLabels } from "./translation-review-labels";
|
|
33
40
|
import { ReviewStatusChip, TranslationProgress } from "./translation-review-parts";
|
|
34
41
|
import { Button, IconButton, Select } from "./ui";
|
|
42
|
+
import { useWriteLock } from "./write-lock";
|
|
35
43
|
|
|
36
44
|
export {
|
|
37
45
|
REVIEW_STATUS_TONES,
|
|
@@ -73,6 +81,33 @@ export type { TranslationReviewLabels } from "./translation-review-labels";
|
|
|
73
81
|
|
|
74
82
|
type MaybePromise = void | Promise<unknown>;
|
|
75
83
|
|
|
84
|
+
/** What asked for a write — see {@link TranslationReviewSaveInfo}. */
|
|
85
|
+
export type TranslationReviewOrigin = "editor" | "row" | "swipe" | "group" | "selection" | "undo";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The second argument of `onSave` and `onClear` (0.25): where the write came from, and
|
|
89
|
+
* whether the panel answers it with a toast of its own.
|
|
90
|
+
*
|
|
91
|
+
* WHY THE APP IS TOLD. keksdose and kastlan toast "Saved" from their mutation on every
|
|
92
|
+
* save. With {@link TranslationReviewPanelProps.undo} on, an approval is answered by the
|
|
93
|
+
* kit's Undo toast instead — and a reviewer swiping through a list then read two toasts
|
|
94
|
+
* per card. The app skips its own where `toasted` is true:
|
|
95
|
+
*
|
|
96
|
+
* onSave={(writes, { toasted }) => save.mutateAsync({ items: writes.map(toApiWrite), quiet: toasted })}
|
|
97
|
+
*
|
|
98
|
+
* An app that ignores the argument keeps working; it only says "Saved" twice.
|
|
99
|
+
*/
|
|
100
|
+
export interface TranslationReviewSaveInfo {
|
|
101
|
+
/** The open editor, a row's approve button, a swipe on a phone card, a group's
|
|
102
|
+
* "Approve unreviewed", the selection's bulk bar, or an Undo pressed on the toast. */
|
|
103
|
+
origin: TranslationReviewOrigin;
|
|
104
|
+
/** The panel shows its own toast for this write (an approval's Undo toast). */
|
|
105
|
+
toasted: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */
|
|
109
|
+
export type TranslationReviewGroupBy = "namespace" | "source" | ((row: TranslationRow) => string);
|
|
110
|
+
|
|
76
111
|
export interface TranslationReviewPanelProps {
|
|
77
112
|
/** One locale's rows, from `translationRows`. */
|
|
78
113
|
rows: readonly TranslationRow[];
|
|
@@ -81,10 +116,12 @@ export interface TranslationReviewPanelProps {
|
|
|
81
116
|
/** The reference language's own name — the reference column's header. */
|
|
82
117
|
referenceLabel: string;
|
|
83
118
|
/** Store verdicts: one for an editor or a row's approve button, many for a bulk
|
|
84
|
-
* approve. Left out (or `readOnly`): nothing can be changed.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
* approve. Left out (or `readOnly`): nothing can be changed. The second argument
|
|
120
|
+
* says where the write came from ({@link TranslationReviewSaveInfo}). */
|
|
121
|
+
onSave?: (writes: TranslationReviewWrite[], info: TranslationReviewSaveInfo) => MaybePromise;
|
|
122
|
+
/** Clear verdicts — back to unreviewed. Left out: no reset, and an approval of a row
|
|
123
|
+
* that had no verdict cannot be undone. */
|
|
124
|
+
onClear?: (keys: TranslationReviewKey[], info: TranslationReviewSaveInfo) => MaybePromise;
|
|
88
125
|
/** A locale the viewer may read but not review: the rows and their verdicts, no
|
|
89
126
|
* actions, and a line saying so. */
|
|
90
127
|
readOnly?: boolean;
|
|
@@ -104,9 +141,18 @@ export interface TranslationReviewPanelProps {
|
|
|
104
141
|
sourceLabels?: Readonly<Record<string, string>>;
|
|
105
142
|
/**
|
|
106
143
|
* Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,
|
|
107
|
-
* `?
|
|
144
|
+
* `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
|
|
145
|
+
*
|
|
146
|
+
* A field passed here is the app's, and the control shows what the app says: one the app
|
|
147
|
+
* passes but does not write back from `onFilterChange` is a control that does nothing.
|
|
148
|
+
* A development build warns about that once per field (0.25.1, Kurvenschmiede); a field
|
|
149
|
+
* left out entirely is fine — the panel keeps it.
|
|
108
150
|
*/
|
|
109
151
|
filter?: Partial<TranslationReviewFilter>;
|
|
152
|
+
/** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
|
|
153
|
+
* setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).
|
|
154
|
+
* Single `useSearchParamState` setters compose since 0.25; before, four in a row
|
|
155
|
+
* clobbered each other (keksdose live #378). */
|
|
110
156
|
onFilterChange?: (filter: TranslationReviewFilter) => void;
|
|
111
157
|
/** The progress bar over the filters. Default true. */
|
|
112
158
|
progress?: boolean;
|
|
@@ -118,10 +164,211 @@ export interface TranslationReviewPanelProps {
|
|
|
118
164
|
formatDate?: (iso: string) => string;
|
|
119
165
|
/** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */
|
|
120
166
|
formatError?: (error: unknown) => ReactNode;
|
|
167
|
+
/**
|
|
168
|
+
* Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward
|
|
169
|
+
* the reading END to approve, toward the START for "Needs a change", which OPENS the
|
|
170
|
+
* editor with the cursor in the wording rather than sending the string back blind — a
|
|
171
|
+
* verdict without a note or a better wording tells whoever fixes it nothing. In a
|
|
172
|
+
* right-to-left page the two sides mirror with the reading direction.
|
|
173
|
+
*
|
|
174
|
+
* DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same
|
|
175
|
+
* gesture, the same feedback, and the same keyboard path — every swipe is also a real
|
|
176
|
+
* button, read out and reachable by Tab, so the gesture is a shortcut and never the only
|
|
177
|
+
* way. A row has no swipe while the editor under it is open, while its own write is out,
|
|
178
|
+
* under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward "Approve"
|
|
179
|
+
* where there is nothing to approve (a missing string, an approved one). A reviewer
|
|
180
|
+
* limited to `areas` never sees a row outside them, so never swipes one.
|
|
181
|
+
*
|
|
182
|
+
* Off by default, so no existing page gains a gesture on the bump. Phones only; the
|
|
183
|
+
* desktop table keeps its approve button.
|
|
184
|
+
*/
|
|
185
|
+
swipe?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a
|
|
188
|
+
* swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores
|
|
189
|
+
* again what the rows said before ({@link reviewUndo}) — or clears them, for rows that
|
|
190
|
+
* had no verdict, which needs `onClear` (without it the toast reports and offers
|
|
191
|
+
* nothing). A batch has ONE Undo.
|
|
192
|
+
*
|
|
193
|
+
* Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many
|
|
194
|
+
* rows at once bring their way back — and off otherwise, so an existing page does not
|
|
195
|
+
* gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own "Saved"
|
|
196
|
+
* toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).
|
|
197
|
+
*/
|
|
198
|
+
undo?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Group the strings under a header per area (0.25, keksdose live #377: *"grouping to the
|
|
201
|
+
* translation review entries for faster reviewing"*): `"namespace"` — the row's
|
|
202
|
+
* `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`
|
|
203
|
+
* with keksdose's `namespaceOf`) — `"source"`, or a function of the row.
|
|
204
|
+
*
|
|
205
|
+
* Each group is a section with a heading ({@link groupHeadingAs}): its name, "n
|
|
206
|
+
* unreviewed / total", and "Approve unreviewed (n)", which approves the group's rows
|
|
207
|
+
* nobody has given a verdict ({@link unreviewedRows}) under the same rules as one
|
|
208
|
+
* approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.
|
|
209
|
+
* A batch larger than `pageSize` asks first, inside the opened group: more than a page
|
|
210
|
+
* is more than the reviewer can have had on screen.
|
|
211
|
+
*
|
|
212
|
+
* WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows
|
|
213
|
+
* first, then the rows are grouped: a header counts what the filters show in it (so
|
|
214
|
+
* "Approve unreviewed" never takes a row the reviewer filtered away), and an area with
|
|
215
|
+
* nothing left is not shown. Groups stand in the order their first string has in the
|
|
216
|
+
* reference; a column sort orders the rows INSIDE every group alike. Each group pages
|
|
217
|
+
* on its own, `pageSize` rows at a time — a header then always stands over its own
|
|
218
|
+
* rows, and paging one area does not move another — rather than one pager cutting
|
|
219
|
+
* through the headers. `storageKey` keeps persisting the ungrouped table only.
|
|
220
|
+
*
|
|
221
|
+
* Groups fold (each on its own, remembered while the panel lives), and a folded group's
|
|
222
|
+
* rows are not rendered at all — only its header. Which groups start open is
|
|
223
|
+
* {@link defaultGroupsOpen}: by default, on a phone they start folded when there are
|
|
224
|
+
* several — the headers are then the area list, and the reviewer opens the one to work
|
|
225
|
+
* through — and on a wide screen they start open while that is cheap (see there).
|
|
226
|
+
*/
|
|
227
|
+
groupBy?: TranslationReviewGroupBy;
|
|
228
|
+
/**
|
|
229
|
+
* Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or
|
|
230
|
+
* folds keeps that; this is what every other group follows.
|
|
231
|
+
*
|
|
232
|
+
* - `"auto"` (default): on a phone, open when there is one group and folded when there
|
|
233
|
+
* are several, as in 0.25.0. On a wide screen, open while opening every group renders
|
|
234
|
+
* at most 100 rows (each group shows up to `pageSize` of them; the budget is one page
|
|
235
|
+
* where `pageSize` is larger) — and folded above that, the headers then being the area
|
|
236
|
+
* list, as on a phone.
|
|
237
|
+
* - `true`: every group starts open, on a phone too.
|
|
238
|
+
* - `false`: every group starts folded, on a wide screen too.
|
|
239
|
+
*
|
|
240
|
+
* WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.
|
|
241
|
+
* keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at
|
|
242
|
+
* once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click
|
|
243
|
+
* to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.
|
|
244
|
+
* kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.
|
|
245
|
+
* What costs is the rows on screen, so the rule counts them rather than the groups:
|
|
246
|
+
* 100 rows is two of the panel's default pages — any one or two areas still open as
|
|
247
|
+
* before, however long, and so do a dozen small ones, or a search whose hits fall in
|
|
248
|
+
* many areas — while a whole catalogue starts as its list of areas: keksdose's shape
|
|
249
|
+
* in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has
|
|
250
|
+
* ~1200). The rule follows the filters: narrow them and the groups open.
|
|
251
|
+
*
|
|
252
|
+
* A boolean rather than a controlled open state: the panel already keeps each group's
|
|
253
|
+
* own state, and an app that only wants "all open" or "all folded" should not have to
|
|
254
|
+
* keep a record of 122 flags to say it.
|
|
255
|
+
*/
|
|
256
|
+
defaultGroupsOpen?: boolean | "auto";
|
|
257
|
+
/** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
|
|
258
|
+
groupLabel?: (group: string) => string;
|
|
259
|
+
/** The level of the group headings. Default `h2`: the panel is a page's body, under
|
|
260
|
+
* the page's `h1`. */
|
|
261
|
+
groupHeadingAs?: "h2" | "h3" | "h4";
|
|
121
262
|
className?: string;
|
|
122
263
|
labels?: Partial<TranslationReviewLabels>;
|
|
123
264
|
}
|
|
124
265
|
|
|
266
|
+
/** The wide screen's query — the same one DataTable switches its layout on, so "a phone"
|
|
267
|
+
* means the same here as in the table. */
|
|
268
|
+
const MD_UP = "(min-width: 768px)";
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* How many rows the groups may render at once on a wide screen before they start folded
|
|
272
|
+
* ({@link TranslationReviewPanelProps.defaultGroupsOpen}): two of the default 50-row pages.
|
|
273
|
+
* A row costs about twenty elements in either layout, so this is the cost of two pages of
|
|
274
|
+
* the ungrouped table — not of 2053 rows in 122 tables.
|
|
275
|
+
*/
|
|
276
|
+
const GROUPS_OPEN_ROW_BUDGET = 100;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Would opening every group stay within the budget? Each group renders up to `pageSize`
|
|
280
|
+
* rows (it pages on its own); the budget is never less than one page, so a single group —
|
|
281
|
+
* which renders at most one — always starts open, as on a phone.
|
|
282
|
+
*/
|
|
283
|
+
function groupsFitOpen(groups: readonly TranslationRowGroup[], pageSize: number): boolean {
|
|
284
|
+
const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);
|
|
285
|
+
let rendered = 0;
|
|
286
|
+
for (const group of groups) {
|
|
287
|
+
rendered += Math.min(group.rows.length, pageSize);
|
|
288
|
+
if (rendered > budget) return false;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The kit's dev switch, as NumberInput's money guard and `lib/logger.ts` read it: a
|
|
295
|
+
* development build — the consumer's bundler makes `import.meta.env.DEV` false in a
|
|
296
|
+
* production one, and the branch is dead code there — and never under vitest, where every
|
|
297
|
+
* consumer suite would print it; a test opts in with `vi.stubEnv("VITEST", "")`. Read per
|
|
298
|
+
* call, and optional-chained: `import.meta.env` is a Vite injection, absent under Node.
|
|
299
|
+
*/
|
|
300
|
+
function devWarnings(): boolean {
|
|
301
|
+
return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type FilterField = keyof TranslationReviewFilter;
|
|
305
|
+
|
|
306
|
+
/** How long after the last filter change the app's `filter` is looked at again: well past
|
|
307
|
+
* a router's transition, so a URL that is still being written is not mistaken for one
|
|
308
|
+
* that never will be. */
|
|
309
|
+
const FILTER_ECHO_MS = 1500;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The development warning for a controlled filter that does not follow (Kurvenschmiede,
|
|
313
|
+
* 0.25.1). A field the app passes in `filter` is the app's: the panel shows the app's
|
|
314
|
+
* value, whatever the reviewer picked. An app that passes a field but does not write the
|
|
315
|
+
* reported value back — its `onFilterChange` hands on only some fields, `filter` is built
|
|
316
|
+
* as `{ ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...fromTheUrl }`, a URL parser rejects what
|
|
317
|
+
* its serialiser wrote, or there is no `onFilterChange` at all — has a control that
|
|
318
|
+
* silently does nothing: the box unticks itself, the select jumps back.
|
|
319
|
+
*
|
|
320
|
+
* So, after the panel reports a change of a field the app controls, it looks at the
|
|
321
|
+
* app's `filter` again once the change has had time to land ({@link FILTER_ECHO_MS}, read
|
|
322
|
+
* from the last COMMITTED props, so a router transition still rendering does not count),
|
|
323
|
+
* and warns — once per field — when that field still holds the value it had before.
|
|
324
|
+
*
|
|
325
|
+
* Quiet by construction for an app that passes no `filter`, and for a field the app LEAVES
|
|
326
|
+
* OUT: that field is the panel's own and works (keksdose keeps the placeholder switch so,
|
|
327
|
+
* on purpose). Quiet too when the app moved the field somewhere else itself. Development
|
|
328
|
+
* builds only (see {@link devWarnings}): in production and under vitest nothing is
|
|
329
|
+
* recorded and no timer is set.
|
|
330
|
+
*/
|
|
331
|
+
function useFilterEchoCheck(filterProp: Partial<TranslationReviewFilter> | undefined) {
|
|
332
|
+
const committed = useRef(filterProp);
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
committed.current = filterProp;
|
|
335
|
+
});
|
|
336
|
+
// Per field: what the app's filter said when the reviewer first changed it, and what
|
|
337
|
+
// the panel reported last.
|
|
338
|
+
const pending = useRef(new Map<FilterField, { from: unknown; to: unknown }>());
|
|
339
|
+
const warned = useRef(new Set<FilterField>());
|
|
340
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
341
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
342
|
+
|
|
343
|
+
return (next: TranslationReviewFilter) => {
|
|
344
|
+
if (filterProp === undefined || !devWarnings()) return;
|
|
345
|
+
for (const field of Object.keys(next) as FilterField[]) {
|
|
346
|
+
const from = filterProp[field];
|
|
347
|
+
if (from === undefined || warned.current.has(field)) continue;
|
|
348
|
+
const seen = pending.current.get(field);
|
|
349
|
+
if (seen) seen.to = next[field];
|
|
350
|
+
else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });
|
|
351
|
+
}
|
|
352
|
+
if (pending.current.size === 0) return;
|
|
353
|
+
clearTimeout(timer.current);
|
|
354
|
+
timer.current = setTimeout(() => {
|
|
355
|
+
const now = committed.current;
|
|
356
|
+
for (const [field, { from, to }] of pending.current) {
|
|
357
|
+
if (to === from || now?.[field] !== from || warned.current.has(field)) continue;
|
|
358
|
+
warned.current.add(field);
|
|
359
|
+
console.warn(
|
|
360
|
+
`[ui-kit] TranslationReviewPanel: the "${field}" filter was changed to ${JSON.stringify(to)}, but the ` +
|
|
361
|
+
`\`filter\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in ` +
|
|
362
|
+
`\`filter\` is the app's: write back every field \`onFilterChange\` reports, in one update ` +
|
|
363
|
+
`(useSearchParamsState's setter takes the object as it is), or leave the field out of \`filter\` and ` +
|
|
364
|
+
`the panel keeps it. (Development builds only; once per field.)`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
pending.current.clear();
|
|
368
|
+
}, FILTER_ECHO_MS);
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
125
372
|
/** The row's text — or, for a missing string, the line that says so. */
|
|
126
373
|
function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {
|
|
127
374
|
return row.text === "" ? (
|
|
@@ -145,7 +392,14 @@ function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: Transla
|
|
|
145
392
|
* Bulk approve takes the selected rows that are not approved yet and have a text:
|
|
146
393
|
* approving an absent string says nothing, and re-approving one says it again. On a
|
|
147
394
|
* phone the table has no checkboxes, so "Select all shown" selects what the filters
|
|
148
|
-
* show — filter to `Unreviewed` in one area, read, select, approve.
|
|
395
|
+
* show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a
|
|
396
|
+
* group's own "Approve unreviewed" is the shorter way, and with `swipe` a card is approved
|
|
397
|
+
* by a swipe.
|
|
398
|
+
*
|
|
399
|
+
* The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on
|
|
400
|
+
* one locale's verdicts can answer out of order, and an Undo must not overtake the
|
|
401
|
+
* approval it takes back. A swipe on the next card while one is out is queued rather than
|
|
402
|
+
* refused — a reviewer swiping down a list is faster than the server.
|
|
149
403
|
*/
|
|
150
404
|
export function TranslationReviewPanel({
|
|
151
405
|
rows: rowsProp,
|
|
@@ -165,12 +419,23 @@ export function TranslationReviewPanel({
|
|
|
165
419
|
storageKey,
|
|
166
420
|
formatDate,
|
|
167
421
|
formatError,
|
|
422
|
+
swipe = false,
|
|
423
|
+
undo: undoProp,
|
|
424
|
+
groupBy,
|
|
425
|
+
groupLabel,
|
|
426
|
+
groupHeadingAs = "h2",
|
|
427
|
+
defaultGroupsOpen = "auto",
|
|
168
428
|
className,
|
|
169
429
|
labels: labelsProp,
|
|
170
430
|
}: TranslationReviewPanelProps) {
|
|
171
431
|
const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
|
|
172
432
|
const locale = useKitLocale();
|
|
433
|
+
const lock = useWriteLock();
|
|
434
|
+
const phone = !useMediaQuery(MD_UP, true);
|
|
435
|
+
const baseId = useId();
|
|
436
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
173
437
|
const editable = !readOnly && onSave !== undefined;
|
|
438
|
+
const undoOn = undoProp ?? (swipe || groupBy !== undefined);
|
|
174
439
|
|
|
175
440
|
const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);
|
|
176
441
|
// The app's fields over the panel's own; a field the app leaves `undefined` is the
|
|
@@ -179,12 +444,20 @@ export function TranslationReviewPanel({
|
|
|
179
444
|
for (const [name, value] of Object.entries(filterProp ?? {})) {
|
|
180
445
|
if (value !== undefined) Object.assign(filter, { [name]: value });
|
|
181
446
|
}
|
|
447
|
+
const echoFilter = useFilterEchoCheck(filterProp);
|
|
182
448
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
449
|
+
// The row whose editor a swipe opened, to be written in: its wording field takes focus.
|
|
450
|
+
const [focusId, setFocusId] = useState<string | null>(null);
|
|
183
451
|
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
const [
|
|
452
|
+
// The writes asked for and not yet answered, in order: the first is the one out. The
|
|
453
|
+
// editor keeps its own, for its own buttons.
|
|
454
|
+
const [queued, setQueued] = useState<readonly string[]>([]);
|
|
455
|
+
const claimed = useRef(new Set<string>());
|
|
456
|
+
const tail = useRef<Promise<unknown>>(Promise.resolve());
|
|
457
|
+
const undoCount = useRef(0);
|
|
187
458
|
const [failure, setFailure] = useState<ReactNode>(null);
|
|
459
|
+
const busy = queued.length > 0;
|
|
460
|
+
const isQueued = (key: string) => queued.includes(key);
|
|
188
461
|
|
|
189
462
|
const rows = useMemo(
|
|
190
463
|
() => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),
|
|
@@ -206,43 +479,166 @@ export function TranslationReviewPanel({
|
|
|
206
479
|
[rows, status, source, namespace, query, placeholdersOnly],
|
|
207
480
|
);
|
|
208
481
|
|
|
482
|
+
// ── Groups ──
|
|
483
|
+
const groups = useMemo(() => {
|
|
484
|
+
if (groupBy === undefined) return null;
|
|
485
|
+
const of =
|
|
486
|
+
groupBy === "namespace"
|
|
487
|
+
? (r: TranslationRow) => r.namespace
|
|
488
|
+
: groupBy === "source"
|
|
489
|
+
? (r: TranslationRow) => r.source
|
|
490
|
+
: groupBy;
|
|
491
|
+
return groupTranslationRows(visible, of);
|
|
492
|
+
}, [visible, groupBy]);
|
|
493
|
+
const groupName = (key: string) =>
|
|
494
|
+
groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : undefined) ?? (key || "—");
|
|
495
|
+
// Only what the reviewer chose; a group nobody has touched follows the default, which
|
|
496
|
+
// follows the filters (see `defaultGroupsOpen`).
|
|
497
|
+
const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});
|
|
498
|
+
const groupOpenByDefault =
|
|
499
|
+
defaultGroupsOpen !== "auto"
|
|
500
|
+
? defaultGroupsOpen
|
|
501
|
+
: groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
|
|
502
|
+
const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;
|
|
503
|
+
const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
|
|
504
|
+
/** A group the reviewer works in — an editor opened in it — stays open when the default
|
|
505
|
+
* turns (a search cleared, the rows grown past the budget): folding it would drop the
|
|
506
|
+
* editor and whatever was typed in it. */
|
|
507
|
+
const keepGroupOpen = (key: string | undefined) => {
|
|
508
|
+
if (key !== undefined) setGroupOpen((prev) => (key in prev ? prev : { ...prev, [key]: true }));
|
|
509
|
+
};
|
|
510
|
+
// The group whose large batch is waiting for a yes — and, until its confirm button
|
|
511
|
+
// has mounted and taken focus, the group that asked.
|
|
512
|
+
const [confirming, setConfirming] = useState<string | null>(null);
|
|
513
|
+
const confirmFocus = useRef<string | null>(null);
|
|
514
|
+
// One sort for every group's table, so a header click orders all of them alike.
|
|
515
|
+
const [groupSorts, setGroupSorts] = useState<SortState[]>([]);
|
|
516
|
+
|
|
209
517
|
const setFilter = (patch: Partial<TranslationReviewFilter>) => {
|
|
210
518
|
const next = { ...filter, ...patch };
|
|
211
519
|
setOwnFilter(next);
|
|
212
520
|
onFilterChange?.(next);
|
|
521
|
+
echoFilter(next);
|
|
213
522
|
// A selection the new filter hides would be acted on unseen.
|
|
214
523
|
setSelected(new Set());
|
|
215
524
|
};
|
|
216
525
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
526
|
+
/** Queue a write under `key` (one per key at a time); resolves whether it worked. */
|
|
527
|
+
const run = (key: string, action: () => MaybePromise): Promise<boolean> => {
|
|
528
|
+
if (claimed.current.has(key)) return Promise.resolve(false);
|
|
529
|
+
claimed.current.add(key);
|
|
530
|
+
setQueued((q) => [...q, key]);
|
|
531
|
+
const step = tail.current.then(async () => {
|
|
532
|
+
setFailure(null);
|
|
533
|
+
try {
|
|
534
|
+
await action();
|
|
535
|
+
return true;
|
|
536
|
+
} catch (caught) {
|
|
537
|
+
setFailure(formatError ? formatError(caught) : labels.failed);
|
|
538
|
+
return false;
|
|
539
|
+
} finally {
|
|
540
|
+
claimed.current.delete(key);
|
|
541
|
+
setQueued((q) => q.filter((k) => k !== key));
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
tail.current = step;
|
|
545
|
+
return step;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/** "String approved — Undo", once `targets` (as they were BEFORE the write) are
|
|
549
|
+
* approved. Undo goes through the same queue as every write. */
|
|
550
|
+
const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {
|
|
551
|
+
const back = reviewUndo(targets);
|
|
552
|
+
const message = labels.approvedToast(targets.length);
|
|
553
|
+
const save = onSave;
|
|
554
|
+
const clear = onClear;
|
|
555
|
+
// Taking back the approval of a row that had no verdict is a clear: without
|
|
556
|
+
// `onClear` there is no way to say it, so the toast reports and offers nothing.
|
|
557
|
+
if (!save || (back.clears.length > 0 && !clear)) {
|
|
558
|
+
toast.success(message, { description });
|
|
559
|
+
return;
|
|
228
560
|
}
|
|
561
|
+
const key = `undo:${++undoCount.current}`;
|
|
562
|
+
toast.undo(message, {
|
|
563
|
+
description,
|
|
564
|
+
onUndo: () =>
|
|
565
|
+
void run(key, async () => {
|
|
566
|
+
if (back.writes.length > 0) await save(back.writes, { origin: "undo", toasted: false });
|
|
567
|
+
if (back.clears.length > 0) await clear?.(back.clears, { origin: "undo", toasted: false });
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const approve = async (
|
|
573
|
+
key: string,
|
|
574
|
+
targets: readonly TranslationRow[],
|
|
575
|
+
origin: TranslationReviewOrigin,
|
|
576
|
+
description?: ReactNode,
|
|
577
|
+
): Promise<boolean> => {
|
|
578
|
+
if (!onSave || targets.length === 0) return false;
|
|
579
|
+
const save = onSave;
|
|
580
|
+
const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
|
|
581
|
+
if (ok && undoOn) sayApproved(targets, description);
|
|
582
|
+
return ok;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/** The editor's own save: it waits, closes and shows its errors itself, so it does
|
|
586
|
+
* not go through the queue — but an approval there is answered like any other. */
|
|
587
|
+
const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {
|
|
588
|
+
const toasted = undoOn && write.verdict === "APPROVED";
|
|
589
|
+
await onSave?.([write], { origin: "editor", toasted });
|
|
590
|
+
if (toasted) sayApproved([row], row.key);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Focus back into the panel once a group's batch has settled, if it fell out: the
|
|
595
|
+
* button that was pressed goes with the rows it approved (and under the `Unreviewed`
|
|
596
|
+
* filter the whole group can go). Two frames, so the app's new rows have been drawn.
|
|
597
|
+
*/
|
|
598
|
+
const keepFocusAt = (groupKey: string) => {
|
|
599
|
+
const check = () => {
|
|
600
|
+
const root = rootRef.current;
|
|
601
|
+
const active = document.activeElement;
|
|
602
|
+
if (!root || (active && active !== document.body && active.isConnected)) return;
|
|
603
|
+
const sections = [...root.querySelectorAll<HTMLElement>("[data-review-group]")];
|
|
604
|
+
const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];
|
|
605
|
+
section?.querySelector<HTMLElement>("[data-disclosure-trigger]")?.focus();
|
|
606
|
+
};
|
|
607
|
+
requestAnimationFrame(() => requestAnimationFrame(check));
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {
|
|
611
|
+
const targets = unreviewedRows(group.rows);
|
|
612
|
+
if (targets.length === 0) return;
|
|
613
|
+
if (!confirmed && targets.length > pageSize) {
|
|
614
|
+
// Asked inside the opened group, over the rows it is about.
|
|
615
|
+
setGroupOpenFor(group.key, true);
|
|
616
|
+
setConfirming(group.key);
|
|
617
|
+
confirmFocus.current = group.key;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const ok = await approve(`group:${group.key}`, targets, "group", groupName(group.key));
|
|
621
|
+
if (ok) setConfirming((key) => (key === group.key ? null : key));
|
|
622
|
+
keepFocusAt(group.key);
|
|
229
623
|
};
|
|
230
624
|
|
|
231
625
|
const visibleIds = new Set(visible.map((r) => r.id));
|
|
232
626
|
const selectedRows = visible.filter((r) => selected.has(r.id));
|
|
233
|
-
|
|
627
|
+
// What "Select all shown" takes: grouped, the rows of the open groups — a folded
|
|
628
|
+
// group's rows are not shown.
|
|
629
|
+
const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;
|
|
630
|
+
const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));
|
|
234
631
|
const toApprove = selectedRows.filter((r) => r.text !== "" && r.status !== "approved");
|
|
235
632
|
const toReset = selectedRows.filter((r) => r.review !== null);
|
|
236
633
|
|
|
237
634
|
const approveSelected = async () => {
|
|
238
|
-
if (
|
|
239
|
-
if (await run("bulk-approve", () => onSave(toApprove.map((r) => reviewWrite(r, "APPROVED"))))) {
|
|
240
|
-
setSelected(new Set());
|
|
241
|
-
}
|
|
635
|
+
if (await approve("bulk-approve", toApprove, "selection")) setSelected(new Set());
|
|
242
636
|
};
|
|
243
637
|
const resetSelected = async () => {
|
|
244
638
|
if (!onClear || toReset.length === 0) return;
|
|
245
|
-
|
|
639
|
+
const clear = onClear;
|
|
640
|
+
const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));
|
|
641
|
+
if (await run("bulk-reset", () => clear(keys, { origin: "selection", toasted: false }))) {
|
|
246
642
|
setSelected(new Set());
|
|
247
643
|
}
|
|
248
644
|
};
|
|
@@ -291,8 +687,7 @@ export function TranslationReviewPanel({
|
|
|
291
687
|
sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),
|
|
292
688
|
},
|
|
293
689
|
];
|
|
294
|
-
if (editable
|
|
295
|
-
const save = onSave;
|
|
690
|
+
if (editable) {
|
|
296
691
|
columns.push({
|
|
297
692
|
key: "approve",
|
|
298
693
|
header: <span className="sr-only">{labels.approve}</span>,
|
|
@@ -304,12 +699,12 @@ export function TranslationReviewPanel({
|
|
|
304
699
|
tone="muted"
|
|
305
700
|
commit
|
|
306
701
|
label={labels.approve}
|
|
307
|
-
pending={
|
|
308
|
-
disabled={busy
|
|
702
|
+
pending={isQueued(`row:${r.id}`)}
|
|
703
|
+
disabled={busy}
|
|
309
704
|
onClick={(event) => {
|
|
310
705
|
// The row's own click opens the editor; this one only approves.
|
|
311
706
|
event.stopPropagation();
|
|
312
|
-
void
|
|
707
|
+
void approve(`row:${r.id}`, [r], "row", r.key);
|
|
313
708
|
}}
|
|
314
709
|
>
|
|
315
710
|
<Check />
|
|
@@ -318,8 +713,223 @@ export function TranslationReviewPanel({
|
|
|
318
713
|
});
|
|
319
714
|
}
|
|
320
715
|
|
|
716
|
+
/**
|
|
717
|
+
* A phone card's swipes: Approve toward the end, "Needs a change" toward the start.
|
|
718
|
+
* None where the row must not be acted on — the same conditions under which its
|
|
719
|
+
* buttons are locked or absent.
|
|
720
|
+
*/
|
|
721
|
+
const swipeActions = (r: TranslationRow, groupKey?: string): MobileSwipeActions | null => {
|
|
722
|
+
if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
|
|
723
|
+
const missing = r.text === "";
|
|
724
|
+
return {
|
|
725
|
+
end:
|
|
726
|
+
missing || r.status === "approved"
|
|
727
|
+
? []
|
|
728
|
+
: [
|
|
729
|
+
{
|
|
730
|
+
label: labels.approve,
|
|
731
|
+
icon: <Check className="size-4" aria-hidden />,
|
|
732
|
+
onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
|
|
733
|
+
className: "bg-[var(--success)]",
|
|
734
|
+
armedClassName: "bg-[var(--success)]",
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
start: [
|
|
738
|
+
{
|
|
739
|
+
label: missing ? labels.suggest : labels.flag,
|
|
740
|
+
icon: <PencilLine className="size-4" aria-hidden />,
|
|
741
|
+
// Opens the editor rather than sending the string back: "needs a change"
|
|
742
|
+
// with neither a note nor a better wording tells nobody anything.
|
|
743
|
+
onCommit: () => {
|
|
744
|
+
keepGroupOpen(groupKey);
|
|
745
|
+
setOpenId(r.id);
|
|
746
|
+
setFocusId(r.id);
|
|
747
|
+
},
|
|
748
|
+
className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
|
|
749
|
+
armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]",
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {
|
|
756
|
+
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
|
|
757
|
+
for (const r of many) {
|
|
758
|
+
if (checked) next.add(r.id);
|
|
759
|
+
else next.delete(r.id);
|
|
760
|
+
}
|
|
761
|
+
return next;
|
|
762
|
+
};
|
|
763
|
+
/** The checkbox column of a table over `list` — the whole list, or one group. */
|
|
764
|
+
const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>["selection"] => {
|
|
765
|
+
if (!editable) return undefined;
|
|
766
|
+
const picked = list.filter((r) => selected.has(r.id)).length;
|
|
767
|
+
return {
|
|
768
|
+
isSelected: (r) => selected.has(r.id),
|
|
769
|
+
onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),
|
|
770
|
+
onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),
|
|
771
|
+
allSelected: list.length > 0 && picked === list.length,
|
|
772
|
+
someSelected: picked > 0 && picked < list.length,
|
|
773
|
+
onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),
|
|
774
|
+
};
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const table = (list: TranslationRow[], group?: { key: string; name: string }) => (
|
|
778
|
+
<DataTable<TranslationRow>
|
|
779
|
+
rows={list}
|
|
780
|
+
rowKey={(r) => r.id}
|
|
781
|
+
columns={columns}
|
|
782
|
+
empty={labels.empty}
|
|
783
|
+
defaultPageSize={pageSize}
|
|
784
|
+
{...(group
|
|
785
|
+
? {
|
|
786
|
+
// Inside the group's own card: no frame in a frame, no column rail per group,
|
|
787
|
+
// no scroller per group, and the group's name as the table's.
|
|
788
|
+
frame: false,
|
|
789
|
+
chrome: "minimal" as const,
|
|
790
|
+
maxBodyHeight: "none",
|
|
791
|
+
sorts: groupSorts,
|
|
792
|
+
onSortsChange: setGroupSorts,
|
|
793
|
+
labels: { table: group.name },
|
|
794
|
+
}
|
|
795
|
+
: { storageKey })}
|
|
796
|
+
isExpanded={(r) => r.id === openId}
|
|
797
|
+
onRowClick={(r) => {
|
|
798
|
+
if (openId !== r.id) keepGroupOpen(group?.key);
|
|
799
|
+
setFocusId(null);
|
|
800
|
+
setOpenId((open) => (open === r.id ? null : r.id));
|
|
801
|
+
}}
|
|
802
|
+
expandedRow={(r) => (
|
|
803
|
+
<TranslationReviewEditor
|
|
804
|
+
key={r.id}
|
|
805
|
+
row={r}
|
|
806
|
+
referenceLabel={referenceLabel}
|
|
807
|
+
localeLabel={localeLabel}
|
|
808
|
+
readOnly={!editable}
|
|
809
|
+
focusWording={focusId === r.id}
|
|
810
|
+
onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}
|
|
811
|
+
onClear={onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : undefined}
|
|
812
|
+
onClose={() => {
|
|
813
|
+
setOpenId(null);
|
|
814
|
+
setFocusId(null);
|
|
815
|
+
}}
|
|
816
|
+
formatDate={formatDate}
|
|
817
|
+
formatError={formatError}
|
|
818
|
+
labels={labelsProp}
|
|
819
|
+
/>
|
|
820
|
+
)}
|
|
821
|
+
mobileCard={(r) => (
|
|
822
|
+
<div className="min-w-0 space-y-1">
|
|
823
|
+
<code className="block break-all text-xs text-[var(--text-muted)]">{r.key}</code>
|
|
824
|
+
<div className="text-sm text-[var(--text-primary)]">
|
|
825
|
+
<TextCell row={r} labels={labels} clamp />
|
|
826
|
+
</div>
|
|
827
|
+
<div className="flex flex-wrap gap-1">
|
|
828
|
+
<ReviewStatusChip status={r.status} labels={labelsProp} />
|
|
829
|
+
{r.placeholderMismatch && (
|
|
830
|
+
<Chip size="sm" tone="danger" variant="outline">
|
|
831
|
+
{labels.placeholderChip}
|
|
832
|
+
</Chip>
|
|
833
|
+
)}
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
mobileSwipeActions={swipe ? (r) => swipeActions(r, group?.key) : undefined}
|
|
838
|
+
selection={selectionFor(list)}
|
|
839
|
+
/>
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const groupSection = (g: TranslationRowGroup, index: number) => {
|
|
843
|
+
const name = groupName(g.key);
|
|
844
|
+
const titleId = `${baseId}-group-${index}`;
|
|
845
|
+
const questionId = `${baseId}-confirm-${index}`;
|
|
846
|
+
const targets = editable ? unreviewedRows(g.rows) : [];
|
|
847
|
+
const key = `group:${g.key}`;
|
|
848
|
+
const open = isGroupOpen(g.key);
|
|
849
|
+
// A folded group renders its header and nothing else. Its body is handed over only
|
|
850
|
+
// while it is open, or when the REVIEWER folded it — so the fold animates over the
|
|
851
|
+
// rows it hides. A group folded by the default gets none: when the default turns (a
|
|
852
|
+
// search cleared over 122 areas), the groups that close would otherwise render their
|
|
853
|
+
// grown tables for the length of the fold.
|
|
854
|
+
const body = open || groupOpen[g.key] === false;
|
|
855
|
+
return (
|
|
856
|
+
<Disclosure
|
|
857
|
+
key={g.key}
|
|
858
|
+
data-review-group={g.key}
|
|
859
|
+
headingAs={groupHeadingAs}
|
|
860
|
+
title={<span id={titleId}>{name}</span>}
|
|
861
|
+
hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}
|
|
862
|
+
open={open}
|
|
863
|
+
onOpenChange={(next) => {
|
|
864
|
+
setGroupOpenFor(g.key, next);
|
|
865
|
+
if (!next) setConfirming((c) => (c === g.key ? null : c));
|
|
866
|
+
}}
|
|
867
|
+
trailing={
|
|
868
|
+
targets.length > 0 ? (
|
|
869
|
+
<Button
|
|
870
|
+
variant="secondary"
|
|
871
|
+
size="sm"
|
|
872
|
+
commit
|
|
873
|
+
// The visible words say what; the group's name, read after them, says where.
|
|
874
|
+
aria-describedby={titleId}
|
|
875
|
+
pending={isQueued(key) && confirming !== g.key}
|
|
876
|
+
disabled={busy}
|
|
877
|
+
onClick={() => void approveGroup(g, false)}
|
|
878
|
+
>
|
|
879
|
+
<Check className="size-4" aria-hidden />
|
|
880
|
+
{labels.approveGroup(targets.length)}
|
|
881
|
+
</Button>
|
|
882
|
+
) : undefined
|
|
883
|
+
}
|
|
884
|
+
bodyClassName="space-y-0 px-0 pb-0 border-t border-[var(--border)]"
|
|
885
|
+
>
|
|
886
|
+
{body && confirming === g.key && targets.length > 0 && (
|
|
887
|
+
<div className="border-b border-[var(--border)] p-3">
|
|
888
|
+
<AlertBanner tone="warning" size="sm">
|
|
889
|
+
<div className="min-w-0 space-y-2">
|
|
890
|
+
<p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>
|
|
891
|
+
<div className="flex flex-wrap gap-2">
|
|
892
|
+
<Button
|
|
893
|
+
// Focus lands here when the question appears: the expected answer to a
|
|
894
|
+
// question the reviewer just asked for, and Undo stays behind it.
|
|
895
|
+
ref={(el) => {
|
|
896
|
+
if (el && confirmFocus.current === g.key) {
|
|
897
|
+
confirmFocus.current = null;
|
|
898
|
+
el.focus();
|
|
899
|
+
}
|
|
900
|
+
}}
|
|
901
|
+
variant="primary"
|
|
902
|
+
size="sm"
|
|
903
|
+
commit
|
|
904
|
+
aria-describedby={questionId}
|
|
905
|
+
pending={isQueued(key)}
|
|
906
|
+
disabled={busy}
|
|
907
|
+
onClick={() => void approveGroup(g, true)}
|
|
908
|
+
>
|
|
909
|
+
{labels.approveGroup(targets.length)}
|
|
910
|
+
</Button>
|
|
911
|
+
<Button
|
|
912
|
+
variant="ghost"
|
|
913
|
+
size="sm"
|
|
914
|
+
disabled={isQueued(key)}
|
|
915
|
+
onClick={() => {
|
|
916
|
+
setConfirming(null);
|
|
917
|
+
keepFocusAt(g.key);
|
|
918
|
+
}}
|
|
919
|
+
>
|
|
920
|
+
{labels.cancel}
|
|
921
|
+
</Button>
|
|
922
|
+
</div>
|
|
923
|
+
</div>
|
|
924
|
+
</AlertBanner>
|
|
925
|
+
</div>
|
|
926
|
+
)}
|
|
927
|
+
{body && table(g.rows, { key: g.key, name })}
|
|
928
|
+
</Disclosure>
|
|
929
|
+
);
|
|
930
|
+
};
|
|
321
931
|
return (
|
|
322
|
-
<div className={cn("min-w-0 space-y-4", className)}>
|
|
932
|
+
<div ref={rootRef} className={cn("min-w-0 space-y-4", className)}>
|
|
323
933
|
{scopeText && (
|
|
324
934
|
<AlertBanner tone="info" variant="inline" size="sm" block live={false}>
|
|
325
935
|
{labels.scope(scopeText)}
|
|
@@ -381,14 +991,14 @@ export function TranslationReviewPanel({
|
|
|
381
991
|
/>
|
|
382
992
|
</div>
|
|
383
993
|
|
|
384
|
-
{editable &&
|
|
994
|
+
{editable && shownRows.length > 0 && (
|
|
385
995
|
// The phone's table has no checkboxes; this is its way into the bulk actions.
|
|
386
996
|
<Button
|
|
387
997
|
variant="ghost"
|
|
388
998
|
size="sm"
|
|
389
999
|
className="md:hidden"
|
|
390
|
-
disabled={
|
|
391
|
-
onClick={() => setSelected(
|
|
1000
|
+
disabled={allShownSelected}
|
|
1001
|
+
onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}
|
|
392
1002
|
>
|
|
393
1003
|
<ListChecks className="size-4" aria-hidden />
|
|
394
1004
|
{labels.selectShown}
|
|
@@ -407,8 +1017,8 @@ export function TranslationReviewPanel({
|
|
|
407
1017
|
variant="primary"
|
|
408
1018
|
size="sm"
|
|
409
1019
|
commit
|
|
410
|
-
pending={
|
|
411
|
-
disabled={busy
|
|
1020
|
+
pending={isQueued("bulk-approve")}
|
|
1021
|
+
disabled={busy || toApprove.length === 0}
|
|
412
1022
|
onClick={() => void approveSelected()}
|
|
413
1023
|
>
|
|
414
1024
|
<Check className="size-4" aria-hidden />
|
|
@@ -419,8 +1029,8 @@ export function TranslationReviewPanel({
|
|
|
419
1029
|
variant="ghost"
|
|
420
1030
|
size="sm"
|
|
421
1031
|
commit
|
|
422
|
-
pending={
|
|
423
|
-
disabled={busy
|
|
1032
|
+
pending={isQueued("bulk-reset")}
|
|
1033
|
+
disabled={busy || toReset.length === 0}
|
|
424
1034
|
onClick={() => void resetSelected()}
|
|
425
1035
|
>
|
|
426
1036
|
<RotateCcw className="size-4" aria-hidden />
|
|
@@ -430,73 +1040,11 @@ export function TranslationReviewPanel({
|
|
|
430
1040
|
</BulkActionBar>
|
|
431
1041
|
)}
|
|
432
1042
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
defaultPageSize={pageSize}
|
|
439
|
-
storageKey={storageKey}
|
|
440
|
-
isExpanded={(r) => r.id === openId}
|
|
441
|
-
onRowClick={(r) => setOpenId((open) => (open === r.id ? null : r.id))}
|
|
442
|
-
expandedRow={(r) => (
|
|
443
|
-
<TranslationReviewEditor
|
|
444
|
-
key={r.id}
|
|
445
|
-
row={r}
|
|
446
|
-
referenceLabel={referenceLabel}
|
|
447
|
-
localeLabel={localeLabel}
|
|
448
|
-
readOnly={!editable}
|
|
449
|
-
onSave={onSave ? (write) => onSave([write]) : undefined}
|
|
450
|
-
onClear={onClear ? (key) => onClear([key]) : undefined}
|
|
451
|
-
onClose={() => setOpenId(null)}
|
|
452
|
-
formatDate={formatDate}
|
|
453
|
-
formatError={formatError}
|
|
454
|
-
labels={labelsProp}
|
|
455
|
-
/>
|
|
456
|
-
)}
|
|
457
|
-
mobileCard={(r) => (
|
|
458
|
-
<div className="min-w-0 space-y-1">
|
|
459
|
-
<code className="block break-all text-xs text-[var(--text-muted)]">{r.key}</code>
|
|
460
|
-
<div className="text-sm text-[var(--text-primary)]">
|
|
461
|
-
<TextCell row={r} labels={labels} clamp />
|
|
462
|
-
</div>
|
|
463
|
-
<div className="flex flex-wrap gap-1">
|
|
464
|
-
<ReviewStatusChip status={r.status} labels={labelsProp} />
|
|
465
|
-
{r.placeholderMismatch && (
|
|
466
|
-
<Chip size="sm" tone="danger" variant="outline">
|
|
467
|
-
{labels.placeholderChip}
|
|
468
|
-
</Chip>
|
|
469
|
-
)}
|
|
470
|
-
</div>
|
|
471
|
-
</div>
|
|
472
|
-
)}
|
|
473
|
-
selection={
|
|
474
|
-
editable
|
|
475
|
-
? {
|
|
476
|
-
isSelected: (r) => selected.has(r.id),
|
|
477
|
-
onToggle: (r, checked) =>
|
|
478
|
-
setSelected((prev) => {
|
|
479
|
-
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
|
|
480
|
-
if (checked) next.add(r.id);
|
|
481
|
-
else next.delete(r.id);
|
|
482
|
-
return next;
|
|
483
|
-
}),
|
|
484
|
-
onToggleMany: (many, checked) =>
|
|
485
|
-
setSelected((prev) => {
|
|
486
|
-
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
|
|
487
|
-
for (const r of many) {
|
|
488
|
-
if (checked) next.add(r.id);
|
|
489
|
-
else next.delete(r.id);
|
|
490
|
-
}
|
|
491
|
-
return next;
|
|
492
|
-
}),
|
|
493
|
-
allSelected,
|
|
494
|
-
someSelected: selectedRows.length > 0 && !allSelected,
|
|
495
|
-
onToggleAll: (checked) => setSelected(checked ? new Set(visible.map((r) => r.id)) : new Set()),
|
|
496
|
-
}
|
|
497
|
-
: undefined
|
|
498
|
-
}
|
|
499
|
-
/>
|
|
1043
|
+
{groups && groups.length > 0 ? (
|
|
1044
|
+
<div className="space-y-3">{groups.map(groupSection)}</div>
|
|
1045
|
+
) : (
|
|
1046
|
+
table(visible)
|
|
1047
|
+
)}
|
|
500
1048
|
</div>
|
|
501
1049
|
);
|
|
502
1050
|
}
|