@raketa-cloud/admin 1.7.5 → 1.7.8

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.
@@ -1811,6 +1811,10 @@ select {
1811
1811
  width: 1.25rem;
1812
1812
  }
1813
1813
 
1814
+ .w-80 {
1815
+ width: 20rem;
1816
+ }
1817
+
1814
1818
  .w-9 {
1815
1819
  width: 2.25rem;
1816
1820
  }
@@ -1831,6 +1835,10 @@ select {
1831
1835
  width: 100%;
1832
1836
  }
1833
1837
 
1838
+ .flex-1 {
1839
+ flex: 1 1 0%;
1840
+ }
1841
+
1834
1842
  .flex-none {
1835
1843
  flex: none;
1836
1844
  }
@@ -2039,6 +2047,11 @@ select {
2039
2047
  padding: 1rem;
2040
2048
  }
2041
2049
 
2050
+ .px-3 {
2051
+ padding-left: 0.75rem;
2052
+ padding-right: 0.75rem;
2053
+ }
2054
+
2042
2055
  .px-4 {
2043
2056
  padding-left: 1rem;
2044
2057
  padding-right: 1rem;
@@ -2128,6 +2141,10 @@ select {
2128
2141
  font-weight: 600;
2129
2142
  }
2130
2143
 
2144
+ .uppercase {
2145
+ text-transform: uppercase;
2146
+ }
2147
+
2131
2148
  .lowercase {
2132
2149
  text-transform: lowercase;
2133
2150
  }
@@ -2218,6 +2235,12 @@ select {
2218
2235
  opacity: 0.6;
2219
2236
  }
2220
2237
 
2238
+ .shadow-inner {
2239
+ --tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
2240
+ --tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);
2241
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
2242
+ }
2243
+
2221
2244
  .shadow-lg {
2222
2245
  --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
2223
2246
  --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.7.5",
3
+ "version": "1.7.8",
4
4
  "main": "src/index.js",
5
5
  "files": [
6
6
  "src/",
@@ -12,8 +12,8 @@
12
12
  "prepare": "npx tailwindcss -i ./src/styles/application.css -o ./dist/application.css"
13
13
  },
14
14
  "devDependencies": {
15
- "@babel/core": "7.28.6",
16
- "@babel/preset-env": "7.28.6",
15
+ "@babel/core": "7.29.0",
16
+ "@babel/preset-env": "7.29.0",
17
17
  "@babel/preset-typescript": "7.28.5",
18
18
  "@repo/typescript-config": "*",
19
19
  "@tailwindcss/forms": "0.5.7",
@@ -62,14 +62,16 @@ export default class Pages extends RaketaClient {
62
62
  async list(query, locale, pageTypeSlug) {
63
63
  const vars = this.buildListInput(query, locale);
64
64
 
65
- if (vars.input.filters.last_updated_from) {
66
- vars.input.filters.last_updated_from = toUTCISO8601String(vars.input.filters.last_updated_from);
67
- }
68
- if (vars.input.filters.last_updated_to) {
69
- vars.input.filters.last_updated_to = toUTCISO8601String(vars.input.filters.last_updated_to);
70
- }
71
-
72
- console.log(vars.input.filters)
65
+ if (vars.input.filters.last_updated_from) {
66
+ vars.input.filters.last_updated_from = toUTCISO8601String(
67
+ vars.input.filters.last_updated_from,
68
+ );
69
+ }
70
+ if (vars.input.filters.last_updated_to) {
71
+ vars.input.filters.last_updated_to = toUTCISO8601String(
72
+ vars.input.filters.last_updated_to,
73
+ );
74
+ }
73
75
 
74
76
  const { pages: items } = await this.query(PagesDocument, {
75
77
  input: {
@@ -20,6 +20,7 @@ function minimalArticleFragment() {
20
20
  publishAt
21
21
  insertedAt
22
22
  updatedAt
23
+ translations
23
24
  }
24
25
  `;
25
26
  }
@@ -22,6 +22,7 @@ function minimalEventFragment() {
22
22
  endsAt
23
23
  insertedAt
24
24
  updatedAt
25
+ translations
25
26
  }
26
27
  `;
27
28
  }
@@ -19,6 +19,7 @@ function minimalPageFragment() {
19
19
  visibility
20
20
  insertedAt
21
21
  updatedAt
22
+ translations
22
23
  }
23
24
  `;
24
25
  }
@@ -14,6 +14,10 @@ const update = async (ctx) => {
14
14
  const current = await ctx.client.settings.find(ctx.locale);
15
15
  const updated = ctx.client.settings.deepMerge(current, params);
16
16
 
17
+ if (params._global_variables !== undefined) {
18
+ updated._global_variables = params._global_variables;
19
+ }
20
+
17
21
  const { errors } = await ctx.client.settings.update(ctx.locale, updated);
18
22
 
19
23
  if (errors) {
@@ -18,7 +18,7 @@ const exportCSV = async (ctx) => {
18
18
  const csv = CSVExportHelper.formatDataAsCSV(
19
19
  pages.list,
20
20
  headers,
21
- fieldMapping,
21
+ fieldMapping
22
22
  );
23
23
 
24
24
  return {
@@ -72,6 +72,10 @@ const extractResourceType = (resource, type) => {
72
72
  return type;
73
73
  };
74
74
 
75
+ function formatTranslations(item) {
76
+ return Object.keys(item?.translations || {}).map(locale => locale).join(";")
77
+ }
78
+
75
79
  const standardFieldMapping = (remoteConfig, locale, type) => ({
76
80
  id: "id",
77
81
  type: (item) => extractResourceType(item, type),
@@ -81,6 +85,7 @@ const standardFieldMapping = (remoteConfig, locale, type) => ({
81
85
  visibility: "visibility",
82
86
  created_at: "insertedAt",
83
87
  updated_at: "updatedAt",
88
+ translations: formatTranslations
84
89
  });
85
90
 
86
91
  const standardHeaders = [
@@ -92,6 +97,7 @@ const standardHeaders = [
92
97
  "visibility",
93
98
  "created_at",
94
99
  "updated_at",
100
+ "translations"
95
101
  ];
96
102
 
97
103
  export default {
@@ -9,6 +9,10 @@ function routeSettings(adapter) {
9
9
  routeMember(adapter, "settings/layout", SettingsHandler, {
10
10
  resource: "settings/layout",
11
11
  });
12
+
13
+ routeMember(adapter, "settings/variables", SettingsHandler, {
14
+ resource: "settings/variables",
15
+ });
12
16
  }
13
17
 
14
18
  export default routeSettings;
@@ -0,0 +1,636 @@
1
+ import RaketaClient from '../cloud/RaketaClient'
2
+ import { request } from 'graphql-request'
3
+
4
+ jest.mock('graphql-request', () => ({
5
+ request: jest.fn(),
6
+ }))
7
+
8
+ describe('RaketaClient', () => {
9
+ function buildMockConfig(overrides = {}) {
10
+ return {
11
+ cloud: {
12
+ gateway_url: 'https://api.example.com',
13
+ gateway_project: 'test-project',
14
+ dxp_service_private: 'test-service',
15
+ },
16
+ ...overrides,
17
+ }
18
+ }
19
+
20
+ function buildClient(config = buildMockConfig(), token = 'test-token') {
21
+ const getConfig = () => config
22
+ return new RaketaClient(getConfig, token)
23
+ }
24
+
25
+ beforeEach(() => {
26
+ jest.clearAllMocks()
27
+ })
28
+
29
+ describe('constructor', () => {
30
+ test('initializes with getConfig and token', () => {
31
+ const getConfig = jest.fn(() => buildMockConfig())
32
+ const client = new RaketaClient(getConfig, 'my-token')
33
+
34
+ expect(client.getConfig).toBe(getConfig)
35
+ expect(client.token).toBe('my-token')
36
+ })
37
+ })
38
+
39
+ describe('gqlRequest', () => {
40
+ test('makes GraphQL request with correct parameters', async () => {
41
+ const client = buildClient()
42
+ const query = 'query { test }'
43
+ const variables = { id: '123' }
44
+ const headers = { 'x-custom': 'value' }
45
+
46
+ request.mockResolvedValue({ data: 'response' })
47
+
48
+ await client.gqlRequest('https://api.example.com/graphql', query, variables, headers)
49
+
50
+ expect(request).toHaveBeenCalledWith({
51
+ url: 'https://api.example.com/graphql',
52
+ document: query,
53
+ variables: variables,
54
+ requestHeaders: headers,
55
+ })
56
+ })
57
+
58
+ test('makes request with default empty variables and headers', async () => {
59
+ const client = buildClient()
60
+ const query = 'query { test }'
61
+
62
+ request.mockResolvedValue({ data: 'response' })
63
+
64
+ await client.gqlRequest('https://api.example.com/graphql', query)
65
+
66
+ expect(request).toHaveBeenCalledWith({
67
+ url: 'https://api.example.com/graphql',
68
+ document: query,
69
+ variables: {},
70
+ requestHeaders: {},
71
+ })
72
+ })
73
+ })
74
+
75
+ describe('query', () => {
76
+ test('makes request to gateway with proper headers', async () => {
77
+ const client = buildClient()
78
+ const query = 'query { users }'
79
+ const variables = { limit: 10 }
80
+
81
+ request.mockResolvedValue({ data: { users: [] } })
82
+
83
+ await client.query(query, variables)
84
+
85
+ expect(request).toHaveBeenCalledWith({
86
+ url: 'https://api.example.com/dispatch',
87
+ document: query,
88
+ variables: variables,
89
+ requestHeaders: {
90
+ 'x-raketa-project': 'test-project',
91
+ 'x-raketa-service': 'test-service',
92
+ 'x-raketa-identity': 'test-token',
93
+ },
94
+ })
95
+ })
96
+
97
+ test('uses token from constructor in headers', async () => {
98
+ const client = buildClient(buildMockConfig(), 'custom-token-123')
99
+ const query = 'query { test }'
100
+
101
+ request.mockResolvedValue({ data: {} })
102
+
103
+ await client.query(query)
104
+
105
+ expect(request).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ requestHeaders: expect.objectContaining({
108
+ 'x-raketa-identity': 'custom-token-123',
109
+ }),
110
+ })
111
+ )
112
+ })
113
+ })
114
+
115
+ describe('logError', () => {
116
+ test('throws error with message', () => {
117
+ const client = buildClient()
118
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
119
+
120
+ expect(() => client.logError('Something went wrong')).toThrow('Something went wrong')
121
+
122
+ consoleSpy.mockRestore()
123
+ })
124
+
125
+ test('logs context to console when provided', () => {
126
+ const client = buildClient()
127
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
128
+ const context = { code: 'ERR_001', details: 'Invalid input' }
129
+
130
+ expect(() => client.logError('Error occurred', context)).toThrow('Error occurred')
131
+ expect(consoleSpy).toHaveBeenCalledWith(context)
132
+
133
+ consoleSpy.mockRestore()
134
+ })
135
+
136
+ test('does not log when context is not provided', () => {
137
+ const client = buildClient()
138
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
139
+
140
+ expect(() => client.logError('Error occurred')).toThrow('Error occurred')
141
+ expect(consoleSpy).not.toHaveBeenCalled()
142
+
143
+ consoleSpy.mockRestore()
144
+ })
145
+ })
146
+
147
+ describe('formatErrors', () => {
148
+ test('formats single error into object', () => {
149
+ const client = buildClient()
150
+ const errors = [{ key: 'email', message: 'Email is invalid' }]
151
+
152
+ const result = client.formatErrors(errors)
153
+
154
+ expect(result).toEqual({
155
+ email: 'Email is invalid',
156
+ })
157
+ })
158
+
159
+ test('formats multiple errors for different keys', () => {
160
+ const client = buildClient()
161
+ const errors = [
162
+ { key: 'email', message: 'Email is required' },
163
+ { key: 'password', message: 'Password is too short' },
164
+ { key: 'username', message: 'Username is taken' },
165
+ ]
166
+
167
+ const result = client.formatErrors(errors)
168
+
169
+ expect(result).toEqual({
170
+ email: 'Email is required',
171
+ password: 'Password is too short',
172
+ username: 'Username is taken',
173
+ })
174
+ })
175
+
176
+ test('concatenates multiple messages for same key', () => {
177
+ const client = buildClient()
178
+ const errors = [
179
+ { key: 'email', message: 'Email is required' },
180
+ { key: 'email', message: 'Email format is invalid' },
181
+ { key: 'password', message: 'Password is required' },
182
+ ]
183
+
184
+ const result = client.formatErrors(errors)
185
+
186
+ expect(result).toEqual({
187
+ email: 'Email is required, Email format is invalid',
188
+ password: 'Password is required',
189
+ })
190
+ })
191
+
192
+ test('remaps generalError to specified key', () => {
193
+ const client = buildClient()
194
+ const errors = [
195
+ { key: 'generalError', message: 'Something went wrong' },
196
+ { key: 'email', message: 'Email is invalid' },
197
+ ]
198
+
199
+ const result = client.formatErrors(errors, 'base')
200
+
201
+ expect(result).toEqual({
202
+ base: 'Something went wrong',
203
+ email: 'Email is invalid',
204
+ })
205
+ })
206
+
207
+ test('keeps generalError key when no remap specified', () => {
208
+ const client = buildClient()
209
+ const errors = [{ key: 'generalError', message: 'Operation failed' }]
210
+
211
+ const result = client.formatErrors(errors)
212
+
213
+ expect(result).toEqual({
214
+ generalError: 'Operation failed',
215
+ })
216
+ })
217
+
218
+ test('handles empty errors array', () => {
219
+ const client = buildClient()
220
+ const result = client.formatErrors([])
221
+
222
+ expect(result).toEqual({})
223
+ })
224
+ })
225
+
226
+ describe('buildFiltersInput', () => {
227
+ test('extracts filters with f: prefix', () => {
228
+ const client = buildClient()
229
+ const query = {
230
+ 'f:status': 'active',
231
+ 'f:category': 'news',
232
+ page: '2',
233
+ order_by: 'created_at',
234
+ }
235
+
236
+ const result = client.buildFiltersInput(query)
237
+
238
+ expect(result).toEqual({
239
+ status: 'active',
240
+ category: 'news',
241
+ })
242
+ })
243
+
244
+ test('ignores filters with empty values', () => {
245
+ const client = buildClient()
246
+ const query = {
247
+ 'f:status': 'active',
248
+ 'f:category': '',
249
+ 'f:tag': null,
250
+ 'f:author': undefined,
251
+ }
252
+
253
+ const result = client.buildFiltersInput(query)
254
+
255
+ expect(result).toEqual({
256
+ status: 'active',
257
+ })
258
+ })
259
+
260
+ test('returns empty object when no filters present', () => {
261
+ const client = buildClient()
262
+ const query = {
263
+ page: '1',
264
+ order_by: 'title',
265
+ }
266
+
267
+ const result = client.buildFiltersInput(query)
268
+
269
+ expect(result).toEqual({})
270
+ })
271
+
272
+ test('returns empty object when query is null or undefined', () => {
273
+ const client = buildClient()
274
+
275
+ expect(client.buildFiltersInput(null)).toEqual({})
276
+ expect(client.buildFiltersInput(undefined)).toEqual({})
277
+ })
278
+
279
+ test('returns empty object when only page param exists', () => {
280
+ const client = buildClient()
281
+ const query = { page: '3' }
282
+
283
+ const result = client.buildFiltersInput(query)
284
+
285
+ expect(result).toEqual({})
286
+ })
287
+ })
288
+
289
+ describe('buildOrderInput', () => {
290
+ test('builds order object with field and direction', () => {
291
+ const client = buildClient()
292
+ const query = {
293
+ order_by: 'created_at',
294
+ order_direction: 'DESC',
295
+ }
296
+
297
+ const result = client.buildOrderInput(query)
298
+
299
+ expect(result).toEqual({
300
+ field: 'created_at',
301
+ direction: 'DESC',
302
+ })
303
+ })
304
+
305
+ test('defaults to ASC when direction not provided', () => {
306
+ const client = buildClient()
307
+ const query = { order_by: 'title' }
308
+
309
+ const result = client.buildOrderInput(query)
310
+
311
+ expect(result).toEqual({
312
+ field: 'title',
313
+ direction: 'ASC',
314
+ })
315
+ })
316
+
317
+ test('returns null when order_by not provided', () => {
318
+ const client = buildClient()
319
+ const query = { order_direction: 'DESC' }
320
+
321
+ const result = client.buildOrderInput(query)
322
+
323
+ expect(result).toBeNull()
324
+ })
325
+
326
+ test('returns null when order_by is empty string', () => {
327
+ const client = buildClient()
328
+ const query = { order_by: '' }
329
+
330
+ const result = client.buildOrderInput(query)
331
+
332
+ expect(result).toBeNull()
333
+ })
334
+ })
335
+
336
+ describe('buildPaginationInput', () => {
337
+ test('builds pagination with page number from query', () => {
338
+ const client = buildClient()
339
+ const query = { page: '3' }
340
+
341
+ const result = client.buildPaginationInput(query)
342
+
343
+ expect(result).toEqual({
344
+ page: 3,
345
+ pageSize: 25,
346
+ })
347
+ })
348
+
349
+ test('defaults to page 1 when not provided', () => {
350
+ const client = buildClient()
351
+ const query = {}
352
+
353
+ const result = client.buildPaginationInput(query)
354
+
355
+ expect(result).toEqual({
356
+ page: 1,
357
+ pageSize: 25,
358
+ })
359
+ })
360
+
361
+ test('parses string page number to integer', () => {
362
+ const client = buildClient()
363
+ const query = { page: '10' }
364
+
365
+ const result = client.buildPaginationInput(query)
366
+
367
+ expect(result.page).toBe(10)
368
+ expect(typeof result.page).toBe('number')
369
+ })
370
+
371
+ test('handles invalid page values by defaulting to 1', () => {
372
+ const client = buildClient()
373
+
374
+ expect(client.buildPaginationInput({ page: 'invalid' })).toEqual({
375
+ page: NaN,
376
+ pageSize: 25,
377
+ })
378
+ })
379
+ })
380
+
381
+ describe('buildListInput', () => {
382
+ test('builds complete input with pagination, filters, orderBy, and locale', () => {
383
+ const client = buildClient()
384
+ const query = {
385
+ page: '2',
386
+ 'f:status': 'published',
387
+ 'f:category': 'tech',
388
+ order_by: 'created_at',
389
+ order_direction: 'DESC',
390
+ }
391
+
392
+ const result = client.buildListInput(query, 'en')
393
+
394
+ expect(result).toEqual({
395
+ input: {
396
+ pagination: { page: 2, pageSize: 25 },
397
+ filters: { status: 'published', category: 'tech' },
398
+ orderBy: { field: 'created_at', direction: 'DESC' },
399
+ locale: 'en',
400
+ },
401
+ })
402
+ })
403
+
404
+ test('omits orderBy when not provided', () => {
405
+ const client = buildClient()
406
+ const query = { page: '1', 'f:status': 'active' }
407
+
408
+ const result = client.buildListInput(query)
409
+
410
+ expect(result).toEqual({
411
+ input: {
412
+ pagination: { page: 1, pageSize: 25 },
413
+ filters: { status: 'active' },
414
+ },
415
+ })
416
+ })
417
+
418
+ test('omits locale when not provided', () => {
419
+ const client = buildClient()
420
+ const query = { page: '1' }
421
+
422
+ const result = client.buildListInput(query)
423
+
424
+ expect(result.input).not.toHaveProperty('locale')
425
+ })
426
+
427
+ test('includes locale when provided', () => {
428
+ const client = buildClient()
429
+ const query = { page: '1' }
430
+
431
+ const result = client.buildListInput(query, 'de')
432
+
433
+ expect(result.input.locale).toBe('de')
434
+ })
435
+
436
+ test('handles empty query object', () => {
437
+ const client = buildClient()
438
+
439
+ const result = client.buildListInput({})
440
+
441
+ expect(result).toEqual({
442
+ input: {
443
+ pagination: { page: 1, pageSize: 25 },
444
+ filters: {},
445
+ },
446
+ })
447
+ })
448
+ })
449
+
450
+ describe('buildAllInput', () => {
451
+ test('builds input without pagination', () => {
452
+ const client = buildClient()
453
+ const query = {
454
+ page: '2',
455
+ 'f:status': 'active',
456
+ order_by: 'title',
457
+ order_direction: 'ASC',
458
+ }
459
+
460
+ const result = client.buildAllInput(query, 'fr')
461
+
462
+ expect(result).toEqual({
463
+ input: {
464
+ filters: { status: 'active' },
465
+ orderBy: { field: 'title', direction: 'ASC' },
466
+ locale: 'fr',
467
+ },
468
+ })
469
+ expect(result.input).not.toHaveProperty('pagination')
470
+ })
471
+
472
+ test('omits orderBy when not provided', () => {
473
+ const client = buildClient()
474
+ const query = { 'f:category': 'news' }
475
+
476
+ const result = client.buildAllInput(query)
477
+
478
+ expect(result).toEqual({
479
+ input: {
480
+ filters: { category: 'news' },
481
+ },
482
+ })
483
+ })
484
+
485
+ test('omits locale when not provided', () => {
486
+ const client = buildClient()
487
+ const query = {}
488
+
489
+ const result = client.buildAllInput(query)
490
+
491
+ expect(result.input).not.toHaveProperty('locale')
492
+ })
493
+ })
494
+
495
+ describe('deepMerge', () => {
496
+ test('merges two simple objects', () => {
497
+ const client = buildClient()
498
+ const target = { a: 1, b: 2 }
499
+ const source = { b: 3, c: 4 }
500
+
501
+ const result = client.deepMerge(target, source)
502
+
503
+ expect(result).toEqual({ a: 1, b: 3, c: 4 })
504
+ // Verify original is not modified
505
+ expect(target).toEqual({ a: 1, b: 2 })
506
+ })
507
+
508
+ test('merges nested objects deeply', () => {
509
+ const client = buildClient()
510
+ const target = {
511
+ settings: { theme: 'dark', locale: 'en' },
512
+ profile: { name: 'John' },
513
+ }
514
+ const source = {
515
+ settings: { locale: 'fr', notifications: true },
516
+ profile: { age: 30 },
517
+ }
518
+
519
+ const result = client.deepMerge(target, source)
520
+
521
+ expect(result).toEqual({
522
+ settings: { theme: 'dark', locale: 'fr', notifications: true },
523
+ profile: { name: 'John', age: 30 },
524
+ })
525
+ })
526
+
527
+ test('replaces arrays instead of merging', () => {
528
+ const client = buildClient()
529
+ const target = { tags: ['javascript', 'node'] }
530
+ const source = { tags: ['typescript', 'deno'] }
531
+
532
+ const result = client.deepMerge(target, source)
533
+
534
+ expect(result).toEqual({ tags: ['typescript', 'deno'] })
535
+ })
536
+
537
+ test('parses JSON strings in source', () => {
538
+ const client = buildClient()
539
+ const target = { config: {} }
540
+ const source = { config: '{"enabled": true, "count": 5}' }
541
+
542
+ const result = client.deepMerge(target, source)
543
+
544
+ expect(result.config).toEqual({ enabled: true, count: 5 })
545
+ })
546
+
547
+ test('parses JSON strings in nested objects', () => {
548
+ const client = buildClient()
549
+ const target = { settings: { meta: {} } }
550
+ const source = { settings: { meta: '{"title": "Test", "tags": ["a", "b"]}' } }
551
+
552
+ const result = client.deepMerge(target, source)
553
+
554
+ expect(result.settings.meta).toEqual({ title: 'Test', tags: ['a', 'b'] })
555
+ })
556
+
557
+ test('handles null values', () => {
558
+ const client = buildClient()
559
+ const target = { a: 1, b: null }
560
+ const source = { b: 2, c: null }
561
+
562
+ const result = client.deepMerge(target, source)
563
+
564
+ expect(result).toEqual({ a: 1, b: 2, c: null })
565
+ })
566
+
567
+ test('does not modify target object', () => {
568
+ const client = buildClient()
569
+ const target = { nested: { value: 'original' } }
570
+ const source = { nested: { value: 'modified' } }
571
+
572
+ client.deepMerge(target, source)
573
+
574
+ expect(target.nested.value).toBe('original')
575
+ })
576
+
577
+ test('handles deeply nested JSON strings', () => {
578
+ const client = buildClient()
579
+ const target = {
580
+ data: {
581
+ user: {
582
+ preferences: {},
583
+ },
584
+ },
585
+ }
586
+ const source = {
587
+ data: {
588
+ user: {
589
+ preferences: '{"theme": "dark", "language": "en"}',
590
+ },
591
+ },
592
+ }
593
+
594
+ const result = client.deepMerge(target, source)
595
+
596
+ expect(result.data.user.preferences).toEqual({
597
+ theme: 'dark',
598
+ language: 'en',
599
+ })
600
+ })
601
+
602
+ test('handles array of objects with JSON strings', () => {
603
+ const client = buildClient()
604
+ const target = { items: [] }
605
+ const source = {
606
+ items: [{ id: 1, data: '{"active": true}' }, { id: 2, data: '{"active": false}' }],
607
+ }
608
+
609
+ const result = client.deepMerge(target, source)
610
+
611
+ expect(result.items[0].data).toEqual({ active: true })
612
+ expect(result.items[1].data).toEqual({ active: false })
613
+ })
614
+
615
+ test('preserves non-JSON strings', () => {
616
+ const client = buildClient()
617
+ const target = { title: 'old' }
618
+ const source = { title: 'new title', description: 'just a string' }
619
+
620
+ const result = client.deepMerge(target, source)
621
+
622
+ expect(result).toEqual({
623
+ title: 'new title',
624
+ description: 'just a string',
625
+ })
626
+ })
627
+
628
+ test('handles empty objects', () => {
629
+ const client = buildClient()
630
+
631
+ expect(client.deepMerge({}, { a: 1 })).toEqual({ a: 1 })
632
+ expect(client.deepMerge({ a: 1 }, {})).toEqual({ a: 1 })
633
+ expect(client.deepMerge({}, {})).toEqual({})
634
+ })
635
+ })
636
+ })
@@ -144,7 +144,7 @@ describe("Articles ExportsHandler", () => {
144
144
  const result = await ExportsHandler.index(ctx);
145
145
 
146
146
  expect(result.text).toBe(
147
- "id,type,locale,title,URL,visibility,created_at,updated_at",
147
+ "id,type,locale,title,URL,visibility,created_at,updated_at,translations",
148
148
  );
149
149
  });
150
150
 
@@ -143,7 +143,7 @@ describe("Events ExportsHandler", () => {
143
143
  const result = await ExportsHandler.index(ctx);
144
144
 
145
145
  expect(result.text).toBe(
146
- "id,type,locale,title,URL,visibility,created_at,updated_at",
146
+ "id,type,locale,title,URL,visibility,created_at,updated_at,translations",
147
147
  );
148
148
  });
149
149
 
@@ -143,7 +143,7 @@ describe("Pages ExportsHandler", () => {
143
143
  const result = await ExportsHandler.index(ctx);
144
144
 
145
145
  expect(result.text).toBe(
146
- "id,type,locale,title,URL,visibility,created_at,updated_at",
146
+ "id,type,locale,title,URL,visibility,created_at,updated_at,translations",
147
147
  );
148
148
  });
149
149
 
@@ -206,6 +206,10 @@ describe("CSVExportHelper", () => {
206
206
  visibility: "public",
207
207
  insertedAt: "2024-01-01",
208
208
  updatedAt: "2024-01-02",
209
+ translations: {
210
+ es: {},
211
+ de: {}
212
+ }
209
213
  },
210
214
  ];
211
215
  const headers = [...CSVExportHelper.standardHeaders];
@@ -222,8 +226,8 @@ describe("CSVExportHelper", () => {
222
226
  );
223
227
 
224
228
  expect(result).toBe(
225
- "id,type,locale,title,URL,visibility,created_at,updated_at\r\n" +
226
- "1,article,en,Test Article,/en/article/1,public,2024-01-01,2024-01-02",
229
+ "id,type,locale,title,URL,visibility,created_at,updated_at,translations\r\n" +
230
+ "1,article,en,Test Article,/en/article/1,public,2024-01-01,2024-01-02,es;de",
227
231
  );
228
232
  });
229
233
  });
@@ -297,6 +301,7 @@ describe("CSVExportHelper", () => {
297
301
  visibility: "visibility",
298
302
  created_at: "insertedAt",
299
303
  updated_at: "updatedAt",
304
+ translations: expect.any(Function),
300
305
  });
301
306
  });
302
307
 
@@ -358,6 +363,7 @@ describe("CSVExportHelper", () => {
358
363
  "visibility",
359
364
  "created_at",
360
365
  "updated_at",
366
+ "translations"
361
367
  ]);
362
368
  });
363
369
  });
@@ -0,0 +1,31 @@
1
+ <div class="mb-4">
2
+ <label for="" class="font-semibold hover:cursor-pointer body-3">Translations</label>
3
+ <div class="checkbox-group mb-4">
4
+ <% translations.filter(item => item.code !== _context.locale).forEach((item,idx)=> { %>
5
+ <div class="flex items-center gap-1">
6
+ <% if (utils.includedInCollection(item.code, _context.query["f:translations"])) { %>
7
+ <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' checked value="<%= item.code %>" class="hover:cursor-pointer">
8
+ <% } else { %>
9
+ <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' value="<%= item.code %>" class="hover:cursor-pointer">
10
+ <% } %>
11
+ <label for='locale-<%= item.code %>' class="body-2 hover:cursor-pointer"><%= item.name %></label>
12
+ </div>
13
+ <% }); %>
14
+ </div>
15
+ </div>
16
+
17
+ <div class="mb-4">
18
+ <label for="" class="font-semibold hover:cursor-pointer body-3">Without translations</label>
19
+ <div class="checkbox-group mb-4">
20
+ <% translations.filter(item => item.code !== _context.locale).forEach((item,idx)=> { %>
21
+ <div class="flex items-center gap-1">
22
+ <% if (utils.includedInCollection(item.code, _context.query["f:without_translations"])) { %>
23
+ <input type="checkbox" name="f:without_translations" id='n-locale-<%= item.code %>' checked value="<%= item.code %>" class="hover:cursor-pointer">
24
+ <% } else { %>
25
+ <input type="checkbox" name="f:without_translations" id='n-locale-<%= item.code %>' value="<%= item.code %>" class="hover:cursor-pointer">
26
+ <% } %>
27
+ <label for='n-locale-<%= item.code %>' class="body-2 hover:cursor-pointer"><%= item.name %></label>
28
+ </div>
29
+ <% }); %>
30
+ </div>
31
+ </div>
@@ -0,0 +1,30 @@
1
+ <div class="mb-4">
2
+ <label for="visibility" class="font-semibold hover:cursor-pointer body-3">Visibility</label>
3
+ <select name="f:visibility">
4
+ <option value="">Please select...</option>
5
+
6
+ <% if (_context.query["f:visibility"] === "PRIVATE") { %>
7
+ <option value="PRIVATE" selected>Private</option>
8
+ <% } else { %>
9
+ <option value="PRIVATE">Private</option>
10
+ <% } %>
11
+
12
+ <% if (_context.query["f:visibility"] === "PUBLIC") { %>
13
+ <option value="PUBLIC" selected>Public</option>
14
+ <% } else { %>
15
+ <option value="PUBLIC">Public</option>
16
+ <% } %>
17
+
18
+ <% if (_context.query["f:visibility"] === "UNLISTED") { %>
19
+ <option value="UNLISTED" selected>Unlisted</option>
20
+ <% } else { %>
21
+ <option value="UNLISTED">Unlisted</option>
22
+ <% } %>
23
+
24
+ <% if (_context.query["f:visibility"] === "PROTECTED") { %>
25
+ <option value="PROTECTED" selected>Protected</option>
26
+ <% } else { %>
27
+ <option value="PROTECTED">Protected</option>
28
+ <% } %>
29
+ </select>
30
+ </div>
@@ -139,6 +139,8 @@
139
139
  <input type="text" name="f:keyword" value="<%= _context.query["f:keyword"] %>" />
140
140
  </div>
141
141
 
142
+ <%- include('./../_shared/_visibility_search_input.ejs') %>
143
+
142
144
  <div class="mb-4">
143
145
  <label for="widget" class="font-semibold hover:cursor-pointer body-3">Widget</label>
144
146
  <input type="text" name="f:widget" value="<%= _context.query["f:widget"] %>" />
@@ -159,21 +161,7 @@
159
161
  <input type="datetime-local" name="f:last_updated_to" value="<%= _context.query["f:last_updated_to"] %>" data-no-hints="true" />
160
162
  </div>
161
163
 
162
- <div class="mb-4">
163
- <label for="" class="font-semibold hover:cursor-pointer body-3">Translations</label>
164
- <div class="checkbox-group mb-4">
165
- <% translations.filter(item => item.code !== _context.locale).forEach((item,idx)=> { %>
166
- <div class="flex items-center gap-1">
167
- <% if (utils.includedInCollection(item.code, _context.query["f:translations"])) { %>
168
- <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' checked value="<%= item.code %>" class="hover:cursor-pointer">
169
- <% } else { %>
170
- <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' value="<%= item.code %>" class="hover:cursor-pointer">
171
- <% } %>
172
- <label for='locale-<%= item.code %>' class="body-2 hover:cursor-pointer"><%= item.name %></label>
173
- </div>
174
- <% }); %>
175
- </div>
176
- </div>
164
+ <%- include('./../_shared/_translations_search_input.ejs') %>
177
165
 
178
166
  <div class="mb-4">
179
167
  <label for="" class="font-semibold hover:cursor-pointer body-3">Tags</label>
@@ -139,6 +139,8 @@
139
139
  <input type="text" name="f:keyword" value="<%= _context.query["f:keyword"] %>" />
140
140
  </div>
141
141
 
142
+ <%- include('./../_shared/_visibility_search_input.ejs') %>
143
+
142
144
  <div class="mb-4">
143
145
  <label for="widget" class="font-semibold hover:cursor-pointer body-3">Widget</label>
144
146
  <input type="text" name="f:widget" value="<%= _context.query["f:widget"] %>" />
@@ -159,21 +161,7 @@
159
161
  <input type="datetime-local" name="f:last_updated_to" value="<%= _context.query["f:last_updated_to"] %>" />
160
162
  </div>
161
163
 
162
- <div class="mb-4">
163
- <label for="" class="font-semibold hover:cursor-pointer body-3">Translations</label>
164
- <div class="checkbox-group mb-4">
165
- <% translations.filter(item => item.code !== _context.locale).forEach((item,idx)=> { %>
166
- <div class="flex items-center gap-1">
167
- <% if (utils.includedInCollection(item.code, _context.query["f:translations"])) { %>
168
- <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' checked value="<%= item.code %>" class="hover:cursor-pointer">
169
- <% } else { %>
170
- <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' value="<%= item.code %>" class="hover:cursor-pointer">
171
- <% } %>
172
- <label for='locale-<%= item.code %>' class="body-2 hover:cursor-pointer"><%= item.name %></label>
173
- </div>
174
- <% }); %>
175
- </div>
176
- </div>
164
+ <%- include('./../_shared/_translations_search_input.ejs') %>
177
165
 
178
166
  <div class="mb-4">
179
167
  <label for="" class="font-semibold hover:cursor-pointer body-3">Tags</label>
@@ -134,6 +134,8 @@
134
134
  <input type="text" name="f:keyword" value="<%= _context.query["f:keyword"] %>" />
135
135
  </div>
136
136
 
137
+ <%- include('./../_shared/_visibility_search_input.ejs') %>
138
+
137
139
  <div class="mb-4">
138
140
  <label for="widget" class="font-semibold hover:cursor-pointer body-3">Widget</label>
139
141
  <input type="text" name="f:widget" value="<%= _context.query["f:widget"] %>" />
@@ -154,21 +156,7 @@
154
156
  <input type="datetime-local" name="f:last_updated_to" value="<%= _context.query["f:last_updated_to"] %>" />
155
157
  </div>
156
158
 
157
- <div class="mb-4">
158
- <label for="" class="font-semibold hover:cursor-pointer body-3">Translations</label>
159
- <div class="checkbox-group mb-4">
160
- <% translations.filter(item => item.code !== _context.locale).forEach((item,idx)=> { %>
161
- <div class="flex items-center gap-1">
162
- <% if (utils.includedInCollection(item.code, _context.query["f:translations"])) { %>
163
- <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' checked value="<%= item.code %>" class="hover:cursor-pointer">
164
- <% } else { %>
165
- <input type="checkbox" name="f:translations" id='locale-<%= item.code %>' value="<%= item.code %>" class="hover:cursor-pointer">
166
- <% } %>
167
- <label for='locale-<%= item.code %>' class="body-2 hover:cursor-pointer"><%= item.name %></label>
168
- </div>
169
- <% }); %>
170
- </div>
171
- </div>
159
+ <%- include('./../_shared/_translations_search_input.ejs') %>
172
160
 
173
161
  <div class="mb-4">
174
162
  <label for="" class="font-semibold hover:cursor-pointer body-3">Tags</label>
@@ -17,6 +17,12 @@
17
17
  Layout and Navigation
18
18
  </a>
19
19
  </div>
20
+
21
+ <div class="<%= active === 'variables' ? activeClass : inactiveClass %>">
22
+ <a href="<%= `/${_context.locale}/settings/variables/edit` %>" class="text-raketa-primary font-semibold">
23
+ Variables
24
+ </a>
25
+ </div>
20
26
  </div>
21
27
  </div>
22
- </div>
28
+ </div>
@@ -0,0 +1,178 @@
1
+ <div class="page-wrapper">
2
+ <div class="page-title">
3
+ <h1 class="heading-1">Global Variables</h1>
4
+ </div>
5
+
6
+ <%- render_partial('../_nav.ejs', { active: 'variables' }) %>
7
+
8
+ <% form_for({ resource }, errors, (f) => { %>
9
+ <form action="<%= `/${_context.locale}/settings/variables` %>" method="post" class="form" onsubmit="filterEmptyVariables()">
10
+
11
+ <div class="form-actions mb-6">
12
+ <div class="btn-toolbar">
13
+ <%- f.submit('Save', { class: 'btn-primary' }) %>
14
+ </div>
15
+ </div>
16
+
17
+ <div class="flex gap-4">
18
+ <div class="flex-1">
19
+ <div class="mb-6">
20
+ <table class="data-grid w-full">
21
+ <thead>
22
+ <tr>
23
+ <th>Variable</th>
24
+ <th>Value</th>
25
+ <th class="actions"></th>
26
+ </tr>
27
+ </thead>
28
+ <tbody id="variables-tbody">
29
+ </tbody>
30
+ </table>
31
+
32
+ <div class="p-4">
33
+ <button type="button" class="btn-text" onclick="addVariable()">
34
+ Add new variable
35
+ </button>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="form-actions">
40
+ <%- f.submit('Save', { class: 'btn-primary' }) %>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="w-80">
45
+ <div class="help">
46
+ <h3 class="heading-3 mb-3">How to use the global variables?</h3>
47
+
48
+ <p class="body-2 mb-4">The variables are intended for short texts, their values can be only 30 characters long.</p>
49
+
50
+ <p class="body-2">Use them in page settings or widgets like this {{VARIABLE_NAME}}</p>
51
+
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ </form>
57
+ <% }) %>
58
+ </div>
59
+
60
+ <script>
61
+ let variables = <%- JSON.stringify(resource._global_variables || {}) %>;
62
+
63
+ function render() {
64
+ const tbody = document.getElementById('variables-tbody');
65
+ const entries = Object.entries(variables);
66
+
67
+ tbody.innerHTML = entries.map(([name, value]) => `
68
+ <tr class="variable-row">
69
+ <td class="w-1/2">
70
+ <input type="text"
71
+ data-key="${escapeHtml(name)}"
72
+ value="${escapeHtml(name)}"
73
+ placeholder="VARIABLE_NAME"
74
+ maxlength="30"
75
+ pattern="[A-Z_][A-Z0-9_]*"
76
+ title="Only uppercase letters, numbers and underscore are allowed"
77
+ class="border border-neutral-300 rounded px-3 py-2 shadow-inner w-full"
78
+ onchange="renameVariable('${escapeHtml(name)}', this.value)">
79
+ </td>
80
+ <td class="w-1/2">
81
+ <input type="text"
82
+ value="${escapeHtml(value)}"
83
+ placeholder="Value"
84
+ maxlength="30"
85
+ class="border border-neutral-300 rounded px-3 py-2 shadow-inner w-full"
86
+ oninput="updateValue('${escapeHtml(name)}', this.value)">
87
+ </td>
88
+ <td class="actions">
89
+ <button type="button" class="btn-danger btn-sm" onclick="removeVariable('${escapeHtml(name)}')">
90
+ Delete
91
+ </button>
92
+ </td>
93
+ </tr>
94
+ `).join('');
95
+ }
96
+
97
+ function updateValue(key, value) {
98
+ variables[key] = value;
99
+ }
100
+
101
+ function renameVariable(oldKey, newKey) {
102
+ newKey = newKey.trim();
103
+
104
+ if (!newKey) {
105
+ alert('Variable name cannot be empty');
106
+ render();
107
+ return;
108
+ }
109
+
110
+ if (oldKey === newKey) return;
111
+
112
+ if (variables[newKey] !== undefined) {
113
+ alert('Variable name already exists');
114
+ render();
115
+ return;
116
+ }
117
+
118
+ variables[newKey] = variables[oldKey];
119
+ delete variables[oldKey];
120
+ render();
121
+ }
122
+
123
+ function addVariable() {
124
+ let counter = 1;
125
+ let newKey = 'NEW_VAR';
126
+
127
+ while (variables[newKey] !== undefined) {
128
+ newKey = `NEW_VAR_${counter}`;
129
+ counter++;
130
+ }
131
+
132
+ variables[newKey] = '';
133
+ render();
134
+
135
+ const inputs = document.querySelectorAll(`input[data-key="${newKey}"]`);
136
+ if (inputs.length > 0) {
137
+ const row = inputs[0].closest('tr');
138
+ const valueInput = row.querySelector('input[placeholder="Value"]');
139
+ if (valueInput) {
140
+ valueInput.focus();
141
+ }
142
+ }
143
+ }
144
+
145
+ function removeVariable(key) {
146
+ delete variables[key];
147
+ render();
148
+ }
149
+
150
+ function filterEmptyVariables() {
151
+ for (const key in variables) {
152
+ if (!key.trim() || !variables[key].trim()) {
153
+ delete variables[key];
154
+ }
155
+ }
156
+
157
+ const form = document.querySelector('form');
158
+
159
+ form.querySelectorAll('input[name^="resource[_global_variables]"]').forEach(el => el.remove());
160
+
161
+ for (const [key, value] of Object.entries(variables)) {
162
+ const input = document.createElement('input');
163
+ input.type = 'hidden';
164
+ input.name = `resource[_global_variables][${key}]`;
165
+ input.value = value;
166
+ form.appendChild(input);
167
+ }
168
+ }
169
+
170
+ function escapeHtml(text) {
171
+ if (!text) return '';
172
+ const div = document.createElement('div');
173
+ div.textContent = text;
174
+ return div.innerHTML;
175
+ }
176
+
177
+ render();
178
+ </script>