adminforth 1.0.57 → 1.0.59

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/dist/index.js CHANGED
@@ -32,14 +32,24 @@ class AdminForth {
32
32
  deleteConfirmation: true,
33
33
  });
34
34
  this.config = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _AdminForth_defaultConfig, "f")), config);
35
+ this.codeInjector = new CodeInjector(this);
35
36
  this.validateConfig();
37
+ this.activatePlugins();
38
+ this.validateConfig(); // revalidate after plugins
36
39
  this.express = new ExpressServer(this);
37
40
  this.auth = new Auth();
38
- this.codeInjector = new CodeInjector(this);
39
41
  this.connectors = {};
40
42
  this.statuses = {};
41
43
  console.log(`🚀 AdminForth v${ADMINFORTH_VERSION} starting up`);
42
44
  }
45
+ activatePlugins() {
46
+ for (let resource of this.config.resources) {
47
+ for (let pluginInstance of resource.plugins || []) {
48
+ pluginInstance.modifyResourceConfig(this, resource);
49
+ }
50
+ }
51
+ ;
52
+ }
43
53
  validateConfig() {
44
54
  if (this.config.rootUser) {
45
55
  if (!this.config.rootUser.username) {
@@ -241,7 +251,11 @@ class AdminForth {
241
251
  for (const column of resource.columns) {
242
252
  if (column.component) {
243
253
  for (const [key, value] of Object.entries(column.component)) {
244
- // console.log(`${key}: ${value}`);
254
+ if (this.codeInjector.allComponentNames[value]) {
255
+ // not obvious, but if we are in this if, it means that this is plugin component
256
+ // and there is no sense to check if it exists in users folder
257
+ continue;
258
+ }
245
259
  const path = value.replace('@@', this.config.customization.customComponentsDir);
246
260
  if (!fs.existsSync(path)) {
247
261
  throw new Error(`Component file ${path} does not exist`);
@@ -456,7 +470,8 @@ class AdminForth {
456
470
  if (!resource) {
457
471
  return { error: `Resource ${resourceId} not found` };
458
472
  }
459
- return { resource };
473
+ // exclude "plugins" key
474
+ return { resource: Object.assign(Object.assign({}, resource), { plugins: undefined }) };
460
475
  }),
461
476
  });
462
477
  server.endpoint({
@@ -30,6 +30,8 @@ function hashify(obj) {
30
30
  }
31
31
  class CodeInjector {
32
32
  constructor(adminforth) {
33
+ this.allComponentNames = {};
34
+ this.srcFoldersToSync = {};
33
35
  this.adminforth = adminforth;
34
36
  }
35
37
  // async runShell({command, verbose = false}) {
@@ -126,7 +128,14 @@ class CodeInjector {
126
128
  });
127
129
  // copy whole custom directory
128
130
  if ((_b = this.adminforth.config.customization) === null || _b === void 0 ? void 0 : _b.customComponentsDir) {
129
- yield fsExtra.copy(this.adminforth.config.customization.customComponentsDir, path.join(CodeInjector.SPA_TMP_PATH, 'src', 'custom'), {
131
+ this.srcFoldersToSync[this.adminforth.config.customization.customComponentsDir] = './';
132
+ }
133
+ for (const [src, dest] of Object.entries(this.srcFoldersToSync)) {
134
+ const to = path.join(CodeInjector.SPA_TMP_PATH, 'src', 'custom', dest);
135
+ if (process.env.HEAVY_DEBUG) {
136
+ console.log(`🪲 await fsExtra.copy from ${src}, ${to}`);
137
+ }
138
+ yield fsExtra.copy(src, to, {
130
139
  recursive: true,
131
140
  });
132
141
  }
@@ -169,11 +178,14 @@ class CodeInjector {
169
178
  }
170
179
  });
171
180
  });
172
- let customComponentsImports = '';
173
181
  customResourceComponents.forEach((filePath) => {
174
182
  const componentName = getComponentNameFromPath(filePath);
175
- customComponentsImports += `import ${componentName} from '${filePath}';\n`;
183
+ this.allComponentNames[filePath] = componentName;
176
184
  });
185
+ let customComponentsImports = '';
186
+ for (const [targetPath, component] of Object.entries(this.allComponentNames)) {
187
+ customComponentsImports += `import ${component} from '${targetPath}';\n`;
188
+ }
177
189
  // Generate Vue.component statements for each icon
178
190
  const iconComponents = uniqueIcons.map((icon) => {
179
191
  const [collection, iconName] = icon.split(':');
@@ -184,10 +196,9 @@ class CodeInjector {
184
196
  }).join('\n');
185
197
  // Generate Vue.component statements for each custom component
186
198
  let customComponentsComponents = '';
187
- customResourceComponents.forEach((filePath) => {
188
- const componentName = getComponentNameFromPath(filePath);
189
- customComponentsComponents += `app.component('${componentName}', ${componentName});\n`;
190
- });
199
+ for (const name of Object.values(this.allComponentNames)) {
200
+ customComponentsComponents += `app.component('${name}', ${name});\n`;
201
+ }
191
202
  let imports = iconImports + '\n';
192
203
  imports += customComponentsImports + '\n';
193
204
  if ((_d = this.adminforth.config.customization) === null || _d === void 0 ? void 0 : _d.vueUsesFile) {
@@ -13,13 +13,16 @@ export function guessLabelFromName(name) {
13
13
  return name.split(/(?=[A-Z])/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
14
14
  }
15
15
  }
16
- const __filename = fileURLToPath(import.meta.url);
17
- let __dirname = path.join(path.dirname(__filename), '..');
18
- if (__dirname.endsWith('dist')) {
19
- // in prod build we are in dist also
20
- __dirname = path.join(__dirname, '..');
21
- }
22
- export const ADMIN_FORTH_ABSOLUTE_PATH = __dirname;
16
+ export const currentFileDir = (metaUrl) => {
17
+ const __filename = fileURLToPath(metaUrl);
18
+ let __dirname = path.dirname(__filename);
19
+ if (__dirname.endsWith('dist')) {
20
+ // in prod build we are in dist also
21
+ __dirname = path.join(__dirname, '..');
22
+ }
23
+ return __dirname;
24
+ };
25
+ export const ADMIN_FORTH_ABSOLUTE_PATH = path.join(currentFileDir(import.meta.url), '..');
23
26
  const package_json = JSON.parse(fs.readFileSync(path.join(ADMIN_FORTH_ABSOLUTE_PATH, 'package.json'), 'utf8'));
24
27
  export const ADMINFORTH_VERSION = package_json.version;
25
28
  export function getComponentNameFromPath(filePath) {
@@ -0,0 +1,24 @@
1
+ import AdminForthPlugin from "../base.js";
2
+ export default class ForeignInlineListPlugin extends AdminForthPlugin {
3
+ constructor(options) {
4
+ super(options, import.meta.url);
5
+ this.options = options;
6
+ }
7
+ modifyResourceConfig(adminforth, resourceConfig) {
8
+ super.modifyResourceConfig(adminforth, resourceConfig);
9
+ // get resource with foreignResourceId
10
+ this.foreignResource = adminforth.config.resources.find((resource) => resource.resourceId === this.options.foreignResourceId);
11
+ if (!this.foreignResource) {
12
+ throw new Error(`ForeignInlineListPlugin: Resource with ID "${this.options.foreignResourceId}" not found`);
13
+ }
14
+ resourceConfig.columns.push({
15
+ name: `foreignInlineList_${this.foreignResource.resourceId}`,
16
+ label: 'Foreign Inline List',
17
+ virtual: true,
18
+ showIn: ['show'],
19
+ component: {
20
+ show: this.componentPath('InlineList.vue'),
21
+ },
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,30 @@
1
+ import { getComponentNameFromPath } from '../modules/utils.js';
2
+ import { currentFileDir } from '../modules/utils.js';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ export default class AdminForthPlugin {
6
+ constructor(pluginOptions, metaUrl) {
7
+ this.customFolderName = 'custom';
8
+ // set up plugin here
9
+ this.pluginDir = currentFileDir(metaUrl);
10
+ }
11
+ modifyResourceConfig(adminforth, resourceConfig) {
12
+ this.adminforth = adminforth;
13
+ }
14
+ componentPath(componentFile) {
15
+ const key = `@@/plugins/${this.constructor.name}/${componentFile}`;
16
+ const componentName = getComponentNameFromPath(key);
17
+ const absSrcFolder = path.join(this.pluginDir, this.customFolderName);
18
+ if (!this.adminforth.codeInjector.srcFoldersToSync[absSrcFolder]) {
19
+ this.adminforth.codeInjector.srcFoldersToSync[absSrcFolder] = `./plugins/${this.constructor.name}/`;
20
+ }
21
+ if (!this.adminforth.codeInjector.allComponentNames[key]) {
22
+ const absSrcPath = path.join(absSrcFolder, componentFile);
23
+ if (!fs.existsSync(absSrcPath)) {
24
+ throw new Error(`Plugin "${this.constructor.name}" tried to use file as component which does not exist at "${absSrcPath}"`);
25
+ }
26
+ this.adminforth.codeInjector.allComponentNames[key] = componentName;
27
+ }
28
+ return key;
29
+ }
30
+ }
@@ -107,7 +107,14 @@ class ExpressServer {
107
107
  }
108
108
  else {
109
109
  this.expressApp.get(`${slashedPrefix}assets/*`, (req, res) => {
110
- res.sendFile(path.join(CodeInjector.SPA_TMP_PATH, 'dist', replaceAtStart(req.url, prefix)));
110
+ res.sendFile(path.join(CodeInjector.SPA_TMP_PATH, 'dist', replaceAtStart(req.url, prefix)), {
111
+ cacheControl: false,
112
+ // store for a year
113
+ headers: {
114
+ 'Cache-Control': 'public, max-age=31536000',
115
+ 'Pragma': 'public',
116
+ }
117
+ });
111
118
  });
112
119
  this.expressApp.get(`${prefix}*`, (req, res) => __awaiter(this, void 0, void 0, function* () {
113
120
  const fullPath = path.join(CodeInjector.SPA_TMP_PATH, 'dist', 'index.html');
@@ -122,7 +129,15 @@ class ExpressServer {
122
129
  res.status(500).send(respondNoServer(`${this.adminforth.config.brandName} is still warming up`, 'Please wait a moment...'));
123
130
  return;
124
131
  }
125
- res.sendFile(fullPath);
132
+ res.sendFile(fullPath, {
133
+ cacheControl: false,
134
+ headers: {
135
+ 'Content-Type': 'text/html',
136
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
137
+ 'Pragma': 'no-cache',
138
+ 'Expires': '0'
139
+ }
140
+ });
126
141
  }));
127
142
  }
128
143
  }
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <nav
3
- v-if="loggedIn"
4
- class="fixed h-14 top-0 z-40 w-full border-b shadow-sm bg-header-bg shadow-header-shadow dark:bg-header-bg-hover dark:border-nav-menu-devider">
3
+ v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady"
4
+ class="fixed h-14 top-0 z-20 w-full border-b shadow-sm bg-header-bg shadow-header-shadow dark:bg-header-bg-hover dark:border-nav-menu-devider">
5
5
  <div class="px-3 py-3 lg:px-5 lg:pl-3">
6
6
  <div class="flex items-center justify-between">
7
7
  <div class="flex items-center justify-start rtl:justify-end">
@@ -55,10 +55,12 @@
55
55
 
56
56
 
57
57
  <aside
58
- v-if="loggedIn"
58
+
59
+ v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady"
59
60
 
60
61
  id="logo-sidebar" class="fixed border-none top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full bg-nav-menu-bg border-r border- sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700" aria-label="Sidebar">
61
62
  <div class="h-full px-3 pb-4 overflow-y-auto bg-nav-menu-bg dark:bg-gray-800">
63
+
62
64
  <div class="flex ms-2 md:me-24 m-4 ">
63
65
  <img src="https://flowbite.com/docs/images/logo.svg" class="h-8 me-3" alt="FlowBite Logo" />
64
66
  <span class="self-center text-header-text-size font-semibold sm:text-header-text-size whitespace-nowrap dark:text-header-text text-header-logo-color">
@@ -71,17 +73,15 @@
71
73
  <div v-else-if="item.type === 'gap'" class="flex items-center justify-center h-8"></div>
72
74
  <div v-else-if="item.type === 'heading'" class="flex items-center justify-left pl-2 h-8 text-gray-400 dark:text-gray-400
73
75
  ">{{ item.label }}</div>
74
-
75
-
76
76
  <li v-else-if="item.children">
77
- <button type="button" class="flex items-center w-full p-2 text-base text-nav-menu-text rounded-default transition duration-75 group hover:bg-nav-menu-bg-hover dark:text-white dark:hover:bg-gray-700"
77
+ <button @click="clickOnMenuItem(i)" type="button" class="flex items-center w-full p-2 text-base text-nav-menu-text rounded-default transition duration-75 group hover:bg-nav-menu-bg-hover dark:text-white dark:hover:bg-gray-700"
78
78
  :aria-controls="`dropdown-example${i}`"
79
79
  :data-collapse-toggle="`dropdown-example${i}`"
80
80
  >
81
81
  <component v-if="item.icon" :is="getIcon(item.icon)" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 dark:group-hover:text-white" ></component>
82
82
 
83
83
  <span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">{{ item.label }}</span>
84
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
84
+ <svg :class="{'rotate-180': opened.includes(i) }" class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
85
85
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
86
86
  </svg>
87
87
  </button>
@@ -102,16 +102,22 @@
102
102
  </div>
103
103
  </aside>
104
104
 
105
- <div class="p-4 sm:ml-64 dark:bg-gray-800" v-if="loggedIn">
105
+ <div class="p-4 sm:ml-64 dark:bg-gray-800" v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady">
106
106
  <div class="p-0 dark:border-gray-700 mt-14">
107
107
  <RouterView/>
108
108
  </div>
109
109
  </div>
110
110
 
111
- <div v-else-if="routerIsReady">
111
+ <div v-else-if="routerIsReady && loginRedirectCheckIsReady">
112
112
  <RouterView/>
113
113
  </div>
114
+ <div v-else class="flex items-center justify-center h-screen">
115
+ <div class="text-3xl text-gray-400 animate-bounce">
116
+ ▶️
117
+ </div>
118
+ </div>
114
119
  <AcceptModal />
120
+
115
121
  </template>
116
122
 
117
123
  <style scoped>
@@ -144,11 +150,14 @@ createHead()
144
150
  const route = useRoute();
145
151
  const router = useRouter();
146
152
  const title = ref('');
153
+ //create a ref to store the opened menu items with ts type;
154
+ const opened = ref<string[]>([]);
147
155
 
148
156
 
149
157
  const routerIsReady = ref(false);
158
+ const loginRedirectCheckIsReady = ref(false);
150
159
 
151
- const loggedIn = computed(() => route.name !== 'login' && routerIsReady.value);
160
+ const loggedIn = computed(() => route.name !== 'login');
152
161
 
153
162
  const theme = ref('light');
154
163
 
@@ -157,6 +166,15 @@ function toggleTheme() {
157
166
  document.documentElement.classList.toggle('dark');
158
167
  }
159
168
 
169
+ function clickOnMenuItem (label: string) {
170
+ if (opened.value.includes(label)) {
171
+ opened.value = opened.value.filter((item) => item !== label);
172
+ } else {
173
+ opened.value.push(label);
174
+ }
175
+
176
+ }
177
+
160
178
  async function logout() {
161
179
  await coreStore.logout();
162
180
  router.push({ name: 'login' });
@@ -175,6 +193,7 @@ onMounted(async () => {
175
193
  initRouter();
176
194
  initFlowbite();
177
195
  await coreStore.fetchMenuAndResource();
196
+ loginRedirectCheckIsReady.value = true;
178
197
  title.value = coreStore.config.title || 'Admin Forth';
179
198
  useHead({
180
199
  title: title.value,
@@ -186,6 +205,11 @@ onMounted(async () => {
186
205
  ],
187
206
 
188
207
  })
208
+ coreStore.menu.forEach((item, i) => {
209
+ if (item.open) {
210
+ opened.value.push(i);
211
+ }
212
+ });
189
213
  })
190
214
 
191
215
  </script>
@@ -2,11 +2,11 @@
2
2
  <!-- drawer component -->
3
3
  <div id="drawer-navigation"
4
4
 
5
- class="fixed top-14 right-0 z-50 p-4 overflow-y-auto transition-transform translate-x-full bg-white w-80 dark:bg-gray-800 shadow-xl dark:shadow-black"
5
+ class="fixed right-0 z-50 p-4 overflow-y-auto transition-transform translate-x-full bg-white w-80 dark:bg-gray-800 shadow-xl dark:shadow-black"
6
6
 
7
7
  :class="show ? 'top-0 transform-none' : ''"
8
8
  tabindex="-1" aria-labelledby="drawer-navigation-label"
9
- :style="{ height: `calc(100vh - 3.5rem)` }"
9
+ :style="{ height: `calc(100vh ` }"
10
10
  >
11
11
  <h5 id="drawer-navigation-label" class="text-base font-semibold text-gray-500 uppercase dark:text-gray-400">
12
12
  Filters
@@ -92,7 +92,7 @@
92
92
  </div>
93
93
  </div>
94
94
 
95
- <div v-if="show" drawer-backdrop="" class="bg-gray-900/50 dark:bg-gray-900/80 fixed inset-0 z-30"
95
+ <div v-if="show" class="bg-gray-900/50 dark:bg-gray-900/80 fixed inset-0 z-30"
96
96
  @click="$emit('hide')">
97
97
  </div>
98
98
  </template>
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <td class="px-6 py-4 whitespace-nowrap">
3
+ {{ column.label }}
4
+ </td>
5
+ <td class="px-6 py-4 whitespace-nowrap whitespace-pre-wrap">
6
+ <ValueRenderer :column="column" :row="row" />
7
+ </td>
8
+ </template>
9
+
10
+ <script setup>
11
+ import ValueRenderer from '@/components/ValueRenderer.vue';
12
+
13
+ defineProps(['column', 'row']);
14
+ </script>
@@ -40,4 +40,5 @@ const router = createRouter({
40
40
  ]
41
41
  })
42
42
 
43
+
43
44
  export default router
@@ -11,12 +11,16 @@ export const useCoreStore = defineStore('core', () => {
11
11
  const resourceColumnsError = ref('');
12
12
  const resourceColumnsId = ref(null);
13
13
  const user = ref(null);
14
+ const flowBitIsInitialised = ref(false);
14
15
 
15
16
  async function fetchMenuAndResource() {
16
17
  const resp = await callAdminForthApi({
17
18
  path: '/get_base_config',
18
19
  method: 'GET',
19
20
  });
21
+ if(!resp){
22
+ return
23
+ }
20
24
  menu.value = resp.menu;
21
25
  resourceById.value = resp.resources.reduce((acc, resource) => {
22
26
  acc[resource.resourceId] = resource;
@@ -122,6 +126,7 @@ export const useCoreStore = defineStore('core', () => {
122
126
  resourceColumns,
123
127
  fetchColumns,
124
128
  resourceColumnsError,
129
+ flowBitIsInitialised,
125
130
  logout
126
131
  }
127
132
  })
@@ -448,10 +448,10 @@ fetchStatus.value.pending = false;
448
448
  allowedActions.value = data.options?.allowedActions;
449
449
 
450
450
 
451
- setTimeout(() => {
452
- initFlowbite();
453
- console.log('initFlowbite');
454
- });
451
+ // setTimeout(() => {
452
+ // initFlowbite();
453
+ // console.log('initFlowbite');
454
+ // });
455
455
  }
456
456
 
457
457
 
@@ -512,6 +512,7 @@ function addToCheckedValues(id) {
512
512
  }
513
513
 
514
514
  async function init() {
515
+
515
516
  await coreStore.fetchColumns({
516
517
  resourceId: route.params.resourceId
517
518
  });
@@ -527,7 +528,13 @@ async function init() {
527
528
  }
528
529
 
529
530
  onMounted(async () => {
531
+ if (!coreStore.flowBitIsInitialised){
532
+ initFlowbite();
533
+ coreStore.flowBitIsInitialised = true;
534
+ }
535
+
530
536
  await init();
537
+
531
538
  });
532
539
 
533
540
  // on route param change
@@ -44,18 +44,12 @@
44
44
  <tr v-for="column in coreStore.resourceColumns?.filter(c => c.showIn.includes('show'))" :key="column.name"
45
45
  class="bg-form-view-bg odd:dark:bg-gray-900 even:dark:bg-gray-800 border-b dark:border-gray-700"
46
46
  >
47
- <td class="px-6 py-4 whitespace-nowrap">
48
- {{ column.label }}
49
- </td>
50
- <td class="px-6 py-4 whitespace-nowrap whitespace-pre-wrap">
51
- <component
52
- :is="showComponentsPerColumn[column.name] || ValueRenderer"
53
- :column="column"
54
- :row="coreStore.record"
55
- />
56
- </td>
47
+ <component
48
+ :is="showComponentsPerColumn[column.name] || ShowTableItem"
49
+ :column="column"
50
+ :row="coreStore.record"
51
+ />
57
52
  </tr>
58
-
59
53
  </tbody>
60
54
  </table>
61
55
  </div>
@@ -68,6 +62,8 @@
68
62
  <script setup>
69
63
 
70
64
  import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
65
+ import ShowTableItem from '@/components/ShowTableItem.vue';
66
+
71
67
  import ValueRenderer from '@/components/ValueRenderer.vue';
72
68
  import { useCoreStore } from '@/stores/core';
73
69
  import { getCustomComponent } from '@/utils';
package/index.ts CHANGED
@@ -43,7 +43,6 @@ class AdminForth {
43
43
  connectorClasses: any;
44
44
  runningHotReload?: boolean;
45
45
 
46
-
47
46
  statuses: {
48
47
  dbDiscover?: 'running' | 'done',
49
48
  }
@@ -51,15 +50,27 @@ class AdminForth {
51
50
 
52
51
  constructor(config: AdminForthConfig) {
53
52
  this.config = {...this.#defaultConfig,...config};
53
+ this.codeInjector = new CodeInjector(this);
54
+
54
55
  this.validateConfig();
56
+ this.activatePlugins();
57
+ this.validateConfig(); // revalidate after plugins
58
+
55
59
  this.express = new ExpressServer(this);
56
60
  this.auth = new Auth();
57
- this.codeInjector = new CodeInjector(this);
58
61
  this.connectors = {};
59
- this.statuses = {}
62
+ this.statuses = {};
60
63
  console.log(`🚀 AdminForth v${ADMINFORTH_VERSION} starting up`)
61
64
  }
62
65
 
66
+ activatePlugins() {
67
+ for (let resource of this.config.resources) {
68
+ for (let pluginInstance of resource.plugins || []) {
69
+ pluginInstance.modifyResourceConfig(this, resource);
70
+ }
71
+ };
72
+ }
73
+
63
74
  validateConfig() {
64
75
  if (this.config.rootUser) {
65
76
  if (!this.config.rootUser.username) {
@@ -297,7 +308,11 @@ class AdminForth {
297
308
  for (const column of resource.columns) {
298
309
  if (column.component) {
299
310
  for (const [key, value] of Object.entries(column.component)) {
300
- // console.log(`${key}: ${value}`);
311
+ if (this.codeInjector.allComponentNames[value]) {
312
+ // not obvious, but if we are in this if, it means that this is plugin component
313
+ // and there is no sense to check if it exists in users folder
314
+ continue;
315
+ }
301
316
  const path = value.replace('@@', this.config.customization.customComponentsDir);
302
317
  if (!fs.existsSync(path)) {
303
318
  throw new Error(`Component file ${path} does not exist`);
@@ -527,7 +542,8 @@ class AdminForth {
527
542
  if (!resource) {
528
543
  return { error: `Resource ${resourceId} not found` };
529
544
  }
530
- return { resource };
545
+ // exclude "plugins" key
546
+ return { resource: { ...resource, plugins: undefined } };
531
547
  },
532
548
  });
533
549
  server.endpoint({
@@ -30,6 +30,8 @@ function hashify(obj) {
30
30
  class CodeInjector {
31
31
 
32
32
  adminforth: AdminForth;
33
+ allComponentNames: { [key: string]: string } = {};
34
+ srcFoldersToSync: { [key: string]: string } = {};
33
35
 
34
36
  static SPA_TMP_PATH = path.join(TMP_DIR, 'adminforth', 'spa_tmp');
35
37
 
@@ -139,12 +141,23 @@ class CodeInjector {
139
141
 
140
142
  // copy whole custom directory
141
143
  if (this.adminforth.config.customization?.customComponentsDir) {
142
- await fsExtra.copy(this.adminforth.config.customization.customComponentsDir, path.join(CodeInjector.SPA_TMP_PATH, 'src', 'custom'), {
144
+ this.srcFoldersToSync[this.adminforth.config.customization.customComponentsDir] = './'
145
+ }
146
+
147
+ for (const [src, dest] of Object.entries(this.srcFoldersToSync)) {
148
+ const to = path.join(CodeInjector.SPA_TMP_PATH, 'src', 'custom', dest);
149
+ if (process.env.HEAVY_DEBUG) {
150
+ console.log(`🪲 await fsExtra.copy from ${src}, ${to}`);
151
+ }
152
+
153
+ await fsExtra.copy(src, to, {
143
154
  recursive: true,
144
155
  });
145
156
  }
146
157
  }
147
158
 
159
+
160
+
148
161
  //collect all 'icon' fields from resources bulkActions
149
162
  this.adminforth.config.resources.forEach((resource) => {
150
163
  if (resource.options?.bulkActions) {
@@ -189,12 +202,15 @@ class CodeInjector {
189
202
  });
190
203
  });
191
204
 
192
- let customComponentsImports = '';
193
205
  customResourceComponents.forEach((filePath) => {
194
206
  const componentName = getComponentNameFromPath(filePath);
195
- customComponentsImports += `import ${componentName} from '${filePath}';\n`;
207
+ this.allComponentNames[filePath] = componentName;
196
208
  });
197
-
209
+
210
+ let customComponentsImports = '';
211
+ for (const [targetPath, component] of Object.entries(this.allComponentNames)) {
212
+ customComponentsImports += `import ${component} from '${targetPath}';\n`;
213
+ }
198
214
 
199
215
 
200
216
  // Generate Vue.component statements for each icon
@@ -208,11 +224,9 @@ class CodeInjector {
208
224
 
209
225
  // Generate Vue.component statements for each custom component
210
226
  let customComponentsComponents = '';
211
- customResourceComponents.forEach((filePath) => {
212
- const componentName = getComponentNameFromPath(filePath);
213
- customComponentsComponents += `app.component('${componentName}', ${componentName});\n`;
214
- })
215
-
227
+ for (const name of Object.values(this.allComponentNames)) {
228
+ customComponentsComponents += `app.component('${name}', ${name});\n`;
229
+ }
216
230
 
217
231
  let imports = iconImports + '\n';
218
232
  imports += customComponentsImports + '\n';
package/modules/utils.ts CHANGED
@@ -15,14 +15,17 @@ export function guessLabelFromName(name) {
15
15
  }
16
16
 
17
17
 
18
- const __filename = fileURLToPath(import.meta.url);
19
- let __dirname = path.join(path.dirname(__filename), '..');
20
- if (__dirname.endsWith('dist')) {
21
- // in prod build we are in dist also
22
- __dirname = path.join(__dirname, '..');
18
+ export const currentFileDir = (metaUrl) => {
19
+ const __filename = fileURLToPath(metaUrl);
20
+ let __dirname = path.dirname(__filename);
21
+ if (__dirname.endsWith('dist')) {
22
+ // in prod build we are in dist also
23
+ __dirname = path.join(__dirname, '..');
24
+ }
25
+ return __dirname;
23
26
  }
24
27
 
25
- export const ADMIN_FORTH_ABSOLUTE_PATH = __dirname;
28
+ export const ADMIN_FORTH_ABSOLUTE_PATH = path.join(currentFileDir(import.meta.url), '..');
26
29
 
27
30
  const package_json = JSON.parse(fs.readFileSync(path.join(ADMIN_FORTH_ABSOLUTE_PATH, 'package.json'), 'utf8'));
28
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adminforth",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "description": "OpenSource Vue3 powered forth-generation admin panel",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <td colspan="2">
3
+ text
4
+ </td>
5
+ </template>
@@ -0,0 +1,39 @@
1
+ import { AdminForthResource } from "../../types/AdminForthConfig.js";
2
+ import AdminForthPlugin from "../base.js";
3
+ import AdminForth from "../../index.js";
4
+
5
+ type PluginOptions = {
6
+ foreignResourceId: string;
7
+ }
8
+
9
+ export default class ForeignInlineListPlugin extends AdminForthPlugin {
10
+ foreignResource: AdminForthResource;
11
+ options: PluginOptions;
12
+
13
+ constructor(options: PluginOptions) {
14
+ super(options, import.meta.url);
15
+
16
+ this.options = options;
17
+
18
+ }
19
+
20
+ modifyResourceConfig(adminforth: AdminForth, resourceConfig: AdminForthResource) {
21
+ super.modifyResourceConfig(adminforth, resourceConfig);
22
+
23
+ // get resource with foreignResourceId
24
+ this.foreignResource = adminforth.config.resources.find((resource) => resource.resourceId === this.options.foreignResourceId);
25
+ if (!this.foreignResource) {
26
+ throw new Error(`ForeignInlineListPlugin: Resource with ID "${this.options.foreignResourceId}" not found`);
27
+ }
28
+
29
+ resourceConfig.columns.push({
30
+ name: `foreignInlineList_${this.foreignResource.resourceId}`,
31
+ label: 'Foreign Inline List',
32
+ virtual: true,
33
+ showIn: ['show'],
34
+ component: {
35
+ show: this.componentPath('InlineList.vue'),
36
+ },
37
+ });
38
+ }
39
+ }
@@ -0,0 +1,44 @@
1
+ import { AdminForthResource } from '../types/AdminForthConfig.js';
2
+ import AdminForth from '../index.js';
3
+ import { getComponentNameFromPath } from '../modules/utils.js';
4
+ import { currentFileDir } from '../modules/utils.js';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+
8
+ export default class AdminForthPlugin {
9
+
10
+ adminforth: AdminForth;
11
+ pluginDir: string;
12
+ customFolderName: string = 'custom';
13
+
14
+ constructor(pluginOptions: any, metaUrl: string) {
15
+ // set up plugin here
16
+ this.pluginDir = currentFileDir(metaUrl);
17
+ }
18
+
19
+ modifyResourceConfig(adminforth: AdminForth, resourceConfig: AdminForthResource) {
20
+ this.adminforth = adminforth;
21
+ }
22
+
23
+ componentPath(componentFile: string) {
24
+ const key = `@@/plugins/${this.constructor.name}/${componentFile}`;
25
+ const componentName = getComponentNameFromPath(key);
26
+
27
+ const absSrcFolder = path.join(this.pluginDir, this.customFolderName);
28
+
29
+ if (!this.adminforth.codeInjector.srcFoldersToSync[absSrcFolder]) {
30
+ this.adminforth.codeInjector.srcFoldersToSync[absSrcFolder] = `./plugins/${this.constructor.name}/`;
31
+ }
32
+
33
+ if (!this.adminforth.codeInjector.allComponentNames[key]) {
34
+ const absSrcPath = path.join(absSrcFolder, componentFile);
35
+ if (!fs.existsSync(absSrcPath)) {
36
+ throw new Error(`Plugin "${this.constructor.name}" tried to use file as component which does not exist at "${absSrcPath}"`);
37
+ }
38
+ this.adminforth.codeInjector.allComponentNames[key] = componentName;
39
+ }
40
+
41
+ return key;
42
+ }
43
+
44
+ }
@@ -112,7 +112,17 @@ class ExpressServer {
112
112
 
113
113
  } else {
114
114
  this.expressApp.get(`${slashedPrefix}assets/*`, (req, res) => {
115
- res.sendFile(path.join(CodeInjector.SPA_TMP_PATH, 'dist', replaceAtStart(req.url, prefix)))
115
+ res.sendFile(
116
+ path.join(CodeInjector.SPA_TMP_PATH, 'dist', replaceAtStart(req.url, prefix)),
117
+ {
118
+ cacheControl: false,
119
+ // store for a year
120
+ headers: {
121
+ 'Cache-Control': 'public, max-age=31536000',
122
+ 'Pragma': 'public',
123
+ }
124
+ }
125
+ )
116
126
  })
117
127
 
118
128
  this.expressApp.get(`${prefix}*`, async (req, res) => {
@@ -128,7 +138,14 @@ class ExpressServer {
128
138
  res.status(500).send(respondNoServer(`${this.adminforth.config.brandName} is still warming up`, 'Please wait a moment...'));
129
139
  return;
130
140
  }
131
- res.sendFile(fullPath);
141
+ res.sendFile(fullPath, {
142
+ cacheControl: false,
143
+ headers: {
144
+ 'Content-Type': 'text/html',
145
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
146
+ 'Pragma': 'no-cache',
147
+ 'Expires': '0'
148
+ } });
132
149
  });
133
150
  }
134
151
  }
package/spa/src/App.vue CHANGED
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <nav
3
- v-if="loggedIn"
4
- class="fixed h-14 top-0 z-40 w-full border-b shadow-sm bg-header-bg shadow-header-shadow dark:bg-header-bg-hover dark:border-nav-menu-devider">
3
+ v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady"
4
+ class="fixed h-14 top-0 z-20 w-full border-b shadow-sm bg-header-bg shadow-header-shadow dark:bg-header-bg-hover dark:border-nav-menu-devider">
5
5
  <div class="px-3 py-3 lg:px-5 lg:pl-3">
6
6
  <div class="flex items-center justify-between">
7
7
  <div class="flex items-center justify-start rtl:justify-end">
@@ -55,10 +55,12 @@
55
55
 
56
56
 
57
57
  <aside
58
- v-if="loggedIn"
58
+
59
+ v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady"
59
60
 
60
61
  id="logo-sidebar" class="fixed border-none top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full bg-nav-menu-bg border-r border- sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700" aria-label="Sidebar">
61
62
  <div class="h-full px-3 pb-4 overflow-y-auto bg-nav-menu-bg dark:bg-gray-800">
63
+
62
64
  <div class="flex ms-2 md:me-24 m-4 ">
63
65
  <img src="https://flowbite.com/docs/images/logo.svg" class="h-8 me-3" alt="FlowBite Logo" />
64
66
  <span class="self-center text-header-text-size font-semibold sm:text-header-text-size whitespace-nowrap dark:text-header-text text-header-logo-color">
@@ -71,17 +73,15 @@
71
73
  <div v-else-if="item.type === 'gap'" class="flex items-center justify-center h-8"></div>
72
74
  <div v-else-if="item.type === 'heading'" class="flex items-center justify-left pl-2 h-8 text-gray-400 dark:text-gray-400
73
75
  ">{{ item.label }}</div>
74
-
75
-
76
76
  <li v-else-if="item.children">
77
- <button type="button" class="flex items-center w-full p-2 text-base text-nav-menu-text rounded-default transition duration-75 group hover:bg-nav-menu-bg-hover dark:text-white dark:hover:bg-gray-700"
77
+ <button @click="clickOnMenuItem(i)" type="button" class="flex items-center w-full p-2 text-base text-nav-menu-text rounded-default transition duration-75 group hover:bg-nav-menu-bg-hover dark:text-white dark:hover:bg-gray-700"
78
78
  :aria-controls="`dropdown-example${i}`"
79
79
  :data-collapse-toggle="`dropdown-example${i}`"
80
80
  >
81
81
  <component v-if="item.icon" :is="getIcon(item.icon)" class="w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 dark:group-hover:text-white" ></component>
82
82
 
83
83
  <span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">{{ item.label }}</span>
84
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
84
+ <svg :class="{'rotate-180': opened.includes(i) }" class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
85
85
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
86
86
  </svg>
87
87
  </button>
@@ -102,16 +102,22 @@
102
102
  </div>
103
103
  </aside>
104
104
 
105
- <div class="p-4 sm:ml-64 dark:bg-gray-800" v-if="loggedIn">
105
+ <div class="p-4 sm:ml-64 dark:bg-gray-800" v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady">
106
106
  <div class="p-0 dark:border-gray-700 mt-14">
107
107
  <RouterView/>
108
108
  </div>
109
109
  </div>
110
110
 
111
- <div v-else-if="routerIsReady">
111
+ <div v-else-if="routerIsReady && loginRedirectCheckIsReady">
112
112
  <RouterView/>
113
113
  </div>
114
+ <div v-else class="flex items-center justify-center h-screen">
115
+ <div class="text-3xl text-gray-400 animate-bounce">
116
+ ▶️
117
+ </div>
118
+ </div>
114
119
  <AcceptModal />
120
+
115
121
  </template>
116
122
 
117
123
  <style scoped>
@@ -144,11 +150,14 @@ createHead()
144
150
  const route = useRoute();
145
151
  const router = useRouter();
146
152
  const title = ref('');
153
+ //create a ref to store the opened menu items with ts type;
154
+ const opened = ref<string[]>([]);
147
155
 
148
156
 
149
157
  const routerIsReady = ref(false);
158
+ const loginRedirectCheckIsReady = ref(false);
150
159
 
151
- const loggedIn = computed(() => route.name !== 'login' && routerIsReady.value);
160
+ const loggedIn = computed(() => route.name !== 'login');
152
161
 
153
162
  const theme = ref('light');
154
163
 
@@ -157,6 +166,15 @@ function toggleTheme() {
157
166
  document.documentElement.classList.toggle('dark');
158
167
  }
159
168
 
169
+ function clickOnMenuItem (label: string) {
170
+ if (opened.value.includes(label)) {
171
+ opened.value = opened.value.filter((item) => item !== label);
172
+ } else {
173
+ opened.value.push(label);
174
+ }
175
+
176
+ }
177
+
160
178
  async function logout() {
161
179
  await coreStore.logout();
162
180
  router.push({ name: 'login' });
@@ -175,6 +193,7 @@ onMounted(async () => {
175
193
  initRouter();
176
194
  initFlowbite();
177
195
  await coreStore.fetchMenuAndResource();
196
+ loginRedirectCheckIsReady.value = true;
178
197
  title.value = coreStore.config.title || 'Admin Forth';
179
198
  useHead({
180
199
  title: title.value,
@@ -186,6 +205,11 @@ onMounted(async () => {
186
205
  ],
187
206
 
188
207
  })
208
+ coreStore.menu.forEach((item, i) => {
209
+ if (item.open) {
210
+ opened.value.push(i);
211
+ }
212
+ });
189
213
  })
190
214
 
191
215
  </script>
@@ -2,11 +2,11 @@
2
2
  <!-- drawer component -->
3
3
  <div id="drawer-navigation"
4
4
 
5
- class="fixed top-14 right-0 z-50 p-4 overflow-y-auto transition-transform translate-x-full bg-white w-80 dark:bg-gray-800 shadow-xl dark:shadow-black"
5
+ class="fixed right-0 z-50 p-4 overflow-y-auto transition-transform translate-x-full bg-white w-80 dark:bg-gray-800 shadow-xl dark:shadow-black"
6
6
 
7
7
  :class="show ? 'top-0 transform-none' : ''"
8
8
  tabindex="-1" aria-labelledby="drawer-navigation-label"
9
- :style="{ height: `calc(100vh - 3.5rem)` }"
9
+ :style="{ height: `calc(100vh ` }"
10
10
  >
11
11
  <h5 id="drawer-navigation-label" class="text-base font-semibold text-gray-500 uppercase dark:text-gray-400">
12
12
  Filters
@@ -92,7 +92,7 @@
92
92
  </div>
93
93
  </div>
94
94
 
95
- <div v-if="show" drawer-backdrop="" class="bg-gray-900/50 dark:bg-gray-900/80 fixed inset-0 z-30"
95
+ <div v-if="show" class="bg-gray-900/50 dark:bg-gray-900/80 fixed inset-0 z-30"
96
96
  @click="$emit('hide')">
97
97
  </div>
98
98
  </template>
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <td class="px-6 py-4 whitespace-nowrap">
3
+ {{ column.label }}
4
+ </td>
5
+ <td class="px-6 py-4 whitespace-nowrap whitespace-pre-wrap">
6
+ <ValueRenderer :column="column" :row="row" />
7
+ </td>
8
+ </template>
9
+
10
+ <script setup>
11
+ import ValueRenderer from '@/components/ValueRenderer.vue';
12
+
13
+ defineProps(['column', 'row']);
14
+ </script>
@@ -40,4 +40,5 @@ const router = createRouter({
40
40
  ]
41
41
  })
42
42
 
43
+
43
44
  export default router
@@ -11,12 +11,16 @@ export const useCoreStore = defineStore('core', () => {
11
11
  const resourceColumnsError = ref('');
12
12
  const resourceColumnsId = ref(null);
13
13
  const user = ref(null);
14
+ const flowBitIsInitialised = ref(false);
14
15
 
15
16
  async function fetchMenuAndResource() {
16
17
  const resp = await callAdminForthApi({
17
18
  path: '/get_base_config',
18
19
  method: 'GET',
19
20
  });
21
+ if(!resp){
22
+ return
23
+ }
20
24
  menu.value = resp.menu;
21
25
  resourceById.value = resp.resources.reduce((acc, resource) => {
22
26
  acc[resource.resourceId] = resource;
@@ -122,6 +126,7 @@ export const useCoreStore = defineStore('core', () => {
122
126
  resourceColumns,
123
127
  fetchColumns,
124
128
  resourceColumnsError,
129
+ flowBitIsInitialised,
125
130
  logout
126
131
  }
127
132
  })
@@ -448,10 +448,10 @@ fetchStatus.value.pending = false;
448
448
  allowedActions.value = data.options?.allowedActions;
449
449
 
450
450
 
451
- setTimeout(() => {
452
- initFlowbite();
453
- console.log('initFlowbite');
454
- });
451
+ // setTimeout(() => {
452
+ // initFlowbite();
453
+ // console.log('initFlowbite');
454
+ // });
455
455
  }
456
456
 
457
457
 
@@ -512,6 +512,7 @@ function addToCheckedValues(id) {
512
512
  }
513
513
 
514
514
  async function init() {
515
+
515
516
  await coreStore.fetchColumns({
516
517
  resourceId: route.params.resourceId
517
518
  });
@@ -527,7 +528,13 @@ async function init() {
527
528
  }
528
529
 
529
530
  onMounted(async () => {
531
+ if (!coreStore.flowBitIsInitialised){
532
+ initFlowbite();
533
+ coreStore.flowBitIsInitialised = true;
534
+ }
535
+
530
536
  await init();
537
+
531
538
  });
532
539
 
533
540
  // on route param change
@@ -44,18 +44,12 @@
44
44
  <tr v-for="column in coreStore.resourceColumns?.filter(c => c.showIn.includes('show'))" :key="column.name"
45
45
  class="bg-form-view-bg odd:dark:bg-gray-900 even:dark:bg-gray-800 border-b dark:border-gray-700"
46
46
  >
47
- <td class="px-6 py-4 whitespace-nowrap">
48
- {{ column.label }}
49
- </td>
50
- <td class="px-6 py-4 whitespace-nowrap whitespace-pre-wrap">
51
- <component
52
- :is="showComponentsPerColumn[column.name] || ValueRenderer"
53
- :column="column"
54
- :row="coreStore.record"
55
- />
56
- </td>
47
+ <component
48
+ :is="showComponentsPerColumn[column.name] || ShowTableItem"
49
+ :column="column"
50
+ :row="coreStore.record"
51
+ />
57
52
  </tr>
58
-
59
53
  </tbody>
60
54
  </table>
61
55
  </div>
@@ -68,6 +62,8 @@
68
62
  <script setup>
69
63
 
70
64
  import BreadcrumbsWithButtons from '@/components/BreadcrumbsWithButtons.vue';
65
+ import ShowTableItem from '@/components/ShowTableItem.vue';
66
+
71
67
  import ValueRenderer from '@/components/ValueRenderer.vue';
72
68
  import { useCoreStore } from '@/stores/core';
73
69
  import { getCustomComponent } from '@/utils';
@@ -1,4 +1,4 @@
1
-
1
+ import AdminForthPlugin from '../plugins/base.js';
2
2
 
3
3
  export type AdminForthConfigMenuItem = {
4
4
  type?: 'heading' | 'group' | 'resource' | 'page' | 'gap' | 'divider',
@@ -8,6 +8,7 @@ export type AdminForthConfigMenuItem = {
8
8
  component?: string,
9
9
  resourceId?: string,
10
10
  homepage?: boolean,
11
+ open?: boolean,
11
12
  children?: Array<AdminForthConfigMenuItem>,
12
13
  }
13
14
 
@@ -44,39 +45,39 @@ export type AdminForthResource = {
44
45
  dataSource: string,
45
46
  columns: Array<AdminForthResourceColumn>,
46
47
  itemLabel?: Function,
48
+ plugins?: Array<AdminForthPlugin>,
47
49
  hooks?: {
48
- show?: {
49
- beforeDatasourceRequest?: Function,
50
- afterDatasourceResponse?: Function,
51
- },
52
- list?: {
53
- beforeDatasourceRequest?: Function,
54
- afterDatasourceResponse?: Function,
55
- },
56
- create?: {
57
- beforeSave?: Function,
58
- afterSave?: Function,
59
- },
60
- edit?: {
61
- beforeSave?: Function,
62
- afterSave?: Function,
63
- },
64
- delete?: {
65
- beforeSave?: Function,
66
- afterSave?: Function,
67
- },
50
+ show?: {
51
+ beforeDatasourceRequest?: Function,
52
+ afterDatasourceResponse?: Function,
53
+ },
54
+ list?: {
55
+ beforeDatasourceRequest?: Function,
56
+ afterDatasourceResponse?: Function,
57
+ },
58
+ create?: {
59
+ beforeSave?: Function,
60
+ afterSave?: Function,
61
+ },
62
+ edit?: {
63
+ beforeSave?: Function,
64
+ afterSave?: Function,
65
+ },
66
+ delete?: {
67
+ beforeSave?: Function,
68
+ afterSave?: Function,
69
+ },
68
70
  },
69
71
  options?: {
70
- bulkActions?: Array<{
71
- label: string,
72
- state: string,
73
- icon: string,
74
- action: Function,
75
- id?: string,
76
- }>,
77
- allowedActions?: AllowedActions,
78
- allowDelete?: boolean,
79
-
72
+ bulkActions?: Array<{
73
+ label: string,
74
+ state: string,
75
+ icon: string,
76
+ action: Function,
77
+ id?: string,
78
+ }>,
79
+ allowedActions?: AllowedActions,
80
+ allowDelete?: boolean,
80
81
  },
81
82
  }
82
83