Skip to content

Commit

Permalink
[PBNTR-667] Rails Typeahead Form Integration (#3893)
Browse files Browse the repository at this point in the history
**What does this PR do?** A clear and concise description with your
runway ticket url.
[PBNTR-667](https://runway.powerhrg.com/backlog_items/PBNTR-667)
addresses missing functionality from the playbook typeahead used within
forms via the `form.typeahead` or `f.typeahead` construction.
Specifically, this ticket is in response to an SLA request for
accessible label, validation, and reset functionality.

The results of my investigation were the following: 
- Label click-to-focus functionality was not present in the kit as
currently structured. The added label functionality utilizes the
recommended Rails form helper `@template.label(@object_name, name)`. It
depends on a label's for property matching the text input's id.
- Validation was not present in the kit as currently structured.
Validation now works in two different ways. A boolean prop `required:
true` taps into the browser's default validation properties. A user can
also use the additional validation prop (in conjunction with `required:
true` to add a custom validation message.
- The resetting issue I believe is just a matter of implementation
because the `is_multi: false` in the provided code snippet turns it a
"react-rendered-rails" typeahead, not a "pure rails" one, and the reset
functionality does not work as expected in the form context.

I also cleaned up the [form kit
page](https://playbook.powerapp.cloud/kits/form) some (having distinct
labels between doc examples so that all label-clicks do not redirect to
the "default' doc example). I would suggest a follow up story to add a
"Form + Typeahead" doc example or clarifying text in the description on
the Typeahead kit page as well to avoid the react-rendered-rails vs.
rails issue.

**Screenshots:** Screenshots to visualize your addition/change
Clicking the label puts the focus on the input (see blue highlight
around input box and cursor in input)
<img width="1291" alt="label focus"
src="https://github.com/user-attachments/assets/04688e62-f6b1-4ae3-ae71-2e4855760155">
Adding `required: true` provides validation (using the browser's generic
"Please fill out this field"
<img width="1289" alt="for PR typeahead required validation"
src="https://github.com/user-attachments/assets/1adee33d-9019-448b-a978-30eee932b618">
Using a validation prop with a custom message will change the validation
message (used along with `required: true`).
<img width="1287" alt="for PR new custom validation error"
src="https://github.com/user-attachments/assets/7f4899e2-b7ce-422b-b64c-8cb9d55b8a1d">




**How to test?** Steps to confirm the desired behavior:
1. Go to [form kit
page](https://pr3893.playbook.beta.gm.powerapp.cloud/kits/form). A
typeahead has been added as the first entry in the Default + Validation
doc example.
2. When you click the "example typeahead" label, the focus should
redirect to the typeahead.
3. When you've selected a user in the typeahead, scroll down to the
bottom of the doc example and hit the "cancel" button. The form field
for typeahead should reset (just like the rest of them do).
4. Without anything entered in the typeahead field, try to submit the
form by pressing the "Save Example" button. The red error indicator
should appear for the typeahead example (just like it does for the rest
of them).
5. Clicking the labels for any other option in the Default + Validation
doc ex should direct focus to the field directly below it, NOT to the
corresponding field in the Default doc example like it does currently in
[production](https://playbook.powerapp.cloud/kits/form#default-validation).


#### Checklist:
- [x] **LABELS** Add a label: `enhancement`, `bug`, `improvement`, `new
kit`, `deprecated`, or `breaking`. See [Changelog &
Labels](https://github.com/powerhome/playbook/wiki/Changelog-&-Labels)
for details.
- [x] **DEPLOY** I have added the `milano` label to show I'm ready for a
review.
~- [ ] **TESTS** I have added test coverage to my code.~
  • Loading branch information
ElisaShapiro authored Nov 14, 2024
1 parent 713b220 commit 06f56c2
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
%>

<%= pb_form_with(scope: :example, url: "", method: :get) do |form| %>
<%= form.typeahead :example_user, props: { data: { typeahead_example1: true, user: {} }, placeholder: "Search for a user" } %>
<%= form.typeahead :example_typeahead, props: { data: { typeahead_example1: true, user: {} }, label: true, placeholder: "Search for a user" } %>
<%= form.text_field :example_text_field, props: { label: true } %>
<%= form.phone_number_field :example_phone_number_field, props: { label: "Example phone field" } %>
<%= form.email_field :example_email_field, props: { label: true } %>
Expand Down Expand Up @@ -92,7 +92,7 @@
const selectedUserData = JSON.parse(selectedUserJSON)

// set the input field's value
event.target.querySelector('input[name=example_user]').value = selectedUserData.login
event.target.querySelector('input[name=example_typeahead]').value = selectedUserData.login

// log the selected option's dataset
console.log('The selected user data:')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<%= pb_form_with(scope: :example, url: "", method: :get, loading: true) do |form| %>
<%= form.text_field :example_text_field, props: { label: true } %>
<%= form.text_field :example_text_field_loading, props: { label: true } %>

<%= form.actions do |action| %>
<%= action.submit %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,74 @@
%>

<%= pb_form_with(scope: :example, method: :get, url: "", validate: true) do |form| %>
<%= form.text_field :example_text_field, props: { label: true, required: true } %>
<%= form.phone_number_field :example_phone_number_field, props: { label: "Example phone field" } %>
<%= form.email_field :example_email_field, props: { label: true, required: true } %>
<%= form.number_field :example_number_field, props: { label: true, required: true } %>
<%= form.search_field :example_project_number, props: { label: true, required: true, validation: { pattern: "[0-9]{2}-[0-9]{5}", message: "Please enter a valid project number (example: 33-12345)." } } %>
<%= form.password_field :example_password_field, props: { label: true, required: true } %>
<%= form.url_field :example_url_field, props: { label: true, required: true } %>
<%= form.text_area :example_text_area, props: { label: true, required: true } %>
<%= form.dropdown_field :example_dropdown, props: { label: true, options: example_dropdown_options, required: true } %>
<%= form.select :example_select, [ ["Yes", 1], ["No", 2] ], props: { label: true, blank_selection: "Select One...", required: true } %>
<%= form.collection_select :example_collection_select, example_collection, :value, :name, props: { label: true, blank_selection: "Select One...", required: true } %>
<%= form.typeahead :example_typeahead_validation, props: { data: { typeahead_example2: true, user: {} }, label: true, placeholder: "Search for a user", required: true, validation: { message: "Please select a user." } } %>
<%= form.text_field :example_text_field_validation, props: { label: true, required: true } %>
<%= form.phone_number_field :example_phone_number_field_validation, props: { label: "Example phone field" } %>
<%= form.email_field :example_email_field_validation, props: { label: true, required: true } %>
<%= form.number_field :example_number_field_validation, props: { label: true, required: true } %>
<%= form.search_field :example_project_number_validation, props: { label: true, required: true, validation: { pattern: "[0-9]{2}-[0-9]{5}", message: "Please enter a valid project number (example: 33-12345)." } } %>
<%= form.password_field :example_password_field_validation, props: { label: true, required: true } %>
<%= form.url_field :example_url_field_validation, props: { label: true, required: true } %>
<%= form.text_area :example_text_area_validation, props: { label: true, required: true } %>
<%= form.dropdown_field :example_dropdown_validation, props: { label: true, options: example_dropdown_options, required: true } %>
<%= form.select :example_select_validation, [ ["Yes", 1], ["No", 2] ], props: { label: true, blank_selection: "Select One...", required: true } %>
<%= form.collection_select :example_collection_select_validation, example_collection, :value, :name, props: { label: true, blank_selection: "Select One...", required: true } %>
<%= form.check_box :example_checkbox, props: { text: "Example Checkbox", label: true, required: true } %>
<%= form.date_picker :example_date_picker_2, props: { label: true, required: true } %>
<%= form.star_rating_field :example_star_rating, props: { variant: "interactive", label: true, required: true } %>
<%= form.star_rating_field :example_star_rating_validation, props: { variant: "interactive", label: true, required: true } %>

<%= form.actions do |action| %>
<%= action.submit %>
<%= action.button props: { type: "reset", text: "Cancel", variant: "secondary" } %>
<% end %>
<% end %>

<!-- form.typeahead user results example template -->
<template data-typeahead-example-result-option>
<%= pb_rails("user", props: {
name: tag(:slot, name: "name"),
orientation: "horizontal",
align: "left",
avatar_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApocMXEAAAAASUVORK5CYII=",
avatar: true
}) %>
</template>

<!-- form.typeahead JS example implementation -->
<%= javascript_tag defer: "defer" do %>
document.addEventListener("pb-typeahead-kit-search", function(event) {
if (!event.target.dataset || !event.target.dataset.typeaheadExample2) return

fetch(`https://api.github.com/search/users?q=${encodeURIComponent(event.detail.searchingFor)}`)
.then(response => response.json())
.then((result) => {
const resultOptionTemplate = document.querySelector("[data-typeahead-example-result-option]")

event.detail.setResults((result.items || []).map((user) => {
const wrapper = resultOptionTemplate.content.cloneNode(true)
wrapper.children[0].dataset.user = JSON.stringify(user)
wrapper.querySelector('slot[name="name"]').replaceWith(user.login)
wrapper.querySelector('img').dataset.src = user.avatar_url
return wrapper
}))
})
})


document.addEventListener("pb-typeahead-kit-result-option-selected", function(event) {
if (!event.target.dataset.typeaheadExample2) return

const selectedUserJSON = event.detail.selected.firstElementChild.dataset.user
const selectedUserData = JSON.parse(selectedUserJSON)

// set the input field's value
event.target.querySelector('input[name=example_typeahead_validation]').value = selectedUserData.login

// log the selected option's dataset
console.log('The selected user data:')
console.dir(selectedUserData)

// do even more with the data later - TBD
event.target.dataset.user = selectedUserJSON
})
<% end %>
38 changes: 36 additions & 2 deletions playbook/app/pb_kits/playbook/pb_typeahead/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { debounce } from 'lodash'
export default class PbTypeahead extends PbEnhancedElement {
_searchInput: HTMLInputElement
_resultsElement: HTMLElement
_debouncedSearch: Function
_debouncedSearch: () => void
_resultsLoadingIndicator: HTMLElement
_resultOptionTemplate: HTMLElement
_resultsOptionCache: Map<string, Array<DocumentFragment>>
_searchContext: string
_validSelection: boolean

static get selector() {
return '[data-pb-typeahead-kit]'
Expand All @@ -19,6 +20,7 @@ export default class PbTypeahead extends PbEnhancedElement {
this.searchInput.addEventListener('focus', () => this.debouncedSearch())
this.searchInput.addEventListener('input', () => this.debouncedSearch())
this.resultsElement.addEventListener('click', (event: MouseEvent) => this.optionSelected(event))
this.element.closest('form')?.addEventListener('submit', (event) => this.handleFormSubmission(event))
}

handleKeydown(event: KeyboardEvent) {
Expand Down Expand Up @@ -86,13 +88,45 @@ export default class PbTypeahead extends PbEnhancedElement {
const resultOption = (event.target as Element).closest('[data-result-option-item]')
if (!resultOption) return

this._validSelection = true
this.removeValidationError()

this.resultsCacheClear()
this.searchInputClear()
this.clearResults()

this.element.dispatchEvent(new CustomEvent('pb-typeahead-kit-result-option-selected', { bubbles: true, detail: { selected: resultOption, typeahead: this } }))
}

removeValidationError() {
const inputWrapper = this.searchInput.closest('.text_input_wrapper')
if (inputWrapper) {
const errorMessage = inputWrapper.querySelector('.pb_body_kit_negative') as HTMLElement
if (errorMessage) {
errorMessage.style.display = 'none'
}
this.searchInput.classList.remove('error')
}
}

showValidationError() {
const inputWrapper = this.searchInput.closest('.text_input_wrapper')
if (inputWrapper) {
const errorMessage = inputWrapper.querySelector('.pb_body_kit_negative') as HTMLElement
if (errorMessage) {
errorMessage.style.display = 'block'
}
this.searchInput.classList.add('error')
}
}

handleFormSubmission(event: Event) {
if (!this._validSelection) {
this.showValidationError()
event.preventDefault()
}
}

clearResults() {
this.resultsElement.innerHTML = ''
}
Expand Down Expand Up @@ -201,7 +235,7 @@ export default class PbTypeahead extends PbEnhancedElement {
}

toggleResultsLoadingIndicator(visible: boolean) {
var visibilityProperty = '0'
let visibilityProperty = '0'
if (visible) visibilityProperty = '1'
this.resultsLoadingIndicator.style.opacity = visibilityProperty
}
Expand Down
7 changes: 5 additions & 2 deletions playbook/app/pb_kits/playbook/pb_typeahead/typeahead.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
<%= pb_rails("text_input", props: {
type: "search",
input_options: object.input_options,
label: object.label,
name: object.name,
value: object.value,
placeholder: object.placeholder,
margin_bottom: "none",
required: object.required,
validation: object.validation,
label: object.label,
id: object.input_options[:id],
}) %>
<%= pb_rails("list", props: { ordered: false, borderless: false, xpadding: true, role: "status", aria: { live: "polite" }, data: { pb_typeahead_kit_results: true } }) do %>
<% end %>
Expand All @@ -33,4 +36,4 @@
<% end %>
</template>
<% end %>
<% end %>
<% end %>
4 changes: 4 additions & 0 deletions playbook/app/pb_kits/playbook/pb_typeahead/typeahead.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class Typeahead < Playbook::KitBase
prop :pill_color, type: Playbook::Props::Enum,
values: %w[primary neutral success warning error info data_1 data_2 data_3 data_4 data_5 data_6 data_7 data_8 windows siding roofing doors gutters solar insulation accessories],
default: "primary"
prop :required, type: Playbook::Props::Boolean,
default: false
prop :validation, type: Playbook::Props::HashProp,
default: {}

def classname
default_margin_bottom = margin_bottom.present? ? "" : " mb_sm"
Expand Down
13 changes: 13 additions & 0 deletions playbook/lib/playbook/forms/builder/typeahead_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ module Forms
class Builder
def typeahead(name, _options = {}, _html_options = {}, props: {})
props[:name] = name

input_id = "#{name}_input"
props[:input_options] ||= {}
props[:input_options][:id] ||= input_id

props[:label] = @template.label(@object_name, name, for: props[:input_options][:id]) if props[:label] == true

if props[:validation]
validation_message = props[:validation][:message]
props[:input_options][:data] ||= {}
props[:input_options][:data][:validation_message] = validation_message
end

@template.pb_rails("typeahead", props: props)
end
end
Expand Down

0 comments on commit 06f56c2

Please sign in to comment.