@eifi1/ui-kit 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +109 -8
- package/dist/components/translation-review.js +315 -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 +100 -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 +2 -2
- 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 +497 -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 +268 -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 { 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,13 @@ 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`.
|
|
108
145
|
*/
|
|
109
146
|
filter?: Partial<TranslationReviewFilter>;
|
|
147
|
+
/** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
|
|
148
|
+
* setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).
|
|
149
|
+
* Single `useSearchParamState` setters compose since 0.25; before, four in a row
|
|
150
|
+
* clobbered each other (keksdose live #378). */
|
|
110
151
|
onFilterChange?: (filter: TranslationReviewFilter) => void;
|
|
111
152
|
/** The progress bar over the filters. Default true. */
|
|
112
153
|
progress?: boolean;
|
|
@@ -118,10 +159,78 @@ export interface TranslationReviewPanelProps {
|
|
|
118
159
|
formatDate?: (iso: string) => string;
|
|
119
160
|
/** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */
|
|
120
161
|
formatError?: (error: unknown) => ReactNode;
|
|
162
|
+
/**
|
|
163
|
+
* Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward
|
|
164
|
+
* the reading END to approve, toward the START for "Needs a change", which OPENS the
|
|
165
|
+
* editor with the cursor in the wording rather than sending the string back blind — a
|
|
166
|
+
* verdict without a note or a better wording tells whoever fixes it nothing. In a
|
|
167
|
+
* right-to-left page the two sides mirror with the reading direction.
|
|
168
|
+
*
|
|
169
|
+
* DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same
|
|
170
|
+
* gesture, the same feedback, and the same keyboard path — every swipe is also a real
|
|
171
|
+
* button, read out and reachable by Tab, so the gesture is a shortcut and never the only
|
|
172
|
+
* way. A row has no swipe while the editor under it is open, while its own write is out,
|
|
173
|
+
* under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward "Approve"
|
|
174
|
+
* where there is nothing to approve (a missing string, an approved one). A reviewer
|
|
175
|
+
* limited to `areas` never sees a row outside them, so never swipes one.
|
|
176
|
+
*
|
|
177
|
+
* Off by default, so no existing page gains a gesture on the bump. Phones only; the
|
|
178
|
+
* desktop table keeps its approve button.
|
|
179
|
+
*/
|
|
180
|
+
swipe?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a
|
|
183
|
+
* swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores
|
|
184
|
+
* again what the rows said before ({@link reviewUndo}) — or clears them, for rows that
|
|
185
|
+
* had no verdict, which needs `onClear` (without it the toast reports and offers
|
|
186
|
+
* nothing). A batch has ONE Undo.
|
|
187
|
+
*
|
|
188
|
+
* Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many
|
|
189
|
+
* rows at once bring their way back — and off otherwise, so an existing page does not
|
|
190
|
+
* gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own "Saved"
|
|
191
|
+
* toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).
|
|
192
|
+
*/
|
|
193
|
+
undo?: boolean;
|
|
194
|
+
/**
|
|
195
|
+
* Group the strings under a header per area (0.25, keksdose live #377: *"grouping to the
|
|
196
|
+
* translation review entries for faster reviewing"*): `"namespace"` — the row's
|
|
197
|
+
* `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`
|
|
198
|
+
* with keksdose's `namespaceOf`) — `"source"`, or a function of the row.
|
|
199
|
+
*
|
|
200
|
+
* Each group is a section with a heading ({@link groupHeadingAs}): its name, "n
|
|
201
|
+
* unreviewed / total", and "Approve unreviewed (n)", which approves the group's rows
|
|
202
|
+
* nobody has given a verdict ({@link unreviewedRows}) under the same rules as one
|
|
203
|
+
* approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.
|
|
204
|
+
* A batch larger than `pageSize` asks first, inside the opened group: more than a page
|
|
205
|
+
* is more than the reviewer can have had on screen.
|
|
206
|
+
*
|
|
207
|
+
* WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows
|
|
208
|
+
* first, then the rows are grouped: a header counts what the filters show in it (so
|
|
209
|
+
* "Approve unreviewed" never takes a row the reviewer filtered away), and an area with
|
|
210
|
+
* nothing left is not shown. Groups stand in the order their first string has in the
|
|
211
|
+
* reference; a column sort orders the rows INSIDE every group alike. Each group pages
|
|
212
|
+
* on its own, `pageSize` rows at a time — a header then always stands over its own
|
|
213
|
+
* rows, and paging one area does not move another — rather than one pager cutting
|
|
214
|
+
* through the headers. `storageKey` keeps persisting the ungrouped table only.
|
|
215
|
+
*
|
|
216
|
+
* Groups fold (each on its own, remembered while the panel lives). On a phone they
|
|
217
|
+
* start folded when there are several — the headers are then the area list, and the
|
|
218
|
+
* reviewer opens the one to work through; on a wide screen they start open.
|
|
219
|
+
*/
|
|
220
|
+
groupBy?: TranslationReviewGroupBy;
|
|
221
|
+
/** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
|
|
222
|
+
groupLabel?: (group: string) => string;
|
|
223
|
+
/** The level of the group headings. Default `h2`: the panel is a page's body, under
|
|
224
|
+
* the page's `h1`. */
|
|
225
|
+
groupHeadingAs?: "h2" | "h3" | "h4";
|
|
121
226
|
className?: string;
|
|
122
227
|
labels?: Partial<TranslationReviewLabels>;
|
|
123
228
|
}
|
|
124
229
|
|
|
230
|
+
/** The wide screen's query — the same one DataTable switches its layout on, so "a phone"
|
|
231
|
+
* means the same here as in the table. */
|
|
232
|
+
const MD_UP = "(min-width: 768px)";
|
|
233
|
+
|
|
125
234
|
/** The row's text — or, for a missing string, the line that says so. */
|
|
126
235
|
function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {
|
|
127
236
|
return row.text === "" ? (
|
|
@@ -145,7 +254,14 @@ function TextCell({ row, labels, clamp }: { row: TranslationRow; labels: Transla
|
|
|
145
254
|
* Bulk approve takes the selected rows that are not approved yet and have a text:
|
|
146
255
|
* approving an absent string says nothing, and re-approving one says it again. On a
|
|
147
256
|
* 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.
|
|
257
|
+
* show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a
|
|
258
|
+
* group's own "Approve unreviewed" is the shorter way, and with `swipe` a card is approved
|
|
259
|
+
* by a swipe.
|
|
260
|
+
*
|
|
261
|
+
* The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on
|
|
262
|
+
* one locale's verdicts can answer out of order, and an Undo must not overtake the
|
|
263
|
+
* approval it takes back. A swipe on the next card while one is out is queued rather than
|
|
264
|
+
* refused — a reviewer swiping down a list is faster than the server.
|
|
149
265
|
*/
|
|
150
266
|
export function TranslationReviewPanel({
|
|
151
267
|
rows: rowsProp,
|
|
@@ -165,12 +281,22 @@ export function TranslationReviewPanel({
|
|
|
165
281
|
storageKey,
|
|
166
282
|
formatDate,
|
|
167
283
|
formatError,
|
|
284
|
+
swipe = false,
|
|
285
|
+
undo: undoProp,
|
|
286
|
+
groupBy,
|
|
287
|
+
groupLabel,
|
|
288
|
+
groupHeadingAs = "h2",
|
|
168
289
|
className,
|
|
169
290
|
labels: labelsProp,
|
|
170
291
|
}: TranslationReviewPanelProps) {
|
|
171
292
|
const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
|
|
172
293
|
const locale = useKitLocale();
|
|
294
|
+
const lock = useWriteLock();
|
|
295
|
+
const phone = !useMediaQuery(MD_UP, true);
|
|
296
|
+
const baseId = useId();
|
|
297
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
173
298
|
const editable = !readOnly && onSave !== undefined;
|
|
299
|
+
const undoOn = undoProp ?? (swipe || groupBy !== undefined);
|
|
174
300
|
|
|
175
301
|
const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);
|
|
176
302
|
// The app's fields over the panel's own; a field the app leaves `undefined` is the
|
|
@@ -180,11 +306,18 @@ export function TranslationReviewPanel({
|
|
|
180
306
|
if (value !== undefined) Object.assign(filter, { [name]: value });
|
|
181
307
|
}
|
|
182
308
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
309
|
+
// The row whose editor a swipe opened, to be written in: its wording field takes focus.
|
|
310
|
+
const [focusId, setFocusId] = useState<string | null>(null);
|
|
183
311
|
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
const [
|
|
312
|
+
// The writes asked for and not yet answered, in order: the first is the one out. The
|
|
313
|
+
// editor keeps its own, for its own buttons.
|
|
314
|
+
const [queued, setQueued] = useState<readonly string[]>([]);
|
|
315
|
+
const claimed = useRef(new Set<string>());
|
|
316
|
+
const tail = useRef<Promise<unknown>>(Promise.resolve());
|
|
317
|
+
const undoCount = useRef(0);
|
|
187
318
|
const [failure, setFailure] = useState<ReactNode>(null);
|
|
319
|
+
const busy = queued.length > 0;
|
|
320
|
+
const isQueued = (key: string) => queued.includes(key);
|
|
188
321
|
|
|
189
322
|
const rows = useMemo(
|
|
190
323
|
() => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),
|
|
@@ -206,6 +339,31 @@ export function TranslationReviewPanel({
|
|
|
206
339
|
[rows, status, source, namespace, query, placeholdersOnly],
|
|
207
340
|
);
|
|
208
341
|
|
|
342
|
+
// ── Groups ──
|
|
343
|
+
const groups = useMemo(() => {
|
|
344
|
+
if (groupBy === undefined) return null;
|
|
345
|
+
const of =
|
|
346
|
+
groupBy === "namespace"
|
|
347
|
+
? (r: TranslationRow) => r.namespace
|
|
348
|
+
: groupBy === "source"
|
|
349
|
+
? (r: TranslationRow) => r.source
|
|
350
|
+
: groupBy;
|
|
351
|
+
return groupTranslationRows(visible, of);
|
|
352
|
+
}, [visible, groupBy]);
|
|
353
|
+
const groupName = (key: string) =>
|
|
354
|
+
groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : undefined) ?? (key || "—");
|
|
355
|
+
// Only what the reviewer chose; a group nobody has touched follows the default.
|
|
356
|
+
const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});
|
|
357
|
+
const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
|
|
358
|
+
const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;
|
|
359
|
+
const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
|
|
360
|
+
// The group whose large batch is waiting for a yes — and, until its confirm button
|
|
361
|
+
// has mounted and taken focus, the group that asked.
|
|
362
|
+
const [confirming, setConfirming] = useState<string | null>(null);
|
|
363
|
+
const confirmFocus = useRef<string | null>(null);
|
|
364
|
+
// One sort for every group's table, so a header click orders all of them alike.
|
|
365
|
+
const [groupSorts, setGroupSorts] = useState<SortState[]>([]);
|
|
366
|
+
|
|
209
367
|
const setFilter = (patch: Partial<TranslationReviewFilter>) => {
|
|
210
368
|
const next = { ...filter, ...patch };
|
|
211
369
|
setOwnFilter(next);
|
|
@@ -214,35 +372,122 @@ export function TranslationReviewPanel({
|
|
|
214
372
|
setSelected(new Set());
|
|
215
373
|
};
|
|
216
374
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
375
|
+
/** Queue a write under `key` (one per key at a time); resolves whether it worked. */
|
|
376
|
+
const run = (key: string, action: () => MaybePromise): Promise<boolean> => {
|
|
377
|
+
if (claimed.current.has(key)) return Promise.resolve(false);
|
|
378
|
+
claimed.current.add(key);
|
|
379
|
+
setQueued((q) => [...q, key]);
|
|
380
|
+
const step = tail.current.then(async () => {
|
|
381
|
+
setFailure(null);
|
|
382
|
+
try {
|
|
383
|
+
await action();
|
|
384
|
+
return true;
|
|
385
|
+
} catch (caught) {
|
|
386
|
+
setFailure(formatError ? formatError(caught) : labels.failed);
|
|
387
|
+
return false;
|
|
388
|
+
} finally {
|
|
389
|
+
claimed.current.delete(key);
|
|
390
|
+
setQueued((q) => q.filter((k) => k !== key));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
tail.current = step;
|
|
394
|
+
return step;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** "String approved — Undo", once `targets` (as they were BEFORE the write) are
|
|
398
|
+
* approved. Undo goes through the same queue as every write. */
|
|
399
|
+
const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {
|
|
400
|
+
const back = reviewUndo(targets);
|
|
401
|
+
const message = labels.approvedToast(targets.length);
|
|
402
|
+
const save = onSave;
|
|
403
|
+
const clear = onClear;
|
|
404
|
+
// Taking back the approval of a row that had no verdict is a clear: without
|
|
405
|
+
// `onClear` there is no way to say it, so the toast reports and offers nothing.
|
|
406
|
+
if (!save || (back.clears.length > 0 && !clear)) {
|
|
407
|
+
toast.success(message, { description });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const key = `undo:${++undoCount.current}`;
|
|
411
|
+
toast.undo(message, {
|
|
412
|
+
description,
|
|
413
|
+
onUndo: () =>
|
|
414
|
+
void run(key, async () => {
|
|
415
|
+
if (back.writes.length > 0) await save(back.writes, { origin: "undo", toasted: false });
|
|
416
|
+
if (back.clears.length > 0) await clear?.(back.clears, { origin: "undo", toasted: false });
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const approve = async (
|
|
422
|
+
key: string,
|
|
423
|
+
targets: readonly TranslationRow[],
|
|
424
|
+
origin: TranslationReviewOrigin,
|
|
425
|
+
description?: ReactNode,
|
|
426
|
+
): Promise<boolean> => {
|
|
427
|
+
if (!onSave || targets.length === 0) return false;
|
|
428
|
+
const save = onSave;
|
|
429
|
+
const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
|
|
430
|
+
if (ok && undoOn) sayApproved(targets, description);
|
|
431
|
+
return ok;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** The editor's own save: it waits, closes and shows its errors itself, so it does
|
|
435
|
+
* not go through the queue — but an approval there is answered like any other. */
|
|
436
|
+
const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {
|
|
437
|
+
const toasted = undoOn && write.verdict === "APPROVED";
|
|
438
|
+
await onSave?.([write], { origin: "editor", toasted });
|
|
439
|
+
if (toasted) sayApproved([row], row.key);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Focus back into the panel once a group's batch has settled, if it fell out: the
|
|
444
|
+
* button that was pressed goes with the rows it approved (and under the `Unreviewed`
|
|
445
|
+
* filter the whole group can go). Two frames, so the app's new rows have been drawn.
|
|
446
|
+
*/
|
|
447
|
+
const keepFocusAt = (groupKey: string) => {
|
|
448
|
+
const check = () => {
|
|
449
|
+
const root = rootRef.current;
|
|
450
|
+
const active = document.activeElement;
|
|
451
|
+
if (!root || (active && active !== document.body && active.isConnected)) return;
|
|
452
|
+
const sections = [...root.querySelectorAll<HTMLElement>("[data-review-group]")];
|
|
453
|
+
const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];
|
|
454
|
+
section?.querySelector<HTMLElement>("[data-disclosure-trigger]")?.focus();
|
|
455
|
+
};
|
|
456
|
+
requestAnimationFrame(() => requestAnimationFrame(check));
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {
|
|
460
|
+
const targets = unreviewedRows(group.rows);
|
|
461
|
+
if (targets.length === 0) return;
|
|
462
|
+
if (!confirmed && targets.length > pageSize) {
|
|
463
|
+
// Asked inside the opened group, over the rows it is about.
|
|
464
|
+
setGroupOpenFor(group.key, true);
|
|
465
|
+
setConfirming(group.key);
|
|
466
|
+
confirmFocus.current = group.key;
|
|
467
|
+
return;
|
|
228
468
|
}
|
|
469
|
+
const ok = await approve(`group:${group.key}`, targets, "group", groupName(group.key));
|
|
470
|
+
if (ok) setConfirming((key) => (key === group.key ? null : key));
|
|
471
|
+
keepFocusAt(group.key);
|
|
229
472
|
};
|
|
230
473
|
|
|
231
474
|
const visibleIds = new Set(visible.map((r) => r.id));
|
|
232
475
|
const selectedRows = visible.filter((r) => selected.has(r.id));
|
|
233
|
-
|
|
476
|
+
// What "Select all shown" takes: grouped, the rows of the open groups — a folded
|
|
477
|
+
// group's rows are not shown.
|
|
478
|
+
const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;
|
|
479
|
+
const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));
|
|
234
480
|
const toApprove = selectedRows.filter((r) => r.text !== "" && r.status !== "approved");
|
|
235
481
|
const toReset = selectedRows.filter((r) => r.review !== null);
|
|
236
482
|
|
|
237
483
|
const approveSelected = async () => {
|
|
238
|
-
if (
|
|
239
|
-
if (await run("bulk-approve", () => onSave(toApprove.map((r) => reviewWrite(r, "APPROVED"))))) {
|
|
240
|
-
setSelected(new Set());
|
|
241
|
-
}
|
|
484
|
+
if (await approve("bulk-approve", toApprove, "selection")) setSelected(new Set());
|
|
242
485
|
};
|
|
243
486
|
const resetSelected = async () => {
|
|
244
487
|
if (!onClear || toReset.length === 0) return;
|
|
245
|
-
|
|
488
|
+
const clear = onClear;
|
|
489
|
+
const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));
|
|
490
|
+
if (await run("bulk-reset", () => clear(keys, { origin: "selection", toasted: false }))) {
|
|
246
491
|
setSelected(new Set());
|
|
247
492
|
}
|
|
248
493
|
};
|
|
@@ -291,8 +536,7 @@ export function TranslationReviewPanel({
|
|
|
291
536
|
sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),
|
|
292
537
|
},
|
|
293
538
|
];
|
|
294
|
-
if (editable
|
|
295
|
-
const save = onSave;
|
|
539
|
+
if (editable) {
|
|
296
540
|
columns.push({
|
|
297
541
|
key: "approve",
|
|
298
542
|
header: <span className="sr-only">{labels.approve}</span>,
|
|
@@ -304,12 +548,12 @@ export function TranslationReviewPanel({
|
|
|
304
548
|
tone="muted"
|
|
305
549
|
commit
|
|
306
550
|
label={labels.approve}
|
|
307
|
-
pending={
|
|
308
|
-
disabled={busy
|
|
551
|
+
pending={isQueued(`row:${r.id}`)}
|
|
552
|
+
disabled={busy}
|
|
309
553
|
onClick={(event) => {
|
|
310
554
|
// The row's own click opens the editor; this one only approves.
|
|
311
555
|
event.stopPropagation();
|
|
312
|
-
void
|
|
556
|
+
void approve(`row:${r.id}`, [r], "row", r.key);
|
|
313
557
|
}}
|
|
314
558
|
>
|
|
315
559
|
<Check />
|
|
@@ -318,8 +562,214 @@ export function TranslationReviewPanel({
|
|
|
318
562
|
});
|
|
319
563
|
}
|
|
320
564
|
|
|
565
|
+
/**
|
|
566
|
+
* A phone card's swipes: Approve toward the end, "Needs a change" toward the start.
|
|
567
|
+
* None where the row must not be acted on — the same conditions under which its
|
|
568
|
+
* buttons are locked or absent.
|
|
569
|
+
*/
|
|
570
|
+
const swipeActions = (r: TranslationRow): MobileSwipeActions | null => {
|
|
571
|
+
if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
|
|
572
|
+
const missing = r.text === "";
|
|
573
|
+
return {
|
|
574
|
+
end:
|
|
575
|
+
missing || r.status === "approved"
|
|
576
|
+
? []
|
|
577
|
+
: [
|
|
578
|
+
{
|
|
579
|
+
label: labels.approve,
|
|
580
|
+
icon: <Check className="size-4" aria-hidden />,
|
|
581
|
+
onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
|
|
582
|
+
className: "bg-[var(--success)]",
|
|
583
|
+
armedClassName: "bg-[var(--success)]",
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
start: [
|
|
587
|
+
{
|
|
588
|
+
label: missing ? labels.suggest : labels.flag,
|
|
589
|
+
icon: <PencilLine className="size-4" aria-hidden />,
|
|
590
|
+
// Opens the editor rather than sending the string back: "needs a change"
|
|
591
|
+
// with neither a note nor a better wording tells nobody anything.
|
|
592
|
+
onCommit: () => {
|
|
593
|
+
setOpenId(r.id);
|
|
594
|
+
setFocusId(r.id);
|
|
595
|
+
},
|
|
596
|
+
className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
|
|
597
|
+
armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]",
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
};
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {
|
|
604
|
+
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
|
|
605
|
+
for (const r of many) {
|
|
606
|
+
if (checked) next.add(r.id);
|
|
607
|
+
else next.delete(r.id);
|
|
608
|
+
}
|
|
609
|
+
return next;
|
|
610
|
+
};
|
|
611
|
+
/** The checkbox column of a table over `list` — the whole list, or one group. */
|
|
612
|
+
const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>["selection"] => {
|
|
613
|
+
if (!editable) return undefined;
|
|
614
|
+
const picked = list.filter((r) => selected.has(r.id)).length;
|
|
615
|
+
return {
|
|
616
|
+
isSelected: (r) => selected.has(r.id),
|
|
617
|
+
onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),
|
|
618
|
+
onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),
|
|
619
|
+
allSelected: list.length > 0 && picked === list.length,
|
|
620
|
+
someSelected: picked > 0 && picked < list.length,
|
|
621
|
+
onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),
|
|
622
|
+
};
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const table = (list: TranslationRow[], group?: { name: string }) => (
|
|
626
|
+
<DataTable<TranslationRow>
|
|
627
|
+
rows={list}
|
|
628
|
+
rowKey={(r) => r.id}
|
|
629
|
+
columns={columns}
|
|
630
|
+
empty={labels.empty}
|
|
631
|
+
defaultPageSize={pageSize}
|
|
632
|
+
{...(group
|
|
633
|
+
? {
|
|
634
|
+
// Inside the group's own card: no frame in a frame, no column rail per group,
|
|
635
|
+
// no scroller per group, and the group's name as the table's.
|
|
636
|
+
frame: false,
|
|
637
|
+
chrome: "minimal" as const,
|
|
638
|
+
maxBodyHeight: "none",
|
|
639
|
+
sorts: groupSorts,
|
|
640
|
+
onSortsChange: setGroupSorts,
|
|
641
|
+
labels: { table: group.name },
|
|
642
|
+
}
|
|
643
|
+
: { storageKey })}
|
|
644
|
+
isExpanded={(r) => r.id === openId}
|
|
645
|
+
onRowClick={(r) => {
|
|
646
|
+
setFocusId(null);
|
|
647
|
+
setOpenId((open) => (open === r.id ? null : r.id));
|
|
648
|
+
}}
|
|
649
|
+
expandedRow={(r) => (
|
|
650
|
+
<TranslationReviewEditor
|
|
651
|
+
key={r.id}
|
|
652
|
+
row={r}
|
|
653
|
+
referenceLabel={referenceLabel}
|
|
654
|
+
localeLabel={localeLabel}
|
|
655
|
+
readOnly={!editable}
|
|
656
|
+
focusWording={focusId === r.id}
|
|
657
|
+
onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}
|
|
658
|
+
onClear={onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : undefined}
|
|
659
|
+
onClose={() => {
|
|
660
|
+
setOpenId(null);
|
|
661
|
+
setFocusId(null);
|
|
662
|
+
}}
|
|
663
|
+
formatDate={formatDate}
|
|
664
|
+
formatError={formatError}
|
|
665
|
+
labels={labelsProp}
|
|
666
|
+
/>
|
|
667
|
+
)}
|
|
668
|
+
mobileCard={(r) => (
|
|
669
|
+
<div className="min-w-0 space-y-1">
|
|
670
|
+
<code className="block break-all text-xs text-[var(--text-muted)]">{r.key}</code>
|
|
671
|
+
<div className="text-sm text-[var(--text-primary)]">
|
|
672
|
+
<TextCell row={r} labels={labels} clamp />
|
|
673
|
+
</div>
|
|
674
|
+
<div className="flex flex-wrap gap-1">
|
|
675
|
+
<ReviewStatusChip status={r.status} labels={labelsProp} />
|
|
676
|
+
{r.placeholderMismatch && (
|
|
677
|
+
<Chip size="sm" tone="danger" variant="outline">
|
|
678
|
+
{labels.placeholderChip}
|
|
679
|
+
</Chip>
|
|
680
|
+
)}
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
)}
|
|
684
|
+
mobileSwipeActions={swipe ? swipeActions : undefined}
|
|
685
|
+
selection={selectionFor(list)}
|
|
686
|
+
/>
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const groupSection = (g: TranslationRowGroup, index: number) => {
|
|
690
|
+
const name = groupName(g.key);
|
|
691
|
+
const titleId = `${baseId}-group-${index}`;
|
|
692
|
+
const questionId = `${baseId}-confirm-${index}`;
|
|
693
|
+
const targets = editable ? unreviewedRows(g.rows) : [];
|
|
694
|
+
const key = `group:${g.key}`;
|
|
695
|
+
return (
|
|
696
|
+
<Disclosure
|
|
697
|
+
key={g.key}
|
|
698
|
+
data-review-group={g.key}
|
|
699
|
+
headingAs={groupHeadingAs}
|
|
700
|
+
title={<span id={titleId}>{name}</span>}
|
|
701
|
+
hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}
|
|
702
|
+
open={isGroupOpen(g.key)}
|
|
703
|
+
onOpenChange={(open) => {
|
|
704
|
+
setGroupOpenFor(g.key, open);
|
|
705
|
+
if (!open) setConfirming((c) => (c === g.key ? null : c));
|
|
706
|
+
}}
|
|
707
|
+
trailing={
|
|
708
|
+
targets.length > 0 ? (
|
|
709
|
+
<Button
|
|
710
|
+
variant="secondary"
|
|
711
|
+
size="sm"
|
|
712
|
+
commit
|
|
713
|
+
// The visible words say what; the group's name, read after them, says where.
|
|
714
|
+
aria-describedby={titleId}
|
|
715
|
+
pending={isQueued(key) && confirming !== g.key}
|
|
716
|
+
disabled={busy}
|
|
717
|
+
onClick={() => void approveGroup(g, false)}
|
|
718
|
+
>
|
|
719
|
+
<Check className="size-4" aria-hidden />
|
|
720
|
+
{labels.approveGroup(targets.length)}
|
|
721
|
+
</Button>
|
|
722
|
+
) : undefined
|
|
723
|
+
}
|
|
724
|
+
bodyClassName="space-y-0 px-0 pb-0 border-t border-[var(--border)]"
|
|
725
|
+
>
|
|
726
|
+
{confirming === g.key && targets.length > 0 && (
|
|
727
|
+
<div className="border-b border-[var(--border)] p-3">
|
|
728
|
+
<AlertBanner tone="warning" size="sm">
|
|
729
|
+
<div className="min-w-0 space-y-2">
|
|
730
|
+
<p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>
|
|
731
|
+
<div className="flex flex-wrap gap-2">
|
|
732
|
+
<Button
|
|
733
|
+
// Focus lands here when the question appears: the expected answer to a
|
|
734
|
+
// question the reviewer just asked for, and Undo stays behind it.
|
|
735
|
+
ref={(el) => {
|
|
736
|
+
if (el && confirmFocus.current === g.key) {
|
|
737
|
+
confirmFocus.current = null;
|
|
738
|
+
el.focus();
|
|
739
|
+
}
|
|
740
|
+
}}
|
|
741
|
+
variant="primary"
|
|
742
|
+
size="sm"
|
|
743
|
+
commit
|
|
744
|
+
aria-describedby={questionId}
|
|
745
|
+
pending={isQueued(key)}
|
|
746
|
+
disabled={busy}
|
|
747
|
+
onClick={() => void approveGroup(g, true)}
|
|
748
|
+
>
|
|
749
|
+
{labels.approveGroup(targets.length)}
|
|
750
|
+
</Button>
|
|
751
|
+
<Button
|
|
752
|
+
variant="ghost"
|
|
753
|
+
size="sm"
|
|
754
|
+
disabled={isQueued(key)}
|
|
755
|
+
onClick={() => {
|
|
756
|
+
setConfirming(null);
|
|
757
|
+
keepFocusAt(g.key);
|
|
758
|
+
}}
|
|
759
|
+
>
|
|
760
|
+
{labels.cancel}
|
|
761
|
+
</Button>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
</AlertBanner>
|
|
765
|
+
</div>
|
|
766
|
+
)}
|
|
767
|
+
{table(g.rows, { name })}
|
|
768
|
+
</Disclosure>
|
|
769
|
+
);
|
|
770
|
+
};
|
|
321
771
|
return (
|
|
322
|
-
<div className={cn("min-w-0 space-y-4", className)}>
|
|
772
|
+
<div ref={rootRef} className={cn("min-w-0 space-y-4", className)}>
|
|
323
773
|
{scopeText && (
|
|
324
774
|
<AlertBanner tone="info" variant="inline" size="sm" block live={false}>
|
|
325
775
|
{labels.scope(scopeText)}
|
|
@@ -381,14 +831,14 @@ export function TranslationReviewPanel({
|
|
|
381
831
|
/>
|
|
382
832
|
</div>
|
|
383
833
|
|
|
384
|
-
{editable &&
|
|
834
|
+
{editable && shownRows.length > 0 && (
|
|
385
835
|
// The phone's table has no checkboxes; this is its way into the bulk actions.
|
|
386
836
|
<Button
|
|
387
837
|
variant="ghost"
|
|
388
838
|
size="sm"
|
|
389
839
|
className="md:hidden"
|
|
390
|
-
disabled={
|
|
391
|
-
onClick={() => setSelected(
|
|
840
|
+
disabled={allShownSelected}
|
|
841
|
+
onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}
|
|
392
842
|
>
|
|
393
843
|
<ListChecks className="size-4" aria-hidden />
|
|
394
844
|
{labels.selectShown}
|
|
@@ -407,8 +857,8 @@ export function TranslationReviewPanel({
|
|
|
407
857
|
variant="primary"
|
|
408
858
|
size="sm"
|
|
409
859
|
commit
|
|
410
|
-
pending={
|
|
411
|
-
disabled={busy
|
|
860
|
+
pending={isQueued("bulk-approve")}
|
|
861
|
+
disabled={busy || toApprove.length === 0}
|
|
412
862
|
onClick={() => void approveSelected()}
|
|
413
863
|
>
|
|
414
864
|
<Check className="size-4" aria-hidden />
|
|
@@ -419,8 +869,8 @@ export function TranslationReviewPanel({
|
|
|
419
869
|
variant="ghost"
|
|
420
870
|
size="sm"
|
|
421
871
|
commit
|
|
422
|
-
pending={
|
|
423
|
-
disabled={busy
|
|
872
|
+
pending={isQueued("bulk-reset")}
|
|
873
|
+
disabled={busy || toReset.length === 0}
|
|
424
874
|
onClick={() => void resetSelected()}
|
|
425
875
|
>
|
|
426
876
|
<RotateCcw className="size-4" aria-hidden />
|
|
@@ -430,73 +880,11 @@ export function TranslationReviewPanel({
|
|
|
430
880
|
</BulkActionBar>
|
|
431
881
|
)}
|
|
432
882
|
|
|
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
|
-
/>
|
|
883
|
+
{groups && groups.length > 0 ? (
|
|
884
|
+
<div className="space-y-3">{groups.map(groupSection)}</div>
|
|
885
|
+
) : (
|
|
886
|
+
table(visible)
|
|
887
|
+
)}
|
|
500
888
|
</div>
|
|
501
889
|
);
|
|
502
890
|
}
|