@kernhq/module-hr 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the two "my own record" pages draw, and what a refused cancellation says.
|
|
3
|
+
*
|
|
4
|
+
* Neither page had an error branch. Attendance fell through to "Nothing recorded yet — clock in to
|
|
5
|
+
* start" when the load *failed*, above three tiles reading 0h / 0h / 0h reduced from `[]` — a
|
|
6
|
+
* confident statement about somebody's month made out of nothing, and the one figure here a person
|
|
7
|
+
* might take to their manager. Time off dropped its balance strip with no message and then said "No
|
|
8
|
+
* time off booked", which reads as a person with nothing rather than as a screen that never loaded.
|
|
9
|
+
* A screenshot of the happy path cannot see any of that, and the mock this module develops against
|
|
10
|
+
* has no way to refuse a call — so the branches are read out of the components themselves.
|
|
11
|
+
*
|
|
12
|
+
* The technique is `ClockControls.test.ts`'s, and the reasons are the same: there is no component
|
|
13
|
+
* renderer in this package, so this parses each page with the compiler Svelte ships and evaluates
|
|
14
|
+
* the `{#if}` chains, the derived flags that steer them, and `cancelFailure` — the file's own
|
|
15
|
+
* source, run, rather than a copy of it restated here.
|
|
16
|
+
*
|
|
17
|
+
* What it cannot see: layout, colour, and anything CSS decides. Those are checked by eye and by
|
|
18
|
+
* `shell`'s `ux.spec.ts`.
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { parse } from 'svelte/compiler'
|
|
23
|
+
import ts from 'typescript'
|
|
24
|
+
import { describe, expect, it } from 'vitest'
|
|
25
|
+
|
|
26
|
+
interface Node {
|
|
27
|
+
type: string
|
|
28
|
+
start: number
|
|
29
|
+
end: number
|
|
30
|
+
[key: string]: unknown
|
|
31
|
+
}
|
|
32
|
+
/** An `IfBlock`, with the three children the chain walk reads named rather than fished out. */
|
|
33
|
+
interface IfNode extends Node {
|
|
34
|
+
test: Node
|
|
35
|
+
consequent: { nodes: Node[] }
|
|
36
|
+
alternate?: { nodes: Node[] } | null
|
|
37
|
+
elseif?: boolean
|
|
38
|
+
}
|
|
39
|
+
type Scope = Record<string, unknown>
|
|
40
|
+
|
|
41
|
+
/** One parsed page, with the handful of questions these tests ask of it. */
|
|
42
|
+
function page(file: string) {
|
|
43
|
+
const source = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf8')
|
|
44
|
+
const ast = parse(source, { modern: true })
|
|
45
|
+
const slice = (node: Node) => source.slice(node.start, node.end)
|
|
46
|
+
|
|
47
|
+
/** Every `{#if}` in the markup, innermost included, in source order. */
|
|
48
|
+
const allIfBlocks = (): Node[] => {
|
|
49
|
+
const found: Node[] = []
|
|
50
|
+
const walk = (node: unknown) => {
|
|
51
|
+
if (!node || typeof node !== 'object') return
|
|
52
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
53
|
+
const record = node as Record<string, unknown>
|
|
54
|
+
if (record.type === 'IfBlock') found.push(record as Node)
|
|
55
|
+
for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
|
|
56
|
+
}
|
|
57
|
+
walk(ast.fragment)
|
|
58
|
+
return found.sort((a, b) => a.start - b.start)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The markup between two offsets with every `{#if}` inside it taken or dropped.
|
|
63
|
+
*
|
|
64
|
+
* The nested blocks are the point. Reading a branch's source *text* would put the stale notice and
|
|
65
|
+
* the month in it whatever the query was doing, so an assertion for either passed against a
|
|
66
|
+
* component that draws neither.
|
|
67
|
+
*/
|
|
68
|
+
function resolve(from: number, to: number, scope: Scope): string {
|
|
69
|
+
const inside = allIfBlocks().filter((b) => b.start >= from && b.end <= to)
|
|
70
|
+
// Outermost first: a nested block is spliced in by the recursion into whichever side survives,
|
|
71
|
+
// not by this loop, or it would appear twice.
|
|
72
|
+
const blocks = inside.filter((b) => !inside.some((o) => o !== b && o.start <= b.start && o.end >= b.end))
|
|
73
|
+
let out = ''
|
|
74
|
+
let cursor = from
|
|
75
|
+
for (const block of blocks) {
|
|
76
|
+
out += source.slice(cursor, block.start)
|
|
77
|
+
out += resolveIf(block, scope)
|
|
78
|
+
cursor = block.end
|
|
79
|
+
}
|
|
80
|
+
return out + source.slice(cursor, to)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve one `{#if}` chain against the scope and render whichever side it takes. */
|
|
84
|
+
function resolveIf(block: Node, scope: Scope): string {
|
|
85
|
+
const holds = new Function(...Object.keys(scope), `return (${slice(block.test as Node)})`)(
|
|
86
|
+
...Object.values(scope),
|
|
87
|
+
)
|
|
88
|
+
const side = holds
|
|
89
|
+
? (block.consequent as { nodes: Node[] })
|
|
90
|
+
: (block.alternate as { nodes: Node[] } | null)
|
|
91
|
+
if (!side || side.nodes.length === 0) return ''
|
|
92
|
+
const nodes = side.nodes
|
|
93
|
+
return resolve(nodes[0]!.start, nodes[nodes.length - 1]!.end, scope)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The `{#if}` chain whose first test is written exactly like this, found by that test. */
|
|
97
|
+
const chain = (test: string): IfNode => {
|
|
98
|
+
const found = allIfBlocks().filter((b) => slice(b.test as Node) === test && !b.elseif)
|
|
99
|
+
expect({ test, matches: found.length }).toEqual({ test, matches: 1 })
|
|
100
|
+
return found[0] as IfNode
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* That chain's tests, in the order the renderer tries them, with `null` for the closing `{:else}`.
|
|
105
|
+
*
|
|
106
|
+
* The order is the whole assertion these support: every branch of these chains can draw
|
|
107
|
+
* *something*, so "renders an error state" holds just as well of a page that draws it above the
|
|
108
|
+
* data and blanks a working screen on a failed background refetch.
|
|
109
|
+
*/
|
|
110
|
+
function branches(test: string): Array<string | null> {
|
|
111
|
+
const out: Array<string | null> = []
|
|
112
|
+
let block: IfNode | undefined = chain(test)
|
|
113
|
+
while (block) {
|
|
114
|
+
out.push(slice(block.test))
|
|
115
|
+
const alternate = block.alternate
|
|
116
|
+
if (!alternate) break
|
|
117
|
+
const elseif = alternate.nodes.find((n) => n.type === 'IfBlock' && n.elseif) as IfNode | undefined
|
|
118
|
+
if (elseif) {
|
|
119
|
+
block = elseif
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
out.push(null)
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What that chain draws for this scope.
|
|
130
|
+
*
|
|
131
|
+
* Comments are stripped: a branch that *explains* in prose why it does not blank the page would
|
|
132
|
+
* otherwise satisfy an assertion about what it renders.
|
|
133
|
+
*/
|
|
134
|
+
const render = (test: string, scope: Scope): string =>
|
|
135
|
+
resolveIf(chain(test), scope).replace(/<!--[\s\S]*?-->/g, '')
|
|
136
|
+
|
|
137
|
+
/** The initialiser of one top-level `const` in the instance script. */
|
|
138
|
+
function init(name: string): string {
|
|
139
|
+
const found: Node[] = []
|
|
140
|
+
const walk = (node: unknown) => {
|
|
141
|
+
if (!node || typeof node !== 'object') return
|
|
142
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
143
|
+
const record = node as Record<string, unknown>
|
|
144
|
+
if (record.type === 'VariableDeclarator' && (record.id as { name?: string })?.name === name)
|
|
145
|
+
found.push(record.init as Node)
|
|
146
|
+
for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
|
|
147
|
+
}
|
|
148
|
+
walk(ast.instance?.content)
|
|
149
|
+
expect({ name, matches: found.length }).toEqual({ name, matches: 1 })
|
|
150
|
+
return slice(found[0]!)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The expression inside a `const x = $derived(…)`, so the flag under test is the file's own.
|
|
155
|
+
*
|
|
156
|
+
* The trailing comma matters: biome wraps a long `$derived(…)` across lines and leaves one, and
|
|
157
|
+
* `return ((…),)` is a syntax error rather than a failing assertion — which reads like the
|
|
158
|
+
* component is broken when it is this helper that is.
|
|
159
|
+
*/
|
|
160
|
+
function derived(name: string): string {
|
|
161
|
+
const source = init(name)
|
|
162
|
+
expect(source.startsWith('$derived(')).toBe(true)
|
|
163
|
+
return source.slice('$derived('.length, -1).trim().replace(/,$/, '')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Evaluate one of the page's `$derived` flags against a scope. */
|
|
167
|
+
const flag = (name: string, scope: Scope): unknown =>
|
|
168
|
+
new Function(...Object.keys(scope), `return (${derived(name)})`)(...Object.values(scope))
|
|
169
|
+
|
|
170
|
+
/** A `const` holding an arrow function, transpiled so its annotations do not reach `Function`. */
|
|
171
|
+
const arrow = (name: string): string =>
|
|
172
|
+
ts.transpileModule(`const ${name} = ${init(name)}`, {
|
|
173
|
+
compilerOptions: { target: ts.ScriptTarget.ESNext },
|
|
174
|
+
}).outputText
|
|
175
|
+
|
|
176
|
+
/** A named `function` declaration from the instance script, with its types stripped. */
|
|
177
|
+
function fn(name: string): string {
|
|
178
|
+
const found: Node[] = []
|
|
179
|
+
const walk = (node: unknown) => {
|
|
180
|
+
if (!node || typeof node !== 'object') return
|
|
181
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
182
|
+
const record = node as Record<string, unknown>
|
|
183
|
+
if (record.type === 'FunctionDeclaration' && (record.id as { name?: string })?.name === name)
|
|
184
|
+
found.push(record as Node)
|
|
185
|
+
for (const value of Object.values(record)) if (value && typeof value === 'object') walk(value)
|
|
186
|
+
}
|
|
187
|
+
walk(ast.instance?.content)
|
|
188
|
+
expect({ name, matches: found.length }).toEqual({ name, matches: 1 })
|
|
189
|
+
return ts.transpileModule(slice(found[0]!), {
|
|
190
|
+
compilerOptions: { target: ts.ScriptTarget.ESNext },
|
|
191
|
+
}).outputText
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { source, branches, render, flag, arrow, fn }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const attendance = page('./AttendancePage.svelte')
|
|
198
|
+
const leave = page('./LeavePage.svelte')
|
|
199
|
+
|
|
200
|
+
/** A day sheet row, shaped enough for the `{#if}`s inside the month list. */
|
|
201
|
+
const day = { overtimeMinutes: 0, anomalies: [] as unknown[] }
|
|
202
|
+
|
|
203
|
+
describe('the month on the attendance page', () => {
|
|
204
|
+
/**
|
|
205
|
+
* The order is the assertion. Every one of these branches can draw *something*, so "renders a
|
|
206
|
+
* skeleton when loading" was true of a page that asked `isError` first and blanked a working
|
|
207
|
+
* month on a failed background refetch.
|
|
208
|
+
*/
|
|
209
|
+
it('asks loading first, then the month it holds, then the failure, then the empty month', () => {
|
|
210
|
+
expect(attendance.branches('loading')).toEqual(['loading', 'days.length', 'daysQuery.isError', null])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('draws a day-shaped skeleton while the first fetch is in flight', () => {
|
|
214
|
+
const body = attendance.render('loading', { loading: true, days: [], daysQuery: {}, day })
|
|
215
|
+
expect(body).toContain('Skeleton')
|
|
216
|
+
expect(body).not.toContain('EmptyState')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The defect this file was written for. `days` is `[]` before an answer and `[]` after a refused
|
|
221
|
+
* one, so the page said "Nothing recorded yet — clock in to start" to somebody whose month simply
|
|
222
|
+
* had not loaded.
|
|
223
|
+
*/
|
|
224
|
+
it('says the month failed and offers a retry rather than claiming nothing is recorded', () => {
|
|
225
|
+
const body = attendance.render('loading', {
|
|
226
|
+
loading: false,
|
|
227
|
+
days: [],
|
|
228
|
+
daysQuery: { isError: true },
|
|
229
|
+
day,
|
|
230
|
+
})
|
|
231
|
+
expect(body).toContain("t('attendance_error')")
|
|
232
|
+
expect(body).toContain("t('retry')")
|
|
233
|
+
expect(body).not.toContain("t('attendance_none')")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('still says nothing is recorded when the answer arrived and the month is genuinely empty', () => {
|
|
237
|
+
const body = attendance.render('loading', {
|
|
238
|
+
loading: false,
|
|
239
|
+
days: [],
|
|
240
|
+
daysQuery: { isError: false },
|
|
241
|
+
day,
|
|
242
|
+
})
|
|
243
|
+
expect(body).toContain("t('attendance_none')")
|
|
244
|
+
expect(body).not.toContain("t('attendance_error')")
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A punch invalidates the whole module, so a refetch that fails while the last good month is
|
|
249
|
+
* still in `data` is the ordinary case — an error branch above the month would blank the page,
|
|
250
|
+
* tiles included, for as long as core takes to come back.
|
|
251
|
+
*/
|
|
252
|
+
it('keeps a held month when a refetch fails, and says it may be out of date', () => {
|
|
253
|
+
const body = attendance.render('loading', {
|
|
254
|
+
loading: false,
|
|
255
|
+
days: [{ id: 'a' }],
|
|
256
|
+
daysQuery: { isError: true },
|
|
257
|
+
day,
|
|
258
|
+
})
|
|
259
|
+
expect(body).toContain('{#each days as day (day.id)}')
|
|
260
|
+
expect(body).toContain("t('attendance_stale')")
|
|
261
|
+
expect(body).toContain("t('retry')")
|
|
262
|
+
expect(body).not.toContain("t('attendance_error')")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('does not say the month may be out of date while the refetch is succeeding', () => {
|
|
266
|
+
const body = attendance.render('loading', {
|
|
267
|
+
loading: false,
|
|
268
|
+
days: [{ id: 'a' }],
|
|
269
|
+
daysQuery: { isError: false },
|
|
270
|
+
day,
|
|
271
|
+
})
|
|
272
|
+
expect(body).toContain('{#each days as day (day.id)}')
|
|
273
|
+
expect(body).not.toContain("t('attendance_stale')")
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('the attendance tiles', () => {
|
|
278
|
+
/**
|
|
279
|
+
* Worked, scheduled and overtime are `days.reduce(…)`, so an unloaded month adds up to "0h worked,
|
|
280
|
+
* 0h scheduled, 0h overtime" — three confident falsehoods about somebody's hours. There is nothing
|
|
281
|
+
* to say until there is a day sheet to say it from.
|
|
282
|
+
*/
|
|
283
|
+
it('is unknown, not zero, when the load failed with nothing held', () => {
|
|
284
|
+
expect(attendance.flag('totalsUnknown', { days: [], loading: false, daysQuery: { isError: true } })).toBe(
|
|
285
|
+
true,
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('is unknown while the first fetch is in flight', () => {
|
|
290
|
+
expect(attendance.flag('totalsUnknown', { days: [], loading: true, daysQuery: { isError: false } })).toBe(
|
|
291
|
+
true,
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
/** A month that really is empty is worth 0h, and saying so is the truth rather than a guess. */
|
|
296
|
+
it('is known when the answer arrived and the month is empty', () => {
|
|
297
|
+
expect(
|
|
298
|
+
attendance.flag('totalsUnknown', { days: [], loading: false, daysQuery: { isError: false } }),
|
|
299
|
+
).toBe(false)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
/** The held totals outrank a failed refetch, for the same reason the held month does. */
|
|
303
|
+
it('is known when a refetch failed but the month is still held', () => {
|
|
304
|
+
expect(
|
|
305
|
+
attendance.flag('totalsUnknown', { days: [{ id: 'a' }], loading: false, daysQuery: { isError: true } }),
|
|
306
|
+
).toBe(false)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('draws skeletons in place of the tiles rather than reducing an empty month', () => {
|
|
310
|
+
expect(attendance.render('totalsUnknown', { totalsUnknown: true })).toContain('tilesUnknown()')
|
|
311
|
+
expect(attendance.render('totalsUnknown', { totalsUnknown: false })).toContain('tiles()')
|
|
312
|
+
expect(attendance.source).toContain('<Skeleton height="86px" />')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A disabled query is `pending` and not fetching, so it is not "loading" — without the workspace
|
|
317
|
+
* test the first frame told somebody with a full month that nothing was recorded.
|
|
318
|
+
*/
|
|
319
|
+
it('counts the frame before a workspace arrives as loading', () => {
|
|
320
|
+
expect(attendance.flag('loading', { workspaceId: '', daysQuery: { isLoading: false } })).toBe(true)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
describe('the balance on the time-off page', () => {
|
|
325
|
+
it('asks loading first, then the balance it holds, then the failure, then the unconfigured workspace', () => {
|
|
326
|
+
expect(leave.branches('balanceLoading')).toEqual([
|
|
327
|
+
'balanceLoading',
|
|
328
|
+
'balances.length',
|
|
329
|
+
'balanceQuery.isError',
|
|
330
|
+
null,
|
|
331
|
+
])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The defect. A refused balance took the strip off the page with no message at all, leaving "No
|
|
336
|
+
* time off booked" underneath it — which reads as a person with nothing left and nothing booked.
|
|
337
|
+
*/
|
|
338
|
+
it('says the balance failed and offers a retry rather than dropping the strip', () => {
|
|
339
|
+
const body = leave.render('balanceLoading', {
|
|
340
|
+
balanceLoading: false,
|
|
341
|
+
balances: [],
|
|
342
|
+
balanceQuery: { isError: true },
|
|
343
|
+
canHr: () => true,
|
|
344
|
+
})
|
|
345
|
+
expect(body).toContain("t('balance_error')")
|
|
346
|
+
expect(body).toContain("t('retry')")
|
|
347
|
+
expect(body).not.toContain("t('leave_types_none')")
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* An empty answer is one row per leave type and no leave types — a workspace nobody has set up,
|
|
352
|
+
* not a person with nothing. The way out is only offered to somebody who has it: a member cannot
|
|
353
|
+
* create a leave type, and a button that 404s is worse than none.
|
|
354
|
+
*/
|
|
355
|
+
it('names the unconfigured workspace when the answer arrived empty, and offers the fix only to an admin', () => {
|
|
356
|
+
const scope = { balanceLoading: false, balances: [], balanceQuery: { isError: false } }
|
|
357
|
+
const admin = leave.render('balanceLoading', { ...scope, canHr: () => true })
|
|
358
|
+
expect(admin).toContain("t('leave_types_none')")
|
|
359
|
+
expect(admin).toContain("t('leave_types')")
|
|
360
|
+
expect(admin).not.toContain("t('balance_error')")
|
|
361
|
+
|
|
362
|
+
const member = leave.render('balanceLoading', { ...scope, canHr: () => false })
|
|
363
|
+
expect(member).toContain("t('leave_types_none')")
|
|
364
|
+
expect(member).not.toContain("t('leave_types')")
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('draws skeletons the shape of the tiles while the first fetch is in flight', () => {
|
|
368
|
+
const body = leave.render('balanceLoading', {
|
|
369
|
+
balanceLoading: true,
|
|
370
|
+
balances: [],
|
|
371
|
+
balanceQuery: {},
|
|
372
|
+
canHr: () => true,
|
|
373
|
+
})
|
|
374
|
+
expect(body).toContain('Skeleton')
|
|
375
|
+
expect(body).not.toContain('EmptyState')
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe('the requests on the time-off page', () => {
|
|
380
|
+
const row = { status: 'pending' }
|
|
381
|
+
const scope = { canCancel: () => true, request: row, canHr: () => true }
|
|
382
|
+
|
|
383
|
+
it('asks loading first, then the requests it holds, then the failure, then the empty list', () => {
|
|
384
|
+
expect(leave.branches('requestsLoading')).toEqual([
|
|
385
|
+
'requestsLoading',
|
|
386
|
+
'requests.length',
|
|
387
|
+
'requestsQuery.isError',
|
|
388
|
+
null,
|
|
389
|
+
])
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('says the list failed and offers a retry rather than claiming nothing is booked', () => {
|
|
393
|
+
const body = leave.render('requestsLoading', {
|
|
394
|
+
...scope,
|
|
395
|
+
requestsLoading: false,
|
|
396
|
+
requests: [],
|
|
397
|
+
requestsQuery: { isError: true },
|
|
398
|
+
})
|
|
399
|
+
expect(body).toContain("t('leave_requests_error')")
|
|
400
|
+
expect(body).toContain("t('retry')")
|
|
401
|
+
expect(body).not.toContain("t('leave_none')")
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('still says nothing is booked when the answer arrived and the list is genuinely empty', () => {
|
|
405
|
+
const body = leave.render('requestsLoading', {
|
|
406
|
+
...scope,
|
|
407
|
+
requestsLoading: false,
|
|
408
|
+
requests: [],
|
|
409
|
+
requestsQuery: { isError: false },
|
|
410
|
+
})
|
|
411
|
+
expect(body).toContain("t('leave_none')")
|
|
412
|
+
expect(body).not.toContain("t('leave_requests_error')")
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One strip for both queries: when core is unreachable both fail, and two identical warnings
|
|
417
|
+
* stacked on one page is noise.
|
|
418
|
+
*/
|
|
419
|
+
it('warns that held data may be out of date, for either query, and never with nothing to hold', () => {
|
|
420
|
+
const held = { balances: [{}], requests: [{}] }
|
|
421
|
+
const none = { balances: [], requests: [] }
|
|
422
|
+
const ok = { isError: false }
|
|
423
|
+
const bad = { isError: true }
|
|
424
|
+
expect(leave.flag('stale', { ...held, balanceQuery: bad, requestsQuery: ok })).toBe(true)
|
|
425
|
+
expect(leave.flag('stale', { ...held, balanceQuery: ok, requestsQuery: bad })).toBe(true)
|
|
426
|
+
expect(leave.flag('stale', { ...held, balanceQuery: ok, requestsQuery: ok })).toBe(false)
|
|
427
|
+
// Nothing held is the error branch's job; a warning strip above an error state says it twice.
|
|
428
|
+
expect(leave.flag('stale', { ...none, balanceQuery: bad, requestsQuery: bad })).toBe(false)
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
describe('cancelling a booking', () => {
|
|
433
|
+
/**
|
|
434
|
+
* The defect. A small ghost button beside a status badge was `onclick={() => cancel.mutate(id)}`,
|
|
435
|
+
* and `canCancel` allows an *approved* request — so one misclick destroyed a granted week: days
|
|
436
|
+
* already off the balance, and an absence colleagues had planned around.
|
|
437
|
+
*/
|
|
438
|
+
it('opens a confirmation instead of cancelling on the click', () => {
|
|
439
|
+
const row = leave.render('requestsLoading', {
|
|
440
|
+
requestsLoading: false,
|
|
441
|
+
requests: [{}],
|
|
442
|
+
requestsQuery: { isError: false },
|
|
443
|
+
canCancel: () => true,
|
|
444
|
+
request: { status: 'approved' },
|
|
445
|
+
canHr: () => true,
|
|
446
|
+
})
|
|
447
|
+
expect(row).toContain("t('cancel_request')")
|
|
448
|
+
expect(row).toContain('cancelling = request')
|
|
449
|
+
expect(row).not.toContain('cancel.mutate')
|
|
450
|
+
// window.confirm is not a dialog anybody can read, style or translate.
|
|
451
|
+
expect(leave.source).not.toContain('confirm(')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
/** Hidden from somebody who may never cancel; offered for the two statuses that can be. */
|
|
455
|
+
it('is offered for a pending or approved booking, and only with the permission', () => {
|
|
456
|
+
const build = new Function('canHr', `${leave.arrow('canCancel')}; return canCancel`)
|
|
457
|
+
const allowed = build(() => true)
|
|
458
|
+
expect(['pending', 'approved'].map(allowed)).toEqual([true, true])
|
|
459
|
+
expect(['rejected', 'withdrawn', 'cancelled'].map(allowed)).toEqual([false, false, false])
|
|
460
|
+
expect(allowed('pending')).toBe(true)
|
|
461
|
+
expect(build(() => false)('pending')).toBe(false)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* "Are you sure?" says nothing. The body names the dates and the working days that come back, and
|
|
466
|
+
* an approved booking gets a line of its own — those are the days the team has already planned
|
|
467
|
+
* around, and the person clicking is the only one who can weigh that.
|
|
468
|
+
*/
|
|
469
|
+
it('states the dates, the days returned and — for an approved booking — the team calendar', () => {
|
|
470
|
+
const approved = leave.render('cancelling', {
|
|
471
|
+
cancelling: { workingDays: 5, startsOn: '2026-09-09', endsOn: '2026-09-13', status: 'approved' },
|
|
472
|
+
dateRange: () => '',
|
|
473
|
+
})
|
|
474
|
+
expect(approved).toContain("t('leave_cancel_body'")
|
|
475
|
+
expect(approved).toContain('count: cancelling.workingDays')
|
|
476
|
+
expect(approved).toContain('range: dateRange(cancelling.startsOn, cancelling.endsOn)')
|
|
477
|
+
expect(approved).toContain("t('leave_cancel_approved')")
|
|
478
|
+
|
|
479
|
+
const pending = leave.render('cancelling', {
|
|
480
|
+
cancelling: { workingDays: 1, startsOn: '2026-09-09', endsOn: '2026-09-09', status: 'pending' },
|
|
481
|
+
dateRange: () => '',
|
|
482
|
+
})
|
|
483
|
+
expect(pending).toContain("t('leave_cancel_body'")
|
|
484
|
+
expect(pending).not.toContain("t('leave_cancel_approved')")
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* `disabled={cancel.isPending}` reaches the button on the *next* render, so two quick clicks both
|
|
489
|
+
* fire — and the second arrives at a request the first already cancelled, which the server refuses
|
|
490
|
+
* for something the person did not do.
|
|
491
|
+
*/
|
|
492
|
+
it('guards the second click in the same tick as the first', () => {
|
|
493
|
+
expect(leave.source).toContain('let cancelInFlight = $state(false)')
|
|
494
|
+
expect(leave.source).toMatch(/if \(!cancelling \|\| cancelInFlight\) return\s+cancelInFlight = true/)
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* `t()` as the runtime resolves it: this module's prefix on a bare key, and — the half `cancelFailure`
|
|
500
|
+
* turns on — the key itself when nothing defines a string for it. `t()` is tested where it lives
|
|
501
|
+
* (`packages/ui/src/lib/i18n.test.ts`); this is the two lines of it the page reads.
|
|
502
|
+
*/
|
|
503
|
+
const translator = (strings: Record<string, string>) => (key: string) =>
|
|
504
|
+
strings[key.includes('.') ? key : `hr.${key}`] ?? (key.includes('.') ? key : `hr.${key}`)
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Markers rather than the real English sentences: an assertion naming the real string could not tell
|
|
508
|
+
* a translation apart from the server's fallback, which is the whole distinction under test.
|
|
509
|
+
*/
|
|
510
|
+
const strings: Record<string, string> = { 'hr.leave_cancel_error': '[fallback]' }
|
|
511
|
+
|
|
512
|
+
/** The page's own `cancelFailure`, run, with every free name it uses handed to it. */
|
|
513
|
+
function failureText(
|
|
514
|
+
error: unknown,
|
|
515
|
+
refusals: Record<string, string> = {},
|
|
516
|
+
bundle: Record<string, string> = strings,
|
|
517
|
+
): string {
|
|
518
|
+
return new Function('t', 'cancelRefusalMessages', `${leave.fn('cancelFailure')}; return cancelFailure`)(
|
|
519
|
+
translator(bundle),
|
|
520
|
+
refusals,
|
|
521
|
+
)(error)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* oRPC hands the client a `KernError` re-thrown as an `ORPCError`: an `Error` with a `code` and, for
|
|
526
|
+
* a refusal, whatever the router attached — `kernErrorToORPC` carries a `reason` across as `data`.
|
|
527
|
+
*/
|
|
528
|
+
const refusal = (message: string, reason?: string) =>
|
|
529
|
+
Object.assign(new Error(message), { code: 'CONFLICT', data: reason ? { reason } : undefined })
|
|
530
|
+
|
|
531
|
+
describe('a refused cancellation', () => {
|
|
532
|
+
/**
|
|
533
|
+
* The defect. `onError: (error) => toast.error(error.message)` put the router's English —
|
|
534
|
+
* "That request is already cancelled" — in front of somebody who reads Persian and cannot act on
|
|
535
|
+
* it either way.
|
|
536
|
+
*/
|
|
537
|
+
it('translates the reason where the router sends one', () => {
|
|
538
|
+
const text = failureText(
|
|
539
|
+
refusal('That request is already cancelled', 'hr.leave.already_cancelled'),
|
|
540
|
+
{ 'hr.leave.already_cancelled': 'hr.leave_cancel_refused_gone' },
|
|
541
|
+
{ ...strings, 'hr.leave_cancel_refused_gone': '[translated: already cancelled]' },
|
|
542
|
+
)
|
|
543
|
+
expect(text).toBe('[translated: already cancelled]')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `leave.requests.cancel` refuses through `KernError.conflict(message)` with no reason today, so
|
|
548
|
+
* the router's sentence is the only thing that says *which* refusal this is. Losing it for a
|
|
549
|
+
* generic string would tell the reader less than the English does.
|
|
550
|
+
*/
|
|
551
|
+
it('falls back to the router’s sentence for a refusal with no reason of its own', () => {
|
|
552
|
+
expect(failureText(refusal('That request is already cancelled'))).toBe(
|
|
553
|
+
'That request is already cancelled',
|
|
554
|
+
)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('falls back to the router’s sentence for a reason no key covers yet', () => {
|
|
558
|
+
expect(failureText(refusal('Something new happened', 'hr.leave.brand_new'))).toBe(
|
|
559
|
+
'Something new happened',
|
|
560
|
+
)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* `t()` answers a key it has no string for with the key itself, so a mapping whose translation has
|
|
565
|
+
* not been merged must not put `hr.leave_cancel_refused_gone` in front of anybody.
|
|
566
|
+
*/
|
|
567
|
+
it('never shows a message key when the mapped string has not been merged', () => {
|
|
568
|
+
const text = failureText(refusal('That request is already cancelled', 'hr.leave.already_cancelled'), {
|
|
569
|
+
'hr.leave.already_cancelled': 'hr.leave_cancel_refused_gone',
|
|
570
|
+
})
|
|
571
|
+
expect(text).toBe('That request is already cancelled')
|
|
572
|
+
expect(text).not.toContain('hr.')
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* A network drop, a 500 or a gateway carry machine text, in English, written for nobody — the
|
|
577
|
+
* refusal is the only failure here that has a sentence meant for a reader.
|
|
578
|
+
*/
|
|
579
|
+
it('uses this module’s own string for a failure that is not a refusal', () => {
|
|
580
|
+
expect(failureText(Object.assign(new Error('Failed to fetch'), { code: 'INTERNAL' }))).toBe('[fallback]')
|
|
581
|
+
expect(failureText(new TypeError('Failed to fetch'))).toBe('[fallback]')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
/** A refusal with an empty sentence must still say something. */
|
|
585
|
+
it('falls back to this module’s string when the refusal carries no sentence', () => {
|
|
586
|
+
expect(failureText(Object.assign(new Error(''), { code: 'CONFLICT' }))).toBe('[fallback]')
|
|
587
|
+
})
|
|
588
|
+
})
|
package/src/client/query.ts
CHANGED
|
@@ -38,6 +38,18 @@ export const hrKeys = {
|
|
|
38
38
|
approvalInbox: (ws: string, includeDecided = false) =>
|
|
39
39
|
['hr', 'approvals', ws, includeDecided ? 'decided' : 'waiting'] as const,
|
|
40
40
|
delegations: (ws: string) => ['hr', 'delegations', ws] as const,
|
|
41
|
+
calendar: (ws: string, id: string) => ['hr', 'calendar', ws, id] as const,
|
|
42
|
+
calendarWorkingDays: (ws: string, calendarId: string, from: string, to: string) =>
|
|
43
|
+
['hr', 'calendar-working-days', ws, calendarId, from, to] as const,
|
|
44
|
+
/**
|
|
45
|
+
* The pack diff is keyed by the pack and the year as well as the calendar: an admin comparing two
|
|
46
|
+
* years must not be shown the first one's answer while the second is still in flight.
|
|
47
|
+
*/
|
|
48
|
+
calendarPackPreview: (ws: string, calendarId: string, packKey: string, year: number) =>
|
|
49
|
+
['hr', 'calendar-pack-preview', ws, calendarId, packKey, year] as const,
|
|
50
|
+
entities: (ws: string) => ['hr', 'entities', ws] as const,
|
|
51
|
+
officePeople: (ws: string, officeId: string, primaryOnly: boolean) =>
|
|
52
|
+
['hr', 'office-people', ws, officeId, primaryOnly ? 'primary' : 'all'] as const,
|
|
41
53
|
} as const
|
|
42
54
|
|
|
43
55
|
/** `YYYY-MM-DD` for a date, in the viewer's own zone rather than UTC. */
|