@raketa-cloud/admin 0.0.57 → 0.0.58
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/package.json +1 -1
- package/src/handlers/pages/TranslationsHandler.js +131 -37
- package/src/routePagesModule.js +12 -12
- package/src/tests/handlers/pages/TranslationHandler.test/create.test.js +279 -0
- package/src/tests/handlers/pages/TranslationHandler.test/create_form.test.js +71 -0
- package/src/tests/handlers/pages/TranslationHandler.test/index.test.js +87 -0
- package/src/views/pages/show.ejs +15 -1
- package/src/views/pages/translations/create_form.ejs +60 -4
- package/src/views/pages/translations/index.ejs +54 -0
- package/src/views/pages/translations/_form.ejs +0 -44
package/package.json
CHANGED
|
@@ -1,21 +1,73 @@
|
|
|
1
|
+
const index = async (ctx) => {
|
|
2
|
+
const { parent_id } = ctx.params;
|
|
3
|
+
const { translations } = ctx.query;
|
|
4
|
+
let translatedPages = [];
|
|
5
|
+
|
|
6
|
+
if (translations) {
|
|
7
|
+
const translationPairs = translations.split(";").map((pair) => {
|
|
8
|
+
const [locale, id] = pair.split(":");
|
|
9
|
+
return { locale, id };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
translatedPages = await Promise.all(
|
|
13
|
+
translationPairs.map(async ({ locale, id }) => {
|
|
14
|
+
const page = await ctx.client.pages.find(id, locale);
|
|
15
|
+
|
|
16
|
+
if (!page) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Cannot find translated page with id ${id} and locale ${locale}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const revisions = await ctx.client.pageRevisions.all(id, locale);
|
|
23
|
+
const openRevision = revisions.list[0];
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...page,
|
|
27
|
+
translationLocale: locale,
|
|
28
|
+
revisionId: openRevision?.id,
|
|
29
|
+
};
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const items = await ctx.client.pages.list(
|
|
35
|
+
ctx.query,
|
|
36
|
+
ctx.locale,
|
|
37
|
+
ctx.resourceConfig.pageType
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const originalPage = await ctx.client.pages.find(parent_id, ctx.locale);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
assigns: {
|
|
44
|
+
items,
|
|
45
|
+
visibilityOptions: ctx.client.pages.visibilityOptions(),
|
|
46
|
+
translations: translatedPages,
|
|
47
|
+
parent_id,
|
|
48
|
+
pageTitle: originalPage.title,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
1
53
|
const create_form = async (ctx) => {
|
|
2
|
-
const { target_locale } = ctx.query;
|
|
3
|
-
const target_locate_tags = await ctx.client.tags.all(target_locale);
|
|
54
|
+
const { target_locale = "" } = ctx.query;
|
|
4
55
|
|
|
5
56
|
const { parent_id } = ctx.params;
|
|
6
57
|
const blueprint = await ctx.client.pages.find(parent_id, ctx.locale);
|
|
7
58
|
|
|
59
|
+
if (!blueprint) {
|
|
60
|
+
throw new Error(`Cannot find page with id ${parent_id}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
8
63
|
const resource = structuredClone(blueprint);
|
|
9
|
-
resource.tags = [];
|
|
10
64
|
|
|
11
65
|
return {
|
|
12
66
|
assigns: {
|
|
13
67
|
resource,
|
|
14
68
|
blueprint,
|
|
15
69
|
targetLocale: target_locale,
|
|
16
|
-
tags: target_locate_tags.list,
|
|
17
70
|
errors: {},
|
|
18
|
-
visibilityOptions: ctx.client.pages.visibilityOptions(),
|
|
19
71
|
_translations: [],
|
|
20
72
|
},
|
|
21
73
|
};
|
|
@@ -23,51 +75,93 @@ const create_form = async (ctx) => {
|
|
|
23
75
|
|
|
24
76
|
const create = async (ctx) => {
|
|
25
77
|
const { parent_id } = ctx.params;
|
|
26
|
-
const
|
|
78
|
+
const originalLocale = ctx.locale;
|
|
79
|
+
const blueprint = await ctx.client.pages.find(parent_id, originalLocale);
|
|
27
80
|
|
|
28
|
-
const
|
|
29
|
-
|
|
81
|
+
const formLanguages = Array.isArray(ctx.resourceParams.resource.languages)
|
|
82
|
+
? ctx.resourceParams.resource.languages
|
|
83
|
+
: [ctx.resourceParams.resource.languages].filter(Boolean);
|
|
30
84
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
widgets: blueprint.widgets,
|
|
37
|
-
categories: blueprint.categories.map((category) => category.id),
|
|
38
|
-
tags: [],
|
|
39
|
-
},
|
|
40
|
-
params
|
|
85
|
+
const existingTranslations = Object.keys(blueprint.translations || {});
|
|
86
|
+
const selectedLocales = formLanguages.filter(
|
|
87
|
+
(locale) =>
|
|
88
|
+
locale !== originalLocale && !existingTranslations.includes(locale)
|
|
41
89
|
);
|
|
42
90
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
91
|
+
const params = {
|
|
92
|
+
title: blueprint.title,
|
|
93
|
+
tags: [],
|
|
94
|
+
visibility: "private",
|
|
95
|
+
slug: blueprint.slug,
|
|
96
|
+
};
|
|
48
97
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
blueprint,
|
|
56
|
-
|
|
57
|
-
tags: tags.list,
|
|
58
|
-
errors: { resource: ctx.client.pages.formatErrors(errors) },
|
|
59
|
-
visibilityOptions: ctx.client.pages.visibilityOptions(),
|
|
60
|
-
_translations: [],
|
|
98
|
+
const translationIds = [];
|
|
99
|
+
|
|
100
|
+
for (const locale of selectedLocales) {
|
|
101
|
+
const resource = ctx.client.pages.deepMerge(
|
|
102
|
+
{
|
|
103
|
+
settings: blueprint.settings,
|
|
104
|
+
widgets: blueprint.widgets,
|
|
105
|
+
categories: blueprint.categories.map((category) => category.id),
|
|
61
106
|
},
|
|
62
|
-
|
|
107
|
+
params
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const { errors, page } = await ctx.client.pages.translate(
|
|
111
|
+
parent_id,
|
|
112
|
+
locale,
|
|
113
|
+
resource
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (errors) {
|
|
117
|
+
return {
|
|
118
|
+
assigns: {
|
|
119
|
+
resource: {
|
|
120
|
+
...resource,
|
|
121
|
+
translations: blueprint.translations || {},
|
|
122
|
+
languages: formLanguages,
|
|
123
|
+
},
|
|
124
|
+
blueprint,
|
|
125
|
+
targetLocale: locale,
|
|
126
|
+
errors: { resource: ctx.client.pages.formatErrors(errors) },
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (page) {
|
|
132
|
+
translationIds.push(`${locale}:${page.id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { errors: revisionErrors } = await ctx.client.pageRevisions.create(
|
|
136
|
+
page.id,
|
|
137
|
+
locale
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (revisionErrors) {
|
|
141
|
+
return {
|
|
142
|
+
assigns: {
|
|
143
|
+
resource: {
|
|
144
|
+
...resource,
|
|
145
|
+
translations: blueprint.translations || {},
|
|
146
|
+
languages: formLanguages,
|
|
147
|
+
},
|
|
148
|
+
blueprint,
|
|
149
|
+
targetLocale: locale,
|
|
150
|
+
errors: {
|
|
151
|
+
resource: ctx.client.pages.formatErrors(revisionErrors),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
63
156
|
}
|
|
64
157
|
|
|
65
158
|
return {
|
|
66
|
-
redirect: `/${
|
|
159
|
+
redirect: `/${originalLocale}/${ctx.resourceConfig.pageTypePlural}/${parent_id}/translations?translations=${translationIds.join(";")}`,
|
|
67
160
|
};
|
|
68
161
|
};
|
|
69
162
|
|
|
70
163
|
export default {
|
|
164
|
+
index,
|
|
71
165
|
create_form,
|
|
72
166
|
create,
|
|
73
167
|
};
|
package/src/routePagesModule.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import routeCollection from "./routeCollection";
|
|
2
|
-
import routeNestedCollection from "./routeNestedCollection";
|
|
3
|
-
import routeNestedMember from "./routeNestedMember";
|
|
4
|
-
import TranslationsHandler from "./handlers/pages/TranslationsHandler";
|
|
5
|
-
import PageRevisionsHandler from "./handlers/page_revisions/PageRevisionsHandler";
|
|
6
|
-
import GeneralHandler from "./handlers/page_revisions/GeneralHandler";
|
|
7
|
-
import VisualHandler from "./handlers/page_revisions/VisualHandler";
|
|
8
1
|
import CodeHandler from "./handlers/page_revisions/CodeHandler";
|
|
2
|
+
import GeneralHandler from "./handlers/page_revisions/GeneralHandler";
|
|
3
|
+
import LocksHandler from "./handlers/page_revisions/LocksHandler";
|
|
9
4
|
import MetaHandler from "./handlers/page_revisions/MetaHandler";
|
|
5
|
+
import PageRevisionsHandler from "./handlers/page_revisions/PageRevisionsHandler";
|
|
6
|
+
import PublishesHandler from "./handlers/page_revisions/PublishesHandler";
|
|
7
|
+
import RevertsHandler from "./handlers/page_revisions/RevertsHandler";
|
|
10
8
|
import TagHandler from "./handlers/page_revisions/TagHandler";
|
|
11
|
-
import LocksHandler from "./handlers/page_revisions/LocksHandler";
|
|
12
9
|
import UnlocksHandler from "./handlers/page_revisions/UnlocksHandler";
|
|
10
|
+
import VisualHandler from "./handlers/page_revisions/VisualHandler";
|
|
11
|
+
import WidgetHandler from "./handlers/page_revisions/WidgetHandler";
|
|
13
12
|
import ClonesHandler from "./handlers/pages/ClonesHandler";
|
|
14
|
-
import URLsHandler from "./handlers/pages/URLsHandler";
|
|
15
13
|
import DeletesHandler from "./handlers/pages/DeletesHandler";
|
|
16
|
-
import RevertsHandler from "./handlers/page_revisions/RevertsHandler";
|
|
17
|
-
import PublishesHandler from "./handlers/page_revisions/PublishesHandler";
|
|
18
|
-
import WidgetHandler from "./handlers/page_revisions/WidgetHandler";
|
|
19
14
|
import PagesHandler from "./handlers/pages/PagesHandler";
|
|
15
|
+
import TranslationsHandler from "./handlers/pages/TranslationsHandler";
|
|
16
|
+
import URLsHandler from "./handlers/pages/URLsHandler";
|
|
17
|
+
import routeCollection from "./routeCollection";
|
|
18
|
+
import routeNestedCollection from "./routeNestedCollection";
|
|
19
|
+
import routeNestedMember from "./routeNestedMember";
|
|
20
20
|
|
|
21
21
|
function routePagesModule(
|
|
22
22
|
adapter,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import PageRevisions from "../../../../cloud/PageRevisions";
|
|
2
|
+
import Pages from "../../../../cloud/Pages";
|
|
3
|
+
import TranslationsHandler from "../../../../handlers/pages/TranslationsHandler";
|
|
4
|
+
import Factory from "../../../support/factory";
|
|
5
|
+
|
|
6
|
+
function buildCtx(params, page) {
|
|
7
|
+
return {
|
|
8
|
+
client: {
|
|
9
|
+
pages: new Pages(() => ({}), "token"),
|
|
10
|
+
pageRevisions: new PageRevisions(() => ({}), "token"),
|
|
11
|
+
},
|
|
12
|
+
params: { parent_id: page.id },
|
|
13
|
+
resourceParams: {
|
|
14
|
+
resource: params,
|
|
15
|
+
},
|
|
16
|
+
locale: "en",
|
|
17
|
+
resourceConfig: {
|
|
18
|
+
pageType: "page",
|
|
19
|
+
pageTypePlural: "pages",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("Translation Handler", () => {
|
|
25
|
+
test("create fails with translation errors", async () => {
|
|
26
|
+
const page = Factory.build("page", {
|
|
27
|
+
translations: {},
|
|
28
|
+
categories: [{ id: "cat1" }],
|
|
29
|
+
});
|
|
30
|
+
const params = {
|
|
31
|
+
languages: ["fr"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ctx = buildCtx(params, page);
|
|
35
|
+
|
|
36
|
+
const mockFind = jest
|
|
37
|
+
.spyOn(ctx.client.pages, "find")
|
|
38
|
+
.mockImplementation(() => page);
|
|
39
|
+
|
|
40
|
+
const translationErrors = [{ key: "title", message: "cannot be blank" }];
|
|
41
|
+
const mockTranslate = jest
|
|
42
|
+
.spyOn(ctx.client.pages, "translate")
|
|
43
|
+
.mockImplementation(() => ({
|
|
44
|
+
page: null,
|
|
45
|
+
errors: translationErrors,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const mockFormatErrors = jest
|
|
49
|
+
.spyOn(ctx.client.pages, "formatErrors")
|
|
50
|
+
.mockImplementation(() => ({ title: "cannot be blank" }));
|
|
51
|
+
|
|
52
|
+
const result = await TranslationsHandler.create(ctx);
|
|
53
|
+
|
|
54
|
+
expect(mockFind).toHaveBeenCalledWith(page.id, "en");
|
|
55
|
+
expect(mockTranslate).toHaveBeenCalled();
|
|
56
|
+
expect(mockFormatErrors).toHaveBeenCalledWith(translationErrors);
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
assigns: {
|
|
60
|
+
resource: {
|
|
61
|
+
title: page.title,
|
|
62
|
+
tags: [],
|
|
63
|
+
visibility: "private",
|
|
64
|
+
slug: page.slug,
|
|
65
|
+
settings: page.settings,
|
|
66
|
+
widgets: page.widgets,
|
|
67
|
+
categories: ["cat1"],
|
|
68
|
+
translations: {},
|
|
69
|
+
languages: ["fr"],
|
|
70
|
+
},
|
|
71
|
+
blueprint: page,
|
|
72
|
+
targetLocale: "fr",
|
|
73
|
+
errors: { resource: { title: "cannot be blank" } },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("create successfully multiple translations with already one is existing", async () => {
|
|
79
|
+
const page = Factory.build("page", {
|
|
80
|
+
translations: { de: "de-id" },
|
|
81
|
+
categories: [{ id: "cat1" }],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const params = {
|
|
85
|
+
languages: ["fr", "es", "de"],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const ctx = buildCtx(params, page);
|
|
89
|
+
|
|
90
|
+
const mockFind = jest
|
|
91
|
+
.spyOn(ctx.client.pages, "find")
|
|
92
|
+
.mockImplementation(() => page);
|
|
93
|
+
|
|
94
|
+
const mockTranslate = jest
|
|
95
|
+
.spyOn(ctx.client.pages, "translate")
|
|
96
|
+
.mockImplementation((parentId, locale) => ({
|
|
97
|
+
page: { id: `translated-page-${locale}` },
|
|
98
|
+
errors: null,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const mockCreateRevision = jest
|
|
102
|
+
.spyOn(ctx.client.pageRevisions, "create")
|
|
103
|
+
.mockImplementation(() => ({ errors: null }));
|
|
104
|
+
|
|
105
|
+
const result = await TranslationsHandler.create(ctx);
|
|
106
|
+
|
|
107
|
+
expect(mockFind).toHaveBeenCalledWith(page.id, "en");
|
|
108
|
+
|
|
109
|
+
expect(mockTranslate).toHaveBeenCalledTimes(2);
|
|
110
|
+
expect(mockTranslate).toHaveBeenCalledWith(
|
|
111
|
+
page.id,
|
|
112
|
+
"fr",
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
title: page.title,
|
|
115
|
+
tags: [],
|
|
116
|
+
visibility: "private",
|
|
117
|
+
slug: page.slug,
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
expect(mockTranslate).toHaveBeenCalledWith(
|
|
121
|
+
page.id,
|
|
122
|
+
"es",
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
title: page.title,
|
|
125
|
+
tags: [],
|
|
126
|
+
visibility: "private",
|
|
127
|
+
slug: page.slug,
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(mockCreateRevision).toHaveBeenCalledTimes(2);
|
|
132
|
+
expect(mockCreateRevision).toHaveBeenCalledWith("translated-page-fr", "fr");
|
|
133
|
+
expect(mockCreateRevision).toHaveBeenCalledWith("translated-page-es", "es");
|
|
134
|
+
|
|
135
|
+
expect(result).toEqual({
|
|
136
|
+
redirect: `/en/pages/${page.id}/translations?translations=fr:translated-page-fr;es:translated-page-es`,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("create translations fails if any translation fails", async () => {
|
|
141
|
+
const page = Factory.build("page", {
|
|
142
|
+
translations: {},
|
|
143
|
+
categories: [{ id: "some-category-uno" }],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const params = {
|
|
147
|
+
languages: ["fr", "es"],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const ctx = buildCtx(params, page);
|
|
151
|
+
|
|
152
|
+
const mockFind = jest
|
|
153
|
+
.spyOn(ctx.client.pages, "find")
|
|
154
|
+
.mockImplementation(() => page);
|
|
155
|
+
|
|
156
|
+
const translationResponses = {
|
|
157
|
+
fr: { page: { id: "translated-page-fr" }, errors: null },
|
|
158
|
+
es: {
|
|
159
|
+
page: null,
|
|
160
|
+
errors: [{ key: "title", message: "invalid for spanish" }],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const mockTranslate = jest
|
|
165
|
+
.spyOn(ctx.client.pages, "translate")
|
|
166
|
+
.mockImplementation((parentId, locale) => translationResponses[locale]);
|
|
167
|
+
|
|
168
|
+
const mockFormatErrors = jest
|
|
169
|
+
.spyOn(ctx.client.pages, "formatErrors")
|
|
170
|
+
.mockImplementation(() => ({
|
|
171
|
+
title: "invalid for spanish",
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
const mockCreateRevision = jest
|
|
175
|
+
.spyOn(ctx.client.pageRevisions, "create")
|
|
176
|
+
.mockImplementation(() => ({ errors: null }));
|
|
177
|
+
|
|
178
|
+
const result = await TranslationsHandler.create(ctx);
|
|
179
|
+
|
|
180
|
+
expect(mockFind).toHaveBeenCalledWith(page.id, "en");
|
|
181
|
+
|
|
182
|
+
expect(mockTranslate).toHaveBeenCalledWith(
|
|
183
|
+
page.id,
|
|
184
|
+
"fr",
|
|
185
|
+
expect.objectContaining({
|
|
186
|
+
title: page.title,
|
|
187
|
+
tags: [],
|
|
188
|
+
visibility: "private",
|
|
189
|
+
slug: page.slug,
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(mockTranslate).toHaveBeenCalledWith(
|
|
194
|
+
page.id,
|
|
195
|
+
"es",
|
|
196
|
+
expect.objectContaining({
|
|
197
|
+
title: page.title,
|
|
198
|
+
tags: [],
|
|
199
|
+
visibility: "private",
|
|
200
|
+
slug: page.slug,
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result).toEqual({
|
|
205
|
+
assigns: {
|
|
206
|
+
resource: {
|
|
207
|
+
title: page.title,
|
|
208
|
+
tags: [],
|
|
209
|
+
visibility: "private",
|
|
210
|
+
slug: page.slug,
|
|
211
|
+
settings: page.settings,
|
|
212
|
+
widgets: page.widgets,
|
|
213
|
+
categories: ["some-category-uno"],
|
|
214
|
+
translations: {},
|
|
215
|
+
languages: ["fr", "es"],
|
|
216
|
+
},
|
|
217
|
+
blueprint: page,
|
|
218
|
+
targetLocale: "es",
|
|
219
|
+
errors: { resource: { title: "invalid for spanish" } },
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("create translation fails with revision errors", async () => {
|
|
225
|
+
const page = Factory.build("page", {
|
|
226
|
+
translations: {},
|
|
227
|
+
categories: [{ id: "cat1" }],
|
|
228
|
+
});
|
|
229
|
+
const params = {
|
|
230
|
+
languages: ["fr"],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const ctx = buildCtx(params, page);
|
|
234
|
+
|
|
235
|
+
const mockFind = jest
|
|
236
|
+
.spyOn(ctx.client.pages, "find")
|
|
237
|
+
.mockImplementation(() => page);
|
|
238
|
+
|
|
239
|
+
const mockTranslate = jest
|
|
240
|
+
.spyOn(ctx.client.pages, "translate")
|
|
241
|
+
.mockImplementation(() => ({
|
|
242
|
+
page: { id: "translated-page-id" },
|
|
243
|
+
errors: null,
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
const revisionErrors = [{ key: "base", message: "something went wrong" }];
|
|
247
|
+
const mockCreateRevision = jest
|
|
248
|
+
.spyOn(ctx.client.pageRevisions, "create")
|
|
249
|
+
.mockImplementation(() => ({ errors: revisionErrors }));
|
|
250
|
+
|
|
251
|
+
const mockFormatErrors = jest
|
|
252
|
+
.spyOn(ctx.client.pages, "formatErrors")
|
|
253
|
+
.mockImplementation(() => ({ base: "something went wrong" }));
|
|
254
|
+
|
|
255
|
+
const result = await TranslationsHandler.create(ctx);
|
|
256
|
+
|
|
257
|
+
expect(mockCreateRevision).toHaveBeenCalledWith("translated-page-id", "fr");
|
|
258
|
+
expect(mockFormatErrors).toHaveBeenCalledWith(revisionErrors);
|
|
259
|
+
|
|
260
|
+
expect(result).toEqual({
|
|
261
|
+
assigns: {
|
|
262
|
+
resource: {
|
|
263
|
+
title: page.title,
|
|
264
|
+
tags: [],
|
|
265
|
+
visibility: "private",
|
|
266
|
+
slug: page.slug,
|
|
267
|
+
settings: page.settings,
|
|
268
|
+
widgets: page.widgets,
|
|
269
|
+
categories: ["cat1"],
|
|
270
|
+
translations: {},
|
|
271
|
+
languages: ["fr"],
|
|
272
|
+
},
|
|
273
|
+
blueprint: page,
|
|
274
|
+
targetLocale: "fr",
|
|
275
|
+
errors: { resource: { base: "something went wrong" } },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import Pages from "../../../../cloud/Pages";
|
|
2
|
+
import TranslationsHandler from "../../../../handlers/pages/TranslationsHandler";
|
|
3
|
+
import Factory from "../../../support/factory";
|
|
4
|
+
|
|
5
|
+
function buildCtx(page) {
|
|
6
|
+
return {
|
|
7
|
+
client: {
|
|
8
|
+
pages: new Pages(() => ({}), "token"),
|
|
9
|
+
},
|
|
10
|
+
params: { parent_id: page.id },
|
|
11
|
+
query: { target_locale: "" },
|
|
12
|
+
locale: "en",
|
|
13
|
+
resourceConfig: {
|
|
14
|
+
pageType: "page",
|
|
15
|
+
pageTypePlural: "pages",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("Page translation form", () => {
|
|
21
|
+
test("create_form when a page exists with target locale", async () => {
|
|
22
|
+
const page = Factory.build("page");
|
|
23
|
+
const ctx = buildCtx(page);
|
|
24
|
+
ctx.query.target_locale = "fr";
|
|
25
|
+
|
|
26
|
+
const mockFindPage = jest
|
|
27
|
+
.spyOn(ctx.client.pages, "find")
|
|
28
|
+
.mockImplementation(() => page);
|
|
29
|
+
|
|
30
|
+
const result = await TranslationsHandler.create_form(ctx);
|
|
31
|
+
|
|
32
|
+
expect(mockFindPage).toHaveBeenCalledWith(page.id, "en");
|
|
33
|
+
|
|
34
|
+
const expectedResource = structuredClone(page);
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
assigns: {
|
|
38
|
+
resource: expectedResource,
|
|
39
|
+
blueprint: page,
|
|
40
|
+
targetLocale: "fr",
|
|
41
|
+
errors: {},
|
|
42
|
+
_translations: [],
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("create_form when a page exists without target locale", async () => {
|
|
48
|
+
const page = Factory.build("page");
|
|
49
|
+
const ctx = buildCtx(page);
|
|
50
|
+
|
|
51
|
+
const mockFindPage = jest
|
|
52
|
+
.spyOn(ctx.client.pages, "find")
|
|
53
|
+
.mockImplementation(() => page);
|
|
54
|
+
|
|
55
|
+
const result = await TranslationsHandler.create_form(ctx);
|
|
56
|
+
|
|
57
|
+
expect(mockFindPage).toHaveBeenCalledWith(page.id, "en");
|
|
58
|
+
|
|
59
|
+
const expectedResource = structuredClone(page);
|
|
60
|
+
|
|
61
|
+
expect(result).toEqual({
|
|
62
|
+
assigns: {
|
|
63
|
+
resource: expectedResource,
|
|
64
|
+
blueprint: page,
|
|
65
|
+
targetLocale: "",
|
|
66
|
+
errors: {},
|
|
67
|
+
_translations: [],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import PageRevisions from "../../../../cloud/PageRevisions";
|
|
2
|
+
import Pages from "../../../../cloud/Pages";
|
|
3
|
+
import TranslationsHandler from "../../../../handlers/pages/TranslationsHandler";
|
|
4
|
+
import Factory from "../../../support/factory";
|
|
5
|
+
|
|
6
|
+
describe("Translations Handler", () => {
|
|
7
|
+
function buildCtx() {
|
|
8
|
+
const originalPage = Factory.build("page", {
|
|
9
|
+
id: "original-page-id",
|
|
10
|
+
title: "Test Page Title",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
client: {
|
|
15
|
+
pages: new Pages(() => ({}), "token"),
|
|
16
|
+
pageRevisions: new PageRevisions(() => ({}), "token"),
|
|
17
|
+
},
|
|
18
|
+
params: { parent_id: originalPage.id },
|
|
19
|
+
query: { translations: "fr:fr-page-id;es:es-page-id" },
|
|
20
|
+
locale: "en",
|
|
21
|
+
resourceConfig: {
|
|
22
|
+
pageType: "page",
|
|
23
|
+
pageTypePlural: "pages",
|
|
24
|
+
},
|
|
25
|
+
originalPage,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("index", async () => {
|
|
30
|
+
const frPage = Factory.build("page", { id: "fr-page-id" });
|
|
31
|
+
const esPage = Factory.build("page", { id: "es-page-id" });
|
|
32
|
+
const esRevision = Factory.build("page_revision", { id: "es-revision-id" });
|
|
33
|
+
const frRevision = Factory.build("page_revision", { id: "fr-revision-id" });
|
|
34
|
+
const ctx = buildCtx();
|
|
35
|
+
|
|
36
|
+
const mockedFind = jest
|
|
37
|
+
.spyOn(ctx.client.pages, "find")
|
|
38
|
+
.mockImplementation((id, locale) => {
|
|
39
|
+
if (locale === "fr") return frPage;
|
|
40
|
+
if (locale === "es") return esPage;
|
|
41
|
+
if (id === "original-page-id") return ctx.originalPage;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const mockedRevisions = jest
|
|
45
|
+
.spyOn(ctx.client.pageRevisions, "all")
|
|
46
|
+
.mockImplementation((id, locale) => ({
|
|
47
|
+
list: locale === "fr" ? [frRevision] : [esRevision],
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const mockedList = jest
|
|
51
|
+
.spyOn(ctx.client.pages, "list")
|
|
52
|
+
.mockImplementation(() => ({}));
|
|
53
|
+
|
|
54
|
+
const result = await TranslationsHandler.index(ctx);
|
|
55
|
+
|
|
56
|
+
expect(mockedFind).toHaveBeenCalledWith("fr-page-id", "fr");
|
|
57
|
+
expect(mockedFind).toHaveBeenCalledWith("es-page-id", "es");
|
|
58
|
+
expect(mockedRevisions).toHaveBeenCalledWith("fr-page-id", "fr");
|
|
59
|
+
expect(mockedRevisions).toHaveBeenCalledWith("es-page-id", "es");
|
|
60
|
+
expect(mockedList).toHaveBeenCalledWith(
|
|
61
|
+
{ translations: "fr:fr-page-id;es:es-page-id" },
|
|
62
|
+
"en",
|
|
63
|
+
"page"
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
assigns: {
|
|
68
|
+
items: {},
|
|
69
|
+
visibilityOptions: ctx.client.pages.visibilityOptions(),
|
|
70
|
+
translations: [
|
|
71
|
+
{
|
|
72
|
+
...frPage,
|
|
73
|
+
translationLocale: "fr",
|
|
74
|
+
revisionId: "fr-revision-id",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
...esPage,
|
|
78
|
+
translationLocale: "es",
|
|
79
|
+
revisionId: "es-revision-id",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
parent_id: "original-page-id",
|
|
83
|
+
pageTitle: "Test Page Title",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/views/pages/show.ejs
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
7
|
<div class="flex flex-wrap items-center gap-1 mb-6 -mt-4">
|
|
8
|
+
<%
|
|
9
|
+
const availableLocales = Object.keys(_context.locales);
|
|
10
|
+
const totalPossibleTranslations = availableLocales.length - 1;
|
|
11
|
+
const existingTranslationsCount = Object.keys(resource.translations).length;
|
|
12
|
+
const canBeTranslated = existingTranslationsCount < totalPossibleTranslations;
|
|
13
|
+
%>
|
|
14
|
+
|
|
15
|
+
|
|
8
16
|
<% Object.keys(_context.locales).forEach(key => { %>
|
|
9
17
|
<% const { name, _code, flag} = _context.locales[key] %>
|
|
10
18
|
<% if (key === _context.locale) { %>
|
|
@@ -19,6 +27,12 @@
|
|
|
19
27
|
</a>
|
|
20
28
|
<% } %>
|
|
21
29
|
<% }); %>
|
|
30
|
+
|
|
31
|
+
<% if (canBeTranslated) { %>
|
|
32
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.pageTypePlural}/${resource.id}/translations/new` %>" class="btn-text">
|
|
33
|
+
Translate
|
|
34
|
+
</a>
|
|
35
|
+
<% } %>
|
|
22
36
|
</div>
|
|
23
37
|
|
|
24
38
|
<% if (revisions.length) { %>
|
|
@@ -155,4 +169,4 @@
|
|
|
155
169
|
</tbody>
|
|
156
170
|
</table>
|
|
157
171
|
</div>
|
|
158
|
-
</div>
|
|
172
|
+
</div>
|
|
@@ -1,9 +1,65 @@
|
|
|
1
1
|
<div class="page-wrapper">
|
|
2
|
-
<div class="page-title">
|
|
3
|
-
<h1 class="heading-1">
|
|
2
|
+
<div class="page-title flex flex-col items-start">
|
|
3
|
+
<h1 class="heading-1">Translate from <%= _context.locales[_context.locale].name %></h1>
|
|
4
|
+
<h1 class="body-2 opacity-50"><%= blueprint.title %></h1>
|
|
4
5
|
</div>
|
|
5
6
|
|
|
6
7
|
<div class="grid gap-4 md:grid-cols-3">
|
|
7
|
-
|
|
8
|
+
<% form_for({ resource }, errors, (f) => { %>
|
|
9
|
+
<form action="<%= `/${_context.locale}/${_context.resourceConfig.pageTypePlural}/${blueprint.id}/translations` %>" method="post" class="form">
|
|
10
|
+
<h6>Available languages:</h6>
|
|
11
|
+
<% if (targetLocale) { %>
|
|
12
|
+
<input type="hidden" name="resource[languages][]" value="<%= targetLocale %>">
|
|
13
|
+
<% } %>
|
|
14
|
+
<div class="checkbox-group !max-h-fit mb-3">
|
|
15
|
+
<% Object.entries(_context.locales).forEach(([key, locale]) => {
|
|
16
|
+
const isExistingTranslation = resource.translations[key];
|
|
17
|
+
const isCurrentLocale = key === _context.locale;
|
|
18
|
+
const wasSelected = Array.isArray(resource.languages) && resource.languages.includes(key);
|
|
19
|
+
const isChecked = isExistingTranslation || isCurrentLocale || key === targetLocale || wasSelected;
|
|
20
|
+
const isDisabled = isExistingTranslation || isCurrentLocale || key === targetLocale;
|
|
21
|
+
%>
|
|
22
|
+
<% if(!isCurrentLocale) { %>
|
|
23
|
+
<label class="<%= isDisabled && key !== targetLocale ? 'opacity-50' : '' %>">
|
|
24
|
+
<input type="<%= isCurrentLocale ? 'hidden' : 'checkbox' %>" name="resource[languages][]" value="<%= key %>" <%= isChecked ? 'checked' : '' %> <%= isDisabled ? 'disabled' : '' %>>
|
|
25
|
+
<%= locale.name %>
|
|
26
|
+
</label>
|
|
27
|
+
<% } %>
|
|
28
|
+
<% }) %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<% if (errors?.resource && targetLocale) { %>
|
|
32
|
+
<div>
|
|
33
|
+
<p class="text-sm text-red-600 font-medium mb-1">
|
|
34
|
+
Failed to create <%= _context.locales[targetLocale].name %> translation.
|
|
35
|
+
</p>
|
|
36
|
+
<% Object.entries(errors.resource).forEach(([errorKey, error]) => { %>
|
|
37
|
+
<p class="text-sm text-red-600 font-medium mb-3"> Reason is <%= errorKey %>: <%= error %></p>
|
|
38
|
+
<% }) %>
|
|
39
|
+
</div>
|
|
40
|
+
<% } %>
|
|
41
|
+
|
|
42
|
+
<div class="form-actions">
|
|
43
|
+
<div class="btn-toolbar">
|
|
44
|
+
<%- f.submit('Initiate translations', { class: 'btn-primary' }) %>
|
|
45
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.pageTypePlural}/${blueprint.id}` %>" class="btn-secondary">Cancel</a>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
<% }) %>
|
|
50
|
+
|
|
51
|
+
<div>
|
|
52
|
+
<div class="help">
|
|
53
|
+
<div class="flex items-center gap-1 mb-2">
|
|
54
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
56
|
+
</svg>
|
|
57
|
+
|
|
58
|
+
<h3 class="heading-3">Contextual help</h3>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore nostrum fugit sint sunt distinctio ut cum, corrupti ad error, quo labore minima. Reprehenderit quasi maxime repudiandae voluptatum commodi saepe molestiae?</p>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
8
64
|
</div>
|
|
9
|
-
</div>
|
|
65
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<div class="page-wrapper">
|
|
2
|
+
<div class="page-title flex flex-col items-start">
|
|
3
|
+
<h1 class="heading-1">Translations initiated</h1>
|
|
4
|
+
<h1 class="body-2 opacity-50"><%= pageTitle %></h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<% if (translations && translations.length > 0) { %>
|
|
8
|
+
<table class="data-grid alt">
|
|
9
|
+
<thead>
|
|
10
|
+
<tr>
|
|
11
|
+
<th>Locale</th>
|
|
12
|
+
<th> </th>
|
|
13
|
+
<th> </th>
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
<% translations.forEach(translation => { %>
|
|
18
|
+
<tr class="border-b border-mono-25">
|
|
19
|
+
<td class="py-4 pl-2 w-full">
|
|
20
|
+
<div class="flex gap-1 items-center">
|
|
21
|
+
<a href="<%= `/${translation.translationLocale}/${_context.resourceConfig.pageTypePlural}/${translation.id}` %>" class="btn-text">
|
|
22
|
+
<%= _context.locales[translation.translationLocale].name %>
|
|
23
|
+
</a>
|
|
24
|
+
</div>
|
|
25
|
+
</td>
|
|
26
|
+
|
|
27
|
+
<td class="whitespace-nowrap">
|
|
28
|
+
<div class="body-3 text-raketa-foreground/50">
|
|
29
|
+
<div class="flex items-center gap-1">
|
|
30
|
+
<a href="<%= `/${translation.translationLocale}/page_revisions/${translation.revisionId}/general/edit` %>" class="btn-text">
|
|
31
|
+
Update Settings
|
|
32
|
+
</a>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</td>
|
|
36
|
+
|
|
37
|
+
<td class="whitespace-nowrap">
|
|
38
|
+
<div class="body-3 text-raketa-foreground/50">
|
|
39
|
+
<div class="flex items-center gap-1">
|
|
40
|
+
<a href="/remote-login?redirect_to=/<%= translation.translationLocale %>/admin/<%= _context.resourceConfig.pageType %>-revisions/<%= translation.revisionId %>/builder" target="_blank" class="btn-text">
|
|
41
|
+
Update Content
|
|
42
|
+
</a>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
<% }) %>
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
<% } %>
|
|
51
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.pageTypePlural}/${parent_id}` %>" class="btn-primary mt-8">
|
|
52
|
+
Go to original
|
|
53
|
+
</a>
|
|
54
|
+
</div>
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
<% form_for({ resource }, errors, (f) => { %>
|
|
2
|
-
<form action="<%= `/${_context.locale}/${_context.resourceConfig.pageTypePlural}/${blueprint.id}/translations` %>" method="post" class="form">
|
|
3
|
-
<input type="hidden" name="target_locale" value="<%= targetLocale %>" />
|
|
4
|
-
|
|
5
|
-
<%- f.field('resource.title', { required: true }) %>
|
|
6
|
-
<%- f.field('resource.slug', { label: "URL", required: true }) %>
|
|
7
|
-
<%- f.field('resource.visibility', { as: 'select', required: true, options: visibilityOptions }) %>
|
|
8
|
-
|
|
9
|
-
<%- include('../../_shared/_credentials_input.ejs', { f, visibilityFieldId: "f_resource_visibility"}) %>
|
|
10
|
-
<%- include('../../_shared/_layout_input.ejs', { f }) %>
|
|
11
|
-
|
|
12
|
-
<%- f.field('resource.settings.meta.title', { required: true, label: "Meta title" }) %>
|
|
13
|
-
<%- f.field('resource.settings.meta.description', { as: "textarea", required: true, label: "Meta description" }) %>
|
|
14
|
-
|
|
15
|
-
<raketa-image-picker name="resource[settings][meta][image]" value="<%= JSON.stringify(resource.settings?.meta?.image || {}) %>" label="Meta image"></raketa-image-picker>
|
|
16
|
-
|
|
17
|
-
<h6>Original tags:</h6>
|
|
18
|
-
<div><%= blueprint.tags.map((tag) => tag.name).join(", ")%></div>
|
|
19
|
-
|
|
20
|
-
<%- f.field('resource.tags', { as: 'checkbox_group', options: tags.map(tag => [tag.id, tag.name]) }) %>
|
|
21
|
-
|
|
22
|
-
<div class="form-actions">
|
|
23
|
-
<div class="btn-toolbar">
|
|
24
|
-
<%- f.submit('Save record', { class: 'btn-primary' }) %>
|
|
25
|
-
|
|
26
|
-
<a href="<%= `/${_context.locale}/${_context.resourceConfig.pageTypePlural}/${blueprint.id}` %>" class="btn-secondary">Cancel</a>
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
29
|
-
</form>
|
|
30
|
-
<% }) %>
|
|
31
|
-
|
|
32
|
-
<div>
|
|
33
|
-
<div class="help">
|
|
34
|
-
<div class="flex items-center gap-1 mb-2">
|
|
35
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
36
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
37
|
-
</svg>
|
|
38
|
-
|
|
39
|
-
<h3 class="heading-3">Contextual help</h3>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore nostrum fugit sint sunt distinctio ut cum, corrupti ad error, quo labore minima. Reprehenderit quasi maxime repudiandae voluptatum commodi saepe molestiae?</p>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|