adminforth 2.22.0-next.4 → 2.22.0-next.41

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.
Files changed (60) hide show
  1. package/commands/createApp/utils.js +43 -3
  2. package/dist/auth.d.ts +1 -0
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +2 -2
  5. package/dist/auth.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/modules/codeInjector.d.ts.map +1 -1
  11. package/dist/modules/codeInjector.js +7 -0
  12. package/dist/modules/codeInjector.js.map +1 -1
  13. package/dist/modules/restApi.js.map +1 -1
  14. package/dist/modules/styles.d.ts +4 -0
  15. package/dist/modules/styles.d.ts.map +1 -1
  16. package/dist/modules/styles.js +4 -0
  17. package/dist/modules/styles.js.map +1 -1
  18. package/dist/modules/utils.d.ts +1 -0
  19. package/dist/modules/utils.d.ts.map +1 -1
  20. package/dist/modules/utils.js +18 -15
  21. package/dist/modules/utils.js.map +1 -1
  22. package/dist/spa/package-lock.json +28 -0
  23. package/dist/spa/package.json +1 -0
  24. package/dist/spa/src/adminforth.ts +1 -0
  25. package/dist/spa/src/afcl/Button.vue +12 -5
  26. package/dist/spa/src/afcl/Dialog.vue +155 -126
  27. package/dist/spa/src/afcl/Input.vue +1 -26
  28. package/dist/spa/src/afcl/Modal.vue +137 -0
  29. package/dist/spa/src/afcl/ProgressBar.vue +42 -6
  30. package/dist/spa/src/afcl/Table.vue +1 -1
  31. package/dist/spa/src/afcl/TreeMapChart.vue +136 -0
  32. package/dist/spa/src/afcl/index.ts +3 -1
  33. package/dist/spa/src/components/AcceptModal.vue +2 -0
  34. package/dist/spa/src/components/GroupsTable.vue +14 -12
  35. package/dist/spa/src/components/ShowTable.vue +4 -2
  36. package/dist/spa/src/components/Sidebar.vue +1 -1
  37. package/dist/spa/src/components/ThreeDotsMenu.vue +1 -1
  38. package/dist/spa/src/stores/core.ts +2 -1
  39. package/dist/spa/src/stores/modal.ts +6 -2
  40. package/dist/spa/src/types/Back.ts +19 -0
  41. package/dist/spa/src/types/Common.ts +6 -0
  42. package/dist/spa/src/types/FrontendAPI.ts +4 -3
  43. package/dist/spa/src/types/adapters/CompletionAdapter.ts +8 -0
  44. package/dist/spa/src/utils/utils.ts +116 -8
  45. package/dist/spa/src/views/CreateView.vue +14 -9
  46. package/dist/spa/src/views/EditView.vue +10 -10
  47. package/dist/spa/src/views/ListView.vue +2 -0
  48. package/dist/spa/tailwind.config.js +1 -1
  49. package/dist/types/Back.d.ts +17 -0
  50. package/dist/types/Back.d.ts.map +1 -1
  51. package/dist/types/Back.js.map +1 -1
  52. package/dist/types/Common.d.ts +5 -0
  53. package/dist/types/Common.d.ts.map +1 -1
  54. package/dist/types/Common.js.map +1 -1
  55. package/dist/types/FrontendAPI.d.ts +4 -0
  56. package/dist/types/FrontendAPI.d.ts.map +1 -1
  57. package/dist/types/FrontendAPI.js.map +1 -1
  58. package/dist/types/adapters/CompletionAdapter.d.ts +7 -1
  59. package/dist/types/adapters/CompletionAdapter.d.ts.map +1 -1
  60. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ <template>
