@raketa-cloud/admin 1.7.5 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.6",
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: {
@@ -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) {
@@ -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
+ })
@@ -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>