@raketa-cloud/admin 1.10.5 → 1.10.6
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/dist/application.css +20 -0
- package/package.json +1 -1
- package/src/cloud/Resources.js +39 -0
- package/src/cloud/documents/contentTasksDocuments.js +1 -0
- package/src/cloud/documents/resourcesDocuments.js +12 -0
- package/src/handlers/resources/PartialTranslationsHandler.js +279 -0
- package/src/routeResourceModule.js +11 -0
- package/src/tests/handlers/resources/PartialTranslationsHandler.test.js +279 -0
- package/src/tests/routeResourceModule.test.js +26 -0
- package/src/tests/views/due_date.test.js +121 -0
- package/src/tests/views/partial_translations.test.js +50 -0
- package/src/views/_shared/_content_tasks.ejs +14 -2
- package/src/views/resources/_shared/_due_date.ejs +71 -0
- package/src/views/resources/partial-translations/create_form.ejs +72 -0
- package/src/views/resources/translations/create_form.ejs +4 -82
package/dist/application.css
CHANGED
|
@@ -326,6 +326,9 @@
|
|
|
326
326
|
.z-50 {
|
|
327
327
|
z-index: 50;
|
|
328
328
|
}
|
|
329
|
+
.m-auto {
|
|
330
|
+
margin: auto;
|
|
331
|
+
}
|
|
329
332
|
.my-4 {
|
|
330
333
|
margin-block: calc(var(--spacing) * 4);
|
|
331
334
|
}
|
|
@@ -514,6 +517,9 @@
|
|
|
514
517
|
.flex-none {
|
|
515
518
|
flex: none;
|
|
516
519
|
}
|
|
520
|
+
.shrink-0 {
|
|
521
|
+
flex-shrink: 0;
|
|
522
|
+
}
|
|
517
523
|
.-translate-x-1\/2 {
|
|
518
524
|
--tw-translate-x: calc(calc(1 / 2 * 100%) * -1);
|
|
519
525
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
|
@@ -569,6 +575,9 @@
|
|
|
569
575
|
.justify-end {
|
|
570
576
|
justify-content: flex-end;
|
|
571
577
|
}
|
|
578
|
+
.gap-0\.5 {
|
|
579
|
+
gap: calc(var(--spacing) * 0.5);
|
|
580
|
+
}
|
|
572
581
|
.gap-1 {
|
|
573
582
|
gap: var(--spacing);
|
|
574
583
|
}
|
|
@@ -587,6 +596,11 @@
|
|
|
587
596
|
.gap-8 {
|
|
588
597
|
gap: calc(var(--spacing) * 8);
|
|
589
598
|
}
|
|
599
|
+
:where(.space-y-1 > :not(:last-child)) {
|
|
600
|
+
--tw-space-y-reverse: 0;
|
|
601
|
+
margin-block-start: calc(var(--spacing) * var(--tw-space-y-reverse));
|
|
602
|
+
margin-block-end: calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)));
|
|
603
|
+
}
|
|
590
604
|
:where(.divide-y > :not(:last-child)) {
|
|
591
605
|
--tw-divide-y-reverse: 0;
|
|
592
606
|
border-bottom-style: var(--tw-border-style);
|
|
@@ -2492,6 +2506,11 @@ body {
|
|
|
2492
2506
|
syntax: "*";
|
|
2493
2507
|
inherits: false;
|
|
2494
2508
|
}
|
|
2509
|
+
@property --tw-space-y-reverse {
|
|
2510
|
+
syntax: "*";
|
|
2511
|
+
inherits: false;
|
|
2512
|
+
initial-value: 0;
|
|
2513
|
+
}
|
|
2495
2514
|
@property --tw-divide-y-reverse {
|
|
2496
2515
|
syntax: "*";
|
|
2497
2516
|
inherits: false;
|
|
@@ -2724,6 +2743,7 @@ body {
|
|
|
2724
2743
|
--tw-rotate-z: initial;
|
|
2725
2744
|
--tw-skew-x: initial;
|
|
2726
2745
|
--tw-skew-y: initial;
|
|
2746
|
+
--tw-space-y-reverse: 0;
|
|
2727
2747
|
--tw-divide-y-reverse: 0;
|
|
2728
2748
|
--tw-border-style: solid;
|
|
2729
2749
|
--tw-gradient-position: initial;
|
package/package.json
CHANGED
package/src/cloud/Resources.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CreateResourceDocument,
|
|
8
8
|
DeleteResourceDocument,
|
|
9
9
|
MiniResourcesDocument,
|
|
10
|
+
MultiPartialTranslateResourceDocument,
|
|
10
11
|
MultiTranslateResourceDocument,
|
|
11
12
|
ResourceByIdDocument,
|
|
12
13
|
ResourceChangePreviewDocument,
|
|
@@ -38,6 +39,17 @@ export default class Resources extends RaketaClient {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
previewUrl(resource, locale) {
|
|
43
|
+
return buildMagicPreviewAddress(
|
|
44
|
+
{
|
|
45
|
+
slug: resource.slug,
|
|
46
|
+
locale,
|
|
47
|
+
resourceType: resource.resourceType,
|
|
48
|
+
},
|
|
49
|
+
this.getConfig(),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
visibilityOptions() {
|
|
42
54
|
return [
|
|
43
55
|
["private", "Private"],
|
|
@@ -213,6 +225,33 @@ export default class Resources extends RaketaClient {
|
|
|
213
225
|
return { resources, errors, localeErrors };
|
|
214
226
|
}
|
|
215
227
|
|
|
228
|
+
async multiPartialTranslate(
|
|
229
|
+
sourceResourceId,
|
|
230
|
+
locales,
|
|
231
|
+
widgets,
|
|
232
|
+
workflow,
|
|
233
|
+
resource,
|
|
234
|
+
) {
|
|
235
|
+
const input = {
|
|
236
|
+
sourceResourceId,
|
|
237
|
+
translations: locales.map((locale) => ({
|
|
238
|
+
locale,
|
|
239
|
+
widgets: JSON.stringify(widgets),
|
|
240
|
+
})),
|
|
241
|
+
};
|
|
242
|
+
this.appendWorkflow(input, workflow, {
|
|
243
|
+
slug: resource.slug,
|
|
244
|
+
locale: resource.locale,
|
|
245
|
+
resourceType: resource.resourceType,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const {
|
|
249
|
+
multiPartialTranslateResource: { contentTask, errors },
|
|
250
|
+
} = await this.query(MultiPartialTranslateResourceDocument, { input });
|
|
251
|
+
|
|
252
|
+
return { contentTask, errors };
|
|
253
|
+
}
|
|
254
|
+
|
|
216
255
|
async translateBatch(
|
|
217
256
|
sourceResourceId,
|
|
218
257
|
locales,
|
|
@@ -199,6 +199,17 @@ const MultiTranslateResourceDocument = gql`
|
|
|
199
199
|
}
|
|
200
200
|
`;
|
|
201
201
|
|
|
202
|
+
const MultiPartialTranslateResourceDocument = gql`
|
|
203
|
+
mutation MultiPartialTranslateResource(
|
|
204
|
+
$input: MultiPartialTranslateResourceInput!
|
|
205
|
+
) {
|
|
206
|
+
multiPartialTranslateResource(input: $input) {
|
|
207
|
+
contentTask ${contentTaskFragment()}
|
|
208
|
+
errors ${errorsFragment()}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
`;
|
|
212
|
+
|
|
202
213
|
const ResourceRevisionsDocument = gql`
|
|
203
214
|
query ResourceRevisions($input: IdTypeInput!) {
|
|
204
215
|
resourceRevisions(input: $input) {
|
|
@@ -351,6 +362,7 @@ export {
|
|
|
351
362
|
ResourceAndSiteDocument,
|
|
352
363
|
TranslateResourceDocument,
|
|
353
364
|
MultiTranslateResourceDocument,
|
|
365
|
+
MultiPartialTranslateResourceDocument,
|
|
354
366
|
ResourceRevisionDocument,
|
|
355
367
|
ResourceRevisionsDocument,
|
|
356
368
|
StartResourceRevisionDocument,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
const toArray = (value) =>
|
|
2
|
+
Array.isArray(value) ? value : [value].filter(Boolean);
|
|
3
|
+
|
|
4
|
+
const isActiveContentTask = (task) =>
|
|
5
|
+
task && !task.completedAt && !task.failedAt;
|
|
6
|
+
|
|
7
|
+
const hasActiveContentTask = (resource) =>
|
|
8
|
+
isActiveContentTask(resource.activeContentTask) ||
|
|
9
|
+
Object.values(resource.translations || {}).some((translation) =>
|
|
10
|
+
isActiveContentTask(translation.active_content_task),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const hasTranslations = (resource) =>
|
|
14
|
+
Object.values(resource.translations || {}).some(
|
|
15
|
+
(translation) => translation?.id,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const targetTranslationLocales = (resource, sourceLocale) =>
|
|
19
|
+
Object.entries(resource.translations || {})
|
|
20
|
+
.filter(
|
|
21
|
+
([locale, translation]) => locale !== sourceLocale && translation?.id,
|
|
22
|
+
)
|
|
23
|
+
.map(([locale]) => locale);
|
|
24
|
+
|
|
25
|
+
const selectedWidgetIds = (resource, query = {}) => {
|
|
26
|
+
const requested = Array.isArray(query.widget_ids)
|
|
27
|
+
? query.widget_ids
|
|
28
|
+
: String(query.widget_ids || "")
|
|
29
|
+
.split(",")
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
|
|
32
|
+
return [...new Set(requested)];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const partitionWidgetIds = (resource, widgetIds) => {
|
|
36
|
+
const resourceWidgetIds = new Set(
|
|
37
|
+
(resource.widgets || []).map((widget) => widget.widgetId).filter(Boolean),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
validWidgetIds: widgetIds.filter((widgetId) =>
|
|
42
|
+
resourceWidgetIds.has(widgetId),
|
|
43
|
+
),
|
|
44
|
+
unknownWidgetIds: widgetIds.filter(
|
|
45
|
+
(widgetId) => !resourceWidgetIds.has(widgetId),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const formAssigns = (resource, query, previewUrl, values = {}, errors = {}) => {
|
|
51
|
+
const submittedWidgetIds = values.widgetIds ?? values.widget_ids;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
resource,
|
|
55
|
+
previewUrl,
|
|
56
|
+
partialTranslation: {
|
|
57
|
+
locales: [],
|
|
58
|
+
workflow: {},
|
|
59
|
+
...values,
|
|
60
|
+
widgetIds:
|
|
61
|
+
submittedWidgetIds === undefined
|
|
62
|
+
? selectedWidgetIds(resource, query)
|
|
63
|
+
: toArray(submittedWidgetIds),
|
|
64
|
+
},
|
|
65
|
+
errors,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const validatePartialTranslation = ({
|
|
70
|
+
resource,
|
|
71
|
+
locales,
|
|
72
|
+
widgetIds,
|
|
73
|
+
workflow,
|
|
74
|
+
unknownWidgetIds,
|
|
75
|
+
unknownLocales,
|
|
76
|
+
}) => {
|
|
77
|
+
const errors = [];
|
|
78
|
+
|
|
79
|
+
if (locales.length === 0) {
|
|
80
|
+
errors.push({
|
|
81
|
+
key: "locales",
|
|
82
|
+
message: "select at least one existing translation",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (widgetIds.length === 0) {
|
|
86
|
+
errors.push({ key: "widgetIds", message: "can't be blank" });
|
|
87
|
+
}
|
|
88
|
+
if (unknownLocales.length > 0) {
|
|
89
|
+
errors.push({
|
|
90
|
+
key: "locales",
|
|
91
|
+
message: `not found in the resource translations: ${unknownLocales.join(", ")}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (!workflow.type) {
|
|
95
|
+
errors.push({ key: "workflow", message: "can't be blank" });
|
|
96
|
+
}
|
|
97
|
+
if (unknownWidgetIds.length > 0) {
|
|
98
|
+
errors.push({
|
|
99
|
+
key: "widgetIds",
|
|
100
|
+
message: `not found in the source resource: ${unknownWidgetIds.join(", ")}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (!hasTranslations(resource)) {
|
|
104
|
+
errors.push({
|
|
105
|
+
key: "generalError",
|
|
106
|
+
message:
|
|
107
|
+
"cannot start a partial translation without existing translations",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (hasActiveContentTask(resource)) {
|
|
111
|
+
errors.push({
|
|
112
|
+
key: "generalError",
|
|
113
|
+
message:
|
|
114
|
+
"cannot start a partial translation while a translation task is in progress",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return errors;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const create_form = async (ctx) => {
|
|
122
|
+
const { parent_id } = ctx.params;
|
|
123
|
+
const { resourceType } = ctx.resourceConfig;
|
|
124
|
+
const resource = await ctx.client.resources.find(
|
|
125
|
+
parent_id,
|
|
126
|
+
ctx.locale,
|
|
127
|
+
resourceType,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (!resource) {
|
|
131
|
+
throw new Error(`Cannot find ${resourceType} with id ${parent_id}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const widgetIds = selectedWidgetIds(resource, ctx.query);
|
|
135
|
+
const { validWidgetIds, unknownWidgetIds } = partitionWidgetIds(
|
|
136
|
+
resource,
|
|
137
|
+
widgetIds,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const previewUrl = ctx.client.resources.previewUrl(
|
|
141
|
+
{ ...resource, resourceType },
|
|
142
|
+
ctx.locale,
|
|
143
|
+
);
|
|
144
|
+
const validationErrors = [];
|
|
145
|
+
|
|
146
|
+
if (widgetIds.length === 0) {
|
|
147
|
+
validationErrors.push({ key: "widgetIds", message: "can't be blank" });
|
|
148
|
+
}
|
|
149
|
+
if (unknownWidgetIds.length > 0) {
|
|
150
|
+
validationErrors.push({
|
|
151
|
+
key: "widgetIds",
|
|
152
|
+
message: `not found in the source resource: ${unknownWidgetIds.join(", ")}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (!hasTranslations(resource)) {
|
|
156
|
+
validationErrors.push({
|
|
157
|
+
key: "generalError",
|
|
158
|
+
message:
|
|
159
|
+
"cannot start a partial translation without existing translations",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (hasActiveContentTask(resource)) {
|
|
163
|
+
validationErrors.push({
|
|
164
|
+
key: "generalError",
|
|
165
|
+
message:
|
|
166
|
+
"cannot start a partial translation while a translation task is in progress",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (validationErrors.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
assigns: formAssigns(
|
|
173
|
+
resource,
|
|
174
|
+
ctx.query,
|
|
175
|
+
previewUrl,
|
|
176
|
+
{ widgetIds: validWidgetIds },
|
|
177
|
+
{
|
|
178
|
+
partialTranslation:
|
|
179
|
+
ctx.client.resources.formatErrors(validationErrors),
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { assigns: formAssigns(resource, ctx.query, previewUrl) };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const create = async (ctx) => {
|
|
189
|
+
const { parent_id } = ctx.params;
|
|
190
|
+
const { resourceType, resourceTypePlural } = ctx.resourceConfig;
|
|
191
|
+
const resource = await ctx.client.resources.find(
|
|
192
|
+
parent_id,
|
|
193
|
+
ctx.locale,
|
|
194
|
+
resourceType,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!resource) {
|
|
198
|
+
throw new Error(`Cannot find ${resourceType} with id ${parent_id}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const previewUrl = ctx.client.resources.previewUrl(
|
|
202
|
+
{ ...resource, resourceType },
|
|
203
|
+
ctx.locale,
|
|
204
|
+
);
|
|
205
|
+
const values = ctx.resourceParams?.partial_translation || {};
|
|
206
|
+
const locales = toArray(values.locales);
|
|
207
|
+
const widgetIds =
|
|
208
|
+
values.widget_ids === undefined
|
|
209
|
+
? selectedWidgetIds({ widgets: [] }, ctx.query)
|
|
210
|
+
: toArray(values.widget_ids);
|
|
211
|
+
const workflow = values.workflow || {};
|
|
212
|
+
const { validWidgetIds, unknownWidgetIds } = partitionWidgetIds(
|
|
213
|
+
resource,
|
|
214
|
+
widgetIds,
|
|
215
|
+
);
|
|
216
|
+
const validLocales = targetTranslationLocales(resource, ctx.locale);
|
|
217
|
+
const unknownLocales = locales.filter(
|
|
218
|
+
(locale) => !validLocales.includes(locale),
|
|
219
|
+
);
|
|
220
|
+
const selectedValidLocales = locales.filter((locale) =>
|
|
221
|
+
validLocales.includes(locale),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const validationErrors = validatePartialTranslation({
|
|
225
|
+
resource,
|
|
226
|
+
locales,
|
|
227
|
+
widgetIds,
|
|
228
|
+
workflow,
|
|
229
|
+
unknownWidgetIds,
|
|
230
|
+
unknownLocales,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (validationErrors.length > 0) {
|
|
234
|
+
return {
|
|
235
|
+
assigns: formAssigns(
|
|
236
|
+
resource,
|
|
237
|
+
ctx.query,
|
|
238
|
+
previewUrl,
|
|
239
|
+
{
|
|
240
|
+
...values,
|
|
241
|
+
locales: selectedValidLocales,
|
|
242
|
+
widgetIds: validWidgetIds,
|
|
243
|
+
workflow,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
partialTranslation:
|
|
247
|
+
ctx.client.resources.formatErrors(validationErrors),
|
|
248
|
+
},
|
|
249
|
+
),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const widgets = (resource.widgets || []).filter((widget) =>
|
|
254
|
+
validWidgetIds.includes(widget.widgetId),
|
|
255
|
+
);
|
|
256
|
+
const { errors } = await ctx.client.resources.multiPartialTranslate(
|
|
257
|
+
parent_id,
|
|
258
|
+
locales,
|
|
259
|
+
widgets,
|
|
260
|
+
workflow,
|
|
261
|
+
{ ...resource, locale: ctx.locale, resourceType },
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (errors) {
|
|
265
|
+
return {
|
|
266
|
+
assigns: formAssigns(
|
|
267
|
+
resource,
|
|
268
|
+
ctx.query,
|
|
269
|
+
previewUrl,
|
|
270
|
+
{ ...values, locales, widgetIds: validWidgetIds, workflow },
|
|
271
|
+
{ partialTranslation: ctx.client.resources.formatErrors(errors) },
|
|
272
|
+
),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { redirect: `/${ctx.locale}/${resourceTypePlural}/${parent_id}` };
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export default { create_form, create };
|
|
@@ -15,6 +15,7 @@ import ClonesHandler from "./handlers/resources/ClonesHandler";
|
|
|
15
15
|
import DeletesHandler from "./handlers/resources/DeletesHandler";
|
|
16
16
|
import ExportsHandler from "./handlers/resources/ExportsHandler";
|
|
17
17
|
import HistoryHandler from "./handlers/resources/HistoryHandler";
|
|
18
|
+
import PartialTranslationsHandler from "./handlers/resources/PartialTranslationsHandler";
|
|
18
19
|
import ReportsAPIHandler from "./handlers/resources/ReportsAPIHandler";
|
|
19
20
|
import ResourcesAPIHandler from "./handlers/resources/ResourcesAPIHandler";
|
|
20
21
|
import ResourcesHandler from "./handlers/resources/ResourcesHandler";
|
|
@@ -93,6 +94,16 @@ function routeResourceModule(
|
|
|
93
94
|
{ ...resourceConfig, viewsFallback: "resources/translations" },
|
|
94
95
|
);
|
|
95
96
|
|
|
97
|
+
if (adapter.globalConfig?.features?.contentTasks) {
|
|
98
|
+
routeNestedCollection(
|
|
99
|
+
adapter,
|
|
100
|
+
baseResource,
|
|
101
|
+
"partial-translations",
|
|
102
|
+
PartialTranslationsHandler,
|
|
103
|
+
{ ...resourceConfig, viewsFallback: "resources/partial-translations" },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
routeNestedCollection(adapter, baseResource, "history", HistoryHandler, {
|
|
97
108
|
...resourceConfig,
|
|
98
109
|
viewsFallback: "resources/history",
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import PartialTranslationsHandler from "../../../handlers/resources/PartialTranslationsHandler";
|
|
2
|
+
|
|
3
|
+
const resource = {
|
|
4
|
+
id: "42",
|
|
5
|
+
slug: "hello",
|
|
6
|
+
title: "Hello",
|
|
7
|
+
translations: { fr: { id: "43" } },
|
|
8
|
+
widgets: [
|
|
9
|
+
{ widgetId: "hero-1", component: "Hero", settings: { title: "Hero" } },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const buildCtx = (params = {}) => ({
|
|
14
|
+
client: {
|
|
15
|
+
resources: {
|
|
16
|
+
find: jest.fn().mockResolvedValue(resource),
|
|
17
|
+
previewUrl: jest.fn().mockReturnValue("https://preview.example/hello"),
|
|
18
|
+
formatErrors: jest.fn((errors) =>
|
|
19
|
+
Object.fromEntries(errors.map((error) => [error.key, error.message])),
|
|
20
|
+
),
|
|
21
|
+
multiPartialTranslate: jest.fn().mockResolvedValue({ errors: null }),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
params: { parent_id: "42" },
|
|
25
|
+
query: {},
|
|
26
|
+
locale: "en",
|
|
27
|
+
resourceConfig: { resourceType: "page", resourceTypePlural: "pages" },
|
|
28
|
+
resourceParams: params,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("PartialTranslationsHandler", () => {
|
|
32
|
+
test("builds the form from the selected source widgets", async () => {
|
|
33
|
+
const ctx = buildCtx();
|
|
34
|
+
ctx.query = { widget_ids: "hero-1" };
|
|
35
|
+
|
|
36
|
+
const result = await PartialTranslationsHandler.create_form(ctx);
|
|
37
|
+
|
|
38
|
+
expect(result.assigns).toMatchObject({
|
|
39
|
+
resource,
|
|
40
|
+
previewUrl: "https://preview.example/hello",
|
|
41
|
+
partialTranslation: { widgetIds: ["hero-1"] },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test.each([{}, { widget_ids: "" }])(
|
|
46
|
+
"returns a validation error when selected widgets are missing",
|
|
47
|
+
async (query) => {
|
|
48
|
+
const ctx = buildCtx();
|
|
49
|
+
ctx.query = query;
|
|
50
|
+
|
|
51
|
+
const result = await PartialTranslationsHandler.create_form(ctx);
|
|
52
|
+
|
|
53
|
+
expect(result.assigns).toMatchObject({
|
|
54
|
+
partialTranslation: { widgetIds: [] },
|
|
55
|
+
errors: { partialTranslation: { widgetIds: "can't be blank" } },
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
test("returns an error and preserves valid widgets when a requested widget does not exist", async () => {
|
|
61
|
+
const ctx = buildCtx();
|
|
62
|
+
ctx.query = { widget_ids: "hero-1,missing-widget" };
|
|
63
|
+
|
|
64
|
+
const result = await PartialTranslationsHandler.create_form(ctx);
|
|
65
|
+
|
|
66
|
+
expect(result.assigns).toMatchObject({
|
|
67
|
+
partialTranslation: { widgetIds: ["hero-1"] },
|
|
68
|
+
errors: {
|
|
69
|
+
partialTranslation: {
|
|
70
|
+
widgetIds: "not found in the source resource: missing-widget",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns an error when the resource has no translations", async () => {
|
|
77
|
+
const ctx = buildCtx();
|
|
78
|
+
ctx.query = { widget_ids: "hero-1" };
|
|
79
|
+
ctx.client.resources.find.mockResolvedValue({
|
|
80
|
+
...resource,
|
|
81
|
+
translations: {},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await PartialTranslationsHandler.create_form(ctx);
|
|
85
|
+
|
|
86
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
87
|
+
generalError:
|
|
88
|
+
"cannot start a partial translation without existing translations",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("returns an error when the source has an active content task", async () => {
|
|
93
|
+
const ctx = buildCtx();
|
|
94
|
+
ctx.query = { widget_ids: "hero-1" };
|
|
95
|
+
ctx.client.resources.find.mockResolvedValue({
|
|
96
|
+
...resource,
|
|
97
|
+
activeContentTask: { workflow: "lokalise_tt_ai" },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = await PartialTranslationsHandler.create_form(ctx);
|
|
101
|
+
|
|
102
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
103
|
+
generalError:
|
|
104
|
+
"cannot start a partial translation while a translation task is in progress",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("starts a partial translation workflow", async () => {
|
|
109
|
+
const ctx = buildCtx({
|
|
110
|
+
partial_translation: {
|
|
111
|
+
locales: ["fr"],
|
|
112
|
+
widget_ids: ["hero-1"],
|
|
113
|
+
workflow: { type: "LOKALISE_TT_AI" },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
118
|
+
|
|
119
|
+
expect(ctx.client.resources.multiPartialTranslate).toHaveBeenCalledWith(
|
|
120
|
+
"42",
|
|
121
|
+
["fr"],
|
|
122
|
+
[
|
|
123
|
+
{
|
|
124
|
+
widgetId: "hero-1",
|
|
125
|
+
component: "Hero",
|
|
126
|
+
settings: { title: "Hero" },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
{ type: "LOKALISE_TT_AI" },
|
|
130
|
+
{ ...resource, locale: "en", resourceType: "page" },
|
|
131
|
+
);
|
|
132
|
+
expect(result).toEqual({ redirect: "/en/pages/42" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does not submit unknown widgets and keeps only valid widgets on the form", async () => {
|
|
136
|
+
const ctx = buildCtx({
|
|
137
|
+
partial_translation: {
|
|
138
|
+
locales: ["fr"],
|
|
139
|
+
widget_ids: ["hero-1", "missing-widget"],
|
|
140
|
+
workflow: { type: "LOKALISE_TT_AI" },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
145
|
+
|
|
146
|
+
expect(ctx.client.resources.multiPartialTranslate).not.toHaveBeenCalled();
|
|
147
|
+
expect(result.assigns.partialTranslation.widgetIds).toEqual(["hero-1"]);
|
|
148
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
149
|
+
widgetIds: "not found in the source resource: missing-widget",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test.each(["en", "stale-locale"])(
|
|
154
|
+
"does not submit locales outside the resource translations: %s",
|
|
155
|
+
async (locale) => {
|
|
156
|
+
const ctx = buildCtx({
|
|
157
|
+
partial_translation: {
|
|
158
|
+
locales: ["fr", locale],
|
|
159
|
+
widget_ids: ["hero-1"],
|
|
160
|
+
workflow: { type: "LOKALISE_TT_AI" },
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
165
|
+
|
|
166
|
+
expect(ctx.client.resources.multiPartialTranslate).not.toHaveBeenCalled();
|
|
167
|
+
expect(result.assigns.partialTranslation.locales).toEqual(["fr"]);
|
|
168
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
169
|
+
locales: `not found in the resource translations: ${locale}`,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
test("does not submit when the resource has no translations", async () => {
|
|
175
|
+
const ctx = buildCtx({
|
|
176
|
+
partial_translation: {
|
|
177
|
+
locales: ["fr"],
|
|
178
|
+
widget_ids: ["hero-1"],
|
|
179
|
+
workflow: { type: "LOKALISE_TT_AI" },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
ctx.client.resources.find.mockResolvedValue({
|
|
183
|
+
...resource,
|
|
184
|
+
translations: {},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
188
|
+
|
|
189
|
+
expect(ctx.client.resources.multiPartialTranslate).not.toHaveBeenCalled();
|
|
190
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
191
|
+
locales: "not found in the resource translations: fr",
|
|
192
|
+
generalError:
|
|
193
|
+
"cannot start a partial translation without existing translations",
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("does not submit when a translation has an active content task", async () => {
|
|
198
|
+
const ctx = buildCtx({
|
|
199
|
+
partial_translation: {
|
|
200
|
+
locales: ["fr"],
|
|
201
|
+
widget_ids: ["hero-1"],
|
|
202
|
+
workflow: { type: "LOKALISE_TT_AI" },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
ctx.client.resources.find.mockResolvedValue({
|
|
206
|
+
...resource,
|
|
207
|
+
translations: {
|
|
208
|
+
fr: {
|
|
209
|
+
id: "43",
|
|
210
|
+
active_content_task: { workflow: "lokalise_tt_ai" },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
216
|
+
|
|
217
|
+
expect(ctx.client.resources.multiPartialTranslate).not.toHaveBeenCalled();
|
|
218
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
219
|
+
generalError:
|
|
220
|
+
"cannot start a partial translation while a translation task is in progress",
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("preserves selected widget IDs when the API returns errors", async () => {
|
|
225
|
+
const ctx = buildCtx({
|
|
226
|
+
partial_translation: {
|
|
227
|
+
locales: ["fr"],
|
|
228
|
+
widget_ids: ["hero-1"],
|
|
229
|
+
workflow: { type: "LOKALISE_TT_AI" },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
ctx.client.resources.find.mockResolvedValue({
|
|
233
|
+
...resource,
|
|
234
|
+
widgets: [
|
|
235
|
+
...resource.widgets,
|
|
236
|
+
{ widgetId: "body-1", component: "Body", settings: {} },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
ctx.client.resources.multiPartialTranslate.mockResolvedValue({
|
|
240
|
+
errors: [{ key: "generalError", message: "translation failed" }],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
244
|
+
|
|
245
|
+
expect(result.assigns.partialTranslation.widgetIds).toEqual(["hero-1"]);
|
|
246
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
247
|
+
generalError: "translation failed",
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("preserves query widget IDs when the submitted body omits them", async () => {
|
|
252
|
+
const ctx = buildCtx({ partial_translation: {} });
|
|
253
|
+
ctx.query = { widget_ids: "hero-1" };
|
|
254
|
+
ctx.client.resources.find.mockResolvedValue({
|
|
255
|
+
...resource,
|
|
256
|
+
widgets: [
|
|
257
|
+
...resource.widgets,
|
|
258
|
+
{ widgetId: "body-1", component: "Body", settings: {} },
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
263
|
+
|
|
264
|
+
expect(result.assigns.partialTranslation.widgetIds).toEqual(["hero-1"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("returns validation errors without calling the API", async () => {
|
|
268
|
+
const ctx = buildCtx({ partial_translation: {} });
|
|
269
|
+
|
|
270
|
+
const result = await PartialTranslationsHandler.create(ctx);
|
|
271
|
+
|
|
272
|
+
expect(ctx.client.resources.multiPartialTranslate).not.toHaveBeenCalled();
|
|
273
|
+
expect(result.assigns.errors.partialTranslation).toEqual({
|
|
274
|
+
locales: "select at least one existing translation",
|
|
275
|
+
widgetIds: "can't be blank",
|
|
276
|
+
workflow: "can't be blank",
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -210,6 +210,32 @@ describe("routeResourceModule", () => {
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
test("registers partial translations only when content tasks are enabled", () => {
|
|
214
|
+
const enabledAdapter = makeAdapter({ features: { contentTasks: true } });
|
|
215
|
+
const disabledAdapter = makeAdapter({ features: { contentTasks: false } });
|
|
216
|
+
|
|
217
|
+
routeResourceModule(enabledAdapter, "article", "articles");
|
|
218
|
+
routeResourceModule(disabledAdapter, "article", "articles");
|
|
219
|
+
|
|
220
|
+
const routes = (adapter) =>
|
|
221
|
+
adapter.routeNestedCollection.mock.calls.flatMap(([mapping]) =>
|
|
222
|
+
mapping.map(([method, path]) => `${method} ${path}`),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(routes(enabledAdapter)).toEqual(
|
|
226
|
+
expect.arrayContaining([
|
|
227
|
+
"GET /articles/:parent_id/partial-translations/new",
|
|
228
|
+
"POST /articles/:parent_id/partial-translations",
|
|
229
|
+
]),
|
|
230
|
+
);
|
|
231
|
+
expect(routes(disabledAdapter)).not.toContain(
|
|
232
|
+
"GET /articles/:parent_id/partial-translations/new",
|
|
233
|
+
);
|
|
234
|
+
expect(routes(disabledAdapter)).not.toContain(
|
|
235
|
+
"POST /articles/:parent_id/partial-translations",
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
213
239
|
test("registers nested members for general, visuals, codes, meta, tags", () => {
|
|
214
240
|
const adapter = makeAdapter();
|
|
215
241
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import vm from "node:vm";
|
|
3
|
+
import ejs from "ejs";
|
|
4
|
+
|
|
5
|
+
const template = readFileSync(
|
|
6
|
+
`${process.cwd()}/src/views/resources/_shared/_due_date.ejs`,
|
|
7
|
+
"utf8",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
function renderDueDate(assigns) {
|
|
11
|
+
return ejs.render(template, assigns);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function setupDueDateScript({ workflow = "", dueDate = "" } = {}) {
|
|
15
|
+
const listeners = {};
|
|
16
|
+
const workflowSelect = {
|
|
17
|
+
value: workflow,
|
|
18
|
+
addEventListener: jest.fn((event, callback) => {
|
|
19
|
+
listeners.workflow = { event, callback };
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
const dueDateWrapper = { style: {} };
|
|
23
|
+
const dueDateInput = {
|
|
24
|
+
value: dueDate,
|
|
25
|
+
min: "",
|
|
26
|
+
validationMessage: "",
|
|
27
|
+
setCustomValidity(message) {
|
|
28
|
+
this.validationMessage = message;
|
|
29
|
+
},
|
|
30
|
+
addEventListener: jest.fn((event, callback) => {
|
|
31
|
+
listeners.dueDate = { event, callback };
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
const elements = {
|
|
35
|
+
workflow: workflowSelect,
|
|
36
|
+
due_date_wrapper: dueDateWrapper,
|
|
37
|
+
due_date: dueDateInput,
|
|
38
|
+
};
|
|
39
|
+
let loadCallback;
|
|
40
|
+
const window = {
|
|
41
|
+
addEventListener: jest.fn((event, callback) => {
|
|
42
|
+
if (event === "load") loadCallback = callback;
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
const document = {
|
|
46
|
+
getElementById: jest.fn((id) => elements[id]),
|
|
47
|
+
};
|
|
48
|
+
const rendered = renderDueDate({
|
|
49
|
+
dueDateField: "resource[workflow][due_date]",
|
|
50
|
+
workflow: { due_date: dueDate },
|
|
51
|
+
});
|
|
52
|
+
const script = rendered.match(/<script>([\s\S]*?)<\/script>/)[1];
|
|
53
|
+
|
|
54
|
+
vm.runInNewContext(script, { window, document, Date });
|
|
55
|
+
loadCallback();
|
|
56
|
+
|
|
57
|
+
return { workflowSelect, dueDateWrapper, dueDateInput, listeners };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("resources shared due date", () => {
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
jest.useRealTimers();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("renders the configured field name and submitted value", () => {
|
|
66
|
+
const standard = renderDueDate({
|
|
67
|
+
dueDateField: "resource[workflow][due_date]",
|
|
68
|
+
workflow: { due_date: "2026-10-19T12:30" },
|
|
69
|
+
});
|
|
70
|
+
const partial = renderDueDate({
|
|
71
|
+
dueDateField: "partial_translation[workflow][due_date]",
|
|
72
|
+
workflow: { due_date: "2026-10-20T09:15" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(standard).toContain('name="resource[workflow][due_date]"');
|
|
76
|
+
expect(standard).toContain('value="2026-10-19T12:30"');
|
|
77
|
+
expect(partial).toContain('name="partial_translation[workflow][due_date]"');
|
|
78
|
+
expect(partial).toContain('value="2026-10-20T09:15"');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("hides and clears the due date when no workflow is selected", () => {
|
|
82
|
+
const { dueDateWrapper, dueDateInput } = setupDueDateScript({
|
|
83
|
+
dueDate: "2026-10-19T12:30",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(dueDateWrapper.style.display).toBe("none");
|
|
87
|
+
expect(dueDateInput.value).toBe("");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("shows the due date and defaults it to the minimum weekday", () => {
|
|
91
|
+
jest.useFakeTimers().setSystemTime(new Date("2026-10-16T10:00:00"));
|
|
92
|
+
|
|
93
|
+
const { dueDateWrapper, dueDateInput } = setupDueDateScript({
|
|
94
|
+
workflow: "LOKALISE_TT_AI",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(dueDateWrapper.style.display).toBe("block");
|
|
98
|
+
expect(dueDateInput.value).toBe("2026-10-19T10:00");
|
|
99
|
+
expect(dueDateInput.min).toBe("2026-10-19T10:00");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("rejects weekend dates and accepts weekdays", () => {
|
|
103
|
+
const { dueDateInput, listeners } = setupDueDateScript({
|
|
104
|
+
workflow: "LOKALISE_TT_AI",
|
|
105
|
+
dueDate: "2026-10-19T12:30",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
dueDateInput.value = "2026-10-17T12:30";
|
|
109
|
+
listeners.dueDate.callback();
|
|
110
|
+
|
|
111
|
+
expect(dueDateInput.value).toBe("");
|
|
112
|
+
expect(dueDateInput.validationMessage).toBe(
|
|
113
|
+
"Weekends are not allowed. Please select a weekday.",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
dueDateInput.value = "2026-10-19T12:30";
|
|
117
|
+
listeners.dueDate.callback();
|
|
118
|
+
|
|
119
|
+
expect(dueDateInput.validationMessage).toBe("");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import ejs from "ejs";
|
|
3
|
+
|
|
4
|
+
const template = readFileSync(
|
|
5
|
+
`${process.cwd()}/src/views/resources/partial-translations/create_form.ejs`,
|
|
6
|
+
"utf8",
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function renderPartialTranslations(translations) {
|
|
10
|
+
return ejs.render(template, {
|
|
11
|
+
_context: {
|
|
12
|
+
locale: "en",
|
|
13
|
+
locales: { en: { name: "English" }, fr: { name: "French" } },
|
|
14
|
+
resourceConfig: { resourceTypePlural: "pages" },
|
|
15
|
+
},
|
|
16
|
+
content_tasks: { workflowsMap: (workflow) => workflow },
|
|
17
|
+
errors: {},
|
|
18
|
+
partialTranslation: { locales: [], widgetIds: ["hero-1"], workflow: {} },
|
|
19
|
+
previewUrl: "https://preview.example/hello",
|
|
20
|
+
render_partial: () => "",
|
|
21
|
+
resource: {
|
|
22
|
+
id: "42",
|
|
23
|
+
title: "Hello",
|
|
24
|
+
translations,
|
|
25
|
+
widgets: [{ widgetId: "hero-1", component: "Hero", settings: {} }],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("partial translations form", () => {
|
|
31
|
+
test("shows an empty-translations message when only the source locale exists", () => {
|
|
32
|
+
const rendered = renderPartialTranslations({ en: { id: "42" } });
|
|
33
|
+
|
|
34
|
+
expect(rendered).toContain("This resource has no translations.");
|
|
35
|
+
expect(rendered).not.toContain('value="en"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("renders only existing target translations", () => {
|
|
39
|
+
const rendered = renderPartialTranslations({
|
|
40
|
+
en: { id: "42" },
|
|
41
|
+
fr: { id: "43" },
|
|
42
|
+
de: {},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(rendered).toContain('value="fr"');
|
|
46
|
+
expect(rendered).not.toContain('value="en"');
|
|
47
|
+
expect(rendered).not.toContain('value="de"');
|
|
48
|
+
expect(rendered).not.toContain("This resource has no translations.");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -60,7 +60,19 @@
|
|
|
60
60
|
<% } %>
|
|
61
61
|
|
|
62
62
|
<td>
|
|
63
|
-
<span class="body-3">
|
|
63
|
+
<span class="body-3 flex items-center gap-0.5">
|
|
64
|
+
<% if (task.lokaliseWorkflow?.partial) { %>
|
|
65
|
+
<svg viewBox="0 0 20 20" class="size-3.5 shrink-0 text-raketa-primary" role="img" aria-label="Partial translation">
|
|
66
|
+
<title>Partial translation</title>
|
|
67
|
+
<path fill="currentColor" d="M10 10V2a8 8 0 1 1-8 8h8Z" />
|
|
68
|
+
</svg>
|
|
69
|
+
<% } else { %>
|
|
70
|
+
<svg viewBox="0 0 20 20" class="size-3.5 shrink-0 text-raketa-primary" role="img" aria-label="Full translation">
|
|
71
|
+
<title>Full translation</title>
|
|
72
|
+
<circle cx="10" cy="10" r="8" fill="currentColor" />
|
|
73
|
+
</svg>
|
|
74
|
+
<% } %>
|
|
75
|
+
|
|
64
76
|
<%= content_tasks.workflowsMap(task.workflow) %>
|
|
65
77
|
</span>
|
|
66
78
|
|
|
@@ -69,7 +81,7 @@
|
|
|
69
81
|
<summary class="cursor-pointer whitespace-nowrap" onclick="event.preventDefault(); this.parentElement.nextElementSibling.showModal()">Unattached keys</summary>
|
|
70
82
|
</details>
|
|
71
83
|
|
|
72
|
-
<dialog class="w-full max-w-2xl rounded-lg bg-raketa-background p-0 text-raketa-foreground shadow-xl backdrop:bg-black/50">
|
|
84
|
+
<dialog class="m-auto w-full max-w-2xl rounded-lg bg-raketa-background p-0 text-raketa-foreground shadow-xl backdrop:bg-black/50">
|
|
73
85
|
<div class="flex items-center justify-between border-b p-4 dark:border-neutral-700">
|
|
74
86
|
<h2 class="heading-3">Unattached keys</h2>
|
|
75
87
|
<form method="dialog">
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<div class="mb-4" id="due_date_wrapper">
|
|
2
|
+
<label for="due_date" class="font-semibold hover:cursor-pointer body-3">Due date (UTC)</label>
|
|
3
|
+
<input type="datetime-local" name="<%= dueDateField %>" id="due_date" value="<%= workflow?.due_date || "" %>" />
|
|
4
|
+
|
|
5
|
+
<p class="mt-1 text-sm text-raketa-foreground/50">
|
|
6
|
+
Select a due date for the translation. Weekends are not a valid choice.
|
|
7
|
+
</p>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<script>
|
|
11
|
+
window.addEventListener("load", () => {
|
|
12
|
+
const workflowSelect = document.getElementById("workflow");
|
|
13
|
+
const dueDateWrapper = document.getElementById("due_date_wrapper");
|
|
14
|
+
const dueDateInput = document.getElementById("due_date");
|
|
15
|
+
|
|
16
|
+
if (!workflowSelect || !dueDateWrapper || !dueDateInput) return;
|
|
17
|
+
|
|
18
|
+
function validateInputValue() {
|
|
19
|
+
if (dueDateInput.value === "") {
|
|
20
|
+
dueDateInput.setCustomValidity("");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const selectedDate = new Date(dueDateInput.value);
|
|
25
|
+
const day = selectedDate.getDay();
|
|
26
|
+
|
|
27
|
+
if (day === 0 || day === 6) {
|
|
28
|
+
dueDateInput.setCustomValidity("Weekends are not allowed. Please select a weekday.");
|
|
29
|
+
dueDateInput.value = "";
|
|
30
|
+
} else {
|
|
31
|
+
dueDateInput.setCustomValidity("");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getMinimumDueDate() {
|
|
36
|
+
const date = new Date();
|
|
37
|
+
date.setDate(date.getDate() + 3);
|
|
38
|
+
|
|
39
|
+
while (date.getDay() === 0 || date.getDay() === 6) {
|
|
40
|
+
date.setDate(date.getDate() + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const year = date.getFullYear();
|
|
44
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
45
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
46
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
47
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
48
|
+
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toggleDueDate() {
|
|
52
|
+
const minDate = getMinimumDueDate();
|
|
53
|
+
dueDateInput.min = minDate;
|
|
54
|
+
|
|
55
|
+
const isDefault = !workflowSelect.value;
|
|
56
|
+
dueDateWrapper.style.display = isDefault ? "none" : "block";
|
|
57
|
+
|
|
58
|
+
if (isDefault) {
|
|
59
|
+
dueDateInput.value = "";
|
|
60
|
+
} else if (!dueDateInput.value) {
|
|
61
|
+
dueDateInput.value = minDate;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
validateInputValue();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
dueDateInput.addEventListener("change", validateInputValue);
|
|
68
|
+
workflowSelect.addEventListener("change", toggleDueDate);
|
|
69
|
+
toggleDueDate();
|
|
70
|
+
});
|
|
71
|
+
</script>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<div class="page-wrapper">
|
|
2
|
+
<div class="page-title flex flex-col items-start">
|
|
3
|
+
<h1 class="heading-1">
|
|
4
|
+
<span class="opacity-50">Partially translate / </span><%= resource.title %>
|
|
5
|
+
</h1>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<% const targetTranslations = Object.entries(resource.translations || {}).filter(([locale, translation]) => locale !== _context.locale && translation?.id); %>
|
|
9
|
+
<div class="grid gap-4 md:grid-cols-3">
|
|
10
|
+
<form action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/partial-translations?widget_ids=${encodeURIComponent(partialTranslation.widgetIds.join(','))}` %>" method="post" class="form">
|
|
11
|
+
<div class="mb-4">
|
|
12
|
+
<h6>Current translations</h6>
|
|
13
|
+
<div class="checkbox-group !max-h-fit">
|
|
14
|
+
<% targetTranslations.forEach(([locale]) => { %>
|
|
15
|
+
<label>
|
|
16
|
+
<input type="checkbox" name="partial_translation[locales][]" value="<%= locale %>" <%= partialTranslation.locales.includes(locale) ? 'checked' : '' %>>
|
|
17
|
+
<%= _context.locales[locale]?.name || locale %>
|
|
18
|
+
</label>
|
|
19
|
+
<% }) %>
|
|
20
|
+
</div>
|
|
21
|
+
<% if (targetTranslations.length === 0) { %>
|
|
22
|
+
<p class="text-sm text-raketa-foreground/50">This resource has no translations.</p>
|
|
23
|
+
<% } %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="mb-4">
|
|
27
|
+
<label for="workflow" class="font-semibold body-3">Translation workflow</label>
|
|
28
|
+
<select name="partial_translation[workflow][type]" id="workflow" required>
|
|
29
|
+
<option value="">Select workflow</option>
|
|
30
|
+
<% ['LOKALISE_TT_AI_RT_HUM', 'LOKALISE_TT_HUM', 'LOKALISE_TT_AI'].forEach(workflow => { %>
|
|
31
|
+
<option value="<%= workflow %>" <%= partialTranslation.workflow?.type === workflow ? 'selected' : '' %>><%= content_tasks.workflowsMap(workflow.toLowerCase()) %></option>
|
|
32
|
+
<% }) %>
|
|
33
|
+
</select>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<%- render_partial('../_shared/_due_date.ejs', {
|
|
37
|
+
workflow: partialTranslation.workflow,
|
|
38
|
+
dueDateField: 'partial_translation[workflow][due_date]',
|
|
39
|
+
}) %>
|
|
40
|
+
|
|
41
|
+
<div class="mb-4">
|
|
42
|
+
<h6>Widgets selected for translation</h6>
|
|
43
|
+
<ul class="mt-2 space-y-1">
|
|
44
|
+
<% partialTranslation.widgetIds.forEach(widgetId => { const widget = (resource.widgets || []).find(item => item.widgetId === widgetId); %>
|
|
45
|
+
<li class="rounded border px-3 py-2 dark:border-neutral-500">
|
|
46
|
+
<input type="hidden" name="partial_translation[widget_ids][]" value="<%= widgetId %>">
|
|
47
|
+
<strong><%= widget?.component || 'Widget' %></strong>
|
|
48
|
+
<% if (widget?.settings?.title) { %> — <%= widget.settings.title %><% } %>
|
|
49
|
+
<span class="text-sm text-raketa-foreground/50">(<%= widgetId %>)</span>
|
|
50
|
+
</li>
|
|
51
|
+
<% }) %>
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="mb-4">
|
|
56
|
+
<h6>Preview of the original page</h6>
|
|
57
|
+
<a href="<%= previewUrl %>" target="_blank" rel="noopener noreferrer" class="btn-text">Open preview</a>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<% Object.entries(errors?.partialTranslation || {}).forEach(([key, error]) => { %>
|
|
61
|
+
<p class="mb-2 text-sm font-medium text-red-600"><%= key %>: <%= error %></p>
|
|
62
|
+
<% }) %>
|
|
63
|
+
|
|
64
|
+
<div class="form-actions">
|
|
65
|
+
<div class="btn-toolbar">
|
|
66
|
+
<button type="submit" class="btn-primary">Initiate partial translations</button>
|
|
67
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}` %>" class="btn-secondary">Cancel</a>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</form>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
@@ -53,88 +53,10 @@
|
|
|
53
53
|
</p>
|
|
54
54
|
</div>
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<p class="text-sm text-raketa-foreground/50 mt-1">
|
|
61
|
-
Select a due date for the translation. Weekends are not a valid choice.
|
|
62
|
-
</p>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<script>
|
|
66
|
-
window.addEventListener("load", (_event) => {
|
|
67
|
-
(function () {
|
|
68
|
-
const workflowSelect = document.getElementById("workflow");
|
|
69
|
-
const dueDateWrapper = document.getElementById("due_date_wrapper");
|
|
70
|
-
const dueDateInput = document.getElementById("due_date");
|
|
71
|
-
|
|
72
|
-
function validateInputValue(dInput) {
|
|
73
|
-
if (dInput.value === "") {
|
|
74
|
-
dInput.setCustomValidity('');
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const selectedDate = new Date(dInput.value);
|
|
79
|
-
const day = selectedDate.getDay();
|
|
80
|
-
|
|
81
|
-
if (day === 0 || day === 6) {
|
|
82
|
-
dInput.setCustomValidity('Weekends are not allowed. Please select a weekday.')
|
|
83
|
-
selectedDate.value = '';
|
|
84
|
-
} else {
|
|
85
|
-
dInput.setCustomValidity('');
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
dueDateInput.addEventListener('change', function(e) {
|
|
90
|
-
validateInputValue(dueDateInput);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
function getTwoDaysFromNowString() {
|
|
94
|
-
const date = new Date();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
date.setDate(date.getDate() + 3);
|
|
98
|
-
|
|
99
|
-
// Skip weekends.
|
|
100
|
-
while(date.getDay() === 0 || date.getDay() === 6) {
|
|
101
|
-
date.setDate(date.getDate() + 1);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const year = date.getFullYear();
|
|
105
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
106
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
107
|
-
const hours = String(date.getHours()).padStart(2, "0");
|
|
108
|
-
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
109
|
-
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function toggleDueDate() {
|
|
113
|
-
if (!workflowSelect || !dueDateWrapper || !dueDateInput) return;
|
|
114
|
-
const minDate = getTwoDaysFromNowString();
|
|
115
|
-
dueDateInput.min = minDate;
|
|
116
|
-
|
|
117
|
-
const isDefault = !workflowSelect.value;
|
|
118
|
-
dueDateWrapper.style.display = isDefault ? "none" : "block";
|
|
119
|
-
|
|
120
|
-
if (isDefault) {
|
|
121
|
-
dueDateInput.value = "";
|
|
122
|
-
} else if (!dueDateInput.value) {
|
|
123
|
-
dueDateInput.value = minDate;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Trigger the on change so hints are visible together with the validation.
|
|
127
|
-
const changeEvent = new Event('change', { bubbles: true });
|
|
128
|
-
dueDateInput.dispatchEvent(changeEvent);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (workflowSelect) {
|
|
132
|
-
workflowSelect.addEventListener("change", toggleDueDate);
|
|
133
|
-
toggleDueDate();
|
|
134
|
-
}
|
|
135
|
-
})();
|
|
136
|
-
})
|
|
137
|
-
</script>
|
|
56
|
+
<%- render_partial('../_shared/_due_date.ejs', {
|
|
57
|
+
workflow: resource.workflow,
|
|
58
|
+
dueDateField: 'resource[workflow][due_date]',
|
|
59
|
+
}) %>
|
|
138
60
|
<% } %>
|
|
139
61
|
|
|
140
62
|
<% const currentLocaleErrors = errors?.localeErrors || []; %>
|