adminforth 1.3.54-next.2 → 1.3.54-next.21

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 (39) hide show
  1. package/dataConnectors/baseConnector.ts +6 -0
  2. package/dataConnectors/clickhouse.ts +11 -6
  3. package/dataConnectors/postgres.ts +5 -1
  4. package/dataConnectors/sqlite.ts +5 -1
  5. package/dist/auth.js +1 -0
  6. package/dist/basePlugin.js +1 -0
  7. package/dist/dataConnectors/baseConnector.js +7 -0
  8. package/dist/dataConnectors/clickhouse.js +11 -5
  9. package/dist/dataConnectors/mongo.js +1 -0
  10. package/dist/dataConnectors/postgres.js +7 -1
  11. package/dist/dataConnectors/sqlite.js +7 -1
  12. package/dist/index.js +1 -0
  13. package/dist/modules/codeInjector.js +16 -6
  14. package/dist/modules/configValidator.js +18 -0
  15. package/dist/modules/operationalResource.js +1 -0
  16. package/dist/modules/restApi.js +1 -0
  17. package/dist/modules/styleGenerator.js +1 -0
  18. package/dist/modules/styles.js +1 -0
  19. package/dist/modules/utils.js +1 -0
  20. package/dist/servers/express.js +1 -0
  21. package/dist/types/AdminForthConfig.js +1 -0
  22. package/dist/types/FrontendAPI.js +1 -0
  23. package/modules/codeInjector.ts +18 -7
  24. package/modules/configValidator.ts +17 -0
  25. package/package.json +4 -3
  26. package/spa/package-lock.json +87 -1
  27. package/spa/package.json +4 -1
  28. package/spa/src/App.vue +3 -3
  29. package/spa/src/components/ResourceForm.vue +37 -2
  30. package/spa/src/components/ResourceListTable.vue +11 -5
  31. package/spa/src/components/ValueRenderer.vue +27 -0
  32. package/spa/src/renderers/CompactUUID.vue +48 -0
  33. package/spa/src/renderers/CountryFlag.vue +69 -0
  34. package/spa/src/utils.ts +11 -0
  35. package/spa/src/views/CreateView.vue +1 -1
  36. package/spa/src/views/EditView.vue +1 -1
  37. package/spa/src/views/ListView.vue +78 -5
  38. package/spa/src/views/ShowView.vue +9 -1
  39. package/tsconfig.json +1 -1
@@ -104,6 +104,14 @@
104
104
  @input="setCurrentValue(column.name, $event.target.value)"
105
105
  >
106
106
  </textarea>
107
+ <textarea
108
+ v-else-if="['json'].includes(column.type)"
109
+ class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
110
+ placeholder="Text"
111
+ :value="currentValues[column.name]"
112
+ @input="setCurrentValue(column.name, $event.target.value)"
113
+ >
114
+ </textarea>
107
115
  <input
108
116
  v-else
109
117
  :type="!column.masked || unmasked[column.name] ? 'text' : 'password'"
@@ -155,7 +163,6 @@ import { useRouter, useRoute } from 'vue-router';
155
163
  const router = useRouter();
156
164
  const route = useRoute();
