@raketa-cloud/admin 1.10.5 → 1.10.7
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 +93 -87
- 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/styles/application.css +4 -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
|
@@ -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",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* Keep dark utilities opt-in through a `.dark` ancestor, rather than the OS theme. */
|
|
4
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
5
|
+
|
|
2
6
|
@plugin "@tailwindcss/forms";
|
|
3
7
|
@plugin "@tailwindcss/typography";
|
|
4
8
|
@source "../**/*.{html,js,ejs}";
|
|
@@ -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>
|