adminforth 2.22.0-next.3 → 2.22.0-next.31

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 (49) 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/utils.d.ts +1 -0
  15. package/dist/modules/utils.d.ts.map +1 -1
  16. package/dist/modules/utils.js +18 -15
  17. package/dist/modules/utils.js.map +1 -1
  18. package/dist/spa/package-lock.json +28 -0
  19. package/dist/spa/package.json +1 -0
  20. package/dist/spa/src/adminforth.ts +1 -0
  21. package/dist/spa/src/afcl/Input.vue +1 -26
  22. package/dist/spa/src/afcl/Modal.vue +118 -0
  23. package/dist/spa/src/afcl/ProgressBar.vue +42 -6
  24. package/dist/spa/src/afcl/Table.vue +1 -1
  25. package/dist/spa/src/afcl/TreeMapChart.vue +136 -0
  26. package/dist/spa/src/afcl/index.ts +3 -1
  27. package/dist/spa/src/components/AcceptModal.vue +2 -0
  28. package/dist/spa/src/components/GroupsTable.vue +14 -12
  29. package/dist/spa/src/components/ShowTable.vue +4 -2
  30. package/dist/spa/src/components/ThreeDotsMenu.vue +1 -1
  31. package/dist/spa/src/stores/core.ts +2 -1
  32. package/dist/spa/src/stores/modal.ts +6 -2
  33. package/dist/spa/src/types/Back.ts +19 -0
  34. package/dist/spa/src/types/FrontendAPI.ts +4 -0
  35. package/dist/spa/src/types/adapters/CompletionAdapter.ts +8 -0
  36. package/dist/spa/src/utils/utils.ts +69 -14
  37. package/dist/spa/src/views/CreateView.vue +8 -4
  38. package/dist/spa/src/views/EditView.vue +8 -4
  39. package/dist/spa/src/views/ListView.vue +2 -0
  40. package/dist/spa/tailwind.config.js +1 -1
  41. package/dist/types/Back.d.ts +17 -0
  42. package/dist/types/Back.d.ts.map +1 -1
  43. package/dist/types/Back.js.map +1 -1
  44. package/dist/types/FrontendAPI.d.ts +4 -0
  45. package/dist/types/FrontendAPI.d.ts.map +1 -1
  46. package/dist/types/FrontendAPI.js.map +1 -1
  47. package/dist/types/adapters/CompletionAdapter.d.ts +7 -1
  48. package/dist/types/adapters/CompletionAdapter.d.ts.map +1 -1
  49. package/package.json +1 -1
@@ -1,14 +1,17 @@
1
1
  <template>
2
- <div class="relative mt-4 lg:mt-10 w-full max-w-[700px] bg-lightProgressBarUnfilledColor rounded-full h-2.5 dark:bg-darkProgressBarUnfilledColor">
3
- <span class="absolute -top-6 left-0 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ leftLabel }}</span>
4
- <span class="absolute -top-6 right-0 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ rightLabel }}</span>
2
+ <div class="relative w-full bg-lightProgressBarUnfilledColor rounded-full h-2.5 dark:bg-darkProgressBarUnfilledColor" :class="props.height ? `h-${props.height}` : ''">
3
+ <span v-if="leftLabel" class="absolute -top-6 left-0 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ leftLabel }}</span>
4
+ <span v-if="rightLabel" class="absolute -top-6 right-0 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ rightLabel }}</span>
5
5
  <div
6
6
  class="bg-lightProgressBarFilledColor dark:bg-darkProgressBarFilledColor h-2.5 rounded-full transition-all duration-300 ease-in-out"
7
+ :class="{ 'progress-bar': showAnimation, [`h-${props.height}`]: props.height }"
7
8
  :style="{ width: `${percentage}%` }"
8
9
  ></div>
9
- <span v-if="showValues" class="absolute top-4 left-0 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ formatValue(minValue) }}</span>
10
- <span v-if="showProgress" class="absolute top-4 right-1/2 translate-x-1/2 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ progressText }}</span>
11
- <span v-if="showValues" class="absolute top-4 right-0 text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ formatValue(maxValue) }}</span>
10
+ <div v-if="showValues || showProgress" class="flex justify-between mt-2">
11
+ <span v-if="showValues" class="text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ formatValue(minValue) }}</span>
12
+ <span v-if="showProgress" class="text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ progressText }}</span>
13
+ <span v-if="showValues" class="text-sm text-lightProgressBarText dark:text-darkProgressBarText">{{ formatValue(maxValue) }}</span>
14
+ </div>
12
15
  </div>
