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 8, 2025
1 parent 5d832b8 commit 1783195
Show file tree
Hide file tree
Showing 7 changed files with 501 additions and 1 deletion.
4 changes: 3 additions & 1 deletion app/forms/work_packages/types/subject_configuration_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
336 changes: 336 additions & 0 deletions frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
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
Loading

0 comments on commit 1783195

Please sign in to comment.