2
+ <div class="afcl-treemap -mb-2" ref="chart"></div>
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import ApexCharts, { type ApexOptions } from 'apexcharts';
7
+ import { ref, type Ref, watch, computed, onUnmounted } from 'vue';
8
+
9
+ const chart: Ref<HTMLDivElement | null> = ref(null);
10
+
11
+ const props = defineProps<{
12
+ data: {
13
+ x: string,
14
+ [key: string]: any,
15
+ }[],
16
+ series: {
17
+ name: string,
18
+ fieldName: string,
19
+ }[],
20
+ options?: ApexOptions,
21
+ }>();
22
+
23
+ const optionsBase: ApexOptions = {
24
+ chart: {
25
+ height: 350,
26
+ type: 'treemap',
27
+ fontFamily: 'Inter, sans-serif',
28
+ toolbar: {
29
+ show: false,
30
+ },
31
+ },
32
+ legend: {
33
+ show: false,
34
+ },
35
+ dataLabels: {
36
+ enabled: true,
37
+ style: {
38
+ fontFamily: 'Inter, sans-serif',
39
+ colors: ['#FFFFFF'],
40
+ },
41
+ },
42
+ plotOptions: {
43
+ treemap: {
44
+ distributed: true,
45
+ enableShades: false,
46
+ },
47
+ },
48
+ };
49
+
50
+ const options = computed(() => {
51
+ if (props.data?.length > 0) {
52
+ props.series.forEach((s) => {
53
+ if (props.data[0][s.fieldName] === undefined) {
54
+ throw new Error(
55
+ `Field ${s.fieldName} not found even in first data point ${JSON.stringify(props.data[0])}, something is wrong`,
56
+ );
57
+ }
58
+ });
59
+ }
60
+
61
+ const nextOptions: ApexOptions = {
62
+ ...optionsBase,
63
+ series: props.series.map((s) => ({
64
+ name: s.name,
65
+ data: (props.data ?? []).map((item: any) => {
66
+ const { x, y: _ignoredY, ...rest } = item ?? {};
67
+ return {
68
+ x,
69
+ y: item?.[s.fieldName],
70
+ ...rest,
71
+ };
72
+ }),
73
+ })),
74
+ };
75
+
76
+ function mergeOptions(target: any, source: any) {
77
+ if (!source) {
78
+ return;
79
+ }
80
+ for (const key in source) {
81
+ if (typeof source[key] === 'object' && !Array.isArray(source[key])) {
82
+ if (!target[key]) {
83
+ target[key] = {};
84
+ }
85
+ mergeOptions(target[key], source[key]);
86
+ } else {
87
+ target[key] = source[key];
88
+ }
89
+ }
90
+ }
91
+
92
+ mergeOptions(nextOptions, props.options);
93
+ return nextOptions;
94
+ });
95
+
96
+ let apexChart: ApexCharts | null = null;
97
+
98
+ watch(() => [options.value, chart.value], (value) => {
99
+ if (!value || !chart.value) {
100
+ return;
101
+ }
102
+
103
+ if (apexChart) {
104
+ apexChart.updateOptions(options.value);
105
+ } else {
106
+ apexChart = new ApexCharts(chart.value, options.value);
107
+ apexChart.render();
108
+ }
109
+ });
110
+
111
+ onUnmounted(() => {
112
+ if (apexChart) {
113
+ apexChart.destroy();
114
+ }
115
+ });
116
+ </script>
117
+
118
+ <style lang="scss">
119
+ :root {
120
+ --afcl-treemap-text: #FFFFFF;
121
+ }
122
+
123
+ [data-theme='dark'] {
124
+ --afcl-treemap-text: #FFFFFF;
125
+ }
126
+
127
+ .afcl-treemap {
128
+ .apexcharts-datalabel {
129
+ fill: var(--afcl-treemap-text);
130
+ }
131
+
132
+ .apexcharts-legend-text {
133
+ color: var(--afcl-treemap-text) !important;
134
+ }
135
+ }
136
+ </style>
@@ -12,6 +12,7 @@ export { default as Dropzone } from './Dropzone.vue';
12
12
  export { default as AreaChart } from './AreaChart.vue';
13
13
  export { default as BarChart } from './BarChart.vue';
14
14
  export { default as PieChart } from './PieChart.vue';
15
+ export { default as TreeMapChart } from './TreeMapChart.vue';
15
16
  export { default as Table } from './Table.vue';
16
17
  export { default as ProgressBar } from './ProgressBar.vue';
17
18
  export { default as Spinner } from './Spinner.vue';
@@ -24,4 +25,5 @@ export { default as Toggle } from './Toggle.vue';
24
25
  export { default as DatePicker } from './DatePicker.vue';
25
26
  export { default as Textarea } from './Textarea.vue';
26
27
  export { default as ButtonGroup } from './ButtonGroup.vue';
27
- export { default as Card } from './Card.vue';
28
+ export { default as Card } from './Card.vue';
29
+ export { default as Modal } from './Modal.vue';
@@ -14,6 +14,8 @@
14
14
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
15
15
  </svg>
