@coraltravelcenter/b2c-landing-builder 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli/index.mjs +12 -4
- package/src/deploy/backoffice-client.mjs +105 -11
- package/src/deploy/deploy.mjs +14 -3
- package/src/deploy/page.mjs +86 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ b2c-landing-vite build
|
|
|
24
24
|
b2c-landing-vite deploy
|
|
25
25
|
b2c-landing-vite deploy --dry-run
|
|
26
26
|
b2c-landing-vite deploy --update
|
|
27
|
+
b2c-landing-vite deploy --relink
|
|
27
28
|
b2c-landing-vite deploy --assets-only
|
|
28
29
|
b2c-landing-vite block:add hero
|
|
29
30
|
b2c-landing-vite block:rename hero main-hero
|
|
@@ -38,6 +39,9 @@ b2c-landing-vite update
|
|
|
38
39
|
скрытый пароль через ADFS. На диск сохраняется только токен (не пароль), токен
|
|
39
40
|
автоматически используется до истечения. Для CI можно передать
|
|
40
41
|
`B2C_BACKOFFICE_TOKEN`.
|
|
42
|
+
- Backoffice-запросы ограничены 30 секундами. Безопасные GET-запросы повторяются
|
|
43
|
+
до двух раз при сетевом сбое, timeout, HTTP 429, 502, 503 или 504. Изменяющие
|
|
44
|
+
CMS запросы автоматически не повторяются, чтобы не создать дубликаты.
|
|
41
45
|
- `deploy` при первом запуске предлагает выбрать application, layout и HTML-зону,
|
|
42
46
|
создаёт CMS-страницу и размещает блоки в порядке `order.json`. При повторном
|
|
43
47
|
запуске для опубликованной страницы создаётся checkout-версия.
|
|
@@ -46,6 +50,12 @@ b2c-landing-vite update
|
|
|
46
50
|
Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
|
|
47
51
|
- `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
|
|
48
52
|
- `deploy --update` обновляет сохранённую версию страницы без создания новой.
|
|
53
|
+
- `deploy --relink` восстанавливает или меняет привязку проекта к существующей
|
|
54
|
+
CMS-странице. Builder предлагает application, поиск страницы по названию или
|
|
55
|
+
URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
|
|
56
|
+
опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
|
|
57
|
+
- `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
|
|
58
|
+
план, но не сохраняет новую привязку и не изменяет CMS.
|
|
49
59
|
- `block:add` создаёт файлы в выбранных форматах разметки и стилей.
|
|
50
60
|
- `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
|
|
51
61
|
- `update` проверяет npm registry и показывает команду ручного обновления.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.8.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clack/prompts": "1.7.0",
|
package/package.json
CHANGED
package/src/cli/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ Commands:
|
|
|
10
10
|
deploy Build, sync assets, and deploy CMS widgets
|
|
11
11
|
deploy --dry-run Show the exact deploy plan without changing CMS
|
|
12
12
|
deploy --update Update the current CMS version in place
|
|
13
|
+
deploy --relink Attach the project to an existing CMS page
|
|
13
14
|
deploy --assets-only Build and sync assets with the B2C CDN
|
|
14
15
|
check Validate project configuration
|
|
15
16
|
block:add <key> Create a block
|
|
@@ -78,11 +79,11 @@ export async function runCli(args) {
|
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
if (command === "deploy") {
|
|
81
|
-
const allowed = new Set(["--assets-only", "--update", "--dry-run"]);
|
|
82
|
+
const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink"]);
|
|
82
83
|
const flags = new Set(rest);
|
|
83
84
|
if (rest.some((argument) => !allowed.has(argument)) || flags.size !== rest.length ||
|
|
84
|
-
(flags.has("--assets-only") && flags.has("--update"))) {
|
|
85
|
-
throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update] [--dry-run]");
|
|
85
|
+
(flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink")))) {
|
|
86
|
+
throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update] [--relink] [--dry-run]");
|
|
86
87
|
}
|
|
87
88
|
const config = await loadConfig();
|
|
88
89
|
validateProject(config);
|
|
@@ -98,10 +99,17 @@ export async function runCli(args) {
|
|
|
98
99
|
token,
|
|
99
100
|
update: flags.has("--update"),
|
|
100
101
|
assetsOnly: flags.has("--assets-only"),
|
|
102
|
+
relink: flags.has("--relink"),
|
|
101
103
|
});
|
|
102
104
|
}
|
|
103
105
|
if (rest.includes("--assets-only")) return deployAssetsOnly({config, preset, token});
|
|
104
|
-
return deployProject({
|
|
106
|
+
return deployProject({
|
|
107
|
+
config,
|
|
108
|
+
preset,
|
|
109
|
+
token,
|
|
110
|
+
update: flags.has("--update"),
|
|
111
|
+
relink: flags.has("--relink"),
|
|
112
|
+
});
|
|
105
113
|
}
|
|
106
114
|
if (command === "block:add" || command === "block:rename") {
|
|
107
115
|
const config = await loadConfig();
|
|
@@ -4,15 +4,62 @@ import path from "node:path";
|
|
|
4
4
|
export const RU_LANGUAGE_ID = "1049";
|
|
5
5
|
export const HTML_WIDGET_ID = "d400b0e7-918d-4d4c-bea3-8522253b6544";
|
|
6
6
|
export const STATUS = Object.freeze({published: 1, checkout: 2, unpublished: 4});
|
|
7
|
+
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
8
|
+
|
|
9
|
+
export class BackofficeRequestError extends Error {
|
|
10
|
+
constructor(message, {code, status, method, endpoint, retryable = false, cause} = {}) {
|
|
11
|
+
super(message, {cause});
|
|
12
|
+
this.name = "BackofficeRequestError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.method = method;
|
|
16
|
+
this.endpoint = endpoint;
|
|
17
|
+
this.retryable = retryable;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function errorCode(status) {
|
|
22
|
+
if (status === 400 || status === 422) return "VALIDATION";
|
|
23
|
+
if (status === 401 || status === 403) return "AUTH";
|
|
24
|
+
if (status === 404) return "NOT_FOUND";
|
|
25
|
+
if (status === 429) return "RATE_LIMIT";
|
|
26
|
+
if (status >= 500) return "SERVER";
|
|
27
|
+
return "HTTP";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function retryDelay(response, attempt) {
|
|
31
|
+
const header = response?.headers.get("retry-after");
|
|
32
|
+
if (header) {
|
|
33
|
+
const seconds = Number(header);
|
|
34
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
35
|
+
const timestamp = Date.parse(header);
|
|
36
|
+
if (Number.isFinite(timestamp)) return Math.max(0, timestamp - Date.now());
|
|
37
|
+
}
|
|
38
|
+
return 500 * (2 ** attempt);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
7
42
|
|
|
8
43
|
export class BackofficeClient {
|
|
9
|
-
constructor({
|
|
44
|
+
constructor({
|
|
45
|
+
brand,
|
|
46
|
+
token,
|
|
47
|
+
fetchImpl = globalThis.fetch,
|
|
48
|
+
timeoutMs = 30_000,
|
|
49
|
+
maxRetries = 2,
|
|
50
|
+
sleep = defaultSleep,
|
|
51
|
+
log = console.warn,
|
|
52
|
+
}) {
|
|
10
53
|
if (!new Set(["coral", "sunmar"]).has(brand)) throw new Error(`Unsupported backoffice brand: ${brand}`);
|
|
11
54
|
if (!token) throw new Error("Backoffice token is required");
|
|
12
55
|
if (typeof fetchImpl !== "function") throw new Error("Fetch API is unavailable");
|
|
13
56
|
this.baseUrl = `https://b2capi.${brand}.ru/BackOffice`;
|
|
14
57
|
this.token = token;
|
|
15
58
|
this.fetchImpl = fetchImpl;
|
|
59
|
+
this.timeoutMs = timeoutMs;
|
|
60
|
+
this.maxRetries = maxRetries;
|
|
61
|
+
this.sleep = sleep;
|
|
62
|
+
this.log = log;
|
|
16
63
|
}
|
|
17
64
|
|
|
18
65
|
async request(method, endpoint, {query, body, form} = {}) {
|
|
@@ -20,17 +67,53 @@ export class BackofficeClient {
|
|
|
20
67
|
for (const [key, value] of Object.entries(query || {})) url.searchParams.set(key, value);
|
|
21
68
|
const headers = {token: this.token, authorization: `Bearer ${this.token}`};
|
|
22
69
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
70
|
+
const canRetry = method === "GET";
|
|
71
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
72
|
+
let response;
|
|
73
|
+
try {
|
|
74
|
+
response = await this.fetchImpl(url, {
|
|
75
|
+
method,
|
|
76
|
+
headers,
|
|
77
|
+
body: form || (body === undefined ? undefined : JSON.stringify(body)),
|
|
78
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
79
|
+
});
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
const timeout = cause?.name === "TimeoutError" || cause?.name === "AbortError";
|
|
82
|
+
const retryable = canRetry && attempt < this.maxRetries;
|
|
83
|
+
if (retryable) {
|
|
84
|
+
const delay = 500 * (2 ** attempt);
|
|
85
|
+
this.log(`[backoffice] ${timeout ? "timeout" : "network error"}; retrying GET ${endpoint} in ${delay}ms`);
|
|
86
|
+
await this.sleep(delay);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
throw new BackofficeRequestError(
|
|
90
|
+
`Backoffice ${method} ${endpoint} ${timeout ? `timed out after ${this.timeoutMs}ms` : `failed: ${cause?.message || "network error"}`}`,
|
|
91
|
+
{code: timeout ? "TIMEOUT" : "NETWORK", method, endpoint, retryable: canRetry, cause}
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const detail = await response.text().catch(() => "");
|
|
96
|
+
const retryableStatus = RETRYABLE_STATUSES.has(response.status);
|
|
97
|
+
if (canRetry && retryableStatus && attempt < this.maxRetries) {
|
|
98
|
+
const delay = retryDelay(response, attempt);
|
|
99
|
+
this.log(`[backoffice] HTTP ${response.status}; retrying GET ${endpoint} in ${delay}ms`);
|
|
100
|
+
await this.sleep(delay);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
throw new BackofficeRequestError(
|
|
104
|
+
`Backoffice ${method} ${endpoint} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
|
|
105
|
+
{
|
|
106
|
+
code: errorCode(response.status),
|
|
107
|
+
status: response.status,
|
|
108
|
+
method,
|
|
109
|
+
endpoint,
|
|
110
|
+
retryable: canRetry && retryableStatus,
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const payload = await response.json();
|
|
115
|
+
return payload?.result;
|
|
31
116
|
}
|
|
32
|
-
const payload = await response.json();
|
|
33
|
-
return payload?.result;
|
|
34
117
|
}
|
|
35
118
|
|
|
36
119
|
getContent(contentId) {
|
|
@@ -57,6 +140,17 @@ export class BackofficeClient {
|
|
|
57
140
|
return result?.versions || [];
|
|
58
141
|
}
|
|
59
142
|
|
|
143
|
+
async getPages(applicationId, {keyword = "", url = ""} = {}) {
|
|
144
|
+
const result = await this.request("GET", "/Page/GetAll", {query: {
|
|
145
|
+
pageIndex: 1,
|
|
146
|
+
pageSize: 100,
|
|
147
|
+
ApplicationId: applicationId,
|
|
148
|
+
Keyword: keyword,
|
|
149
|
+
Url: url,
|
|
150
|
+
}});
|
|
151
|
+
return result?.result || result || [];
|
|
152
|
+
}
|
|
153
|
+
|
|
60
154
|
createPage(body) {
|
|
61
155
|
return this.request("POST", "/Page/Create", {body});
|
|
62
156
|
}
|
package/src/deploy/deploy.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import {planAssetChanges, uploadChangedAssets} from "./assets.mjs";
|
|
|
2
2
|
import {BackofficeClient} from "./backoffice-client.mjs";
|
|
3
3
|
import {readBuildManifest} from "./manifest.mjs";
|
|
4
4
|
import {readDeploymentState, writeDeploymentState} from "./state.mjs";
|
|
5
|
-
import {createPagePlacement, prepareExistingPage, selectNewPagePlacement} from "./page.mjs";
|
|
5
|
+
import {createPagePlacement, prepareExistingPage, selectExistingPagePlacement, selectNewPagePlacement} from "./page.mjs";
|
|
6
6
|
import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
|
|
7
7
|
|
|
8
8
|
function paths(items) {
|
|
@@ -21,6 +21,7 @@ export async function planDeployment({
|
|
|
21
21
|
client,
|
|
22
22
|
update = false,
|
|
23
23
|
assetsOnly = false,
|
|
24
|
+
relink = false,
|
|
24
25
|
prompts,
|
|
25
26
|
log = console.log,
|
|
26
27
|
}) {
|
|
@@ -43,7 +44,13 @@ export async function planDeployment({
|
|
|
43
44
|
let placement;
|
|
44
45
|
let pageContent;
|
|
45
46
|
let page;
|
|
46
|
-
if (
|
|
47
|
+
if (relink) {
|
|
48
|
+
const selected = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
|
|
49
|
+
placement = selected.placement;
|
|
50
|
+
pageContent = selected.pageContent;
|
|
51
|
+
const checkout = !update && [1, 4].includes(pageContent.status);
|
|
52
|
+
page = {action: checkout ? "relink and create checkout version" : "relink and update selected version", pageContentId: placement.pageContentId};
|
|
53
|
+
} else if (previous.placement) {
|
|
47
54
|
placement = previous.placement;
|
|
48
55
|
pageContent = await api.getContent(placement.pageContentId);
|
|
49
56
|
const checkout = !update && [1, 4].includes(pageContent.status);
|
|
@@ -106,6 +113,7 @@ export async function deployProject({
|
|
|
106
113
|
token,
|
|
107
114
|
client,
|
|
108
115
|
update = false,
|
|
116
|
+
relink = false,
|
|
109
117
|
prompts,
|
|
110
118
|
log = console.log,
|
|
111
119
|
}) {
|
|
@@ -121,7 +129,10 @@ export async function deployProject({
|
|
|
121
129
|
uploadFile: api.uploadFile.bind(api),
|
|
122
130
|
listFiles: api.listFiles?.bind(api),
|
|
123
131
|
});
|
|
124
|
-
const
|
|
132
|
+
const linked = relink ? await selectExistingPagePlacement({client: api, domain: preset.domain, prompts}) : null;
|
|
133
|
+
const page = linked
|
|
134
|
+
? await prepareExistingPage({client: api, placement: linked.placement, update})
|
|
135
|
+
: previous.placement
|
|
125
136
|
? await prepareExistingPage({client: api, placement: previous.placement, update})
|
|
126
137
|
: await createPagePlacement({client: api, manifest, prompts});
|
|
127
138
|
const widgets = await syncWidgets({
|
package/src/deploy/page.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {HTML_WIDGET_ID, STATUS} from "./backoffice-client.mjs";
|
|
1
|
+
import {HTML_WIDGET_ID, RU_LANGUAGE_ID, STATUS} from "./backoffice-client.mjs";
|
|
2
2
|
|
|
3
3
|
function suitableAreas(scheme) {
|
|
4
4
|
return scheme.flatMap((row, rowIndex) => (row.children || []).map((column, columnIndex) => ({
|
|
@@ -10,6 +10,10 @@ function suitableAreas(scheme) {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function pageWidgets(pageContent) {
|
|
14
|
+
return pageContent.pageContents?.find((content) => String(content.languageId) === RU_LANGUAGE_ID)?.widgets || [];
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
function widgetNames(area) {
|
|
14
18
|
return (area.usedWidgets || []).map((widget, index) =>
|
|
15
19
|
widget.widgetName || widget.cmsTitle || widget.name || `widget ${index + 1}`
|
|
@@ -24,6 +28,31 @@ function areaLabel({area, rowIndex, columnIndex}) {
|
|
|
24
28
|
return `Row ${rowIndex + 1}, column ${columnIndex + 1}${width}: ${name} — ${summary}`;
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
|
|
32
|
+
function existingAreaLabel(descriptor, widgets) {
|
|
33
|
+
const areaId = descriptor.area.layoutAreaId || descriptor.area.id;
|
|
34
|
+
const actual = widgets.filter((widget) => widget.layoutAreaId === areaId);
|
|
35
|
+
return areaLabel({
|
|
36
|
+
...descriptor,
|
|
37
|
+
area: {...descriptor.area, usedWidgets: actual.map((widget) => ({widgetName: widget.cmsTitle || widget.widgetName}))},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pageLabel(page, domain) {
|
|
42
|
+
const preview = page.contents?.[0]?.uniqueId;
|
|
43
|
+
const published = page.publishedUrls?.[RU_LANGUAGE_ID];
|
|
44
|
+
const link = published
|
|
45
|
+
? `https://${domain}/${String(published).replace(/^\/+/, "")}`
|
|
46
|
+
: preview ? `https://${domain}/preview/${preview}/` : "no link";
|
|
47
|
+
return `${page.pageTitle || page.name || page.pageId} — ${link}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function versionLabel(content) {
|
|
51
|
+
const details = [content.statusName, content.version != null ? `v${content.version}` : null, content.author]
|
|
52
|
+
.filter(Boolean).join(", ");
|
|
53
|
+
return `${details || content.pageContentId}${content.modifiedTime ? ` — ${content.modifiedTime}` : ""}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
async function defaultPrompts() {
|
|
28
57
|
const {isCancel, select, text} = await import("@clack/prompts");
|
|
29
58
|
const answer = async (promise) => {
|
|
@@ -94,6 +123,62 @@ export async function selectNewPagePlacement({client, manifest, prompts}) {
|
|
|
94
123
|
};
|
|
95
124
|
}
|
|
96
125
|
|
|
126
|
+
export async function selectExistingPagePlacement({client, domain, prompts}) {
|
|
127
|
+
prompts ||= await defaultPrompts();
|
|
128
|
+
const applications = (await client.getApplications()).filter((app) => !/backoffice/i.test(app.name));
|
|
129
|
+
if (!applications.length) throw new Error("No backoffice applications are available");
|
|
130
|
+
const applicationId = await prompts.select({
|
|
131
|
+
message: "Application",
|
|
132
|
+
options: applications.map((app) => ({label: app.name, value: app.id})),
|
|
133
|
+
});
|
|
134
|
+
const searchBy = await prompts.select({
|
|
135
|
+
message: "Find an existing page by",
|
|
136
|
+
options: [{label: "Title", value: "title"}, {label: "URL", value: "url"}],
|
|
137
|
+
});
|
|
138
|
+
const query = await prompts.text({
|
|
139
|
+
message: searchBy === "title" ? "Page title (at least 3 characters)" : "Page URL (at least 3 characters)",
|
|
140
|
+
validate: (value) => value?.trim().length >= 3 ? undefined : "Enter at least 3 characters",
|
|
141
|
+
});
|
|
142
|
+
const pages = await client.getPages(applicationId, searchBy === "title"
|
|
143
|
+
? {keyword: query.trim()}
|
|
144
|
+
: {url: query.trim()});
|
|
145
|
+
if (!pages.length) throw new Error(`No CMS pages found for ${JSON.stringify(query.trim())}`);
|
|
146
|
+
const pageId = await prompts.select({
|
|
147
|
+
message: "Existing CMS page",
|
|
148
|
+
options: pages.map((page) => ({label: pageLabel(page, domain), value: page.pageId || page.id, page})),
|
|
149
|
+
});
|
|
150
|
+
const page = pages.find((candidate) => (candidate.pageId || candidate.id) === pageId);
|
|
151
|
+
const versions = page.contents?.length ? page.contents : await client.getContentVersions(pageId);
|
|
152
|
+
if (!versions?.length) throw new Error("The selected CMS page has no versions");
|
|
153
|
+
const pageContentId = await prompts.select({
|
|
154
|
+
message: "Page version",
|
|
155
|
+
options: versions.map((content) => ({label: versionLabel(content), value: content.pageContentId || content.contentId})),
|
|
156
|
+
});
|
|
157
|
+
const pageContent = await client.getContent(pageContentId);
|
|
158
|
+
const areas = suitableAreas(pageContent.layout?.schema || []);
|
|
159
|
+
if (!areas.length) throw new Error("The selected page layout has no HTML widget area");
|
|
160
|
+
const widgets = pageWidgets(pageContent);
|
|
161
|
+
const layoutAreaId = await prompts.select({
|
|
162
|
+
message: "Where on the existing page should the landing blocks go?",
|
|
163
|
+
options: areas.map((descriptor) => ({
|
|
164
|
+
label: existingAreaLabel(descriptor, widgets),
|
|
165
|
+
value: descriptor.area.layoutAreaId || descriptor.area.id,
|
|
166
|
+
})),
|
|
167
|
+
});
|
|
168
|
+
const names = widgets.filter((widget) => widget.layoutAreaId === layoutAreaId)
|
|
169
|
+
.map((widget, index) => widget.cmsTitle || widget.widgetName || `widget ${index + 1}`);
|
|
170
|
+
const orderIndex = await prompts.select({
|
|
171
|
+
message: "Exact insertion point",
|
|
172
|
+
options: Array.from({length: names.length + 1}, (_, index) => ({
|
|
173
|
+
label: index === 0
|
|
174
|
+
? names.length ? `At the beginning, before “${names[0]}”` : "Into the empty area"
|
|
175
|
+
: `After “${names[index - 1]}”`,
|
|
176
|
+
value: index,
|
|
177
|
+
})),
|
|
178
|
+
});
|
|
179
|
+
return {pageContent, placement: {pageContentId, layoutAreaId, orderIndex}};
|
|
180
|
+
}
|
|
181
|
+
|
|
97
182
|
export async function createPagePlacement(options) {
|
|
98
183
|
const selected = await selectNewPagePlacement(options);
|
|
99
184
|
const {client} = options;
|