@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.
- package/dist/application.css +23 -0
- package/package.json +3 -3
- package/src/cloud/Pages.js +10 -8
- package/src/handlers/_shared/SettingsHandler.js +4 -0
- package/src/routeSettings.js +4 -0
- package/src/tests/RaketaClient.test.js +636 -0
- package/src/views/settings/_nav.ejs +7 -1
- package/src/views/settings/variables/edit_form.ejs +178 -0
package/dist/application.css
CHANGED
|
@@ -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.
|
|
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.
|
|
16
|
-
"@babel/preset-env": "7.
|
|
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",
|
package/src/cloud/Pages.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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) {
|
package/src/routeSettings.js
CHANGED
|
@@ -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>
|