@kernhq/module-inventory 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +7 -5
  2. package/dist/contract/models.d.ts +24 -1
  3. package/dist/contract/models.d.ts.map +1 -1
  4. package/dist/contract/models.js +35 -3
  5. package/dist/contract/models.js.map +1 -1
  6. package/dist/contract/router.d.ts +58 -2
  7. package/dist/contract/router.d.ts.map +1 -1
  8. package/dist/contract/router.js +29 -1
  9. package/dist/contract/router.js.map +1 -1
  10. package/dist/server/router.d.ts +57 -2
  11. package/dist/server/router.d.ts.map +1 -1
  12. package/dist/server/router.js +23 -1
  13. package/dist/server/router.js.map +1 -1
  14. package/dist/server/schema.d.ts.map +1 -1
  15. package/dist/server/schema.js +11 -0
  16. package/dist/server/schema.js.map +1 -1
  17. package/dist/server/services/categories.d.ts +110 -10
  18. package/dist/server/services/categories.d.ts.map +1 -1
  19. package/dist/server/services/categories.js +198 -13
  20. package/dist/server/services/categories.js.map +1 -1
  21. package/migrations/0008_category_order_unique.sql +71 -0
  22. package/migrations/meta/_journal.json +7 -0
  23. package/package.json +2 -1
  24. package/src/client/errors.test.ts +30 -0
  25. package/src/client/errors.ts +31 -3
  26. package/src/client/messages.ts +82 -19
  27. package/src/client/mock.test.ts +71 -1
  28. package/src/client/mock.ts +51 -12
  29. package/src/client/module.ts +19 -1
  30. package/src/client/reorder.test.ts +100 -0
  31. package/src/client/reorder.ts +79 -0
  32. package/src/client/sequence.test.ts +248 -0
  33. package/src/client/sequence.ts +185 -0
  34. package/src/client/settings/CategoriesSettings.svelte +430 -105
  35. package/src/contract/models.ts +36 -3
  36. package/src/contract/router.ts +29 -0
  37. package/src/module.test.ts +23 -0
  38. package/src/server/inventory.int.test.ts +545 -10
  39. package/src/server/migrations.test.ts +140 -2
  40. package/src/server/router.ts +25 -1
  41. package/src/server/schema.ts +11 -0
  42. package/src/server/services/categories.ts +221 -20
@@ -25,8 +25,14 @@
25
25
  *
26
26
  * Pure and string-free: this file decides *which* key, `i18n.ts` holds the words, and a `.svelte`
27
27
  * file cannot be unit-tested. `errorMessage()` at the bottom is the one function the screens call.
28
+ *
29
+ * The one import is a number from the contract — the same file the server reads it from — because a
30
+ * limit a sentence refuses to name is a limit nobody can plan around. It brings no runtime with it;
31
+ * what this file still must never reach for is `i18n.ts`, which drags a Svelte compiler in behind it.
28
32
  */
29
33
 
34
+ import { MAX_LIVE_CATEGORIES } from '../contract/models.js'
35
+
30
36
  /** What oRPC hands a screen. `code` is the contract's `ErrorCode`; `data` is what the server put in it. */
31
37
  export interface ServerError {
32
38
  code?: unknown
@@ -121,10 +127,24 @@ const REASON_KEYS: Record<string, string> = {
121
127
  'inventory.repair.already_complete': 'error_repair_already_complete',
122
128
  'inventory.repair.returned_before_sent': 'error_repair_returned_before_sent',
123
129
  'inventory.category.name_taken': 'error_category_name_taken',
130
+ 'inventory.category.order_stale': 'error_category_order_stale',
131
+ 'inventory.category.limit_reached': 'error_category_limit_reached',
124
132
  }
125
133
 
126
134
  export const reasonKeys = (): readonly string[] => Object.keys(REASON_KEYS)
127
135
 
136
+ /**
137
+ * The one refusal whose sentence has a number in it, and where that number comes from.
138
+ *
139
+ * A limit stated as "quite a lot" is not stated. `KernError.conflict` carries a reason and no data,
140
+ * so the server cannot hand the figure over — and it does not need to: it is a constant in the
141
+ * contract, which is the same file both halves read. Passed through `t`, it goes through
142
+ * `Intl.NumberFormat`, so a Persian reader is shown ۵۰۰ rather than 500.
143
+ */
144
+ const REASON_VALUES: Record<string, Record<string, string | number>> = {
145
+ 'inventory.category.limit_reached': { max: MAX_LIVE_CATEGORIES },
146
+ }
147
+
128
148
  /**
129
149
  * The sentence a whole class of failure earns, when no reason narrows it further.
130
150
  *
@@ -162,12 +182,17 @@ export interface ErrorLine {
162
182
  key: string
163
183
  /** The server's own sentence, for a failure nothing here recognised. Null otherwise. */
164
184
  detail: string | null
185
+ /** What the sentence's placeholders need. Absent for every key that has none. */
186
+ values?: Record<string, string | number>
165
187
  }
166
188
 
