From ad4c4d5ebd1f24f8be82f4967c8d7aa8ecb1347f Mon Sep 17 00:00:00 2001 From: Ben Chidgey Date: Tue, 14 Jan 2025 11:30:23 +0000 Subject: [PATCH 01/10] 494910: Add instance fallback --- src/server/common/components/running-service/template.njk | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/common/components/running-service/template.njk b/src/server/common/components/running-service/template.njk index b5d9c08c..f13b5223 100644 --- a/src/server/common/components/running-service/template.njk +++ b/src/server/common/components/running-service/template.njk @@ -80,7 +80,8 @@ {{ appInstanceIcon({ description: "Undeployed", classes: "app-icon app-icon--small"}) }} {% endif %} {% endcall %} - + {% else %} + {{ appInstanceIcon({ description: "Undeployed", classes: "app-icon app-icon--small"}) }} {% endfor %}

From 0e93659bfaf66a44d14de8c6678e7e5d10e6f402 Mon Sep 17 00:00:00 2001 From: Ben Chidgey Date: Wed, 15 Jan 2025 15:44:39 +0000 Subject: [PATCH 02/10] 494910: Remove old publish and child update suggestions work This has been moved into the autocomplete --- .../populate-autocomplete-suggestions.js | 58 ------------------- src/client/javascripts/application.js | 4 -- 2 files changed, 62 deletions(-) delete mode 100644 src/client/common/helpers/populate-autocomplete-suggestions.js diff --git a/src/client/common/helpers/populate-autocomplete-suggestions.js b/src/client/common/helpers/populate-autocomplete-suggestions.js deleted file mode 100644 index 8d6e9a95..00000000 --- a/src/client/common/helpers/populate-autocomplete-suggestions.js +++ /dev/null @@ -1,58 +0,0 @@ -import isFunction from 'lodash/isFunction.js' - -import { clientNotification } from '~/src/client/common/helpers/client-notification.js' -import { buildSuggestions } from '~/src/server/common/components/autocomplete/helpers/build-suggestions.js' -import { publish } from '~/src/client/common/helpers/event-emitter.js' - -function populateAutocompleteSuggestions($controller) { - if (!$controller) { - return - } - - const isLoadingClassName = 'app-loader--is-loading' - const $target = document.querySelector( - `[data-js*="${$controller.getAttribute('data-target')}"]` - ) - const $loader = document.querySelector( - `[data-js="${$controller.getAttribute('data-loader')}"]` - ) - const dataFetcherName = $controller.getAttribute('data-fetcher') - const publishTo = $controller.getAttribute('data-publish-to') - - const dataFetcher = window.cdp[dataFetcherName] - - if (!$target || !isFunction(dataFetcher)) { - return - } - - $controller.addEventListener('change', async (event) => { - const delayedLoader = setTimeout(() => { - $loader.classList.add(isLoadingClassName) - }, 200) - - const name = event?.target?.name - const value = event?.target?.value - - try { - const suggestions = await dataFetcher(value) - - clearTimeout(delayedLoader) - $loader?.classList?.remove(isLoadingClassName) - - const suggestionsName = $target.id - - window.cdp.suggestions[suggestionsName] = buildSuggestions(suggestions) - - if (publishTo) { - publish(publishTo, { queryParams: { [name]: value } }) - } - } catch (error) { - clientNotification(error.message) - - clearTimeout(delayedLoader) - $loader?.classList?.remove(isLoadingClassName) - } - }) -} - -export { populateAutocompleteSuggestions } diff --git a/src/client/javascripts/application.js b/src/client/javascripts/application.js index 56f04020..78d9d0ab 100644 --- a/src/client/javascripts/application.js +++ b/src/client/javascripts/application.js @@ -17,7 +17,6 @@ import { initModule } from '~/src/client/common/helpers/init-module.js' import { inputAssistant } from '~/src/server/common/components/input-assistant/input-assistant.js' import { paramsToHiddenInputs } from '~/src/client/common/helpers/params-to-hidden-inputs.js' import { poll } from '~/src/client/common/helpers/poll.js' -import { populateAutocompleteSuggestions } from '~/src/client/common/helpers/populate-autocomplete-suggestions.js' import { populateSelectOptions } from '~/src/client/common/helpers/populate-select-options.js' import { protectForm } from '~/src/client/common/helpers/protect-form/index.js' import { search } from '~/src/server/common/components/search/search.js' @@ -61,9 +60,6 @@ initModule('app-search', search) initClass('app-autocomplete', Autocomplete) initClass('app-autocomplete-advanced', AutocompleteAdvanced) -// Populate autocomplete from a separate controller input -initModule('app-autocomplete-controller', populateAutocompleteSuggestions) - // Xhr Container initModule('app-xhr-subscriber', xhrSubscriber) From f993c68876d3b8b6daeecf7695d0eff9cc384b39 Mon Sep 17 00:00:00 2001 From: Ben Chidgey Date: Wed, 15 Jan 2025 15:46:05 +0000 Subject: [PATCH 03/10] 494910: Turn off unbound method in tests --- .eslintrc.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0327e09f..a07702a6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -165,7 +165,7 @@ module.exports = { rules: { // Allow Jest to assert on mocked unbound methods '@typescript-eslint/unbound-method': 'off', - 'jest/unbound-method': 'error', + 'jest/unbound-method': 'off', // Allow custom expect functions in tests, that start with expect 'jest/expect-expect': [ 'error', From 96cb0e1ae4d77a9436d7375cc864ac7795f8ea3c Mon Sep 17 00:00:00 2001 From: Ben Chidgey 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