From 17ccd5e66d44d9e02709fb77f5176c7d6d02a680 Mon Sep 17 00:00:00 2001 From: Bruno Pagno Date: Mon, 16 Dec 2024 16:35:40 +0100 Subject: [PATCH] create a pattern autocompleter input This commit introduces a new type of input field which can handle autocomplete with 'tokens', so that we can build patterns like `Example pattern {{token}} value`. The current state is somewhat functional, but still not polished. --- .../types/subject_configuration_form.rb | 5 +- .../pattern-autocompleter.controller.ts | 307 ++++++++++++++++++ frontend/src/stimulus/setup.ts | 2 + .../open_project/forms/dsl/input_methods.rb | 4 + .../forms/dsl/pattern_autocompleter_input.rb | 34 ++ .../forms/pattern_autocompleter.html.erb | 98 ++++++ .../forms/pattern_autocompleter.rb | 24 ++ 7 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts create mode 100644 lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb create mode 100644 lib/primer/open_project/forms/pattern_autocompleter.html.erb create mode 100644 lib/primer/open_project/forms/pattern_autocompleter.rb diff --git a/app/forms/work_packages/types/subject_configuration_form.rb b/app/forms/work_packages/types/subject_configuration_form.rb index 9faf6d55fae0..f71032f1b94a 100644 --- a/app/forms/work_packages/types/subject_configuration_form.rb +++ b/app/forms/work_packages/types/subject_configuration_form.rb @@ -57,9 +57,10 @@ def has_pattern?(type) end subject_form.group(data: { "admin--subject-configuration-target": "patternInput" }) do |toggleable_group| - toggleable_group.text_field( + toggleable_group.pattern_autocompleter( name: :subject_pattern, - value: subject_pattern, + pattern: subject_pattern, + suggestions: ::Types::Patterns::TokenPropertyMapper.new.tokens_for_type(model), label: I18n.t("types.edit.subject_configuration.pattern.label"), caption: I18n.t("types.edit.subject_configuration.pattern.caption"), required: true, diff --git a/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts new file mode 100644 index 000000000000..7a752d1df2c5 --- /dev/null +++ b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts @@ -0,0 +1,307 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class PatternAutocompleterController extends Controller { + static targets = [ + 'tokenTemplate', + 'content', + 'formInput', + 'suggestions', + ]; + + declare readonly tokenTemplateTarget:HTMLTemplateElement; + declare readonly contentTarget:HTMLElement; + declare readonly formInputTarget:HTMLInputElement; + declare readonly suggestionsTarget:HTMLElement; + + static values = { patternInitial: String }; + declare patternInitialValue:string; + + // internal state + currentRange:Range|undefined = undefined; + selectedSuggestion:{ element:HTMLElement|null, index:number } = { element: null, index: 0 }; + + connect() { + this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' '; + } + + // Input field events + input_keydown(event:KeyboardEvent) { + // insert the selected suggestion + if (event.key === 'Enter') { + // prevent entering new line characters + event.preventDefault(); + + const selectedItem = this.suggestionsTarget.querySelector('.selected') as HTMLElement; + if (selectedItem) { + this.insertToken(this.createToken(selectedItem.dataset.value!)); + this.clearSuggestionsFilter(); + } + } + + // move up and down the suggestions selection + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.selectSuggestionAt(this.selectedSuggestion.index - 1); + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.selectSuggestionAt(this.selectedSuggestion.index + 1); + } + + // close the suggestions + if (event.key === 'Escape') { + this.hide(this.suggestionsTarget); + } + + // update cursor + this.setRange(); + } + + input_change() { + // browsers insert a `
` tag on empty contenteditable elements so we need to cleanup + if (this.contentTarget.innerHTML === '
') { + this.contentTarget.innerHTML = ' '; + } + + this.ensureSpacesAround(); + + // show suggestions for the current word + const word = this.currentWord(); + if (word && word.length > 0) { + this.filterSuggestions(word); + this.selectSuggestionAt(0); + this.show(this.suggestionsTarget); + } else { + this.clearSuggestionsFilter(); + } + + // update cursor + this.setRange(); + } + + input_mouseup() { + this.setRange(); + } + + input_focus() { + this.setRange(); + } + + input_blur() { + // make sure the actual form input has the current value + this.updateFormInputValue(); + + this.clearSuggestionsFilter(); + } + + // Autocomplete events + suggestions_select(event:PointerEvent) { + const target = event.currentTarget as HTMLElement; + + if (target) { + this.insertToken(this.createToken(target.dataset.value!)); + this.clearSuggestionsFilter(); + } + } + + suggestions_toggle() { + if (this.suggestionsTarget.getAttribute('hidden')) { + this.show(this.suggestionsTarget); + } else { + this.clearSuggestionsFilter(); + } + } + + // Token events + remove_token(event:PointerEvent) { + const target = event.currentTarget as HTMLElement; + + if (target) { + const tokenElement = target.closest('[data-role="token"]'); + if (tokenElement) { + tokenElement.remove(); + } + + this.updateFormInputValue(); + } + } + + // internal methods + private updateFormInputValue():void { + this.formInputTarget.value = this.toBlueprint(); + } + + private ensureSpacesAround():void { + if (this.contentTarget.innerHTML.startsWith('<')) { + this.contentTarget.insertBefore(document.createTextNode(' '), this.contentTarget.children[0]); + } + if (this.contentTarget.innerHTML.endsWith('>')) { + this.contentTarget.appendChild(document.createTextNode(' ')); + } + } + + private setRange():void { + const selection = document.getSelection(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + if (range.startContainer.nodeType === Node.TEXT_NODE) { + this.currentRange = range; + } + } + } + + private insertToken(tokenElement:HTMLElement) { + if (this.currentRange) { + const targetNode = this.currentRange.startContainer; + const targetOffset = this.currentRange.startOffset; + + let pos = targetOffset - 1; + while (pos > -1 && targetNode.textContent?.charAt(pos) !== ' ') { pos-=1; } + + const wordRange = document.createRange(); + wordRange.setStart(targetNode, pos + 1); + wordRange.setEnd(targetNode, targetOffset); + + wordRange.deleteContents(); + wordRange.insertNode(tokenElement); + + const postRange = document.createRange(); + postRange.setStartAfter(tokenElement); + + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(postRange); + + this.updateFormInputValue(); + this.setRange(); + + // clear suggestions + this.clearSuggestionsFilter(); + } else { + this.contentTarget.appendChild(tokenElement); + } + } + + private currentWord():string|null { + const selection = document.getSelection(); + if (selection) { + return (selection.anchorNode?.textContent?.slice(0, selection.anchorOffset) + .split(' ') + .pop() as string) + .toLowerCase(); + } + + return null; + } + + private clearSuggestionsFilter():void { + this.hide(this.suggestionsTarget); + const suggestionElements = this.suggestionsTarget.children; + for (let i = 0; i < suggestionElements.length; i+=1) { + this.show(suggestionElements[i] as HTMLElement); + } + } + + private filterSuggestions(word:string):void { + const suggestionElements = this.suggestionsTarget.children; + for (let i = 0; i < suggestionElements.length; i+=1) { + const suggestionElement = suggestionElements[i] as HTMLElement; + if (!suggestionElement.dataset.value) { continue; } + + if (suggestionElement.textContent?.trim().toLowerCase().includes(word) || suggestionElement.dataset.value.includes(word)) { + this.show(suggestionElement); + } else { + this.hide(suggestionElement); + } + } + + // show autocomplete + this.show(this.suggestionsTarget); + } + + private selectSuggestionAt(index:number):void { + if (this.selectedSuggestion.element) { + this.selectedSuggestion.element.classList.remove('selected'); + this.selectedSuggestion.element = null; + } + + const possibleTargets = this.suggestionsTarget.querySelectorAll('[data-role="suggestion-item"]:not([hidden])'); + if (possibleTargets.length > 0) { + if (index < 0) { index += possibleTargets.length; } + index %= possibleTargets.length; + const element = possibleTargets[index]; + element.classList.add('selected'); + this.selectedSuggestion.element = element as HTMLElement; + this.selectedSuggestion.index = index; + } + } + + private hide(el:HTMLElement):void { + el.setAttribute('hidden', 'hidden'); + } + + private show(el:HTMLElement):void { + el.removeAttribute('hidden'); + } + + private createToken(value:string):HTMLElement { + const target = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement; + const contentElement = target.firstElementChild as HTMLElement; + (contentElement.querySelector('[data-role="token-text"]') as HTMLElement).innerText = value; + return contentElement; + } + + private toHtml(blueprint:string):string { + let htmlValue = blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML); + if (htmlValue.startsWith('<')) { htmlValue = ` ${htmlValue}`; } + if (htmlValue.endsWith('>')) { htmlValue = `${htmlValue} `; } + return htmlValue; + } + + private toBlueprint():string { + let result = ''; + this.contentTarget.childNodes.forEach((node:Element) => { + if (node.nodeType === Node.TEXT_NODE) { + // Plain text node + result += node.textContent; + } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token') { + // Token element + const tokenText = node.querySelector('[data-role="token-text"]'); + if (tokenText) { + result += `{{${tokenText.textContent?.trim()}}}`; + } + } + }); + return result.trim(); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index b1b052b5e8c8..3a8ed1ad7a62 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -13,6 +13,7 @@ import OpShowWhenValueSelectedController from './controllers/show-when-value-sel import FlashController from './controllers/flash.controller'; import OpProjectsZenModeController from './controllers/dynamic/projects/zen-mode.controller'; import PasswordConfirmationDialogController from './controllers/password-confirmation-dialog.controller'; +import PatternAutocompleterController from './controllers/pattern-autocompleter.controller'; declare global { interface Window { @@ -41,3 +42,4 @@ instance.register('show-when-checked', OpShowWhenCheckedController); instance.register('show-when-value-selected', OpShowWhenValueSelectedController); instance.register('table-highlighting', TableHighlightingController); instance.register('projects-zen-mode', OpProjectsZenModeController); +instance.register('pattern-autocompleter', PatternAutocompleterController); diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index eca21f89ef75..ed7422071659 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -9,6 +9,10 @@ def autocompleter(**, &) add_input AutocompleterInput.new(builder:, form:, **, &) end + def pattern_autocompleter(**, &) + add_input PatternAutocompleterInput.new(builder:, form:, **, &) + end + def color_select_list(**, &) add_input ColorSelectInput.new(builder:, form:, **, &) end diff --git a/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb b/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb new file mode 100644 index 000000000000..7e1095954f2e --- /dev/null +++ b/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + module Dsl + class PatternAutocompleterInput < Primer::Forms::Dsl::Input + attr_reader :name, :label, :pattern, :suggestions + + def initialize(name:, label:, pattern:, suggestions:, **system_arguments) + @name = name + @label = label + @pattern = pattern + @suggestions = suggestions + + super(**system_arguments) + end + + def to_component + PatternAutocompleter.new(name:, label:, pattern:, suggestions:) + end + + def type + :pattern_autocompleter + end + + def focusable? + true + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/pattern_autocompleter.html.erb b/lib/primer/open_project/forms/pattern_autocompleter.html.erb new file mode 100644 index 000000000000..4db83abf7e3d --- /dev/null +++ b/lib/primer/open_project/forms/pattern_autocompleter.html.erb @@ -0,0 +1,98 @@ +<%# this should not be here :shrug: %> + + +<%= + content_tag( + :div, + class: "pattern-autocompleter", + "data-controller": "pattern-autocompleter", + "data-pattern-autocompleter-pattern-initial-value": @pattern + ) do +%> + <%= + render( + Primer::Alpha::TextField.new( + label: @label, + name: @name, + hidden: true, # set to false for debugging + value: @pattern, + data: { "pattern-autocompleter-target": "formInput" } + ) + ) + %> + + + + <%= content_tag(:div, style: "position: relative;") do %> + <%= + render( + Primer::Beta::Octicon.new( + icon: "triangle-down", + size: :small, + style: "cursor: pointer;position: absolute;right: 1em;top: 0.5em;", + "data-action": "click->pattern-autocompleter#suggestions_toggle" + ) + ) + %> + <%= + render( + Primer::Box.new( + contenteditable: true, + border: true, border_radius: 2, p: 1, + style: "white-space: pre-wrap;", + "data-pattern-autocompleter-target": "content", + data: { + action: "keydown->pattern-autocompleter#input_keydown + input->pattern-autocompleter#input_change + mouseup->pattern-autocompleter#input_mouseup + focus->pattern-autocompleter#input_focus + blur->pattern-autocompleter#input_blur" + } + ) + ) + %> + <% end %> + <%= + render( + Primer::Alpha::ActionList.new( + role: :list, + hidden: true, + show_dividers: false, + "data-pattern-autocompleter-target": "suggestions" + ) + ) do |component| + @suggestions.each_key do |key| + component.with_divider_content(key.to_s.humanize) + entries = @suggestions[key] + entries.each do |prop, label| + component.with_item(label:, data: select_item_action.merge({ value: prop })) + end + component.with_divider + end + end + %> +<% end %> diff --git a/lib/primer/open_project/forms/pattern_autocompleter.rb b/lib/primer/open_project/forms/pattern_autocompleter.rb new file mode 100644 index 000000000000..01db0e270a0c --- /dev/null +++ b/lib/primer/open_project/forms/pattern_autocompleter.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + class PatternAutocompleter < Primer::Forms::BaseComponent + def initialize(name:, label:, pattern:, suggestions:) + super() + @name = name + @label = label + @pattern = pattern + @suggestions = suggestions + end + + def select_item_action + { + action: "click->pattern-autocompleter#suggestions_select", + role: "suggestion-item" + } + end + end + end + end +end