167
189
  export function errorLine(err: unknown): ErrorLine {
168
190
  const reason = reasonOf(err)
169
191
  const byReason = reason ? REASON_KEYS[reason] : undefined
170
- if (byReason) return { key: byReason, detail: null }
192
+ if (byReason) {
193
+ const values = reason ? REASON_VALUES[reason] : undefined
194
+ return values ? { key: byReason, detail: null, values } : { key: byReason, detail: null }
195
+ }
171
196
 
172
197
  const code = codeOf(err)
173
198
 
@@ -194,8 +219,11 @@ export function errorLine(err: unknown): ErrorLine {
194
219
  * `@kernhq/ui`, and that entry point drags a Svelte compiler into whatever imports it, which is
195
220
  * what makes a helper untestable.
196
221
  */
197
- export function errorMessage(err: unknown, translate: (key: string) => string): string {
222
+ export function errorMessage(
223
+ err: unknown,
224
+ translate: (key: string, values?: Record<string, string | number>) => string,
225
+ ): string {
198
226
  const line = errorLine(err)
199
- const sentence = translate(line.key)
227
+ const sentence = translate(line.key, line.values)
200
228
  return line.detail ? `${sentence} — ${line.detail}` : sentence
201
229
  }
@@ -128,13 +128,22 @@ export const en: Record<string, Message> = {
128
128
  'inventory.category_new': 'New category',
129
129
  'inventory.category_edit': 'Edit category',
130
130
  'inventory.category_name_placeholder': 'Laptops, Furniture, Cameras…',
131
- 'inventory.category_order': 'Position',
132
- 'inventory.category_order_hint':
133
- 'Lower comes first. Categories sharing a position fall back to their names.',
134
- // A hint and an error are not the same sentence. This field passed the hint as both, so typing
135
- // "x" restated how positions sort — in red, under a field whose problem was that it held no
136
- // number. An error says what is wrong with what is there.
137
- 'inventory.category_order_invalid': 'Use a whole number from 0 to 9999',
131
+ // This was a **Position** field: a number box, and a hint explaining that lower comes first and
132
+ // that two categories sharing a number fall back to their names. That is a database column with a
133
+ // form around it. Nobody arranges their filing by integer, and the form invited the one state it
134
+ // then had to explain. The order is dragged now, so the words are about the gesture and about
135
+ // the buttons beside it, because a drag is unreachable by keyboard.
136
+ 'inventory.category_reorder_hint':
137
+ 'Drag a category, or use the arrows on its row, to change the order they appear in.',
138
+ 'inventory.category_move_up': 'Move {name} up',
139
+ 'inventory.category_move_down': 'Move {name} down',
140
+ // What a screen reader is told after a move — "after Cameras", never "position 3 of 9". A
141
+ // neighbour's name is the thing that says where something is; a number is two more facts to hold
142
+ // in your head to work out the same answer. Worded as where it *is* rather than what just
143
+ // happened, so pressing the button on a row that cannot move any further is still true.
144
+ 'inventory.category_position_first': '{name} is first',
145
+ 'inventory.category_position_last': '{name} is last',
146
+ 'inventory.category_position_after': '{name} is after {other}',
138
147
  'inventory.category_created_toast': '{name} added',
139
148
  'inventory.category_updated_toast': '{name} saved',
140
149
  'inventory.category_archived_toast': '{name} archived',
@@ -312,6 +321,27 @@ export const en: Record<string, Message> = {
312
321
  'inventory.error_repair_returned_before_sent':
313
322
  'A repair cannot come back before it was sent. Check the two dates.',
314
323
  'inventory.error_category_name_taken': 'This workspace already has a category with that name.',
324
+ // The refusal a reorder earns when somebody added, archived or restored a category in another tab
325
+ // while this page was open. The list in hand no longer describes the workspace, so the server
326
+ // refuses the whole thing rather than renumbering what it was given and dropping the rest
327
+ // somewhere nobody chose.
328
+ //
329
+ // Three facts, in the order somebody needs them: what happened, that **this arrangement was not
330
+ // saved**, and that the list on screen is the current one. The middle one used to be missing, and
331
+ // the sentence went straight from "somebody changed the categories" to "put them in order again"
332
+ // — which reads as though the drag had landed and only needed repeating. It also said the list had
333
+ // been refreshed while the screen was still holding the very list the server had refused, so
334
+ // repeating the drag earned the same refusal for ever. `reseed` is what makes the last clause true.
335
+ 'inventory.error_category_order_stale':
336
+ 'Somebody else changed the categories while this page was open, so this order was not saved. The list below has been refreshed — arrange it again.',
337
+ // A stated ceiling rather than a silent one. `categories.reorder` is handed every live category at
338
+ // once, so its input array has a bound; leaving the bound only there would let a workspace grow
339
+ // past it one category at a time and then find the only procedure that can order them refusing to
340
+ // run. The number is `MAX_LIVE_CATEGORIES` from the contract, passed through `t` so it is written
341
+ // in the reader's own digits — and archiving really does make room, because the limit counts the
342
+ // live rows.
343
+ 'inventory.error_category_limit_reached':
344
+ 'A workspace can keep {max} categories at once, and this one has them all. Archive one it no longer uses to make room.',
315
345
  // The classes of failure, for a refusal carrying no reason of its own. A capability switched off
316
346
  // answers 404 rather than 403 — that is the module contract — so it lands on `not_found`, which
317
347
  // is the right sentence for it: the surface is not there any more, look again.
@@ -437,9 +467,14 @@ export const fa: Record<string, Message> = {
437
467
  'inventory.category_new': 'دستهٔ جدید',
438
468
  'inventory.category_edit': 'ویرایش دسته‌بندی',
439
469
  'inventory.category_name_placeholder': 'لپ‌تاپ، مبلمان، دوربین…',
440
- 'inventory.category_order': 'ترتیب',
441
- 'inventory.category_order_hint': 'عدد کمتر جلوتر می‌آید. دسته‌هایی با ترتیب یکسان بر اساس نام مرتب می‌شوند.',
442
- 'inventory.category_order_invalid': 'عددی درست از ۰ تا ۹۹۹۹ وارد کنید',
470
+ // فارسی جمله را با هدف آغاز می‌کند، نه با کنش — برعکس انگلیسی.
471
+ 'inventory.category_reorder_hint':
472
+ 'برای تغییر ترتیب نمایش، دسته‌بندی را بکشید یا از پیکان‌های همان ردیف استفاده کنید.',
473
+ 'inventory.category_move_up': 'انتقال {name} به بالا',
474
+ 'inventory.category_move_down': 'انتقال {name} به پایین',
475
+ 'inventory.category_position_first': '{name} در ابتدای فهرست است',
476
+ 'inventory.category_position_last': '{name} در انتهای فهرست است',
477
+ 'inventory.category_position_after': '{name} پس از {other} است',
443
478
  'inventory.category_created_toast': '{name} افزوده شد',
444
479
  'inventory.category_updated_toast': '{name} ذخیره شد',
445
480
  'inventory.category_archived_toast': '{name} بایگانی شد',
@@ -584,6 +619,11 @@ export const fa: Record<string, Message> = {
584
619
  'inventory.error_repair_returned_before_sent':
585
620
  'تعمیر نمی‌تواند پیش از فرستاده‌شدن بازگردد. دو تاریخ را بررسی کنید.',
586
621
  'inventory.error_category_name_taken': 'این فضای کاری از پیش دسته‌بندی‌ای با این نام دارد.',
622
+ // فارسی جمله را با زمان آغاز می‌کند، نه با فاعل — برعکس انگلیسی.
623
+ 'inventory.error_category_order_stale':
624
+ 'هنگامی که این صفحه باز بود، کس دیگری دسته‌بندی‌ها را تغییر داد؛ بنابراین این ترتیب ذخیره نشد. فهرست زیر تازه شده است — دوباره مرتبش کنید.',
625
+ 'inventory.error_category_limit_reached':
626
+ 'هر فضای کاری هم‌زمان می‌تواند {max} دسته‌بندی داشته باشد و این فضا همه را دارد. برای باز شدن جا، دسته‌بندی‌ای را که دیگر به کار نمی‌آید بایگانی کنید.',
587
627
  'inventory.error_not_found':
588
628
  'آن دیگر آنجا نیست. شاید کسی حذف یا بایگانی‌اش کرده باشد — صفحه را دوباره بارگذاری کنید.',
589
629
  'inventory.error_forbidden': 'اجازهٔ این کار را ندارید.',
@@ -703,9 +743,12 @@ export const ar: Record<string, Message> = {
703
743
  'inventory.category_new': 'فئة جديدة',
704
744
  'inventory.category_edit': 'تعديل الفئة',
705
745
  'inventory.category_name_placeholder': 'حواسيب، أثاث، كاميرات…',
706
- 'inventory.category_order': 'الترتيب',
707
- 'inventory.category_order_hint': 'الأصغر يأتي أولًا. الفئات المتساوية تُرتَّب بأسمائها.',
708
- 'inventory.category_order_invalid': 'أدخل عددًا صحيحًا من ٠ إلى ٩٩٩٩',
746
+ 'inventory.category_reorder_hint': 'اسحب فئة أو استخدم السهمين في صفّها لتغيير ترتيب ظهورها.',
747
+ 'inventory.category_move_up': 'نقل {name} إلى الأعلى',
748
+ 'inventory.category_move_down': 'نقل {name} إلى الأسفل',
749
+ 'inventory.category_position_first': '{name} في أول القائمة',
750
+ 'inventory.category_position_last': '{name} في آخر القائمة',
751
+ 'inventory.category_position_after': '{name} بعد {other}',
709
752
  'inventory.category_created_toast': 'تمت إضافة {name}',
710
753
  'inventory.category_updated_toast': 'تم حفظ {name}',
711
754
  'inventory.category_archived_toast': 'تمت أرشفة {name}',
@@ -851,6 +894,10 @@ export const ar: Record<string, Message> = {
851
894
  'inventory.error_repair_already_complete': 'سُجّل هذا الإصلاح منتهيًا بالفعل.',
852
895
  'inventory.error_repair_returned_before_sent': 'لا يمكن أن يعود الإصلاح قبل إرساله. راجع التاريخين.',
853
896
  'inventory.error_category_name_taken': 'في مساحة العمل هذه فئة بهذا الاسم بالفعل.',
897
+ 'inventory.error_category_order_stale':
898
+ 'غيّر شخص آخر الفئات بينما كانت هذه الصفحة مفتوحة، فلم يُحفَظ هذا الترتيب. القائمة أدناه محدَّثة — أعد ترتيبها.',
899
+ 'inventory.error_category_limit_reached':
900
+ 'يمكن لمساحة العمل أن تضم {max} فئة في وقت واحد، وهذه المساحة بلغت العدد. أرشِف فئة لم تعد تُستعمَل لتوفير مكان.',
854
901
  'inventory.error_not_found': 'لم يعد ذلك موجودًا. ربما أزاله أحدهم أو أرشفه — أعد تحميل الصفحة لترى.',
855
902
  'inventory.error_forbidden': 'لا تملك صلاحية القيام بذلك.',
856
903
  'inventory.error_module_disabled': 'وحدة الأصول مُطفأة في مساحة العمل هذه.',
@@ -963,9 +1010,13 @@ export const de: Record<string, Message> = {
963
1010
  'inventory.category_new': 'Neue Kategorie',
964
1011
  'inventory.category_edit': 'Kategorie bearbeiten',
965
1012
  'inventory.category_name_placeholder': 'Laptops, Möbel, Kameras…',
966
- 'inventory.category_order': 'Position',
967
- 'inventory.category_order_hint': 'Kleinere Zahlen stehen vorn. Bei gleicher Position entscheidet der Name.',
968
- 'inventory.category_order_invalid': 'Ganze Zahl von 0 bis 9999 eingeben',
1013
+ 'inventory.category_reorder_hint':
1014
+ 'Ziehen Sie eine Kategorie oder nutzen Sie die Pfeile in ihrer Zeile, um die Reihenfolge zu ändern.',
1015
+ 'inventory.category_move_up': '{name} nach oben schieben',
1016
+ 'inventory.category_move_down': '{name} nach unten schieben',
1017
+ 'inventory.category_position_first': '{name} steht an erster Stelle',
1018
+ 'inventory.category_position_last': '{name} steht an letzter Stelle',
1019
+ 'inventory.category_position_after': '{name} steht hinter {other}',
969
1020
  'inventory.category_created_toast': '{name} hinzugefügt',
970
1021
  'inventory.category_updated_toast': '{name} gespeichert',
971
1022
  'inventory.category_archived_toast': '{name} archiviert',
@@ -1124,6 +1175,10 @@ export const de: Record<string, Message> = {
1124
1175
  'Eine Reparatur kann nicht vor der Abgabe zurückkommen. Prüfen Sie die beiden Daten.',
1125
1176
  'inventory.error_category_name_taken':
1126
1177
  'In diesem Workspace gibt es bereits eine Kategorie mit diesem Namen.',
1178
+ 'inventory.error_category_order_stale':
1179
+ 'Jemand anderes hat die Kategorien geändert, während diese Seite offen war — diese Reihenfolge wurde nicht gespeichert. Die Liste unten ist wieder aktuell; ordnen Sie sie erneut.',
1180
+ 'inventory.error_category_limit_reached':
1181
+ 'Ein Workspace kann {max} Kategorien gleichzeitig führen, und dieser hat sie alle. Archivieren Sie eine, die nicht mehr gebraucht wird, um Platz zu schaffen.',
1127
1182
  'inventory.error_not_found':
1128
1183
  'Das ist nicht mehr da. Vielleicht hat es jemand entfernt oder archiviert — laden Sie die Seite neu.',
1129
1184
  'inventory.error_forbidden': 'Dazu sind Sie nicht berechtigt.',
@@ -1246,9 +1301,13 @@ export const tr: Record<string, Message> = {
1246
1301
  'inventory.category_new': 'Yeni kategori',
1247
1302
  'inventory.category_edit': 'Kategoriyi düzenle',
1248
1303
  'inventory.category_name_placeholder': 'Dizüstüler, Mobilya, Kameralar…',
1249
- 'inventory.category_order': 'Sıra',
1250
- 'inventory.category_order_hint': 'Küçük sayı önce gelir. Sırası aynı olan kategoriler ada göre sıralanır.',
1251
- 'inventory.category_order_invalid': '0 ile 9999 arasında bir tam sayı girin',
1304
+ 'inventory.category_reorder_hint':
1305
+ 'Görünme sırasını değiştirmek için bir kategoriyi sürükleyin ya da satırındaki okları kullanın.',
1306
+ 'inventory.category_move_up': '{name} kategorisini yukarı taşı',
1307
+ 'inventory.category_move_down': '{name} kategorisini aşağı taşı',
1308
+ 'inventory.category_position_first': '{name} ilk sırada',
1309
+ 'inventory.category_position_last': '{name} son sırada',
1310
+ 'inventory.category_position_after': '{name}, {other} kategorisinden sonra',
1252
1311
  'inventory.category_created_toast': '{name} eklendi',
1253
1312
  'inventory.category_updated_toast': '{name} kaydedildi',
1254
1313
  'inventory.category_archived_toast': '{name} arşivlendi',
@@ -1397,6 +1456,10 @@ export const tr: Record<string, Message> = {
1397
1456
  'inventory.error_repair_returned_before_sent':
1398
1457
  'Bir onarım gönderilmeden önce dönemez. İki tarihi kontrol edin.',
1399
1458
  'inventory.error_category_name_taken': 'Bu çalışma alanında bu adda bir kategori zaten var.',
1459
+ 'inventory.error_category_order_stale':
1460
+ 'Bu sayfa açıkken başkası kategorileri değiştirdi, bu yüzden bu sıralama kaydedilmedi. Aşağıdaki liste yenilendi — sırayı yeniden verin.',
1461
+ 'inventory.error_category_limit_reached':
1462
+ 'Bir çalışma alanı aynı anda {max} kategori tutabilir ve bu alanda hepsi dolu. Yer açmak için artık kullanılmayan bir kategoriyi arşivleyin.',
1400
1463
  'inventory.error_not_found':
1401
1464
  'O artık orada değil. Biri kaldırmış ya da arşivlemiş olabilir — sayfayı yeniden yükleyin.',
1402
1465
  'inventory.error_forbidden': 'Bunu yapma yetkiniz yok.',
@@ -235,7 +235,7 @@ describe('mock categories', () => {
235
235
 
236
236
  it('creates, renames and archives without ever deleting', async () => {
237
237
  const api = createMockInventoryApi()
238
- const made = await api.categories.create({ workspaceId: WS, name: 'Tools', order: 9 })
238
+ const made = await api.categories.create({ workspaceId: WS, name: 'Tools' })
239
239
  expect(made.name).toBe('Tools')
240
240
  const renamed = await api.categories.update({ workspaceId: WS, categoryId: made.id, name: 'Hand tools' })
241
241
  expect(renamed.name).toBe('Hand tools')
@@ -245,6 +245,76 @@ describe('mock categories', () => {
245
245
  await api.categories.archive({ workspaceId: WS, categoryId: made.id, archived: false })
246
246
  expect((await api.categories.list({ workspaceId: WS })).map((c) => c.id)).toContain(made.id)
247
247
  })
248
+
249
+ /**
250
+ * A new category joins the **end**, and so does a restored one.
251
+ *
252
+ * The demo has to agree with the server about this or the settings page contradicts itself in
253
+ * exactly the environment the product is shown in: a category added while somebody watches
254
+ * appearing in the middle of a list they just arranged is the defect the *Position* field caused.
255
+ */
256
+ it('appends a new category, and a restored one, rather than dropping it into the middle', async () => {
257
+ const api = createMockInventoryApi()
258
+ const made = await api.categories.create({ workspaceId: WS, name: 'Tools' })
259
+ expect((await api.categories.list({ workspaceId: WS })).map((c) => c.name)).toEqual([
260
+ 'Laptops',
261
+ 'Displays',
262
+ 'Furniture',
263
+ 'Cameras',
264
+ 'Tools',
265
+ ])
266
+
267
+ const all = await api.categories.list({ workspaceId: WS, archived: true })
268
+ const phones = all.find((c) => c.name === 'Phones')
269
+ await api.categories.archive({ workspaceId: WS, categoryId: phones?.id ?? '', archived: false })
270
+ const live = await api.categories.list({ workspaceId: WS })
271
+ expect(live.at(-1)?.name, 'restored at the end, not back on its old number').toBe('Phones')
272
+ expect(new Set(live.map((c) => c.order)).size, 'and no two live categories share a place').toBe(
273
+ live.length,
274
+ )
275
+ expect(made.name).toBe('Tools')
276
+ })
277
+
278
+ /**
279
+ * Reordering, including both refusals — a demo that can only show the happy path cannot exercise
280
+ * the settings page's rollback, which is the half of the feature nobody sees until it matters.
281
+ */
282
+ it('reorders the sequence, and refuses a list that no longer describes the workspace', async () => {
283
+ const api = createMockInventoryApi()
284
+ const live = await api.categories.list({ workspaceId: WS })
285
+ const ids = live.map((c) => c.id)
286
+
287
+ const moved = await api.categories.reorder({
288
+ workspaceId: WS,
289
+ categoryIds: [ids[3] as string, ...ids.slice(0, 3)],
290
+ })
291
+ expect(moved.map((c) => c.name)).toEqual(['Cameras', 'Laptops', 'Displays', 'Furniture'])
292
+ expect((await api.categories.list({ workspaceId: WS })).map((c) => c.name)).toEqual([
293
+ 'Cameras',
294
+ 'Laptops',
295
+ 'Displays',
296
+ 'Furniture',
297
+ ])
298
+
299
+ await expect(
300
+ api.categories.reorder({ workspaceId: WS, categoryIds: ids.slice(0, 2) }),
301
+ ).rejects.toMatchObject({ code: 'CONFLICT', data: { reason: 'inventory.category.order_stale' } })
302
+
303
+ await expect(
304
+ api.categories.reorder({
305
+ workspaceId: WS,
306
+ categoryIds: [...ids, '01920000-0000-7000-8000-0000000000ff'],
307
+ }),
308
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' })
309
+
310
+ // Neither refusal wrote anything.
311
+ expect((await api.categories.list({ workspaceId: WS })).map((c) => c.name)).toEqual([
312
+ 'Cameras',
313
+ 'Laptops',
314
+ 'Displays',
315
+ 'Furniture',
316
+ ])
317
+ })
248
318
  })
249
319
 
250
320
  /**
@@ -545,6 +545,14 @@ export function createMockInventoryApi(options: MockInventoryOptions = {}) {
545
545
  return category
546
546
  }
547
547
 
548
+ /**
549
+ * One past the highest position in use, archived rows counted — the server's `appended()`.
550
+ *
551
+ * Archived rows count because one of them can be restored, and a restored category landing on a
552
+ * live one's number is the tie that made a "Position" field a bad idea in the first place.
553
+ */
554
+ const appendedOrder = () => categories.reduce((max, c) => Math.max(max, c.order), -1) + 1
555
+
548
556
  const openPeriodFor = (id: string) =>
549
557
  periods.find((period) => period.assetId === id && period.effectiveTo === null)
550
558
 
@@ -766,15 +774,7 @@ export function createMockInventoryApi(options: MockInventoryOptions = {}) {
766
774
  .map(stamp)
767
775
  },
768
776
 
769
- create: async ({
770
- workspaceId,
771
- name,
772
- order = 0,
773
- }: {
774
- workspaceId: string
775
- name: string
776
- order?: number
777
- }) => {
777
+ create: async ({ workspaceId, name }: { workspaceId: string; name: string }) => {
778
778
  remember(workspaceId)
779
779
  // The server's unique index is what actually decides, and it answers a duplicate with a
780
780
  // sentence rather than a 500. The demo answers the same way.
@@ -789,7 +789,9 @@ export function createMockInventoryApi(options: MockInventoryOptions = {}) {
789
789
  id: categoryId(),
790
790
  workspaceId: '' as Asset['workspaceId'],
791
791
  name,
792
- order,
792
+ // Appended, like the server: a new category joins the end of the sequence rather than
793
+ // landing at the front tied with whatever is already there.
794
+ order: appendedOrder(),
793
795
  createdAt: now,
794
796
  updatedAt: now,
795
797
  archivedAt: null,
@@ -806,7 +808,6 @@ export function createMockInventoryApi(options: MockInventoryOptions = {}) {
806
808
  workspaceId?: string
807
809
  categoryId: string
808
810
  name?: string
809
- order?: number
810
811
  }) => {
811
812
  remember(workspaceId)
812
813
  const category = findCategory(id)
@@ -832,10 +833,48 @@ export function createMockInventoryApi(options: MockInventoryOptions = {}) {
832
833
  remember(workspaceId)
833
834
  const category = findCategory(id)
834
835
  // Nothing deletes: an asset filed under this category keeps naming it.
835
- category.archivedAt = archived === false ? null : new Date().toISOString()
836
+ const restoring = archived === false
837
+ category.archivedAt = restoring ? null : new Date().toISOString()
838
+ // A restore appends, like the server: the position it left with belongs to somebody else by
839
+ // now, and the end of the list is the one place a person can find it again.
840
+ if (restoring) category.order = appendedOrder()
836
841
  category.updatedAt = new Date().toISOString()
837
842
  return stamp(category)
838
843
  },
844
+
845
+ /**
846
+ * The sequence, rewritten from the ids — and the two refusals the server makes, because a
847
+ * demo that cannot reproduce them is a demo of the happy path.
848
+ *
849
+ * An id this workspace does not have is `NOT_FOUND`; a list that does not name every live
850
+ * category exactly once is the stale conflict, reason and all, so the settings page's rollback
851
+ * can be exercised without a server.
852
+ */
853
+ reorder: async ({ workspaceId, categoryIds }: { workspaceId?: string; categoryIds: string[] }) => {
854
+ remember(workspaceId)
855
+ const named = new Set(categoryIds)
856
+ if (named.size !== categoryIds.length)
857
+ throw new MockApiError('BAD_REQUEST', 'That list of categories names the same one more than once.')
858
+ for (const id of categoryIds) findCategory(id)
859
+ const live = categories.filter((category) => !category.archivedAt)
860
+ if (
861
+ live.some((category) => !named.has(category.id)) ||
862
+ categoryIds.some((id) => findCategory(id).archivedAt)
863
+ )
864
+ throw new MockApiError(
865
+ 'CONFLICT',
866
+ 'The categories changed while this list was open, so this order was not saved. Reload the list and arrange it again.',
867
+ 'inventory.category.order_stale',
868
+ )
869
+ const now = new Date().toISOString()
870
+ for (const [index, id] of categoryIds.entries()) {
871
+ const category = findCategory(id)
872
+ if (category.order === index) continue
873
+ category.order = index
874
+ category.updatedAt = now
875
+ }
876
+ return categoryIds.map((id) => stamp(findCategory(id)))
877
+ },
839
878
  },
840
879
 
841
880
  /**
@@ -15,7 +15,25 @@ import { INVENTORY_PERMISSIONS } from './permissions.js'
15
15
  */
16
16
  export const inventoryClientModule = defineClientModule({
17
17
  id: 'inventory',
18
- name: 'Inventory',
18
+ /**
19
+ * A getter, for the reason every label below is one — and now that this field is rendered too, the
20
+ * reason reaches it.
21
+ *
22
+ * `name` was the last string in this file left as an English literal, on the grounds that a
23
+ * manifest name is data an operator greps rather than a string a reader sees. That stopped being
24
+ * true: the dashboard's widget picker heads this module's group with `mod.name` directly, so a
25
+ * Persian reader was shown "Inventory" as a section label in an otherwise Persian panel, and the
26
+ * shell's settings rail falls back to it for any module whose navigation it cannot read.
27
+ *
28
+ * `name` is typed as a plain `string` on `ClientModule`, and a getter satisfies that exactly as
29
+ * `get label()` does for a nav item. Nothing snapshots it: `defineClientModule` returns the object
30
+ * unchanged, `registerModule` pushes that same object into an array, and every reader goes through
31
+ * the property — so the language it resolves in is the one on screen rather than the one that
32
+ * happened to be loaded at import time.
33
+ */
34
+ get name() {
35
+ return t('nav')
36
+ },
19
37
  icon: 'briefcase',
20
38
  messages: inventoryMessageBundles,
21
39
 
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { moveBy, placementOf, sameOrder } from './reorder.js'
3
+
4
+ /**
5
+ * The arithmetic behind dragging a category, and behind the two buttons that do the same thing
6
+ * without a pointer.
7
+ *
8
+ * The settings page is a `.svelte` file and cannot be unit-tested here, which is exactly why this
9
+ * arithmetic does not live in it. Every case below is one somebody will actually reach: the first
10
+ * row cannot go up, the last cannot go down, a drag that ends where it started must cost nothing,
11
+ * and a row that was archived in another tab is not in the list any more.
12
+ */
13
+ const list = (...ids: string[]) => ids.map((id) => ({ id, name: id.toUpperCase() }))
14
+ const ids = (items: readonly { id: string }[]) => items.map((item) => item.id)
15
+
16
+ describe('moving a row', () => {
17
+ it('moves one up', () => {
18
+ expect(ids(moveBy(list('a', 'b', 'c'), 'c', -1))).toEqual(['a', 'c', 'b'])
19
+ })
20
+
21
+ it('moves one down', () => {
22
+ expect(ids(moveBy(list('a', 'b', 'c'), 'a', 1))).toEqual(['b', 'a', 'c'])
23
+ })
24
+
25
+ it('moves one to the top and to the bottom', () => {
26
+ expect(ids(moveBy(list('a', 'b', 'c', 'd'), 'd', -3))).toEqual(['d', 'a', 'b', 'c'])
27
+ expect(ids(moveBy(list('a', 'b', 'c', 'd'), 'a', 3))).toEqual(['b', 'c', 'd', 'a'])
28
+ })
29
+
30
+ /**
31
+ * Clamped rather than refused. *Move up* on the row that is already first is a button somebody
32
+ * will press, and it is not a mistake — the answer is that it is already first.
33
+ */
34
+ it('clamps a move that would fall off either end', () => {
35
+ expect(ids(moveBy(list('a', 'b', 'c'), 'c', -99))).toEqual(['c', 'a', 'b'])
36
+ expect(ids(moveBy(list('a', 'b', 'c'), 'a', 99))).toEqual(['b', 'c', 'a'])
37
+ })
38
+
39
+ /**
40
+ * The same array back, by reference — the caller reads that as "nothing to send". An equal copy
41
+ * would pass every assertion above and post a reorder every time somebody pressed the button on
42
+ * the row that cannot move.
43
+ */
44
+ it('gives back the very same list when nothing moves', () => {
45
+ const items = list('a', 'b', 'c')
46
+ expect(moveBy(items, 'a', -1)).toBe(items)
47
+ expect(moveBy(items, 'c', 1)).toBe(items)
48
+ expect(moveBy(items, 'b', 0)).toBe(items)
49
+ expect(moveBy(items, 'nobody', -1)).toBe(items)
50
+ expect(moveBy([], 'a', 1)).toEqual([])
51
+ })
52
+
53
+ it('leaves the list it was given alone', () => {
54
+ const items = list('a', 'b', 'c')
55
+ moveBy(items, 'a', 2)
56
+ expect(ids(items)).toEqual(['a', 'b', 'c'])
57
+ })
58
+
59
+ it('moves the only row nowhere', () => {
60
+ const items = list('a')
61
+ expect(moveBy(items, 'a', -1)).toBe(items)
62
+ expect(moveBy(items, 'a', 1)).toBe(items)
63
+ })
64
+ })
65
+
66
+ describe('saying where a row landed', () => {
67
+ it('names the row it now follows, rather than a position', () => {
68
+ expect(placementOf(list('a', 'b', 'c'), 'b')).toEqual({ at: 'after', previous: { id: 'a', name: 'A' } })
69
+ })
70
+
71
+ it('calls the ends the ends', () => {
72
+ expect(placementOf(list('a', 'b', 'c'), 'a')).toEqual({ at: 'first' })
73
+ expect(placementOf(list('a', 'b', 'c'), 'c')).toEqual({ at: 'last' })
74
+ })
75
+
76
+ it('calls the only row first, which is true and reads better than last', () => {
77
+ expect(placementOf(list('a'), 'a')).toEqual({ at: 'first' })
78
+ })
79
+
80
+ it('says a row that is no longer in the list is gone, rather than guessing', () => {
81
+ // Somebody archived it in another tab while this page was open.
82
+ expect(placementOf(list('a', 'b'), 'c')).toEqual({ at: 'gone' })
83
+ })
84
+ })
85
+
86
+ describe('whether anything actually changed', () => {
87
+ it('sees a drag that ended where it started', () => {
88
+ expect(sameOrder(list('a', 'b', 'c'), list('a', 'b', 'c'))).toBe(true)
89
+ })
90
+
91
+ it('sees a real move, a longer list and a different membership', () => {
92
+ expect(sameOrder(list('a', 'b', 'c'), list('a', 'c', 'b'))).toBe(false)
93
+ expect(sameOrder(list('a', 'b'), list('a', 'b', 'c'))).toBe(false)
94
+ expect(sameOrder(list('a', 'b'), list('a', 'z'))).toBe(false)
95
+ })
96
+
97
+ it('calls two empty lists the same', () => {
98
+ expect(sameOrder([], [])).toBe(true)
99
+ })
100
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Moving one row up or down a list, and saying where it landed.
3
+ *
4
+ * Pure arithmetic, in its own file, for the reason `custody.ts` and `price.ts` are: a `.svelte` file
5
+ * cannot be unit-tested here, so anything inside one is only ever checked by reading it. The ends of
6
+ * a list are exactly where an off-by-one lives — moving the first row up, moving the last row down,
7
+ * dragging something to a position it already occupies — and those are three assertions rather than
8
+ * three careful reads.
9
+ *
10
+ * **A no-op returns the array it was given, by reference.** That is the signal the caller acts on:
11
+ * nothing moved, so there is nothing to send and nothing to announce as a move. Returning an equal
12
+ * copy would look identical in a test and cost a request every time somebody pressed *move up* on
13
+ * the row that is already first.
14
+ */
15
+
16
+ /**
17
+ * The one property `svelte-dnd-action` reads, and therefore the one this file needs.
18
+ *
19
+ * The library tracks items by `id` and by nothing else — a list keyed by anything else renders
20
+ * perfectly and refuses to move, by mouse and by keyboard, with no error at all. A `Category` has
21
+ * an `id` already, which is the only reason the settings page hands its rows over unwrapped.
22
+ */
23
+ export interface Ordered {
24
+ id: string
25
+ }
26
+
27
+ /**
28
+ * The list with `id` moved `delta` places, or the same list when that would change nothing.
29
+ *
30
+ * `delta` is clamped rather than refused: *move up* on the first row is a thing somebody will press,
31
+ * and the honest answer is "it is already first", not an error. The input is never mutated.
32
+ */
33
+ export function moveBy<T extends Ordered>(items: readonly T[], id: string, delta: number): readonly T[] {
34
+ const from = items.findIndex((item) => item.id === id)
35
+ if (from === -1) return items
36
+ const to = Math.min(items.length - 1, Math.max(0, from + delta))
37
+ if (to === from) return items
38
+ const next = [...items]
39
+ const [moved] = next.splice(from, 1)
40
+ next.splice(to, 0, moved as T)
41
+ return next
42
+ }
43
+
44
+ /** Where a row sits now, in the terms a sentence can use — never as a number. */
45
+ export type Placement<T> =
46
+ | { at: 'first' }
47
+ | { at: 'last' }
48
+ /** Somewhere in the middle: the row it now follows is what identifies the spot. */
49
+ | { at: 'after'; previous: T }
50
+ /** It is not in this list at all — it was archived or removed while the page was open. */
51
+ | { at: 'gone' }
52
+
53
+ /**
54
+ * Where `id` ended up, for the sentence a screen reader is given.
55
+ *
56
+ * **"first", "last" or "after {name}" rather than a position number.** A number is the thing this
57
+ * whole screen stopped showing: nobody arranges their categories by index, and "moved to position 4
58
+ * of 9" asks somebody to hold two numbers in their head to work out what a neighbour's name would
59
+ * have told them outright. A one-row list is `first`, which is true and reads better than `last`.
60
+ */
61
+ export function placementOf<T extends Ordered>(items: readonly T[], id: string): Placement<T> {
62
+ const at = items.findIndex((item) => item.id === id)
63
+ if (at === -1) return { at: 'gone' }
64
+ if (at === 0) return { at: 'first' }
65
+ if (at === items.length - 1) return { at: 'last' }
66
+ return { at: 'after', previous: items[at - 1] as T }
67
+ }
68
+
69
+ /**
70
+ * Whether two lists hold the same ids in the same order.
71
+ *
72
+ * What decides a request is worth making. A drag that ends where it started fires `finalize` exactly
73
+ * as a real one does — the library has no opinion about whether anything moved — so without this
74
+ * every aborted drag would post a reorder and every open screen in the workspace would be told
75
+ * about a write that changed nothing.
76
+ */
77
+ export function sameOrder(a: readonly Ordered[], b: readonly Ordered[]): boolean {
78
+ return a.length === b.length && a.every((item, index) => item.id === b[index]?.id)
79
+ }