Skip to content

Commit

Permalink
create a pattern autocompleter input
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
brunopagno committed Jan 10, 2025
1 parent 869ad9a commit 17ccd5e
Show file tree
Hide file tree
Showing 7 changed files with 472 additions and 2 deletions.
5 changes: 3 additions & 2 deletions app/forms/work_packages/types/subject_configuration_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
307 changes: 307 additions & 0 deletions frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
Original file line number Diff line number Diff line change
@@ -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 `<br>` tag on empty contenteditable elements so we need to cleanup
if (this.contentTarget.innerHTML === '<br>') {
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();
}
}
2 changes: 2 additions & 0 deletions frontend/src/stimulus/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
4 changes: 4 additions & 0 deletions lib/primer/open_project/forms/dsl/input_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 17ccd5e

Please sign in to comment.