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). The item form shows the default fields plus those of the typed category, and swaps them as the category changes.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant <dustinpoissant@gmail.com>",
@@ -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=${editing}>
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');
@@ -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, and the type
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
- Items in a category hold the default fields and that category's under one set of names, so a
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 !== 'category' && !(k === 'type' && targetType === existing.type) && !allowed.includes(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 [field] = await db.update(kempoInventoryField).set(updates)
153
- .where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category))).returning();
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 {