kempo-inventory 0.4.1 → 0.4.2

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.
@@ -4,6 +4,7 @@ locked: true
4
4
  <page title="Inventory - Admin">
5
5
  <content>
6
6
  <script type="module" src="/kempo-ui/components/Table.js"></script>
7
+ <script type="module" src="/kempo-ui/components/Tabs.js"></script>
7
8
  <script type="module" src="/kempo-ui/components/controls/TcSearch.js"></script>
8
9
  <script type="module" src="/kempo-ui/components/controls/TcFilters.js"></script>
9
10
  <script type="module" src="/kempo-ui/components/controls/TcFieldSortHide.js"></script>
@@ -20,9 +21,17 @@ locked: true
20
21
  <script type="module" src="/kempo-ui/components/controls/TcOpen.js"></script>
21
22
  <script type="module" src="/kempo/components/Permission.js"></script>
22
23
  <script type="module" src="/inventory/components/RowAction.js"></script>
24
+ <script type="module" src="/inventory/components/FieldsManager.js"></script>
25
+ <script type="module" src="/inventory/components/ToolbarControl.js"></script>
23
26
 
24
27
  <h1 class="mb">Inventory</h1>
25
28
 
29
+ <k-tabs active="items" hash-routing>
30
+ <k-tab id="itemsTab" for="items">Items</k-tab>
31
+ <k-tab id="categoriesTab" for="categories">Categories</k-tab>
32
+ <k-tab id="fieldsTab" for="fields">Fields</k-tab>
33
+
34
+ <k-tab-content id="itemsContent" name="items">
26
35
  <!--
27
36
  Everything that acts on the table lives in the table: search, filters and the buttons above it,
28
37
  paging below it, and the row actions beside each record.
@@ -42,14 +51,10 @@ locked: true
42
51
  <kc-tc-field-sort-hide></kc-tc-field-sort-hide>
43
52
  <div class="flex"></div>
44
53
  <k-permission has="kempo-inventory:fields:manage">
45
- <button id="importBtn" class="btn m0"><k-icon name="download" rotation="180"></k-icon> Import</button>
46
- </k-permission>
47
- <k-permission has="kempo-inventory:fields:manage">
48
- <button id="exportBtn" class="btn m0"><k-icon name="download"></k-icon> Export</button>
54
+ <k-inventory-toolbar-control action="import"><k-icon name="download" rotation="180"></k-icon> Import</k-inventory-toolbar-control>
49
55
  </k-permission>
50
- <a href="/admin/extension/kempo-inventory/categories" class="btn"><k-icon name="folder"></k-icon> Categories</a>
51
56
  <k-permission has="kempo-inventory:fields:manage">
52
- <a href="/admin/extension/kempo-inventory/fields" class="btn"><k-icon name="fields"></k-icon> Fields</a>
57
+ <k-inventory-toolbar-control action="export"><k-icon name="download"></k-icon> Export</k-inventory-toolbar-control>
53
58
  </k-permission>
54
59
  <k-permission has="kempo-inventory:items:create">
55
60
  <button id="newItemBtn" class="primary m0"><k-icon name="add"></k-icon> New Item</button>
@@ -68,6 +73,49 @@ locked: true
68
73
  <div><kc-tc-page-size></kc-tc-page-size></div>
69
74
  </div>
70
75
  </k-table>
76
+ </k-tab-content>
77
+
78
+ <k-tab-content id="categoriesContent" name="categories">
79
+ <p class="mb tc-muted">
80
+ An item's category is the text in its Category box, so a category appears here as soon as an item uses it.
81
+ Here you can give each one a picture and a description, set up fields that only items in it have (the gear button), rename it (which updates its items, and merges it
82
+ into another category if you pick a name that already exists), or add one before any item uses it.
83
+ </p>
84
+
85
+ <k-table id="categoriesTable" enable-pages page-size="25" page-size-options="[10,25,50]" placeholder="No categories yet." filtered-placeholder="No categories match your search.">
86
+ <div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
87
+ <kc-tc-search style="width: 16rem"></kc-tc-search>
88
+ <kc-tc-filters></kc-tc-filters>
89
+ <kc-tc-field-sort-hide></kc-tc-field-sort-hide>
90
+ <div class="flex"></div>
91
+ <k-permission has="kempo-inventory:items:update">
92
+ <button id="newCategoryBtn" class="primary m0"><k-icon name="add"></k-icon> New Category</button>
93
+ </k-permission>
94
+ </div>
95
+ <div class="d-f full" style="align-items: center;">
96
+ <div>
97
+ <kc-tc-first-page></kc-tc-first-page>
98
+ <kc-tc-prev-page></kc-tc-prev-page>
99
+ <kc-tc-next-page></kc-tc-next-page>
100
+ <kc-tc-last-page></kc-tc-last-page>
101
+ </div>
102
+ <div class="flex"></div>
103
+ <div><kc-tc-page-select></kc-tc-page-select></div>
104
+ <div class="flex"></div>
105
+ <div><kc-tc-page-size></kc-tc-page-size></div>
106
+ </div>
107
+ </k-table>
108
+ <p id="uncategorised" class="mt tc-muted"></p>
109
+ </k-tab-content>
110
+
111
+ <k-tab-content id="fieldsContent" name="fields">
112
+ <k-inventory-fields></k-inventory-fields>
113
+ </k-tab-content>
114
+
115
+ <location name="inventory-admin-tabs" />
116
+ </k-tabs>
117
+
118
+ <k-permission-target has="kempo-inventory:fields:manage" target="#fieldsTab, #fieldsContent"></k-permission-target>
71
119
 
72
120
  <script type="module">
73
121
  import { getItems, deleteItem, getFields } from '/inventory/sdk.js';
@@ -191,8 +239,10 @@ locked: true
191
239
  if(action === 'adjust') openAdjustDialog({ item: record, onSaved: load });
192
240
  });
193
241
 
194
- document.getElementById('importBtn')?.addEventListener('click', () => openImportDialog({ onDone: load }));
195
- document.getElementById('exportBtn')?.addEventListener('click', () => openExportDialog());
242
+ $table.addEventListener('toolbarAction', ({ detail: { action } }) => {
243
+ if(action === 'import') openImportDialog({ onDone: load });
244
+ else if(action === 'export') openExportDialog();
245
+ });
196
246
  /* Adding an item is a page of its own: a dialog is too easy to dismiss and loses everything entered. */
197
247
  document.getElementById('newItemBtn')?.addEventListener('click', () => { location.href = '/admin/extension/kempo-inventory/new'; });
198
248
 
@@ -200,5 +250,190 @@ locked: true
200
250
  await load();
201
251
  showFlash();
202
252
  </script>
