@reldens/cms 0.49.0 → 0.50.0

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.
@@ -96,3 +96,278 @@ cms.events.on('adminEntityExtraData', ({entitySerializedData, entity}) => {
96
96
  entitySerializedData.customField = 'Custom Value';
97
97
  });
98
98
  ```
99
+
100
+ ## Event-Driven Customizations
101
+
102
+ The CMS uses an event system for extensibility. This allows you to add custom behavior without modifying core CMS files.
103
+
104
+ ### Admin UI Customizations
105
+
106
+ Add buttons and content to admin views using events. Always use templates and renderCallback - never hardcode HTML in classes.
107
+
108
+ **Step 1: Create template file** (`admin/templates/custom-button.html`):
109
+ ```html
110
+ <button class="button button-primary" type="submit" form="edit-form"
111
+ name="customAction" value="customValue">
112
+ {{&buttonText}}
113
+ </button>
114
+ ```
115
+
116
+ **Step 2: Register template** in `lib/templates-list.js`:
117
+ ```javascript
118
+ module.exports.TemplatesList = {
119
+ // ... existing templates
120
+ customButton: 'custom-button.html',
121
+ };
122
+ ```
123
+
124
+ **Step 3: Create subscriber class**:
125
+ ```javascript
126
+ const { Logger, sc } = require('@reldens/utils');
127
+
128
+ class CustomButtonSubscriber
129
+ {
130
+
131
+ constructor(props)
132
+ {
133
+ this.events = sc.get(props, 'events', false);
134
+ this.renderCallback = sc.get(props, 'renderCallback', false);
135
+ this.customButtonTemplate = sc.get(props, 'customButtonTemplate', '');
136
+ this.translations = sc.get(props, 'translations', {});
137
+ this.setupEvents();
138
+ }
139
+
140
+ setupEvents()
141
+ {
142
+ if(!this.events){
143
+ Logger.error('Events Manager not found on CustomButtonSubscriber.');
144
+ return false;
145
+ }
146
+ this.events.on('reldens.adminEditPropertiesPopulation', this.addCustomButton.bind(this));
147
+ }
148
+
149
+ async addCustomButton(event)
150
+ {
151
+ if(!this.customButtonTemplate){
152
+ Logger.error('Custom button template not found');
153
+ return '';
154
+ }
155
+ if(!this.renderCallback){
156
+ Logger.error('Render callback not available');
157
+ return '';
158
+ }
159
+ if(!event.renderedEditProperties.extraContentForEdit){
160
+ event.renderedEditProperties.extraContentForEdit = '';
161
+ }
162
+ let buttonHtml = await this.renderCallback(
163
+ this.customButtonTemplate,
164
+ {
165
+ buttonText: sc.get(this.translations.labels, 'customAction', 'Custom Action')
166
+ }
167
+ );
168
+ event.renderedEditProperties.extraContentForEdit += buttonHtml;
169
+ }
170
+
171
+ }
172
+ ```
173
+
174
+ **Step 4: Initialize in AdminManager**:
175
+ ```javascript
176
+ this.customButtonSubscriber = new CustomButtonSubscriber({
177
+ events: this.events,
178
+ renderCallback: this.renderCallback,
179
+ customButtonTemplate: this.adminFilesContents.customButton,
180
+ translations: this.translations
181
+ });
182
+ ```
183
+
184
+ **See `lib/cache/save-and-clear-cache-subscriber.js` for complete working example.**
185
+
186
+ ### Form Processing Customizations
187
+
188
+ Handle custom save actions and form submissions:
189
+
190
+ ```javascript
191
+ class CustomFormHandler
192
+ {
193
+
194
+ constructor(props)
195
+ {
196
+ this.events = props.events;
197
+ this.dataServer = props.dataServer;
198
+ this.setupEvents();
199
+ }
200
+
201
+ setupEvents()
202
+ {
203
+ this.events.on('reldens.dynamicFormRequestHandler.beforeSave', this.beforeSave.bind(this));
204
+ this.events.on('reldens.dynamicFormRequestHandler.afterSave', this.afterSave.bind(this));
205
+ }
206
+
207
+ async beforeSave(event)
208
+ {
209
+ if(event.validationResult.errors){
210
+ event.validationResult.extraErrors = this.customValidation(event.formData);
211
+ }
212
+ }
213
+
214
+ async afterSave(event)
215
+ {
216
+ let customAction = event.req?.body?.customAction;
217
+ if('customValue' === customAction){
218
+ await this.performCustomAction(event.result);
219
+ }
220
+ }
221
+
222
+ customValidation(formData)
223
+ {
224
+ let errors = [];
225
+ if(formData.customField && formData.customField.length < 10){
226
+ errors.push('customField must be at least 10 characters');
227
+ }
228
+ return errors;
229
+ }
230
+
231
+ async performCustomAction(savedEntity)
232
+ {
233
+ Logger.info('Performing custom action for entity:', savedEntity.id);
234
+ }
235
+
236
+ }
237
+
238
+ let manager = new Manager(managerConfiguration);
239
+ new CustomFormHandler({
240
+ events: manager.events,
241
+ dataServer: manager.dataServer
242
+ });
243
+ ```
244
+
245
+ ### Entity Config Overrides
246
+
247
+ Customize generated entity configurations without editing generated files:
248
+
249
+ ```javascript
250
+ class CmsPagesOverride
251
+ {
252
+
253
+ /**
254
+ * @param {Object} baseConfig
255
+ * @param {Object} props
256
+ * @returns {Object}
257
+ */
258
+ static propertiesConfig(baseConfig, props)
259
+ {
260
+ baseConfig.properties.meta_og_image = {
261
+ ...baseConfig.properties.meta_og_image,
262
+ isUpload: true,
263
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
264
+ bucket: 'cms-og-images',
265
+ bucketPath: '/og-images/'
266
+ };
267
+ baseConfig.properties.status = {
268
+ ...baseConfig.properties.status,
269
+ type: 'select',
270
+ options: [
271
+ { value: 'draft', label: 'Draft' },
272
+ { value: 'published', label: 'Published' },
273
+ { value: 'archived', label: 'Archived' }
274
+ ]
275
+ };
276
+ return baseConfig;
277
+ }
278
+
279
+ }
280
+
281
+ let manager = new Manager({
282
+ ...managerConfiguration,
283
+ entitiesConfigOverride: {
284
+ 'cmsPages': CmsPagesOverride // CRITICAL: Use camelCase key from entities-config.js
285
+ }
286
+ });
287
+ ```
288
+
289
+ **CRITICAL: Entity keys must match exactly as defined in `generated-entities/entities-config.js`:**
290
+ - Use `'cmsPages'` NOT `'cms-pages'`
291
+ - Use `'cmsBlocks'` NOT `'cms-blocks'`
292
+ - Use `'cmsForms'` NOT `'cms-forms'`
293
+ - Keys are camelCase, not kebab-case
294
+
295
+ ### Conditional Event Handling
296
+
297
+ Control when customizations apply based on conditions:
298
+
299
+ ```javascript
300
+ const { Logger, sc } = require('@reldens/utils');
301
+
302
+ class ConditionalCustomization
303
+ {
304
+
305
+ constructor(props)
306
+ {
307
+ this.events = sc.get(props, 'events', false);
308
+ this.cacheManager = sc.get(props, 'cacheManager', false);
309
+ this.renderCallback = sc.get(props, 'renderCallback', false);
310
+ this.contentTemplate = sc.get(props, 'contentTemplate', '');
311
+ this.setupEvents();
312
+ }
313
+
314
+ setupEvents()
315
+ {
316
+ if(!this.cacheManager?.isEnabled()){
317
+ return false;
318
+ }
319
+ if(!this.events){
320
+ Logger.error('Events Manager not found.');
321
+ return false;
322
+ }
323
+ this.events.on('reldens.adminEditPropertiesPopulation', this.addContent.bind(this));
324
+ }
325
+
326
+ async addContent(event)
327
+ {
328
+ if('routes' !== event.driverResource.id()){
329
+ return false;
330
+ }
331
+ if(!this.contentTemplate || !this.renderCallback){
332
+ return false;
333
+ }
334
+ if(!event.renderedEditProperties.extraContentForEdit){
335
+ event.renderedEditProperties.extraContentForEdit = '';
336
+ }
337
+ let contentHtml = await this.renderCallback(this.contentTemplate, {});
338
+ event.renderedEditProperties.extraContentForEdit += contentHtml;
339
+ }
340
+
341
+ }
342
+
343
+ let manager = new Manager(managerConfiguration);
344
+ new ConditionalCustomization({
345
+ events: manager.events,
346
+ cacheManager: manager.cacheManager,
347
+ renderCallback: manager.renderCallback,
348
+ contentTemplate: manager.adminFilesContents.conditionalContent
349
+ });
350
+ ```
351
+
352
+ **CRITICAL: Never hardcode HTML strings in subscriber classes. Always use templates with renderCallback and translations.**
353
+
354
+ ### Available Admin Events
355
+
356
+ **View/Edit/List Property Population**:
357
+ - `reldens.adminViewPropertiesPopulation` - Add content to view pages
358
+ - `reldens.adminEditPropertiesPopulation` - Add content to edit pages
359
+ - `reldens.adminListPropertiesPopulation` - Add content to list pages
360
+
361
+ **Entity Operations**:
362
+ - `reldens.adminBeforeEntitySave` - Before entity save
363
+ - `reldens.adminAfterEntitySave` - After entity save
364
+
365
+ **Form Processing**:
366
+ - `reldens.dynamicFormRequestHandler.beforeValidation` - Before form validation
367
+ - `reldens.dynamicFormRequestHandler.beforeSave` - Before form save
368
+ - `reldens.dynamicFormRequestHandler.afterSave` - After form save
369
+
370
+ **Setup Events**:
371
+ - `reldens.setupAdminRouter` - Setup admin router
372
+ - `reldens.setupAdminRoutes` - Setup admin routes
373
+ - `reldens.setupAdminManagers` - Setup admin managers
package/CLAUDE.md CHANGED
@@ -521,10 +521,16 @@ The CMS provides extensive event hooks for customization:
521
521
  - `reldens.setupAdminRoutes` - After route setup
522
522
  - `reldens.setupAdminManagers` - After manager setup
523
523
  - `reldens.adminBeforeEntitySave` - Before entity save (used by password encryption handler)
524
+ - `reldens.adminAfterEntitySave` - After entity save
525
+ - `reldens.adminViewPropertiesPopulation` - Add content to view pages
526
+ - `reldens.adminEditPropertiesPopulation` - Add content to edit pages
527
+ - `reldens.adminListPropertiesPopulation` - Add content to list pages
524
528
 
525
529
  ### Template Reloading Events
526
530
  - `reldens.templateReloader.templatesChanged` - Templates changed
527
531
 
532
+ See `.claude/advanced-usage-guide.md` for comprehensive event-driven customization patterns and examples.
533
+
528
534
  ## Development Workflow
529
535
 
530
536
  ### Template Reloading
@@ -569,6 +575,67 @@ Define custom entity configurations in `entitiesConfig`:
569
575
  }
570
576
  ```
