From 685b5e13c05b67ebc45fb486895bb75d087692a4 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`. --- app/components/_index.sass | 1 + .../types/pattern_autocompleter.html.erb | 119 +++++++ .../types/pattern_autocompleter.rb | 51 +++ .../types/pattern_autocompleter.sass | 5 + .../types/subject_configuration_form.rb | 6 +- .../pattern-autocompleter.controller.ts | 308 ++++++++++++++++++ frontend/src/stimulus/setup.ts | 2 + .../open_project/forms/dsl/input_methods.rb | 4 + .../forms/dsl/pattern_autocompleter_input.rb | 61 ++++ 9 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 app/components/work_packages/types/pattern_autocompleter.html.erb create mode 100644 app/components/work_packages/types/pattern_autocompleter.rb create mode 100644 app/components/work_packages/types/pattern_autocompleter.sass create mode 100644 frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts create mode 100644 lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb diff --git a/app/components/_index.sass b/app/components/_index.sass index 59f6c3cdfa43..5778221f836f 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -21,3 +21,4 @@ @import "work_package_relations_tab/index_component" @import "users/hover_card_component" @import "enterprise_edition/banner_component" +@import "work_packages/types/pattern_autocompleter" diff --git a/app/components/work_packages/types/pattern_autocompleter.html.erb b/app/components/work_packages/types/pattern_autocompleter.html.erb new file mode 100644 index 000000000000..9fbdc941b9ce --- /dev/null +++ b/app/components/work_packages/types/pattern_autocompleter.html.erb @@ -0,0 +1,119 @@ +<%#-- 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. + +++#%> + +<%= + content_tag( + :div, + class: "pattern-autocompleter", + "data-controller": "pattern-autocompleter", + "data-pattern-autocompleter-pattern-initial-value": @value + ) do +%> + <%= + @input.builder.hidden_field( + name, + value: @value, + 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 + blur->pattern-autocompleter#input_blur" + } + ) + ) + %> + <% end %> + <%= render(Primer::Box.new(box_shadow: :medium, border_radius: 2)) do %> + <%= + 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 %> +<% end %> diff --git a/app/components/work_packages/types/pattern_autocompleter.rb b/app/components/work_packages/types/pattern_autocompleter.rb new file mode 100644 index 000000000000..6d19cc04105d --- /dev/null +++ b/app/components/work_packages/types/pattern_autocompleter.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2024 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. +# ++ + +module WorkPackages + module Types + class PatternAutocompleter < Primer::Forms::BaseComponent + delegate :name, to: :@input + + def initialize(input:, value:, suggestions:) + super() + @input = input + @value = value + @suggestions = suggestions + end + + def select_item_action + { + action: "click->pattern-autocompleter#suggestions_select", + role: "suggestion-item" + } + end + end + end +end diff --git a/app/components/work_packages/types/pattern_autocompleter.sass b/app/components/work_packages/types/pattern_autocompleter.sass new file mode 100644 index 000000000000..e376442da3a0 --- /dev/null +++ b/app/components/work_packages/types/pattern_autocompleter.sass @@ -0,0 +1,5 @@ +.pattern-autocompleter + .selected + color: var(--list-item-hover--color) + background-color: var(--control-transparent-bgColor-hover) + diff --git a/app/forms/work_packages/types/subject_configuration_form.rb b/app/forms/work_packages/types/subject_configuration_form.rb index 9faf6d55fae0..86dc58cdbdfc 100644 --- a/app/forms/work_packages/types/subject_configuration_form.rb +++ b/app/forms/work_packages/types/subject_configuration_form.rb @@ -57,13 +57,13 @@ 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, + 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, - input_width: :large + required: true ) end 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..d637da296f95 --- /dev/null +++ b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts @@ -0,0 +1,308 @@ +/* + * -- 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' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + this.clearSuggestionsFilter(); + 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(); + this.hide(this.suggestionsTarget); + } + + // update cursor + this.setRange(); + } + + input_mouseup() { + this.setRange(); + } + + input_focus() { + this.setRange(); + } + + input_blur() { + this.updateFormInputValue(); + this.hide(this.suggestionsTarget); + } + + // 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() { + this.clearSuggestionsFilter(); + if (this.suggestionsTarget.getAttribute('hidden')) { + this.show(this.suggestionsTarget); + } else { + this.hide(this.suggestionsTarget); + } + } + + // 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.parentNode === this.contentTarget) { + 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(); + this.hide(this.suggestionsTarget); + } 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 { + 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..caf935dd56e1 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- 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. +#++ + +module Primer + module OpenProject + module Forms + module Dsl + class PatternAutocompleterInput < Primer::Forms::Dsl::Input + attr_reader :name, :value, :suggestions + + def initialize(name:, value:, suggestions:, **system_arguments) + @name = name + @value = value + @suggestions = suggestions + + super(**system_arguments) + end + + def to_component + WorkPackages::Types::PatternAutocompleter.new(input: self, value:, suggestions:) + end + + def type + :pattern_autocompleter + end + + def focusable? + true + end + end + end + end + end +end