Require tips on implementing @mention tag functionality in Appian in text and paragraph fields?

Certified Senior Developer

The Business wants to implement a functionality where, a User Tag can be added to a note [paragraph field] to a new/existing record. What is the best way to achieve this without having to perform a lot of post-processing in the system.

Example: [Text] @UserName1 [Some More Text] @UserName2 [Additional Text]

Should trigger emails to the mentioned users about the note added.


Current Suggested Solution: have a user picker where user can tag users and below the user can add the notes in paragraph field. Which most likely be rejected due to extra clicks, and it might also cause some ambiguity otherwise requiring tagged users to be mentioned again in the added note itself.

  Discussion posts and replies are publicly visible

  • AFAIK, Only rich text editor field supports inline links.

    But your current suggested solution looks good, this reply section is also designed similarly.

  • 0
    Certified Lead Developer

    If it just about detecting mentions, you can do a regex match on the entered text. Now you could check matches against available users and show them near to the text field.

    A simple regex example:

    a!localVariables(
      local!someText: "ahjfasfj afjkh afjka sh @stefan asjfklas h sajf hsak @otto",
      regexallmatches(
        "@[A-Za-z0-9-]*",
        local!someText,
        "g"
      )
    )

  • 0
    Certified Senior Developer
    in reply to Stefan Helzle

    Well, they want something where the @ symbol will provide with a list of users as well from which they can select from. [just like we do in social media platforms like Linked-in] I know this is not achievable.

    Your suggested code will work however there can be user errors like spelling which will cause us to miss out on actual users. As the margin of error can be too high to select a correct user from the specified name and mentions of non existent users can also be avoided.

  • 0
    Certified Lead Developer
    in reply to bhushanc581

    Find below extended example. the function "usersearch" is from the plugin "PersonalizationUtilities".

    There might be some more things to consider. I did just some personal R&D here. This is no copy&paste final code.

    The rule PCO_Isvoid() is my catch all check for null/empty/blank etc. I think, in this case it can be replaced with count(local!matches) > 0.

    a!localVariables(
      local!text,
      local!matches,
      local!users,
      {
        a!columnsLayout(
          columns: {
            a!columnLayout(
              contents: {
                a!paragraphField(
                  label: "Paragraph",
                  value: local!text,
                  refreshAfter: "KEYPRESS",
                  saveInto: {
                    local!text,
                    a!save(
                      target: local!matches,
                      value: regexallmatches(
                        "\s@[A-Za-z0-9-]{4,}",
                        local!text,
                        "g"
                      )
                    ),
                    if(
                      rule!PCO_IsVoid(local!matches),
                      a!save(
                        target: local!users,
                        value: null
                      ),
                      {
                        a!save(
                          target: local!users,
                          value: a!forEach(
                            items: local!matches,
                            expression: {
                              match: fv!item,
                              users: usersearch({"username"}, {0}, {mid(fv!item, 3, 100)})
                            }
                          )
                        ),
                        a!forEach(
                          items: local!users,
                          expression: a!save(
                            target: local!text,
                            value: if(
                              count(fv!item.users) = 1,
                              substitute(
                                local!text,
                                fv!item.match,
                                " " & fv!item.users[1]
                              ),
                              local!text
                            )
                          )
                        )
                      }
                    )
                  }
                )
              }
            ),
            a!columnLayout(
              contents: {
                a!richTextDisplayField(
                  label: "Detected Users",
                  labelPosition: "ABOVE",
                  value: joinarray(
                    a!forEach(
                      items: local!users,
                      expression: if(
                        count(fv!item.users) = 1,
                        fv!item.users[1],
                        null
                      ),
                    ),
                    char(10)
                  )
                )
              }
            )
          }
        )
      }
    )

  • 0
    Certified Lead Developer
    in reply to bhushanc581

    You would be able to have the field validation evaluate whether any "@username" tags actually match a good username, and validate otherwise.  That's maybe the most you can do other than keeping your user picker below the paragraph box.

  • To add to your options here, this is an OOTB example that could definitely be combined with regex as Stefan notes, for enhanced tag parsing, etc.  

    There are some interesting logic questions here such as determining your username if you allow periods (as we do, e.g. first.last accounts), determining if the period is part of the user or the end of a sentence.  The supporting cleaner rule here will ignore the period if there is an unallowable (non alpha-numeric or period) character after it, but include if the following character is allowable.  Regex would also make it quicker and easier to validate things such as, integers are allowed but only at the end of the username.  Otherwise this example basically splits the input text on "@", determines where the end of each tag is via cleaner rule and returns a dictionary with keys "tag", "isUser" (for validation), "startIndex" and "length".  One situation it does not handle yet is returning the proper index if the username is tagged more than once.  It also could be expanded to create a richTextDisplayField which replaces the tags with links (user profile, email, etc).  Give it a whirl.

    Supporting cleaner rule:

    a!localVariables(
      local!text: lower(ri!text),
      local!allowed: "abcdefghijklmnopqrstuvwxyz.0123456789",
      local!allowedList: a!forEach(items: 1+enumerate(len(local!allowed)),expression: index(local!allowed,fv!index)),
      local!cleaned: a!forEach(
        items: 1+enumerate(len(local!text)),
        expression: {
          a!localVariables(
            local!char: index(local!text,fv!index,null),
            if(contains(local!allowedList,local!char),local!char," ")
          )
        }
      ),
      local!output: left(
        ri!text,
        index(
          wherecontains(
            " ",
            a!forEach(local!cleaned,tostring(fv!item))
          ),
          1,
          len(ri!text)+1
        )-1
      ),
      
      if(
        right(local!output,1)=".",
        left(local!output,len(local!output)-1),
        local!output
      )
    )

    Interface:

    a!localVariables(
      local!text,
      local!tags: if(rule!APN_isEmpty(local!text),"",
        reject(
          fn!isnull,
          a!forEach(
            items: split(local!text,"@"),
            expression: {
              if(
                fv!index=1, /* ignore anything before the first @ */
                null,
                a!localVariables(
                  local!tag: rule!chris_test_inline_tags_clean(text: fv!item), /* call the cleaner rule */
                  {
                    tag: local!tag,
                    isUser: isusernametaken(local!tag),
                    startIndex: search(local!tag,local!text,1),
                    length: len(local!tag)
                  },
                )
              )
            }
          )
        )
      ),
      a!columnsLayout(
        columns: {
          a!columnLayout(
            contents: {
              a!paragraphField(
                label: "Text",
                value: local!text,
                saveInto: local!text
              ),
              a!paragraphField(
                label: "Tags",
                value: local!tags,
                readOnly: true
              ),
            }
          ),
          a!columnLayout()
        }
      )
    )

  • 0
    Certified Lead Developer
    in reply to Chris

    I know usernames are allowed to include a period, but are they allowed to end with one?  If not, that would probably make the regex quite a bit simpler to write, off the topf of my head (i haven't parsed what you've provided above so you might already handle this somehow).  Just a thought.

  • The example above is ignoring periods at the end of a username, however my 20.3 admin console did just allow me to create a test account as "testuser." with a trailing period..  Not sure if there are any business situations that would utilize accounts with period at the end, but I guess it is system allowed.  Which does make the tag cleaning more annoying Slight smile

  • Updated Interface example which pulls out the valid users (listed on the right side):

    a!localVariables(
      local!text,
      local!tags: if(rule!APN_isEmpty(local!text),"",
        reject(
          fn!isnull,
          a!forEach(
            items: split(local!text,"@"),
            expression: {
              if(
                fv!index=1, /* ignore anything before the first @ */
                null,
                a!localVariables(
                  local!tag: rule!chris_test_inline_tags_clean(text: fv!item), /* call the cleaner rule */
                  {
                    tag: local!tag,
                    isUser: isusernametaken(local!tag),
                    startIndex: search(local!tag,local!text,1),
                    length: len(local!tag)
                  },
                )
              )
            }
          )
        )
      ),
      a!columnsLayout(
        columns: {
          a!columnLayout(
            contents: {
              a!paragraphField(
                label: "Text",
                value: local!text,
                saveInto: local!text
              ),
              a!paragraphField(
                label: "Tags",
                value: local!tags,
                readOnly: true
              ),
            }
          ),
          a!columnLayout(
            contents: {
              a!richTextDisplayField(
                label: "User List",
                value: {
                  a!forEach(
                    items: local!tags,
                    expression: {
                      if(
                        fv!item.isUser,
                        {
                          a!richTextItem(
                            showWhen: fv!item.isUser,
                            text: touser(fv!item.tag)
                          ),
                          char(13)
                        },
                        {}
                      )
                    }
                  )
                }
              )
            }
          )
        }
      )
    )