157
165
  const props = defineProps({
158
- loading: Boolean,
159
166
  resource: Object,
160
167
  record: Object,
161
168
  validating: Boolean,
@@ -193,6 +200,13 @@ const columnError = (column) => {
193
200
  ) {
194
201
  return 'This field is required';
195
202
  }
203
+ if (column.type === 'json' && currentValues.value[column.name]) {
204
+ try {
205
+ JSON.parse(currentValues.value[column.name]);
206
+ } catch (e) {
207
+ return 'Invalid JSON';
208
+ }
209
+ }
196
210
  if ( column.type === 'string' || column.type === 'text' ) {
197
211
  if ( column.maxLength && currentValues.value[column.name]?.length > column.maxLength ) {
198
212
  return `This field must be shorter than ${column.maxLength} characters`;
@@ -243,11 +257,32 @@ const setCurrentValue = (key, value) => {
243
257
  }
244
258
 
245
259
  currentValues.value = { ...currentValues.value };
246
- emit('update:record', currentValues.value);
260
+
261
+ //json fields should transform to object
262
+ const up = {...currentValues.value};
263
+ props.resource.columns.forEach((column) => {
264
+ if (column.type === 'json' && up[column.name]) {
265
+ try {
266
+ up[column.name] = JSON.parse(up[column.name]);
267
+ } catch (e) {
268
+ // do nothing
269
+ }
270
+ }
271
+ });
272
+ emit('update:record', up);
247
273
  };
248
274
 
249
275
  onMounted(() => {
276
+
250
277
  currentValues.value = Object.assign({}, props.record);
278
+ // json values should transform to string
279
+ props.resource.columns.forEach((column) => {
280
+ if (column.type === 'json' && currentValues.value[column.name]) {
281
+ currentValues.value[column.name] = JSON.stringify(currentValues.value[column.name], null, 2);
282
+ }
283
+ });
284
+ console.log('currentValues', currentValues.value);
285
+
251
286
  initFlowbite();
252
287
  emit('update:isValid', isValid.value);
253
288
  });
@@ -250,7 +250,7 @@
250
250
  <script setup>
251
251
 
252
252
 
253
- import { computed, ref, watch } from 'vue';
253
+ import { computed, onMounted, ref, watch } from 'vue';
254
254
  import { callAdminForthApi } from '@/utils';
255
255
 
256
256
  import ValueRenderer from '@/components/ValueRenderer.vue';
@@ -260,19 +260,20 @@ import { showSuccesTost, showErrorTost } from '@/composables/useFrontendApi';
260
260
  import SkeleteLoader from '@/components/SkeleteLoader.vue';
261
261
 
262
262
  import {
263
- IconInboxOutline,
263
+ IconInboxOutline,
264
264
  } from '@iconify-prerendered/vue-flowbite';
265
265
 
266
266
  import {
267
- IconEyeSolid,
268
- IconPenSolid,
269
- IconTrashBinSolid
267
+ IconEyeSolid,
268
+ IconPenSolid,
269
+ IconTrashBinSolid
270
270
  } from '@iconify-prerendered/vue-flowbite';
271
271
  import router from '@/router';
272
272
 
273
273
  const coreStore = useCoreStore();
274
274
 
275
275
  const props = defineProps([
276
+ 'page',
276
277
  'resource',
277
278
  'rows',
278
279
  'totalRows',
@@ -296,6 +297,7 @@ const page = ref(1);
296
297
  const sort = ref([]);
297
298
 
298
299
 
300
+
299
301
  watch(() => page.value, (newPage) => {
300
302
  emits('update:page', newPage);
301
303
  });
@@ -319,6 +321,10 @@ watch(() => props.sort, (newSort) => {
319
321
  sort.value = newSort;
320
322
  });
321
323
 
324
+ watch(() => props.page, (newPage) => {
325
+ page.value = newPage;
326
+ });
327
+
322
328
  function addToCheckedValues(id) {
323
329
  console.log('checking', checkboxesInternal.value, 'id', id)
324
330
  if (checkboxesInternal.value.includes(id)) {
@@ -29,6 +29,9 @@
29
29
  <span v-else-if="column.type === 'richtext'">
30
30
  <div v-html="protectAgainstXSS(record[column.name])" class="allow-lists"></div>
31
31
  </span>
32
+ <span v-else-if="column.type === 'json'">
33
+ <JsonViewer :value="record[column.name]" copyable sort :theme="theme" />
34
+ </span>
32
35
  <span v-else>
33
36
  {{ checkEmptyValues(record[column.name],route.meta.type) }}
34
37
  </span>
@@ -44,13 +47,19 @@ import timezone from 'dayjs/plugin/timezone';
44
47
  import {checkEmptyValues} from '@/utils';
45
48
  import { useRoute, useRouter } from 'vue-router';
46
49
  import sanitizeHtml from 'sanitize-html';
50
+ import { JsonViewer } from "vue3-json-viewer";
51
+ import "vue3-json-viewer/dist/index.css";
47
52
 
48
53
 
49
54
  import { useCoreStore } from '@/stores/core';
55
+ import { computed } from 'vue';
50
56
 
51
57
  const coreStore = useCoreStore();
52
58
  const route = useRoute();
53
59
 
60
+ const theme = computed(() => {
61
+ return window.localStorage.getItem('af__theme') || 'light';
62
+ });
54
63
 
55
64
  dayjs.extend(utc);
56
65
  dayjs.extend(timezone);
@@ -111,4 +120,22 @@ function formatTime(time) {
111
120
  }
112
121
 
113
122
  }
123
+ </style>
124
+
125
+ <style lang="scss" >
126
+
127
+ .jv-container .jv-code {
128
+ padding: 10px 10px;
129
+ }
130
+
131
+ .jv-container .jv-button[class] {
132
+ @apply text-lightPrimary;
133
+ @apply dark:text-darkPrimary;
134
+
135
+ }
136
+
137
+ .jv-container.jv-dark {
138
+ background: transparent;
139
+ }
140
+
114
141
  </style>
@@ -0,0 +1,48 @@
1
+ <template>
2
+ <span class="flex items-center"
3
+ :data-tooltip-target="val && `tooltip-${id}`"
4
+ data-tooltip-placement="top"
5
+ >
6
+ {{ visualValue }} <IconFileCopyAltSolid @click.stop="copyToCB" class="w-5 h-5 text-lightPrimary dark:text-darkPrimary" v-if="val"/>
7
+
8
+ <div :id="`tooltip-${id}`" role="tooltip" v-if="val"
9
+ class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700">
10
+ {{ props.record[props.column.name] }}
11
+ <div class="tooltip-arrow" data-popper-arrow></div>
12
+ </div>
13
+ </span>
14
+ </template>
15
+
16
+ <script setup>
17
+ import { computed, ref, onMounted } from 'vue';
18
+ import { IconFileCopyAltSolid } from '@iconify-prerendered/vue-flowbite';
19
+ import { initFlowbite } from 'flowbite';
20
+
21
+ const visualValue = computed(() => {
22
+ // if lenght is more then 8, show only first 4 and last 4 characters, ... in the middle
23
+ const val = props.record[props.column.name];
24
+ if (val && val.length > 8) {
25
+ return `${val.substr(0, 4)}...${val.substr(val.length - 4)}`;
26
+ }
27
+ return val;
28
+ });
29
+
30
+ const props = defineProps(['column', 'record', 'meta']);
31
+
32
+ const id = ref();
33
+
34
+ function copyToCB() {
35
+ navigator.clipboard.writeText(props.record[props.column.name]);
36
+ window.adminforth.alert({
37
+ message: 'ID copied to clipboard',
38
+ variant: 'success',
39
+ })
40
+ }
41
+
42
+ onMounted(async () => {
43
+ id.value = Math.random().toString(36).substring(7);
44
+ await new Promise(resolve => setTimeout(resolve, 0));
45
+ initFlowbite();
46
+ });
47
+
48
+ </script>
@@ -0,0 +1,69 @@
1
+ <template>
2
+ <span class="flex items-center">
3
+ <span
4
+ :class="{[`fi-${countryIsoLow}`]: true, 'flag-icon': countryName}"
5
+ :data-tooltip-target="`tooltip-${id}`"
6
+ ></span>
7
+
8
+ <span v-if="meta.showCountryName" class="ms-2">{{ countryName }}</span>
9
+
10
+ <div
11
+ v-if="!meta.showCountryName && countryName"
12
+ :id="`tooltip-${id}`" role="tooltip"
13
+ class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
14
+ >
15
+ {{ countryName }}
16
+ <div class="tooltip-arrow" data-popper-arrow></div>
17
+ </div>
18
+ </span>
19
+
20
+ </template>
21
+
22
+ <script setup>
23
+
24
+ import { computed, ref, onMounted } from 'vue';
25
+ import { initFlowbite } from 'flowbite';
26
+ import 'flag-icons/css/flag-icons.min.css';
27
+ import isoCountries from 'i18n-iso-countries';
28
+ import enLocal from 'i18n-iso-countries/langs/en.json';
29
+
30
+ isoCountries.registerLocale(enLocal);
31
+
32
+ const props = defineProps(['column', 'record', 'meta', 'resource', 'adminUser']);
33
+
34
+ const id = ref();
35
+
36
+
37
+ onMounted(async () => {
38
+ id.value = Math.random().toString(36).substring(7);
39
+ await new Promise(resolve => setTimeout(resolve, 0));
40
+ initFlowbite();
41
+ });
42
+
43
+ const countryIsoLow = computed(() => {
44
+ return props.record[props.column.name]?.toLowerCase();
45
+ });
46
+
47
+ const countryName = computed(() => {
48
+ if (!countryIsoLow.value) {
49
+ return '';
50
+ }
51
+ return isoCountries.getName(countryIsoLow.value, 'en');
52
+ });
53
+
54
+ </script>
55
+
56
+ <style scoped lang="scss">
57
+
58
+ .flag-icon {
59
+ width: 2rem;
60
+ height: 1.5rem;
61
+ flex-shrink: 0;
62
+
63
+ // border radius for background
64
+ border-radius: 2px;
65
+ // add some silkiness to the flag
66
+ box-shadow: inset -1px -1px 0.5px 0px rgba(0 0 0 / 0.2), inset 1px 1px 0.5px 0px rgba(255 255 255 / 0.2);
67
+ }
68
+
69
+ </style>
package/spa/src/utils.ts CHANGED
@@ -146,4 +146,15 @@ export function applyRegexValidation(value: any, validation: ValidationObject[]
146
146
  }
147
147
  }
148
148
  }
149
+ }
150
+
151
+ export function currentQuery() {
152
+ return router.currentRoute.value.query;
153
+ }
154
+
155
+ export function setQuery(query: any) {
156
+ const currentQuery = { ...router.currentRoute.value.query, ...query };
157
+ router.replace({
158
+ query: currentQuery,
159
+ });
149
160
  }
@@ -90,7 +90,7 @@ import ThreeDotsMenu from '@/components/ThreeDotsMenu.vue';
90
90
  const isValid = ref(false);
91
91
  const validating = ref(false);
92
92
 
93
- const loading = ref(false);
93
+ const loading = ref(true);
94
94
  const saving = ref(false);
95
95
 
96
96
  const route = useRoute();
@@ -90,7 +90,7 @@ const validating = ref(false);
90
90
  const route = useRoute();
91
91
  const router = useRouter();
92
92
 
93
- const loading = ref(false);
93
+ const loading = ref(true);
94
94
 
95
95
  const saving = ref(false);
96
96
 
@@ -21,7 +21,6 @@
21
21
  @click="()=>{checkboxes = []}"
22
22
  v-if="checkboxes.length"
23
23
  data-tooltip-target="tooltip-remove-all"
24
- data-tooltip-placement="bottom"
25
24
  class="flex gap-1 items-center py-1 px-3 me-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-darkListTable dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 rounded-default"
26
25
  >
27
26
  <IconBanOutline class="w-5 h-5 "/>
@@ -89,6 +88,7 @@
89
88
  <ResourceListTable
90
89
  :resource="coreStore.resource"
91
90
  :rows="rows"
91
+ :page="page"
92
92
  @update:page="page = $event"
93
93
  @update:sort="sort = $event"
94
94
  @update:checkboxes="checkboxes = $event"
@@ -115,7 +115,7 @@ import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
115
115
  import ResourceListTable from '@/components/ResourceListTable.vue';
116
116
  import { useCoreStore } from '@/stores/core';
117
117
  import { useFiltersStore } from '@/stores/filters';
118
- import { callAdminForthApi, getIcon } from '@/utils';
118
+ import { callAdminForthApi, currentQuery, getIcon, setQuery } from '@/utils';
119
119
  import { computed, onMounted, ref, watch } from 'vue';
120
120
  import { useRoute } from 'vue-router';
121
121
  import { showErrorTost } from '@/composables/useFrontendApi'
@@ -227,6 +227,18 @@ async function startBulkAction(actionId) {
227
227
  }
228
228
 
229
229
 
230
+ class SortQuerySerializer {
231
+ static serialize(sort) {
232
+ return sort.map(s => `${s.field}__${s.direction}`).join(',');
233
+ }
234
+ static deserialize(str) {
235
+ return str.split(',').map(s => {
236
+ const [field, direction] = s.split('__');
237
+ return { field, direction };
238
+ });
239
+ }
240
+ }
241
+
230
242
 
231
243
  async function init() {
232
244
 
@@ -236,9 +248,26 @@ async function init() {
236
248
 
237
249
  initFlowbite();
238
250
 
239
- // !!! clear filters should be in same tick with sort assignment so that watch can catch it
240
- filtersStore.clearFilters();
241
- if (coreStore.resource.options?.defaultSort) {
251
+ // !!! clear filters should be in same tick with sort assignment so that watch can catch it as one change
252
+
253
+ // try to init filters from query params
254
+ const filters = Object.keys(route.query).filter(k => k.startsWith('filter__')).map(k => {
255
+ const [_, field, operator] = k.split('__');
256
+ return {
257
+ field,
258
+ operator,
259
+ value: JSON.parse(decodeURIComponent(route.query[k]))
260
+ }
261
+ });
262
+ if (filters.length) {
263
+ filtersStore.setFilters(filters);
264
+ } else {
265
+ filtersStore.clearFilters();
266
+ }
267
+
268
+ if (route.query.sort) {
269
+ sort.value = SortQuerySerializer.deserialize(route.query.sort);
270
+ } else if (coreStore.resource.options?.defaultSort) {
242
271
  sort.value = [{
243
272
  field: coreStore.resource.options.defaultSort.columnName,
244
273
  direction: coreStore.resource.options.defaultSort.direction
@@ -246,6 +275,11 @@ async function init() {
246
275
  } else {
247
276
  sort.value = [];
248
277
  }
278
+ // page init should be also in same tick
279
+ if (route.query.page) {
280
+ page.value = parseInt(route.query.page);
281
+ }
282
+
249
283
  // await getList(); - Not needed here, watch will trigger it
250
284
  columnsMinMax.value = await callAdminForthApi({
251
285
  path: '/get_min_max_for_columns',
@@ -257,6 +291,8 @@ async function init() {
257
291
  }
258
292
 
259
293
  watch([page, sort, () => filtersStore.filters], async () => {
294
+ console.log('🔄️ page/sort/filter change fired, page:', page.value);
295
+
260
296
  await getList();
261
297
  }, { deep: true });
262
298
 
@@ -264,16 +300,53 @@ window.adminforth.list.refresh = async () => {
264
300
  await getList();
265
301
  }
266
302
 
303
+ let initInProcess = false;
304
+
267
305
  watch(() => filtersStore.filters, async (to, from) => {
306
+ if (initInProcess) {
307
+ return;
308
+ }
309
+ console.log('🔄️ filters changed', JSON.stringify(to))
268
310
  page.value = 1;
269
311
  checkboxes.value = [];
312
+ // update query param for each filter as filter_<column_name>=value
313
+ const query = {};
314
+ const currentQ = currentQuery();
315
+ filtersStore.filters.forEach(f => {
316
+ if (f.value) {
317
+ query[`filter__${f.field}__${f.operator}`] = encodeURIComponent(JSON.stringify(f.value));
318
+ }
319
+ });
320
+ // set every key in currentQ which starts with filter_ to undefined if it is not in query
321
+ Object.keys(currentQ).forEach(k => {
322
+ if (k.startsWith('filter_') && !query[k]) {
323
+ query[k] = undefined;
324
+ }
325
+ });
326
+ setQuery(query);
270
327
  }, {deep: true});
271
328
 
272
329
  onMounted(async () => {
330
+ initInProcess = true;
273
331
  await init();
274
332
  initThreeDotsDropdown();
333
+ initInProcess = false;
334
+ });
335
+
336
+ watch([page], async () => {
337
+ setQuery({ page: page.value });
275
338
  });
276
339
 
277
340
 
278
341
 
342
+
343
+ watch([sort], async () => {
344
+ if (!sort.value.length) {
345
+ setQuery({ sort: undefined });
346
+ return;
347
+ }
348
+ setQuery({ sort: SortQuerySerializer.serialize(sort.value) });
349
+ });
350
+
351
+
279
352
  </script>
@@ -10,6 +10,14 @@
10
10
  :adminUser="coreStore.adminUser"
11
11
  />
12
12
  <BreadcrumbsWithButtons>
13
+ <RouterLink v-if="coreStore.resource?.options?.allowedActions?.create"
14
+ :to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"
15
+ class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 rounded-default"
16
+ >
17
+ <IconPlusOutline class="w-4 h-4 me-2"/>
18
+ Add new
19
+ </RouterLink>
20
+
13
21
  <RouterLink v-if="coreStore?.resourceOptions?.allowedActions?.edit" :to="{ name: 'resource-edit', params: { resourceId: $route.params.resourceId, primaryKey: $route.params.primaryKey } }"
14
22
  class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-default border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
15
23
  >
@@ -126,7 +134,7 @@ import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
126
134
  import ValueRenderer from '@/components/ValueRenderer.vue';
127
135
  import { useCoreStore } from '@/stores/core';
128
136
  import { getCustomComponent, checkAcessByAllowedActions, initThreeDotsDropdown } from '@/utils';
129
- import { IconPenSolid, IconTrashBinSolid } from '@iconify-prerendered/vue-flowbite';
137
+ import { IconPenSolid, IconTrashBinSolid, IconPlusOutline } from '@iconify-prerendered/vue-flowbite';
130
138
  import { onMounted, ref } from 'vue';
131
139
  import { useRoute,useRouter } from 'vue-router';
132
140
  import {callAdminForthApi} from '@/utils';
package/tsconfig.json CHANGED
@@ -55,7 +55,7 @@
55
55
  // "declarationMap": true, /* Create sourcemaps for d.ts files. */
56
56
  // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
57
57
  // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
58
- // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
58
+ "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
59
59
  // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
60
60
  "outDir": "./dist", /* Specify an output folder for all emitted files. */
61
61
  // "removeComments": true, /* Disable emitting comments. */