13
16
  </template>
14
17
 
@@ -26,6 +29,8 @@ interface Props {
26
29
  showLabels?: boolean
27
30
  showValues?: boolean
28
31
  showProgress?: boolean
32
+ showAnimation?: boolean
33
+ height?: number
29
34
  }
30
35
 
31
36
  const props = withDefaults(defineProps<Props>(), {
@@ -53,3 +58,34 @@ const formatValue = (value: number): string => {
53
58
  return formatter(value)
54
59
  }
55
60
  </script>
61
+
62
+
63
+ <style scoped>
64
+ .progress-bar {
65
+ position: relative;
66
+ overflow: hidden;
67
+ }
68
+
69
+ .progress-bar::after {
70
+ content: "";
71
+ position: absolute;
72
+ inset: 0;
73
+ width: 100%;
74
+
75
+ background: linear-gradient(
76
+ -45deg,
77
+ transparent 35%,
78
+ rgba(255,255,255,0.4),
79
+ transparent 65%
80
+ );
81
+
82
+ transform: translateX(-100%);
83
+ animation: progress-slide 2s linear infinite;
84
+ }
85
+
86
+ @keyframes progress-slide {
87
+ 100% {
88
+ transform: translateX(100%);
89
+ }
90
+ }
91
+ </style>
@@ -78,7 +78,7 @@
78
78
  </tr>
79
79
  </tbody>
80
80
  </table>
81
- <nav class="afcl-table-pagination-container bg-lightTableBackground dark:bg-darkTableBackground mt-2 flex flex-col gap-2 items-center sm:flex-row justify-center sm:justify-between px-4 pb-4"
81
+ <nav class="afcl-table-pagination-container bg-lightTableBackground dark:bg-darkTableBackground pt-2 flex flex-col gap-2 items-center sm:flex-row justify-center sm:justify-between px-4 pb-4"
82
82
  v-if="totalPages > 1"
83
83
  :aria-label="$t('Table navigation')"
84
84
  :class="makePaginationSticky ? 'sticky bottom-0 pt-4' : ''"
@@ -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
@@ -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-48 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
 
@@ -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
  */
@@ -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
  }
@@ -10,6 +10,7 @@ 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
+ import { useI18n } from 'vue-i18n';
13
14
 
14
15
 
15
16
 
@@ -524,41 +525,95 @@ 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
- if (oldRecord[key] !== newRecord[key]) {
534
+ const oldValue = typeof oldRecord[key] === 'object' && oldRecord[key] !== null ? JSON.stringify(oldRecord[key]) : oldRecord[key];
535
+ const newValue = typeof newRecord[key] === 'object' && newRecord[key] !== null ? JSON.stringify(newRecord[key]) : newRecord[key];
536
+ if (oldValue !== newValue) {
533
537
  if (
534
538
  (
535
- oldRecord[key] === undefined ||
536
- oldRecord[key] === null ||
537
- oldRecord[key] === '' ||
538
- (Array.isArray(oldRecord[key]) && oldRecord[key].length === 0)
539
+ oldValue === undefined ||
540
+ oldValue === null ||
541
+ oldValue === '' ||
542
+ (Array.isArray(oldValue) && oldValue.length === 0) ||
543
+ oldValue === '[]'
539
544
  )
540
545
  &&
541
546
  (
542
- newRecord[key] === undefined ||
543
- newRecord[key] === null ||
544
- newRecord[key] === '' ||
545
- (Array.isArray(newRecord[key]) && newRecord[key].length === 0)
547
+ newValue === undefined ||
548
+ newValue === null ||
549
+ newValue === '' ||
550
+ (Array.isArray(newValue) && newValue.length === 0) ||
551
+ newValue === '[]'
546
552
  )
547
553
  ) {
548
554
  // console.log(`Value for key ${key} is considered equal (empty)`)
549
555
  continue;
550
556
  }
551
557
 
552
- const column = coreStore.resource.columns.find((c) => c.name === key);
558
+ const column = coreStore.resource?.columns.find((c) => c.name === key);
553
559
  if (column?.foreignResource) {
554
560
  if (newRecord[key] === oldRecord[key]?.pk) {
555
561
  // console.log(`Value for key ${key} is considered equal (foreign key)`)
556
562
  continue;
557
563
  }
558
564
  }
559
- // console.log(`Value for key ${key} is different`, { oldValue: oldRecord[key], newValue: newRecord[key] });
560
- return false;
565
+ // console.log(`Value for key ${key} is different`, { oldValue: oldValue, newValue: newValue });
566
+ changedColumns[key] = { oldValue, newValue };
561
567
  }
562
568
  }
563
- return true;
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);
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
+ }
564
619
  }