253
+
254
+ <script type="module">
255
+ import { getCategories, saveCategory, deleteCategory } from '/inventory/sdk.js';
256
+ import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
257
+ import { html } from '/kempo-ui/lit-all.min.js';
258
+ import Toast from '/kempo-ui/components/Toast.js';
259
+ import Dialog from '/kempo-ui/components/Dialog.js';
260
+ import { tagsValue, suggestTags, readTags, lowerTagInput, lowerTagChips } from '/inventory/components/ItemFields.js';
261
+
262
+ await window.customElements.whenDefined('k-table');
263
+ const $table = document.getElementById('categoriesTable');
264
+
265
+ const [, permData] = await getAllCurrentUserPermissions();
266
+ const canEdit = new Set(permData?.permissions ?? []).has('kempo-inventory:items:update');
267
+
268
+ const manageFields = document.createElement('k-inventory-row-action');
269
+ manageFields.setAttribute('slot', 'before');
270
+ manageFields.setAttribute('action', 'fields');
271
+ manageFields.setAttribute('icon', 'settings');
272
+ manageFields.setAttribute('title', 'Fields for this category');
273
+ $table.appendChild(manageFields);
274
+
275
+ if(canEdit){
276
+ const edit = document.createElement('k-inventory-row-action');
277
+ edit.setAttribute('slot', 'before');
278
+ edit.setAttribute('action', 'edit');
279
+ edit.setAttribute('icon', 'edit');
280
+ edit.setAttribute('title', 'Edit');
281
+ $table.appendChild(edit);
282
+
283
+ $table.setAttribute('request-delete', '');
284
+ const del = document.createElement('kc-tc-delete-record');
285
+ del.setAttribute('slot', 'after');
286
+ $table.appendChild(del);
287
+ }
288
+
289
+ const keyOf = name => String(name ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
290
+ let categories = [];
291
+
292
+ /* Plain strings only, so a name or description can never be read as markup. */
293
+ const decorate = c => ({
294
+ ...c,
295
+ itemsText: String(c.count),
296
+ imageText: c.image ? 'Yes' : '',
297
+ fieldsText: String(c.fields),
298
+ tagsText: c.tags.join(', '),
299
+ ownerText: c.owner,
300
+ });
301
+
302
+ const load = async () => {
303
+ const [error, data] = await getCategories({ includeEmpty: true });
304
+ if(error){
305
+ Toast.error(error.msg || 'Failed to load the categories');
306
+ return;
307
+ }
308
+ categories = data.categories;
309
+ $table.setData({
310
+ records: categories.map(decorate),
311
+ fields: [
312
+ { name: 'name', label: 'Name', size: 220, editable: false },
313
+ { name: 'itemsText', label: 'Items', size: 80, editable: false },
314
+ { name: 'fieldsText', label: 'Fields', size: 80, editable: false },
315
+ { name: 'description', label: 'Description', size: 300, editable: false },
316
+ { name: 'tagsText', label: 'Tags', size: 200, editable: false },
317
+ { name: 'imageText', label: 'Image', size: 80, editable: false },
318
+ ...(categories.some(c => c.owner) ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
319
+ ],
320
+ });
321
+ document.getElementById('uncategorised').textContent = data.uncategorised
322
+ ? `${data.uncategorised} item${data.uncategorised === 1 ? ' has' : 's have'} no category.`
323
+ : '';
324
+ };
325
+
326
+ /* One dialog for new and edit. Saving always sends what the dialog shows, so it is the source of truth. */
327
+ const openDialog = category => {
328
+ const editing = Boolean(category);
329
+ const $dialog = Dialog.create(html`
330
+ <div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
331
+ <div class="mb">
332
+ <label class="d-b mb-sm" for="cat-name">Name *</label>
333
+ <input type="text" id="cat-name" class="full" maxlength="100" ?disabled=${editing && category.owner} .value=${category?.name ?? ''}>
334
+ ${editing && category.owner ? html`<small class="d-b tc-muted">Managed by the ${category.owner} extension, which is the only one that can rename it. You can still change everything else.</small>` : ''}
335
+ ${editing && !category.owner && category.count ? html`<small class="d-b tc-muted">Renaming also changes the category on its ${category.count} item${category.count === 1 ? '' : 's'}.</small>` : ''}
336
+ </div>
337
+ <div class="mb">
338
+ <label class="d-b mb-sm" for="cat-description">Description</label>
339
+ <textarea id="cat-description" class="full" rows="3" maxlength="500" .value=${category?.description ?? ''}></textarea>
340
+ </div>
341
+ <div class="mb">
342
+ <label class="d-b mb-sm" for="cat-tags">Tags</label>
343
+ <k-tags id="cat-tags" .value=${tagsValue(category?.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags>
344
+ </div>
345
+ <div class="mb">
346
+ <label class="d-b mb-sm">Image</label>
347
+ <k-inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></k-inventory-media-field>
348
+ </div>
349
+ </div>
350
+ `, {
351
+ title: editing ? `Edit ${category.name}` : 'New Category',
352
+ confirmText: editing ? 'Save' : 'Create',
353
+ cancelText: 'Cancel',
354
+ confirmAction: async event => {
355
+ event.keepDialogOpen = true;
356
+ const name = $dialog.querySelector('#cat-name').value.trim();
357
+ if(!name){
358
+ Toast.error('A name is required');
359
+ return;
360
+ }
361
+ const changes = {
362
+ description: $dialog.querySelector('#cat-description').value,
363
+ tags: readTags($dialog.querySelector('#cat-tags')),
364
+ image: $dialog.querySelector('#cat-image').value[0] ?? null,
365
+ };
366
+
367
+ let error, result;
368
+ if(editing){
369
+ if(name !== category.name && !category.owner) changes.newName = name;
370
+ [error, result] = await saveCategory(category.name, changes);
371
+ } else {
372
+ /* Creating must not silently overwrite one that is already there. */
373
+ if(categories.some(c => keyOf(c.name) === keyOf(name))){
374
+ Toast.error('That category already exists. Edit it instead');
375
+ return;
376
+ }
377
+ [error, result] = await saveCategory(name, changes);
378
+ }
379
+ if(error){
380
+ Toast.error(error.msg || 'Failed to save the category');
381
+ return;
382
+ }
383
+ $dialog.close();
384
+ const moved = result.category.moved;
385
+ Toast.success(!editing ? 'Category created' : moved ? `Saved. ${moved} item${moved === 1 ? '' : 's'} updated` : 'Category saved');
386
+ load();
387
+ },
388
+ });
389
+ };
390
+
391
+ $table.addEventListener('itemAction', ({ detail: { action, record } }) => {
392
+ if(action === 'edit') openDialog(categories.find(c => c.key === record.key));
393
+ if(action === 'fields'){
394
+ document.querySelector('k-tabs').active = 'fields';
395
+ history.pushState(null, '', '#fields');
396
+ document.querySelector('k-inventory-fields').setPicked([record.key]);
397
+ }
398
+ });
399
+ document.getElementById('newCategoryBtn')?.addEventListener('click', () => openDialog(null));
400
+
401
+ $table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
402
+ const [record] = records;
403
+ if(record.owner){
404
+ reject();
405
+ Toast.error(`"${record.name}" is managed by the ${record.owner} extension and cannot be deleted here`);
406
+ return;
407
+ }
408
+ if(record.fields){
409
+ reject();
410
+ Toast.error(`"${record.name}" still has ${record.fields} field${record.fields === 1 ? '' : 's'}. Delete ${record.fields === 1 ? 'it' : 'them'} first`);
411
+ return;
412
+ }
413
+ if(record.count){
414
+ reject();
415
+ Toast.error(`${record.count} item${record.count === 1 ? ' is' : 's are'} still in "${record.name}". Move or clear ${record.count === 1 ? 'it' : 'them'} first`);
416
+ return;
417
+ }
418
+ Dialog.confirm(`Delete the category "${record.name}"? Its image and description are removed. This action cannot be undone.`, async confirmed => {
419
+ if(!confirmed){ reject(); return; }
420
+ const [error] = await deleteCategory(record.name);
421
+ if(error){
422
+ reject();
423
+ Toast.error(error.msg || 'Failed to delete the category');
424
+ return;
425
+ }
426
+ approve();
427
+ Toast.success('Category deleted');
428
+ load();
429
+ });
430
+ });
431
+
432
+ const categoriesPane = document.querySelector('k-tab-content[name="categories"]');
433
+ if(categoriesPane.active) load();
434
+ else document.querySelector('k-tabs').addEventListener('tabfirstshown', ({ detail }) => {
435
+ if(detail.tab === 'categories') load();
436
+ });
437
+ </script>
203
438
  </content>
204
439
  </page>
@@ -1,6 +1,3 @@
1
1
  <content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="87">
