Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions src/views/WorkspaceApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -511,8 +511,10 @@ export default {
* `null` undims everything; an array dims every item whose id is
* NOT present (an empty array therefore dims everything).
*
* @param {Array<string>|null} matchIds the current matching ids.
* @param {Array<string|number>|null} matchIds the current matching ids
* (numbers off the API row, strings once normalised below).
* @return {void}
* @spec openspec/specs/tile-quick-search/spec.md
*/
applySearchDimming(matchIds) {
if (typeof document === 'undefined') {
Expand All @@ -522,13 +524,26 @@ export default {
if (!items) {
return
}
/*
* A PLACEMENT ID IS A NUMBER; A DOM ATTRIBUTE IS A STRING.
* `matchIds` comes from `searchableTiles()`, which copies
* `placement.id` straight off the API row — an integer. The value
* read back out of a rendered cell is `getAttribute()`, which is
* always a string. `Array.prototype.includes` compares with
* SameValueZero, i.e. no coercion at all, so `[7].includes('7')`
* is `false` — EVERY tile was dimmed on every query, including
* the matches the user was looking for (launchpad#95).
* Normalising both sides to strings makes the comparison one
* between two values of the same type.
*/
const wanted = matchIds === null ? null : matchIds.map((id) => String(id))
items.forEach((el) => {
if (matchIds === null) {
if (wanted === null) {
el.classList.remove('launchpad-grid-item--dimmed')
return
}
const id = el.getAttribute('data-placement-id')
el.classList.toggle('launchpad-grid-item--dimmed', matchIds.includes(id) === false)
el.classList.toggle('launchpad-grid-item--dimmed', wanted.includes(id) === false)
})
},

Expand All @@ -539,12 +554,28 @@ export default {
* REQ-QSEARCH-003 "honouring its configured link target"). Non-tile
* placements without a link are focused instead, best-effort.
*
* @param {{id: string, placement: object}} item the opened search
* result.
* @param {{id: (string|number), placement: object}} item the opened
* search result. `placement.id` is an INTEGER off the API row.
* @return {void}
* @spec openspec/specs/tile-quick-search/spec.md
*/
activateSearchResult(item) {
const placementId = item?.placement?.id
/*
* `String(...)`, not the raw value. `placement.id` is an INTEGER
* off the API row, and the line below used to call
* `placementId.replace(...)` on it. `Number.prototype.replace`
* does not exist, so this threw a `TypeError` on every single
* activation — inside a Vue event handler, where nothing surfaces
* it, so pressing Enter on a search result silently did nothing
* (launchpad#95). The truthiness guard above did not catch it: a
* non-zero integer is truthy.
*
* `?? ''` rather than a bare cast so that `null`/`undefined`
* become the empty string and are rejected by the guard, instead
* of being stringified into the literal `"null"` and sent to
* `querySelector` as a real id to look for.
*/
const placementId = String(item?.placement?.id ?? '')
if (!placementId || !this.$el) {
return
}
Expand Down
91 changes: 91 additions & 0 deletions src/views/__tests__/WorkspaceApp.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,97 @@ describe('WorkspaceApp', () => {
expect(clickSpy).toHaveBeenCalled()
})

/*
* launchpad#95 — TWO DEFECTS, ONE ROOT CAUSE, AND A FIXTURE THAT HID
* BOTH.
*
* Every test above seeds a placement id as a STRING (`'p1'`,
* `'match'`). `WidgetPlacement` rows arrive from
* `GET /api/dashboard/{id}` with an INTEGER `id` — the column is an
* auto-increment primary key — so no fixture in this file had ever
* exercised the type the product actually handles, and both defects
* below were invisible to a green suite:
*
* 1. `applySearchDimming()` compared `getAttribute()` (always a
* string) against the raw ids with `Array.includes`, which does
* not coerce. With integer ids nothing ever matched, so EVERY
* tile was dimmed — including the ones the user searched for.
* 2. `activateSearchResult()` called `.replace()` on the id.
* `Number.prototype.replace` does not exist, so Enter threw a
* `TypeError` inside a Vue event handler and silently did
* nothing.
*
* These two tests are the regression guard, and they are written
* against the production shape on purpose. Both are RED on the code
* as it stood before the fix: the first because `matchEl` is dimmed,
* the second because the call throws before reaching the link.
*/
it('onSearchFilter leaves an INTEGER-id match undimmed (launchpad#95)', () => {
const wrapper = mountShell({ inject: { activeDashboardId: 'd1' } })
const grid = wrapper.find('.workspace-shell__grid').element
const matchEl = document.createElement('div')
matchEl.className = 'launchpad-grid-item'
matchEl.setAttribute('data-placement-id', '7')
const otherEl = document.createElement('div')
otherEl.className = 'launchpad-grid-item'
otherEl.setAttribute('data-placement-id', '8')
grid.appendChild(matchEl)
grid.appendChild(otherEl)

// The ids are numbers, exactly as `searchableTiles()` copies them
// off the API row — NOT the strings the older fixtures used.
wrapper.vm.onSearchFilter([7])

expect(
matchEl.classList.contains('launchpad-grid-item--dimmed'),
'a matching tile must not be de-emphasised',
).toBe(false)
expect(
otherEl.classList.contains('launchpad-grid-item--dimmed'),
'CONTROL: a non-matching tile must still be de-emphasised, or the assertion above is satisfied by "nothing is ever dimmed"',
).toBe(true)
})

it('onSearchOpen activates a tile whose placement id is an INTEGER (launchpad#95)', () => {
const wrapper = mountShell({ inject: { activeDashboardId: 'd1' } })
const grid = wrapper.find('.workspace-shell__grid').element
const el = document.createElement('div')
el.className = 'launchpad-grid-item'
el.setAttribute('data-placement-id', '7')
const link = document.createElement('a')
link.setAttribute('href', '#deck')
el.appendChild(link)
grid.appendChild(el)

const scrollSpy = vi.fn()
el.scrollIntoView = scrollSpy
const clickSpy = vi.spyOn(link, 'click')

wrapper.vm.onSearchOpen({ id: 7, label: 'Deck', placement: { id: 7 } })

expect(scrollSpy, 'the matched tile must be scrolled into view').toHaveBeenCalled()
expect(clickSpy, 'Enter must activate the tile\'s rendered link').toHaveBeenCalled()
})

it('onSearchOpen ignores a placement with no id rather than looking for the string "null"', () => {
const wrapper = mountShell({ inject: { activeDashboardId: 'd1' } })
const grid = wrapper.find('.workspace-shell__grid').element
const el = document.createElement('div')
el.className = 'launchpad-grid-item'
// A cell literally labelled "null" — the shape a bare String()
// cast would go looking for, and find.
el.setAttribute('data-placement-id', 'null')
const link = document.createElement('a')
link.setAttribute('href', '#deck')
el.appendChild(link)
grid.appendChild(el)
const clickSpy = vi.spyOn(link, 'click')

wrapper.vm.onSearchOpen({ id: null, label: 'Deck', placement: { id: null } })

expect(clickSpy, 'a null id must not activate the tile that happens to be called "null"').not.toHaveBeenCalled()
})

it('onSearchFallback opens a web-search URL in a new tab', () => {
const wrapper = mountShell({ inject: { activeDashboardId: 'd1' } })
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {})
Expand Down
Loading
Loading