@@ -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, generateMessageHtmlForRecordChange } 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';
@@ -121,7 +121,7 @@ async function onUpdateRecord(newRecord: any) {
121
121
  }
122
122
 
123
123
  function checkIfWeCanLeavePage() {
124
- return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialValues.value, record.value);
124
+ return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialValues.value, record.value).ok === false;
125
125
  }
126
126
 
127
127
  function onBeforeUnload(event: BeforeUnloadEvent) {
@@ -139,8 +139,12 @@ onBeforeUnmount(() => {
139
139
 
140
140
  onBeforeRouteLeave(async (to, from, next) => {
141
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);
142
+ const { changedFields } = compareOldAndNewRecord(initialValues.value, record.value);
143
+
144
+ const messageHtml = generateMessageHtmlForRecordChange(changedFields, t);
145
+
146
+ const answer = await confirm({ messageHtml: messageHtml, yes: t('Yes'), no: t('No') });
147
+ if (!answer) return next(false);
144
148
  }
145
149
  next();
146
150
  });
@@ -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 } 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';
@@ -113,7 +113,7 @@ function onBeforeUnload(event: BeforeUnloadEvent) {
113
113
  }
114
114
 
115
115
  function checkIfWeCanLeavePage() {
116
- return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialRecord.value, record.value);
116
+ return wasSaveSuccessful.value || cancelButtonClicked.value || compareOldAndNewRecord(initialRecord.value, record.value).ok === false;
117
117
  }
118
118
 
119
119
  window.addEventListener('beforeunload', onBeforeUnload);
@@ -124,8 +124,12 @@ onBeforeUnmount(() => {
124
124
 
125
125
  onBeforeRouteLeave(async (to, from, next) => {
126
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);
127
+ const { changedFields } = compareOldAndNewRecord(initialRecord.value, record.value);
128
+
129
+ const messageHtml = generateMessageHtmlForRecordChange(changedFields, t);
130
+
131
+ const answer = await confirm({ messageHtml: messageHtml, yes: t('Yes'), no: t('No') });
132
+ if (!answer) return next(false);
129
133
  }
130
134
  next();
131
135
  });
@@ -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
 
@@ -472,6 +472,7 @@ export type BeforeDataSourceRequestFunction = (params: {
472
472
  cookies: Record<string, string>;
473
473
  requestUrl: string;
474
474
  };
475
+ filtersTools: any;
475
476
  adminforth: IAdminForth;
476
477
  }) => Promise<{
477
478
  ok: boolean;
@@ -1446,6 +1447,22 @@ export interface AdminForthInputConfig {
1446
1447
  *
1447
1448
  */
1448
1449
  baseUrl?: string;
1450
+ /**
1451
+ * Most of components are explicitely registered in AdminForth e.g. when you are using them in renderers, page injections and so on.
1452
+ * But some times you might want to have some components registered globally Explicitly. E.g. for ussage in other components without import.
1453
+ *
1454
+ * ```ts
1455
+ * componentsToExplicitRegister: [
1456
+ * {
1457
+ * file: '@@/my-component.vue',
1458
+ * meta: {
1459
+ * some: 'meta'
1460
+ * }
1461
+ * }
1462
+ * ```
1463
+ *
1464
+ */
1465
+ componentsToExplicitRegister?: AdminForthComponentDeclarationFull[];
1449
1466
  }
1450
1467
  export interface AdminForthConfigCustomization extends Omit<AdminForthInputConfigCustomization, 'loginPageInjections' | 'globalInjections'> {
1451
1468
  brandName: string;