@kernhq/module-inventory 0.2.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.
- package/README.md +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/0008_category_order_unique.sql +71 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +395 -0
- package/src/client/errors.ts +229 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
package/src/client/price.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CURRENCIES,
|
|
4
|
+
currencyOptions,
|
|
5
|
+
formatPrice,
|
|
6
|
+
minorUnitExponent,
|
|
7
|
+
parsePrice,
|
|
8
|
+
priceExample,
|
|
9
|
+
} from './price.js'
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
12
|
* The regression this file exists for.
|
|
@@ -9,8 +16,8 @@ import { formatPrice, parsePrice } from './price.js'
|
|
|
9
16
|
* languages this module ships, silently, with no error anywhere. `abc` and `-5` were worse: they
|
|
10
17
|
* came back `null` and the asset saved with no price at all.
|
|
11
18
|
*/
|
|
12
|
-
const minor = (raw: string, locale: string) => {
|
|
13
|
-
const result = parsePrice(raw, locale)
|
|
19
|
+
const minor = (raw: string, locale: string, currency?: string | null) => {
|
|
20
|
+
const result = parsePrice(raw, locale, currency)
|
|
14
21
|
return result.ok ? result.minor : 'invalid'
|
|
15
22
|
}
|
|
16
23
|
|
|
@@ -76,6 +83,97 @@ describe('parsePrice', () => {
|
|
|
76
83
|
})
|
|
77
84
|
})
|
|
78
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The second silent 100×, and the one the picker itself created.
|
|
88
|
+
*
|
|
89
|
+
* `parsePrice` and `formatPrice` both hard-coded 1/100 while the currency list offers **JPY**,
|
|
90
|
+
* which has no minor unit at all, and **KWD**, which has three decimal places. So ¥1000 typed into
|
|
91
|
+
* the field was stored as 100000 minor units — ¥100 000 to anything reading the column honestly —
|
|
92
|
+
* and every dinar amount was out by a factor of ten. **IRR is the one that stings**: the rial has
|
|
93
|
+
* no minor unit either, and it is the currency of a language this module ships.
|
|
94
|
+
*/
|
|
95
|
+
describe('the currency decides the minor unit', () => {
|
|
96
|
+
it('asks the currency rather than assuming hundredths', () => {
|
|
97
|
+
expect(minorUnitExponent('USD')).toBe(2)
|
|
98
|
+
expect(minorUnitExponent('EUR')).toBe(2)
|
|
99
|
+
expect(minorUnitExponent('JPY')).toBe(0)
|
|
100
|
+
expect(minorUnitExponent('IRR')).toBe(0)
|
|
101
|
+
expect(minorUnitExponent('KWD')).toBe(3)
|
|
102
|
+
// No currency on an amount is not a reason to make the field unusable, and two decimals is
|
|
103
|
+
// what ISO 4217 assigns by default.
|
|
104
|
+
expect(minorUnitExponent(null)).toBe(2)
|
|
105
|
+
expect(minorUnitExponent('')).toBe(2)
|
|
106
|
+
// The contract takes any three letters, so a code `Intl` refuses must not throw out of a form.
|
|
107
|
+
expect(minorUnitExponent('nonsense')).toBe(2)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('stores ¥1000 as 1000, not as 10', () => {
|
|
111
|
+
expect(minor('1000', 'en', 'JPY')).toBe(1000)
|
|
112
|
+
expect(minor('1,000', 'en', 'JPY')).toBe(1000)
|
|
113
|
+
expect(formatPrice(1000, 'en', 'JPY')).toBe('1000')
|
|
114
|
+
// What it did before: hundredths, so the amount read back a hundred times too small.
|
|
115
|
+
expect(formatPrice(1000, 'en')).toBe('10.00')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('keeps all three of a dinar’s decimal places', () => {
|
|
119
|
+
expect(minor('1.234', 'en', 'KWD')).toBe(1234)
|
|
120
|
+
expect(minor('1,234.567', 'en', 'KWD')).toBe(1234567)
|
|
121
|
+
expect(formatPrice(1234567, 'en', 'KWD')).toBe('1234.567')
|
|
122
|
+
// The old behaviour dropped the third place and was ten times out.
|
|
123
|
+
expect(minor('1.234', 'en')).toBe('invalid')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('still reads a two-decimal currency exactly as before', () => {
|
|
127
|
+
expect(minor('19.99', 'en', 'USD')).toBe(1999)
|
|
128
|
+
expect(minor('1.234,56', 'de', 'EUR')).toBe(123456)
|
|
129
|
+
expect(formatPrice(123456, 'de', 'EUR')).toBe('1234,56')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The point of the whole fix: there is no path where a wrong number is stored quietly. `19.99`
|
|
134
|
+
* in a yen field is somebody who has not noticed which field they are in, and rounding it to
|
|
135
|
+
* ¥20 for them is the same class of silence as the `1.234,56` → €1.23 bug this file began with.
|
|
136
|
+
*/
|
|
137
|
+
it('refuses more decimal places than the currency has, rather than rounding them away', () => {
|
|
138
|
+
expect(minor('19.99', 'en', 'JPY')).toBe('invalid')
|
|
139
|
+
expect(minor('0.5', 'en', 'JPY')).toBe('invalid')
|
|
140
|
+
expect(minor('.5', 'en', 'IRR')).toBe('invalid')
|
|
141
|
+
expect(minor('1.2345', 'en', 'KWD')).toBe('invalid')
|
|
142
|
+
expect(minor('1.234', 'en', 'USD')).toBe('invalid')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('round-trips every shipped locale against a currency of each shape', () => {
|
|
146
|
+
for (const locale of ['en', 'de', 'tr', 'fa', 'ar']) {
|
|
147
|
+
for (const currency of ['USD', 'JPY', 'KWD', 'IRR']) {
|
|
148
|
+
for (const value of [0, 7, 2500, 123456, 99999999]) {
|
|
149
|
+
expect({
|
|
150
|
+
locale,
|
|
151
|
+
currency,
|
|
152
|
+
value,
|
|
153
|
+
back: minor(formatPrice(value, locale, currency), locale, currency),
|
|
154
|
+
}).toEqual({ locale, currency, value, back: value })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('holds up an example the field would actually accept', () => {
|
|
161
|
+
// It said "Enter an amount like 1234.56" in a yen field and then refused 1234.56.
|
|
162
|
+
expect(priceExample('en', 'JPY')).toBe('1234')
|
|
163
|
+
expect(priceExample('en', 'KWD')).toBe('1234.567')
|
|
164
|
+
expect(priceExample('en', 'USD')).toBe('1234.56')
|
|
165
|
+
expect(priceExample('de', null)).toBe('1234,56')
|
|
166
|
+
for (const currency of ['USD', 'JPY', 'KWD', 'IRR', 'EUR']) {
|
|
167
|
+
const example = priceExample('en', currency)
|
|
168
|
+
expect({ currency, example, ok: parsePrice(example, 'en', currency).ok }).toEqual({
|
|
169
|
+
currency,
|
|
170
|
+
example,
|
|
171
|
+
ok: true,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
79
177
|
describe('formatPrice', () => {
|
|
80
178
|
it('has no price to show for no price', () => {
|
|
81
179
|
expect(formatPrice(null, 'en')).toBe('')
|
|
@@ -104,3 +202,53 @@ describe('formatPrice', () => {
|
|
|
104
202
|
expect(formatPrice(123456, 'en')).toBe('1234.56')
|
|
105
203
|
})
|
|
106
204
|
})
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The list a money field offers.
|
|
208
|
+
*
|
|
209
|
+
* It was `['USD', 'EUR', 'IRR', 'AED']` in a product whose language menu offers Turkish — so a
|
|
210
|
+
* workspace in Istanbul could not record what it paid for anything. The contract takes any ISO
|
|
211
|
+
* 4217 code (`z.string().length(3)`) and the server stores whatever arrives, so nothing but the
|
|
212
|
+
* picker was ever short: four options, one of them missing for one of the five shipped languages.
|
|
213
|
+
*/
|
|
214
|
+
describe('CURRENCIES', () => {
|
|
215
|
+
it('has a currency for every language this module ships', () => {
|
|
216
|
+
// The point of the whole fix. `tr` is on the language menu and TRY was not on this list.
|
|
217
|
+
for (const code of ['USD', 'EUR', 'TRY', 'IRR', 'AED']) expect(CURRENCIES).toContain(code)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('offers codes `Intl` recognises, so a price is never labelled with something invented', () => {
|
|
221
|
+
for (const code of CURRENCIES) {
|
|
222
|
+
const formatted = new Intl.NumberFormat('en', { style: 'currency', currency: code }).format(1)
|
|
223
|
+
// An unknown code comes back as the literal code; a real one gets a symbol or a name, and
|
|
224
|
+
// either way `Intl` accepts it rather than throwing on a malformed one.
|
|
225
|
+
expect({ code, formatted: typeof formatted }).toEqual({ code, formatted: 'string' })
|
|
226
|
+
expect(code).toMatch(/^[A-Z]{3}$/)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('lists each code once', () => {
|
|
231
|
+
expect([...new Set(CURRENCIES)]).toEqual([...CURRENCIES])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The order the comment above the list claims, which the list did not have.
|
|
236
|
+
*
|
|
237
|
+
* It said "the currency of each shipped language first, in the order the locales are listed
|
|
238
|
+
* everywhere else in this package (en, ar, de, fa, tr)" while the list read `USD, EUR, AED, TRY,
|
|
239
|
+
* IRR` — en, de, ar, tr, fa, which is no order at all. A rule nothing checks is a rule the next
|
|
240
|
+
* person appends underneath and quietly breaks, so it is checked.
|
|
241
|
+
*/
|
|
242
|
+
it('opens with the currency of each shipped language, in the locale order', () => {
|
|
243
|
+
expect(CURRENCIES.slice(0, 5)).toEqual(['USD', 'AED', 'EUR', 'IRR', 'TRY'])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('puts the empty option first, because an asset with no price has no currency', () => {
|
|
247
|
+
const options = currencyOptions('—')
|
|
248
|
+
expect(options[0]).toEqual({ value: '', label: '—' })
|
|
249
|
+
expect(options.slice(1).map((o) => o.value)).toEqual([...CURRENCIES])
|
|
250
|
+
// Codes rather than names: ISO 4217 is what the column stores, and a name is one more thing to
|
|
251
|
+
// translate five times for no gain over what somebody reads on their own bank statement.
|
|
252
|
+
expect(options.slice(1).every((o) => o.label === o.value)).toBe(true)
|
|
253
|
+
})
|
|
254
|
+
})
|
package/src/client/price.ts
CHANGED
|
@@ -21,6 +21,120 @@
|
|
|
21
21
|
/** `minor: null` is a real answer — an empty field means "no price", not a bad one. */
|
|
22
22
|
export type PriceResult = { ok: true; minor: number | null } | { ok: false }
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The currencies a money field offers, in one place because two forms ask for one.
|
|
26
|
+
*
|
|
27
|
+
* The asset form and the repair form put the same question — what is this amount in — and a second
|
|
28
|
+
* copy of the list is a list that gains a currency in one form and not the other. Codes rather than
|
|
29
|
+
* names: ISO 4217 is what the column stores, and a currency name is one more thing to translate
|
|
30
|
+
* five times for no gain over the code somebody already reads on their own bank statement.
|
|
31
|
+
*
|
|
32
|
+
* **This list was `['USD', 'EUR', 'IRR', 'AED']` in a product that ships Turkish.** A Turkish
|
|
33
|
+
* workspace could not record what it paid for anything in lira: the contract takes any three-letter
|
|
34
|
+
* code (`z.string().length(3)`) and the server stores whatever arrives, so the shortage was the
|
|
35
|
+
* picker's alone — four options, one of which was missing for one of the five languages on the
|
|
36
|
+
* language menu. TRY, and the currency of every other locale this module ships, is now here.
|
|
37
|
+
*
|
|
38
|
+
* The order is deliberate and is not alphabetical: the currency of each shipped language first, in
|
|
39
|
+
* the order the locales are listed everywhere else in this package (en, ar, de, fa, tr) — so USD,
|
|
40
|
+
* AED, EUR, IRR, TRY — then the ones a company in those places is most likely to have paid an
|
|
41
|
+
* invoice in. A picker sorted A–Z puts AED above USD for a reader in Berlin, which is tidy and
|
|
42
|
+
* useless.
|
|
43
|
+
*
|
|
44
|
+
* **The comment said that while the list said `USD, EUR, AED, TRY, IRR`**, which is no order at
|
|
45
|
+
* all — en, de, ar, tr, fa — so the rule a sixth currency was supposed to be filed under described
|
|
46
|
+
* something that had never been true. `price.test.ts` now asserts the first five, because a stated
|
|
47
|
+
* order nothing checks drifts back the first time somebody appends a code.
|
|
48
|
+
*
|
|
49
|
+
* It is still a list, not the whole of ISO 4217: all 180 codes in a `<select>` is not a kindness.
|
|
50
|
+
*
|
|
51
|
+
* **What a new asset defaults to is deliberately nothing.** The obvious improvement on "the first
|
|
52
|
+
* code in the list" is "whatever this workspace uses", and that is right — but a workspace currency
|
|
53
|
+
* does not exist: `InventorySettings` holds the tag prefix, the tag padding and the two notice
|
|
54
|
+
* windows, and nothing else, so `defaultCurrency` is a contract change rather than something a
|
|
55
|
+
* picker can invent. The tempting shortcut is to read it off the reader's locale, and that is
|
|
56
|
+
* *worse* than no default: two colleagues in one workspace, one reading German and one reading
|
|
57
|
+
* Turkish, would be handed different pre-selected currencies for the same register, notice nothing,
|
|
58
|
+
* and store EUR against one laptop and TRY against the next. A currency is a fact about the
|
|
59
|
+
* company, not about the language somebody happens to read the screen in. So the forms seed from
|
|
60
|
+
* the *record* — a repair inherits its asset's currency, an edit keeps its own — and a new asset
|
|
61
|
+
* starts empty, which asks the question instead of answering it wrongly.
|
|
62
|
+
*/
|
|
63
|
+
export const CURRENCIES = [
|
|
64
|
+
'USD',
|
|
65
|
+
'AED',
|
|
66
|
+
'EUR',
|
|
67
|
+
'IRR',
|
|
68
|
+
'TRY',
|
|
69
|
+
'GBP',
|
|
70
|
+
'SAR',
|
|
71
|
+
'QAR',
|
|
72
|
+
'KWD',
|
|
73
|
+
'EGP',
|
|
74
|
+
'CHF',
|
|
75
|
+
'SEK',
|
|
76
|
+
'NOK',
|
|
77
|
+
'DKK',
|
|
78
|
+
'PLN',
|
|
79
|
+
'CAD',
|
|
80
|
+
'AUD',
|
|
81
|
+
'JPY',
|
|
82
|
+
'CNY',
|
|
83
|
+
'INR',
|
|
84
|
+
] as const
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The options both money fields offer, built once.
|
|
88
|
+
*
|
|
89
|
+
* The empty option is first and is a real answer: an asset with no price has no currency either,
|
|
90
|
+
* and a picker with no way back to "none" is a field somebody can fill in and never clear.
|
|
91
|
+
*/
|
|
92
|
+
export function currencyOptions(noneLabel: string): Array<{ value: string; label: string }> {
|
|
93
|
+
return [{ value: '', label: noneLabel }, ...CURRENCIES.map((code) => ({ value: code, label: code }))]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const EXPONENTS: Map<string, number> = new Map()
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How many decimal places this currency has — asked of the currency, never assumed to be two.
|
|
100
|
+
*
|
|
101
|
+
* **This module stored ¥1000 as ¥10 and every KWD amount ten times too small.** `parsePrice` and
|
|
102
|
+
* `formatPrice` both hard-coded 1/100, and the picker offers JPY, which has *no* minor unit, and
|
|
103
|
+
* KWD, which has *three* decimal places. So `1000` typed into a yen field became 100000 minor
|
|
104
|
+
* units, read back as `1000.00`, and was ¥100 000 to anything that read the column honestly — a
|
|
105
|
+
* silent 100× on exactly the shape of field this file was rewritten to stop silently mangling.
|
|
106
|
+
*
|
|
107
|
+
* **IRR is the one that matters most here**, because it is the currency of a language this module
|
|
108
|
+
* ships: the rial has no minor unit either, so a Persian workspace was the default victim.
|
|
109
|
+
*
|
|
110
|
+
* CLDR knows each currency's exponent and `Intl` will hand it over, so nothing is tabulated: a
|
|
111
|
+
* currency added to `CURRENCIES` next year gets the right answer without anybody remembering this
|
|
112
|
+
* function exists. A code `Intl` refuses, or one it has never heard of, falls back to two — which
|
|
113
|
+
* is what ISO 4217 assigns by default, and is the only guess available.
|
|
114
|
+
*
|
|
115
|
+
* The locale is fixed at `'en'` deliberately: the exponent is a fact about the *currency*, and
|
|
116
|
+
* asking under the reader's locale would make the same money parse differently for two colleagues.
|
|
117
|
+
*/
|
|
118
|
+
export function minorUnitExponent(currency: string | null | undefined): number {
|
|
119
|
+
if (!currency) return 2
|
|
120
|
+
const cached = EXPONENTS.get(currency)
|
|
121
|
+
if (cached !== undefined) return cached
|
|
122
|
+
let exponent = 2
|
|
123
|
+
try {
|
|
124
|
+
// `maximumFractionDigits` is optional in the DOM lib and always present in practice for a
|
|
125
|
+
// currency formatter; `??` is what keeps the two type-checks agreeing about that.
|
|
126
|
+
exponent =
|
|
127
|
+
new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ??
|
|
128
|
+
2
|
|
129
|
+
} catch {
|
|
130
|
+
// A malformed code — the contract takes any three letters and the picker is not the only way
|
|
131
|
+
// in. Two decimals is ISO 4217's own default and keeps the field usable.
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isInteger(exponent) || exponent < 0 || exponent > 4) exponent = 2
|
|
134
|
+
EXPONENTS.set(currency, exponent)
|
|
135
|
+
return exponent
|
|
136
|
+
}
|
|
137
|
+
|
|
24
138
|
interface Separators {
|
|
25
139
|
group: string
|
|
26
140
|
decimal: string
|
|
@@ -86,8 +200,18 @@ function isGroupedInteger(text: string, group: string): boolean {
|
|
|
86
200
|
*
|
|
87
201
|
* Returns `{ ok: false }` for anything that is not a non-negative number in `locale` — including a
|
|
88
202
|
* negative one, which the contract has no way to mean and the old parser dropped in silence.
|
|
203
|
+
*
|
|
204
|
+
* `currency` decides how many decimal places the amount is allowed and what it is scaled by. More
|
|
205
|
+
* fractional digits than the currency has is a **rejection, not a rounding**: `19.99` in a yen
|
|
206
|
+
* field is not ¥20, it is somebody who has not noticed which field they are in, and quietly
|
|
207
|
+
* storing a number nobody typed is the whole class of failure this file exists to end. Omitting
|
|
208
|
+
* `currency` keeps two decimals, which is what an amount with no currency on it has always meant.
|
|
209
|
+
*
|
|
210
|
+
* The scaled integer is built by **concatenating digits**, never by multiplying: `19.99 * 100` is
|
|
211
|
+
* `1998.9999999999998` in a double, and the `Math.round` that used to hide it would have had to
|
|
212
|
+
* hide `1234.567 * 1000` too. Digits in, digits out, and no float on the path at all.
|
|
89
213
|
*/
|
|
90
|
-
export function parsePrice(raw: string, locale: string): PriceResult {
|
|
214
|
+
export function parsePrice(raw: string, locale: string, currency?: string | null): PriceResult {
|
|
91
215
|
const text = toLatinDigits(raw).replace(BIDI, '').trim()
|
|
92
216
|
if (!text) return { ok: true, minor: null }
|
|
93
217
|
|
|
@@ -103,11 +227,12 @@ export function parsePrice(raw: string, locale: string): PriceResult {
|
|
|
103
227
|
if (!isGroupedInteger(whole, group)) return { ok: false }
|
|
104
228
|
if (frac !== undefined && !/^\d+$/.test(frac)) return { ok: false }
|
|
105
229
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
if (
|
|
230
|
+
const exponent = minorUnitExponent(currency)
|
|
231
|
+
// A currency with no minor unit has no decimal point either, so `frac` of any length fails here.
|
|
232
|
+
if (frac !== undefined && frac.length > exponent) return { ok: false }
|
|
109
233
|
|
|
110
|
-
const
|
|
234
|
+
const digits = group ? whole.split(group).join('') : whole
|
|
235
|
+
const minor = Number(`${digits}${(frac ?? '').padEnd(exponent, '0')}`)
|
|
111
236
|
// A price that cannot survive the round trip through a double is not a price anybody typed.
|
|
112
237
|
if (!Number.isSafeInteger(minor)) return { ok: false }
|
|
113
238
|
return { ok: true, minor }
|
|
@@ -119,17 +244,36 @@ export function parsePrice(raw: string, locale: string): PriceResult {
|
|
|
119
244
|
* The edit form has to seed itself from `priceMinor`, and seeding it with `1234.56` for a German
|
|
120
245
|
* reader would make them look at a number their own locale says is €123,456 — then `parsePrice`
|
|
121
246
|
* would reject it on the grouping rule and they would be told their own data is invalid. What comes
|
|
122
|
-
* out of here is exactly what goes back in.
|
|
247
|
+
* out of here is exactly what goes back in, for the currency the amount is in.
|
|
123
248
|
*/
|
|
124
|
-
export function formatPrice(
|
|
249
|
+
export function formatPrice(
|
|
250
|
+
minor: number | null | undefined,
|
|
251
|
+
locale: string,
|
|
252
|
+
currency?: string | null,
|
|
253
|
+
): string {
|
|
125
254
|
if (minor === null || minor === undefined) return ''
|
|
255
|
+
const exponent = minorUnitExponent(currency)
|
|
256
|
+
const value = minor / 10 ** exponent
|
|
126
257
|
try {
|
|
127
258
|
return new Intl.NumberFormat(locale, {
|
|
128
|
-
minimumFractionDigits:
|
|
129
|
-
maximumFractionDigits:
|
|
259
|
+
minimumFractionDigits: exponent,
|
|
260
|
+
maximumFractionDigits: exponent,
|
|
130
261
|
useGrouping: false,
|
|
131
|
-
}).format(
|
|
262
|
+
}).format(value)
|
|
132
263
|
} catch {
|
|
133
|
-
return String(
|
|
264
|
+
return String(value)
|
|
134
265
|
}
|
|
135
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The amount the "enter an amount like…" error holds up as an example.
|
|
270
|
+
*
|
|
271
|
+
* It has to be an amount the field would *accept*, or the error tells somebody to type something
|
|
272
|
+
* that is also rejected. `1234.56` was hard-coded, so a yen field said "Enter an amount like
|
|
273
|
+
* 1234.56" and then refused it. Built from the exponent instead: `1234` in yen, `1234.567` in
|
|
274
|
+
* dinars, and the reader's own separators and digits around it either way.
|
|
275
|
+
*/
|
|
276
|
+
export function priceExample(locale: string, currency?: string | null): string {
|
|
277
|
+
const exponent = minorUnitExponent(currency)
|
|
278
|
+
return formatPrice(Number(`1234${'567'.slice(0, exponent).padEnd(exponent, '0')}`), locale, currency)
|
|
279
|
+
}
|
package/src/client/query.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* the list and the dashboard card behind it stayed stale until a reload. Every other module keys
|
|
16
16
|
* the list and the row off one singular entity name (`['tracker', 'issue', …]`); so does this one
|
|
17
17
|
* now.
|
|
18
|
+
*
|
|
19
|
+
* **The timeline and the custody list hang off the asset's own key**, one segment deeper. That is
|
|
20
|
+
* what lets custody announce itself as `entity: 'asset'` and refresh all four things it changed —
|
|
21
|
+
* the row, the list, the detail panel and both of its tabs — from one change event, because
|
|
22
|
+
* `partialMatchKey` matches on the prefix. A separate `['inventory', 'custody', …]` would need the
|
|
23
|
+
* server to emit a second entity for the same write.
|
|
18
24
|
*/
|
|
19
25
|
export const inventoryKeys = {
|
|
20
26
|
/** Everything under this module, for the blunt invalidation after a write. */
|
|
@@ -22,4 +28,34 @@ export const inventoryKeys = {
|
|
|
22
28
|
assets: (ws: string, filters?: Record<string, unknown>) =>
|
|
23
29
|
filters ? (['inventory', 'asset', ws, filters] as const) : (['inventory', 'asset', ws] as const),
|
|
24
30
|
asset: (ws: string, id: string) => ['inventory', 'asset', ws, id] as const,
|
|
31
|
+
/** The asset's own timeline. Under the asset, so one `asset` change refreshes it. */
|
|
32
|
+
assetHistory: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'history'] as const,
|
|
33
|
+
/** Every custody period for one asset. Under the asset, for the same reason. */
|
|
34
|
+
assetCustody: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'custody'] as const,
|
|
35
|
+
/** One asset's repairs. Under the asset, so a repair announced as an `asset` change refreshes it. */
|
|
36
|
+
assetRepairs: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'repairs'] as const,
|
|
37
|
+
/** One asset's files, its repairs' included — the panel groups them, so there is one key. */
|
|
38
|
+
assetAttachments: (ws: string, id: string) => ['inventory', 'asset', ws, id, 'attachments'] as const,
|
|
39
|
+
/**
|
|
40
|
+
* The workspace's repairs, which belong to no single asset — the "what is away right now" card.
|
|
41
|
+
*
|
|
42
|
+
* Its own entity, because it cannot hang under one asset's key: `src/server/router.ts` therefore
|
|
43
|
+
* announces a repair twice, once as the asset that changed and once as the repair itself.
|
|
44
|
+
*/
|
|
45
|
+
repairs: (ws: string, filters?: Record<string, unknown>) =>
|
|
46
|
+
filters ? (['inventory', 'repair', ws, filters] as const) : (['inventory', 'repair', ws] as const),
|
|
47
|
+
/**
|
|
48
|
+
* The register in numbers.
|
|
49
|
+
*
|
|
50
|
+
* Under the **asset** prefix on purpose, one segment deep: every number it holds is a count of
|
|
51
|
+
* assets, so every write that changes one of those counts already announces `entity: 'asset'` and
|
|
52
|
+
* this is refreshed by the same event. `'stats'` cannot collide with `asset(ws, id)` — that
|
|
53
|
+
* segment is always a uuid.
|
|
54
|
+
*/
|
|
55
|
+
stats: (ws: string) => ['inventory', 'asset', ws, 'stats'] as const,
|
|
56
|
+
/**
|
|
57
|
+
* The workspace's categories. Its own entity because it has its own change event: a rename
|
|
58
|
+
* touches every asset row on screen, and `src/server/router.ts` therefore emits both.
|
|
59
|
+
*/
|
|
60
|
+
categories: (ws: string, archived = false) => ['inventory', 'category', ws, { archived }] as const,
|
|
25
61
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { repairActions } from './repairs.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the Repairs section is allowed to offer.
|
|
6
|
+
*
|
|
7
|
+
* A decision rather than rendering, and wrong in a way nothing else would catch: a *Send for repair*
|
|
8
|
+
* button on an item that is already at a repairer produces a 409 every single time it is pressed —
|
|
9
|
+
* the unique index refuses the second open repair — so the interface must not offer it at all.
|
|
10
|
+
*/
|
|
11
|
+
describe('repairActions', () => {
|
|
12
|
+
it('offers sending it away when nothing is open', () => {
|
|
13
|
+
expect(repairActions({ open: false, archived: false, may: true })).toEqual(['create'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('offers completing and correcting while one is open, and never a second send', () => {
|
|
17
|
+
const actions = repairActions({ open: true, archived: false, may: true })
|
|
18
|
+
expect(actions).toEqual(['complete', 'edit'])
|
|
19
|
+
// The one combination that would conflict on every press.
|
|
20
|
+
expect(actions).not.toContain('create')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('offers nothing without the permission — hidden, not disabled', () => {
|
|
24
|
+
expect(repairActions({ open: false, archived: false, may: false })).toEqual([])
|
|
25
|
+
expect(repairActions({ open: true, archived: false, may: false })).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('offers nothing new on an archived item, which the server refuses anyway', () => {
|
|
29
|
+
expect(repairActions({ open: false, archived: true, may: true })).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps the way out of an open repair reachable even on an archived item', () => {
|
|
33
|
+
// Archiving is refused while a repair is open, so this state should be unreachable — and if
|
|
34
|
+
// the data ever says otherwise, hiding *complete* would leave the item stuck at `under_repair`
|
|
35
|
+
// for ever with no control that could finish it.
|
|
36
|
+
expect(repairActions({ open: true, archived: true, may: true })).toEqual(['complete', 'edit'])
|
|
37
|
+
})
|
|
38
|
+
})
|