16
16
  <h3 class="afcl-confirmation-title mb-5 text-lg font-normal text-lightAcceptModalText dark:text-darkAcceptModalText">{{ modalStore?.modalContent?.content }}</h3>
17
+ <h3 class=" afcl-confirmation-title mb-5 text-lg font-normal text-lightAcceptModalText dark:text-darkAcceptModalText" v-html="modalStore?.modalContent?.contentHTML"></h3>
18
+
17
19
  <button @click="()=>{ modalStore.onAcceptFunction(true);modalStore.togleModal()}" type="button" class="afcl-confirmation-accept-button text-lightAcceptModalConfirmButtonText bg-lightAcceptModalConfirmButtonBackground hover:bg-lightAcceptModalConfirmButtonBackgroundHover focus:ring-4 focus:outline-none focus:ring-lightAcceptModalConfirmButtonFocus font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center dark:text-darkAcceptModalConfirmButtonText dark:bg-darkAcceptModalConfirmButtonBackground dark:hover:bg-darkAcceptModalConfirmButtonBackgroundHover dark:focus:ring-darkAcceptModalConfirmButtonFocus">
18
20
  {{ modalStore?.modalContent?.acceptText }}
19
21
  </button>
@@ -22,21 +22,23 @@
22
22
  class="bg-lightForm dark:bg-darkForm dark:border-darkFormBorder block md:table-row"
23
23
  :class="{ 'border-b': i !== group.columns.length - 1}"
24
24
  >
25
- <td class="px-6 py-4 flex items-center block md:table-cell pb-0 md:pb-4"
25
+ <td class="px-6 py-4 flex items-center block pb-0 md:pb-4 relative md:table-cell"
26
26
  :class="{'rounded-bl-lg border-b-none': i === group.columns.length - 1}"> <!--align-top-->
27
- <span class="flex items-center gap-1">
28
- {{ column.label }}
29
- <Tooltip v-if="column.required[mode]">
27
+ <div class="absolute inset-0 flex items-center overflow-hidden px-6 py-4 max-h-32">
28
+ <span class="flex items-center gap-1">
29
+ {{ column.label }}
30
+ <Tooltip v-if="column.required[mode]">
30
31
 
31
- <IconExclamationCircleSolid v-if="column.required[mode]" class="w-4 h-4"
32
- :class="(columnError(column) && validating) ? 'text-lightInputErrorColor dark:text-darkInputErrorColor' : 'text-lightRequiredIconColor dark:text-darkRequiredIconColor'"
33
- />
32
+ <IconExclamationCircleSolid v-if="column.required[mode]" class="w-4 h-4"
33
+ :class="(columnError(column) && validating) ? 'text-lightInputErrorColor dark:text-darkInputErrorColor' : 'text-lightRequiredIconColor dark:text-darkRequiredIconColor'"
34
+ />
34
35
 
35
- <template #tooltip>
36
- {{ $t('Required field') }}
37
- </template>
38
- </Tooltip>
39
- </span>
36
+ <template #tooltip>
37
+ {{ $t('Required field') }}
38
+ </template>
39
+ </Tooltip>
40
+ </span>
41
+ </div>
40
42
  </td>
41
43
  <td
42
44
  class="px-6 py-4 whitespace-pre-wrap relative block md:table-cell"
@@ -32,8 +32,10 @@
32
32
  :record="coreStore.record"
33
33
  />
34
34
  <template v-else-if="checkShowIf(column, record, resource?.columns || [])">
35
- <td class="px-6 py-4 relative block md:table-cell font-bold md:font-normal pb-0 md:pb-4">
36
- {{ column.label }}
35
+ <td class="px-6 py-4 relative block flex justify-start font-bold md:font-normal pb-0 md:pb-4 relative md:table-cell">
36
+ <div class="absolute inset-0 flex items-center overflow-hidden px-6 py-4 max-h-32">
37
+ {{ column.label }}
38
+ </div>
37
39
  </td>
38
40
  <td class="px-6 py-4 whitespace-pre-wrap" :data-af-column="column.name">
39
41
  <component
@@ -28,7 +28,7 @@
28
28
  :class="{
