Date: Wed, 15 Jan 2025 15:47:12 +0000
Subject: [PATCH 04/10] 494910: Autocomplete updates
- Bring in the publish work
- Bring in the sibiling data fetcher work
- Add typeahead flag a typeahead will make calls whilst typing
- Update autocomplete to only make a fetch with matching text or
suggestion
- Updates to tests and increase coverage
---
.../autocomplete-advanced.test.js | 72 +---
.../components/autocomplete/autocomplete.js | 134 +++++-
.../autocomplete/autocomplete.test.js | 387 +++++++++++++-----
.../components/autocomplete/template.njk | 14 +-
4 files changed, 444 insertions(+), 163 deletions(-)
diff --git a/src/server/common/components/autocomplete/autocomplete-advanced.test.js b/src/server/common/components/autocomplete/autocomplete-advanced.test.js
index 7103d535..45840254 100644
--- a/src/server/common/components/autocomplete/autocomplete-advanced.test.js
+++ b/src/server/common/components/autocomplete/autocomplete-advanced.test.js
@@ -2,6 +2,7 @@ import { renderTestComponent } from '~/test-helpers/component-helpers.js'
import { AutocompleteAdvanced } from '~/src/server/common/components/autocomplete/autocomplete-advanced.js'
import { publish } from '~/src/client/common/helpers/event-emitter.js'
import { defaultOption } from '~/src/server/common/helpers/options/default-option.js'
+import { enterValue, pressEnter } from '~/test-helpers/keyboard.js'
describe('#autocomplete-advanced', () => {
let autocompleteInput
@@ -165,9 +166,7 @@ describe('#autocomplete-advanced', () => {
describe('When partial value', () => {
describe('Entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'abb'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'abb')
})
test('Should open suggestions', () => {
@@ -187,9 +186,7 @@ describe('#autocomplete-advanced', () => {
describe('That matches multiple suggestions is entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'ro'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'ro')
})
test('Should open suggestions', () => {
@@ -228,9 +225,7 @@ describe('#autocomplete-advanced', () => {
describe('With exact match entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'Barbie'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'Barbie')
})
test('Should show all suggestions', () => {
@@ -253,9 +248,7 @@ describe('#autocomplete-advanced', () => {
describe('With hint text entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'Id: 556'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'Id: 556')
suggestionsContainer.children[0].click()
})
@@ -282,15 +275,11 @@ describe('#autocomplete-advanced', () => {
describe('When value removed from input', () => {
beforeEach(() => {
- autocompleteInput.focus()
-
// Add value to input
- autocompleteInput.value = 'fro'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'fro')
// Remove value from input
- autocompleteInput.value = ''
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, '')
})
test('Suggestions should be open', () => {
@@ -311,9 +300,7 @@ describe('#autocomplete-advanced', () => {
describe('When value without results entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'blah'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'blah')
})
test('Should open suggestions', () => {
@@ -331,9 +318,7 @@ describe('#autocomplete-advanced', () => {
describe('When no results message is clicked', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'ranDom'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'ranDom')
suggestionsContainer.children[0].click()
})
@@ -459,10 +444,7 @@ describe('#autocomplete-advanced', () => {
autocompleteInput.dispatchEvent(arrowDownKeyEvent)
autocompleteInput.dispatchEvent(arrowDownKeyEvent)
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ pressEnter(autocompleteInput)
})
test('Should provide expected suggestion value', () => {
@@ -604,16 +586,8 @@ describe('#autocomplete-advanced', () => {
describe('When keyboard "enter" key is pressed with input value', () => {
beforeEach(() => {
- autocompleteInput.focus()
-
- // Add value to input
- autocompleteInput.value = 'fro'
- autocompleteInput.dispatchEvent(new Event('input'))
-
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ enterValue(autocompleteInput, 'fro')
+ pressEnter(autocompleteInput)
})
test('Suggestions should be closed', () => {
@@ -624,16 +598,8 @@ describe('#autocomplete-advanced', () => {
describe('When keyboard "enter" key is pressed with matching input value', () => {
beforeEach(() => {
- autocompleteInput.focus()
-
- // Add value to input
- autocompleteInput.value = 'RoboCop'
- autocompleteInput.dispatchEvent(new Event('input'))
-
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ enterValue(autocompleteInput, 'RoboCop')
+ pressEnter(autocompleteInput)
})
test('Suggestions should be closed', () => {
@@ -701,10 +667,7 @@ describe('#autocomplete-advanced', () => {
})
autocompleteInput.dispatchEvent(escapeKeyEvent)
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ pressEnter(autocompleteInput)
})
test('Suggestions should be open', () => {
@@ -819,10 +782,7 @@ describe('#autocomplete-advanced', () => {
describe('With publish event', () => {
test('Should reset autocomplete', () => {
- // enter value into autocomplete
- autocompleteInput.focus()
- autocompleteInput.value = 'Run get to the chopper'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'Run get to the chopper')
// select first suggestion
suggestionsContainer.children[0].click()
diff --git a/src/server/common/components/autocomplete/autocomplete.js b/src/server/common/components/autocomplete/autocomplete.js
index 04045fc2..6593783b 100644
--- a/src/server/common/components/autocomplete/autocomplete.js
+++ b/src/server/common/components/autocomplete/autocomplete.js
@@ -1,7 +1,12 @@
import qs from 'qs'
import isNull from 'lodash/isNull.js'
-import { subscribe } from '~/src/client/common/helpers/event-emitter.js'
+import {
+ publish,
+ subscribe
+} from '~/src/client/common/helpers/event-emitter.js'
+import { buildSuggestions } from '~/src/server/common/components/autocomplete/helpers/build-suggestions.js'
+import { clientNotification } from '~/src/client/common/helpers/client-notification.js'
const selectMessage = ' - - select - - '
const tickSvgIcon = `
@@ -74,9 +79,23 @@ class Autocomplete {
const $autocomplete = document.createElement('input')
this.subscribeTo = $select.dataset.subscribeTo
+ this.publishTo = $select.dataset.publishTo
+
this.noSuggestionsMessage = $select.dataset.noSuggestionsMessage
this.removePassWidgets = $select.dataset.removePassWidgets
this.placeholder = $select.dataset.placeholder
+ this.typeahead = $select.dataset.typeahead
+
+ this.siblingDataFetcher = {
+ isEnabled: $select.dataset.siblingDataFetcherName,
+ name: $select.dataset.siblingDataFetcherName,
+ $target: document.querySelector(
+ `[data-js*="${$select.dataset.siblingDataFetcherTarget}"]`
+ ),
+ $targetLoader: document.querySelector(
+ `[data-js*="${$select.dataset.siblingDataFetcherTargetLoader}"]`
+ )
+ }
const suggestion = this.getSuggestionByValue($select.value)
@@ -460,9 +479,80 @@ class Autocomplete {
subscribe(this.subscribeTo, this.resetAutocomplete.bind(this))
}
+ publishEvent(name, value) {
+ publish(this.publishTo, { queryParams: { [name]: value } })
+ }
+
+ /**
+ * Call sibling data fetcher method to fetch suggestions for a targeted sibling input in the same form
+ * @param {string} value - text input value
+ * @returns {undefined|Suggestions|Error}
+ */
+ callSiblingDataFetcher(value) {
+ const siblingDataFetcherMethod = window.cdp[this.siblingDataFetcher.name]
+ const targetId = this.siblingDataFetcher.$target?.id
+
+ if (!targetId || typeof siblingDataFetcherMethod !== 'function') {
+ return
+ }
+
+ if (!value) {
+ window.cdp.suggestions[targetId] = []
+ return
+ }
+
+ const isLoadingClassName = 'app-loader--is-loading'
+ const $targetLoader = this.siblingDataFetcher.$targetLoader
+ const delayedLoader = setTimeout(() => {
+ $targetLoader.classList.add(isLoadingClassName)
+ }, 200)
+
+ return siblingDataFetcherMethod(value)
+ .then((fetchedSuggestions) => {
+ const suggestionOptions = buildSuggestions(fetchedSuggestions)
+ window.cdp.suggestions[targetId] = suggestionOptions
+
+ return suggestionOptions
+ })
+ .catch((error) => {
+ clientNotification(error.message)
+ return error
+ })
+ .finally(() => {
+ clearTimeout(delayedLoader)
+ $targetLoader?.classList?.remove(isLoadingClassName)
+ })
+ }
+
+ /**
+ * @typedef {object} Input
+ * @property {string|undefined} text - the visual text you see in the input
+ * @property {string|undefined} value - the value set to the hidden input
+ */
+
+ /**
+ * Set input text value and hidden input value. Both can be empty strings or undefined
+ * Also dispatch publish event if setup and call fetcher if enabled
+ * @param {Input} args
+ */
+ updateInputValue({ text, value, withPublish = true } = {}) {
+ const inputText = text ?? ''
+ const inputValue = value ?? ''
+
+ this.$autocomplete.value = inputText
+ this.$autocompleteHiddenInput.value = inputValue
+
+ if (this.publishTo && withPublish) {
+ this.publishEvent(this.$autocompleteHiddenInput.name, inputValue)
+ }
+
+ if (this.siblingDataFetcher.isEnabled) {
+ this.callSiblingDataFetcher(inputValue)
+ }
+ }
+
resetAutocompleteValues() {
- this.$autocomplete.value = ''
- this.$autocompleteHiddenInput.value = ''
+ this.updateInputValue({ text: '', value: '' })
}
resetAutocomplete() {
@@ -488,9 +578,10 @@ class Autocomplete {
if (queryParamValue) {
const suggestion = this.getSuggestionByValue(queryParamValue)
- this.$autocomplete.value = suggestion?.text ?? queryParamValue
- this.$autocompleteHiddenInput.value =
- suggestion?.value ?? queryParamValue
+ const text = suggestion?.text ?? queryParamValue
+ const value = suggestion?.value ?? queryParamValue
+ this.updateInputValue({ text, value, withPublish: false })
+
this.showCloseButton()
}
@@ -593,8 +684,21 @@ class Autocomplete {
suggestionIndex: this.suggestionIndex
})
- const suggestion = this.getSuggestionByText(textValue)
- this.$autocompleteHiddenInput.value = suggestion?.value ?? textValue
+ const foundSuggestion = this.getSuggestionByText(textValue)
+
+ // An exact match was found
+ if (foundSuggestion?.value) {
+ this.updateInputValue({
+ text: foundSuggestion.text,
+ value: foundSuggestion.value
+ })
+ return
+ }
+
+ // Only send request to search on typing if component is a typeahead. Always clear if value is empty
+ if (this.typeahead ?? !textValue) {
+ this.updateInputValue({ text: textValue, value: textValue })
+ }
})
// Mainly keyboard navigational events
@@ -765,9 +869,11 @@ class Autocomplete {
this.suggestionIndex
)
- this.$autocomplete.value = $currentSuggestion.dataset?.text
- this.$autocompleteHiddenInput.value =
- $currentSuggestion.dataset?.value
+ this.updateInputValue({
+ text: $currentSuggestion.dataset?.text,
+ value: $currentSuggestion.dataset?.value
+ })
+
this.dispatchInputEvent()
this.suggestionIndex = null
}
@@ -790,8 +896,10 @@ class Autocomplete {
)
if ($suggestion && $suggestion.dataset.interactive !== 'false') {
- this.$autocomplete.value = $suggestion?.dataset?.text
- this.$autocompleteHiddenInput.value = $suggestion?.dataset?.value
+ this.updateInputValue({
+ text: $suggestion?.dataset?.text,
+ value: $suggestion?.dataset?.value
+ })
this.dispatchInputEvent()
this.suggestionIndex = $suggestion.getAttribute('aria-posinset') - 1
diff --git a/src/server/common/components/autocomplete/autocomplete.test.js b/src/server/common/components/autocomplete/autocomplete.test.js
index 3553fb53..2c3adc9a 100644
--- a/src/server/common/components/autocomplete/autocomplete.test.js
+++ b/src/server/common/components/autocomplete/autocomplete.test.js
@@ -1,7 +1,10 @@
+import { subscribe } from '~/src/client/common/helpers/event-emitter.js'
import { renderTestComponent } from '~/test-helpers/component-helpers.js'
import { Autocomplete } from '~/src/server/common/components/autocomplete/autocomplete.js'
import { defaultOption } from '~/src/server/common/helpers/options/default-option.js'
import { dispatchDomContentLoaded } from '~/test-helpers/dispatch-dom-content-loaded.js'
+import { enterValue, pressEnter } from '~/test-helpers/keyboard.js'
+import { flushAsync } from '~/test-helpers/flush-async.js'
const basicSuggestions = [
defaultOption,
@@ -18,11 +21,8 @@ const basicSuggestions = [
value: 'Barbie'
}
]
-let autocompleteInput
-let chevronButton
-let suggestionsContainer
-function setupAutoComplete({ userSearchParam, params = {} } = {}) {
+function setupAutoComplete({ userSearchParam, params = {} }) {
if (userSearchParam) {
global.window = Object.create(window)
Object.defineProperty(window, 'location', {
@@ -32,32 +32,31 @@ function setupAutoComplete({ userSearchParam, params = {} } = {}) {
})
}
- const $component = renderTestComponent('autocomplete', {
- label: {
- text: 'By'
- },
- hint: {
- text: 'Choose a user'
- },
- id: 'user',
- name: 'user',
- suggestions: basicSuggestions,
- ...params
- })
-
- // Add suggestions into the components tag
- const scriptElement = document.createElement('script')
- scriptElement.innerHTML = $component(
- '[data-testid="app-autocomplete-suggestions"]'
- )
- .first()
- .html()
- document.getElementsByTagName('html')[0].appendChild(scriptElement)
+ return renderTestComponent('autocomplete', params)
+}
- // Append dropdown component to a form and then add it to the document
- document.body.innerHTML = ``
+/**
+ * Set up mock form
+ */
+function setupForm($components) {
+ document.body.innerHTML = ``
+
+ // Add components suggestions into the components tag
+ $components.forEach(($component) => {
+ const scriptElement = document.createElement('script')
+ scriptElement.innerHTML = $component(
+ '[data-testid="app-autocomplete-suggestions"]'
+ )
+ .first()
+ .html()
+ document.getElementsByTagName('html')[0].appendChild(scriptElement)
+
+ const form = document.getElementById('mock-dropdown-form')
+
+ form.innerHTML += $component('[data-testid="app-autocomplete-group"]')
+ .first()
+ .html()
+ })
// Init ClientSide JavaScript
const autocompletes = Array.from(
@@ -68,23 +67,123 @@ function setupAutoComplete({ userSearchParam, params = {} } = {}) {
autocompletes.forEach(($autocomplete) => new Autocomplete($autocomplete))
}
- autocompleteInput = document.querySelector(
- '[data-testid="app-autocomplete-input"]'
- )
- chevronButton = document.querySelector('[data-testid="app-chevron-button"]')
- suggestionsContainer = document.querySelector(
- '[data-testid="app-autocomplete-suggestions"]'
- )
+ return autocompletes
+}
+
+function setupSingleAutoComplete({ userSearchParam, params = {} } = {}) {
+ const elements = setup([
+ setupAutoComplete({
+ params: {
+ label: { text: 'By' },
+ hint: { text: 'Choose a user' },
+ id: 'user',
+ name: 'user',
+ suggestions: basicSuggestions,
+ ...params
+ },
+ userSearchParam
+ })
+ ])
if (userSearchParam) {
dispatchDomContentLoaded()
}
+
+ const firstElement = elements.at(0)
+
+ return {
+ autocompleteInput: firstElement.autocompleteInput,
+ autocompleteHiddenInput: firstElement.autocompleteHiddenInput,
+ chevronButton: firstElement.chevronButton,
+ suggestionsContainer: firstElement.suggestionsContainer
+ }
+}
+
+/**
+ * Setup multiple auto completes. One is a controller and the others data/suggestions is controlled by the first
+ * ones choice
+ */
+function setupMultipleAutoCompletes({ userSearchParam, params = {} } = {}) {
+ const elements = setup([
+ setupAutoComplete({
+ params: {
+ label: { text: 'By' },
+ hint: { text: 'Choose a user' },
+ id: 'user',
+ name: 'user',
+ suggestions: basicSuggestions,
+ ...params
+ }
+ }),
+ setupAutoComplete({
+ params: {
+ label: { text: 'Version' },
+ hint: { text: 'Choose a version' },
+ id: 'version',
+ name: 'version',
+ dataJs: 'version',
+ suggestions: [],
+ noSuggestionsMessage: 'choose Image name',
+ loader: {
+ name: 'version-loader'
+ }
+ }
+ })
+ ])
+
+ if (userSearchParam) {
+ dispatchDomContentLoaded()
+ }
+
+ const firstElement = elements.at(0)
+ const secondElement = elements.at(1)
+
+ return {
+ autocompleteInput: firstElement.autocompleteInput,
+ autocompleteHiddenInput: firstElement.autocompleteHiddenInput,
+ chevronButton: firstElement.chevronButton,
+ suggestionsContainer: firstElement.suggestionsContainer,
+ siblingAutocompleteInput: secondElement.autocompleteInput,
+ siblingAutocompleteHiddenInput: secondElement.autocompleteHiddenInput,
+ siblingChevronButton: secondElement.chevronButton,
+ siblingSuggestionsContainer: secondElement.suggestionsContainer
+ }
+}
+
+/**
+ * Setup Mock form and autocomplete functions. Provide autocomplete elements back for testing purposes
+ */
+function setup(components) {
+ const autoCompletes = setupForm(components)
+
+ return autoCompletes.map((autoComplete) => ({
+ autocompleteInput: autoComplete.querySelector(
+ '[data-testid="app-autocomplete-input"]'
+ ),
+ autocompleteHiddenInput: autoComplete.querySelector(`input[type="hidden"]`),
+ chevronButton: autoComplete.querySelector(
+ '[data-testid="app-chevron-button"]'
+ ),
+ suggestionsContainer: autoComplete.querySelector(
+ '[data-testid="app-autocomplete-suggestions"]'
+ )
+ }))
}
describe('#autocomplete', () => {
describe('Without query param', () => {
+ let autocompleteInput
+ let autocompleteHiddenInput
+ let chevronButton
+ let suggestionsContainer
+
beforeEach(() => {
- setupAutoComplete()
+ ;({
+ autocompleteInput,
+ autocompleteHiddenInput,
+ chevronButton,
+ suggestionsContainer
+ } = setupSingleAutoComplete())
})
describe('On load', () => {
@@ -170,9 +269,7 @@ describe('#autocomplete', () => {
describe('When partial value', () => {
describe('Entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'abb'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'abb')
})
test('Should open suggestions', () => {
@@ -188,13 +285,15 @@ describe('#autocomplete', () => {
'Roger Rabbit'
)
})
+
+ test('Should not have set hidden input value', () => {
+ expect(autocompleteHiddenInput.value).toBe('')
+ })
})
describe('That matches multiple suggestions is entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'ro'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'ro')
})
test('Should open suggestions', () => {
@@ -215,9 +314,7 @@ describe('#autocomplete', () => {
describe('With crazy case value entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'Barb'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'Barb')
})
test('Should narrow to only expected case insensitive suggestion', () => {
@@ -231,9 +328,7 @@ describe('#autocomplete', () => {
describe('With exact match entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'Barbie'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'Barbie')
})
test('Should show all suggestions', () => {
@@ -247,19 +342,16 @@ describe('#autocomplete', () => {
expect(matchedSuggestion.textContent.trim()).toBe('Barbie')
})
+
+ test('Should have set hidden input value', () => {
+ expect(autocompleteHiddenInput.value).toBe('Barbie')
+ })
})
describe('When value removed from input', () => {
beforeEach(() => {
- autocompleteInput.focus()
-
- // Add value to input
- autocompleteInput.value = 'fro'
- autocompleteInput.dispatchEvent(new Event('input'))
-
- // Remove value from input
- autocompleteInput.value = ''
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'fro')
+ enterValue(autocompleteInput, '')
})
test('Suggestions should be open', () => {
@@ -280,9 +372,7 @@ describe('#autocomplete', () => {
describe('When value without results entered into input', () => {
beforeEach(() => {
- autocompleteInput.focus()
- autocompleteInput.value = 'blah'
- autocompleteInput.dispatchEvent(new Event('input'))
+ enterValue(autocompleteInput, 'blah')
})
test('Should open suggestions', () => {
@@ -415,10 +505,7 @@ describe('#autocomplete', () => {
autocompleteInput.dispatchEvent(arrowDownKeyEvent)
autocompleteInput.dispatchEvent(arrowDownKeyEvent)
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ pressEnter(autocompleteInput)
})
test('Should provide expected suggestion value', () => {
@@ -557,16 +644,8 @@ describe('#autocomplete', () => {
describe('When keyboard "enter" key is pressed with input value', () => {
beforeEach(() => {
- autocompleteInput.focus()
-
- // Add value to input
- autocompleteInput.value = 'fro'
- autocompleteInput.dispatchEvent(new Event('input'))
-
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ enterValue(autocompleteInput, 'fro')
+ pressEnter(autocompleteInput)
})
test('Suggestions should be closed', () => {
@@ -577,16 +656,8 @@ describe('#autocomplete', () => {
describe('When keyboard "enter" key is pressed with matching input value', () => {
beforeEach(() => {
- autocompleteInput.focus()
-
- // Add value to input
- autocompleteInput.value = 'RoboCop'
- autocompleteInput.dispatchEvent(new Event('input'))
-
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ enterValue(autocompleteInput, 'RoboCop')
+ pressEnter(autocompleteInput)
})
test('Suggestions should be closed', () => {
@@ -654,10 +725,7 @@ describe('#autocomplete', () => {
})
autocompleteInput.dispatchEvent(escapeKeyEvent)
- const enterKeyEvent = new KeyboardEvent('keydown', {
- code: 'enter'
- })
- autocompleteInput.dispatchEvent(enterKeyEvent)
+ pressEnter(autocompleteInput)
})
test('Suggestions should be open', () => {
@@ -697,8 +765,13 @@ describe('#autocomplete', () => {
})
describe('With query param', () => {
+ let autocompleteInput
+ let suggestionsContainer
+
beforeEach(() => {
- setupAutoComplete({ userSearchParam: 'Barbie' })
+ ;({ autocompleteInput, suggestionsContainer } = setupSingleAutoComplete({
+ userSearchParam: 'Barbie'
+ }))
})
describe('On load with query param', () => {
@@ -756,17 +829,147 @@ describe('#autocomplete', () => {
describe('With placeholder', () => {
const typeHere = ' - - type here - - '
+ let autocompleteInput
beforeEach(() => {
- setupAutoComplete({
+ ;({ autocompleteInput } = setupSingleAutoComplete({
params: {
placeholder: typeHere
}
- })
+ }))
})
test('Should have expected placeholder', () => {
expect(autocompleteInput.getAttribute('placeholder')).toBe(typeHere)
})
})
+
+ describe('With publish to', () => {
+ const eventName = 'mock-auto-complete-event'
+ const mockSubscriber = jest.fn()
+ let autocompleteInput
+
+ beforeEach(() => {
+ ;({ autocompleteInput } = setupSingleAutoComplete({
+ params: { publishTo: eventName }
+ }))
+ subscribe(eventName, mockSubscriber)
+ })
+
+ test('Should publish to subscriber as expected', () => {
+ enterValue(autocompleteInput, 'RoboCop')
+ pressEnter(autocompleteInput)
+
+ expect(mockSubscriber).toHaveBeenCalled()
+ expect(mockSubscriber.mock.calls[0][0].detail).toEqual({
+ queryParams: {
+ user: 'RoboCop'
+ }
+ })
+ })
+ })
+
+ describe('With sibling data fetcher', () => {
+ const mockFetchVersions = jest.fn()
+ let autocompleteInput
+ let siblingAutocompleteInput
+ let siblingSuggestionsContainer
+
+ beforeEach(() => {
+ window.cdp = window.cdp || {}
+ window.cdp.fetchVersions = mockFetchVersions
+ ;({
+ autocompleteInput,
+ siblingAutocompleteInput,
+ siblingSuggestionsContainer
+ } = setupMultipleAutoCompletes({
+ params: {
+ siblingDataFetcher: {
+ name: 'fetchVersions',
+ target: 'version',
+ targetLoader: 'version-loader'
+ }
+ }
+ }))
+ })
+
+ test('When sibling input clicked, Should contain expected suggestions', () => {
+ siblingAutocompleteInput.click()
+ const children = siblingSuggestionsContainer.children
+
+ expect(children).toHaveLength(1)
+ expect(children[0].textContent).toContain(' - - choose Image name - - ')
+ })
+
+ test('When choice made in parent autocomplete, Should provide sibling with fetched suggestions', async () => {
+ mockFetchVersions.mockResolvedValue([
+ { text: '1.0.0', value: '1.0.0' },
+ { text: '1.1.0', value: '1.1.0' }
+ ])
+
+ enterValue(autocompleteInput, 'RoboCop')
+
+ expect(mockFetchVersions).toHaveBeenCalledWith('RoboCop')
+
+ await flushAsync()
+
+ siblingAutocompleteInput.click()
+ const children = siblingSuggestionsContainer.children
+
+ expect(children).toHaveLength(2)
+ expect(children[0].textContent).toContain('1.0.0')
+ expect(children[1].textContent).toContain('1.1.0')
+ })
+
+ test('When choice made and deleted, Should clear sibling suggestions', async () => {
+ mockFetchVersions.mockResolvedValue([
+ { text: '2.0.0', value: '2.0.0' },
+ { text: '2.1.0', value: '2.1.0' }
+ ])
+
+ enterValue(autocompleteInput, 'RoboCop')
+
+ expect(mockFetchVersions).toHaveBeenCalledWith('RoboCop')
+
+ await flushAsync()
+
+ siblingAutocompleteInput.click()
+ const children = siblingSuggestionsContainer.children
+
+ expect(children).toHaveLength(2)
+ expect(children[0].textContent).toContain('2.0.0')
+ expect(children[1].textContent).toContain('2.1.0')
+
+ siblingAutocompleteInput.blur()
+
+ enterValue(autocompleteInput, '')
+
+ siblingAutocompleteInput.click()
+
+ expect(children).toHaveLength(1)
+ expect(children[0].textContent).toContain(' - - choose Image name - - ')
+ })
+ })
+
+ describe('As a type ahead', () => {
+ let autocompleteInput
+ let autocompleteHiddenInput
+
+ beforeEach(() => {
+ ;({ autocompleteInput, autocompleteHiddenInput } =
+ setupSingleAutoComplete({ params: { typeahead: true } }))
+ })
+
+ test('Should have set hidden input value with partial match', () => {
+ enterValue(autocompleteInput, 'Rob')
+
+ expect(autocompleteHiddenInput.value).toBe('Rob')
+ })
+
+ test('Should have set hidden input value with full match', () => {
+ enterValue(autocompleteInput, 'RoboCop')
+
+ expect(autocompleteHiddenInput.value).toBe('RoboCop')
+ })
+ })
})
diff --git a/src/server/common/components/autocomplete/template.njk b/src/server/common/components/autocomplete/template.njk
index 4694ff15..3303b100 100644
--- a/src/server/common/components/autocomplete/template.njk
+++ b/src/server/common/components/autocomplete/template.njk
@@ -53,8 +53,7 @@
diff --git a/src/server/running-services/views/list.njk b/src/server/running-services/views/list.njk
index 4c268f24..baa52451 100644
--- a/src/server/running-services/views/list.njk
+++ b/src/server/running-services/views/list.njk
@@ -38,6 +38,7 @@
},
value: formValues.service,
suggestions: serviceFilters,
+ typeahead: true,
placeholder: "Search by service"
}) }}
diff --git a/src/server/services/list/views/list.njk b/src/server/services/list/views/list.njk
index 644a8624..e060be1f 100644
--- a/src/server/services/list/views/list.njk
+++ b/src/server/services/list/views/list.njk
@@ -46,6 +46,7 @@
},
value: formValues.service,
suggestions: serviceFilters,
+ typeahead: true,
placeholder: "Search by service"
}) }}
From 95a5d23e9bfdb661a4a8206288bd24cb63aa1105 Mon Sep 17 00:00:00 2001
From: Ben Chidgey
Date: Wed, 15 Jan 2025 15:52:57 +0000
Subject: [PATCH 07/10] 494910: Update deploy service imageName to and
autocomplete
---
.../helpers/schema/details-validation.js | 7 +-----
.../deploy-service/views/details-form.njk | 24 +++++++------------
2 files changed, 10 insertions(+), 21 deletions(-)
diff --git a/src/server/deploy-service/helpers/schema/details-validation.js b/src/server/deploy-service/helpers/schema/details-validation.js
index 63b1e148..5a76ca05 100644
--- a/src/server/deploy-service/helpers/schema/details-validation.js
+++ b/src/server/deploy-service/helpers/schema/details-validation.js
@@ -2,7 +2,6 @@ import Joi from 'joi'
import Boom from '@hapi/boom'
import { fetchAvailableVersions } from '~/src/server/deploy-service/helpers/fetch/fetch-available-versions.js'
-import { fetchDeployableImageNames } from '~/src/server/deploy-service/helpers/fetch/fetch-deployable-image-names.js'
async function detailsValidation(queryValues, options) {
const isAuthenticated = options?.context?.auth?.isAuthenticated ?? false
@@ -11,13 +10,9 @@ async function detailsValidation(queryValues, options) {
throw Boom.boomify(Boom.unauthorized())
}
- const deployableImageNames = await fetchDeployableImageNames({
- scope: options?.context?.auth?.credentials?.scope
- })
const availableVersions = await fetchAvailableVersions(queryValues?.imageName)
-
const validationResult = Joi.object({
- imageName: Joi.string().valid(...deployableImageNames),
+ imageName: Joi.string().allow(''),
version: Joi.string().valid(
...availableVersions.map((version) => version.tag)
),
diff --git a/src/server/deploy-service/views/details-form.njk b/src/server/deploy-service/views/details-form.njk
index f4363677..a805a136 100644
--- a/src/server/deploy-service/views/details-form.njk
+++ b/src/server/deploy-service/views/details-form.njk
@@ -45,29 +45,17 @@
{% call govukFieldset() %}
- {{ govukSelect({
+ {{ appAutocomplete({
id: "image-name",
name: "imageName",
label: {
text: "Image Name",
classes: "app-label"
},
- classes: "app-select app-select--wide",
hint: {
- text: "Elastic Container Registry (ECR) image name",
- classes: "app-hint"
- },
- formGroup: {
- classes: "app-form-group app-form-group-js"
+ text: "Elastic Container Registry (ECR) image name"
},
value: imageName or formValues.imageName,
- attributes: {
- "data-js": "app-autocomplete-controller",
- "data-fetcher": "fetchVersions",
- "data-target": "deploy-version",
- "data-loader": "deploy-version-loader",
- "data-publish-to": eventName.autocompleteUpdate + "-version"
- },
errorMessage: {
text: formErrors.imageName.message,
classes: "app-error-message",
@@ -75,7 +63,13 @@
"data-js": "app-error"
}
} if formErrors.imageName.message,
- items: deployableImageNameOptions
+ suggestions: deployableImageNameOptions,
+ siblingDataFetcher: {
+ name: "fetchVersions",
+ target: "deploy-version",
+ targetLoader: "deploy-version-loader"
+ },
+ publishTo: eventName.autocompleteUpdate + "-version"
}) }}
{% call appAutocomplete({
From c6b2ad9261cccb6e5912deb652a31e5d52516859 Mon Sep 17 00:00:00 2001
From: Ben Chidgey
Date: Wed, 15 Jan 2025 15:54:10 +0000
Subject: [PATCH 08/10] 494910: Update table data
- Pass id and text
- Lower case and hyphenate headers values
---
.../common/components/entity-table/template.njk | 2 +-
.../controllers/running-services-list.js | 9 ++++++---
src/server/services/list/controller.js | 16 +++++++++-------
.../helpers/build-services-table-data.test.js | 4 ++--
.../list/transformers/service-to-entity-row.js | 4 ++--
.../transformers/service-to-entity-row.test.js | 8 ++++----
6 files changed, 24 insertions(+), 19 deletions(-)
diff --git a/src/server/common/components/entity-table/template.njk b/src/server/common/components/entity-table/template.njk
index f7cb6549..50e4cfcf 100644
--- a/src/server/common/components/entity-table/template.njk
+++ b/src/server/common/components/entity-table/template.njk
@@ -7,7 +7,7 @@
{% for header in params.headers %}
-