adminforth 2.22.0-next.4 → 2.22.0-next.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/createApp/utils.js +43 -3
- package/dist/auth.d.ts +1 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/codeInjector.d.ts.map +1 -1
- package/dist/modules/codeInjector.js +7 -0
- package/dist/modules/codeInjector.js.map +1 -1
- package/dist/modules/restApi.js.map +1 -1
- package/dist/modules/styles.d.ts +4 -0
- package/dist/modules/styles.d.ts.map +1 -1
- package/dist/modules/styles.js +4 -0
- package/dist/modules/styles.js.map +1 -1
- package/dist/modules/utils.d.ts +1 -0
- package/dist/modules/utils.d.ts.map +1 -1
- package/dist/modules/utils.js +18 -15
- package/dist/modules/utils.js.map +1 -1
- package/dist/spa/package-lock.json +28 -0
- package/dist/spa/package.json +1 -0
- package/dist/spa/src/adminforth.ts +1 -0
- package/dist/spa/src/afcl/Button.vue +12 -5
- package/dist/spa/src/afcl/Dialog.vue +155 -126
- package/dist/spa/src/afcl/Input.vue +1 -26
- package/dist/spa/src/afcl/Modal.vue +137 -0
- package/dist/spa/src/afcl/ProgressBar.vue +42 -6
- package/dist/spa/src/afcl/Table.vue +1 -1
- package/dist/spa/src/afcl/TreeMapChart.vue +136 -0
- package/dist/spa/src/afcl/index.ts +3 -1
- package/dist/spa/src/components/AcceptModal.vue +2 -0
- package/dist/spa/src/components/GroupsTable.vue +14 -12
- package/dist/spa/src/components/ShowTable.vue +4 -2
- package/dist/spa/src/components/Sidebar.vue +1 -1
- package/dist/spa/src/components/ThreeDotsMenu.vue +1 -1
- package/dist/spa/src/stores/core.ts +2 -1
- package/dist/spa/src/stores/modal.ts +6 -2
- package/dist/spa/src/types/Back.ts +19 -0
- package/dist/spa/src/types/FrontendAPI.ts +4 -3
- package/dist/spa/src/types/adapters/CompletionAdapter.ts +8 -0
- package/dist/spa/src/utils/utils.ts +112 -8
- package/dist/spa/src/views/CreateView.vue +8 -9
- package/dist/spa/src/views/EditView.vue +4 -9
- package/dist/spa/src/views/ListView.vue +2 -0
- package/dist/spa/tailwind.config.js +1 -1
- package/dist/types/Back.d.ts +17 -0
- package/dist/types/Back.d.ts.map +1 -1
- package/dist/types/Back.js.map +1 -1
- package/dist/types/FrontendAPI.d.ts +4 -0
- package/dist/types/FrontendAPI.d.ts.map +1 -1
- package/dist/types/FrontendAPI.js.map +1 -1
- package/dist/types/adapters/CompletionAdapter.d.ts +7 -1
- package/dist/types/adapters/CompletionAdapter.d.ts.map +1 -1
- 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
|
|
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
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
36
|
-
|
|
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-
|
|
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
|
-
|
|
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: '
|
|
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 || '
|
|
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
|
*/
|
|
@@ -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
|
|
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,108 @@ 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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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> → <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;
|
|
564
626
|
}
|
|
565
|
-
|
|
627
|
+
|
|
628
|
+
setActive(value: boolean) {
|
|
629
|
+
this.active = value;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function onBeforeRouteLeaveCreateEditViewGuard(initialValues: any, record: any, checkIfWeCanLeavePage: () => boolean, leaveGuardActive: leaveGuardActiveClass): Promise<boolean> {
|
|
634
|
+
|
|
635
|
+
const { confirm } = useAdminforth();
|
|
636
|
+
const { t } = useI18n();
|
|
637
|
+
|
|
638
|
+
onBeforeRouteLeave(async (to, from) => {
|
|
639
|
+
|
|
640
|
+
if (leaveGuardActive.isActive()) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (checkIfWeCanLeavePage()) {
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
leaveGuardActive.setActive(true);
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const { changedFields } = compareOldAndNewRecord(
|
|
652
|
+
initialValues.value,
|
|
653
|
+
record.value
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const messageHtml =
|
|
657
|
+
generateMessageHtmlForRecordChange(changedFields, t);
|
|
658
|
+
|
|
659
|
+
const answer = await confirm({
|
|
660
|
+
messageHtml,
|
|
661
|
+
yes: t('Yes'),
|
|
662
|
+
no: t('No'),
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
return answer;
|
|
666
|
+
} finally {
|
|
667
|
+
leaveGuardActive.setActive(false);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
566
670
|
}
|
|
@@ -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';
|
|
@@ -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) {
|
|
@@ -137,13 +137,12 @@ onBeforeUnmount(() => {
|
|
|
137
137
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
});
|
|
140
|
+
|
|
141
|
+
const leaveGuardActive = new leaveGuardActiveClass();
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
onBeforeRouteLeaveCreateEditViewGuard(initialValues, record, checkIfWeCanLeavePage, leaveGuardActive);
|
|
145
|
+
|
|
147
146
|
|
|
148
147
|
onBeforeMount(() => {
|
|
149
148
|
clearSaveInterceptors(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';
|
|
@@ -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);
|
|
@@ -122,13 +122,8 @@ onBeforeUnmount(() => {
|
|
|
122
122
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
});
|
|
125
|
+
const leaveGuardActive = new leaveGuardActiveClass();
|
|
126
|
+
onBeforeRouteLeaveCreateEditViewGuard(initialRecord, record, checkIfWeCanLeavePage, leaveGuardActive);
|
|
132
127
|
|
|
133
128
|
const resourceFormRef = ref<InstanceType<typeof ResourceForm> | null>(null);
|
|
134
129
|
|
package/dist/types/Back.d.ts
CHANGED
|
@@ -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;
|