From 17831957b79d106f1b6ed7380524ede47b5e7348 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 | 4 +- .../pattern-autocompleter.controller.ts | 336 ++++++++++++++++++ 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, 501 insertions(+), 1 deletion(-) 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 455b1213e5ad..db64b2118543 100644 --- a/app/forms/work_packages/types/subject_configuration_form.rb +++ b/app/forms/work_packages/types/subject_configuration_form.rb @@ -50,8 +50,10 @@ class SubjectConfigurationForm < ApplicationForm end subject_form.group(data: { "admin--subject-configuration-target": "patternInput" }) do |toggleable_group| - toggleable_group.text_field( + toggleable_group.pattern_autocompleter( name: :pattern, + pattern: model.enabled_patterns["subject"] || "", + 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..c86c2d82e0cc --- /dev/null +++ b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts @@ -0,0 +1,336 @@ +/* + * -- 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. + * ++ + */ + +/* + * CHECKLIST OF ALL THEM THINGS + * + * focus/highlight + * [x] on enter + * [x] on leave + * click + * [x] when the click hits a editable field + * [x] when the click hits a token + * [x] when the clicks hits only the parent div + * [x] when the click hits the 'x' on a token + * navigation + * [x] arrow keys + * [x] tab + * editting + * [x] backspace/delete when the next/previous character is a token + * [x] make sure it's possible to add text in the beginning and the end of the input + * [x] prevent enter key from entering new lines + * [ ] copy + * [ ] paste (with and without tokens) + * [x] manually typing {{ tags + * accessibility concerns + * [x] aria tokens ---> PRIMER SEEMS TO TAKE CARE OF THESE + * autocomplete popup + * [x] autocomplete on key type + * [x] adjust context of autocomplete based on the current word (e.g. typing in the middle of a word) + * [x] clear autocomplete filtering + * [x] actually insert autocompleted tag into the text + * [x] keyboard support (UP/DOWN/SELECT) + * styling + * [x] primer CSS classes + * [ ] actually do styling to look like figma +*/ + +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; + + // holds the caret position in the component + lastKnownCursorPosition:{ node:Node|null, offset:number } = { node: null, offset: 0 }; + // holds the selected item in the suggestions list + selectedSuggestion:{ element:HTMLElement|null, index:number } = { element: null, index: 0 }; + + connect() { + this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' '; + } + + // 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(); + } + } + + // Input field events + input_keydown(event:KeyboardEvent) { + if (event.key === 'Enter') { + // prevent entering new line characters + event.preventDefault(); + + const selectedItem = this.suggestionsTarget.querySelector('.selected') as HTMLElement; + + if (selectedItem) { + this.insertTokenAtLastKnownCursorPosition(selectedItem.dataset.value!); + this.clearSuggestionsFilter(); + } + } + + // move up and down the suggestions selection + if (event.key === 'ArrowUp') { + this.selectSuggestionAt(this.selectedSuggestion.index - 1); + } + if (event.key === 'ArrowDown') { + this.selectSuggestionAt(this.selectedSuggestion.index + 1); + } + + if (event.key === 'Escape') { + this.hide(this.suggestionsTarget); + } + + this.updateLastKnownCursorPosition(); + } + + input_change(event:Event) { + const target = event.currentTarget as HTMLElement; + if (target) { + this.updateFormInputValue(); + } + + const word = this.currentWord(); + if (word && word.length > 0) { + this.filterSuggestions(word); + this.selectSuggestionAt(0); + this.show(this.suggestionsTarget); + } else { + this.clearSuggestionsFilter(); + } + + this.ensureSpacesAround(); + this.updateLastKnownCursorPosition(); + } + + input_focus() { + // handling the first time someone clicks on the input when the last item is a non contenteditable element + if (!this.lastKnownCursorPosition.node && this.contentTarget.innerHTML.endsWith('>')) { + const afterToken = this.contentTarget.appendChild(document.createTextNode(' ')); + this.setCursorPositionAt(afterToken, 1); + } + } + + input_blur() { + this.updateFormInputValue(); + } + + // Autocomplete events + suggestions_select(event:PointerEvent) { + const target = event.currentTarget as HTMLElement; + + if (target) { + this.insertTokenAtLastKnownCursorPosition(target.dataset.value!); + this.clearSuggestionsFilter(); + } + } + + suggestions_toggle() { + if (this.suggestionsTarget.getAttribute('hidden')) { + this.show(this.suggestionsTarget); + } else { + this.clearSuggestionsFilter(); + } + } + + // internal methods + private updateFormInputValue():void { + this.formInputTarget.value = this.toBlueprint(); + } + + private updateLastKnownCursorPosition():void { + const selection = document.getSelection(); + if (selection && selection.anchorNode?.nodeType === Node.TEXT_NODE) { + this.lastKnownCursorPosition.node = selection.anchorNode; + this.lastKnownCursorPosition.offset = selection.anchorOffset; + } + } + + 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 insertTokenAtLastKnownCursorPosition(value:string) { + if (this.lastKnownCursorPosition.node) { + const targetNode = this.lastKnownCursorPosition.node; + const targetOffset = this.lastKnownCursorPosition.offset; + + let pos = targetOffset - 1; + while (pos > -1 && targetNode.textContent?.charAt(pos) !== ' ') { pos-=1; } + const content = targetNode.textContent!; + + this.contentTarget.insertBefore(document.createTextNode(content.substring(0, pos + 1)), targetNode); + this.contentTarget.insertBefore(this.tokenElementWithValue(value), targetNode); + const afterToken = this.contentTarget.insertBefore(document.createTextNode(content.substring(targetOffset)), targetNode); + this.contentTarget.removeChild(targetNode); + + this.setCursorPositionAt(afterToken, 0); + + this.ensureSpacesAround(); + this.updateFormInputValue(); + + // hide suggestions + this.hide(this.suggestionsTarget); + } else { + // investigate, these edge cases are important + console.warn('last known position is wrong: ', this.lastKnownCursorPosition); + } + } + + private setCursorPositionAt(node:Node, offset:number):void { + const range = document.createRange(); + range.setStart(node, offset); + range.collapse(true); + + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(range); + + this.updateLastKnownCursorPosition(); + } + + 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 tokenElementWithValue(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.tokenElementWithValue(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..0f7a5f13bcb8 --- /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: false, # set to false for debugging + disabled: true, # remove this later + 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 + focus->pattern-autocompleter#input_focus + blur->pattern-autocompleter#input_blur + input->pattern-autocompleter#input_change" + } + ) + ) + %> + <% 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