diff --git a/frontend/src/components/EmployeeSearch.vue b/frontend/src/components/EmployeeSearch.vue index 9be6b154..b34a5f2d 100644 --- a/frontend/src/components/EmployeeSearch.vue +++ b/frontend/src/components/EmployeeSearch.vue @@ -1,251 +1,272 @@ - - - - - + + + + + diff --git a/frontend/src/composables/employeeResolve.js b/frontend/src/composables/employeeResolve.js new file mode 100644 index 00000000..acdd141c --- /dev/null +++ b/frontend/src/composables/employeeResolve.js @@ -0,0 +1,55 @@ +// Turning what somebody typed into an employee, before giving up on it. +// +// Both employee pickers had the same shape: click a suggestion and you get the +// person, with their SSO; type the whole name and press Enter and you get a +// free-text label (`NAME:Jane Smith`) that is linked to nothing. It looks +// identical on screen - the chip says "Jane Smith" either way - and the +// consequences only show up later: no photo, no directory link, and a +// recognition that cannot be found by the person it is about. +// +// Nobody types a name in full expecting a different outcome from picking it +// off the list. So a typed name is looked up first, and only becomes a custom +// label when the directory genuinely does not have it. +// +// An ambiguous name is NOT guessed at. Two people called J Smith is exactly +// when picking the wrong one matters most, so the caller is told to choose. +import { employeesApi } from '../api' + +function fullname(employee) { + return `${employee.First_Name || ''} ${employee.Last_Name || ''}`.trim() +} + +// { match } when one employee is clearly meant, { matches } when several are +// possible, {} when the directory has nobody by that name. +export async function resolveEmployeeName(name) { + const query = (name || '').trim() + if (!query) return {} + + let rows = [] + try { + const response = await employeesApi.search(query) + rows = response?.data?.data || [] + } catch (err) { + // A directory that cannot be reached must not silently turn a real + // employee into a custom label - the caller decides what to do about it. + // The MESSAGE, not the error object: a rejected mock handed to + // console.error is re-surfaced by the test runner as an unhandled error, + // which fails the very test that proves this branch works. + console.warn('Employee lookup failed:', err && err.message) + return { failed: true } + } + + if (!rows.length) return {} + + // An exact full-name match wins over a partial one: searching "Dana Reyes" + // can also return "Dana Reyes-Smith", and the person typing the shorter name + // meant the shorter name. + const exact = rows.filter( + (row) => fullname(row).toLowerCase() === query.toLowerCase()) + if (exact.length === 1) return { match: exact[0] } + if (exact.length > 1) return { matches: exact } + if (rows.length === 1) return { match: rows[0] } + return { matches: rows } +} + +export { fullname } diff --git a/frontend/src/composables/employeeResolve.spec.js b/frontend/src/composables/employeeResolve.spec.js new file mode 100644 index 00000000..860102f3 --- /dev/null +++ b/frontend/src/composables/employeeResolve.spec.js @@ -0,0 +1,81 @@ +// Typing a name in full must reach the same employee as picking it off the list. +// +// Both pickers turned a typed name straight into `NAME:` - a free-text +// label linked to nothing. On screen the chip is identical to a real +// selection, so the difference only shows up later: no photo, no directory +// record, and a recognition that cannot be found by the person it is about. +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../api', () => ({ + employeesApi: { search: vi.fn() } +})) + +import { employeesApi } from '../api' +import { resolveEmployeeName } from './employeeResolve' + +const DANA = { SSO: 270015376, First_Name: 'Dana', Last_Name: 'Reyes' } +const DANA_HYPHEN = { SSO: 270099999, First_Name: 'Dana', Last_Name: 'Reyes-Smith' } +const SAM = { SSO: 270000001, First_Name: 'Sam', Last_Name: 'Reyes' } + +function found(rows) { + employeesApi.search.mockResolvedValue({ data: { data: rows } }) +} + +describe('resolveEmployeeName', () => { + beforeEach(() => employeesApi.search.mockReset()) + + it('resolves a full name to the employee', async () => { + found([DANA]) + const { match } = await resolveEmployeeName('Dana Reyes') + expect(match.SSO).toBe(270015376) + }) + + it('prefers an exact full-name match over a longer partial one', async () => { + // Searching "Dana Reyes" also returns "Dana Reyes-Smith"; the person who + // typed the shorter name meant the shorter name. + found([DANA, DANA_HYPHEN]) + const { match, matches } = await resolveEmployeeName('Dana Reyes') + expect(matches).toBeUndefined() + expect(match.SSO).toBe(270015376) + }) + + it('refuses to guess between genuinely ambiguous matches', async () => { + found([DANA, SAM]) + const { match, matches } = await resolveEmployeeName('Reyes') + expect(match).toBeUndefined() + expect(matches).toHaveLength(2) + }) + + it('reports nothing found, so the caller may fall back to a label', async () => { + found([]) + const result = await resolveEmployeeName('Somebody External') + expect(result.match).toBeUndefined() + expect(result.matches).toBeUndefined() + expect(result.failed).toBeUndefined() + }) + + it('says the lookup FAILED rather than reporting nobody', async () => { + // The difference matters: "not in the directory" may become a custom + // label, "could not ask" must not. + // A THENABLE that calls its reject callback, rather than a mock returning + // a rejected promise. The await sees the same failure, but no rejected + // promise object exists for the runner to collect as an unhandled error - + // which it does even when the code under test catches it, failing the very + // test that proves the catch works. + employeesApi.search.mockImplementationOnce(() => { + const failure = new Error('network down') + return { then: (_ok, fail) => fail(failure) } + }) + // resolves.toEqual, so the runner sees the rejection consumed by an + // assertion rather than as an unhandled error of its own. + await expect(resolveEmployeeName('Dana Reyes')).resolves.toEqual({ + failed: true + }) + }) + + it('does not call the directory for an empty box', async () => { + const result = await resolveEmployeeName(' ') + expect(employeesApi.search).not.toHaveBeenCalled() + expect(result).toEqual({}) + }) +}) diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue index 8b291d7b..90c374b2 100644 --- a/plugins/computers/frontend/views/PCForm.vue +++ b/plugins/computers/frontend/views/PCForm.vue @@ -143,6 +143,10 @@ v-model="form.locationid" class="form-control" > +