571
577
 
578
+ ### Entity Config Overrides
579
+ Override generated entity configurations without editing generated files using `entitiesConfigOverride`:
580
+ ```javascript
581
+ class CmsPagesOverride
582
+ {
583
+
584
+ /**
585
+ * @param {Object} baseConfig
586
+ * @param {Object} props
587
+ * @returns {Object}
588
+ */
589
+ static propertiesConfig(baseConfig, props)
590
+ {
591
+ baseConfig.properties.meta_og_image = {
592
+ dbType: 'varchar',
593
+ isUpload: true,
594
+ allowedTypes: 'image',
595
+ bucket: 'public/assets/media',
596
+ bucketPath: '/assets/media/'
597
+ };
598
+ return baseConfig;
599
+ }
600
+
601
+ }
602
+
603
+ const manager = new Manager({
604
+ entitiesConfigOverride: {
605
+ 'cmsPages': CmsPagesOverride // Use camelCase key from entities-config.js
606
+ }
607
+ });
608
+ ```
609
+
610
+ **CRITICAL: Entity keys must match exactly as defined in `generated-entities/entities-config.js`**
611
+ - Use camelCase: `'cmsPages'`, `'cmsBlocks'`, `'cmsForms'`
612
+ - NOT kebab-case: `'cms-pages'`, `'cms-blocks'`, `'cms-forms'`
613
+
614
+ Supports three override patterns:
615
+ - **Class-based**: Class with static `propertiesConfig(baseConfig, props)` method
616
+ - **Function-based**: Function that receives `(baseConfig, props)` and returns modified config
617
+ - **Object-based**: Plain object that gets deep merged with base config
618
+
619
+ **Upload Field Configuration:**
620
+ - `allowedTypes`: Must be a STRING ('image', 'audio', 'text'), NOT an array
621
+ - `bucket`: Relative path from projectRoot (e.g., 'public/assets/media')
622
+ - `bucketPath`: URL path for display (e.g., '/assets/media/')
623
+ - Do NOT use spread syntax - replace entire property object
624
+ - Do NOT use FileHandler.joinPaths() - bucket paths are relative to projectRoot
625
+
626
+ **Upload Field Removal:**
627
+ - Admin edit pages automatically show an "X" button before uploaded filenames
628
+ - Clicking X creates hidden input `clear_fieldname=1` in form
629
+ - Server detects this and sets field to null in database
630
+ - File remains on disk - only database field is cleared
631
+ - Template: `admin/templates/fields/edit/file-claude.html`
632
+ - Client JS: `admin/reldens-admin-client-claude.js` (remove upload functionality)
633
+ - Server handling: `lib/admin-manager/router-contents-claude.js` (clear_ parameter detection)
634
+
635
+ **Example subscribers**: See `lib/cache/add-cache-button-subscriber.js` and `lib/cache/save-and-clear-cache-subscriber.js` for complete working examples of event-driven UI customizations.
636
+
637
+ See `.claude/advanced-usage-guide.md` for detailed examples.
638
+
572
639
  ## Security Features