29
29
  'hidden': !(coreStore.config?.showBrandLogoInSidebar !== false && (!iconOnlySidebarEnabled || !isSidebarIconOnly || (isSidebarIconOnly && isSidebarHovering))) }"
30
30
  />
31
- <img :src="loadFile(coreStore.config?.iconOnlySidebar?.logo || '')" :alt="`${ coreStore.config?.brandName } Logo`" class="af-sidebar-icon-only-logo h-8" :class="{ 'hidden': !(coreStore.config?.showBrandLogoInSidebar !== false && coreStore.config?.iconOnlySidebar?.logo && iconOnlySidebarEnabled && isSidebarIconOnly && !isSidebarHovering) }" />
31
+ <img v-if="coreStore.config?.iconOnlySidebar?.logo" :src="loadFile(coreStore.config?.iconOnlySidebar?.logo || '')" :alt="`${ coreStore.config?.brandName } Logo`" class="af-sidebar-icon-only-logo h-8" :class="{ 'hidden': !(coreStore.config?.showBrandLogoInSidebar !== false && coreStore.config?.iconOnlySidebar?.logo && iconOnlySidebarEnabled && isSidebarIconOnly && !isSidebarHovering) }" />
32
32
  <span
33
33
  v-if="coreStore.config?.showBrandNameInSidebar && (!iconOnlySidebarEnabled || !isSidebarIconOnly || (isSidebarIconOnly && isSidebarHovering))"
34
34
  class="af-title self-center text-lightNavbarText-size font-semibold sm:text-lightNavbarText-size whitespace-nowrap dark:text-darkSidebarText text-lightSidebarText"
@@ -18,7 +18,7 @@
18
18
  'hidden': !showDropdown,
19
19
  'left-0 md:left-auto': checkboxes && checkboxes.length > 0
20
20
  }"
21
- class="absolute z-30 mt-3 bg-lightThreeDotsMenuBodyBackground divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-darkThreeDotsMenuBodyBackground dark:divide-gray-600 right-0">
21
+ class="absolute z-30 mt-3 bg-lightThreeDotsMenuBodyBackground divide-y divide-gray-100 rounded-lg shadow w-max max-w-64 dark:bg-darkThreeDotsMenuBodyBackground dark:divide-gray-600 right-0">
22
22
  <ul class="py-2 text-sm text-lightThreeDotsMenuBodyText dark:text-darkThreeDotsMenuBodyText" aria-labelledby="dropdownMenuIconButton">
23
23
  <li v-for="(item, i) in threeDotsDropdownItems" :key="`dropdown-item-${i}`">
24
24
  <div
@@ -121,7 +121,8 @@ export const useCoreStore = defineStore('core', () => {
121
121
  item.badge = badge;
122
122
  }
123
123
  });
124
- websocket.unsubscribeAll();
124
+ // TODO: This thing was created for something. Find out why
125
+ // websocket.unsubscribeAll();
125
126
  subscribeToMenuBadges();
126
127
 
127
128
  }
@@ -4,6 +4,7 @@ import { defineStore } from 'pinia'
4
4
  type ModalContentType = {
5
5
  title?: string;
6
6
  content?: string;
7
+ contentHTML?: string;
7
8
  acceptText?: string;
8
9
  cancelText?: string;
9
10
  }
