Repository: opf/openproject
Pull request #18239: [#62040] correct input of <,> characters
Author: login: Kharonus
Created: 2025-03-11T14:58:54Z | Merged: 2025-03-13T12:43:41Z

# Ticket
[OP#62040](https://community.openproject.org/wp/62040)

# What are you trying to accomplish?
- allow usage of `<>` for pattern input
- rendered token show localized text

# What approach did you choose and why?
- escape <> on rendering html from blueprint
- render token with localized text
  - property is stored in `data-prop` instead of the text



Diff:
diff --git a/app/components/work_packages/types/pattern_input.html.erb b/app/components/work_packages/types/pattern_input.html.erb
index 32e5bcc2a7f9..bbc5d7e09c44 100644
--- a/app/components/work_packages/types/pattern_input.html.erb
+++ b/app/components/work_packages/types/pattern_input.html.erb
@@ -43,7 +43,7 @@ See COPYRIGHT and LICENSE files for more details.
     <%= @input.builder.hidden_field(name, value: @value, data: { "pattern-input-target": "formInput" }) %>
 
     <template data-pattern-input-target="tokenTemplate">
-      <%= render(Primer::Beta::Label.new(tag: :span, scheme: :accent, "data-role": :token)) %>
+      <%= render(Primer::Beta::Label.new(tag: :span, scheme: :accent, data: { role: :token, prop: "__placeholder" })) %>
     </template>
 
     <template data-pattern-input-target="suggestionsHeadingTemplate">
diff --git a/frontend/src/stimulus/controllers/pattern-input.controller.ts b/frontend/src/stimulus/controllers/pattern-input.controller.ts
index 0acd8713ab82..50d8c7727393 100644
--- a/frontend/src/stimulus/controllers/pattern-input.controller.ts
+++ b/frontend/src/stimulus/controllers/pattern-input.controller.ts
@@ -36,6 +36,8 @@ type FilteredSuggestions = Array<{
   values:Array<{ prop:string; value:string; }>;
 }>;
 
+type TokenElement = HTMLElement&{ dataset:{ role:'token', prop:string } };
+
 export default class PatternInputController extends Controller {
   static targets = [
     'tokenTemplate',
@@ -71,15 +73,14 @@ export default class PatternInputController extends Controller {
   declare readonly suggestionsInitialValue:Record<string, Record<string, string>>;
   declare readonly insertAsTextTemplateValue:string;
 
-  validTokens:string[];
+  validTokenMap:Record<string, string> = {};
   currentRange:Range|undefined = undefined;
 
   connect() {
-    this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' ';
-    this.validTokens = Object.values(this.suggestionsInitialValue)
-      .map((group) => (Object.keys(group)))
-      .reduce((acc, val) => acc.concat(val), []);
+    this.validTokenMap = Object.values(this.suggestionsInitialValue)
+      .reduce((acc, val) => ({ ...acc, ...val }), {});
 
+    this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' ';
     this.tagInvalidTokens();
     this.clearSuggestionsFilter();
   }
@@ -262,7 +263,7 @@ export default class PatternInputController extends Controller {
     return this.contentTarget.innerHTML.startsWith('<');
   }
 
-  private insertToken(tokenElement:HTMLElement) {
+  private insertToken(token:TokenElement) {
     if (this.currentRange) {
       const targetNode = this.currentRange.startContainer;
       const targetOffset = this.currentRange.startOffset;
@@ -277,9 +278,9 @@ export default class PatternInputController extends Controller {
       wordRange.setEnd(targetNode, targetOffset);
 
       wordRange.deleteContents();
-      wordRange.insertNode(tokenElement);
+      wordRange.insertNode(token);
 
-      this.setRealCaretPositionAtNode(tokenElement);
+      this.setRealCaretPositionAtNode(token);
 
       this.updateFormInputValue();
       this.setRange();
@@ -287,7 +288,7 @@ export default class PatternInputController extends Controller {
       // clear suggestions
       this.clearSuggestionsFilter();
     } else {
-      this.contentTarget.appendChild(tokenElement);
+      this.contentTarget.appendChild(token);
     }
   }
 
@@ -378,11 +379,8 @@ export default class PatternInputController extends Controller {
   }
 
   private tagInvalidTokens():void {
-    this.contentTarget.querySelectorAll('[data-role="token"]').forEach((element) => {
-      const token = element.textContent?.trim();
-
-      let exists = false;
-      this.validTokens.forEach((prop) => { if (prop === token) { exists = true; } });
+    this.contentTarget.querySelectorAll('[data-role="token"]').forEach((element:HTMLElement) => {
+      const exists = Object.keys(this.validTokenMap).some((key) => key === element.dataset.prop);
 
       if (exists) {
         element.classList.remove('Label--danger');
@@ -392,31 +390,45 @@ export default class PatternInputController extends Controller {
     });
   }
 
-  private createToken(value:string):HTMLElement {
-    const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
-    const contentElement = templateTarget.firstElementChild as HTMLElement;
-    contentElement.innerText = value;
+  private createToken(value:string):TokenElement {
+    const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as DocumentFragment;
+    const contentElement = templateTarget.firstElementChild as TokenElement;
+    contentElement.dataset.prop = value;
+    contentElement.innerText = this.validTokenMap[value] || value;
     return contentElement;
   }
 
   private toHtml(blueprint:string):string {
-    return blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
+    return blueprint
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
   }
 
   private toBlueprint():string {
     let result = '';
-    this.contentTarget.childNodes.forEach((node:Element) => {
-      if (node.nodeType === Node.TEXT_NODE) {
-        // Plain text node
+    this.contentTarget.childNodes.forEach((node:ChildNode) => {
+      if (this.isText(node)) {
         result += node.textContent;
-      } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token') {
-        // Token element
-        result += `{{${node.textContent?.trim()}}}`;
+      } else if (this.isToken(node)) {
+        result += `{{${node.dataset.prop}}}`;
       }
     });
     return result.trim();
   }
 
+  private isToken(node:ChildNode):node is TokenElement {
+    return this.isElement(node) && node.dataset.role === 'token';
+  }
+
+  private isText(node:ChildNode):node is Text {
+    return node.nodeType === Node.TEXT_NODE;
+  }
+
+  private isElement(node:ChildNode):node is HTMLElement {
+    return node.nodeType === Node.ELEMENT_NODE;
+  }
+
   private isWhitespace(value:string):boolean {
     return /\s/.test(value);
   }


Reviews:
user: login: brunopagno
body: I checked out on a local branch and tested it out. It looks pretty good!

And the code approach is what I had in mind. To me it looks like a win :clap: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T15:37:27Z
user: login: myabc
body: You might consider defining a `TokenElement` type (locally) which would let you make use of user-defined type guards and reduce the number of `as` type assertions:

```ts
type TokenElement = HTMLElement & { dataset: { role: 'token', prop: string } };
```
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T02:24:49Z
user: login: Kharonus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T09:25:41Z
user: login: Kharonus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T10:29:59Z

Review comments:
diff_hunk: @@ -395,28 +395,36 @@ export default class PatternInputController extends Controller {
   private createToken(value:string):HTMLElement {
     const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
     const contentElement = templateTarget.firstElementChild as HTMLElement;
-    contentElement.innerText = value;
+    contentElement.dataset.prop = value;
+    contentElement.innerText = this.validTokenMap[value];
     return contentElement;
   }
 
   private toHtml(blueprint:string):string {
-    return blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
+    return blueprint
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
   }
 
   private toBlueprint():string {
     let result = '';
-    this.contentTarget.childNodes.forEach((node:Element) => {
+    this.contentTarget.childNodes.forEach((node:ChildNode) => {
       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') {
+      } else if (this.isToken(node)) {
         // Token element
-        result += `{{${node.textContent?.trim()}}}`;
+        result += `{{${(node as HTMLElement).dataset.prop}}}`;
       }
     });
     return result.trim();
   }
 
+  private isToken(node:ChildNode):boolean {
+    return node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token';
+  }
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: myabc
body: ```suggestion
  private isToken(node:ChildNode):node is TokenElement {
    return this.isElement(node) && node.dataset.role === 'token';
  }

  private isText(node:Node):node is Text {
    return node.nodeType === Node.TEXT_NODE; 
  }

  private isElement(node:Node):node is HTMLElement {
    return node.nodeType === Node.ELEMENT_NODE;
  }
```

created_at: 2025-03-12T02:09:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -395,28 +395,36 @@ export default class PatternInputController extends Controller {
   private createToken(value:string):HTMLElement {
     const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
     const contentElement = templateTarget.firstElementChild as HTMLElement;
-    contentElement.innerText = value;
+    contentElement.dataset.prop = value;
+    contentElement.innerText = this.validTokenMap[value];
     return contentElement;
   }
 
   private toHtml(blueprint:string):string {
-    return blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
+    return blueprint
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
   }
 
   private toBlueprint():string {
     let result = '';
-    this.contentTarget.childNodes.forEach((node:Element) => {
+    this.contentTarget.childNodes.forEach((node:ChildNode) => {
       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') {
+      } else if (this.isToken(node)) {
         // Token element
-        result += `{{${node.textContent?.trim()}}}`;
+        result += `{{${(node as HTMLElement).dataset.prop}}}`;
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: myabc
body: ```suggestion
    this.contentTarget.childNodes.forEach((node:ChildNode) => {
      if (this.isText(node)) {
        // Plain text node
        result += node.textContent;
      } else if (this.isToken(node)) {
        // Token element
        result += `{{${node.dataset.prop}}}`;
```
created_at: 2025-03-12T02:11:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -395,28 +395,36 @@ export default class PatternInputController extends Controller {
   private createToken(value:string):HTMLElement {
     const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
     const contentElement = templateTarget.firstElementChild as HTMLElement;
-    contentElement.innerText = value;
+    contentElement.dataset.prop = value;
+    contentElement.innerText = this.validTokenMap[value];
     return contentElement;
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: myabc
body: ```suggestion
    const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as TokenElement;
    const contentElement = templateTarget.firstElementChild as TokenElement;
    contentElement.dataset.prop = value;
    contentElement.innerText = this.validTokenMap[value];
    return contentElement;
```
created_at: 2025-03-12T02:12:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -378,11 +378,11 @@ export default class PatternInputController extends Controller {
   }
 
   private tagInvalidTokens():void {
-    this.contentTarget.querySelectorAll('[data-role="token"]').forEach((element) => {
-      const token = element.textContent?.trim();
-
+    this.contentTarget.querySelectorAll('[data-role="token"]').forEach((element:HTMLElement) => {
       let exists = false;
-      this.validTokens.forEach((prop) => { if (prop === token) { exists = true; } });
+      Object.keys(this.validTokenMap).forEach((prop) => {
+        if (prop === element.dataset.prop) { exists = true; }
+      });
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: myabc
body: It's not possible to break out of a `forEach` loop early:

```suggestion
let exists = Object.keys(this.validTokenMap).some((prop) => prop === element.dataset.prop);
```
created_at: 2025-03-12T02:17:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -395,28 +395,36 @@ export default class PatternInputController extends Controller {
   private createToken(value:string):HTMLElement {
     const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
     const contentElement = templateTarget.firstElementChild as HTMLElement;
-    contentElement.innerText = value;
+    contentElement.dataset.prop = value;
+    contentElement.innerText = this.validTokenMap[value];
     return contentElement;
   }
 
   private toHtml(blueprint:string):string {
-    return blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
+    return blueprint
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
   }
 
   private toBlueprint():string {
     let result = '';
-    this.contentTarget.childNodes.forEach((node:Element) => {
+    this.contentTarget.childNodes.forEach((node:ChildNode) => {
       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') {
+      } else if (this.isToken(node)) {
         // Token element
-        result += `{{${node.textContent?.trim()}}}`;
+        result += `{{${(node as HTMLElement).dataset.prop}}}`;
       }
     });
     return result.trim();
   }
 
+  private isToken(node:ChildNode):boolean {
+    return node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token';
+  }
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: myabc
body: (`isText` and `isElement` might be better defined as utility functions somewhere - and for that matter, `isWhitespace` too)
created_at: 2025-03-12T02:19:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -378,11 +378,11 @@ export default class PatternInputController extends Controller {
   }
 
   private tagInvalidTokens():void {
-    this.contentTarget.querySelectorAll('[data-role="token"]').forEach((element) => {
-      const token = element.textContent?.trim();
-
+    this.contentTarget.querySelectorAll('[data-role="token"]').forEach((element:HTMLElement) => {
       let exists = false;
-      this.validTokens.forEach((prop) => { if (prop === token) { exists = true; } });
+      Object.keys(this.validTokenMap).forEach((prop) => {
+        if (prop === element.dataset.prop) { exists = true; }
+      });
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: Kharonus
body: true, way better loop
created_at: 2025-03-12T09:25:40Z
author_association: MEMBER
diff_hunk: @@ -395,28 +395,36 @@ export default class PatternInputController extends Controller {
   private createToken(value:string):HTMLElement {
     const templateTarget = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
     const contentElement = templateTarget.firstElementChild as HTMLElement;
-    contentElement.innerText = value;
+    contentElement.dataset.prop = value;
+    contentElement.innerText = this.validTokenMap[value];
     return contentElement;
   }
 
   private toHtml(blueprint:string):string {
-    return blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
+    return blueprint
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
   }
 
   private toBlueprint():string {
     let result = '';
-    this.contentTarget.childNodes.forEach((node:Element) => {
+    this.contentTarget.childNodes.forEach((node:ChildNode) => {
       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') {
+      } else if (this.isToken(node)) {
         // Token element
-        result += `{{${node.textContent?.trim()}}}`;
+        result += `{{${(node as HTMLElement).dataset.prop}}}`;
       }
     });
     return result.trim();
   }
 
+  private isToken(node:ChildNode):boolean {
+    return node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token';
+  }
path: frontend/src/stimulus/controllers/pattern-input.controller.ts
user: login: Kharonus
body: IMHO the functions are too small and too trivial yet, to be extracted into utility functions.

Extracted the other functions, though, as suggested.
created_at: 2025-03-12T10:29:58Z
author_association: MEMBER

PR comments:
user: login: Kharonus
created_at: 2025-03-11T15:00:43Z
body: This is actually a bugfix, but while fixing it I had a nice idea of how to already rendering localized texts.

I added a pull preview, so that you can test it. Especially @dominic-braeunlein. The question is now, is it better then before. I know, that this is not the final state we want to reach.
author_association: MEMBER
user: login: Kharonus
created_at: 2025-03-12T11:47:39Z
body: thanks @myabc for some nice hints for better typing.
author_association: MEMBER