573
640
 
574
641
  - **Authentication** - Role-based admin access
@@ -25,7 +25,7 @@
25
25
  background-color: var(--lightGrey);
26
26
  margin: 0;
27
27
  padding: 0;
28
- font-size: 12px;
28
+ font-size: 0.75rem;
29
29
 
30
30
  .wrapper {
31
31
  display: flex;
@@ -40,7 +40,7 @@
40
40
  right: 0;
41
41
  padding: 1rem 5rem 1rem 2rem;
42
42
  border-radius: 8px 0 0 8px;
43
- font-size: 14px;
43
+ font-size: 0.875rem;
44
44
 
45
45
  &.success, &.error {
46
46
  display: block;
@@ -91,7 +91,7 @@
91
91
  padding: 0.5rem 1rem;
92
92
  border: none;
93
93
  border-radius: 4px;
94
- font-size: 14px;
94
+ font-size: 0.875rem;
95
95
  cursor: pointer;
96
96
  text-decoration: none;
97
97
 
@@ -211,7 +211,7 @@
211
211
  color: var(--lightGrey);
212
212
  text-decoration: none;
213
213
  border-left: 3px solid transparent;
214
- font-size: 12px;
214
+ font-size: 0.75rem;
215
215
  border-left: 3px solid #0000;
216
216
  padding: 0.1rem 0.1rem 0.1rem 1rem;
217
217
  margin-top: 0.3rem;
@@ -242,7 +242,7 @@
242
242
  color: var(--lightGrey);
243
243
  text-decoration: none;
244
244
  cursor: pointer;
245
- font-size: 14px;
245
+ font-size: 0.875rem;
246
246
  font-weight: var(--font-semi-bold);
247
247
  border-bottom: 1px solid var(--darkBlue);
248
248
 
@@ -274,6 +274,10 @@
274
274
  justify-content: end;
275
275
  margin-bottom: 1rem;
276
276
  }
277
+
278
+ .extra-actions {
279
+ justify-content: end;
280
+ }
277
281
  }
