@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.10.5",
3
+ "version": "1.10.6",
4
4
  "main": "src/index.js",
5
5
  "files": [
6
6
  "src/",
@@ -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,
@@ -28,6 +28,7 @@ export const ContentTasksDocument = gql`
28
28
  lokaliseWorkflow {
29
29
  id
30
30
  status
31
+ partial
31
32
  settings
32
33
  insertedAt
33
34
  updatedAt
@@ -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
- <div class="mb-4" id="due_date_wrapper">
57
- <label for="due_date" class="font-semibold hover:cursor-pointer body-3">Due date (UTC)</label>
58
- <input type="datetime-local" name="resource[workflow][due_date]" id="due_date" value="<%= resource.workflow?.due_date || "" %>" />
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 || []; %>