Skip to content

Commit

Permalink
Add a view component for tagging
Browse files Browse the repository at this point in the history
  • Loading branch information
taylor-steve committed Aug 31, 2024
1 parent bc58475 commit f58de16
Show file tree
Hide file tree
Showing 24 changed files with 740 additions and 1,792 deletions.
764 changes: 194 additions & 570 deletions app/assets/javascripts/spotlight/spotlight.esm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/spotlight/spotlight.esm.js.map

Large diffs are not rendered by default.

770 changes: 196 additions & 574 deletions app/assets/javascripts/spotlight/spotlight.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/spotlight/spotlight.js.map

Large diffs are not rendered by default.

15 changes: 0 additions & 15 deletions app/assets/stylesheets/spotlight/_catalog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,27 +71,12 @@
}

form.edit_solr_document {
.bootstrap-tagsinput {
@extend .clearfix;
cursor: text;
}
.bg-warning.form-text {
font-size: 0.9em;
padding: 3px 6px;
}
}

.bootstrap-tagsinput {
display: block;
.twitter-typeahead {
width: auto;
}

.tt-input {
vertical-align: baseline !important;
}
}

.blacklight-catalog-edit, .blacklight-catalog-show {
.img-thumbnail {
@extend .col-md-6;
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/spotlight/_spotlight.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
@import "report_a_problem";
@import "exhibits_index";
@import "collapse_toggle";
@import "tag_selector";
@import "translations";
@import "utilities";
@import "view_larger";
Expand Down
34 changes: 34 additions & 0 deletions app/assets/stylesheets/spotlight/_tag_selector.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.tag-selector {
.tag-selection-search-bar input {
border: none;
outline: none;
}

.tag-selector-input {
display: none;
}

.dropdown-content {
overflow-y: auto;
max-height: 180px;
label {
padding: 5px 10px;
cursor: pointer;

&:hover,
&.active {
background-color: $light;
}
}
}
}

.no-js .tag-selector {
.tag-selector-input {
display: block;
}

.tag-selection-wrapper {
display: none;
}
}
39 changes: 39 additions & 0 deletions app/components/spotlight/tag_selector_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<div data-controller="tag-selector" class="tag-selector" data-tag-selector-translations-value="<%= translation_data.to_json %>" data-tag-selector-tags-value="<%= selected_tags %>" data-tag-selector-close-button-html-value="<%= close_button_html %>">
<% if form.nil? %>
<%= text_field_tag field_name, selected_tags_value, class: 'tag-selector-input', placeholder: t('.no_js_placeholder'), data: { tag_selector_target: "tagsField" } %>
<% else %>
<%= form.text_field field_name, value: selected_tags_value, class: 'tag-selector-input', placeholder: t('.no_js_placeholder'), data: { tag_selector_target: "tagsField" } %>
<% end %>
<input type="hidden" value="<%= selected_tags_value %>" data-tag-selector-target="initialTags">

<div class='mb-3'>
<div class="tag-selection-wrapper" data-tag-selector-target="tagControlWrapper">
<div class="dropdown w-100 mb-3 d-inline-block" data-action="click@window->tag-selector#clickOutside">
<div data-tag-selector-target="textExtractionDropdown">
<div class="border rounded tag-selection-search-bar d-flex">
<button type="button" aria-label="toggle dropdown" class="btn btn-link text-secondary mr-1 me-1" data-action="click->tag-selector#tagDropdown" aria-label="">
<i class="bi bi-search"><%= search_icon_svg.html_safe %></i>
</button>
<input class="flex-grow-1" data-action="input->tag-selector#search input->tag-selector#updateTagToAdd focus->tag-selector#tagDropdown keydown->tag-selector#handleKeydown"
data-tag-selector-target="tagSearch" placeholder="<%= t('.search') %>" aria-label="<%= t('.search') %>">
<button type="button" aria-label="toggle dropdown dropdown-toggle" class="btn btn-link text-secondary dropdown-toggle" data-action="click->tag-selector#tagDropdown" id="caret">
<i class="bi bi-caret-down"></i>
</button>
</div>
</div>
<div id="tag-selection-tags" data-tag-selector-target="dropdownContent" class="dropdown-content d-none tags-group border rounded">
<% all_tags.each do |tag| %>
<label class="d-block">
<input type="checkbox" <%= 'checked' if selected?(tag) %> data-action="click->tag-selector#tagUpdate" data-tag-selector-target="searchResultTags" data-tag="<%= tag %>">
<%= tag %>
</label>
<% end %>
<label class="d-none" data-tag-selector-target="addNewTagWrapper">
<input type="checkbox" data-action="click->tag-selector#tagCreate" data-tag-selector-target="newTag" data-tag=""> Add new tag
</label>
</div>
</div>
<div data-tag-selector-target="selectedTags"></div>
</div>
</div>
</div>
61 changes: 61 additions & 0 deletions app/components/spotlight/tag_selector_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module Spotlight
# Displays a tag selection input
# This uses a plain text input that acts-as-taggable-on expects.
class TagSelectorComponent < ViewComponent::Base
# selected_tags_value is a comma delimited string of tags
def initialize(field_name:, all_tags:, selected_tags_value: nil, form: nil)
@form = form
@field_name = field_name
@selected_tags_value = selected_tags_value || ''
@all_tags = all_tags&.sort_by { |tag| tag.name.downcase }

super
end

def selected_tags
selected_tags_value.split(',').map(&:strip)
end

def close_button_html
# If we remove Bootstrap 4 support, we can remove this.
bootstrap4? ? '&times;' : ''
end

# If we remove Blacklight 7 or Bootstrap 4 support, we can remove this and use one of the built-ins.
def search_icon_svg
<<~SVG
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0V0z"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
SVG
end

private

def bootstrap_version
bootstrap_gem = Gem.loaded_specs['bootstrap']
bootstrap_gem&.version&.to_s
end

def bootstrap4?
bootstrap_version&.start_with?('4')
end

# To pass to the JS
def translation_data
{
add_new_tag: t('.add_new_tag'),
remove: t('.remove'),
selected_tags: t('.selected_tags')
}
end

def selected?(tag)
selected_tags.include?(tag.name)
end

attr_reader :form, :field_name, :selected_tags_value, :all_tags
end
end
6 changes: 6 additions & 0 deletions app/components/spotlight/tag_selector_component.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
en:
add_new_tag: "Add new tag"
no_js_placeholder: "Enter tags delimited by commas"
remove: "Remove"
search: "Enter Tag"
selected_tags: "Selected tag(s)"
190 changes: 190 additions & 0 deletions app/javascript/controllers/tag_selector_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {

static targets = [
'addNewTagWrapper',
'dropdownContent',
'initialTags',
'newTag',
'searchResultTags',
'selectedTags',
'tagControlWrapper',
'tagSearch',
'tagsField',
'textExtractionDropdown'
]

static values = {
tags: Array,
closeButtonHtml: String,
translations: Object
}

tagDropdown (event) {
const ishidden = this.dropdownContentTarget.classList.contains('d-none')
this.dropdownContentTarget.classList.toggle('d-none')
this.textExtractionDropdownTarget.querySelector('#caret').innerHTML = `<i class="bi bi-caret-${ishidden ? 'up' : 'down'}">`
}

clickOutside (event) {
const isshown = !this.dropdownContentTarget.classList.contains('d-none')
const inselected = event.target.classList.contains('pill-close')
const incontainer = this.tagControlWrapperTarget.contains(event.target)

if (!incontainer && !inselected && isshown) {
this.tagDropdown(event)
}
}

handleKeydown (event) {
if (event.key === 'Enter') {
event.preventDefault()
const tagElementToAdd = this.dropdownContentTarget.querySelector('.active').firstElementChild
if (tagElementToAdd) tagElementToAdd.click()
}

if (event.key === ',') {
event.preventDefault()
this.addNewTagWrapperTarget.click()
this.tagSearchTarget.focus()
}
}

addNewTag (event) {
if (this.addNewTagWrapperTarget.classList.contains('d-none') || this.newTagTarget.dataset.tag.length === 0) {
return
}

this.tagsValue = this.tagsValue.concat([this.newTagTarget.dataset.tag])
this.resetSearch(event)
}

resetSearch(event) {
this.tagSearchTarget.value = ''
this.newTagTarget.innerHTML = ''
this.newTagTarget.dataset.tag = ''
this.addNewTagWrapperTarget.classList.remove('d-block')
this.addNewTagWrapperTarget.classList.add('d-none')

this.searchResultTagsTargets.forEach(target => {
target.parentElement.classList.add('d-block')
target.parentElement.classList.remove('d-none')
})
}

tagUpdate (event) {
const target = event.target ? event.target : event
if (target.checked) {
this.tagsValue = this.tagsValue.concat([target.dataset.tag])
} else {
this.tagsValue = this.tagsValue.filter(tag => tag !== target.dataset.tag)
}
}

tagCreate(event) {
event.preventDefault()
const newTagCheckbox = document.createElement('label')
newTagCheckbox.classList.add('d-block')
newTagCheckbox.innerHTML = `<input type="checkbox" checked data-action="click->${this.identifier}#tagUpdate" data-tag-selector-target="searchResultTags" data-tag="${this.newTagTarget.dataset.tag}"> ${this.newTagTarget.dataset.tag}`

const existingTags = Array.from(this.dropdownContentTarget.querySelectorAll('label:not(#add-new-tag-wrapper)'))
const insertPosition = existingTags.findIndex(tag => tag.textContent.trim().localeCompare(this.newTagTarget.dataset.tag) > 0)
if (insertPosition === -1) {
this.addNewTagWrapperTarget.insertAdjacentElement('beforebegin', newTagCheckbox)
} else {
existingTags[insertPosition].insertAdjacentElement('beforebegin', newTagCheckbox)
}

this.tagsValue = this.tagsValue.concat([this.newTagTarget.dataset.tag])
this.tagSearchTarget.value = ''
this.tagSearchTarget.dispatchEvent(new Event('input'))
}

tagsValueChanged () {
if (this.tagsValue.length === 0) {
this.selectedTagsTarget.classList.add('d-none')
} else {
this.selectedTagsTarget.classList.remove('d-none')
this.selectedTagsTarget.innerHTML = `<div>${this.translationsValue.selected_tags}</div>
<ul class="list-unstyled border rounded mb-3 p-1">${this.renderTagPills()}</ul>`
}

// The backend expects the comma with the space. If we're not careful here, observedFormsStatusHasChanged
// will return true and warn the user that the form has changed, even when it really hasn't.
const newValue = this.tagsValue.join(', ')
if (this.tagsFieldTarget.value !== newValue) {
this.tagsFieldTarget.value = newValue
}
}

search (event) {
const normalizeRegex = /[^\w\s]/gi
const searchTerm = event.target.value.replace(normalizeRegex, '').toLowerCase().trim()
let exactMatch = false
this.dropdownContentTarget.classList.remove('d-none')

this.searchResultTagsTargets.forEach(target => {
target.parentElement.classList.remove('active')
const compareTerm = target.dataset.tag.replace(normalizeRegex, '').toLowerCase().trim()
if (compareTerm.includes(searchTerm)) {
target.parentElement.classList.add('d-block')
target.parentElement.classList.remove('d-none')
if (compareTerm === searchTerm) exactMatch = true
} else {
target.parentElement.classList.add('d-none')
target.parentElement.classList.remove('d-block')
}
})

if (searchTerm.length > 0 && !exactMatch) {
this.addNewTagWrapperTarget.classList.remove('d-none')
this.addNewTagWrapperTarget.classList.add('d-block')
} else {
this.addNewTagWrapperTarget.classList.add('d-none')
this.addNewTagWrapperTarget.classList.remove('d-block')
}
this.addNewTagWrapperTarget.classList.remove('active')

const firstVisibleTag = this.dropdownContentTarget.querySelector('label.d-block')
if (firstVisibleTag) {
firstVisibleTag.classList.add('active')
}
}

updateTagToAdd (event) {
this.newTagTarget.dataset.tag = event.target.value.trim()
this.newTagTarget.nextSibling.textContent = ` ${this.translationsValue.add_new_tag}: ${event.target.value}`
}

deselect (event) {
event.preventDefault()

const target = this.searchResultTagsTargets.find((tag) => tag.dataset.tag === event.target.dataset.tag)
if (target) {
target.checked = false
this.tagUpdate(target)
} else {
this.tagsValue = this.tagsValue.filter(tag => tag !== event.target.dataset.tag)
}
}

renderTagPills () {
return this.tagsValue.map((tag) => {
return `
<li class="d-inline-flex gap-2 align-items-center my-2">
<span class="bg-light badge rounded-pill border selected-item d-inline-flex align-items-center text-dark">
<span class="selected-item-label d-inline-flex">${tag}</span>
<button
type="button"
data-action="${this.identifier}#deselect"
data-tag="${tag}"
class="btn-close close ms-1 ml-1"
aria-label="${this.translationsValue.remove} ${tag}"
>${this.closeButtonHtmlValue}</button>
</span>
</li>
`
}).join('')
}
}
Loading

0 comments on commit f58de16

Please sign in to comment.