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