@focus-reactive/payload-plugin-translator 0.9.1 → 0.10.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 +25 -0
- package/dist/client/entities/translation/api/mutations/useQueueDocumentTranslation.d.ts +1 -1
- package/dist/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.d.ts +3 -4
- package/dist/client/entities/translation/ui/AutoTranslateMarker/AutoTranslateMarker.js +8 -12
- package/dist/client/entities/translation/ui/AutoTranslateMarker/styles.module.scss +3 -12
- package/dist/client/features/collection-translation-form/model/schema.d.ts +3 -3
- package/dist/client/features/collection-translation-form/model/schema.js +8 -1
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -1
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +36 -6
- package/dist/client/features/translate-document-form/model/schema.d.ts +3 -3
- package/dist/client/features/translate-document-form/model/schema.js +8 -1
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +3 -1
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +36 -6
- package/dist/client/shared/lib/forms/pruneSourceFromTarget.d.ts +7 -0
- package/dist/client/shared/lib/forms/pruneSourceFromTarget.js +14 -0
- package/dist/client/shared/ui/InfoPopover/InfoPopover.d.ts +21 -0
- package/dist/client/shared/ui/InfoPopover/InfoPopover.js +45 -0
- package/dist/client/shared/ui/InfoPopover/index.d.ts +1 -0
- package/dist/client/shared/ui/InfoPopover/index.js +3 -0
- package/dist/client/shared/ui/InfoPopover/styles.module.scss +49 -0
- package/dist/client/shared/ui/MultiSelect/MultiSelect.d.ts +40 -0
- package/dist/client/shared/ui/MultiSelect/MultiSelect.js +129 -0
- package/dist/client/shared/ui/MultiSelect/index.d.ts +2 -0
- package/dist/client/shared/ui/MultiSelect/index.js +3 -0
- package/dist/client/shared/ui/MultiSelect/multiSelectModel.d.ts +25 -0
- package/dist/client/shared/ui/MultiSelect/multiSelectModel.js +41 -0
- package/dist/client/shared/ui/MultiSelect/styles.module.scss +126 -0
- package/dist/client/shared/ui/form/FormCheckboxPublish/FormCheckboxPublish.js +4 -7
- package/dist/client/shared/ui/form/FormMultiSelect/FormMultiSelect.d.ts +22 -0
- package/dist/client/shared/ui/form/FormMultiSelect/FormMultiSelect.js +70 -0
- package/dist/client/shared/ui/form/FormMultiSelect/index.d.ts +1 -0
- package/dist/client/shared/ui/form/FormMultiSelect/index.js +3 -0
- package/dist/client/shared/ui/form/FormSelectStrategy/FormSelectStrategy.js +4 -7
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.d.ts +3 -1
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.d.ts +3 -1
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.js +5 -2
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.js +8 -3
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.d.ts +2 -0
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js +2 -1
- package/dist/client/widgets/translate-document/ui/TranslateDocument.d.ts +3 -1
- package/dist/client/widgets/translate-document/ui/TranslateDocument.export.d.ts +3 -1
- package/dist/client/widgets/translate-document/ui/TranslateDocument.export.js +5 -2
- package/dist/client/widgets/translate-document/ui/TranslateDocument.js +7 -3
- package/dist/client/widgets/translate-document/ui/TranslateDocument.server.d.ts +2 -0
- package/dist/client/widgets/translate-document/ui/TranslateDocument.server.js +2 -1
- package/dist/composition/levels/collectionLevel.js +1 -1
- package/dist/composition/levels/documentLevel.js +1 -1
- package/dist/core/kernel/field-traversal/index.d.ts +1 -0
- package/dist/core/kernel/field-traversal/index.js +1 -0
- package/dist/core/kernel/field-traversal/projectFieldLike.d.ts +32 -0
- package/dist/core/kernel/field-traversal/projectFieldLike.js +60 -0
- package/dist/index.d.ts +1 -0
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.js +11 -11
- package/dist/server/features/enqueue-translation/handler.js +28 -8
- package/dist/server/features/enqueue-translation/model.d.ts +3 -3
- package/dist/server/features/enqueue-translation/model.js +6 -1
- package/dist/server/features/enqueue-translation/resolveTargetLocales.d.ts +29 -0
- package/dist/server/features/enqueue-translation/resolveTargetLocales.js +41 -0
- package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +3 -3
- package/dist/server/modules/auto-translate/index.d.ts +2 -0
- package/dist/server/modules/auto-translate/index.js +2 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +2 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.js +2 -0
- package/dist/server/modules/translation-levels/types.d.ts +4 -0
- package/dist/types/CollectionSchemaMap.d.ts +7 -4
- package/dist/types/CollectionSchemaMap.js +4 -2
- package/dist/types/TargetSelection.d.ts +10 -0
- package/dist/types/TargetSelection.js +11 -0
- package/package.json +1 -1
- package/dist/client/entities/translation/api/mutations/useQueueDocumentTranslations.d.ts +0 -8
- package/dist/client/entities/translation/api/mutations/useQueueDocumentTranslations.js +0 -20
- package/dist/client/shared/ui/form/FormSelectLocale/FormSelectLocale.d.ts +0 -11
- package/dist/client/shared/ui/form/FormSelectLocale/FormSelectLocale.js +0 -19
- package/dist/client/shared/ui/form/FormSelectLocale/index.d.ts +0 -1
- package/dist/client/shared/ui/form/FormSelectLocale/index.js +0 -3
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ It works at three levels — translate the **document** you're editing, **bulk-t
|
|
|
16
16
|
- **Deep translation** — every localized leaf field at any nesting level (groups, arrays, blocks, tabs).
|
|
17
17
|
- **Rich text** — full Lexical translation, preserving formatting and structure.
|
|
18
18
|
- **Three surfaces** — a per-document popup, a bulk-collection dashboard, and a per-field control, toggled via `levels`.
|
|
19
|
+
- **Single or multi target** — translate into one locale, or pick several at once, via `targetSelection`.
|
|
19
20
|
- **Async or sync** — queue-based background jobs (Payload Jobs) by default, or run inline.
|
|
20
21
|
- **Pluggable providers** — OpenAI built in, or implement your own.
|
|
21
22
|
- **Strategies** — overwrite everything or skip locales that already have content.
|
|
@@ -143,6 +144,7 @@ Allowed on **`text`, `textarea`, and `richText`** fields (a compile error on oth
|
|
|
143
144
|
| `levels` | `TranslationLevel[]` | No | `[documentLevel(), collectionLevel()]` | Which surfaces to enable — see [Translation surfaces](#translation-surfaces-levels). |
|
|
144
145
|
| `provenance` | `boolean \| { slug?: string }` | No | `false` (disabled) | Opt in to recording a provenance record per translation. _Since v0.7.0._ See [Provenance](#provenance-opt-in) below. |
|
|
145
146
|
| `lifecycle` | `{ onQueued?, onCompleted?, onFailed? }` | No | `undefined` | Server-side callbacks fired around each task. _Since v0.7.0._ See [Lifecycle callbacks](#lifecycle-callbacks). |
|
|
147
|
+
| `targetSelection` | `'single' \| 'multi'` | No | `'single'` | Let an editor pick several target locales in one run. _Since v0.10.0._ See [Target-language selection](#target-language-selection) below. |
|
|
146
148
|
|
|
147
149
|
```typescript
|
|
148
150
|
translatorPlugin({
|
|
@@ -153,6 +155,29 @@ translatorPlugin({
|
|
|
153
155
|
});
|
|
154
156
|
```
|
|
155
157
|
|
|
158
|
+
### Target-language selection
|
|
159
|
+
|
|
160
|
+
_Since v0.10.0._
|
|
161
|
+
|
|
162
|
+
By default the translation forms (per-document panel and bulk dashboard) translate into **one**
|
|
163
|
+
target locale per run. Set `targetSelection: 'multi'` to let an editor pick **several** target
|
|
164
|
+
locales at once — the "To" field becomes a compact multi-select and the run fans out one translation
|
|
165
|
+
per _(document × target locale)_.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
translatorPlugin({
|
|
169
|
+
collections: [Posts, Pages],
|
|
170
|
+
translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
|
|
171
|
+
runner: createPayloadJobsRunner(),
|
|
172
|
+
targetSelection: "multi", // default is "single" (one target per run — unchanged)
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Fully backward-compatible and opt-in: the default `'single'` keeps today's behaviour exactly, and
|
|
177
|
+
there is no schema or migration. Unknown or duplicate target locales are ignored, and the source
|
|
178
|
+
locale is never translated into itself. The `/enqueue` endpoint accepts `target_lng` as either a
|
|
179
|
+
single string or an array of strings, so existing API callers keep working.
|
|
180
|
+
|
|
156
181
|
### Provenance (opt-in)
|
|
157
182
|
|
|
158
183
|
_Since v0.7.0._
|
|
@@ -5,10 +5,9 @@ type AutoTranslateMarkerProps = {
|
|
|
5
5
|
sourceLocale: string;
|
|
6
6
|
};
|
|
7
7
|
/**
|
|
8
|
-
* A quiet, off-to-the-side marker in the translation popups' header
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* flow (title / Translate / Status). Rendered only when auto-translate is enabled.
|
|
8
|
+
* A quiet, off-to-the-side marker in the translation popups' header that a collection is opted into
|
|
9
|
+
* auto-translate. The detail lives behind the icon (tap/click), not the layout — ambient config the
|
|
10
|
+
* editor rarely needs. Rendered only when auto-translate is enabled.
|
|
12
11
|
*/
|
|
13
12
|
export declare function AutoTranslateMarker({ targets, sourceLocale }: AutoTranslateMarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
14
13
|
export {};
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { AutoTranslateIcon } from "../../../../shared/lib/assets/icons/AutoTranslateIcon";
|
|
4
|
-
import
|
|
4
|
+
import InfoPopover from "../../../../shared/ui/InfoPopover";
|
|
5
5
|
import styles from "./styles.module.scss";
|
|
6
6
|
/**
|
|
7
|
-
* A quiet, off-to-the-side marker in the translation popups' header
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* flow (title / Translate / Status). Rendered only when auto-translate is enabled.
|
|
7
|
+
* A quiet, off-to-the-side marker in the translation popups' header that a collection is opted into
|
|
8
|
+
* auto-translate. The detail lives behind the icon (tap/click), not the layout — ambient config the
|
|
9
|
+
* editor rarely needs. Rendered only when auto-translate is enabled.
|
|
11
10
|
*/ export function AutoTranslateMarker({ targets, sourceLocale }) {
|
|
12
|
-
return /*#__PURE__*/ _jsx(
|
|
11
|
+
return /*#__PURE__*/ _jsx(InfoPopover, {
|
|
12
|
+
label: "Auto-translate enabled",
|
|
13
|
+
className: styles.marker,
|
|
14
|
+
icon: /*#__PURE__*/ _jsx(AutoTranslateIcon, {}),
|
|
13
15
|
content: /*#__PURE__*/ _jsxs("span", {
|
|
14
16
|
className: styles.tip,
|
|
15
17
|
children: [
|
|
@@ -21,12 +23,6 @@ import styles from "./styles.module.scss";
|
|
|
21
23
|
targets.join(" · "),
|
|
22
24
|
"."
|
|
23
25
|
]
|
|
24
|
-
}),
|
|
25
|
-
children: /*#__PURE__*/ _jsx("span", {
|
|
26
|
-
className: styles.marker,
|
|
27
|
-
tabIndex: 0,
|
|
28
|
-
"aria-label": "Auto-translate enabled",
|
|
29
|
-
children: /*#__PURE__*/ _jsx(AutoTranslateIcon, {})
|
|
30
26
|
})
|
|
31
27
|
});
|
|
32
28
|
}
|
|
@@ -1,24 +1,15 @@
|
|
|
1
|
-
// A muted, low-emphasis header marker
|
|
2
|
-
//
|
|
1
|
+
// A muted, low-emphasis header marker at the edge of the title row; the detail lives behind the icon,
|
|
2
|
+
// so it never competes with the popup's main flow. (Button reset + focus come from InfoPopover.)
|
|
3
3
|
.marker {
|
|
4
|
-
display: inline-flex;
|
|
5
|
-
align-items: center;
|
|
6
4
|
flex-shrink: 0;
|
|
7
5
|
color: var(--theme-elevation-400, #a5a5a5);
|
|
8
6
|
cursor: help;
|
|
9
|
-
outline: none;
|
|
10
7
|
}
|
|
11
8
|
|
|
12
9
|
.marker:hover {
|
|
13
10
|
color: var(--theme-elevation-600, #6d6d6d);
|
|
14
11
|
}
|
|
15
12
|
|
|
16
|
-
.marker:focus-visible {
|
|
17
|
-
outline: 2px solid var(--theme-elevation-400, #a5a5a5);
|
|
18
|
-
outline-offset: 2px;
|
|
19
|
-
border-radius: 3px;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
13
|
.tip {
|
|
23
14
|
display: block;
|
|
24
15
|
max-width: 15rem;
|
|
@@ -27,4 +18,4 @@
|
|
|
27
18
|
|
|
28
19
|
.tip code {
|
|
29
20
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
30
|
-
}
|
|
21
|
+
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const validationSchema: z.ZodObject<{
|
|
3
3
|
source_lng: z.ZodString;
|
|
4
|
-
target_lng: z.ZodString
|
|
4
|
+
target_lng: z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>, string | string[], string | string[]>;
|
|
5
5
|
collection_slug: z.ZodString;
|
|
6
6
|
strategy: z.ZodEnum<["overwrite", "skip_existing"]>;
|
|
7
7
|
publish_on_translation: z.ZodDefault<z.ZodBoolean>;
|
|
8
8
|
}, "strip", z.ZodTypeAny, {
|
|
9
9
|
collection_slug: string;
|
|
10
10
|
source_lng: string;
|
|
11
|
-
target_lng: string;
|
|
11
|
+
target_lng: string | string[];
|
|
12
12
|
strategy: "overwrite" | "skip_existing";
|
|
13
13
|
publish_on_translation: boolean;
|
|
14
14
|
}, {
|
|
15
15
|
collection_slug: string;
|
|
16
16
|
source_lng: string;
|
|
17
|
-
target_lng: string;
|
|
17
|
+
target_lng: string | string[];
|
|
18
18
|
strategy: "overwrite" | "skip_existing";
|
|
19
19
|
publish_on_translation?: boolean | undefined;
|
|
20
20
|
}>;
|
|
@@ -2,7 +2,14 @@ import { z } from "zod";
|
|
|
2
2
|
import { FORM_FIELDS } from "./constants";
|
|
3
3
|
export const validationSchema = z.object({
|
|
4
4
|
[FORM_FIELDS.SOURCE_LNG]: z.string().nonempty(""),
|
|
5
|
-
[
|
|
5
|
+
// Single mode binds a string, multi mode a string[]. Either empty shape ("" / []) is rejected with
|
|
6
|
+
// one human-readable message, so a target must always be chosen before submit (AC7).
|
|
7
|
+
[FORM_FIELDS.TARGET_LNG]: z.union([
|
|
8
|
+
z.string(),
|
|
9
|
+
z.array(z.string())
|
|
10
|
+
]).refine((value)=>value.length > 0, {
|
|
11
|
+
message: "Select at least one language"
|
|
12
|
+
}),
|
|
6
13
|
[FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: z.string().nonempty(),
|
|
7
14
|
[FORM_FIELDS.STRATEGY]: z.enum([
|
|
8
15
|
"overwrite",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { UseFormReturn } from "react-hook-form";
|
|
2
|
+
import type { TargetSelectionMode } from "../../../../types/TargetSelection";
|
|
2
3
|
import type { FormValues } from "../model/schema";
|
|
3
4
|
type CollectionTranslationFormProps = {
|
|
4
5
|
form: UseFormReturn<FormValues>;
|
|
5
6
|
onSubmit: (values: FormValues) => Promise<void>;
|
|
6
7
|
selectedCount: number;
|
|
7
8
|
hasDrafts: boolean;
|
|
9
|
+
targetSelection: TargetSelectionMode;
|
|
8
10
|
};
|
|
9
|
-
export declare function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, }: CollectionTranslationFormProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, targetSelection, }: CollectionTranslationFormProps): import("react/jsx-runtime").JSX.Element;
|
|
10
12
|
export {};
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo } from "react";
|
|
2
3
|
import { FormProvider } from "react-hook-form";
|
|
3
4
|
import { SendIcon } from "../../../shared/lib/assets/icons/SendIcon";
|
|
5
|
+
import { pruneSourceFromTarget } from "../../../shared/lib/forms/pruneSourceFromTarget";
|
|
6
|
+
import { useLocaleOptions } from "../../../shared/lib/payload/hooks/useLocaleOptions";
|
|
4
7
|
import Button from "../../../shared/ui/Button";
|
|
5
|
-
import
|
|
8
|
+
import FormSelect from "../../../shared/ui/form/FormSelect";
|
|
9
|
+
import FormMultiSelect from "../../../shared/ui/form/FormMultiSelect";
|
|
6
10
|
import { FormSelectStrategy } from "../../../shared/ui/form/FormSelectStrategy";
|
|
7
11
|
import { FormCheckboxPublish } from "../../../shared/ui/form/FormCheckboxPublish";
|
|
8
12
|
import { FORM_FIELDS } from "../model/constants";
|
|
9
13
|
import styles from "./styles.module.scss";
|
|
10
|
-
export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts }) {
|
|
14
|
+
export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, targetSelection }) {
|
|
15
|
+
const localeOptions = useLocaleOptions();
|
|
16
|
+
const source = form.watch(FORM_FIELDS.SOURCE_LNG);
|
|
17
|
+
const targetOptions = useMemo(()=>localeOptions.filter((option)=>option.value !== source), [
|
|
18
|
+
localeOptions,
|
|
19
|
+
source
|
|
20
|
+
]);
|
|
21
|
+
useEffect(()=>{
|
|
22
|
+
const pruned = pruneSourceFromTarget(form.getValues(FORM_FIELDS.TARGET_LNG), source);
|
|
23
|
+
if (pruned !== null) {
|
|
24
|
+
form.setValue(FORM_FIELDS.TARGET_LNG, pruned, {
|
|
25
|
+
shouldValidate: false
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}, [
|
|
29
|
+
source,
|
|
30
|
+
form
|
|
31
|
+
]);
|
|
11
32
|
return /*#__PURE__*/ _jsx(FormProvider, {
|
|
12
33
|
...form,
|
|
13
34
|
children: /*#__PURE__*/ _jsxs("fieldset", {
|
|
@@ -16,15 +37,24 @@ export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDr
|
|
|
16
37
|
/*#__PURE__*/ _jsxs("div", {
|
|
17
38
|
className: styles.row,
|
|
18
39
|
children: [
|
|
19
|
-
/*#__PURE__*/ _jsx(
|
|
40
|
+
/*#__PURE__*/ _jsx(FormSelect, {
|
|
20
41
|
size: "sm",
|
|
21
42
|
label: "From",
|
|
22
|
-
name: FORM_FIELDS.SOURCE_LNG
|
|
43
|
+
name: FORM_FIELDS.SOURCE_LNG,
|
|
44
|
+
options: localeOptions,
|
|
45
|
+
placeholder: "-"
|
|
23
46
|
}),
|
|
24
|
-
/*#__PURE__*/ _jsx(
|
|
47
|
+
targetSelection === "multi" ? /*#__PURE__*/ _jsx(FormMultiSelect, {
|
|
25
48
|
size: "sm",
|
|
26
49
|
label: "To",
|
|
27
|
-
name: FORM_FIELDS.TARGET_LNG
|
|
50
|
+
name: FORM_FIELDS.TARGET_LNG,
|
|
51
|
+
options: targetOptions
|
|
52
|
+
}) : /*#__PURE__*/ _jsx(FormSelect, {
|
|
53
|
+
size: "sm",
|
|
54
|
+
label: "To",
|
|
55
|
+
name: FORM_FIELDS.TARGET_LNG,
|
|
56
|
+
options: targetOptions,
|
|
57
|
+
placeholder: "-"
|
|
28
58
|
})
|
|
29
59
|
]
|
|
30
60
|
}),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const validationSchema: z.ZodObject<{
|
|
3
3
|
source_lng: z.ZodString;
|
|
4
|
-
target_lng: z.ZodString
|
|
4
|
+
target_lng: z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>, string | string[], string | string[]>;
|
|
5
5
|
collection_id: z.ZodString;
|
|
6
6
|
collection_slug: z.ZodString;
|
|
7
7
|
strategy: z.ZodDefault<z.ZodEnum<["overwrite", "skip_existing"]>>;
|
|
@@ -10,14 +10,14 @@ export declare const validationSchema: z.ZodObject<{
|
|
|
10
10
|
collection_slug: string;
|
|
11
11
|
collection_id: string;
|
|
12
12
|
source_lng: string;
|
|
13
|
-
target_lng: string;
|
|
13
|
+
target_lng: string | string[];
|
|
14
14
|
strategy: "overwrite" | "skip_existing";
|
|
15
15
|
publish_on_translation: boolean;
|
|
16
16
|
}, {
|
|
17
17
|
collection_slug: string;
|
|
18
18
|
collection_id: string;
|
|
19
19
|
source_lng: string;
|
|
20
|
-
target_lng: string;
|
|
20
|
+
target_lng: string | string[];
|
|
21
21
|
strategy?: "overwrite" | "skip_existing" | undefined;
|
|
22
22
|
publish_on_translation?: boolean | undefined;
|
|
23
23
|
}>;
|
|
@@ -2,7 +2,14 @@ import { z } from "zod";
|
|
|
2
2
|
import { FORM_FIELDS } from "./constants";
|
|
3
3
|
export const validationSchema = z.object({
|
|
4
4
|
[FORM_FIELDS.SOURCE_LNG]: z.string().nonempty(),
|
|
5
|
-
[
|
|
5
|
+
// Single mode binds a string, multi mode a string[]. Either empty shape ("" / []) is rejected with
|
|
6
|
+
// one human-readable message, so a target must always be chosen before submit (AC7).
|
|
7
|
+
[FORM_FIELDS.TARGET_LNG]: z.union([
|
|
8
|
+
z.string(),
|
|
9
|
+
z.array(z.string())
|
|
10
|
+
]).refine((value)=>value.length > 0, {
|
|
11
|
+
message: "Select at least one language"
|
|
12
|
+
}),
|
|
6
13
|
[FORM_FIELDS.HIDDEN_COLLECTION_ID]: z.string().nonempty(),
|
|
7
14
|
[FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: z.string().nonempty(),
|
|
8
15
|
[FORM_FIELDS.STRATEGY]: z.enum([
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { UseFormReturn } from "react-hook-form";
|
|
2
|
+
import type { TargetSelectionMode } from "../../../../types/TargetSelection";
|
|
2
3
|
import type { FormValues } from "../model/schema";
|
|
3
4
|
type DocumentTranslationFormProps = {
|
|
4
5
|
form: UseFormReturn<FormValues>;
|
|
5
6
|
onSubmit: (values: FormValues) => Promise<void>;
|
|
6
7
|
hasDrafts?: boolean;
|
|
8
|
+
targetSelection: TargetSelectionMode;
|
|
7
9
|
};
|
|
8
|
-
export declare function DocumentTranslationForm({ form, onSubmit, hasDrafts, }: DocumentTranslationFormProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export declare function DocumentTranslationForm({ form, onSubmit, hasDrafts, targetSelection, }: DocumentTranslationFormProps): import("react/jsx-runtime").JSX.Element;
|
|
9
11
|
export {};
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo } from "react";
|
|
2
3
|
import { FormProvider } from "react-hook-form";
|
|
3
4
|
import { SendIcon } from "../../../shared/lib/assets/icons/SendIcon";
|
|
5
|
+
import { pruneSourceFromTarget } from "../../../shared/lib/forms/pruneSourceFromTarget";
|
|
6
|
+
import { useLocaleOptions } from "../../../shared/lib/payload/hooks/useLocaleOptions";
|
|
4
7
|
import Button from "../../../shared/ui/Button";
|
|
5
|
-
import
|
|
8
|
+
import FormSelect from "../../../shared/ui/form/FormSelect";
|
|
9
|
+
import FormMultiSelect from "../../../shared/ui/form/FormMultiSelect";
|
|
6
10
|
import { FormSelectStrategy } from "../../../shared/ui/form/FormSelectStrategy";
|
|
7
11
|
import { FormCheckboxPublish } from "../../../shared/ui/form/FormCheckboxPublish";
|
|
8
12
|
import { FORM_FIELDS } from "../model/constants";
|
|
9
13
|
import styles from "./styles.module.scss";
|
|
10
|
-
export function DocumentTranslationForm({ form, onSubmit, hasDrafts }) {
|
|
14
|
+
export function DocumentTranslationForm({ form, onSubmit, hasDrafts, targetSelection }) {
|
|
15
|
+
const localeOptions = useLocaleOptions();
|
|
16
|
+
const source = form.watch(FORM_FIELDS.SOURCE_LNG);
|
|
17
|
+
const targetOptions = useMemo(()=>localeOptions.filter((option)=>option.value !== source), [
|
|
18
|
+
localeOptions,
|
|
19
|
+
source
|
|
20
|
+
]);
|
|
21
|
+
useEffect(()=>{
|
|
22
|
+
const pruned = pruneSourceFromTarget(form.getValues(FORM_FIELDS.TARGET_LNG), source);
|
|
23
|
+
if (pruned !== null) {
|
|
24
|
+
form.setValue(FORM_FIELDS.TARGET_LNG, pruned, {
|
|
25
|
+
shouldValidate: false
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}, [
|
|
29
|
+
source,
|
|
30
|
+
form
|
|
31
|
+
]);
|
|
11
32
|
return /*#__PURE__*/ _jsx(FormProvider, {
|
|
12
33
|
...form,
|
|
13
34
|
children: /*#__PURE__*/ _jsxs("fieldset", {
|
|
@@ -16,15 +37,24 @@ export function DocumentTranslationForm({ form, onSubmit, hasDrafts }) {
|
|
|
16
37
|
/*#__PURE__*/ _jsxs("div", {
|
|
17
38
|
className: styles.row,
|
|
18
39
|
children: [
|
|
19
|
-
/*#__PURE__*/ _jsx(
|
|
40
|
+
/*#__PURE__*/ _jsx(FormSelect, {
|
|
20
41
|
size: "sm",
|
|
21
42
|
label: "From",
|
|
22
|
-
name: FORM_FIELDS.SOURCE_LNG
|
|
43
|
+
name: FORM_FIELDS.SOURCE_LNG,
|
|
44
|
+
options: localeOptions,
|
|
45
|
+
placeholder: "-"
|
|
23
46
|
}),
|
|
24
|
-
/*#__PURE__*/ _jsx(
|
|
47
|
+
targetSelection === "multi" ? /*#__PURE__*/ _jsx(FormMultiSelect, {
|
|
25
48
|
size: "sm",
|
|
26
49
|
label: "To",
|
|
27
|
-
name: FORM_FIELDS.TARGET_LNG
|
|
50
|
+
name: FORM_FIELDS.TARGET_LNG,
|
|
51
|
+
options: targetOptions
|
|
52
|
+
}) : /*#__PURE__*/ _jsx(FormSelect, {
|
|
53
|
+
size: "sm",
|
|
54
|
+
label: "To",
|
|
55
|
+
name: FORM_FIELDS.TARGET_LNG,
|
|
56
|
+
options: targetOptions,
|
|
57
|
+
placeholder: "-"
|
|
28
58
|
})
|
|
29
59
|
]
|
|
30
60
|
}),
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remove the source locale from a target-field value so a target can never equal the source. Handles
|
|
3
|
+
* both the single (`string`) and multi (`string[]`) target shapes. Returns the pruned value, or `null`
|
|
4
|
+
* when nothing needed pruning — the caller then skips the form write to avoid a redundant update (and
|
|
5
|
+
* the re-render / validation it would trigger).
|
|
6
|
+
*/
|
|
7
|
+
export declare function pruneSourceFromTarget(value: string | string[], source: string): string | string[] | null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remove the source locale from a target-field value so a target can never equal the source. Handles
|
|
3
|
+
* both the single (`string`) and multi (`string[]`) target shapes. Returns the pruned value, or `null`
|
|
4
|
+
* when nothing needed pruning — the caller then skips the form write to avoid a redundant update (and
|
|
5
|
+
* the re-render / validation it would trigger).
|
|
6
|
+
*/ export function pruneSourceFromTarget(value, source) {
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
const filtered = value.filter((locale)=>locale !== source);
|
|
9
|
+
return filtered.length === value.length ? null : filtered;
|
|
10
|
+
}
|
|
11
|
+
return value === source ? "" : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//# sourceMappingURL=pruneSourceFromTarget.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
type InfoPopoverProps = {
|
|
3
|
+
/** The detail shown when the icon is activated. */
|
|
4
|
+
content: ReactNode;
|
|
5
|
+
/** Accessible name for the trigger button (the popover has no visible label). */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Trigger glyph; defaults to a question mark. */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
10
|
+
sideOffset?: number;
|
|
11
|
+
/** Extra class on the trigger button (icon slot styling). */
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A click/tap info affordance: an icon button that toggles a small popover with the detail. Replaces
|
|
16
|
+
* hover-only tooltips for help text so it works on touch, is keyboard-operable, and never opens itself
|
|
17
|
+
* when a surrounding popover autofocuses it (a Popover, unlike a Tooltip, does not open on focus). For
|
|
18
|
+
* naming an icon *control*, use a Tooltip instead — this is for disclosing information.
|
|
19
|
+
*/
|
|
20
|
+
export default function InfoPopover({ content, label, icon, side, sideOffset, className, }: InfoPopoverProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
5
|
+
import { QuestionCircleIcon } from "../../lib/assets/icons/QuestionCircleIcon";
|
|
6
|
+
import styles from "./styles.module.scss";
|
|
7
|
+
/**
|
|
8
|
+
* A click/tap info affordance: an icon button that toggles a small popover with the detail. Replaces
|
|
9
|
+
* hover-only tooltips for help text so it works on touch, is keyboard-operable, and never opens itself
|
|
10
|
+
* when a surrounding popover autofocuses it (a Popover, unlike a Tooltip, does not open on focus). For
|
|
11
|
+
* naming an icon *control*, use a Tooltip instead — this is for disclosing information.
|
|
12
|
+
*/ export default function InfoPopover({ content, label, icon, side = "top", sideOffset = 6, className }) {
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
return /*#__PURE__*/ _jsxs(Popover.Root, {
|
|
15
|
+
open: open,
|
|
16
|
+
onOpenChange: setOpen,
|
|
17
|
+
children: [
|
|
18
|
+
/*#__PURE__*/ _jsx(Popover.Trigger, {
|
|
19
|
+
asChild: true,
|
|
20
|
+
children: /*#__PURE__*/ _jsx("button", {
|
|
21
|
+
type: "button",
|
|
22
|
+
className: `${styles.trigger} ${className ?? ""}`,
|
|
23
|
+
"aria-label": label,
|
|
24
|
+
children: icon ?? /*#__PURE__*/ _jsx(QuestionCircleIcon, {})
|
|
25
|
+
})
|
|
26
|
+
}),
|
|
27
|
+
/*#__PURE__*/ _jsx(Popover.Portal, {
|
|
28
|
+
children: /*#__PURE__*/ _jsxs(Popover.Content, {
|
|
29
|
+
className: styles.content,
|
|
30
|
+
side: side,
|
|
31
|
+
sideOffset: sideOffset,
|
|
32
|
+
collisionPadding: 12,
|
|
33
|
+
children: [
|
|
34
|
+
content,
|
|
35
|
+
/*#__PURE__*/ _jsx(Popover.Arrow, {
|
|
36
|
+
className: styles.arrow
|
|
37
|
+
})
|
|
38
|
+
]
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
]
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=InfoPopover.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./InfoPopover";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
.trigger {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
padding: 0;
|
|
6
|
+
border: none;
|
|
7
|
+
background: none;
|
|
8
|
+
color: inherit;
|
|
9
|
+
line-height: 0;
|
|
10
|
+
// Cursor is chosen per usage (marker → pointer, `?` help icons → help) so it never loses to
|
|
11
|
+
// CSS-module source order.
|
|
12
|
+
|
|
13
|
+
&:focus-visible {
|
|
14
|
+
outline: 2px solid var(--theme-elevation-400);
|
|
15
|
+
outline-offset: 2px;
|
|
16
|
+
border-radius: 3px;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Same bubble as the Tooltip, so help text looks identical whether hovered (elsewhere) or tapped here.
|
|
21
|
+
.content {
|
|
22
|
+
max-width: 280px;
|
|
23
|
+
padding: 0.5rem 0.75rem;
|
|
24
|
+
border: 1px solid var(--theme-elevation-150);
|
|
25
|
+
border-radius: var(--style-radius-s);
|
|
26
|
+
background: var(--theme-elevation-50);
|
|
27
|
+
color: var(--theme-elevation-900);
|
|
28
|
+
font-size: 0.8125rem;
|
|
29
|
+
line-height: 1.4;
|
|
30
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
31
|
+
z-index: 9999;
|
|
32
|
+
animation: fadeIn 150ms ease-out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.arrow {
|
|
36
|
+
fill: var(--theme-elevation-50);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@keyframes fadeIn {
|
|
40
|
+
from {
|
|
41
|
+
opacity: 0;
|
|
42
|
+
transform: translateY(2px);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
to {
|
|
46
|
+
opacity: 1;
|
|
47
|
+
transform: translateY(0);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef } from "react";
|
|
2
|
+
import type { MultiSelectOption } from "./multiSelectModel";
|
|
3
|
+
export type { MultiSelectOption } from "./multiSelectModel";
|
|
4
|
+
/**
|
|
5
|
+
* Props mirror the single `Select` (`ComponentPropsWithRef<"button">` + the same custom `$`-prefixed
|
|
6
|
+
* knobs), except `value`/`onChange` are omitted from the button props and redefined for a multi
|
|
7
|
+
* selection (a native `<button>` has a `string` value + a form `onChange`, incompatible with these).
|
|
8
|
+
*/
|
|
9
|
+
type MultiSelectProps = Omit<ComponentPropsWithRef<"button">, "value" | "onChange" | "onBlur"> & {
|
|
10
|
+
/** Selected option values (controlled). */
|
|
11
|
+
value: string[];
|
|
12
|
+
/** Next selection whenever the user toggles an option or "select all". */
|
|
13
|
+
onChange: (value: string[]) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Fired when the popover CLOSES (the field is "done"), not when the trigger loses focus — opening
|
|
16
|
+
* the popover moves focus into it, and firing blur then would mark the field touched before the
|
|
17
|
+
* user has picked anything (premature `onTouched` validation).
|
|
18
|
+
*/
|
|
19
|
+
onBlur?: () => void;
|
|
20
|
+
options: MultiSelectOption[];
|
|
21
|
+
/** Shown on the trigger when nothing is selected. */
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
hasError?: boolean;
|
|
24
|
+
$size?: "sm" | "md" | "lg";
|
|
25
|
+
/** Stretch to fill the container (default). Set `false` to size to content. */
|
|
26
|
+
$fullWidth?: boolean;
|
|
27
|
+
/** Label for the "select all" row. */
|
|
28
|
+
selectAllLabel?: string;
|
|
29
|
+
/** Show a filter input in the popover once the option count exceeds this. */
|
|
30
|
+
filterThreshold?: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* A controlled multi-select whose collapsed trigger is a `<button>` styled identically to the single
|
|
34
|
+
* `Select` (same border, chevron, focus ring, error + disabled states, size ramp). The multiplicity
|
|
35
|
+
* lives in a checkbox popover with a "select all" row and a progressive filter — the trigger shows a
|
|
36
|
+
* compact summary (`de, fr +2` / `All (N)`) and never grows with the selection. All decision logic
|
|
37
|
+
* lives in `./multiSelectModel`; the popover width tracks the trigger and grows to its content.
|
|
38
|
+
*/
|
|
39
|
+
declare const MultiSelect: import("react").ForwardRefExoticComponent<Omit<MultiSelectProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
|
|
40
|
+
export default MultiSelect;
|