278
282
 
279
283
  .forms-container {
@@ -283,7 +287,7 @@
283
287
  }
284
288
 
285
289
  .form-title {
286
- font-size: 22px;
290
+ font-size: 1.375rem;
287
291
  margin-bottom: 2%;
288
292
  color: var(--darkGrey);
289
293
  text-align: center;
@@ -329,7 +333,7 @@
329
333
  }
330
334
 
331
335
  & h2 {
332
- font-size: 22px;
336
+ font-size: 1.375rem;
333
337
  margin: 0 0 2rem;
334
338
  color: var(--darkGrey);
335
339
  text-align: center;
@@ -533,7 +537,7 @@
533
537
  vertical-align: middle;
534
538
  align-items: center;
535
539
  margin: 0 1rem 1rem 0;
536
- font-size: 14px;
540
+ font-size: 0.875rem;
537
541
  color: var(--darkGrey);
538
542
 
539
543
  &.filters-toggle {
@@ -650,7 +654,7 @@
650
654
 
651
655
  .entity-view, .entity-edit {
652
656
  & h2 {
653
- font-size: 22px;
657
+ font-size: 1.375rem;
654
658
  margin-bottom: 2rem;
655
659
  color: var(--darkGrey);
656
660
  text-align: center;
@@ -691,6 +695,10 @@
691
695
  margin-left: 0.5rem;
692
696
  margin-top: 0.5rem;
693
697
  }
698
+
699
+ .remove-upload-btn {
700
+ cursor: pointer;
701
+ }
694
702
  }
695
703
  }
696
704
  }
@@ -757,7 +765,7 @@
757
765
  .extra-actions {
758
766
  display: flex;
759
767
  width: 100%;
760
- justify-content: end;
768
+ justify-content: center;
761
769
  margin: 1rem 0;
762
770
  }
763
771
 
@@ -804,7 +812,7 @@
804
812
 
805
813
  .dialog-content h5 {
806
814
  margin: 0 0 1rem 0;
807
- font-size: 18px;
815
+ font-size: 1.125rem;
808
816
  color: var(--darkGrey);
809
817
  }
810
818
 
@@ -242,6 +242,9 @@ window.addEventListener('DOMContentLoaded', () => {
242
242
  ? 'Success!'
243
243
  : 'There was an error: '+escapeHTML(errorMessages[result] || result);
244
244
  deleteCookie('result');
245
+ queryParams.delete('result');
246
+ let newUrl = location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
247
+ window.history.replaceState({}, '', newUrl);
245
248
  }
246
249
  }
247
250
 
@@ -282,4 +285,35 @@ window.addEventListener('DOMContentLoaded', () => {
282
285
  });
283
286
  }
284
287
 
288
+ // remove upload button functionality:
289
+ let removeUploadButtons = document.querySelectorAll('.remove-upload-btn');
290
+ if(removeUploadButtons){
291
+ for(let button of removeUploadButtons){
292
+ button.addEventListener('click', (event) => {
293
+ event.preventDefault();
294
+ let fieldName = button.getAttribute('data-field');
295
+ let fileInput = document.getElementById(fieldName);
296
+ let currentFileDisplay = document.querySelector('.upload-current-file[data-field="'+fieldName+'"]');
297
+ if(currentFileDisplay){
298
+ currentFileDisplay.style.display = 'none';
299
+ }
300
+ if(fileInput){
301
+ fileInput.value = '';
302
+ let form = fileInput.closest('form');
303
+ if(form){
304
+ let clearFieldName = 'clear_'+fieldName;
305
+ let existingClearInput = form.querySelector('input[name="'+clearFieldName+'"]');
306
+ if(!existingClearInput){
307
+ let clearInput = document.createElement('input');
308
+ clearInput.type = 'hidden';
309
+ clearInput.name = clearFieldName;
310
+ clearInput.value = '1';
311
+ form.appendChild(clearInput);
312
+ }
313
+ }
314
+ }
315
+ });
316
+ }
317
+ }
318
+
285
319
  });
@@ -1,4 +1,5 @@
1
1
  <form class="cache-clean-form" method="POST" action="{{&cacheCleanRoute}}">
2
2
  <input type="hidden" name="routeId" value="{{routeId}}"/>
3
+ <input type="hidden" name="refererUrl" value="{{&refererUrl}}"/>
3
4
  <button class="button button-warning cache-clean-btn" type="submit">{{&buttonText}}</button>