2
2
  <k-aside-item icon-src="/admin/extension/kempo-inventory/icons/inventory.svg" group="Products" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
3
3
  </content>
4
- <content id="kempo-inventory-categories-nav" name="Inventory Categories" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="86">
5
- <k-aside-item icon-src="/admin/extension/kempo-inventory/icons/inventory-categories.svg" group="Products" href="/admin/extension/kempo-inventory/categories/" no-expand>Inventory Categories</k-aside-item>
6
- </content>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant <dustinpoissant@gmail.com>",
@@ -39,7 +39,7 @@
39
39
  "drizzle-orm": "^0.45.2"
40
40
  },
41
41
  "peerDependencies": {
42
- "kempo": ">=4.9.7",
42
+ "kempo": ">=4.9.10",
43
43
  "kempo-ui": ">=0.7.0",
44
44
  "kempo-media": ">=0.3.0",
45
45
  "kempo-files": ">=0.1.3",
@@ -0,0 +1,340 @@
1
+ import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
+ import { html, css, nothing, render } from '/kempo-ui/lit-all.min.js';
3
+ import '/kempo-ui/components/Table.js';
4
+ import '/kempo-ui/components/controls/TcDeleteRecord.js';
5
+ import '/kempo-ui/components/controls/TcSearch.js';
6
+ import '/kempo-ui/components/Toggle.js';
7
+ import '/kempo-ui/components/Icon.js';
8
+ import '/kempo-ui/components/FilterList.js';
9
+ import '/kempo-ui/components/FilterItem.js';
10
+ import '/inventory/components/ToolbarControl.js';
11
+ import '/kempo/components/Permission.js';
12
+ import Toast from '/kempo-ui/components/Toast.js';
13
+ import Dialog from '/kempo-ui/components/Dialog.js';
14
+ import '/inventory/components/RowAction.js';
15
+ import { getFields, createField, updateField, deleteField, getCategories } from '/inventory/sdk.js';
16
+
17
+ /*
18
+ The item fields, as a tab of the inventory page (served at /inventory/components/FieldsManager.js). A field you add for every item
19
+ appears on all of them; a field added for one category only appears on items in that category. Loads when its tab is first shown.
20
+
21
+ <k-inventory-fields></k-inventory-fields>
22
+ */
23
+ const TYPE_LABELS = {
24
+ text: 'Text',
25
+ longtext: 'Long text',
26
+ number: 'Number',
27
+ boolean: 'Yes / No',
28
+ date: 'Date',
29
+ color: 'Color',
30
+ rating: 'Rating (stars)',
31
+ select: 'Choice list',
32
+ item: 'Link to another item',
33
+ media: 'Photos / files',
34
+ };
35
+
36
+ /* Same rule the server applies, so the key preview matches what gets saved. */
37
+ const keyFromLabel = label => {
38
+ const words = String(label ?? '').replace(/[^a-zA-Z0-9]+/g, ' ').trim().split(' ').filter(Boolean);
39
+ const key = words.map((w, i) => i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1)).join('');
40
+ return /^[a-z]/.test(key) ? key.slice(0, 40) : '';
41
+ };
42
+
43
+ const decorate = field => ({
44
+ ...field,
45
+ typeText: TYPE_LABELS[field.type] ?? field.type,
46
+ requiredText: field.required ? 'Yes' : 'No',
47
+ listedText: field.listed ? 'Yes' : 'No',
48
+ ownerText: field.owner || 'Custom',
49
+ scopeText: field.category ? field.categoryName : 'Every item',
50
+ optionsText: field.options.join(', '),
51
+ cropText: field.type === 'media' ? [field.imageRatio ? field.imageRatio.replace(' / ', ':') : '', field.imageMax ? `max ${field.imageMax}px` : ''].filter(Boolean).join(', ') : '',
52
+ });
53
+
54
+ export default class FieldsManager extends ShadowComponent {
55
+ constructor(){
56
+ super();
57
+ this.creatableTypes = Object.keys(TYPE_LABELS);
58
+ this.conversions = {};
59
+ this.categories = [];
60
+ this.allFields = [];
61
+ this.loaded = false;
62
+ /* '' = every field, '*' = the default fields only, otherwise a category key. The category link on the Categories tab sets it. */
63
+ const wanted = new URLSearchParams(location.search).get('category');
64
+ this.scope = wanted ? wanted.trim().replace(/\s+/g, ' ').toLowerCase() : '';
65
+ this.picked = this.scope ? [this.scope] : [];
66
+ }
67
+
68
+ connectedCallback(){
69
+ super.connectedCallback();
70
+ if(this.hasAttribute('category')) this.setPicked([this.getAttribute('category')]);
71
+ const pane = this.closest('k-tab-content');
72
+ // Loads when its tab is first opened, so a tab nobody opens makes no requests.
73
+ if(!pane || pane.active) this.start();
74
+ else this.closest('k-tabs')?.addEventListener('tabfirstshown', ({ detail }) => {
75
+ if(detail.tab === pane.name) this.start();
76
+ });
77
+ }
78
+
79
+ firstUpdated(){
80
+ this.table.addEventListener('requestDelete', this.requestDelete);
81
+ this.table.addEventListener('toolbarAction', ({ detail: { action } }) => {
82
+ if(action === 'scope') this.picked.length ? this.setPicked([]) : this.openScopePicker();
83
+ });
84
+ this.table.addEventListener('itemAction', ({ detail: { action, record } }) => {
85
+ if(action === 'edit') this.openFieldDialog(record);
86
+ });
87
+ this.renderRoot.querySelector('#newFieldBtn').addEventListener('click', () => this.openFieldDialog(null));
88
+ }
89
+
90
+ get table(){
91
+ return this.renderRoot.querySelector('#fieldsTable');
92
+ }
93
+
94
+ start = () => {
95
+ if(this.loaded) return;
96
+ this.loaded = true;
97
+ this.load();
98
+ };
99
+
100
+ load = async () => {
101
+ const [categoriesError, categoryData] = await getCategories({ includeEmpty: true });
102
+ if(!categoriesError) this.categories = categoryData.categories;
103
+ const [error, data] = await getFields();
104
+ if(error){
105
+ Toast.error(error.msg || 'Failed to load fields');
106
+ return;
107
+ }
108
+ this.creatableTypes = data.types;
109
+ this.conversions = data.conversions ?? {};
110
+ this.allFields = data.fields;
111
+ this.requestUpdate();
112
+ this.renderTable();
113
+ };
114
+
115
+ static styles = css`
116
+ .scope.selected:hover { text-decoration: line-through; }
117
+ `;
118
+
119
+ scopeLabel(){
120
+ const labels = new Map(this.scopeOptions());
121
+ return this.picked.map(key => labels.get(key) ?? key).join(', ');
122
+ }
123
+
124
+ /* A fixed-height, filterable list in a dialog: picking one closes it, and the choice shows as a chip above the table. */
125
+ openScopePicker = () => {
126
+ const $dialog = Dialog.create(html`
127
+ <div class="p">
128
+ <input type="search" class="full m0 rb0" placeholder="Search Categories" aria-label="Search Categories" @input=${event => $dialog.querySelector('k-filter-list').filter(event.target.value)}>
129
+ <k-filter-list class="bl br bb" style="display: block; height: 20rem; overflow-y: auto;">
130
+ ${this.scopeOptions().map(([key, label]) => html`
131
+ <k-filter-item filter-keywords=${label}><a href="#" class="d-b px pyh no-link bb r0" @click=${event => { event.preventDefault(); $dialog.close(); this.setPicked([key]); }}>${label}</a></k-filter-item>
132
+ `)}
133
+ </k-filter-list>
134
+ </div>
135
+ `, { title: 'Show fields for', confirmText: 'Close', cancelText: '' });
136
+ };
137
+
138
+ setPicked = keys => {
139
+ this.picked = keys;
140
+ this.scope = keys.length === 1 ? keys[0] : '';
141
+ this.requestUpdate();
142
+ this.renderTable();
143
+ };
144
+
145
+ /* The filter's choices: the default fields, then each category, as the names the tags show. */
146
+ scopeOptions(){
147
+ return [['*', 'Every item (default fields)'], ...this.categories.map(c => [c.key, c.name])];
148
+ }
149
+
150
+ renderTable(){
151
+ const visible = this.allFields.filter(f => this.picked.length === 0 || this.picked.some(key => key === '*' ? !f.category : f.category === key));
152
+ this.table?.setData({
153
+ records: visible.map(decorate),
154
+ fields: [
155
+ { name: 'label', label: 'Label', size: 200, editable: false },
156
+ { name: 'key', label: 'Key', size: 150, editable: false },
157
+ { name: 'scopeText', label: 'Applies to', size: 170, editable: false },
158
+ { name: 'typeText', label: 'Type', size: 170, editable: false },
159
+ { name: 'requiredText', label: 'Required', size: 90, editable: false },
160
+ { name: 'listedText', label: 'In list', size: 80, editable: false },
161
+ { name: 'optionsText', label: 'Choices', size: 200, editable: false },
162
+ { name: 'cropText', label: 'Photo crop', size: 150, editable: false },
163
+ { name: 'ownerText', label: 'Managed by', size: 170, editable: false },
164
+ ],
165
+ });
166
+ }
167
+
168
+ requestDelete = ({ detail: { records, approve, reject } }) => {
169
+ const [record] = records;
170
+ if(record.owner){
171
+ reject();
172
+ Toast.error(`"${record.label}" is managed by the ${record.owner} extension and cannot be deleted here`);
173
+ return;
174
+ }
175
+ Dialog.confirm(`Delete the field "${record.label}"? Its value is removed from ${record.category ? 'every item in that category' : 'every item'}. This action cannot be undone.`, async confirmed => {
176
+ if(!confirmed){ reject(); return; }
177
+ const [error] = await deleteField(record.key, { category: record.category });
178
+ if(error){
179
+ reject();
180
+ Toast.error(error.msg || 'Failed to delete field');
181
+ return;
182
+ }
183
+ approve();
184
+ Toast.success('Field deleted');
185
+ this.load();
186
+ });
187
+ };
188
+
189
+ openFieldDialog(field){
190
+ const editing = Boolean(field);
191
+ const managed = editing && Boolean(field.owner);
192
+ let keyTouched = editing;
193
+ const categories = this.categories;
194
+ const scope = this.scope;
195
+
196
+ const $dialog = Dialog.create(html`
197
+ <div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
198
+ ${managed ? html`<p class="mb tc-muted">Managed by the ${field.owner} extension. You can rename it, change its description and choose whether it shows in the list.</p>` : ''}
199
+ <div class="mb">
200
+ <label class="d-b mb-sm" for="fld-label">Label *</label>
201
+ <input type="text" id="fld-label" class="full" .value=${field?.label ?? ''}
202
+ @input=${e => {
203
+ if(keyTouched) return;
204
+ $dialog.querySelector('#fld-key').value = keyFromLabel(e.target.value);
205
+ }}>
206
+ </div>
207
+ <div class="mb">
208
+ <label class="d-b mb-sm" for="fld-scope">Applies to</label>
209
+ <select id="fld-scope" class="full" ?disabled=${editing}>
210
+ <option value="" ?selected=${editing ? !field.category : scope === '' || scope === '*'}>Every item</option>
211
+ ${categories.map(c => html`<option value=${c.name} ?selected=${editing ? c.key === field.category : c.key === scope}>Only ${c.name}</option>`)}
212
+ </select>
213
+ </div>
214
+ <div class="d-f mb" style="gap: var(--spacer);">
215
+ <div class="flex">
216
+ <label class="d-b mb-sm" for="fld-key">Key *</label>
217
+ <input type="text" id="fld-key" class="full" ?disabled=${editing} .value=${field?.key ?? ''}
218
+ @input=${() => { keyTouched = true; }}>
219
+ </div>
220
+ <div class="flex">
221
+ <label class="d-b mb-sm" for="fld-type">Type *</label>
222
+ <select id="fld-type" class="full" ?disabled=${editing && (managed || !this.conversions[field.type]?.length)}>
223
+ ${Object.entries(TYPE_LABELS).filter(([value]) => editing
224
+ ? value === field.type || (!managed && this.conversions[field.type]?.includes(value))
225
+ : this.creatableTypes.includes(value)).map(([value, label]) => html`<option value=${value} ?selected=${value === (field?.type ?? 'text')}>${label}</option>`)}
226
+ </select>
227
+ </div>
228
+ </div>
229
+ <div id="fld-options-row" class="mb" style="display: none;">
230
+ <label class="d-b mb-sm" for="fld-options">Choices (one per line)</label>
231
+ <textarea id="fld-options" class="full" rows="4" ?disabled=${managed} .value=${field?.options.join('\n') ?? ''}></textarea>
232
+ </div>
233
+ <div id="fld-crop-row" class="mb" style="display: none;">
234
+ <div class="d-f" style="gap: var(--spacer);">
235
+ <div class="flex">
236
+ <label class="d-b mb-sm" for="fld-ratio">Force photo shape</label>
237
+ <input type="text" id="fld-ratio" class="full" placeholder="e.g. 1:1, 4:3, 16:9" autocomplete="off" .value=${field?.imageRatio ? field.imageRatio.replace(' / ', ':') : ''}>
238
+ </div>
239
+ <div style="width: 9rem;">
240
+ <label class="d-b mb-sm" for="fld-max">Max size (px)</label>
241
+ <input type="number" id="fld-max" class="full" min="0" max="8000" step="1" placeholder="no limit" .value=${field?.imageMax || ''}>
242
+ </div>
243
+ </div>
244
+ <small class="d-b tc-muted">Photos are cropped to this shape as they are added (a cropper opens, locked to it) and kept within the maximum size. Leave the shape empty to allow any shape; with only a maximum, photos are just scaled down.</small>
245
+ </div>
246
+ <div id="fld-suggest-row" class="mb" style="display: none;">
247
+ <k-toggle id="fld-suggest" .value=${Boolean(field?.suggest)}>Suggest values already used</k-toggle>
248
+ <small class="d-b tc-muted">Offers values from other items as you type, so repeated entries such as a brand stay consistent. Anything new can still be typed.</small>
249
+ </div>
250
+ <div class="mb">
251
+ <label class="d-b mb-sm" for="fld-description">Description</label>
252
+ <input type="text" id="fld-description" class="full" .value=${field?.description ?? ''}>
253
+ </div>
254
+ <div class="d-f mb" style="gap: var(--spacer); align-items: center;">
255
+ ${managed ? '' : html`<k-toggle id="fld-required" .value=${Boolean(field?.required)}>Required</k-toggle>`}
256
+ <k-toggle id="fld-listed" .value=${field ? Boolean(field.listed) : true}>Show in list</k-toggle>
257
+ <div class="flex"></div>
258
+ <div style="width: 7rem;">
259
+ <label class="d-b mb-sm" for="fld-position">Order</label>
260
+ <input type="number" id="fld-position" class="full" step="1" .value=${field?.position ?? 0}>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ `, {
265
+ title: editing ? `Edit ${field.label}` : 'New Field',
266
+ confirmText: editing ? 'Save' : 'Create',
267
+ cancelText: 'Cancel',
268
+ confirmAction: async event => {
269
+ event.keepDialogOpen = true;
270
+ const value = id => $dialog.querySelector(id).value;
271
+ const data = {
272
+ label: value('#fld-label').trim(),
273
+ description: value('#fld-description'),
274
+ listed: $dialog.querySelector('#fld-listed').value === true,
275
+ suggest: $dialog.querySelector('#fld-suggest').value === true,
276
+ position: Number(value('#fld-position') || 0),
277
+ };
278
+ if($type.value === 'media'){
279
+ data.imageRatio = value('#fld-ratio').trim();
280
+ data.imageMax = Number(value('#fld-max') || 0);
281
+ }
282
+ if(!managed){
283
+ data.required = $dialog.querySelector('#fld-required').value === true;
284
+ data.options = value('#fld-options').split('\n').map(o => o.trim()).filter(Boolean);
285
+ }
286
+ if(!data.label){
287
+ Toast.error('A label is required');
288
+ return;
289
+ }
290
+ let error;
291
+ if(editing){
292
+ if(value('#fld-type') !== field.type) data.type = value('#fld-type');
293
+ [error] = await updateField(field.key, data, { category: field.category });
294
+ } else {
295
+ [error] = await createField({ ...data, key: value('#fld-key').trim(), type: value('#fld-type'), category: value('#fld-scope') });
296
+ }
297
+ if(error){
298
+ Toast.error(error.msg || 'Failed to save field');
299
+ return;
300
+ }
301
+ $dialog.close();
302
+ Toast.success(editing ? 'Field saved' : 'Field created');
303
+ this.load();
304
+ },
305
+ });
306
+
307
+ const $type = $dialog.querySelector('#fld-type');
308
+ const syncOptions = () => {
309
+ $dialog.querySelector('#fld-options-row').style.display = $type.value === 'select' ? '' : 'none';
310
+ $dialog.querySelector('#fld-suggest-row').style.display = $type.value === 'text' ? '' : 'none';
311
+ $dialog.querySelector('#fld-crop-row').style.display = $type.value === 'media' ? '' : 'none';
312
+ };
313
+ $type.addEventListener('change', syncOptions);
314
+ syncOptions();
315
+ }
316
+
317
+ render(){
318
+ return html`
319
+ <p class="mb tc-muted">
320
+ Every item has a SKU, name, category, quantity and description. A field you add for <strong>every item</strong> appears on all of them;
321
+ a field you add for one category (a paint's "Finish", say) only appears on items in that category.
322
+ Fields added by other extensions are managed by those extensions.
323
+ </p>
324
+ <k-table id="fieldsTable" request-delete enable-pages page-size="25" page-size-options="[10,25,50,100]" placeholder="No custom fields yet." filtered-placeholder="No fields match your search.">
325
+ <k-inventory-row-action slot="before" action="edit" icon="edit" title="Edit"></k-inventory-row-action>
326
+ <kc-tc-delete-record slot="after"></kc-tc-delete-record>
327
+ <div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
328
+ <kc-tc-search style="width: 16rem"></kc-tc-search>
329
+ ${this.hasAttribute('category') ? nothing : html`<k-inventory-toolbar-control action="scope" class="scope${this.picked.length ? ' selected' : ''}">${this.picked.length ? html`<k-icon name="close"></k-icon> ${this.scopeLabel()}` : html`<k-icon name="filter"></k-icon> Category`}</k-inventory-toolbar-control>`}
330
+ <div class="flex"></div>
331
+ <k-permission has="kempo-inventory:fields:manage">
332
+ <button id="newFieldBtn" class="primary m0"><k-icon name="add"></k-icon> New Field</button>
333
+ </k-permission>
334
+ </div>
335
+ </k-table>
336
+ `;
337
+ }
338
+ }
339
+
340
+ customElements.define('k-inventory-fields', FieldsManager);
@@ -0,0 +1,31 @@
1
+ import ButtonControl from '/kempo-ui/components/controls/ButtonControl.js';
2
+ import { html } from '/kempo-ui/lit-all.min.js';
3
+ import '/kempo-ui/components/Icon.js';
4
+
5
+ /*
6
+ A labelled control for the table's top bar, drawn like the table's own controls. It reports its action as a
7
+ `toolbarAction` event on the table: { action }. The page does the work.
8
+
9
+ <k-inventory-toolbar-control action="export"><k-icon name="download"></k-icon> Export</k-inventory-toolbar-control>
10
+ */
11
+ export default class ToolbarControl extends ButtonControl {
12
+ static properties = {
13
+ ...ButtonControl.properties,
14
+ action: { type: String },
15
+ };
16
+
17
+ constructor(){
18
+ super();
19
+ this.action = '';
20
+ }
21
+
22
+ handleAction(){
23
+ this.host?.dispatchEvent(new CustomEvent('toolbarAction', { detail: { action: this.action } }));
24
+ }
25
+
26
+ render(){
27
+ return html`<slot></slot>`;
28
+ }
29
+ }
30
+
31
+ customElements.define('k-inventory-toolbar-control', ToolbarControl);
@@ -1,229 +0,0 @@
1
- <!--
2
- locked: true
3
- -->
4
- <page title="Inventory Categories - Admin">
5
- <content>
6
- <script type="module" src="/kempo-ui/components/Table.js"></script>
7
- <script type="module" src="/kempo-ui/components/controls/TcDeleteRecord.js"></script>
8
- <script type="module" src="/kempo-ui/components/controls/TcSearch.js"></script>
9
- <script type="module" src="/kempo-ui/components/controls/TcFirstPage.js"></script>
10
- <script type="module" src="/kempo-ui/components/controls/TcPrevPage.js"></script>
11
- <script type="module" src="/kempo-ui/components/controls/TcNextPage.js"></script>
12
- <script type="module" src="/kempo-ui/components/controls/TcLastPage.js"></script>
13
- <script type="module" src="/kempo-ui/components/controls/TcPageSelect.js"></script>
14
- <script type="module" src="/kempo-ui/components/controls/TcPageSize.js"></script>
15
- <script type="module" src="/kempo-ui/components/Icon.js"></script>
16
- <script type="module" src="/kempo/components/Permission.js"></script>
17
- <script type="module" src="/inventory/components/RowAction.js"></script>
18
-
19
- <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
20
- <a href="/admin/extension/kempo-inventory/" class="btn"><k-icon name="arrow" direction="left"></k-icon> Inventory</a>
21
- <h1 class="flex mb0">Categories</h1>
22
- </div>
23
- <p class="mb tc-muted">
24
- An item's category is the text in its Category box, so a category appears here as soon as an item uses it.
25
- Here you can give each one a picture and a description, set up fields that only items in it have (the gear button), rename it (which updates its items, and merges it
26
- into another category if you pick a name that already exists), or add one before any item uses it.
27
- </p>
28
-
29
- <k-table id="categoriesTable" enable-pages page-size="25" page-size-options="[10,25,50]" placeholder="No categories yet." filtered-placeholder="No categories match your search.">
30
- <div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
31
- <kc-tc-search style="width: 16rem"></kc-tc-search>
32
- <div class="flex"></div>
33
- <k-permission has="kempo-inventory:items:update">
34
- <button id="newCategoryBtn" class="primary m0"><k-icon name="add"></k-icon> New Category</button>
35
- </k-permission>
36
- </div>
37
- <div class="d-f full" style="align-items: center;">
38
- <div>
39
- <kc-tc-first-page></kc-tc-first-page>
40
- <kc-tc-prev-page></kc-tc-prev-page>
41
- <kc-tc-next-page></kc-tc-next-page>
42
- <kc-tc-last-page></kc-tc-last-page>
43
- </div>
44
- <div class="flex"></div>
45
- <div><kc-tc-page-select></kc-tc-page-select></div>
46
- <div class="flex"></div>
47
- <div><kc-tc-page-size></kc-tc-page-size></div>
48
- </div>
49
- </k-table>
50
- <p id="uncategorised" class="mt tc-muted"></p>
51
-
52
- <script type="module">
53
- import { getCategories, saveCategory, deleteCategory } from '/inventory/sdk.js';
54
- import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
55
- import { html } from '/kempo-ui/lit-all.min.js';
56
- import Toast from '/kempo-ui/components/Toast.js';
57
- import Dialog from '/kempo-ui/components/Dialog.js';
58
- import { tagsValue, suggestTags, readTags, lowerTagInput, lowerTagChips } from '/inventory/components/ItemFields.js';
59
-
60
- await window.customElements.whenDefined('k-table');
61
- const $table = document.getElementById('categoriesTable');
62
-
63
- const [, permData] = await getAllCurrentUserPermissions();
64
- const canEdit = new Set(permData?.permissions ?? []).has('kempo-inventory:items:update');
65
-
66
- const manageFields = document.createElement('k-inventory-row-action');
67
- manageFields.setAttribute('slot', 'before');
68
- manageFields.setAttribute('action', 'fields');
69
- manageFields.setAttribute('icon', 'settings');
70
- manageFields.setAttribute('title', 'Fields for this category');
71
- $table.appendChild(manageFields);
72
-
73
- if(canEdit){
74
- const edit = document.createElement('k-inventory-row-action');
75
- edit.setAttribute('slot', 'before');
76
- edit.setAttribute('action', 'edit');
77
- edit.setAttribute('icon', 'edit');
78
- edit.setAttribute('title', 'Edit');
79
- $table.appendChild(edit);
80
-
81
- $table.setAttribute('request-delete', '');
82
- const del = document.createElement('kc-tc-delete-record');
83
- del.setAttribute('slot', 'after');
84
- $table.appendChild(del);
85
- }
86
-
87
- const keyOf = name => String(name ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
88
- let categories = [];
89
-
90
- /* Plain strings only, so a name or description can never be read as markup. */
91
- const decorate = c => ({
92
- ...c,
93
- itemsText: String(c.count),
94
- imageText: c.image ? 'Yes' : '',
95
- fieldsText: String(c.fields),
96
- tagsText: c.tags.join(', '),
97
- ownerText: c.owner,
98
- });
99
-
100
- const load = async () => {
101
- const [error, data] = await getCategories({ includeEmpty: true });
102
- if(error){
103
- Toast.error(error.msg || 'Failed to load the categories');
104
- return;
105
- }
106
- categories = data.categories;
107
- $table.setData({
108
- records: categories.map(decorate),
109
- fields: [
110
- { name: 'name', label: 'Name', size: 220, editable: false },
111
- { name: 'itemsText', label: 'Items', size: 80, editable: false },
112
- { name: 'fieldsText', label: 'Fields', size: 80, editable: false },
113
- { name: 'description', label: 'Description', size: 300, editable: false },
114
- { name: 'tagsText', label: 'Tags', size: 200, editable: false },
115
- { name: 'imageText', label: 'Image', size: 80, editable: false },
116
- ...(categories.some(c => c.owner) ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
117
- ],
118
- });
119
- document.getElementById('uncategorised').textContent = data.uncategorised
120
- ? `${data.uncategorised} item${data.uncategorised === 1 ? ' has' : 's have'} no category.`
121
- : '';
122
- };
123
-
124
- /* One dialog for new and edit. Saving always sends what the dialog shows, so it is the source of truth. */
125
- const openDialog = category => {
126
- const editing = Boolean(category);
127
- const $dialog = Dialog.create(html`
128
- <div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
129
- <div class="mb">
130
- <label class="d-b mb-sm" for="cat-name">Name *</label>
131
- <input type="text" id="cat-name" class="full" maxlength="100" ?disabled=${editing && category.owner} .value=${category?.name ?? ''}>
132
- ${editing && category.owner ? html`<small class="d-b tc-muted">Managed by the ${category.owner} extension, which is the only one that can rename it. You can still change everything else.</small>` : ''}
133
- ${editing && !category.owner && category.count ? html`<small class="d-b tc-muted">Renaming also changes the category on its ${category.count} item${category.count === 1 ? '' : 's'}.</small>` : ''}
134
- </div>
135
- <div class="mb">
136
- <label class="d-b mb-sm" for="cat-description">Description</label>
137
- <textarea id="cat-description" class="full" rows="3" maxlength="500" .value=${category?.description ?? ''}></textarea>
138
- </div>
139
- <div class="mb">
140
- <label class="d-b mb-sm" for="cat-tags">Tags</label>
141
- <k-tags id="cat-tags" .value=${tagsValue(category?.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags>
142
- </div>
143
- <div class="mb">
144
- <label class="d-b mb-sm">Image</label>
145
- <k-inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></k-inventory-media-field>
146
- </div>
147
- </div>
148
- `, {
149
- title: editing ? `Edit ${category.name}` : 'New Category',
150
- confirmText: editing ? 'Save' : 'Create',
151
- cancelText: 'Cancel',
152
- confirmAction: async event => {
153
- event.keepDialogOpen = true;
154
- const name = $dialog.querySelector('#cat-name').value.trim();
155
- if(!name){
156
- Toast.error('A name is required');
157
- return;
158
- }
159
- const changes = {
160
- description: $dialog.querySelector('#cat-description').value,
161
- tags: readTags($dialog.querySelector('#cat-tags')),
162
- image: $dialog.querySelector('#cat-image').value[0] ?? null,
163
- };
164
-
165
- let error, result;
166
- if(editing){
167
- if(name !== category.name && !category.owner) changes.newName = name;
168
- [error, result] = await saveCategory(category.name, changes);
169
- } else {
170
- /* Creating must not silently overwrite one that is already there. */
171
- if(categories.some(c => keyOf(c.name) === keyOf(name))){
172
- Toast.error('That category already exists. Edit it instead');
173
- return;
174
- }
175
- [error, result] = await saveCategory(name, changes);
176
- }
177
- if(error){
178
- Toast.error(error.msg || 'Failed to save the category');
179
- return;
180
- }
181
- $dialog.close();
182
- const moved = result.category.moved;
183
- Toast.success(!editing ? 'Category created' : moved ? `Saved. ${moved} item${moved === 1 ? '' : 's'} updated` : 'Category saved');
184
- load();
185
- },
186
- });
187
- };
188
-
189
- $table.addEventListener('itemAction', ({ detail: { action, record } }) => {
190
- if(action === 'edit') openDialog(categories.find(c => c.key === record.key));
191
- if(action === 'fields') location.href = `/admin/extension/kempo-inventory/fields?category=${encodeURIComponent(record.name)}`;
192
- });
193
- document.getElementById('newCategoryBtn')?.addEventListener('click', () => openDialog(null));
194
-
195
- $table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
196
- const [record] = records;
197
- if(record.owner){
198
- reject();
199
- Toast.error(`"${record.name}" is managed by the ${record.owner} extension and cannot be deleted here`);
200
- return;
201
- }
202
- if(record.fields){
203
- reject();
204
- Toast.error(`"${record.name}" still has ${record.fields} field${record.fields === 1 ? '' : 's'}. Delete ${record.fields === 1 ? 'it' : 'them'} first`);
205
- return;
206
- }
207
- if(record.count){
208
- reject();
209
- Toast.error(`${record.count} item${record.count === 1 ? ' is' : 's are'} still in "${record.name}". Move or clear ${record.count === 1 ? 'it' : 'them'} first`);
210
- return;
211
- }
212
- Dialog.confirm(`Delete the category "${record.name}"? Its image and description are removed. This action cannot be undone.`, async confirmed => {
213
- if(!confirmed){ reject(); return; }
214
- const [error] = await deleteCategory(record.name);
215
- if(error){
216
- reject();
217
- Toast.error(error.msg || 'Failed to delete the category');
218
- return;
219
- }
220
- approve();
221
- Toast.success('Category deleted');
222
- load();
223
- });
224
- });
225
-
226
- await load();
227
- </script>
228
- </content>
229
- </page>
@@ -1,300 +0,0 @@
1
- <!--
2
- locked: true
3
- -->
4
- <page title="Inventory Fields - Admin">
5
- <content>
6
- <script type="module" src="/kempo-ui/components/Table.js"></script>
7
- <script type="module" src="/kempo-ui/components/controls/TcDeleteRecord.js"></script>
8
- <script type="module" src="/kempo-ui/components/controls/TcSearch.js"></script>
9
- <script type="module" src="/kempo-ui/components/Toggle.js"></script>
10
- <script type="module" src="/kempo-ui/components/Icon.js"></script>
11
- <script type="module" src="/kempo/components/Permission.js"></script>
12
- <script type="module" src="/inventory/components/RowAction.js"></script>
13
-
14
- <div class="d-f mb" style="align-items: center; gap: var(--spacer);">
15
- <a href="/admin/extension/kempo-inventory/" class="btn"><k-icon name="arrow" direction="left"></k-icon> Inventory</a>
16
- <h1 class="flex mb0">Item Fields</h1>
17
- </div>
18
- <p class="mb tc-muted">
19
- Every item has a SKU, name, category, quantity and description. A field you add for <strong>every item</strong> appears on all of them;
20
- a field you add for one category (a paint's "Finish", say) only appears on items in that category.
21
- Fields added by other extensions are managed by those extensions.
22
- </p>
23
-
24
- <k-table id="fieldsTable" placeholder="No custom fields yet." filtered-placeholder="No fields match your search.">
25
- <div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
26
- <kc-tc-search style="width: 16rem"></kc-tc-search>
27
- <label class="d-f m0" style="align-items: center; gap: var(--spacer_h);">Show fields for
28
- <select id="scopeFilter" style="width: auto;" aria-label="Show fields for"></select>
29
- </label>
30
- <div class="flex"></div>
31
- <k-permission has="kempo-inventory:fields:manage">
32
- <button id="newFieldBtn" class="primary m0"><k-icon name="add"></k-icon> New Field</button>
33
- </k-permission>
34
- </div>
35
- </k-table>
36
-
37
- <script type="module">
38
- import { getFields, createField, updateField, deleteField, getCategories } from '/inventory/sdk.js';
39
- import { html } from '/kempo-ui/lit-all.min.js';
40
- import Toast from '/kempo-ui/components/Toast.js';
41
- import Dialog from '/kempo-ui/components/Dialog.js';
42
-
43
- await window.customElements.whenDefined('k-table');
44
- await window.customElements.whenDefined('k-toggle');
45
- const $table = document.getElementById('fieldsTable');
46
-
47
- const TYPE_LABELS = {
48
- text: 'Text',
49
- longtext: 'Long text',
50
- number: 'Number',
51
- boolean: 'Yes / No',
52
- date: 'Date',
53
- color: 'Color',
54
- rating: 'Rating (stars)',
55
- select: 'Choice list',
56
- item: 'Link to another item',
57
- media: 'Photos / files',
58
- };
59
-
60
- const addControl = (tag, slot, attributes = {}) => {
61
- const $control = document.createElement(tag);
62
- $control.setAttribute('slot', slot);
63
- for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
64
- $table.appendChild($control);
65
- };
66
- addControl('k-inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
67
- $table.setAttribute('request-delete', '');
68
- addControl('kc-tc-delete-record', 'after');
69
-
70
- /* Same rule the server applies, so the key preview matches what gets saved. */
71
- const keyFromLabel = label => {
72
- const words = String(label ?? '').replace(/[^a-zA-Z0-9]+/g, ' ').trim().split(' ').filter(Boolean);
73
- const key = words.map((w, i) => i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1)).join('');
74
- return /^[a-z]/.test(key) ? key.slice(0, 40) : '';
75
- };
76
-
77
- const decorate = field => ({
78
- ...field,
79
- typeText: TYPE_LABELS[field.type] ?? field.type,
80
- requiredText: field.required ? 'Yes' : 'No',
81
- listedText: field.listed ? 'Yes' : 'No',
82
- ownerText: field.owner || 'Custom',
83
- scopeText: field.category ? field.categoryName : 'Every item',
84
- optionsText: field.options.join(', '),
85
- cropText: field.type === 'media' ? [field.imageRatio ? field.imageRatio.replace(' / ', ':') : '', field.imageMax ? `max ${field.imageMax}px` : ''].filter(Boolean).join(', ') : '',
86
- });
87
-
88
- /* The types that can be created right now; "media" is absent when kempo-media is not installed. */
89
- let creatableTypes = Object.keys(TYPE_LABELS);
90
- /* Which type changes are safe, from the server: { select: ['text'], text: ['longtext'] }. */
91
- let conversions = {};
92
-
93
- /* '' = every field, '*' = the default fields only, otherwise a category key. */
94
- const $scope = document.getElementById('scopeFilter');
95
- let categories = [];
96
- let allFields = [];
97
- const wanted = new URLSearchParams(location.search).get('category');
98
- let scope = wanted ? wanted.trim().replace(/\s+/g, ' ').toLowerCase() : '';
99
-
100
- const renderScopeOptions = () => {
101
- $scope.replaceChildren(...[['', 'All fields'], ['*', 'Every item (default fields)'], ...categories.map(c => [c.key, c.name])].map(([value, label]) => {
102
- const $option = document.createElement('option');
103
- $option.value = value;
104
- $option.textContent = label;
105
- $option.selected = value === scope;
106
- return $option;
107
- }));
108
- };
109
- const renderTable = () => {
110
- const visible = allFields.filter(f => scope === '' || (scope === '*' ? !f.category : f.category === scope));
111
- $table.setData({
112
- records: visible.map(decorate),
113
- fields: [
114
- { name: 'label', label: 'Label', size: 200, editable: false },
115
- { name: 'key', label: 'Key', size: 150, editable: false },
116
- { name: 'scopeText', label: 'Applies to', size: 170, editable: false },
117
- { name: 'typeText', label: 'Type', size: 170, editable: false },
118
- { name: 'requiredText', label: 'Required', size: 90, editable: false },
119
- { name: 'listedText', label: 'In list', size: 80, editable: false },
120
- { name: 'optionsText', label: 'Choices', size: 200, editable: false },
121
- { name: 'cropText', label: 'Photo crop', size: 150, editable: false },
122
- { name: 'ownerText', label: 'Managed by', size: 170, editable: false },
123
- ],
124
- });
125
- };
126
- $scope.addEventListener('change', () => { scope = $scope.value; renderTable(); });
127
-
128
- const load = async () => {
129
- const [categoriesError, categoryData] = await getCategories({ includeEmpty: true });
130
- if(!categoriesError) categories = categoryData.categories;
131
- const [error, data] = await getFields();
132
- if(!error){
133
- creatableTypes = data.types;
134
- conversions = data.conversions ?? {};
135
- }
136
- if(error){
137
- Toast.error(error.msg || 'Failed to load fields');
138
- return;
139
- }
140
- allFields = data.fields;
141
- renderScopeOptions();
142
- renderTable();
143
- };
144
-
145
- $table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
146
- const [record] = records;
147
- if(record.owner){
148
- reject();
149
- Toast.error(`"${record.label}" is managed by the ${record.owner} extension and cannot be deleted here`);
150
- return;
151
- }
152
- Dialog.confirm(`Delete the field "${record.label}"? Its value is removed from ${record.category ? 'every item in that category' : 'every item'}. This action cannot be undone.`, async confirmed => {
153
- if(!confirmed){ reject(); return; }
154
- const [error] = await deleteField(record.key, { category: record.category });
155
- if(error){
156
- reject();
157
- Toast.error(error.msg || 'Failed to delete field');
158
- return;
159
- }
160
- approve();
161
- Toast.success('Field deleted');
162
- load();
163
- });
164
- });
165
-
166
- const openFieldDialog = field => {
167
- const editing = Boolean(field);
168
- const managed = editing && Boolean(field.owner);
169
- let keyTouched = editing;
170
-
171
- const $dialog = Dialog.create(html`
172
- <div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
173
- ${managed ? html`<p class="mb tc-muted">Managed by the ${field.owner} extension. You can rename it, change its description and choose whether it shows in the list.</p>` : ''}
174
- <div class="mb">
175
- <label class="d-b mb-sm" for="fld-label">Label *</label>
176
- <input type="text" id="fld-label" class="full" .value=${field?.label ?? ''}
177
- @input=${e => {
178
- if(keyTouched) return;
179
- $dialog.querySelector('#fld-key').value = keyFromLabel(e.target.value);
180
- }}>
181
- </div>
182
- <div class="mb">
183
- <label class="d-b mb-sm" for="fld-scope">Applies to</label>
184
- <select id="fld-scope" class="full" ?disabled=${editing}>
185
- <option value="" ?selected=${editing ? !field.category : scope === '' || scope === '*'}>Every item</option>
186
- ${categories.map(c => html`<option value=${c.name} ?selected=${editing ? c.key === field.category : c.key === scope}>Only ${c.name}</option>`)}
187
- </select>
188
- </div>
189
- <div class="d-f mb" style="gap: var(--spacer);">
190
- <div class="flex">
191
- <label class="d-b mb-sm" for="fld-key">Key *</label>
192
- <input type="text" id="fld-key" class="full" ?disabled=${editing} .value=${field?.key ?? ''}
193
- @input=${() => { keyTouched = true; }}>
194
- </div>
195
- <div class="flex">
196
- <label class="d-b mb-sm" for="fld-type">Type *</label>
197
- <select id="fld-type" class="full" ?disabled=${editing && (managed || !conversions[field.type]?.length)}>
198
- ${Object.entries(TYPE_LABELS).filter(([value]) => editing
199
- ? value === field.type || (!managed && conversions[field.type]?.includes(value))
200
- : creatableTypes.includes(value)).map(([value, label]) => html`<option value=${value} ?selected=${value === (field?.type ?? 'text')}>${label}</option>`)}
201
- </select>
202
- </div>
203
- </div>
204
- <div id="fld-options-row" class="mb" style="display: none;">
205
- <label class="d-b mb-sm" for="fld-options">Choices (one per line)</label>
206
- <textarea id="fld-options" class="full" rows="4" ?disabled=${managed} .value=${field?.options.join('\n') ?? ''}></textarea>
207
- </div>
208
- <div id="fld-crop-row" class="mb" style="display: none;">
209
- <div class="d-f" style="gap: var(--spacer);">
210
- <div class="flex">
211
- <label class="d-b mb-sm" for="fld-ratio">Force photo shape</label>
212
- <input type="text" id="fld-ratio" class="full" placeholder="e.g. 1:1, 4:3, 16:9" autocomplete="off" .value=${field?.imageRatio ? field.imageRatio.replace(' / ', ':') : ''}>
213
- </div>
214
- <div style="width: 9rem;">
215
- <label class="d-b mb-sm" for="fld-max">Max size (px)</label>
216
- <input type="number" id="fld-max" class="full" min="0" max="8000" step="1" placeholder="no limit" .value=${field?.imageMax || ''}>
217
- </div>
218
- </div>
219
- <small class="d-b tc-muted">Photos are cropped to this shape as they are added (a cropper opens, locked to it) and kept within the maximum size. Leave the shape empty to allow any shape; with only a maximum, photos are just scaled down.</small>
220
- </div>
221
- <div id="fld-suggest-row" class="mb" style="display: none;">
222
- <k-toggle id="fld-suggest" .value=${Boolean(field?.suggest)}>Suggest values already used</k-toggle>
223
- <small class="d-b tc-muted">Offers values from other items as you type, so repeated entries such as a brand stay consistent. Anything new can still be typed.</small>
224
- </div>
225
- <div class="mb">
226
- <label class="d-b mb-sm" for="fld-description">Description</label>
227
- <input type="text" id="fld-description" class="full" .value=${field?.description ?? ''}>
228
- </div>
229
- <div class="d-f mb" style="gap: var(--spacer); align-items: center;">
230
- ${managed ? '' : html`<k-toggle id="fld-required" .value=${Boolean(field?.required)}>Required</k-toggle>`}
231
- <k-toggle id="fld-listed" .value=${field ? Boolean(field.listed) : true}>Show in list</k-toggle>
232
- <div class="flex"></div>
233
- <div style="width: 7rem;">
234
- <label class="d-b mb-sm" for="fld-position">Order</label>
235
- <input type="number" id="fld-position" class="full" step="1" .value=${field?.position ?? 0}>
236
- </div>
237
- </div>
238
- </div>
239
- `, {
240
- title: editing ? `Edit ${field.label}` : 'New Field',
241
- confirmText: editing ? 'Save' : 'Create',
242
- cancelText: 'Cancel',
243
- confirmAction: async event => {
244
- event.keepDialogOpen = true;
245
- const value = id => $dialog.querySelector(id).value;
246
- const data = {
247
- label: value('#fld-label').trim(),
248
- description: value('#fld-description'),
249
- listed: $dialog.querySelector('#fld-listed').value === true,
250
- suggest: $dialog.querySelector('#fld-suggest').value === true,
251
- position: Number(value('#fld-position') || 0),
252
- };
253
- if($type.value === 'media'){
254
- data.imageRatio = value('#fld-ratio').trim();
255
- data.imageMax = Number(value('#fld-max') || 0);
256
- }
257
- if(!managed){
258
- data.required = $dialog.querySelector('#fld-required').value === true;
259
- data.options = value('#fld-options').split('\n').map(o => o.trim()).filter(Boolean);
260
- }
261
- if(!data.label){
262
- Toast.error('A label is required');
263
- return;
264
- }
265
- let error;
266
- if(editing){
267
- if(value('#fld-type') !== field.type) data.type = value('#fld-type');
268
- [error] = await updateField(field.key, data, { category: field.category });
269
- } else {
270
- [error] = await createField({ ...data, key: value('#fld-key').trim(), type: value('#fld-type'), category: value('#fld-scope') });
271
- }
272
- if(error){
273
- Toast.error(error.msg || 'Failed to save field');
274
- return;
275
- }
276
- $dialog.close();
277
- Toast.success(editing ? 'Field saved' : 'Field created');
278
- load();
279
- },
280
- });
281
-
282
- const $type = $dialog.querySelector('#fld-type');
283
- const syncOptions = () => {
284
- $dialog.querySelector('#fld-options-row').style.display = $type.value === 'select' ? '' : 'none';
285
- $dialog.querySelector('#fld-suggest-row').style.display = $type.value === 'text' ? '' : 'none';
286
- $dialog.querySelector('#fld-crop-row').style.display = $type.value === 'media' ? '' : 'none';
287
- };
288
- $type.addEventListener('change', syncOptions);
289
- syncOptions();
290
- };
291
-
292
- $table.addEventListener('itemAction', ({ detail: { action, record } }) => {
293
- if(action === 'edit') openFieldDialog(record);
294
- });
295
- document.getElementById('newFieldBtn')?.addEventListener('click', () => openFieldDialog(null));
296
-
297
- await load();
298
- </script>
299
- </content>
300
- </page>