adminforth 2.18.2 → 2.19.0-next.2

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.
@@ -0,0 +1,522 @@
1
+ import { nextTick, onMounted, ref, resolveComponent } from 'vue';
2
+ import type { CoreConfig } from '../spa_types/core';
3
+ import type { ValidationObject } from '../types/Common.js';
4
+ import router from "../router";
5
+ import { useCoreStore } from '../stores/core';
6
+ import { useUserStore } from '../stores/user';
7
+ import { Dropdown } from 'flowbite';
8
+ import adminforth from '../adminforth';
9
+ import sanitizeHtml from 'sanitize-html'
10
+ import debounce from 'debounce';
11
+ import type { AdminForthResourceColumnInputCommon, Predicate } from '@/types/Common';
12
+ import { i18nInstance } from '../i18n'
13
+
14
+
15
+
16
+ const LS_LANG_KEY = `afLanguage`;
17
+ const MAX_CONSECUTIVE_EMPTY_RESULTS = 2;
18
+ const ITEMS_PER_PAGE_LIMIT = 100;
19
+
20
+ export async function callApi({path, method, body, headers, silentError = false}: {
21
+ path: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
22
+ body?: any
23
+ headers?: Record<string, string>
24
+ silentError?: boolean
25
+ }): Promise<any> {
26
+ const t = i18nInstance?.global.t || ((s: string) => s)
27
+ const options = {
28
+ method,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'accept-language': localStorage.getItem(LS_LANG_KEY) || 'en',
32
+ ...headers
33
+ },
34
+ body: JSON.stringify(body),
35
+ };
36
+ const fullPath = `${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}${path}`;
37
+ try {
38
+ const r = await fetch(fullPath, options);
39
+ if (r.status == 401 ) {
40
+ useUserStore().unauthorize();
41
+ useCoreStore().resetAdminUser();
42
+ const currentPath = router.currentRoute.value.path;
43
+ const homeRoute = router.getRoutes().find(route => route.name === 'home');
44
+ const homePagePath = (homeRoute?.redirect as string) || '/';
45
+ let next = '';
46
+ if (currentPath !== '/login' && currentPath !== homePagePath) {
47
+ if (Object.keys(router.currentRoute.value.query).length > 0) {
48
+ next = currentPath + '?' + Object.entries(router.currentRoute.value.query).map(([key, value]) => `${key}=${value}`).join('&');
49
+ } else {
50
+ next = currentPath;
51
+ }
52
+ await router.push({ name: 'login', query: { next: next } });
53
+ } else {
54
+ await router.push({ name: 'login' });
55
+ }
56
+ return null;
57
+ }
58
+ return await r.json();
59
+ } catch(e) {
60
+ // if it is internal error, say to user
61
+ if (e instanceof TypeError && e.message === 'Failed to fetch') {
62
+ // this is a network error
63
+ if (!silentError) {
64
+ adminforth.alert({variant:'danger', message: t('Network error, please check your Internet connection and try again'),})
65
+ }
66
+ return null;
67
+ }
68
+
69
+ if (!silentError) {
70
+ adminforth.alert({variant:'danger', message: t('Something went wrong, please try again later'),})
71
+ }
72
+ console.error(`error in callApi ${path}`, e);
73
+ }
74
+ }
75
+
76
+ export async function callAdminForthApi({ path, method, body=undefined, headers=undefined, silentError = false }: {
77
+ path: string,
78
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
79
+ body?: any,
80
+ headers?: Record<string, string>,
81
+ silentError?: boolean
82
+ }): Promise<any> {
83
+ try {
84
+ return callApi({path: `/adminapi/v1${path}`, method, body, headers, silentError} );
85
+ } catch (e) {
86
+ console.error('error', e);
87
+ return { error: `Unexpected error: ${e}` };
88
+ }
89
+ }
90
+
91
+ export function getCustomComponent({ file, meta }: { file: string, meta?: any }) {
92
+ const name = file.replace(/@/g, '').replace(/\./g, '').replace(/\//g, '');
93
+ return resolveComponent(name);
94
+ }
95
+
96
+ export function getIcon(icon: string) {
97
+ // icon format is "feather:icon-name". We need to get IconName in pascal case
98
+ if (!icon.includes(':')) {
99
+ throw new Error('Icon name should be in format "icon-set:icon-name"');
100
+ }
101
+ const [iconSet, iconName] = icon.split(':');
102
+ const compName = 'Icon' + iconName.split('-').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join('');
103
+ return resolveComponent(compName);
104
+ }
105
+
106
+ export const loadFile = (file: string) => {
107
+ if (file.startsWith('http')) {
108
+ return file;
109
+ }
110
+ let path;
111
+ let baseUrl = '';
112
+ if (file.startsWith('@/')) {
113
+ path = file.replace('@/', '');
114
+ baseUrl = new URL(`../${path}`, import.meta.url).href;
115
+ } else if (file.startsWith('@@/')) {
116
+ path = file.replace('@@/', '');
117
+ baseUrl = new URL(`../custom/${path}`, import.meta.url).href;
118
+ } else {
119
+ baseUrl = new URL(`../${file}`, import.meta.url).href;
120
+ }
121
+ return baseUrl;
122
+ }
123
+
124
+
125
+ export function checkEmptyValues(value: any, viewType: 'show' | 'list' ) {
126
+ const config: CoreConfig | {} | null = useCoreStore().config;
127
+ let emptyFieldPlaceholder = '';
128
+ if (config && 'emptyFieldPlaceholder' in config) {
129
+ const efp = (config as CoreConfig).emptyFieldPlaceholder;
130
+ if(typeof efp === 'string') {
131
+ emptyFieldPlaceholder = efp;
132
+ } else {
133
+ emptyFieldPlaceholder = efp?.[viewType] || '';
134
+ }
135
+ if (value === null || value === undefined || value === '') {
136
+ return emptyFieldPlaceholder;
137
+ }
138
+ }
139
+ return value;
140
+ }
141
+
142
+ export function checkAcessByAllowedActions(allowedActions:any, action:any ) {
143
+ if (!allowedActions) {
144
+ console.warn('allowedActions not set');
145
+ return;
146
+ }
147
+ if(allowedActions[action] === false) {
148
+ console.warn(`Action ${action} is not allowed`);
149
+ router.back();
150
+ }
151
+ }
152
+
153
+ export function initThreeDotsDropdown() {
154
+ const threeDotsDropdown: HTMLElement | null = document.querySelector('#listThreeDotsDropdown');
155
+ if (threeDotsDropdown) {
156
+ // this resource has three dots dropdown
157
+ const dd = new Dropdown(
158
+ threeDotsDropdown,
159
+ document.querySelector('[data-dropdown-toggle="listThreeDotsDropdown"]') as HTMLElement,
160
+ { placement: 'bottom-end' }
161
+ );
162
+ adminforth.list.closeThreeDotsDropdown = () => {
163
+ dd.hide();
164
+ }
165
+ }
166
+ }
167
+
168
+ export function applyRegexValidation(value: any, validation: ValidationObject[] | undefined) {
169
+
170
+ if ( validation?.length ) {
171
+ const validationArray = validation;
172
+ for (let i = 0; i < validationArray.length; i++) {
173
+ if (validationArray[i].regExp) {
174
+ let flags = '';
175
+ if (validationArray[i].caseSensitive) {
176
+ flags += 'i';
177
+ }
178
+ if (validationArray[i].multiline) {
179
+ flags += 'm';
180
+ }
181
+ if (validationArray[i].global) {
182
+ flags += 'g';
183
+ }
184
+
185
+ const regExp = new RegExp(validationArray[i].regExp, flags);
186
+ if (value === undefined || value === null) {
187
+ value = '';
188
+ }
189
+ let valueS = `${value}`;
190
+
191
+ if (!regExp.test(valueS)) {
192
+ return validationArray[i].message;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ export function currentQuery() {
200
+ return router.currentRoute.value.query;
201
+ }
202
+
203
+ export function setQuery(query: any) {
204
+ const currentQuery = { ...router.currentRoute.value.query, ...query };
205
+ router.replace({
206
+ query: currentQuery,
207
+ });
208
+ }
209
+
210
+ export function verySimpleHash(str: string): string {
211
+ return `${str.split('').reduce((a, b)=>{a=((a<<5)-a)+b.charCodeAt(0);return a&a},0)}`;
212
+ }
213
+
214
+ export function humanifySize(size: number) {
215
+ if (!size) {
216
+ return '';
217
+ }
218
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
219
+ let i = 0
220
+ while (size >= 1024 && i < units.length - 1) {
221
+ size /= 1024
222
+ i++
223
+ }
224
+ return `${size.toFixed(1)} ${units[i]}`
225
+ }
226
+
227
+ export function protectAgainstXSS(value: string) {
228
+ return sanitizeHtml(value, {
229
+ allowedTags: [
230
+ "address", "article", "aside", "footer", "header", "h1", "h2", "h3", "h4",
231
+ "h5", "h6", "hgroup", "main", "nav", "section", "blockquote", "dd", "div",
232
+ "dl", "dt", "figcaption", "figure", "hr", "li", "main", "ol", "p", "pre",
233
+ "ul", "a", "abbr", "b", "bdi", "bdo", "br", "cite", "code", "data", "dfn",
234
+ "em", "i", "kbd", "mark", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp",
235
+ "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "caption",
236
+ "col", "colgroup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", 'img'
237
+ ],
238
+ allowedAttributes: {
239
+ 'li': [ 'data-list' ],
240
+ 'img': [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ],
241
+ // Allow markup on spans (classes & styles), and
242
+ // generic data/aria/style attributes on any element. (e.g. for KaTeX-related previews)
243
+ 'span': [ 'class', 'style' ],
244
+ '*': [ 'data-*', 'aria-*', 'style' ]
245
+ },
246
+ });
247
+ }
248
+
249
+ export function isPolymorphicColumn(column: any): boolean {
250
+ return !!(column.foreignResource?.polymorphicResources && column.foreignResource.polymorphicResources.length > 0);
251
+ }
252
+
253
+ export function handleForeignResourcePagination(
254
+ column: any,
255
+ items: any[],
256
+ emptyResultsCount: number = 0,
257
+ isSearching: boolean = false
258
+ ): { hasMore: boolean; emptyResultsCount: number } {
259
+ const isPolymorphic = isPolymorphicColumn(column);
260
+
261
+ if (isPolymorphic) {
262
+ if (isSearching) {
263
+ return {
264
+ hasMore: items.length > 0,
265
+ emptyResultsCount: 0
266
+ };
267
+ } else {
268
+ if (items.length === 0) {
269
+ const newEmptyCount = emptyResultsCount + 1;
270
+ return {
271
+ hasMore: newEmptyCount < MAX_CONSECUTIVE_EMPTY_RESULTS, // Stop loading after 2 consecutive empty results
272
+ emptyResultsCount: newEmptyCount
273
+ };
274
+ } else {
275
+ return {
276
+ hasMore: true,
277
+ emptyResultsCount: 0
278
+ };
279
+ }
280
+ }
281
+ } else {
282
+ return {
283
+ hasMore: items.length === ITEMS_PER_PAGE_LIMIT,
284
+ emptyResultsCount: 0
285
+ };
286
+ }
287
+ }
288
+
289
+ export async function loadMoreForeignOptions({
290
+ columnName,
291
+ searchTerm = '',
292
+ columns,
293
+ resourceId,
294
+ columnOptions,
295
+ columnLoadingState,
296
+ columnOffsets,
297
+ columnEmptyResultsCount
298
+ }: {
299
+ columnName: string;
300
+ searchTerm?: string;
301
+ columns: any[];
302
+ resourceId: string;
303
+ columnOptions: any;
304
+ columnLoadingState: any;
305
+ columnOffsets: any;
306
+ columnEmptyResultsCount: any;
307
+ }) {
308
+ const column = columns?.find(c => c.name === columnName);
309
+ if (!column || !column.foreignResource) return;
310
+
311
+ const state = columnLoadingState[columnName];
312
+ if (state.loading || !state.hasMore) return;
313
+
314
+ state.loading = true;
315
+
316
+ try {
317
+ const list = await callAdminForthApi({
318
+ method: 'POST',
319
+ path: `/get_resource_foreign_data`,
320
+ body: {
321
+ resourceId,
322
+ column: columnName,
323
+ limit: 100,
324
+ offset: columnOffsets[columnName],
325
+ search: searchTerm,
326
+ },
327
+ });
328
+
329
+ if (!list || !Array.isArray(list.items)) {
330
+ console.warn(`Unexpected API response for column ${columnName}:`, list);
331
+ state.hasMore = false;
332
+ return;
333
+ }
334
+
335
+ if (!columnOptions.value) {
336
+ columnOptions.value = {};
337
+ }
338
+ if (!columnOptions.value[columnName]) {
339
+ columnOptions.value[columnName] = [];
340
+ }
341
+ columnOptions.value[columnName].push(...list.items);
342
+
343
+ columnOffsets[columnName] += 100;
344
+
345
+ const paginationResult = handleForeignResourcePagination(
346
+ column,
347
+ list.items,
348
+ columnEmptyResultsCount[columnName] || 0,
349
+ false // not searching
350
+ );
351
+
352
+ columnEmptyResultsCount[columnName] = paginationResult.emptyResultsCount;
353
+ state.hasMore = paginationResult.hasMore;
354
+
355
+ } catch (error) {
356
+ console.error('Error loading more options:', error);
357
+ } finally {
358
+ state.loading = false;
359
+ }
360
+ }
361
+
362
+ export async function searchForeignOptions({
363
+ columnName,
364
+ searchTerm,
365
+ columns,
366
+ resourceId,
367
+ columnOptions,
368
+ columnLoadingState,
369
+ columnOffsets,
370
+ columnEmptyResultsCount
371
+ }: {
372
+ columnName: string;
373
+ searchTerm: string;
374
+ columns: any[];
375
+ resourceId: string;
376
+ columnOptions: any;
377
+ columnLoadingState: any;
378
+ columnOffsets: any;
379
+ columnEmptyResultsCount: any;
380
+ }) {
381
+ const column = columns?.find(c => c.name === columnName);
382
+
383
+ if (!column || !column.foreignResource || !column.foreignResource.searchableFields) {
384
+ return;
385
+ }
386
+
387
+ const state = columnLoadingState[columnName];
388
+ if (state.loading) return;
389
+
390
+ state.loading = true;
391
+
392
+ try {
393
+ const list = await callAdminForthApi({
394
+ method: 'POST',
395
+ path: `/get_resource_foreign_data`,
396
+ body: {
397
+ resourceId,
398
+ column: columnName,
399
+ limit: 100,
400
+ offset: 0,
401
+ search: searchTerm,
402
+ },
403
+ });
404
+
405
+ if (!list || !Array.isArray(list.items)) {
406
+ console.warn(`Unexpected API response for column ${columnName}:`, list);
407
+ state.hasMore = false;
408
+ return;
409
+ }
410
+
411
+ if (!columnOptions.value) {
412
+ columnOptions.value = {};
413
+ }
414
+ columnOptions.value[columnName] = list.items;
415
+ columnOffsets[columnName] = 100;
416
+
417
+ const paginationResult = handleForeignResourcePagination(
418
+ column,
419
+ list.items,
420
+ columnEmptyResultsCount[columnName] || 0,
421
+ true // is searching
422
+ );
423
+
424
+ columnEmptyResultsCount[columnName] = paginationResult.emptyResultsCount;
425
+ state.hasMore = paginationResult.hasMore;
426
+
427
+ } catch (error) {
428
+ console.error('Error searching options:', error);
429
+ } finally {
430
+ state.loading = false;
431
+ }
432
+ }
433
+
434
+ export function createSearchInputHandlers(
435
+ columns: any[],
436
+ searchFunction: (columnName: string, searchTerm: string) => void,
437
+ getDebounceMs?: (column: any) => number
438
+ ) {
439
+ if (!columns) return {};
440
+
441
+ return columns.reduce((acc, c) => {
442
+ if (c.foreignResource && c.foreignResource.searchableFields) {
443
+ const debounceMs = getDebounceMs ? getDebounceMs(c) : 300;
444
+ return {
445
+ ...acc,
446
+ [c.name]: debounce((searchTerm: string) => {
447
+ searchFunction(c.name, searchTerm);
448
+ }, debounceMs),
449
+ };
450
+ }
451
+ return acc;
452
+ }, {} as Record<string, (searchTerm: string) => void>);
453
+ }
454
+
455
+ export function checkShowIf(c: AdminForthResourceColumnInputCommon, record: Record<string, any>, allColumns: AdminForthResourceColumnInputCommon[]) {
456
+ if (!c.showIf) return true;
457
+ const recordCopy = { ...record };
458
+ for (const col of allColumns) {
459
+ if (!recordCopy[col.name]) {
460
+ recordCopy[col.name] = null;
461
+ }
462
+ }
463
+ const evaluatePredicate = (predicate: Predicate): boolean => {
464
+ const results: boolean[] = [];
465
+
466
+ if ("$and" in predicate) {
467
+ results.push(predicate.$and.every(evaluatePredicate));
468
+ }
469
+
470
+ if ("$or" in predicate) {
471
+ results.push(predicate.$or.some(evaluatePredicate));
472
+ }
473
+
474
+ const fieldEntries = Object.entries(predicate).filter(([key]) => !key.startsWith('$'));
475
+ if (fieldEntries.length > 0) {
476
+ const fieldResult = fieldEntries.every(([field, condition]) => {
477
+ const recordValue = recordCopy[field];
478
+
479
+ if (condition === undefined) {
480
+ return true;
481
+ }
482
+ if (typeof condition !== "object" || condition === null) {
483
+ return recordValue === condition;
484
+ }
485
+
486
+ if ("$eq" in condition) return recordValue === condition.$eq;
487
+ if ("$not" in condition) return recordValue !== condition.$not;
488
+ if ("$gt" in condition) return recordValue > condition.$gt;
489
+ if ("$gte" in condition) return recordValue >= condition.$gte;
490
+ if ("$lt" in condition) return recordValue < condition.$lt;
491
+ if ("$lte" in condition) return recordValue <= condition.$lte;
492
+ if ("$in" in condition) return (Array.isArray(condition.$in) && condition.$in.includes(recordValue));
493
+ if ("$nin" in condition) return (Array.isArray(condition.$nin) && !condition.$nin.includes(recordValue));
494
+ if ("$includes" in condition)
495
+ return (
496
+ Array.isArray(recordValue) &&
497
+ recordValue.includes(condition.$includes)
498
+ );
499
+ if ("$nincludes" in condition)
500
+ return (
501
+ Array.isArray(recordValue) &&
502
+ !recordValue.includes(condition.$nicludes)
503
+ );
504
+
505
+ return true;
506
+ });
507
+ results.push(fieldResult);
508
+ }
509
+
510
+ return results.every(result => result);
511
+ };
512
+
513
+ return evaluatePredicate(c.showIf);
514
+ }
515
+
516
+ export function btoa_function(source: string): string {
517
+ return btoa(source);
518
+ }
519
+
520
+ export function atob_function(source: string): string {
521
+ return atob(source);
522
+ }