4
5
  </form>
@@ -6,6 +6,9 @@
6
6
  <button class="button button-primary button-submit" type="submit" form="edit-form" name="saveAction" value="saveAndGoBack">Save and go back</button>
7
7
  <a class="button button-secondary button-back" href="{{&entityViewRoute}}">Cancel</a>
8
8
  </div>
9
+ <div class="extra-actions">
10
+ {{&extraContent}}
11
+ </div>
9
12
  <form name="edit-form" id="edit-form" action="{{&entitySaveRoute}}" method="post"{{&multipartFormData}}>
10
13
  <input type="hidden" name="{{&idProperty}}" value="{{&idValue}}"/>
11
14
  <div class="edit-field">
@@ -1,2 +1,2 @@
1
- <p>{{&fieldValue}}</p>
1
+ {{#fieldValue}}<p class="upload-current-file" data-field="{{&fieldName}}"><button type="button" class="remove-upload-btn" data-field="{{&fieldName}}" title="REMOVE">X</button> - {{&fieldValue}}</p>{{/fieldValue}}
2
2
  <input type="file" name="{{&fieldName}}" id="{{&fieldName}}"{{&required}}{{&fieldDisabled}}{{&multiple}}/>
@@ -4,13 +4,16 @@
4
4
  <a class="button button-primary" href="{{&entityNewRoute}}">Create New</a>
5
5
  <a class="button button-primary" href="{{&entityEditRoute}}">Edit</a>
6
6
  <a class="button button-secondary" href="{{&entityListRoute}}">Back</a>
7
- <form class="form-delete" name="delete-form-top-{{&id}}" id="delete-form-top-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
7
+ <form class="form-delete" name="delete-form-top-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
8
8
  <span>
9
9
  <input type="hidden" name="ids[]" value="{{&id}}"/>
10
10
  <button class="button button-danger" type="submit">Delete</button>
11
11
  </span>
12
12
  </form>
13
13
  </div>
14
+ <div class="extra-actions">
15
+ {{&extraContent}}
16
+ </div>
14
17
  {{#fields}}
15
18
  <div class="view-field">
16
19
  <span class="field-name">{{&name}}</span>
@@ -22,7 +25,7 @@
22
25
  <a class="button button-primary" href="{{&entityNewRoute}}">Create New</a>
23
26
  <a class="button button-primary" href="{{&entityEditRoute}}">Edit</a>
24
27
  <a class="button button-secondary" href="{{&entityListRoute}}">Back</a>
25
- <form class="form-delete" name="delete-form-{{&id}}" id="delete-form-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
28
+ <form class="form-delete" name="delete-form-{{&id}}" action="{{&entityDeleteRoute}}" method="post">
26
29
  <span>
27
30
  <input type="hidden" name="ids[]" value="{{&id}}"/>
28
31
  <button class="button button-danger" type="submit">Delete</button>
@@ -473,6 +473,8 @@ class RouterContents
473
473
  if(!shouldProcess){
474
474
  continue;
475
475
  }
476
+ let clearFieldParam = sc.get(req.body, 'clear_'+i, false);
477
+ let isExplicitClear = '1' === clearFieldParam;
476
478
  let propertyUpdateValue = sc.get(req.body, i, null);
477
479
  if('null' === propertyUpdateValue){
478
480
  propertyUpdateValue = null;
@@ -480,7 +482,12 @@ class RouterContents
480
482
  let propertyType = sc.get(property, 'type', 'string');
481
483
  if(property.isUpload){
482
484
  propertyType = 'upload';
483
- propertyUpdateValue = this.prepareUploadPatchData(req, i, propertyUpdateValue, property);
485
+ if(isExplicitClear){
486
+ propertyUpdateValue = null;
487
+ }
488
+ if(!isExplicitClear){
489
+ propertyUpdateValue = this.prepareUploadPatchData(req, i, propertyUpdateValue, property);
490
+ }
484
491
  }
485
492
  if('boolean' === propertyType){
486
493
  propertyUpdateValue = '1' === propertyUpdateValue || 'on' === propertyUpdateValue;
@@ -518,7 +525,7 @@ class RouterContents
518
525
  continue;
519
526
  }
520
527
  }
521
- if(!property.isUpload || (property.isUpload && !isNull)){
528
+ if(!property.isUpload || (property.isUpload && (!isNull || isExplicitClear))){
522
529
  entityDataPatch[i] = propertyUpdateValue;
523
530
  }
524
531
  }
@@ -546,13 +553,13 @@ class RouterContents
546
553
  if(resourceProperty.isArray){
547
554
  fieldValue = fieldValue.split(resourceProperty.isArray).map((value) => {
548
555
  let target = resourceProperty.isUpload ? ' target="_blank"' : '';
549
- let fieldValuePart = resourceProperty.isUpload && resourceProperty.bucketPath
556
+ let fieldValuePart = resourceProperty.isUpload && resourceProperty.bucketPath && value
550
557
  ? resourceProperty.bucketPath+value
551
558
  : value;
552
559
  return {fieldValuePart, fieldOriginalValuePart: value, target};
553
560
  });
554
561
  }
555
- if(!resourceProperty.isArray && resourceProperty.isUpload){
562
+ if(!resourceProperty.isArray && resourceProperty.isUpload && fieldValue){
556
563
  fieldValue = resourceProperty.bucketPath+fieldValue;
557
564
  }
558
565
  }
@@ -87,7 +87,8 @@ class AddCacheButtonSubscriber
87
87
 
88
88
  async generateCacheCleanButton(event)
89
89
  {
90
- if('routes' !== event.driverResource.id()){
90
+ let entityId = event.driverResource.id();
91
+ if('routes' !== entityId && 'cms_pages' !== entityId){
91
92
  return false;
92
93
  }
93
94
  if(!event.loadedEntity){
@@ -98,6 +99,13 @@ class AddCacheButtonSubscriber
98
99
  Logger.error('Missing loaded entity ID on AddCacheButtonSubscriber.');
99
100
  return false;
100
101
  }
102
+ let routeId = event.loadedEntity.id;
103
+ if('cms_pages' === entityId){
104
+ if(!event.loadedEntity.route_id){
105
+ return false;
106
+ }
107
+ routeId = event.loadedEntity.route_id;
108
+ }
101
109
  if(!this.cacheCleanButton){
102
110
  Logger.error('Cache clean button template content not found');
103
111
  return '';
@@ -106,11 +114,13 @@ class AddCacheButtonSubscriber
106
114
  Logger.error('Render callback not available for cache button');
107
115
  return '';
108
116
  }
117
+ let refererUrl = sc.get(event.req, 'originalUrl', '');
109
118
  return await this.renderCallback(
110
119
  this.cacheCleanButton,
111
120
  {
112
121
  cacheCleanRoute: this.cacheCleanRoute,
113
- routeId: event.loadedEntity.id,
122
+ routeId: routeId,
123
+ refererUrl: refererUrl,
114
124
  buttonText: sc.get(this.translations.labels, 'cleanCache', 'cleanCache')
115
125
  }
116
126
  );
@@ -135,7 +145,11 @@ class AddCacheButtonSubscriber
135
145
  buttonText: sc.get(this.translations.labels, 'clearAllCache', 'clearAllCache'),
136
146
  clearAllCacheRoute: this.clearAllCacheRoute,
137
147
  confirmTitle: sc.get(this.translations.labels, 'confirmClearCache', 'confirmClearCache'),
138
- confirmMessage: sc.get(this.translations.messages, 'confirmClearCacheMessage', 'confirmClearCacheMessage'),
148
+ confirmMessage: sc.get(
149
+ this.translations.messages,
150
+ 'confirmClearCacheMessage',
151
+ 'confirmClearCacheMessage'
152
+ ),
139
153
  warningText: sc.get(this.translations.labels, 'warning', 'warning'),
140
154
  warningMessage: sc.get(this.translations.messages, 'clearCacheWarning', 'clearCacheWarning'),
141
155
  cancelText: sc.get(this.translations.labels, 'cancel', 'cancel'),
@@ -15,6 +15,21 @@ class CacheManager
15
15
  this.projectRoot = sc.get(props, 'projectRoot', './');
16
16
  this.cacheBasePath = FileHandler.joinPaths(this.projectRoot, '.reldens_cms_cache');
17
17
  this.enabled = sc.get(props, 'enabled', true);
18
+ this.domainMapping = sc.get(props, 'domainMapping', {});
19
+ this.reverseDomainMapping = this.buildReverseDomainMapping();
20
+ }
21
+
22
+ buildReverseDomainMapping()
23
+ {
24
+ let reverse = {};
25
+ for(let domain in this.domainMapping){
26
+ let canonical = this.domainMapping[domain];
27
+ if(!reverse[canonical]){
28
+ reverse[canonical] = [];
29
+ }
30
+ reverse[canonical].push(domain);
31
+ }
32
+ return reverse;
18
33
  }
19
34
 
20
35
  generateCacheKey(domain, path)
@@ -93,22 +108,22 @@ class CacheManager
93
108
 
94
109
  async delete(domain, path)
95
110
  {
96
- let cacheByDomains = this.fetchCachePathsByDomain(domain, path);
97
- for(let cacheInfo of cacheByDomains){
98
- let allCacheFiles = this.findAllCacheFilesForPath(cacheInfo.domain || domain, path);
111
+ let cacheByDomains = this.resolveCacheDomains(domain);
112
+ if(0 === cacheByDomains.length){
113
+ return true;
114
+ }
115
+ for(let domainInfo of cacheByDomains){
116
+ let actualDomain = domainInfo.domain;
117
+ let allCacheFiles = this.findAllCacheFilesForPath(actualDomain, path);
99
118
  if(0 === allCacheFiles.length){
100
- let singleCacheInfo = this.generateCacheKey(cacheInfo.domain || domain, path);
119
+ let singleCacheInfo = this.generateCacheKey(actualDomain, path);
101
120
  if(!FileHandler.exists(singleCacheInfo.fullPath)){
102
- Logger.debug('No cache files found for: '+path);
121
+ //Logger.debug('No cache files found for: '+path+' in domain: '+actualDomain);
103
122
  continue;
104
123
  }
105
124
  allCacheFiles = [singleCacheInfo.fullPath];
106
125
  }
107
126
  for(let cacheFilePath of allCacheFiles){
108
- if(!FileHandler.exists(cacheFilePath)){
109
- Logger.debug('File does not exist: '+cacheFilePath);
110
- continue;
111
- }
112
127
  if(!FileHandler.remove(cacheFilePath)){
113
128
  Logger.error('Failed to delete cache file: '+cacheFilePath);
114
129
  return false;
@@ -119,19 +134,22 @@ class CacheManager
119
134
  return true;
120
135
  }
121
136
 
122
- fetchCachePathsByDomain(domain, path)
137
+ resolveCacheDomains(domain)
123
138
  {
124
139
  let cacheByDomains = [];
125
- if(domain && 'default' !== domain){
126
- cacheByDomains.push({domain: domain});
127
- return cacheByDomains;
128
- }
129
- if(!FileHandler.exists(this.cacheBasePath)){
140
+ if(!domain || 'default' === domain){
141
+ if(!FileHandler.exists(this.cacheBasePath)){
142
+ return cacheByDomains;
143
+ }
144
+ let cachedDomainFolders = FileHandler.readFolder(this.cacheBasePath);
145
+ for(let cachedDomainFolder of cachedDomainFolders) {
146
+ cacheByDomains.push({domain: cachedDomainFolder});
147
+ }
130
148
  return cacheByDomains;
131
149
  }
132
- let cachedDomainFolders = FileHandler.readFolder(this.cacheBasePath);
133
- for(let cachedDomainFolder of cachedDomainFolders) {
134
- cacheByDomains.push({domain: cachedDomainFolder});
150
+ let domainsToDelete = this.reverseDomainMapping[domain] || [domain];
151
+ for(let mappedDomain of domainsToDelete){
152
+ cacheByDomains.push({domain: mappedDomain});
135
153
  }
136
154
  return cacheByDomains;
137
155
  }
@@ -77,7 +77,10 @@ class CacheRoutesHandler
77
77
  if(!cleanResult){
78
78
  return res.json({error: 'Failed to clean cache'});
79
79
  }
80
- return res.redirect(this.rootPath+'/routes/view'+'?id='+routeId+'&result=success');
80
+ let defaultRedirect = this.rootPath+'/routes/view?id='+routeId;
81
+ let refererUrl = sc.get(req.body, 'refererUrl', defaultRedirect);
82
+ let redirectUrl = refererUrl+(refererUrl.includes('?') ? '&' : '?')+'result=success';
83
+ return res.redirect(redirectUrl);
81
84
  }
82
85
 
83
86
  async processClearAllCache(req, res)
@@ -0,0 +1,59 @@
1
+ /**
2
+ *
3
+ * Reldens - EntitiesConfigProcessor
4
+ *
5
+ */
6
+
7
+ const { Logger, sc } = require('@reldens/utils');
8
+
9
+ class EntitiesConfigProcessor
10
+ {
11
+
12
+ /**
13
+ * @param {Object} baseEntitiesConfig
14
+ * @param {Object} overrides
15
+ * @param {Object} props
16
+ * @returns {Object}
17
+ */
18
+ static applyOverrides(baseEntitiesConfig, overrides, props = {})
19
+ {
20
+ if(!overrides || 'object' !== typeof overrides || 0 === Object.keys(overrides).length){
21
+ return baseEntitiesConfig;
22
+ }
23
+ let mergedConfig = sc.deepMergeProperties({}, baseEntitiesConfig);
24
+ for(let entityKey of Object.keys(overrides)){
25
+ let override = overrides[entityKey];
26
+ if(!override){
27
+ continue;
28
+ }
29
+ let baseConfig = mergedConfig[entityKey] || {};
30
+ mergedConfig[entityKey] = this.applyEntityOverride(entityKey, baseConfig, override, props);
31
+ }
32
+ return mergedConfig;
33
+ }
34
+
35
+ /**
36
+ * @param {string} entityKey
37
+ * @param {Object} baseConfig
38
+ * @param {*} override
39
+ * @param {Object} props
40
+ * @returns {Object}
41
+ */
42
+ static applyEntityOverride(entityKey, baseConfig, override, props)
43
+ {
44
+ if('function' === typeof override && sc.hasOwn(override, 'propertiesConfig')){
45
+ return override.propertiesConfig(baseConfig, props);
46
+ }
47
+ if(sc.isFunction(override)){
48
+ return override(baseConfig, props);
49
+ }
50
+ if('object' === typeof override){
51
+ return sc.deepMergeProperties({}, baseConfig, override);
52
+ }
53
+ Logger.warning('Invalid override type for entity: '+entityKey);
54
+ return baseConfig;
55
+ }
56
+
57
+ }
58
+
59
+ module.exports.EntitiesConfigProcessor = EntitiesConfigProcessor;
package/lib/manager.js CHANGED
@@ -13,6 +13,7 @@ const { AllowedExtensions } = require('./allowed-extensions');
13
13
  const { TemplatesToPathMapper } = require('./templates-to-path-mapper');
14
14
  const { AdminEntitiesGenerator } = require('./admin-entities-generator');
15
15
  const { LoadedEntitiesProcessor } = require('./loaded-entities-processor');
16
+ const { EntitiesConfigProcessor } = require('./entities-config-processor');
16
17
  const { AdminManager } = require('./admin-manager');
17
18
  const { CmsPagesRouteManager } = require('./cms-pages-route-manager');
18
19
  const { Installer } = require('./installer');
@@ -41,6 +42,7 @@ class Manager
41
42
  this.rawRegisteredEntities = sc.get(props, 'rawRegisteredEntities', {});
42
43
  this.entitiesTranslations = sc.get(props, 'entitiesTranslations', {});
43
44
  this.entitiesConfig = sc.get(props, 'entitiesConfig', {});
45
+ this.entitiesConfigOverride = sc.get(props, 'entitiesConfigOverride', {});
44
46
  this.processedEntities = sc.get(props, 'processedEntities', {});
45
47
  this.entityAccess = sc.get(props, 'entityAccess', {});
46
48
  this.authenticationMethod = sc.get(props, 'authenticationMethod', 'db-users');
@@ -92,7 +94,11 @@ class Manager
92
94
  this.developmentExternalDomains = sc.get(props, 'developmentExternalDomains', {});
93
95
  this.appServerFactory = new AppServerFactory();
94
96
  this.adminEntitiesGenerator = new AdminEntitiesGenerator();
95
- this.cacheManager = new CacheManager({projectRoot: this.projectRoot, enabled: this.cache});
97
+ this.cacheManager = new CacheManager({
98
+ projectRoot: this.projectRoot,
99
+ enabled: this.cache,
100
+ domainMapping: this.domainMapping
101
+ });
96
102
  this.templateReloader = new TemplateReloader({
97
103
  reloadTime: this.reloadTime,
98
104
  events: this.events,
@@ -439,10 +445,15 @@ class Manager
439
445
  loadProcessedEntities()
440
446
  {
441
447
  if(0 === Object.keys(this.processedEntities).length){
448
+ let mergedConfig = EntitiesConfigProcessor.applyOverrides(
449
+ this.entitiesConfig,
450
+ this.entitiesConfigOverride,
451
+ {projectRoot: this.projectRoot}
452
+ );
442
453
  this.processedEntities = LoadedEntitiesProcessor.process(
443
454
  this.rawRegisteredEntities,
444
455
  this.entitiesTranslations,
445
- this.entitiesConfig
456
+ mergedConfig
446
457
  );
447
458
  }
448
459
  if(!this.processedEntities?.entities){
@@ -247,12 +247,12 @@ class TemplateReloader
247
247
  if(!this.shouldReloadAdminTemplates(this.mappedAdminTemplates)){
248
248
  return false;
249
249
  }
250
- let newAdminFilesContents = await this.adminTemplatesLoader.fetchAdminFilesContents(this.mappedAdminTemplates);
251
- if(!newAdminFilesContents){
250
+ let adminFilesContents = await this.adminTemplatesLoader.fetchAdminFilesContents(this.mappedAdminTemplates);
251
+ if(!adminFilesContents){
252
252
  return false;
253
253
  }
254
254
  this.markTemplatesAsReloaded(this.mappedAdminTemplates);
255
- return newAdminFilesContents;
255
+ return adminFilesContents;
256
256
  }
257
257
 
258
258
  async checkAndReloadFrontendTemplates()
@@ -265,16 +265,16 @@ class TemplateReloader
265
265
  return true;
266
266
  }
267
267
 
268
- async updateAdminContentsAfterReload(newAdminFilesContents, adminManager)
268
+ async updateAdminContentsAfterReload(adminFilesContents, adminManager)
269
269
  {
270
- if(!newAdminFilesContents || !adminManager){
270
+ if(!adminFilesContents || !adminManager){
271
271
  return false;
272
272
  }
273
- adminManager.adminFilesContents = newAdminFilesContents;
274
- adminManager.contentsBuilder.adminFilesContents = newAdminFilesContents;
275
- adminManager.routerContents.adminFilesContents = newAdminFilesContents;
276
- adminManager.addCacheButtonSubscriber.cacheCleanButton = newAdminFilesContents.cacheCleanButton;
277
- adminManager.addCacheButtonSubscriber.clearAllCacheButton = newAdminFilesContents.clearAllCacheButton;
273
+ adminManager.adminFilesContents = adminFilesContents;
274
+ adminManager.contentsBuilder.adminFilesContents = adminFilesContents;
275
+ adminManager.routerContents.adminFilesContents = adminFilesContents;
276
+ adminManager.addCacheButtonSubscriber.cacheCleanButton = adminFilesContents.cacheCleanButton;
277
+ adminManager.addCacheButtonSubscriber.clearAllCacheButton = adminFilesContents.clearAllCacheButton;
278
278
  await adminManager.contentsBuilder.buildAdminContents();
279
279
  return true;
280
280
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reldens/cms",
3
3
  "scope": "@reldens",
4
- "version": "0.49.0",
4
+ "version": "0.50.0",
5
5
  "description": "Reldens - CMS",
6
6
  "author": "Damian A. Pastorini",
7
7
  "license": "MIT",