@@ -12,7 +13,8 @@ import { defineStore } from 'pinia'
12
13
  export const useModalStore = defineStore('modal', () => {
13
14
  const modalContent = ref({
14
15
  title: 'title',
15
- content: 'content',
16
+ content: '',
17
+ contentHTML: '',
16
18
  acceptText: 'acceptText',
17
19
  cancelText: 'cancelText',
18
20
  });
@@ -31,7 +33,8 @@ export const useModalStore = defineStore('modal', () => {
31
33
  function setModalContent(content: ModalContentType) {
32
34
  modalContent.value = {
33
35
  title: content.title || 'title',
34
- content: content.content || 'content',
36
+ content: content.content || '',
37
+ contentHTML: content.contentHTML || '',
35
38
  acceptText: content.acceptText || 'acceptText',
36
39
  cancelText: content.cancelText || 'cancelText',
37
40
  };
@@ -41,6 +44,7 @@ export const useModalStore = defineStore('modal', () => {
41
44
  modalContent.value = {
42
45
  title: 'title',
43
46
  content: 'content',
47
+ contentHTML: '',
44
48
  acceptText: 'acceptText',
45
49
  cancelText: 'cancelText',
46
50
  };
@@ -515,6 +515,7 @@ export type BeforeDataSourceRequestFunction = (params: {
515
515
  cookies: Record<string, string>,
516
516
  requestUrl: string,
517
517
  },
518
+ filtersTools: any,
518
519
  adminforth: IAdminForth,
519
520
  }) => Promise<{
520
521
  ok: boolean,
@@ -1582,6 +1583,24 @@ export interface AdminForthInputConfig {
1582
1583
  *
1583
1584
  */
1584
1585
  baseUrl?: string,
1586
+
1587
+
1588
+ /**
1589
+ * Most of components are explicitely registered in AdminForth e.g. when you are using them in renderers, page injections and so on.
1590
+ * But some times you might want to have some components registered globally Explicitly. E.g. for ussage in other components without import.
1591
+ *
1592
+ * ```ts
1593
+ * componentsToExplicitRegister: [
1594
+ * {
1595
+ * file: '@@/my-component.vue',
1596
+ * meta: {
1597
+ * some: 'meta'
1598
+ * }
1599
+ * }
1600
+ * ```
1601
+ *
1602
+ */
1603
+ componentsToExplicitRegister?: AdminForthComponentDeclarationFull[]
1585
1604
 
1586
1605
  }
1587
1606
 
@@ -361,6 +361,12 @@ export interface AdminForthResourceInputCommon {
361
361
  recordLabel?: (item: any) => string,
362
362
 
363
363
 
364
+ /**
365
+ * If true, user will not see warning about unsaved changes when tries to leave edit or create page with unsaved changes.
366
+ * default is false
367
+ */
368
+ dontShowWarningAboutUnsavedChanges?: boolean,
369
+
364
370
  /**
365
371
  * General options for resource.
366
372
  */
@@ -159,6 +159,10 @@ export type ConfirmParams = {
159
159
  * The message to display in the dialog
160
160
  */
161
161
  message?: string;
162
+ /**
163
+ * Message to display in the dialog as HTML (can be used instead of message)
164
+ */
165
+ messageHtml?: string;
162
166
  /**
163
167
  * The text to display in the "accept" button
164
168
  */
@@ -209,6 +213,3 @@ export enum AlertVariant {
209
213
  }
210
214
 
211
215
 
212
-
213
-
214
-
@@ -17,9 +17,17 @@ export interface CompletionAdapter {
17
17
  content: string,
18
18
  stop: string[],
19
19
  maxTokens: number,
20
+ outputSchema?: any
20
21
  ): Promise<{
21
22
  content?: string;
22
23
  finishReason?: string;
23
24
  error?: string;
24
25
  }>;
26
+
27
+ /**
28
+ * This method should return the number of tokens in the input content.
29
+ * @param content - The input text for which to measure the token count
30
+ * @returns The number of tokens in the input content
31
+ */
32
+ measureTokensCount(content: string): Promise<number> | number;
25
33
  }
@@ -5,12 +5,13 @@ import router from "../router";
5
5
  import { useCoreStore } from '../stores/core';
6
6
  import { useUserStore } from '../stores/user';
7
7
  import { Dropdown } from 'flowbite';
8
- import adminforth from '../adminforth';
8
+ import adminforth, { useAdminforth } from '../adminforth';
9
9
  import sanitizeHtml from 'sanitize-html'
10
10
  import debounce from 'debounce';
11
11
  import type { AdminForthResourceColumnInputCommon, Predicate } from '@/types/Common';
12
12
  import { i18nInstance } from '../i18n'
13
-
13
+ import { useI18n } from 'vue-i18n';
14
+ import { onBeforeRouteLeave } from 'vue-router';
14
15
 
15
16
 
16
17
 
@@ -524,9 +525,10 @@ export function atob_function(source: string): string {
524
525
  return atob(source);
525
526
  }
526
527
 
527
- export function compareOldAndNewRecord(oldRecord: Record<string, any>, newRecord: Record<string, any>): boolean {
528
+ export function compareOldAndNewRecord(oldRecord: Record<string, any>, newRecord: Record<string, any>): {ok: boolean, changedFields: Record<string, {oldValue: any, newValue: any}>} {
528
529
  const newKeys = Object.keys(newRecord);
529
530
  const coreStore = useCoreStore();
531
+ const changedColumns: Record<string, { oldValue: any, newValue: any }> = {};
530
532
 
531
533
  for (const key of newKeys) {
532
534
  const oldValue = typeof oldRecord[key] === 'object' && oldRecord[key] !== null ? JSON.stringify(oldRecord[key]) : oldRecord[key];
@@ -537,21 +539,23 @@ export function compareOldAndNewRecord(oldRecord: Record<string, any>, newRecord
537
539
  oldValue === undefined ||
538
540
  oldValue === null ||
539
541
  oldValue === '' ||
540
- (Array.isArray(oldValue) && oldValue.length === 0)
542
+ (Array.isArray(oldValue) && oldValue.length === 0) ||
543
+ oldValue === '[]'
541
544
  )
542
545
  &&
543
546
  (
544
547
  newValue === undefined ||
545
548
  newValue === null ||
546
549
  newValue === '' ||
547
- (Array.isArray(newValue) && newValue.length === 0)
550
+ (Array.isArray(newValue) && newValue.length === 0) ||
551
+ newValue === '[]'
548
552
  )
549
553
  ) {
550
554
  // console.log(`Value for key ${key} is considered equal (empty)`)
551
555
  continue;
552
556
  }
553
557
 
554
- const column = coreStore.resource.columns.find((c) => c.name === key);
558
+ const column = coreStore.resource?.columns.find((c) => c.name === key);
555
559
  if (column?.foreignResource) {
556
560
  if (newRecord[key] === oldRecord[key]?.pk) {
557
561
  // console.log(`Value for key ${key} is considered equal (foreign key)`)
@@ -559,8 +563,112 @@ export function compareOldAndNewRecord(oldRecord: Record<string, any>, newRecord
559
563
  }
560
564
  }
561
565
  // console.log(`Value for key ${key} is different`, { oldValue: oldValue, newValue: newValue });
562
- return false;
566
+ changedColumns[key] = { oldValue, newValue };
567
+ }
568
+ }
569
+ return { ok: Object.keys(changedColumns).length !== 0, changedFields: changedColumns };
570
+ }
571
+
572
+ export function generateMessageHtmlForRecordChange(changedFields: Record<string, { oldValue: any, newValue: any }>, t: ReturnType<typeof useI18n>['t']): string {
573
+ const coreStore = useCoreStore();
574
+
575
+ const escapeHtml = (value: any) => {
576
+ if (value === null || value === undefined || value === '') return `<em>${t('empty')}</em>`;
577
+ let s: string;
578
+ if (typeof value === 'object') {
579
+ try {
580
+ s = JSON.stringify(value);
581
+ } catch (e) {
582
+ s = String(value);
583
+ }
584
+ } else {
585
+ s = String(value);
563
586
  }
587
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
588
+ };
589
+
590
+ const items = Object.keys(changedFields || {}).map(key => {
591
+ const column = coreStore.resource?.columns?.find((c: any) => c.name === key);
592
+ const label = column?.label || key;
593
+ const oldV = escapeHtml(changedFields[key].oldValue);
594
+ const newV = escapeHtml(changedFields[key].newValue);
595
+ return `<li class="truncate"><strong>${escapeHtml(label)}</strong>: <span class="af-old-value text-muted">${oldV}</span> &#8594; <span class="af-new-value">${newV}</span></li>`;
596
+ }).join('');
597
+
598
+ const listHtml = items ? `<ul class="mt-2 list-disc list-inside">${items}</ul>` : '';
599
+ const messageHtml = `<div>${escapeHtml(t('There are unsaved changes. Are you sure you want to leave this page?'))}${listHtml}</div>`;
600
+ return messageHtml;
601
+ }
602
+
603
+ export function getTimeAgoString(date: Date): string {
604
+ const now = new Date();
605
+ const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
606
+
607
+ if (diffInSeconds < 60) {
608
+ return `${diffInSeconds} ${diffInSeconds === 1 ? 'second' : 'seconds'} ago`;
609
+ } else if (diffInSeconds < 3600) {
610
+ const minutes = Math.floor(diffInSeconds / 60);
611
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
612
+ } else if (diffInSeconds < 86400) {
613
+ const hours = Math.floor(diffInSeconds / 3600);
614
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
615
+ } else {
616
+ const days = Math.floor(diffInSeconds / 86400);
617
+ return `${days} ${days === 1 ? 'day' : 'days'} ago`;
618
+ }
619
+ }
620
+
621
+ export class leaveGuardActiveClass {
622
+ private active = false;
623
+
624
+ isActive() {
625
+ return this.active;
626
+ }
627
+
628
+ setActive(value: boolean) {
629
+ this.active = value;
564
630
  }
565
- return true;
631
+ }
632
+
633
+ export async function onBeforeRouteLeaveCreateEditViewGuard(initialValues: any, record: any, checkIfWeCanLeavePage: () => boolean, leaveGuardActive: leaveGuardActiveClass, isActive: { value: boolean }): Promise<void> {
634
+
635
+ const { confirm } = useAdminforth();
636
+ const { t } = useI18n();
637
+
638
+ onBeforeRouteLeave(async (to, from) => {
639
+ if (!isActive.value) {
640
+ return true;
641
+ }
642
+
643
+
644
+ if (leaveGuardActive.isActive()) {
645
+ return false;
646
+ }
647
+
648
+ if (checkIfWeCanLeavePage()) {
649
+ return true;
650
+ }
651
+
652
+ leaveGuardActive.setActive(true);
653
+
654
+ try {
655
+ const { changedFields } = compareOldAndNewRecord(
656
+ initialValues.value,
657
+ record.value
658
+ );
659
+
660
+ const messageHtml =
661
+ generateMessageHtmlForRecordChange(changedFields, t);
662
+
663
+ const answer = await confirm({
664
+ messageHtml,
665
+ yes: t('Yes'),
666
+ no: t('No'),
667
+ });
668
+
669
+ return answer;
670
+ } finally {
671
+ leaveGuardActive.setActive(false);
672
+ }
673
+ });
566
674
  }
@@ -79,7 +79,7 @@ import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
79
79
  import ResourceForm from '@/components/ResourceForm.vue';
80
80
  import SingleSkeletLoader from '@/components/SingleSkeletLoader.vue';
81
81
  import { useCoreStore } from '@/stores/core';
82
- import { callAdminForthApi, getCustomComponent,checkAcessByAllowedActions, initThreeDotsDropdown, checkShowIf, compareOldAndNewRecord } from '@/utils';
82
+ import { callAdminForthApi, getCustomComponent,checkAcessByAllowedActions, initThreeDotsDropdown, checkShowIf, compareOldAndNewRecord, onBeforeRouteLeaveCreateEditViewGuard, leaveGuardActiveClass, onBeforeRouteLeaveCreateEditView } from '@/utils';
83
83
  import { IconFloppyDiskSolid } from '@iconify-prerendered/vue-flowbite';
84
84
  import { onMounted, onBeforeMount, onBeforeUnmount, ref, watch, nextTick } from 'vue';
85
85
  import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
@@ -115,16 +115,20 @@ const readonlyColumns = ref([]);
115
115
 
116
116
  const cancelButtonClicked = ref(false);
117
117
  const wasSaveSuccessful = ref(false);
118
+ const useLeaveGuard = ref( false );
118
119
 
119
120
  async function onUpdateRecord(newRecord: any) {
120
121
  record.value = newRecord;
121
122
  }
122
123
 
123
124
  function checkIfWeCanLeavePage() {
124
- return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialValues.value, record.value);
125
+ return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialValues.value, record.value).ok === false;
125
126
  }
126
127
 
127
128
  function onBeforeUnload(event: BeforeUnloadEvent) {
129
+ if (!useLeaveGuard.value) {
130
+ return;
131
+ }
128
132
  if (!checkIfWeCanLeavePage()) {
129
133
  event.preventDefault();
130
134
  event.returnValue = '';
@@ -137,19 +141,20 @@ onBeforeUnmount(() => {
137
141
  window.removeEventListener('beforeunload', onBeforeUnload);
138
142
  });
139
143
 
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
- });
144
+
145
+ const leaveGuardActive = new leaveGuardActiveClass();
146
+
147
+ onBeforeRouteLeaveCreateEditViewGuard(initialValues, record, checkIfWeCanLeavePage, leaveGuardActive, useLeaveGuard);
148
+
149
+
147
150
 
148
151
  onBeforeMount(() => {
149
152
  clearSaveInterceptors(route.params.resourceId as string);
150
153
  });
151
154
 
152
155
  onMounted(async () => {
156
+ useLeaveGuard.value = coreStore.resource?.options?.dontShowWarningAboutUnsavedChanges !== true;
157
+
153
158
  loading.value = true;
154
159
  await coreStore.fetchResourceFull({
155
160
  resourceId: route.params.resourceId as string,
@@ -74,7 +74,7 @@ import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
74
74
  import ResourceForm from '@/components/ResourceForm.vue';
75
75
  import SingleSkeletLoader from '@/components/SingleSkeletLoader.vue';
76
76
  import { useCoreStore } from '@/stores/core';
77
- import { callAdminForthApi, getCustomComponent,checkAcessByAllowedActions, initThreeDotsDropdown, compareOldAndNewRecord } from '@/utils';
77
+ import { callAdminForthApi, getCustomComponent,checkAcessByAllowedActions, initThreeDotsDropdown, compareOldAndNewRecord, generateMessageHtmlForRecordChange, leaveGuardActiveClass, onBeforeRouteLeaveCreateEditViewGuard } from '@/utils';
78
78
  import { IconFloppyDiskSolid } from '@iconify-prerendered/vue-flowbite';
79
79
  import { computed, onMounted, onBeforeMount, ref, type Ref, nextTick, watch, onBeforeUnmount } from 'vue';
80
80
  import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
@@ -104,8 +104,12 @@ const record: Ref<Record<string, any>> = ref({});
104
104
  const initialRecord = computed(() => coreStore.record);
105
105
  const wasSaveSuccessful = ref(false);
106
106
  const cancelButtonClicked = ref(false);
107
-
107
+ const useLeaveGuard = ref( false );
108
+
108
109
  function onBeforeUnload(event: BeforeUnloadEvent) {
110
+ if (!useLeaveGuard.value) {
111
+ return;
112
+ }
109
113
  if (!checkIfWeCanLeavePage()) {
110
114
  event.preventDefault();
111
115
  event.returnValue = '';
@@ -113,7 +117,7 @@ function onBeforeUnload(event: BeforeUnloadEvent) {
113
117
  }
114
118
 
115
119
  function checkIfWeCanLeavePage() {
116
- return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialRecord.value, record.value);
120
+ return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialRecord.value, record.value).ok === false;
117
121
  }
118
122
 
119
123
  window.addEventListener('beforeunload', onBeforeUnload);
@@ -122,13 +126,8 @@ onBeforeUnmount(() => {
122
126
  window.removeEventListener('beforeunload', onBeforeUnload);
123
127
  });
124
128
 
125
- onBeforeRouteLeave(async (to, from, next) => {
126
- if (!checkIfWeCanLeavePage()) {
127
- const answer = await confirm({message: t('There are unsaved changes. Are you sure you want to leave this page?'), yes: 'Yes', no: 'No'});
128
- if (!answer) return next(false);
129
- }
130
- next();
131
- });
129
+ const leaveGuardActive = new leaveGuardActiveClass();
130
+ onBeforeRouteLeaveCreateEditViewGuard(initialRecord, record, checkIfWeCanLeavePage, leaveGuardActive, useLeaveGuard);
132
131
 
133
132
  const resourceFormRef = ref<InstanceType<typeof ResourceForm> | null>(null);
134
133
 
@@ -158,6 +157,7 @@ onBeforeMount(() => {
158
157
  });
159
158
 
160
159
  onMounted(async () => {
160
+ useLeaveGuard.value = coreStore.resource?.options?.dontShowWarningAboutUnsavedChanges !== true;
161
161
  loading.value = true;
162
162
 
163
163
  await coreStore.fetchResourceFull({
@@ -26,6 +26,8 @@
26
26
  :meta="(c as AdminForthComponentDeclarationFull).meta"
27
27
  :resource="coreStore.resource"
28
28
  :adminUser="coreStore.adminUser"
29
+ :checkboxes="checkboxes"
30
+ :clearCheckboxes="clearCheckboxes"
29
31
  />
30
32
  <button
31
33
  @click="()=>{checkboxes = []}"
@@ -10,7 +10,7 @@ export default {
10
10
 
11
11
  darkMode: 'class',
12
12
  plugins: [
13
-
13
+ require('@tailwindcss/typography'),
14
14
  ],
15
15
  }
16
16