kempo-inventory 0.4.0 → 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.
package/admin/index.page.html
CHANGED
|
@@ -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>
|
|
@@ -17,17 +18,27 @@ locked: true
|
|
|
17
18
|
<script type="module" src="/kempo-ui/components/Toggle.js"></script>
|
|
18
19
|
<script type="module" src="/kempo-ui/components/Combobox.js"></script>
|
|
19
20
|
<script type="module" src="/kempo-ui/components/Icon.js"></script>
|
|
21
|
+
<script type="module" src="/kempo-ui/components/controls/TcOpen.js"></script>
|
|
20
22
|
<script type="module" src="/kempo/components/Permission.js"></script>
|
|
21
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>
|
|
22
26
|
|
|
23
27
|
<h1 class="mb">Inventory</h1>
|
|
24
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">
|
|
25
35
|
<!--
|
|
26
36
|
Everything that acts on the table lives in the table: search, filters and the buttons above it,
|
|
27
37
|
paging below it, and the row actions beside each record.
|
|
28
38
|
-->
|
|
29
39
|
<k-table
|
|
30
40
|
id="itemsTable"
|
|
41
|
+
open-on-click
|
|
31
42
|
enable-pages
|
|
32
43
|
page-size="25"
|
|
33
44
|
page-size-options="[10,25,50,100]"
|
|
@@ -40,14 +51,10 @@ locked: true
|
|
|
40
51
|
<kc-tc-field-sort-hide></kc-tc-field-sort-hide>
|
|
41
52
|
<div class="flex"></div>
|
|
42
53
|
<k-permission has="kempo-inventory:fields:manage">
|
|
43
|
-
<
|
|
44
|
-
</k-permission>
|
|
45
|
-
<k-permission has="kempo-inventory:fields:manage">
|
|
46
|
-
<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>
|
|
47
55
|
</k-permission>
|
|
48
|
-
<a href="/admin/extension/kempo-inventory/categories" class="btn"><k-icon name="folder"></k-icon> Categories</a>
|
|
49
56
|
<k-permission has="kempo-inventory:fields:manage">
|
|
50
|
-
<
|
|
57
|
+
<k-inventory-toolbar-control action="export"><k-icon name="download"></k-icon> Export</k-inventory-toolbar-control>
|
|
51
58
|
</k-permission>
|
|
52
59
|
<k-permission has="kempo-inventory:items:create">
|
|
53
60
|
<button id="newItemBtn" class="primary m0"><k-icon name="add"></k-icon> New Item</button>
|
|
@@ -66,6 +73,49 @@ locked: true
|
|
|
66
73
|
<div><kc-tc-page-size></kc-tc-page-size></div>
|
|
67
74
|
</div>
|
|
68
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>
|
|
69
119
|
|
|
70
120
|
<script type="module">
|
|
71
121
|
import { getItems, deleteItem, getFields } from '/inventory/sdk.js';
|
|
@@ -90,8 +140,9 @@ locked: true
|
|
|
90
140
|
$control.setAttribute('slot', slot);
|
|
91
141
|
for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
|
|
92
142
|
$table.appendChild($control);
|
|
143
|
+
return $control;
|
|
93
144
|
};
|
|
94
|
-
if(can('items:update')) addControl('
|
|
145
|
+
if(can('items:update')) addControl('kc-tc-open', 'before', { title: 'Edit' }).appendChild(Object.assign(document.createElement('k-icon'), { name: 'edit' }));
|
|
95
146
|
if(can('quantity:adjust')) addControl('k-inventory-row-action', 'before', { action: 'adjust', icon: 'repeat', title: 'Adjust quantity' });
|
|
96
147
|
if(can('items:delete')){
|
|
97
148
|
$table.setAttribute('request-delete', '');
|
|
@@ -111,7 +162,7 @@ locked: true
|
|
|
111
162
|
user-supplied text itself.
|
|
112
163
|
*/
|
|
113
164
|
const decorate = (item, ctx) => {
|
|
114
|
-
const record = { ...item, qtyText: String(item.quantity), tagsText: item.tags.join(', '), ownerText: item.owner };
|
|
165
|
+
const record = { ...item, qtyText: String(item.quantity), tagsText: item.tags.join(', '), ownerText: item.owner, ...(can('items:update') && { _url: `/admin/extension/kempo-inventory/edit?id=${encodeURIComponent(item.id)}` }) };
|
|
115
166
|
/* Only the fields that apply to this item's category; a column another category's items fill stays blank here. */
|
|
116
167
|
for(const field of applicableFields(fields, item.category).filter(f => f.listed)) record[`f_${field.key}`] = displayValue(field, item.fields[field.key], ctx);
|
|
117
168
|
return record;
|
|
@@ -119,7 +170,7 @@ locked: true
|
|
|
119
170
|
|
|
120
171
|
const columns = () => [
|
|
121
172
|
{ name: 'sku', label: 'SKU', size: 130, editable: false },
|
|
122
|
-
{ name: 'name', label: 'Name',
|
|
173
|
+
{ name: 'name', label: 'Name', editable: false },
|
|
123
174
|
{ name: 'category', label: 'Category', size: 140, editable: false },
|
|
124
175
|
{ name: 'qtyText', label: 'Quantity', size: 100, editable: false },
|
|
125
176
|
{ name: 'tagsText', label: 'Tags', size: 160, editable: false },
|
|
@@ -185,13 +236,13 @@ locked: true
|
|
|
185
236
|
});
|
|
186
237
|
|
|
187
238
|
$table.addEventListener('itemAction', ({ detail: { action, record } }) => {
|
|
188
|
-
/* Editing is a page of its own, like adding: a dialog is too easy to dismiss and loses what was entered. */
|
|
189
|
-
if(action === 'edit') location.href = `/admin/extension/kempo-inventory/edit?id=${encodeURIComponent(record.id)}`;
|
|
190
239
|
if(action === 'adjust') openAdjustDialog({ item: record, onSaved: load });
|
|
191
240
|
});
|
|
192
241
|
|
|
193
|
-
|
|
194
|
-
|
|
242
|
+
$table.addEventListener('toolbarAction', ({ detail: { action } }) => {
|
|
243
|
+
if(action === 'import') openImportDialog({ onDone: load });
|
|
244
|
+
else if(action === 'export') openExportDialog();
|
|
245
|
+
});
|
|
195
246
|
/* Adding an item is a page of its own: a dialog is too easy to dismiss and loses everything entered. */
|
|
196
247
|
document.getElementById('newItemBtn')?.addEventListener('click', () => { location.href = '/admin/extension/kempo-inventory/new'; });
|
|
197
248
|
|
|
@@ -199,5 +250,190 @@ locked: true
|
|
|
199
250
|
await load();
|
|
200
251
|
showFlash();
|
|
201
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>
|
|
202
438
|
</content>
|
|
203
439
|
</page>
|
package/admin/nav.global.html
CHANGED
|
@@ -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,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-inventory",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "A basic inventory management system for kempo cms",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"author": "Dustin Poissant",
|
|
6
|
+
"author": "Dustin Poissant <dustinpoissant@gmail.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/dustinpoissant/kempo-inventory.git"
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"drizzle-orm": "^0.45.2"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"kempo": ">=4.
|
|
43
|
-
"kempo-ui": ">=0.
|
|
42
|
+
"kempo": ">=4.9.10",
|
|
43
|
+
"kempo-ui": ">=0.7.0",
|
|
44
44
|
"kempo-media": ">=0.3.0",
|
|
45
45
|
"kempo-files": ">=0.1.3",
|
|
46
46
|
"kempo-thumbs": ">=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>
|