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"
+ }
+ )
+ %>
+
+
+ <%=
+ render(
+ Primer::Beta::Label.new(
+ contenteditable: false,
+ tag: :span, display: :inline, scheme: :accent,
+ "data-role": :token
+ )
+ ) do
+ %>
+ <%= content_tag(:span, style: "cursor: default;", "data-role": "token-text") { "__VALUE__" } %>
+ <%=
+ render(
+ Primer::Beta::Octicon.new(
+ icon: :x, size: :xsmall, style: "cursor: pointer;margin: 0.2em;",
+ "data-action": "click->pattern-autocompleter#remove_token"
+ )
+ )
+ %>
+ <% end %>
+
+
+ <%= 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