kempo-inventory 0.5.2 → 0.5.3
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/README.md
CHANGED
|
@@ -40,7 +40,7 @@ The item list, its filters and the item form are all built from the field list.
|
|
|
40
40
|
|
|
41
41
|
### Fields for one category
|
|
42
42
|
|
|
43
|
-
A field applies either to **every item** (the default) or to **one category**. Make a paint's "Finish" (Matte, Satin, Gloss) for the Paint category and it only appears on items whose category is Paint; filament can have its own "Diameter" and nothing else sees it. Choose what a field **Applies to** when you create it (**Inventory > Categories** has a gear button per category that opens its fields
|
|
43
|
+
A field applies either to **every item** (the default) or to **one category**. Make a paint's "Finish" (Matte, Satin, Gloss) for the Paint category and it only appears on items whose category is Paint; filament can have its own "Diameter" and nothing else sees it. Choose what a field **Applies to** when you create it, and change it later by editing the field (its values move with it; the change is refused if the key is already used where the field is going). **Inventory > Categories** has a gear button per category that opens its fields. The item form shows the default fields plus those of the typed category, and swaps them as the category changes.
|
|
44
44
|
|
|
45
45
|
- A key is unique within its category, so two categories can each have a "finish". A category cannot reuse a key a default field has (and the other way round), because both would be stored under the same name on its items.
|
|
46
46
|
- Moving an item to another category keeps the values it held for fields it no longer shows. They are hidden, not deleted, and come back if the item returns.
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ export default async (request, response) => {
|
|
|
7
7
|
|
|
8
8
|
const body = request.body || {};
|
|
9
9
|
const changes = {};
|
|
10
|
-
for(const property of ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax']){
|
|
10
|
+
for(const property of ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax', 'category']){
|
|
11
11
|
if(body[property] !== undefined) changes[property] = body[property];
|
|
12
12
|
}
|
|
13
13
|
const [error, field] = await updateField(request.params.key, changes, { category: request.query.category ?? '' });
|
|
@@ -206,7 +206,7 @@ export default class FieldsManager extends ShadowComponent {
|
|
|
206
206
|
</div>
|
|
207
207
|
<div class="mb">
|
|
208
208
|
<label class="d-b mb-sm" for="fld-scope">Applies to</label>
|
|
209
|
-
<select id="fld-scope" class="full" ?disabled=${
|
|
209
|
+
<select id="fld-scope" class="full" ?disabled=${managed}>
|
|
210
210
|
<option value="" ?selected=${editing ? !field.category : scope === '' || scope === '*'}>Every item</option>
|
|
211
211
|
${categories.map(c => html`<option value=${c.name} ?selected=${editing ? c.key === field.category : c.key === scope}>Only ${c.name}</option>`)}
|
|
212
212
|
</select>
|
|
@@ -282,6 +282,7 @@ export default class FieldsManager extends ShadowComponent {
|
|
|
282
282
|
if(!managed){
|
|
283
283
|
data.required = $dialog.querySelector('#fld-required').value === true;
|
|
284
284
|
data.options = value('#fld-options').split('\n').map(o => o.trim()).filter(Boolean);
|
|
285
|
+
data.category = value('#fld-scope');
|
|
285
286
|
}
|
|
286
287
|
if(!data.label){
|
|
287
288
|
Toast.error('A label is required');
|
package/server/utils/fields.js
CHANGED
|
@@ -13,10 +13,10 @@ const isUniqueViolation = error => (error?.code || error?.cause?.code) === '2350
|
|
|
13
13
|
|
|
14
14
|
/*
|
|
15
15
|
Properties an extension's own field accepts on update, and what anyone else (the admin UI) may
|
|
16
|
-
adjust on a field an extension owns. Key and ownership never change after creation,
|
|
17
|
-
only changes where nothing can be lost (see CONVERSIONS in fieldTypes.js).
|
|
16
|
+
adjust on a field an extension owns. Key and ownership never change after creation, the scope
|
|
17
|
+
(`category`) can, and the type only changes where nothing can be lost (see CONVERSIONS in fieldTypes.js).
|
|
18
18
|
*/
|
|
19
|
-
const OWNER_EDITABLE = ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax'];
|
|
19
|
+
const OWNER_EDITABLE = ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax', 'category'];
|
|
20
20
|
const ANYONE_EDITABLE = ['label', 'description', 'listed', 'suggest', 'position', 'imageRatio', 'imageMax'];
|
|
21
21
|
|
|
22
22
|
/*
|
|
@@ -60,6 +60,22 @@ export const itemsInScope = category => category
|
|
|
60
60
|
? sql`lower(regexp_replace(btrim(${kempoInventoryItem.category}), '[[:space:]]+', ' ', 'g')) = ${category}`
|
|
61
61
|
: sql`true`;
|
|
62
62
|
|
|
63
|
+
/*
|
|
64
|
+
Items in a category hold the default fields and that category's under one set of names, so a
|
|
65
|
+
key cannot be reused where it would collide: a default field's key is taken in every category,
|
|
66
|
+
and a category's key is taken in the default scope. `exclude` is the scope the field is leaving.
|
|
67
|
+
*/
|
|
68
|
+
const keyClash = async (key, scope, exclude = null) => {
|
|
69
|
+
const [clash] = await db.select({ category: kempoInventoryField.category }).from(kempoInventoryField).where(and(
|
|
70
|
+
eq(kempoInventoryField.key, key),
|
|
71
|
+
exclude === null ? sql`true` : sql`${kempoInventoryField.category} <> ${exclude}`,
|
|
72
|
+
scope ? sql`${kempoInventoryField.category} in ('', ${scope})` : sql`true`,
|
|
73
|
+
));
|
|
74
|
+
if(!clash) return null;
|
|
75
|
+
const where = clash.category === scope ? (scope ? 'this category' : 'the default fields') : (scope ? 'the default fields' : 'a category');
|
|
76
|
+
return { code: 409, msg: `A field with the key "${key}" already exists in ${where}` };
|
|
77
|
+
};
|
|
78
|
+
|
|
63
79
|
/*
|
|
64
80
|
Creates a field. `owner` is '' for a field made by a person in the admin, or the extension's
|
|
65
81
|
name for one it registers itself.
|
|
@@ -80,19 +96,8 @@ export const createField = async (definition, { owner = '' } = {}) => {
|
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
key cannot be reused where it would collide: a default field's key is taken in every category,
|
|
86
|
-
and a category's key is taken in the default scope.
|
|
87
|
-
*/
|
|
88
|
-
const clash = await db.select({ category: kempoInventoryField.category }).from(kempoInventoryField).where(and(
|
|
89
|
-
eq(kempoInventoryField.key, normalized.key),
|
|
90
|
-
scope ? sql`${kempoInventoryField.category} in ('', ${scope})` : sql`true`,
|
|
91
|
-
));
|
|
92
|
-
if(clash.length){
|
|
93
|
-
const where = clash[0].category === scope ? (scope ? 'this category' : 'the default fields') : (scope ? 'the default fields' : 'a category');
|
|
94
|
-
return [{ code: 409, msg: `A field with the key "${normalized.key}" already exists in ${where}` }, null];
|
|
95
|
-
}
|
|
99
|
+
const clash = await keyClash(normalized.key, scope);
|
|
100
|
+
if(clash) return [clash, null];
|
|
96
101
|
|
|
97
102
|
const field = await db.transaction(async tx => {
|
|
98
103
|
if(scope){
|
|
@@ -119,13 +124,18 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
119
124
|
const [lookupError, existing] = await getField(key, category);
|
|
120
125
|
if(lookupError) return [lookupError, null];
|
|
121
126
|
|
|
127
|
+
const targetName = changes.category === undefined ? undefined : tidyCategory(changes.category);
|
|
128
|
+
const targetScope = targetName === undefined ? existing.category : categoryKey(targetName);
|
|
129
|
+
const scopeChanged = targetScope !== existing.category;
|
|
130
|
+
if(scopeChanged && targetName.length > MAX_CATEGORY) return [{ code: 400, msg: `A category name must be ${MAX_CATEGORY} characters or fewer` }, null];
|
|
131
|
+
|
|
122
132
|
const targetType = changes.type ?? existing.type;
|
|
123
133
|
if(!canConvert(existing.type, targetType)){
|
|
124
134
|
return [{ code: 409, msg: `A ${existing.type} field cannot be changed to ${targetType}. Delete it and create a new one instead` }, null];
|
|
125
135
|
}
|
|
126
136
|
const isOwner = existing.owner === (owner ?? '');
|
|
127
137
|
const allowed = isOwner ? OWNER_EDITABLE : ANYONE_EDITABLE;
|
|
128
|
-
const rejected = Object.keys(changes).filter(k => k !== 'key' && k
|
|
138
|
+
const rejected = Object.keys(changes).filter(k => k !== 'key' && !(k === 'category' && !scopeChanged) && !(k === 'type' && targetType === existing.type) && !allowed.includes(k));
|
|
129
139
|
if(rejected.length){
|
|
130
140
|
return [{ code: 403, msg: `${rejected.join(', ')} can only be changed by ${existing.owner ? `the "${existing.owner}" extension` : 'the field\'s owner'}` }, null];
|
|
131
141
|
}
|
|
@@ -135,7 +145,7 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
135
145
|
|
|
136
146
|
const updates = {};
|
|
137
147
|
for(const property of allowed){
|
|
138
|
-
if(changes[property] !== undefined) updates[property] = normalized[property];
|
|
148
|
+
if(property !== 'category' && changes[property] !== undefined) updates[property] = normalized[property];
|
|
139
149
|
}
|
|
140
150
|
if(targetType !== existing.type){
|
|
141
151
|
/* Converting: the type changes, and what only made sense for the old type goes with it. */
|
|
@@ -143,14 +153,29 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
143
153
|
updates.options = normalized.options;
|
|
144
154
|
updates.suggest = normalized.suggest;
|
|
145
155
|
}
|
|
156
|
+
if(scopeChanged){
|
|
157
|
+
const clash = await keyClash(existing.key, targetScope, existing.category);
|
|
158
|
+
if(clash) return [clash, null];
|
|
159
|
+
updates.category = targetScope;
|
|
160
|
+
}
|
|
146
161
|
if(!Object.keys(updates).length) return [{ code: 400, msg: 'No changes provided' }, null];
|
|
147
162
|
|
|
148
163
|
const refused = await guard(EVENTS.fieldBeforeUpdate, { field: existing, changes: updates, actor: owner ?? '' });
|
|
149
164
|
if(refused) return [refused, null];
|
|
150
165
|
|
|
151
166
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
167
|
+
const field = await db.transaction(async tx => {
|
|
168
|
+
if(scopeChanged && targetScope){
|
|
169
|
+
/* Moving into a category: it has to exist as a record, as it does when a field is created there. */
|
|
170
|
+
const now = new Date();
|
|
171
|
+
await tx.insert(kempoInventoryCategory)
|
|
172
|
+
.values({ id: newId(), key: targetScope, name: targetName, image: null, created: now, updated: now })
|
|
173
|
+
.onConflictDoNothing();
|
|
174
|
+
}
|
|
175
|
+
const [updated] = await tx.update(kempoInventoryField).set(updates)
|
|
176
|
+
.where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category))).returning();
|
|
177
|
+
return updated;
|
|
178
|
+
});
|
|
154
179
|
await notify(EVENTS.fieldUpdated, { field, previous: existing, actor: owner ?? '' });
|
|
155
180
|
return [null, field];
|
|
156
181
|
} catch {
|