[Rt-commit] rt branch, 5.0-trunk, updated. rt-5.0.1-232-g2da10d0f3f

? sunnavy sunnavy at bestpractical.com
Thu Apr 1 13:44:00 EDT 2021


The branch, 5.0-trunk has been updated
       via  2da10d0f3f908d08811799b297d81552ac62b941 (commit)
       via  2c3a518cf2fc2aac087877baa99caf80e517e596 (commit)
       via  101d4a49e1bc28b9de58f82a9b7a5c9cf0e72ab8 (commit)
       via  44877abae8008ebb85a0ae6ba95f5eceb874db30 (commit)
       via  65136a9b6f2d23f2cb054c112391be58d3420cf5 (commit)
       via  df5f7b3cf9c685160e43b67f491512377170e2ec (commit)
       via  d895bba7d7cc335b1544a7b416ef6c1b595ccc8a (commit)
       via  8408a86aa10e8b87bd5b8190d31995a4a2358d75 (commit)
       via  8b71da11b8fb32b7ac57f095ad8d620602191382 (commit)
       via  cca7cb20559188ce565c84823dc7f04be92bcef3 (commit)
       via  9b34a0f8e1f476606d43dce8775ca40fd40fe268 (commit)
       via  acd7eb687e7a9db1876fd438ec7d174092dfc549 (commit)
       via  06224a80eb8894bb4ad9885b8b5a4ee4cd2711ad (commit)
       via  ba59372e25bf788c203c701616c7d1d74a3c4b2f (commit)
       via  48dbc77ec017cae67b19652a6c59a1b42464bc15 (commit)
       via  d49a1a5263c737c64654add2c0a6ee66979dd180 (commit)
       via  db60fb9e79bd2909bb380e378dceb60106d41522 (commit)
       via  54fe6d06a2439f5f3f203e77b051bd7acc412ac3 (commit)
       via  19f2e08a5ce52e910e81c84bf13db5ed20c585fe (commit)
       via  72c299bf7c4d76763dd1ae2ecec3da30332bcf5c (commit)
       via  0ee4e69495e75fabf956efd8486639298ebc3de6 (commit)
       via  72f21a9579d74174ead0c2bcafae9fc921ca23e0 (commit)
       via  3fe198a5b090bc8b249e00e80a4e72062ddecc35 (commit)
       via  9aab297d9507cf2c66e4f88b2f5889b97e158cd9 (commit)
       via  dcdbade3c0ef85c412f3fde7aafb30db4845f3ce (commit)
       via  38ce6ca36e0b855677a5b2f50775725ed4d29b8c (commit)
       via  8cb0f8354a38c0d50311b39c717c786093122450 (commit)
       via  0cca648652c29d40439f92c5364065b097de6b90 (commit)
       via  97297bef4ba94ab2edefbc79c0e6548c5d27931d (commit)
       via  591579de086cdb279dcb9723e3c51fd0a6f0bdf7 (commit)
       via  dcd3ee96f6847d7e9f48bbdc0720d69d30e4ed41 (commit)
       via  6fc6be9791f5d7fe6a7ed5c10aa98de8a5a4631f (commit)
       via  a993c48696190c8a7549913e1cfc076cba327510 (commit)
       via  d36154468351f1e5bbbdd41f61b0af220b62e587 (commit)
       via  fe5c51454013d5e9fa9f04217bf1c9f312e2096e (commit)
       via  ae488f31230a7689abf7707b16ce26b66706b8c6 (commit)
       via  5fa0bfda3980747e1d4625fdc46060c351b47f5b (commit)
       via  b753aa411bf3f5946eeeea09aff8471b446f3d7e (commit)
       via  6d6304741f8b15466261ea010a944a498e258a8a (commit)
       via  be277569dd34e5cf36895a572257bfa899772133 (commit)
       via  92aff8842dd62df5623384cf25097076876d7df8 (commit)
       via  b1e6dfd2f8dc71ba70aaf00e7c11d55fe133b6d0 (commit)
       via  d2a595fcdb58713e0596e89317e4fe11b3b4b632 (commit)
       via  63c81c99b99b34474c6b3423d500fdf8885a4de9 (commit)
       via  124db83e80913c661fda4e53ba7730bf0f4696ca (commit)
       via  0116ce10e86c697fb3df3f1737364cc9f5baa58b (commit)
       via  7f379a59d0be8207750779e83b6f10c3df14bb6d (commit)
       via  77c7f617c959f0134fe3685cf288e55097abcf4b (commit)
       via  ed65ee9026bf06665e128d034eb68ab615f53dcd (commit)
       via  69b9a5d58099cf41f9a7d3d9fe6d5e6f6c1dacf3 (commit)
       via  7d895ac15f2577c63034f4d51f77244f8c362929 (commit)
       via  ffdc0925cd2446a6e4515f5f0aaede546812dd79 (commit)
       via  6aef6880de9083b0ed83ee5dd2ba033a9e77ecaa (commit)
       via  cf8d115a9ba7795e29a050d32d5369686e9c6835 (commit)
       via  9cb360f8352b15f733e0998bff4f5e3f2ce3d083 (commit)
       via  a661236d645c4693af6de42701883a80ada49de0 (commit)
       via  c9231d05710432a23a92cc5ed551eb7a8b435978 (commit)
       via  6d44d7e7ab939ed88c49b5bcdc3e5f5131967dcb (commit)
       via  938968cc9f63d64047552ded541c9fad5ab55bf7 (commit)
       via  458dc3ce482db5e773c6d71f66e2c381019cb3b3 (commit)
       via  4e2d9bf1dae090144ef6630ef0bd96318cab01d9 (commit)
       via  eedf30d8efff673d45b3f0beb0f1c8359df05768 (commit)
       via  7f4f5d42201c2ca28a6e42886fea2fc9afb0ab63 (commit)
       via  d6127ad940cfb182d37eaf2179c7648a4168c71d (commit)
       via  e31fa8917a21eabad2c17ddc17d334ca602944bb (commit)
       via  807fc60c2273a9bee39b6601bcfd845bb6c4448c (commit)
       via  181ce8e91ed126aa4a7e4e5d14460ebf75c4cb79 (commit)
       via  77f60c16026f8d1e06fc4627afc42425f690c975 (commit)
       via  dded56f87f43104bd40e48c6469112a2326b8c76 (commit)
       via  36d726805f9139228a02f5e2ad542fb20367427d (commit)
       via  bb33cfd6983312d86fb339ff0ece99beab7bbf6f (commit)
       via  2f4fb5a400033dd69b37a7dee902e289829da7ae (commit)
       via  72d0156301b181c8f3d1ee62c6a0c924c6391398 (commit)
       via  755ebc672687b0ff4920cec027f0e3f7eb6acc67 (commit)
       via  c237545f3acb8160582e115b425ff44737f95957 (commit)
       via  801e13a18327df2ae84c54fb14be1d3dbbddb4d2 (commit)
       via  51dce7dc9b65f6ea4b932ff0c6b351298346d6a8 (commit)
       via  49d72a69819932d62a7c5cefb3d4b956b2dafdfd (commit)
       via  073933b8f7ab8453aa44d82fe7d9d8caeff95b5d (commit)
       via  8c9aca29a97457a3faee3adb14ad63cc002301ea (commit)
       via  b1e4c79ee961ff789fe6aa2ac2c5daf8253992d4 (commit)
       via  d6a996bf5c51d7f0c7d4a04ad92e52a12d01b647 (commit)
       via  8e5909337be8581f912cacea51a844acd4abc1a3 (commit)
       via  674e4f5171c840942fa3455ad16abaaa3edc4f6a (commit)
       via  2071d5bc79c09dae1200294d89a74abcc3fd42a9 (commit)
       via  2e3c228f330ae0048a6674bdb7302d8afa87d3c9 (commit)
       via  c38437afba84a38bc48049f77fd07f6ae2da7293 (commit)
       via  f0d5babe23d74e12e771a130eae782d2e1069738 (commit)
       via  ee1985edc64409d8d7ecca5cbecb0ece483b165a (commit)
       via  c3f994533048bf11e1d9c0db8a6d895d5672280b (commit)
       via  ca1cee922ea1c084449d442158a62cd4ddc9eed1 (commit)
       via  fed93849071b9460953c36d7f2a6d74f2588c953 (commit)
       via  2043c8a45e645b475da77b5d6f4ddc763abb356c (commit)
       via  fcbca1973bff5edcbaa965ec8fb75f16ff4a0d39 (commit)
       via  6a8ea9f51aee3002eb9a6361f1fe22d8c4130376 (commit)
       via  eff063c2481838f072f9b6398e98328c765b5cdb (commit)
       via  8c914a0aef00e31e289533f04637987b196f91be (commit)
       via  855c9df08d09402111d1ee1597f21930dbd29988 (commit)
       via  574c2e0ec13e03f9e2392b4713c31cfa73f710b9 (commit)
       via  1baa631446e6e1fbc395df7d41044d5c45c5520d (commit)
       via  93faa6da1fba197a94c49c3b6da0da4fd1dd3941 (commit)
       via  c7c2988f7b0e6fe4d10c8eb8a6e5c57346d63a37 (commit)
       via  e74805287b50a8753cb3f73f79335622631a4f25 (commit)
       via  ef729ae7ea410435cb2be551ceb1996425da9b36 (commit)
       via  9f603a421277f558231a387ea06872a3aa7fe59f (commit)
       via  006676e5f0091ab18a9bbb9bdffb682f8334f1fc (commit)
       via  a3c5c3f073598d4c008c7a49ef0ec63229827bfa (commit)
       via  ba843e17394f37bd2925b934732c8ea5dfe8712b (commit)
       via  a903bc01a608fec77b0e2c13f3d096935a2821dc (commit)
       via  516aa424fd3ad1fd9b00fc02080829325baf0bbb (commit)
       via  dd264d68d38e1df31908a890fd9038b01ccf30f6 (commit)
       via  ba03f06a26b5a44945aa175ddb78f22e23078b44 (commit)
       via  0d20bdf79fffd9aed6e2c29d177fc978f00a9b25 (commit)
       via  298da0b7b1e7dca2e88cff6152dbe168f8b51d6a (commit)
       via  8750e4b643b5703b3aae52d625e0b59c4a2180f4 (commit)
       via  18b5d085d89c775fd79fe9a20f25ddacf1ae8ef1 (commit)
       via  c58ada2ba416dd51f1f35043cca9ae38dc6bd803 (commit)
       via  757ccab492182288eb5bdfb94d4af69e60de9bc9 (commit)
       via  a1e09f28739fd164d8de73f77dde64e8bcd6c595 (commit)
       via  b001d35d72ce2c6bebe2a68a0b496db1833f46d3 (commit)
       via  9cbf7b129633b07b6990c6a57fa3339fc658d196 (commit)
       via  214ec3857bad78615d61a62bbcd24fa848b4ed78 (commit)
       via  1d1745b55eb548636170b2ddb058dbac5edf0e4a (commit)
       via  1ab8481c909c9fb2645b8a39342374da25a07e2a (commit)
       via  6449c83016f50a07945277bd2fa60c93323d2ea9 (commit)
       via  52bf8213a26aae7d74f4d2fc260cc37e06de145d (commit)
       via  cc64be89fc56b2d82db336c420f96585b3b53bf3 (commit)
       via  0b5efed83e8554362049c3999d21461507c07d75 (commit)
       via  7b90ad070641eee11d7337afc1a4c9b92bfd31d1 (commit)
       via  768861023940d8297fc45bf2d14dcdc198030a38 (commit)
       via  d1ec80c78a05fb175be64c3f808585b509ce0c92 (commit)
       via  69bfd11c84eb860d0c6f10aead91e5e91fe11f25 (commit)
       via  f9d1c4ca0bb73dec9ee4ca7af9a4839fbb181a0b (commit)
       via  5857fdf9f92fcb914560a6d1a17ad9f68378c159 (commit)
       via  b47c88327557c63aec2721f8fb3b1ea80d33ca43 (commit)
       via  f336a221215cc624c0a3ae21638d34e414900141 (commit)
       via  245807296a0b8456f39b28d4b1fa344259c454b6 (commit)
       via  31ca5c2ab297d4593102e6fb28f5ae18644109d0 (commit)
       via  2505933f070b68f0bc59feb4e060f59c05359eb6 (commit)
       via  5dc91ca1477e4d8e10ab2406adb80835deff4dd7 (commit)
       via  7c57aa072b1d0f1c591da5bdea5cac0320d458fe (commit)
       via  b15a6cf52c9eda8dfee1abf6de070eb7b70741b5 (commit)
       via  b83819374cd5ebdc5c8ddf3e3e1bd168a03e3e0c (commit)
       via  feec798d3d18fa4151042675e3eef359ba883010 (commit)
       via  de3e4f500064e00bc84f54336dfc8638607a1b5c (commit)
       via  406252f3112fda7dfce4cf590f77cf11b2df5b30 (commit)
       via  6389f6eaf01ae9d9e7f5987243480c63d60d41ba (commit)
       via  9d05b93c1317f8b7b8f5edf4bd0ad65a676bbfaf (commit)
       via  49ae6ce096cb70fc6b043505ed4981e6b508fb34 (commit)
       via  39a112d9d520e738314f03e2df00a8d64e430c34 (commit)
       via  ae10cd4540132cd35d30cf79558facde6d844b7e (commit)
       via  6cf274f5bc5b38db59f9c55c2482efd1c32effe5 (commit)
       via  807096bb2b0a8376379f4c047aa5183eae370597 (commit)
       via  e48a276d2713b1bb120e55d681b98c6a201c7921 (commit)
       via  fdc698a95b0b162924232b6f3a1acc631708ed6f (commit)
       via  a821bfcf92ec39ae657d19583860e7d4ae213218 (commit)
       via  35236873a7abb91542256b8fa7e0a52941dcf81f (commit)
       via  4526de5c6980956733e3a5c37760d283bdb3b611 (commit)
       via  d0f2b70a14292615ff329dd9256f78e2c554ca9c (commit)
       via  87bc2add4ec45df15a0aeeb7b12203df075e31c3 (commit)
       via  eed358f53e28bee8d6f2540f13b9faf7a4951a43 (commit)
       via  43f8d97b24b4b33606eb6243974b33bd4ba4d898 (commit)
       via  55b70e4963c4f22c0ca512930e5fe3a81b74114b (commit)
       via  d5113497de3c43a301d724375da451cdcc497c65 (commit)
       via  26bf392d817288ec5231894acce4af9929ae807d (commit)
       via  c42cdcc2ffb1eb68110852aa9b35522027209251 (commit)
       via  b367e508aecc5962424f3ffe81d19fbc137b9e5a (commit)
       via  23a848df5ffcb7637e77dec6e52c55b98852b675 (commit)
       via  7b048865db28ce6f558a3c7c487727190718e9c8 (commit)
       via  f061a7f528f1378bf04cc374b0c9f2eeb386fc18 (commit)
       via  d1a9e08613d58f152470eeab9110e8f58ff13bda (commit)
       via  ab2fb018bba2ff2bfad61453708d9f3c9acb6634 (commit)
       via  5ed396095275a5e94e4cc86969a2da438f564e40 (commit)
       via  9ae1fc046a4c3ff766e6e650e7f98c5fdc6f885b (commit)
       via  547eee59572d71ddc525ef80c2ad580c1df1e713 (commit)
       via  fac9e840c6cbb382ec8ceb7c28eec1f8cef505ab (commit)
       via  82d92b9072eab550a38ad9a7f0dfcfb2c405c93b (commit)
       via  a6fc87ebb3729d1fe9128651731634ba97d6c14c (commit)
       via  f10597b4167a1e5a8e5a4ba2c28b27dd6ab70af6 (commit)
       via  1e28eef1981ae4350eefbf31c0348cf6a498dfab (commit)
       via  820df69baa211e0733e3865a579dedf6f6d31037 (commit)
       via  86055935f3ac8e951317f6afd041eca4404a1a44 (commit)
       via  315cf966b09a97834014a286f36a7f8d974ce2de (commit)
       via  ee4d6d6020f9c6f56b9cc1db0764db456694ce2a (commit)
       via  dc1b2b79c241d06d25741894f1d692b68eb91e64 (commit)
       via  7f679f5e840145b91aeec2b2dd2ccc3b21e1517a (commit)
       via  c152c2dcb0c41d3b0d65f18daafda1d2db76ba6e (commit)
       via  e020bd194812b00a08631815e7658fc2658e6294 (commit)
       via  b6150f7e90e97de7f60a324d28d81817ddba7d7d (commit)
       via  de5e1be4fa841fe0d7f6f3eac376bd0edcaa58b4 (commit)
       via  6bf51e3198b80a1c864334f801c17d783fa8beda (commit)
       via  60e0752809c813de6eab7cc8b7558601faa3758e (commit)
      from  67f7316ee38759f4fc72fa8d80b1fe032a415cd0 (commit)

Summary of changes:
 Makefile.in                                        |   2 +-
 configure.ac                                       |   4 +-
 devel/third-party/README                           |   1 +
 docs/UPGRADING-4.4                                 |  10 +
 docs/authentication.pod                            |   7 +
 etc/RT_Config.pm.in                                |  28 ++
 etc/initialdata                                    |  13 +
 etc/upgrade/4.3.0/content                          |   2 +-
 etc/upgrade/4.4.5/content                          |  19 ++
 lib/RT/Action/Notify.pm                            | 126 +++----
 lib/RT/Action/SendEmail.pm                         |   8 +
 lib/RT/Asset.pm                                    |   4 +-
 lib/RT/Attachment.pm                               |  58 +++-
 lib/RT/Authen/ExternalAuth.pm                      |  33 +-
 lib/RT/Authen/ExternalAuth/LDAP.pm                 | 217 ++++++++-----
 lib/RT/Condition/{Overdue.pm => ViaInterface.pm}   |  33 +-
 lib/RT/Config.pm                                   |  49 ++-
 lib/RT/Crypt.pm                                    | 119 +++++--
 lib/RT/Crypt/GnuPG.pm                              |  72 +++-
 lib/RT/Crypt/SMIME.pm                              | 361 +++++++++++++++++++--
 lib/RT/CustomField.pm                              |  39 +++
 lib/RT/CustomRole.pm                               |   6 +-
 lib/RT/Dashboard/Mailer.pm                         |  20 +-
 lib/RT/GroupMember.pm                              |  10 +-
 lib/RT/GroupMembers.pm                             |   2 +-
 lib/RT/I18N.pm                                     |  50 ++-
 lib/RT/Interface/Email.pm                          |   3 +
 lib/RT/Interface/REST.pm                           |  14 +-
 lib/RT/Interface/Web.pm                            |  96 +++++-
 lib/RT/Interface/Web/MenuBuilder.pm                |   3 +-
 lib/RT/ObjectCustomFields.pm                       |  27 ++
 lib/RT/Principal.pm                                |  16 +
 lib/RT/Record.pm                                   |  14 +-
 lib/RT/Record/AddAndSort.pm                        |  19 +-
 lib/RT/Report/Tickets.pm                           |  12 +-
 lib/RT/Shredder.pm                                 |  23 +-
 lib/RT/Shredder/Dependencies.pm                    |   1 +
 lib/RT/Shredder/Plugin/Objects.pm                  |   3 +-
 lib/RT/Shredder/Plugin/SQLDump.pm                  |   2 +
 lib/RT/Test/{GnuPG.pm => Crypt.pm}                 | 158 +++++++--
 lib/RT/Test/GnuPG.pm                               |   7 +
 lib/RT/Test/SMIME.pm                               |   9 +
 lib/RT/Test/Web.pm                                 |   8 +
 lib/RT/Tickets.pm                                  |   4 +-
 lib/RT/Transaction.pm                              |  13 +-
 lib/RT/User.pm                                     |  31 +-
 sbin/rt-validator.in                               |  58 +++-
 share/html/Admin/Elements/Portal                   |   1 +
 share/html/Admin/Groups/Members.html               |   9 +
 .../dhandler => Admin/Helpers/ClearMasonCache}     |   7 +-
 share/html/Admin/Tools/Configuration.html          |  23 ++
 share/html/Admin/Users/Keys.html                   |  10 +-
 .../AuthTokens.html => Crypt/GetGPGPubkey.html}    |  38 ++-
 .../Create.html => Crypt/GetSMIMECert.html}        |  79 +++--
 .../html/Dashboards/Elements/ShowPortlet/dashboard |   1 +
 share/html/Dashboards/Render.html                  |   8 +-
 share/html/Dashboards/Subscription.html            |  28 ++
 share/html/Elements/Crypt/KeyIssues                |   4 +
 share/html/Elements/Crypt/SelectKeyForEncryption   |   6 +-
 share/html/Elements/Crypt/SignEncryptWidget        |   9 +-
 share/html/Elements/CryptStatus                    |  64 +++-
 share/html/Elements/EmailInput                     |   5 +
 share/html/Elements/RT__Ticket/ColumnMap           |  10 +-
 share/html/Elements/ShowLinksOfType                |   6 +-
 share/html/Elements/ShowSearch                     |   4 +
 share/html/Helpers/PreviewScrips                   |   8 +-
 share/html/Helpers/ShowSimplifiedRecipients        |   2 +-
 share/html/Prefs/Other.html                        |   2 +-
 share/html/REST/1.0/Forms/attachment/default       |   1 +
 share/html/REST/1.0/Forms/ticket/attachments       |   1 +
 share/html/REST/1.0/Forms/ticket/default           |   1 +
 share/html/REST/1.0/Forms/ticket/history           |  13 +-
 share/html/REST/1.0/dhandler                       |   3 +-
 share/html/Search/Chart                            | 129 +++++++-
 share/html/Search/Chart.html                       |  63 +++-
 share/html/Search/Elements/Article                 |   4 +-
 share/html/Search/Elements/EditFormat              |   4 +-
 share/html/Search/Elements/SelectGroupBy           |  10 +
 share/html/Search/JSChart                          |  71 +++-
 share/html/SelfService/Create.html                 |   6 +
 share/html/Ticket/Create.html                      |   8 +-
 share/html/Ticket/Elements/UpdateCc                |   8 +-
 share/html/Ticket/Forward.html                     |   2 +-
 share/html/Ticket/Modify.html                      |   6 +
 share/html/Ticket/ModifyAll.html                   |   6 +
 share/html/Ticket/Update.html                      |   8 +-
 share/html/Widgets/SavedSearch                     |  15 +-
 share/html/m/ticket/create                         |   8 +-
 share/html/m/ticket/reply                          |   2 +-
 share/static/js/util.js                            |  69 +++-
 t/api/customfield.t                                |  57 ++++
 t/api/transaction.t                                | 102 ++++++
 t/assets/web.t                                     |  16 +
 t/crypt/gnupg/attachments-in-db.t                  |   3 +-
 t/crypt/no-signer-address.t                        |   3 +-
 t/crypt/smime/attachments-in-db.t                  |   4 +-
 t/crypt/smime/bad-recipients.t                     |   6 +-
 t/crypt/smime/crl-check.t                          |  46 +++
 t/crypt/smime/extract-email-address.t              |  31 ++
 t/crypt/smime/other-certs.t                        |  77 +++++
 t/crypt/smime/revoked.t                            |  74 +++++
 t/customroles/notify.t                             |  48 ++-
 t/data/smime/keys/CAWithCRL/cacert.pem             |  22 ++
 t/data/smime/keys/CAWithCRL/mycrl.cnf              |   1 +
 t/data/smime/keys/CAWithCRL/private/cakey.pem      |  30 ++
 t/data/smime/keys/dianne at skoll.ca.crt              |  34 ++
 t/data/smime/keys/revoked-ca.pem                   |  49 +++
 t/data/smime/keys/revoked at example.com.pem          |  39 +++
 t/data/smime/keys/sender-crl at example.com.key       |  30 ++
 t/data/smime/keys/sender-crl at example.com.pem       |  23 ++
 t/data/smime/keys/smime at example.com.crt            |  33 ++
 t/externalauth/ldap_email_login.t                  |  93 ++++++
 t/mail/crypt-gnupg.t                               |  29 +-
 ...going.t => crypt-per-queue-outgoing-protocol.t} |  37 ++-
 t/mail/gnupg-bad.t                                 |   5 +-
 t/mail/gnupg-incoming.t                            |  46 ++-
 t/mail/gnupg-outgoing-encrypted-plaintext.t        |   3 +-
 t/mail/gnupg-outgoing-encrypted.t                  |   3 +-
 t/mail/gnupg-outgoing-plain-plaintext.t            |   3 +-
 t/mail/gnupg-outgoing-plain.t                      |   3 +-
 t/mail/gnupg-outgoing-signed-plaintext.t           |   3 +-
 t/mail/gnupg-outgoing-signed.t                     |   3 +-
 t/mail/gnupg-outgoing-signed_encrypted-plaintext.t |   3 +-
 t/mail/gnupg-outgoing-signed_encrypted.t           |   3 +-
 t/mail/gnupg-realmail.t                            |   2 +-
 t/mail/gnupg-reverification.t                      |   2 +-
 t/mail/gnupg-special.t                             |   2 +-
 t/mail/mime_decoding.t                             |  10 +
 t/mail/smime/incoming.t                            |  66 +++-
 t/mail/smime/other-signed.t                        |  18 +-
 t/mail/smime/outgoing.t                            |  10 +-
 t/mail/smime/realmail.t                            |  10 +-
 t/mail/smime/reject_on_unencrypted.t               |  16 +-
 .../CVE-2012-4735-incoming-encryption-header.t     |   2 +-
 t/security/CVE-2012-4735-sign-any-key.t            |   7 +-
 t/security/CVE-2012-4735-sign-encrypt-header.t     |   3 +-
 t/shredder/03plugin_objects.t                      |  33 ++
 t/shredder/03plugin_users.t                        |  19 ++
 t/shredder/04organization.t                        |  34 ++
 t/ticket/search.t                                  |  20 +-
 t/ticket/search_by_txn.t                           |  32 +-
 t/validator/role_groups.t                          |  33 ++
 t/web/admin_user.t                                 |  10 +-
 t/web/crypt-gnupg.t                                |  29 +-
 t/web/gnupg-headers.t                              |   3 +-
 t/web/gnupg-select-keys-on-create.t                |  10 +-
 t/web/gnupg-select-keys-on-update.t                |  10 +-
 t/web/gnupg-tickyboxes.t                           |   2 +-
 t/web/html_template.t                              |  34 +-
 t/web/saved_search_chart.t                         |  55 ++--
 t/web/smime/outgoing.t                             |  61 ++--
 t/web/ticket_time.t                                | 149 +++++++++
 t/web/ticket_txn_content.t                         |  43 ++-
 153 files changed, 3600 insertions(+), 663 deletions(-)
 create mode 100644 etc/upgrade/4.4.5/content
 copy lib/RT/Condition/{Overdue.pm => ViaInterface.pm} (81%)
 copy lib/RT/Test/{GnuPG.pm => Crypt.pm} (75%)
 copy share/html/{SelfService/Attachment/dhandler => Admin/Helpers/ClearMasonCache} (89%)
 copy share/html/{Admin/Users/AuthTokens.html => Crypt/GetGPGPubkey.html} (74%)
 copy share/html/{Admin/Actions/Create.html => Crypt/GetSMIMECert.html} (59%)
 create mode 100644 t/crypt/smime/crl-check.t
 create mode 100644 t/crypt/smime/extract-email-address.t
 create mode 100644 t/crypt/smime/other-certs.t
 create mode 100644 t/crypt/smime/revoked.t
 create mode 100644 t/data/smime/keys/CAWithCRL/cacert.pem
 create mode 100644 t/data/smime/keys/CAWithCRL/mycrl.cnf
 create mode 100644 t/data/smime/keys/CAWithCRL/private/cakey.pem
 create mode 100644 t/data/smime/keys/dianne at skoll.ca.crt
 create mode 100644 t/data/smime/keys/revoked-ca.pem
 create mode 100644 t/data/smime/keys/revoked at example.com.pem
 create mode 100644 t/data/smime/keys/sender-crl at example.com.key
 create mode 100644 t/data/smime/keys/sender-crl at example.com.pem
 create mode 100644 t/data/smime/keys/smime at example.com.crt
 create mode 100644 t/externalauth/ldap_email_login.t
 copy t/mail/{smime/outgoing.t => crypt-per-queue-outgoing-protocol.t} (58%)
 create mode 100644 t/shredder/03plugin_objects.t
 create mode 100644 t/shredder/04organization.t
 create mode 100644 t/validator/role_groups.t
 create mode 100644 t/web/ticket_time.t

- Log -----------------------------------------------------------------
commit 2da10d0f3f908d08811799b297d81552ac62b941
Merge: 67f7316ee3 2c3a518cf2
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Mar 31 05:23:07 2021 +0800

    Merge branch '4.4-trunk' into 5.0-trunk

diff --cc devel/third-party/README
index ac45a3e02a,f9227a89f7..4883f6b8cf
--- a/devel/third-party/README
+++ b/devel/third-party/README
@@@ -52,16 -22,17 +52,17 @@@ License: MI
  * eyedropper.svg
  Description: color picker icon
  Origin: http://thenounproject.com/noun/eye-dropper/ (The Noun Project)
+ Author: The Noun Project
  License: CC BY 3.0
  
 -* jquery-1.11.3.js
 +* jquery-3.4.1.js
  Description: DOM manipulation
 -Origin: http://code.jquery.com/jquery-1.11.3.js
 +Origin: http://code.jquery.com/jquery-3.4.1.js
  License: MIT
  
 -* jquery-modal-0.5.2.js
 -Description: modal popup dialogs
 -Origin: https://github.com/kylefox/jquery-modal
 +* jquery-jgrowl-1.4.5/
 +Description: javascript notifications
 +Origin: https://github.com/stanlemon/jGrowl/tree/1.4.5
  License: MIT
  
  * jquery-tablesorter-2.0.5b.js 
diff --cc etc/RT_Config.pm.in
index 352fa73ea0,85b04929df..e9aba3fa32
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@@ -3204,11 -2370,21 +3204,22 @@@ giving quick access back to a search af
  
  =cut
  
 -Set($QuoteFolding, 1);
 +Set($AssetHideSimpleSearch, 0);
  
 -=item C<$QuoteWrapWidth>
++=item C<$AssetMultipleOwner>
+ 
 -C<$QuoteWrapWidth> controls the number of columns to use when wrapping
 -quoted text within transactions.
++By default an asset is limited to a single user as an owner. By setting
++this to a true value, you can allow multiple users and groups as owner.
++If you change this back to a false value while having multiple owners
++set on any assets, RT's behavior may be inconsistent.
+ 
+ =cut
+ 
 -Set($QuoteWrapWidth, 70);
++Set($AssetMultipleOwner, 0);
+ 
  =back
  
 -
 -=head1 Application logic
 +=head2 Message box properties
  
  =over 4
  
@@@ -3867,9 -3094,13 +3891,13 @@@ Set( %SMIME
      CAPath => undef,
      AcceptUntrustedCAs => undef,
      Passphrase => undef,
+     OtherCertificatesToSend => undef,
+     CheckCRL => 0,
+     CheckOCSP => 0,
+     CheckRevocationDownloadTimeout => 30,
  );
  
 -=head2 GnuPG configuration
 +=head3 GnuPG configuration
  
  A full description of the (somewhat extensive) GnuPG integration can
  be found by running the command `perldoc L<RT::Crypt::GnuPG>` (or
diff --cc lib/RT/Config.pm
index d12902ca57,8053db1de3..0e9152aaaf
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@@ -1496,465 -1251,7 +1526,468 @@@ our %META
      ServiceAgreements => {
          Type => 'HASH',
      },
 +    AssetHideSimpleSearch => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
++    AssetMultipleOwner => {
++        Widget => '/Widgets/Form/Boolean',
++    },
 +    AssetShowSearchResultCount => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AllowUserAutocompleteForUnprivileged => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AlwaysDownloadAttachments => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AmbiguousDayInFuture => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AmbiguousDayInPast => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ApprovalRejectionNotes => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ArticleOnTicketCreate => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AutoCreateNonExternalUsers => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AutocompleteOwnersForSearch => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    CanonicalizeRedirectURLs => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    CanonicalizeURLsInFeeds => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ChartsTimezonesInDB => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    CheckMoreMSMailHeaders => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    DateDayBeforeMonth => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    DisplayTotalTimeWorked => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    DontSearchFileAttachments => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    DropLongAttachments => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    EditCustomFieldsSingleColumn => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    EnableReminders => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    EnablePriorityAsString => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ExternalStorageDirectLink => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ForceApprovalsView => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ForwardFromUser => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    Framebusting => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    HideArticleSearchOnReplyCreate => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    HideResolveActionsWithDependencies => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    HideTimeFieldsFromUnprivilegedUsers => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    LoopsToRTOwner => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    MessageBoxIncludeSignature => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    MessageBoxIncludeSignatureOnComment => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    OnlySearchActiveTicketsInSimpleSearch => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ParseNewMessageForTicketCcs => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    PreferDateTimeFormatNatural => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    PreviewScripMessages => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    RecordOutgoingEmail => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    RestrictLoginReferrer => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    RestrictReferrer => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SearchResultsAutoRedirect => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SelfServiceUseDashboard => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ShowBccHeader => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ShowEditSystemConfig => {
 +        Immutable => 1,
 +        Widget    => '/Widgets/Form/Boolean',
 +    },
 +    ShowEditLifecycleConfig => {
 +        Immutable => 1,
 +        Widget    => '/Widgets/Form/Boolean',
 +    },
 +    ShowMoreAboutPrivilegedUsers => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ShowRTPortal => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ShowRemoteImages => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ShowTransactionImages => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    StoreLoops => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    StrictLinkACL => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SuppressInlineTextFiles => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    TreatAttachedEmailAsFiles => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    TruncateLongAttachments => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    TrustHTMLAttachments => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    UseFriendlyFromLine => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    UseFriendlyToLine => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    UseOriginatorHeader => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    UseSQLForACLChecks => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    UseTransactionBatch => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ValidateUserEmailAddresses => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebFallbackToRTLogin => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebFlushDbCacheEveryRequest => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebHttpOnlyCookies => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebRemoteUserAuth => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebRemoteUserAutocreate => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebRemoteUserContinuous => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebRemoteUserGecos => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WebSecureCookies => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    WikiImplicitLinks => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    HideOneTimeSuggestions => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    LinkArticlesOnInclude => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SelfServiceCorrespondenceOnly => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SelfServiceDownloadUserData => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SelfServiceShowGroupTickets => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    SelfServiceShowArticleSearch => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    ShowSearchResultCount => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    AllowGroupAutocompleteForUnprivileged => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +
 +    AttachmentListCount => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    AutoLogoff => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    BcryptCost => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    DefaultSummaryRows => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    DropdownMenuLimit => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    ExternalStorageCutoffSize => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    LogoutRefresh => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    MaxAttachmentSize => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    MaxFulltextAttachmentSize => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    MinimumPasswordLength => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    MoreAboutRequestorGroupsLimit => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    TicketsItemMapSize => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +
 +    AssetDefaultSearchResultOrderBy => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    CanonicalizeEmailAddressMatch => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    CanonicalizeEmailAddressReplace => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    CommentAddress => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    CorrespondAddress => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    DashboardAddress => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    DashboardSubject => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    DefaultErrorMailPrecedence => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    DefaultMailPrecedence => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    DefaultSearchResultOrderBy => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    EmailOutputEncoding => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    FriendlyFromLineFormat => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    FriendlyToLineFormat => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LDAPHost => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LDAPUser => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LDAPPassword => {
 +        Widget => '/Widgets/Form/String',
 +        Obfuscate => sub {
 +            my ($config, $sources, $user) = @_;
 +            return $user->loc('Password not printed');
 +        },
 +    },
 +    LDAPBase => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LDAPGroupBase => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LogDir => {
 +        Immutable => 1,
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LogToFileNamed => {
 +        Immutable => 1,
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LogoAltText => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LogoLinkURL => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    LogoURL => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    OwnerEmail => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    QuoteWrapWidth => {
 +        Widget => '/Widgets/Form/Integer',
 +    },
 +    RedistributeAutoGeneratedMessages => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    SelfServiceRequestUpdateQueue => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    SendmailArguments => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    SendmailBounceArguments => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    SendmailPath => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    SetOutgoingMailFrom => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    Timezone => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +    VERPPrefix => {
 +        Widget => '/Widgets/Form/String',
 +        WidgetArguments => { Hints  => 'rt-', },
 +    },
 +    VERPDomain => {
 +        Widget => '/Widgets/Form/String',
 +        WidgetArguments => {
 +            Callback => sub {  return { Hints => RT->Config->Get( 'Organization') } },
 +        },
 +    },
 +    WebImagesURL => {
 +        Widget => '/Widgets/Form/String',
 +    },
 +
 +    AssetDefaultSearchResultOrder => {
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => [qw(ASC DESC)] },
 +    },
 +    LogToSyslog => {
 +        Immutable => 1,
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
 +    },
 +    LogToSTDERR => {
 +        Immutable => 1,
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
 +    },
 +    LogToFile => {
 +        Immutable => 1,
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
 +    },
 +    LogStackTraces => {
 +        Immutable => 1,
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
 +    },
 +    StatementLog => {
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => ['', qw(debug info notice warning error critical alert emergency)] },
 +    },
 +
 +    DefaultCatalog => {
 +        Widget          => '/Widgets/Form/Select',
 +        WidgetArguments => {
 +            Description => 'Default catalog',    #loc
 +            Callback    => sub {
 +                my $ret = { Values => [], ValuesLabel => {} };
 +                my $c = RT::Catalogs->new( $HTML::Mason::Commands::session{'CurrentUser'} );
 +                $c->UnLimit;
 +                while ( my $catalog = $c->Next ) {
 +                    next unless $catalog->CurrentUserHasRight("CreateAsset");
 +                    push @{ $ret->{Values} }, $catalog->Id;
 +                    $ret->{ValuesLabel}{ $catalog->Id } = $catalog->Name;
 +                }
 +                return $ret;
 +            },
 +        }
 +    },
 +    DefaultSearchResultOrder => {
 +        Widget => '/Widgets/Form/Select',
 +        WidgetArguments => { Values => [qw(ASC DESC)] },
 +    },
 +    LogToSyslogConf => {
 +        Immutable     => 1,
 +    },
 +    ShowMobileSite => {
 +        Widget => '/Widgets/Form/Boolean',
 +    },
 +    StaticRoots => {
 +        Type      => 'ARRAY',
 +        Immutable => 1,
 +    },
 +    EmailSubjectTagRegex => {
 +        Immutable => 1,
 +    },
 +    ExtractSubjectTagMatch => {
 +        Immutable => 1,
 +    },
 +    ExtractSubjectTagNoMatch => {
 +        Immutable => 1,
 +    },
 +    WebNoAuthRegex => {
 +        Immutable => 1,
 +    },
 +    SelfServiceRegex => {
 +        Immutable => 1,
 +    },
  );
  my %OPTIONS = ();
  my @LOADED_CONFIGS = ();
diff --cc lib/RT/Interface/Web.pm
index 96cbd51251,698b7e5624..3170c298ba
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@@ -273,9 -266,15 +274,17 @@@ sub HandleRequest 
          require Module::Refresh;
          Module::Refresh->refresh;
      }
+     else {
+         require File::Spec;
+         my $mason_cache_created = ( stat File::Spec->catdir( $RT::MasonDataDir, 'obj' ) )[ 9 ] // '';
+         if ( ( $HTML::Mason::Commands::m->{rt_mason_cache_created} // '' ) ne $mason_cache_created ) {
+             $HTML::Mason::Commands::m->interp->flush_code_cache;
+             $HTML::Mason::Commands::m->{rt_mason_cache_created} = $mason_cache_created;
+         }
+     }
  
 +    RT->Config->RefreshConfigFromDatabase();
 +
      $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8");
  
      $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ];
@@@ -1920,11 -1898,41 +1948,46 @@@ sub RequestENV 
      return $name ? $env->{$name} : $env;
  }
  
 +sub ClientIsIE {
 +    # IE 11.0 dropped "MSIE", so we can't use that alone
 +    return RequestENV('HTTP_USER_AGENT') =~ m{MSIE|Trident/} ? 1 : 0;
 +}
 +
+ =head2 ClearMasonCache
+ 
+ Delete current mason cache.
+ 
+ =cut
+ 
+ sub ClearMasonCache {
+     require File::Path;
+     require File::Spec;
+     my $mason_obj_dir = File::Spec->catdir( $RT::MasonDataDir, 'obj' );
+ 
+     my $error;
+ 
+     # There is a race condition that other processes add new cache items while
+     # remove_tree is running, which could prevent it from deleting the whole "obj"
+     # directory with errors like "Directory not empty". Let's try for a few times
+     # here to get around it.
+ 
+     for ( 1 .. 10 ) {
+         last unless -e $mason_obj_dir;
+         File::Path::remove_tree( $mason_obj_dir, { safe => 1, error => \$error } );
+     }
+ 
+     if ( $error && @$error ) {
+ 
+         # Only one dir is specified, so there will be only one error if any
+         my ( $file, $message ) = %{ $error->[0] };
+         RT->Logger->error("Failed to clear mason cache: $file => $message");
+         return ( 0, HTML::Mason::Commands::loc( "Failed to clear mason cache: [_1] => [_2]", $file, $message ) );
+     }
+     else {
+         return ( 1, HTML::Mason::Commands::loc('Cache cleared') );
+     }
+ }
+ 
  package HTML::Mason::Commands;
  
  use vars qw/$r $m %session/;
@@@ -4984,104 -4708,29 +5044,108 @@@ Returns an array of results messages
  
  =cut
  
 -sub JSON {
 -    RT::Interface::Web::EncodeJSON(@_);
 -}
 +sub ProcessAuthToken {
 +    my %args = (
 +        ARGSRef => undef,
 +        @_
 +    );
 +    my $args_ref = $args{ARGSRef};
  
 -sub CSSClass {
 -    my $value = shift;
 -    return '' unless defined $value;
 -    $value =~ s/[^A-Za-z0-9_-]/_/g;
 -    return $value;
 -}
 +    my @results;
 +    my $token = RT::AuthToken->new( $session{CurrentUser} );
  
 -sub GetCustomFieldInputName {
 -    RT::Interface::Web::GetCustomFieldInputName(@_);
 +    if ( $args_ref->{Create} ) {
 +
 +        # Don't require password for systems with some form of federated auth
 +        # or if configured to not require a password
 +        my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
 +        my $require_password = 1;
 +        if ( RT->Config->Get('DisablePasswordForAuthToken') or not $res{'CanSet'}) {
 +            $require_password = 0;
 +        }
 +
 +        if ( !length( $args_ref->{Description} ) ) {
 +            push @results, loc("Description cannot be blank.");
 +        }
 +        elsif ( $require_password && !length( $args_ref->{Password} ) ) {
 +            push @results, loc("Please enter your current password.");
 +        }
 +        elsif ( $require_password && !$session{CurrentUser}->IsPassword( $args_ref->{Password} ) ) {
 +            push @results, loc("Please enter your current password correctly.");
 +        }
 +        else {
 +            my ( $ok, $msg, $auth_string ) = $token->Create(
 +                Owner       => $args_ref->{Owner},
 +                Description => $args_ref->{Description},
 +            );
 +            if ($ok) {
 +                push @results, $msg;
 +                push @results,
 +                    loc(
 +                        '"[_1]" is your new authentication token. Treat it carefully like a password. Please save it now because you cannot access it again.',
 +                        $auth_string
 +                    );
 +            }
 +            else {
 +                push @results, loc('Unable to create a new authentication token. Contact your RT administrator.');
 +                RT->Logger->error('Unable to create authentication token: ' . $msg);
 +            }
 +        }
 +    }
 +    elsif ( $args_ref->{Update} || $args_ref->{Revoke} ) {
 +
 +        $token->Load( $args_ref->{Token} );
 +        if ( $token->Id ) {
 +            if ( $args_ref->{Update} ) {
 +                if ( length( $args_ref->{Description} ) ) {
 +                    if ( $args_ref->{Description} ne $token->Description ) {
 +                        my ( $ok, $msg ) = $token->SetDescription( $args_ref->{Description} );
 +                        push @results, $msg;
 +                    }
 +                }
 +                else {
 +                    push @results, loc("Description cannot be blank.");
 +                }
 +            }
 +            elsif ( $args_ref->{Revoke} ) {
 +                my ( $ok, $msg ) = $token->Delete;
 +                push @results, $msg;
 +            }
 +        }
 +        else {
 +            push @results, loc("Could not find token: [_1]", $args_ref->{Token});
 +        }
 +    }
 +    return @results;
  }
  
 -sub GetCustomFieldInputNamePrefix {
 -    RT::Interface::Web::GetCustomFieldInputNamePrefix(@_);
 +=head3 CachedCustomFieldValues FIELD
 +
 +Similar to FIELD->Values, but caches the return value of FIELD->Values
 +in $m->notes in anticipation of it being used again.
 +
 +=cut
 +
 +sub CachedCustomFieldValues {
 +    my $cf = shift;
 +
 +    my $key = 'CF-' . $cf->Id . '-Values';
 +
 +    if ($m->notes($key)) {
 +        # Reset the iterator so we always start from the beginning
 +        $m->notes($key)->GotoFirstItem;
 +        return $m->notes($key);
 +    }
 +
 +    # Wasn't in the cache; grab it and cache it.
 +    $m->notes($key, $cf->Values);
 +    return $m->notes($key);
  }
  
+ sub PreprocessTimeUpdates {
+     RT::Interface::Web::PreprocessTimeUpdates(@_);
+ }
+ 
  package RT::Interface::Web;
  RT::Base->_ImportOverlays();
  
diff --cc lib/RT/Interface/Web/MenuBuilder.pm
index e40e26aa3d,0000000000..781565c043
mode 100644,000000..100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@@ -1,1699 -1,0 +1,1700 @@@
 +# BEGIN BPS TAGGED BLOCK {{{
 +#
 +# COPYRIGHT:
 +#
 +# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
 +#                                          <sales at bestpractical.com>
 +#
 +# (Except where explicitly superseded by other copyright notices)
 +#
 +#
 +# LICENSE:
 +#
 +# This work is made available to you under the terms of Version 2 of
 +# the GNU General Public License. A copy of that license should have
 +# been provided with this software, but in any event can be snarfed
 +# from www.gnu.org.
 +#
 +# This work 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 or visit their web page on the internet at
 +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
 +#
 +#
 +# CONTRIBUTION SUBMISSION POLICY:
 +#
 +# (The following paragraph is not intended to limit the rights granted
 +# to you to modify and distribute this software under the terms of
 +# the GNU General Public License and is only of importance to you if
 +# you choose to contribute your changes and enhancements to the
 +# community by submitting them to Best Practical Solutions, LLC.)
 +#
 +# By intentionally submitting any modifications, corrections or
 +# derivatives to this work, or any other work intended for use with
 +# Request Tracker, to Best Practical Solutions, LLC, you confirm that
 +# you are the copyright holder for those contributions and you grant
 +# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
 +# royalty-free, perpetual, license to use, copy, create derivative
 +# works based on those contributions, and sublicense and distribute
 +# those contributions and any derivatives thereof.
 +#
 +# END BPS TAGGED BLOCK }}}
 +
 +=head1 NAME
 +
 +RT::Interface::Web::MenuBuilder
 +
 +=cut
 +
 +use strict;
 +use warnings;
 +
 +package RT::Interface::Web::MenuBuilder;
 +
 +sub loc { HTML::Mason::Commands::loc( @_ ); }
 +
 +sub QueryString {
 +    my %args = @_;
 +    my $u    = URI->new();
 +    $u->query_form(map { $_ => $args{$_} } sort keys %args);
 +    return $u->query;
 +}
 +
 +sub BuildMainNav {
 +    my $request_path = shift;
 +    my $top          = shift;
 +    my $widgets      = shift;
 +    my $page         = shift;
 +
 +    my %args = ( @_ );
 +
 +    my $query_string = $args{QueryString};
 +    my $query_args = $args{QueryArgs};
 +
 +    my $current_user = $HTML::Mason::Commands::session{CurrentUser};
 +
 +    if ($request_path =~ m{^/Asset/}) {
 +        $widgets->child( asset_search => raw_html => $HTML::Mason::Commands::m->scomp('/Asset/Elements/Search') );
 +        $widgets->child( create_asset => raw_html => $HTML::Mason::Commands::m->scomp('/Asset/Elements/CreateAsset') );
 +    }
 +    elsif ($request_path =~ m{^/Articles/}) {
 +        $widgets->child( article_search => raw_html => $HTML::Mason::Commands::m->scomp('/Articles/Elements/GotoArticle') );
 +        $widgets->child( create_article => raw_html => $HTML::Mason::Commands::m->scomp('/Articles/Elements/CreateArticleButton') );
 +    } else {
 +        $widgets->child( simple_search => raw_html => $HTML::Mason::Commands::m->scomp('SimpleSearch', Placeholder => loc('Search Tickets')) );
 +        $widgets->child( create_ticket => raw_html => $HTML::Mason::Commands::m->scomp('CreateTicket') );
 +    }
 +
 +    my $home = $top->child( home => title => loc('Homepage'), path => '/' );
 +    $home->child( create_ticket => title => loc("Create Ticket"),
 +                  path => "/Ticket/Create.html" );
 +
 +    my $search = $top->child( search => title => loc('Search'), path => '/Search/Simple.html' );
 +
 +    my $tickets = $search->child( tickets => title => loc('Tickets'), path => '/Search/Build.html' );
 +    $tickets->child( simple => title => loc('Simple Search'), path => "/Search/Simple.html" );
 +    $tickets->child( new    => title => loc('New Search'),    path => "/Search/Build.html?NewQuery=1" );
 +
 +    my $recents = $tickets->child( recent => title => loc('Recently Viewed'));
 +    for my $ticket ( $current_user->RecentlyViewedTickets ) {
 +        my $title = $ticket->{subject} || loc( "(No subject)" );
 +        if ( length $title > 50 ) {
 +            $title = substr($title, 0, 47);
 +            $title =~ s/\s+$//;
 +            $title .= "...";
 +        }
 +        $title = "#$ticket->{id}: " . $title;
 +        $recents->child( "$ticket->{id}" => title => $title, path => "/Ticket/Display.html?id=" . $ticket->{id} );
 +    }
 +
 +    $search->child( articles => title => loc('Articles'),   path => "/Articles/Article/Search.html" )
 +        if $current_user->HasRight( Right => 'ShowArticlesMenu', Object => RT->System );
 +
 +    $search->child( users => title => loc('Users'),   path => "/User/Search.html" );
 +
 +    $search->child( groups      =>
 +                    title       => loc('Groups'),
 +                    path        => "/Group/Search.html",
 +                    description => 'Group search'
 +    );
 +
 +    my $search_assets;
 +    if ($HTML::Mason::Commands::session{CurrentUser}->HasRight( Right => 'ShowAssetsMenu', Object => RT->System )) {
 +        $search_assets = $search->child( assets => title => loc("Assets"), path => "/Search/Build.html?Class=RT::Assets" );
 +        if (!RT->Config->Get('AssetHideSimpleSearch')) {
 +            $search_assets->child("asset_simple", title => loc("Simple Search"), path => "/Asset/Search/");
 +        }
 +        $search_assets->child("assetsql", title => loc("New Search"), path => "/Search/Build.html?Class=RT::Assets;NewQuery=1");
 +    }
 +
 +
 +    my $txns = $search->child( transactions => title => loc('Transactions'), path => '/Search/Build.html?Class=RT::Transactions;ObjectType=RT::Ticket' );
 +    my $txns_tickets = $txns->child( tickets => title => loc('Tickets'), path => "/Search/Build.html?Class=RT::Transactions;ObjectType=RT::Ticket" );
 +    $txns_tickets->child( new => title => loc('New Search'), path => "/Search/Build.html?Class=RT::Transactions;ObjectType=RT::Ticket;NewQuery=1" );
 +
 +    my $reports = $top->child( reports =>
 +        title       => loc('Reports'),
 +        description => loc('Reports and Dashboards'),
 +        path        => loc('/Reports'),
 +    );
 +
 +    unless ($HTML::Mason::Commands::session{'dashboards_in_menu'}) {
 +        my $dashboards_in_menu = $current_user->UserObj->Preferences(
 +            'DashboardsInMenu',
 +            {},
 +        );
 +
 +        unless ($dashboards_in_menu->{dashboards}) {
 +            my ($default_dashboards) =
 +                RT::System->new( $current_user )
 +                    ->Attributes
 +                    ->Named('DashboardsInMenu');
 +            if ($default_dashboards) {
 +                $dashboards_in_menu = $default_dashboards->Content;
 +            }
 +        }
 +
 +        $HTML::Mason::Commands::session{'dashboards_in_menu'} = $dashboards_in_menu->{dashboards} || [];
 +    }
 +
 +    my @dashboards;
 +    for my $id ( @{$HTML::Mason::Commands::session{'dashboards_in_menu'}} ) {
 +        my $dash = RT::Dashboard->new( $current_user );
 +        my ( $status, $msg ) = $dash->LoadById($id);
 +        if ( $status ) {
 +            push @dashboards, $dash;
 +        } else {
 +            $RT::Logger->debug( "Failed to load dashboard $id: $msg, removing from menu" );
 +            $home->RemoveDashboardMenuItem(
 +                DashboardId => $id,
 +                CurrentUser => $HTML::Mason::Commands::session{CurrentUser}->UserObj,
 +            );
 +            @{ $HTML::Mason::Commands::session{'dashboards_in_menu'} } =
 +              grep { $_ != $id } @{ $HTML::Mason::Commands::session{'dashboards_in_menu'} };
 +        }
 +    }
 +
 +    if (@dashboards) {
 +        for my $dash (@dashboards) {
 +            $reports->child( 'dashboard-' . $dash->id,
 +                title => $dash->Name,
 +                path  => '/Dashboards/' . $dash->id . '/' . $dash->Name
 +            );
 +        }
 +    }
 +
 +    # Get the list of reports in the Reports menu
 +    unless (   $HTML::Mason::Commands::session{'reports_in_menu'}
 +            && ref( $HTML::Mason::Commands::session{'reports_in_menu'}) eq 'ARRAY'
 +            && @{$HTML::Mason::Commands::session{'reports_in_menu'}}
 +           ) {
 +        my $reports_in_menu = $current_user->UserObj->Preferences(
 +            'ReportsInMenu',
 +            {},
 +        );
 +        unless ( $reports_in_menu && ref $reports_in_menu eq 'ARRAY' ) {
 +            my ($default_reports) =
 +                RT::System->new( RT->SystemUser )
 +                    ->Attributes
 +                    ->Named('ReportsInMenu');
 +            if ($default_reports) {
 +                $reports_in_menu = $default_reports->Content;
 +            }
 +            else {
 +                $reports_in_menu = [];
 +            }
 +        }
 +
 +        $HTML::Mason::Commands::session{'reports_in_menu'} = $reports_in_menu || [];
 +    }
 +
 +    for my $report ( @{$HTML::Mason::Commands::session{'reports_in_menu'}} ) {
 +        $reports->child(  $report->{id} =>
 +            title       => $report->{title},
 +            path        => $report->{path},
 +        );
 +    }
 +
 +    $reports->child( edit => title => loc('Update This Menu'), path => '/Prefs/DashboardsInMenu.html' );
 +    $reports->child( more => title => loc('All Dashboards'),   path => '/Dashboards/index.html' );
 +    my $dashboard = RT::Dashboard->new( $current_user );
 +    if ( $dashboard->CurrentUserCanCreateAny ) {
 +        $reports->child('dashboard_create' => title => loc('New Dashboard'), path => "/Dashboards/Modify.html?Create=1" );
 +    }
 +
 +
 +    if ($current_user->HasRight( Right => 'ShowArticlesMenu', Object => RT->System )) {
 +        my $articles = $top->child( articles => title => loc('Articles'), path => "/Articles/index.html");
 +        $articles->child( articles => title => loc('Overview'), path => "/Articles/index.html" );
 +        $articles->child( topics   => title => loc('Topics'),   path => "/Articles/Topics.html" );
 +        $articles->child( create   => title => loc('Create'),   path => "/Articles/Article/PreCreate.html" );
 +        $articles->child( search   => title => loc('Search'),   path => "/Articles/Article/Search.html" );
 +    }
 +
 +    if ($current_user->HasRight( Right => 'ShowAssetsMenu', Object => RT->System )) {
 +        my $assets = $top->child(
 +            "assets",
 +            title => loc("Assets"),
 +            path  => RT->Config->Get('AssetHideSimpleSearch') ? "/Search/Build.html?Class=RT::Assets;NewQuery=1" : "/Asset/Search/",
 +        );
 +        $assets->child( "create", title => loc("Create"), path => "/Asset/Create.html" );
 +        if (!RT->Config->Get('AssetHideSimpleSearch')) {
 +            $assets->child( "simple_search", title => loc("Simple Search"), path => "/Asset/Search/" );
 +        }
 +        $assets->child( "search", title => loc("New Search"), path => "/Search/Build.html?Class=RT::Assets;NewQuery=1" );
 +    }
 +
 +    my $tools = $top->child( tools => title => loc('Tools'), path => '/Tools/index.html' );
 +
 +    $tools->child( my_day =>
 +        title       => loc('My Day'),
 +        description => loc('Easy updating of your open tickets'),
 +        path        => '/Tools/MyDay.html',
 +    );
 +
 +    if ( RT->Config->Get('EnableReminders') ) {
 +        $tools->child( my_reminders =>
 +            title       => loc('My Reminders'),
 +            description => loc('Easy viewing of your reminders'),
 +            path        => '/Tools/MyReminders.html',
 +        );
 +    }
 +
 +    if ( $current_user->HasRight( Right => 'ShowApprovalsTab', Object => RT->System ) ) {
 +        $tools->child( approval =>
 +            title       => loc('Approval'),
 +            description => loc('My Approvals'),
 +            path        => '/Approvals/',
 +        );
 +    }
 +
 +    if ( $current_user->HasRight( Right => 'ShowConfigTab', Object => RT->System ) )
 +    {
 +        _BuildAdminMenu( $request_path, $top, $widgets, $page, %args );
 +    }
 +
 +    my $username = '<span class="current-user">'
 +                 . $HTML::Mason::Commands::m->interp->apply_escapes($current_user->Name, 'h')
 +                 . '</span>';
 +    my $about_me = $top->child( 'preferences' =>
 +        title        => loc('Logged in as [_1]', $username),
 +        escape_title => 0,
 +        path         => '/User/Summary.html?id=' . $current_user->id,
 +        sort_order   => 99,
 +    );
 +
 +    $about_me->child( rt_name => title => loc("RT for [_1]", RT->Config->Get('rtname')), path => '/' );
 +
 +    if ( $current_user->UserObj
 +         && $current_user->HasRight( Right => 'ModifySelf', Object => RT->System )) {
 +        my $settings = $about_me->child( settings => title => loc('Settings'), path => '/Prefs/Other.html' );
 +        $settings->child( options        => title => loc('Preferences'),        path => '/Prefs/Other.html' );
 +        $settings->child( about_me       => title => loc('About me'),       path => '/Prefs/AboutMe.html' );
 +        if ( $current_user->HasRight( Right => 'ManageAuthTokens', Object => RT->System ) ) {
 +            $settings->child( auth_tokens => title => loc('Auth Tokens'), path => '/Prefs/AuthTokens.html' );
 +        }
 +        $settings->child( search_options => title => loc('Search options'), path => '/Prefs/SearchOptions.html' );
 +        $settings->child( myrt           => title => loc('RT at a glance'), path => '/Prefs/MyRT.html' );
 +        $settings->child( dashboards_in_menu =>
 +            title => loc('Modify Reports menu'),
 +            path  => '/Prefs/DashboardsInMenu.html',
 +        );
 +        $settings->child( queue_list    => title => loc('Queue list'),   path => '/Prefs/QueueList.html' );
 +
 +        my $search_menu = $settings->child( 'saved-searches' => title => loc('Saved Searches') );
 +        my $searches = [ $HTML::Mason::Commands::m->comp( "/Search/Elements/SearchesForObject",
 +                          Object => RT::System->new( $current_user )) ];
 +        my $i = 0;
 +
 +        for my $search (@$searches) {
 +            $search_menu->child( "search-" . $i++ =>
 +                title => $search->[1],
 +                path  => "/Prefs/Search.html?"
 +                       . QueryString( name => ref( $search->[2] ) . '-' . $search->[2]->Id ),
 +            );
 +
 +        }
 +
 +        if ( $request_path =~ qr{/Prefs/(?:SearchOptions|CustomDateRanges)\.html} ) {
 +            $page->child(
 +                search_options => title => loc('Search Preferences'),
 +                path               => "/Prefs/SearchOptions.html"
 +            );
 +            $page->child(
 +                custom_date_ranges => title => loc('Custom Date Ranges'),
 +                path               => "/Prefs/CustomDateRanges.html"
 +            )
 +        }
 +
 +        if ( $request_path =~ m{^/Prefs/AuthTokens\.html} ) {
 +            $page->child( create_auth_token => title => loc('Create'),
 +                raw_html => q[<a class="btn menu-item" href="#create-auth-token" data-toggle="modal" rel="modal:open">].loc("Create")."</a>"
 +            );
 +        }
 +    }
 +    my $logout_url = RT->Config->Get('LogoutURL');
 +    if ( $current_user->Name
 +         && (   !RT->Config->Get('WebRemoteUserAuth')
 +              || RT->Config->Get('WebFallbackToRTLogin') )) {
 +        $about_me->child( logout => title => loc('Logout'), path => $logout_url );
 +    }
 +    if ( $request_path =~ m{^/Dashboards/(\d+)?}) {
 +        if ( my $id = ( $1 || $HTML::Mason::Commands::DECODED_ARGS->{'id'} ) ) {
 +            my $obj = RT::Dashboard->new( $current_user );
 +            $obj->LoadById($id);
 +            if ( $obj and $obj->id ) {
 +                $page->child( basics       => title => loc('Basics'),       path => "/Dashboards/Modify.html?id=" . $obj->id);
 +                $page->child( content      => title => loc('Content'),      path => "/Dashboards/Queries.html?id=" . $obj->id);
 +                $page->child( subscription => title => loc('Subscription'), path => "/Dashboards/Subscription.html?id=" . $obj->id)
 +                    if $obj->CurrentUserCanSubscribe;
 +                $page->child( show         => title => loc('Show'),         path => "/Dashboards/" . $obj->id . "/" . $obj->Name)
 +            }
 +        }
 +    }
 +
 +
 +    my $search_results_page_menu;
 +    if ( $request_path =~ m{^/Ticket/} ) {
 +        if ( ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} || '' ) =~ /^(\d+)$/ ) {
 +            my $id  = $1;
 +            my $obj = RT::Ticket->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                my $actions = $page->child( actions => title => loc('Actions'), sort_order  => 95 );
 +
 +                my %can = %{ $obj->CurrentUser->PrincipalObj->HasRights( Object => $obj ) };
 +                # since CurrentUserCanSetOwner returns ($ok, $msg), the parens ensure that $can{} gets $ok
 +                ( $can{'_ModifyOwner'} ) = $obj->CurrentUserCanSetOwner();
 +                my $can = sub {
 +                    unless ($_[0] eq 'ExecuteCode') {
 +                        return $can{$_[0]} || $can{'SuperUser'};
 +                    } else {
 +                        return !RT->Config->Get('DisallowExecuteCode')
 +                            && ( $can{'ExecuteCode'} || $can{'SuperUser'} );
 +                    }
 +                };
 +
 +                $page->child( bookmark => raw_html => $HTML::Mason::Commands::m->scomp( '/Ticket/Elements/Bookmark', id => $id ), sort_order => 98 );
 +
 +                if ($can->('ModifyTicket')) {
 +                    $page->child( timer => raw_html => $HTML::Mason::Commands::m->scomp( '/Ticket/Elements/PopupTimerLink', id => $id ), sort_order => 99 );
 +                }
 +
 +                $page->child( display => title => loc('Display'), path => "/Ticket/Display.html?id=" . $id );
 +                $page->child( history => title => loc('History'), path => "/Ticket/History.html?id=" . $id );
 +
 +                # comment out until we can do it for an individual custom field
 +                #if ( $can->('ModifyTicket') || $can->('ModifyCustomField') ) {
 +                $page->child( basics => title => loc('Basics'), path => "/Ticket/Modify.html?id=" . $id );
 +
 +                #}
 +
 +                if ( $can->('ModifyTicket') || $can->('_ModifyOwner') || $can->('Watch') || $can->('WatchAsAdminCc') ) {
 +                    $page->child( people => title => loc('People'), path => "/Ticket/ModifyPeople.html?id=" . $id );
 +                }
 +
 +                if ( $can->('ModifyTicket') ) {
 +                    $page->child( dates => title => loc('Dates'), path => "/Ticket/ModifyDates.html?id=" . $id );
 +                    $page->child( links => title => loc('Links'), path => "/Ticket/ModifyLinks.html?id=" . $id );
 +                }
 +
 +                #if ( $can->('ModifyTicket') || $can->('ModifyCustomField') || $can->('_ModifyOwner') ) {
 +                $page->child( jumbo => title => loc('Jumbo'), path => "/Ticket/ModifyAll.html?id=" . $id );
 +                #}
 +
 +                if ( RT->Config->Get('EnableReminders') ) {
 +                    $page->child( reminders => title => loc('Reminders'), path => "/Ticket/Reminders.html?id=" . $id );
 +                }
 +
 +                if ( $can->('ModifyTicket') or $can->('ReplyToTicket') ) {
 +                    $actions->child( reply => title => loc('Reply'), path => "/Ticket/Update.html?Action=Respond;id=" . $id );
 +                }
 +
 +                if ( $can->('ModifyTicket') or $can->('CommentOnTicket') ) {
 +                    $actions->child( comment => title => loc('Comment'), path => "/Ticket/Update.html?Action=Comment;id=" . $id );
 +                }
 +
 +                if ( $can->('ForwardMessage') ) {
 +                    $actions->child( forward => title => loc('Forward'), path => "/Ticket/Forward.html?id=" . $id );
 +                }
 +
 +                my $hide_resolve_with_deps = RT->Config->Get('HideResolveActionsWithDependencies')
 +                    && $obj->HasUnresolvedDependencies;
 +
 +                my $current   = $obj->Status;
 +                my $lifecycle = $obj->LifecycleObj;
 +                my $i         = 1;
 +                foreach my $info ( $lifecycle->Actions($current) ) {
 +                    my $next = $info->{'to'};
 +                    next unless $lifecycle->IsTransition( $current => $next );
 +
 +                    my $check = $lifecycle->CheckRight( $current => $next );
 +                    next unless $can->($check);
 +
 +                    next if $hide_resolve_with_deps
 +                        && $lifecycle->IsInactive($next)
 +                        && !$lifecycle->IsInactive($current);
 +
 +                    my $action = $info->{'update'} || '';
 +                    my $url = '/Ticket/';
 +                    $url .= "Update.html?". QueryString(
 +                        $action
 +                            ? (Action        => $action)
 +                            : (SubmitTicket  => 1, Status => $next),
 +                        DefaultStatus => $next,
 +                        id            => $id,
 +                    );
 +                    my $key = $info->{'label'} || ucfirst($next);
 +                    $actions->child( $key => title => loc( $key ), path => $url);
 +                }
 +
 +                my ($can_take, $tmsg) = $obj->CurrentUserCanSetOwner( Type => 'Take' );
 +                my ($can_steal, $smsg) = $obj->CurrentUserCanSetOwner( Type => 'Steal' );
 +                my ($can_untake, $umsg) = $obj->CurrentUserCanSetOwner( Type => 'Untake' );
 +                if ( $can_take ){
 +                    $actions->child( take => title => loc('Take'), path => "/Ticket/Display.html?Action=Take;id=" . $id );
 +                }
 +                elsif ( $can_steal ){
 +                    $actions->child( steal => title => loc('Steal'), path => "/Ticket/Display.html?Action=Steal;id=" . $id );
 +                }
 +                elsif ( $can_untake ){
 +                    $actions->child( untake => title => loc('Untake'), path => "/Ticket/Display.html?Action=Untake;id=" . $id );
 +                }
 +
 +                # TODO needs a "Can extract article into a class applied to this queue" check
 +                $actions->child( 'extract-article' =>
 +                    title => loc('Extract Article'),
 +                    path  => "/Articles/Article/ExtractIntoClass.html?Ticket=".$obj->id,
 +                ) if $current_user->HasRight( Right => 'ShowArticlesMenu', Object => RT->System );
 +
 +                $actions->child( 'edit_assets' =>
 +                    title => loc('Edit Assets'),
 +                     path => "/Asset/Search/Bulk.html?Query=Linked=" . $obj->id,
 +                ) if $can->('ModifyTicket')
 +                  && $HTML::Mason::Commands::session{CurrentUser}->HasRight( Right => 'ShowAssetsMenu', Object => RT->System );
 +
 +                if ( defined $HTML::Mason::Commands::session{"collection-RT::Tickets"} ) {
 +                    # we have to update session data if we get new ItemMap
-                     my $updatesession = 1 unless ( $HTML::Mason::Commands::session{"collection-RT::Tickets"}->{'item_map'} );
++                    my $updatesession;
++                    $updatesession = 1 unless ( $HTML::Mason::Commands::session{"collection-RT::Tickets"}->{'item_map'} );
 +
 +                    my $item_map = $HTML::Mason::Commands::session{"collection-RT::Tickets"}->ItemMap;
 +
 +                    if ($updatesession) {
 +                        $HTML::Mason::Commands::session{"collection-RT::Tickets"}->PrepForSerialization();
 +                    }
 +
 +                    my $search = $search_results_page_menu = $HTML::Mason::Commands::m->notes('search-results-page-menu', RT::Interface::Web::Menu->new());
 +
 +                    # Don't display prev links if we're on the first ticket
 +                    if ( $item_map->{$id}->{prev} ) {
 +                        $search->child(
 +                            first        => title => q{<span class="fas fa-angle-double-left"></span>},
 +                            escape_title => 0,
 +                            class        => "nav",
 +                            path         => "/Ticket/Display.html?id=" . $item_map->{first},
 +                            attributes   => {
 +                                'data-toggle'         => 'tooltip',
 +                                'data-original-title' => loc('First'),
 +                                alt                   => loc('First'),
 +                            },
 +                        );
 +                        $search->child(
 +                            prev         => title => q{<span class="fas fa-angle-left"></span>},
 +                            escape_title => 0,
 +                            class        => "nav",
 +                            path         => "/Ticket/Display.html?id=" . $item_map->{$id}->{prev},
 +                            attributes   => {
 +                                'data-toggle'         => 'tooltip',
 +                                'data-original-title' => loc('Prev'),
 +                                alt                   => loc('Prev'),
 +                            },
 +                        );
 +                    }
 +                    # Don't display next links if we're on the last ticket
 +                    if ( $item_map->{$id}->{next} ) {
 +                        $search->child(
 +                            next         => title => q{<span class="fas fa-angle-right"></span>},
 +                            escape_title => 0,
 +                            class        => "nav",
 +                            path         => "/Ticket/Display.html?id=" . $item_map->{$id}->{next},
 +                            attributes   => {
 +                                'data-toggle'         => 'tooltip',
 +                                'data-original-title' => loc('Next'),
 +                                alt                   => loc('Next'),
 +                            },
 +                        );
 +                        if ( $item_map->{last} ) {
 +                            $search->child(
 +                                last         => title => q{<span class="fas fa-angle-double-right"></span>},
 +                                escape_title => 0,
 +                                class        => "nav",
 +                                path         => "/Ticket/Display.html?id=" . $item_map->{last},
 +                                attributes   => {
 +                                    'data-toggle'         => 'tooltip',
 +                                    'data-original-title' => loc('Last'),
 +                                    alt                   => loc('Last'),
 +                                },
 +                            );
 +                        }
 +                    }
 +                }
 +            }
 +        }
 +    }
 +
 +    # display "View ticket" link in transactions
 +    if ( $request_path =~ m{^/Transaction/Display.html} ) {
 +        if ( ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} || '' ) =~ /^(\d+)$/ ) {
 +            my $txn_id = $1;
 +            my $txn = RT::Transaction->new( $current_user );
 +            $txn->Load( $txn_id );
 +            my $object = $txn->Object;
 +            if ( $object->Id ) {
 +                my $object_type = $object->RecordType;
 +                if ( $object_type eq 'Ticket' ) {
 +                    $page->child( view_ticket => title => loc("View ticket"), path => "/Ticket/Display.html?id=" . $object->Id );
 +                }
 +            }
 +        }
 +    }
 +
 +    # Scope here so we can share in the Privileged callback
 +    my $args      = '';
 +    my $has_query = '';
 +    if (
 +        (
 +               $request_path =~ m{^/(?:Ticket|Transaction|Search)/}
 +            && $request_path !~ m{^/Search/Simple\.html}
 +        )
 +        || (   $request_path =~ m{^/Search/Simple\.html}
 +            && $HTML::Mason::Commands::DECODED_ARGS->{'q'} )
 +
 +        # TODO: Asset simple search and SQL search don't share query, we
 +        # can't simply link to SQL search page on asset pages without
 +        # identifying if it's from simple search or SQL search. For now,
 +        # show "Current Search" only if asset simple search is disabled.
 +
 +        || ( $search_assets && $request_path =~ m{^/Asset/(?!Search/)} && RT->Config->Get('AssetHideSimpleSearch') )
 +      )
 +    {
 +        my $class = $HTML::Mason::Commands::DECODED_ARGS->{Class}
 +            || ( $request_path =~ m{^/(Transaction|Ticket|Asset)/} ? "RT::$1s" : 'RT::Tickets' );
 +
 +        my $search;
 +        if ( $class eq 'RT::Tickets' ) {
 +            $search = $top->child('search')->child('tickets');
 +        }
 +        elsif ( $class eq 'RT::Assets' ) {
 +            $search = $search_assets;
 +        }
 +        else {
 +            $search = $txns_tickets;
 +        }
 +
 +        my $hash_name = join '-', 'CurrentSearchHash', $class,
 +            $HTML::Mason::Commands::DECODED_ARGS->{ObjectType} || ( $class eq 'RT::Transactions' ? 'RT::Ticket' : () );
 +        my $current_search = $HTML::Mason::Commands::session{$hash_name} || {};
 +        my $search_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchLoad'} || $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchId'} || $current_search->{'SearchId'} || '';
 +        my $chart_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedChartSearchId'} || $current_search->{SavedChartSearchId};
 +
 +        $has_query = 1 if ( $HTML::Mason::Commands::DECODED_ARGS->{'Query'} or $current_search->{'Query'} );
 +
 +        my %query_args;
 +        my %fallback_query_args = (
 +            SavedSearchId => ( $search_id eq 'new' ) ? undef : $search_id,
 +            SavedChartSearchId => $chart_id,
 +            (
 +                map {
 +                    my $p = $_;
 +                    $p => $HTML::Mason::Commands::DECODED_ARGS->{$p} || $current_search->{$p}
 +                } qw(Query Format OrderBy Order Page Class ObjectType ResultPage ExtraQueryParams),
 +            ),
 +            RowsPerPage => (
 +                defined $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
 +                ? $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
 +                : $current_search->{'RowsPerPage'}
 +            ),
 +        );
 +
 +        if ( my $extra_params = $fallback_query_args{ExtraQueryParams} ) {
 +            for my $param ( ref $extra_params eq 'ARRAY' ? @$extra_params : $extra_params ) {
 +                $fallback_query_args{$param}
 +                    = $HTML::Mason::Commands::DECODED_ARGS->{$param} || $current_search->{$param};
 +            }
 +        }
 +
 +        $fallback_query_args{Class} ||= $class;
 +        $fallback_query_args{ObjectType} ||= 'RT::Ticket' if $class eq 'RT::Transactions';
 +
 +        if ($query_string) {
 +            $args = '?' . $query_string;
 +        }
 +        else {
 +            my %final_query_args = ();
 +            # key => callback to avoid unnecessary work
 +
 +            if ( my $extra_params = $query_args->{ExtraQueryParams} ) {
 +                $final_query_args{ExtraQueryParams} = $extra_params;
 +                for my $param ( ref $extra_params eq 'ARRAY' ? @$extra_params : $extra_params ) {
 +                    $final_query_args{$param} = $query_args->{$param};
 +                }
 +            }
 +
 +            for my $param (keys %fallback_query_args) {
 +                $final_query_args{$param} = defined($query_args->{$param})
 +                                          ? $query_args->{$param}
 +                                          : $fallback_query_args{$param};
 +            }
 +
 +            for my $field (qw(Order OrderBy)) {
 +                if ( ref( $final_query_args{$field} ) eq 'ARRAY' ) {
 +                    $final_query_args{$field} = join( "|", @{ $final_query_args{$field} } );
 +                } elsif (not defined $final_query_args{$field}) {
 +                    delete $final_query_args{$field};
 +                }
 +                else {
 +                    $final_query_args{$field} ||= '';
 +                }
 +            }
 +
 +            $args = '?' . QueryString(%final_query_args);
 +        }
 +
 +        my $current_search_menu;
 +        if (   $class eq 'RT::Tickets' && $request_path =~ m{^/Ticket}
 +            || $class eq 'RT::Transactions' && $request_path =~ m{^/Transaction}
 +            || $class eq 'RT::Assets' && $request_path =~ m{^/Asset/(?!Search/)} )
 +        {
 +            $current_search_menu = $search->child( current_search => title => loc('Current Search') );
 +            $current_search_menu->path("/Search/Results.html$args") if $has_query;
 +
 +            if ( $search_results_page_menu && $has_query ) {
 +                $search_results_page_menu->child(
 +                    current_search => title => q{<span class="fas fa-list"></span>},
 +                    escape_title   => 0,
 +                    sort_order     => -1,
 +                    path           => "/Search/Results.html$args",
 +                    attributes     => {
 +                        'data-toggle'         => 'tooltip',
 +                        'data-original-title' => loc('Return to Search Results'),
 +                        alt                   => loc('Return to Search Results'),
 +                    },
 +                );
 +            }
 +        }
 +        else {
 +            $current_search_menu = $page;
 +        }
 +
 +        $current_search_menu->child( edit_search =>
 +            title => loc('Edit Search'), path => "/Search/Build.html" . ( ($has_query) ? $args : '' ) );
 +        if ( $current_user->HasRight( Right => 'ShowSearchAdvanced', Object => RT->System ) ) {
 +            $current_search_menu->child( advanced => title => loc('Advanced'), path => "/Search/Edit.html$args" );
 +        }
 +        if ($has_query) {
 +            my $result_page = $HTML::Mason::Commands::DECODED_ARGS->{ResultPage};
 +            if ( $result_page ) {
 +                if ( my $web_path = RT->Config->Get('WebPath') ) {
 +                    $result_page =~ s!^$web_path!!;
 +                }
 +            }
 +            else {
 +                $result_page = '/Search/Results.html';
 +            }
 +
 +            $current_search_menu->child( results => title => loc('Show Results'), path => "$result_page$args" );
 +        }
 +
 +        if ( $has_query ) {
 +            if ( $class eq 'RT::Tickets' ) {
 +                if ( $current_user->HasRight( Right => 'ShowSearchBulkUpdate', Object => RT->System ) ) {
 +                    $current_search_menu->child( bulk  => title => loc('Bulk Update'), path => "/Search/Bulk.html$args" );
 +                }
 +                $current_search_menu->child( chart => title => loc('Chart'),       path => "/Search/Chart.html$args" );
 +            }
 +            elsif ( $class eq 'RT::Assets' ) {
 +                $current_search_menu->child( bulk  => title => loc('Bulk Update'), path => "/Asset/Search/Bulk.html$args" );
 +            }
 +
 +            my $more = $current_search_menu->child( more => title => loc('Feeds') );
 +
 +            $more->child( spreadsheet => title => loc('Spreadsheet'), path => "/Search/Results.tsv$args" );
 +
 +            if ( $class eq 'RT::Tickets' ) {
 +                my %rss_data
 +                    = map { $_ => $query_args->{$_} || $fallback_query_args{$_} || '' } qw(Query Order OrderBy);
 +                my $RSSQueryString = "?"
 +                    . QueryString(
 +                    Query   => $rss_data{Query},
 +                    Order   => $rss_data{Order},
 +                    OrderBy => $rss_data{OrderBy}
 +                    );
 +                my $RSSPath = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
 +                    $current_user->UserObj->Name,
 +                    $current_user->UserObj->GenerateAuthString(
 +                    $rss_data{Query} . $rss_data{Order} . $rss_data{OrderBy} );
 +
 +                $more->child( rss => title => loc('RSS'), path => "/NoAuth/rss/$RSSPath/$RSSQueryString" );
 +                my $ical_path = join '/', map $HTML::Mason::Commands::m->interp->apply_escapes( $_, 'u' ),
 +                    $current_user->UserObj->Name,
 +                    $current_user->UserObj->GenerateAuthString( $rss_data{Query} ),
 +                    $rss_data{Query};
 +                $more->child( ical => title => loc('iCal'), path => '/NoAuth/iCal/' . $ical_path );
 +
 +                #XXX TODO better abstraction of SuperUser right check
 +                if ( $current_user->HasRight( Right => 'SuperUser', Object => RT->System ) ) {
 +                    my $shred_args = QueryString(
 +                        Search          => 1,
 +                        Plugin          => 'Tickets',
 +                        'Tickets:query' => $rss_data{'Query'},
 +                        'Tickets:limit' => $query_args->{'Rows'},
 +                    );
 +
 +                    $more->child(
 +                        shredder => title => loc('Shredder'),
 +                        path     => '/Admin/Tools/Shredder/?' . $shred_args
 +                    );
 +                }
 +            }
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Article/} ) {
 +        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
 +            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
 +            $page->child( display => title => loc('Display'), path => "/Articles/Article/Display.html?id=".$id );
 +            $page->child( history => title => loc('History'), path => "/Articles/Article/History.html?id=".$id );
 +            $page->child( modify  => title => loc('Modify'),  path => "/Articles/Article/Edit.html?id=".$id );
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Articles/} ) {
 +        $page->child( search => title => loc("Search"),       path => "/Articles/Article/Search.html" );
 +        if ( $request_path =~ m{^/Articles/Article/} and ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} || '' ) =~ /^(\d+)$/ ) {
 +            my $id  = $1;
 +            my $obj = RT::Article->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                $page->child( display => title => loc("Display"), path => "/Articles/Article/Display.html?id=" . $id );
 +                $page->child( history => title => loc('History'), path => '/Articles/Article/History.html?id=' . $id );
 +
 +                if ( $obj->CurrentUserHasRight('ModifyArticle') ) {
 +                    $page->child(modify => title => loc('Modify'), path => '/Articles/Article/Edit.html?id=' . $id );
 +                }
 +            }
 +        }
 +
 +    }
 +
 +    if ($request_path =~ m{^/Asset/} and $HTML::Mason::Commands::DECODED_ARGS->{id} and $HTML::Mason::Commands::DECODED_ARGS->{id} !~ /\D/) {
 +        _BuildAssetMenu( $request_path, $top, $widgets, $page, %args );
 +    } elsif ( $request_path =~ m{^/Asset/Search/(?:index\.html)?$}
 +        || ( $request_path =~ m{^/Asset/Search/Bulk\.html$} && $HTML::Mason::Commands::DECODED_ARGS->{Catalog} ) ) {
 +        my %search = map @{$_},
 +            grep defined $_->[1] && length $_->[1],
 +            map {ref $HTML::Mason::Commands::DECODED_ARGS->{$_} ? [$_, $HTML::Mason::Commands::DECODED_ARGS->{$_}[0]] : [$_, $HTML::Mason::Commands::DECODED_ARGS->{$_}] }
 +            grep /^(?:q|SearchAssets|!?(Name|Description|Catalog|Status|Role\..+|CF\..+)|Order(?:By)?|Page)$/,
 +            keys %$HTML::Mason::Commands::DECODED_ARGS;
 +
 +        if ( $request_path =~ /Bulk/) {
 +            $page->child('search',
 +                title => loc('Show Results'),
 +                path => '/Asset/Search/?' . (keys %search ? QueryString(%search) : ''),
 +            );
 +        } else {
 +            $page->child('bulk',
 +                title => loc('Bulk Update'),
 +                path => '/Asset/Search/Bulk.html?' . (keys %search ? QueryString(%search) : ''),
 +            );
 +        }
 +
 +        $page->child('csv',
 +            title => loc('Download Spreadsheet'),
 +            path  => '/Search/Results.tsv?' . QueryString(%search, Class => 'RT::Assets'),
 +        );
 +    } elsif ($request_path =~ m{^/Asset/Search/}) {
 +        my %search = map @{$_},
 +            grep defined $_->[1] && length $_->[1],
 +            map {ref $HTML::Mason::Commands::DECODED_ARGS->{$_} ? [$_, $HTML::Mason::Commands::DECODED_ARGS->{$_}[0]] : [$_, $HTML::Mason::Commands::DECODED_ARGS->{$_}] }
 +            grep /^(?:q|SearchAssets|!?(Name|Description|Catalog|Status|Role\..+|CF\..+)|Order(?:By)?|Page)$/,
 +            keys %$HTML::Mason::Commands::DECODED_ARGS;
 +
 +        my $current_search = $HTML::Mason::Commands::session{"CurrentSearchHash-RT::Assets"} || {};
 +        my $search_id = $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchLoad'} || $HTML::Mason::Commands::DECODED_ARGS->{'SavedSearchId'} || $current_search->{'SearchId'} || '';
 +        my $args      = '';
 +        my $has_query;
 +        $has_query = 1 if ( $HTML::Mason::Commands::DECODED_ARGS->{'Query'} or $current_search->{'Query'} );
 +
 +        my %query_args;
 +        my %fallback_query_args = (
 +            Class => 'RT::Assets',
 +            SavedSearchId => ( $search_id eq 'new' ) ? undef : $search_id,
 +            (
 +                map {
 +                    my $p = $_;
 +                    $p => $HTML::Mason::Commands::DECODED_ARGS->{$p} || $current_search->{$p}
 +                } qw(Query Format OrderBy Order Page)
 +            ),
 +            RowsPerPage => (
 +                defined $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
 +                ? $HTML::Mason::Commands::DECODED_ARGS->{'RowsPerPage'}
 +                : $current_search->{'RowsPerPage'}
 +            ),
 +        );
 +
 +        if ($query_string) {
 +            $args = '?' . $query_string;
 +        }
 +        else {
 +            my %final_query_args = ();
 +            # key => callback to avoid unnecessary work
 +
 +            for my $param (keys %fallback_query_args) {
 +                $final_query_args{$param} = defined($query_args->{$param})
 +                                          ? $query_args->{$param}
 +                                          : $fallback_query_args{$param};
 +            }
 +
 +            for my $field (qw(Order OrderBy)) {
 +                if ( ref( $final_query_args{$field} ) eq 'ARRAY' ) {
 +                    $final_query_args{$field} = join( "|", @{ $final_query_args{$field} } );
 +                } elsif (not defined $final_query_args{$field}) {
 +                    delete $final_query_args{$field};
 +                }
 +                else {
 +                    $final_query_args{$field} ||= '';
 +                }
 +            }
 +
 +            $args = '?' . QueryString(%final_query_args);
 +        }
 +
 +        $page->child('edit_search',
 +            title      => loc('Edit Search'),
 +            path       => '/Search/Build.html' . $args,
 +        );
 +        $page->child( advanced => title => loc('Advanced'), path => '/Search/Edit.html' . $args );
 +        if ($has_query) {
 +            $page->child( results => title => loc('Show Results'), path => '/Search/Results.html' . $args );
 +            $page->child('bulk',
 +                title => loc('Bulk Update'),
 +                path => '/Asset/Search/Bulk.html' . $args,
 +            );
 +            my $more = $page->child( more => title => loc('Feeds') );
 +            $more->child( spreadsheet => title => loc('Spreadsheet'), path => "/Search/Results.tsv$args" );
 +        }
 +    } elsif ($request_path =~ m{^/Admin/Global/CustomFields/Catalog-Assets\.html$}) {
 +        $page->child("create", title => loc("Create New"), path => "/Admin/CustomFields/Modify.html?Create=1;LookupType=" . RT::Asset->CustomFieldLookupType);
 +    } elsif ($request_path =~ m{^/Admin/CustomFields(/|/index\.html)?$}
 +            and $HTML::Mason::Commands::DECODED_ARGS->{'Type'} and $HTML::Mason::Commands::DECODED_ARGS->{'Type'} eq RT::Asset->CustomFieldLookupType) {
 +        $page->child("create")->path( $page->child("create")->path . ";LookupType=" . RT::Asset->CustomFieldLookupType );
 +    } elsif ($request_path =~ m{^/Admin/Assets/Catalogs/}) {
 +        my $actions = $request_path =~ m{/((index|Create)\.html)?$}
 +            ? $page
 +            : $page->child("catalogs", title => loc("Catalogs"), path => "/Admin/Assets/Catalogs/");
 +
 +        $actions->child("select", title => loc("Select"), path => "/Admin/Assets/Catalogs/");
 +        $actions->child("create", title => loc("Create"), path => "/Admin/Assets/Catalogs/Create.html");
 +
 +        my $catalog = RT::Catalog->new( $current_user );
 +        $catalog->Load($HTML::Mason::Commands::DECODED_ARGS->{id}) if $HTML::Mason::Commands::DECODED_ARGS->{id};
 +
 +        if ($catalog->id and $catalog->CurrentUserCanSee) {
 +            my $query = "id=" . $catalog->id;
 +            $page->child("modify", title => loc("Basics"), path => "/Admin/Assets/Catalogs/Modify.html?$query");
 +            $page->child("people", title => loc("Roles"),  path => "/Admin/Assets/Catalogs/Roles.html?$query");
 +
 +            $page->child("cfs", title => loc("Asset Custom Fields"), path => "/Admin/Assets/Catalogs/CustomFields.html?$query");
 +
 +            $page->child("group-rights", title => loc("Group Rights"), path => "/Admin/Assets/Catalogs/GroupRights.html?$query");
 +            $page->child("user-rights",  title => loc("User Rights"),  path => "/Admin/Assets/Catalogs/UserRights.html?$query");
 +
 +            $page->child("default-values", title => loc('Default Values'), path => "/Admin/Assets/Catalogs/DefaultValues.html?$query");
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/User/(Summary|History)\.html} ) {
 +        if ($page->child('summary')) {
 +            # Already set up from having AdminUser and ShowConfigTab;
 +            # but rename "Basics" to "Edit" in this context
 +            $page->child( 'basics' )->title( loc('Edit') );
 +        } elsif ( $current_user->HasRight( Object => $RT::System, Right => 'ShowUserHistory' ) ) {
 +            $page->child( display => title => loc('Summary'), path => '/User/Summary.html?id=' . $HTML::Mason::Commands::DECODED_ARGS->{'id'} );
 +            $page->child( history => title => loc('History'), path => '/User/History.html?id=' . $HTML::Mason::Commands::DECODED_ARGS->{'id'} );
 +        }
 +    }
 +
 +    if ( $request_path =~ /^\/(?:index.html|$)/ ) {
 +        my $alt = loc('Edit');
 +        $page->child( edit => raw_html => q[<a id="page-edit" class="menu-item" href="] . RT->Config->Get('WebPath') . qq[/Prefs/MyRT.html"><span class="fas fa-cog" alt="$alt" data-toggle="tooltip" data-placement="top" data-original-title="$alt"></span></a>] );
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Tools/(Configuration|EditConfig|ConfigHistory)} ) {
 +        $page->child( display => title => loc('View'), path => "/Admin/Tools/Configuration.html" );
 +        $page->child( modify => title => loc('Edit'), path => "/Admin/Tools/EditConfig.html" ) if RT->Config->Get('ShowEditSystemConfig');
 +        $page->child( history => title => loc('History'), path => "/Admin/Tools/ConfigHistory.html" );
 +    }
 +
 +    # due to historical reasons of always having been in /Elements/Tabs
 +    $HTML::Mason::Commands::m->callback( CallbackName => 'Privileged', Path => $request_path, Search_Args => $args, Has_Query => $has_query, ARGSRef => \%args, CallbackPage => '/Elements/Tabs' );
 +}
 +
 +sub _BuildAssetMenu {
 +    my $request_path = shift;
 +    my $top          = shift;
 +    my $widgets      = shift;
 +    my $page         = shift;
 +
 +    my %args = ( @_ );
 +
 +    my $current_user = $HTML::Mason::Commands::session{CurrentUser};
 +
 +    my $id    = $HTML::Mason::Commands::DECODED_ARGS->{id};
 +    my $asset = RT::Asset->new( $current_user );
 +    $asset->Load($id);
 +
 +    if ($asset->id) {
 +        $page->child("display",     title => HTML::Mason::Commands::loc("Display"),        path => "/Asset/Display.html?id=$id");
 +        $page->child("history",     title => HTML::Mason::Commands::loc("History"),        path => "/Asset/History.html?id=$id");
 +        $page->child("basics",      title => HTML::Mason::Commands::loc("Basics"),         path => "/Asset/Modify.html?id=$id");
 +        $page->child("links",       title => HTML::Mason::Commands::loc("Links"),          path => "/Asset/ModifyLinks.html?id=$id");
 +        $page->child("people",      title => HTML::Mason::Commands::loc("People"),         path => "/Asset/ModifyPeople.html?id=$id");
 +        $page->child("dates",       title => HTML::Mason::Commands::loc("Dates"),          path => "/Asset/ModifyDates.html?id=$id");
 +
 +        for my $grouping (RT::CustomField->CustomGroupings($asset)) {
 +            my $cfs = $asset->CustomFields;
 +            $cfs->LimitToGrouping( $asset => $grouping );
 +            next unless $cfs->Count;
 +            $page->child(
 +                "cf-grouping-$grouping",
 +                title   => HTML::Mason::Commands::loc($grouping),
 +                path    => "/Asset/ModifyCFs.html?id=$id;Grouping=" . $HTML::Mason::Commands::m->interp->apply_escapes($grouping, 'u'),
 +            );
 +        }
 +
 +        _BuildAssetMenuActionSubmenu( $request_path, $top, $widgets, $page, %args, Asset => $asset );
 +    }
 +}
 +
 +sub _BuildAssetMenuActionSubmenu {
 +    my $request_path = shift;
 +    my $top          = shift;
 +    my $widgets      = shift;
 +    my $page         = shift;
 +
 +    my %args = (
 +        Asset => undef,
 +        @_
 +    );
 +
 +    my $asset = $args{Asset};
 +    my $id    = $asset->id;
 +
 +    my $actions = $page->child("actions", title => HTML::Mason::Commands::loc("Actions"));
 +    $actions->child("create-linked-ticket", title => HTML::Mason::Commands::loc("Create linked ticket"), path => "/Asset/CreateLinkedTicket.html?Asset=$id");
 +
 +    my $status    = $asset->Status;
 +    my $lifecycle = $asset->LifecycleObj;
 +    for my $action ( $lifecycle->Actions($status) ) {
 +        my $next = $action->{'to'};
 +        next unless $lifecycle->IsTransition( $status => $next );
 +
 +        my $check = $lifecycle->CheckRight( $status => $next );
 +        next unless $asset->CurrentUserHasRight($check);
 +
 +        my $label = $action->{'label'} || ucfirst($next);
 +        $actions->child(
 +            $label,
 +            title   => HTML::Mason::Commands::loc($label),
 +            path    => "/Asset/Modify.html?id=$id;Update=1;DisplayAfter=1;Status="
 +                        . $HTML::Mason::Commands::m->interp->apply_escapes($next, 'u'),
 +
 +            class       => "asset-lifecycle-action",
 +            attributes  => {
 +                'data-current-status'   => $status,
 +                'data-next-status'      => $next,
 +            },
 +        );
 +    }
 +}
 +
 +sub _BuildAdminMenu {
 +    my $request_path = shift;
 +    my $top          = shift;
 +    my $widgets      = shift;
 +    my $page         = shift;
 +
 +    my %args = ( @_ );
 +
 +    my $current_user = $HTML::Mason::Commands::session{CurrentUser};
 +
 +    my $admin = $top->child( admin => title => loc('Admin'), path => '/Admin/' );
 +    if ( $current_user->HasRight( Object => RT->System, Right => 'AdminUsers' ) ) {
 +        my $users = $admin->child( users =>
 +            title       => loc('Users'),
 +            description => loc('Manage users and passwords'),
 +            path        => '/Admin/Users/',
 +        );
 +        $users->child( select => title => loc('Select'), path => "/Admin/Users/" );
 +        $users->child( create => title => loc('Create'), path => "/Admin/Users/Modify.html?Create=1" );
 +    }
 +    my $groups = $admin->child( groups =>
 +        title       => loc('Groups'),
 +        description => loc('Manage groups and group membership'),
 +        path        => '/Admin/Groups/',
 +    );
 +    $groups->child( select => title => loc('Select'), path => "/Admin/Groups/" );
 +    $groups->child( create => title => loc('Create'), path => "/Admin/Groups/Modify.html?Create=1" );
 +
 +    my $queues = $admin->child( queues =>
 +        title       => loc('Queues'),
 +        description => loc('Manage queues and queue-specific properties'),
 +        path        => '/Admin/Queues/',
 +    );
 +    $queues->child( select => title => loc('Select'), path => "/Admin/Queues/" );
 +    $queues->child( create => title => loc('Create'), path => "/Admin/Queues/Modify.html?Create=1" );
 +
 +    if ( $current_user->HasRight( Object => RT->System, Right => 'AdminCustomField' ) ) {
 +        my $cfs = $admin->child( 'custom-fields' =>
 +            title       => loc('Custom Fields'),
 +            description => loc('Manage custom fields and custom field values'),
 +            path        => '/Admin/CustomFields/',
 +        );
 +        $cfs->child( select => title => loc('Select'), path => "/Admin/CustomFields/" );
 +        $cfs->child( create => title => loc('Create'), path => "/Admin/CustomFields/Modify.html?Create=1" );
 +    }
 +
 +    if ( $current_user->HasRight( Object => RT->System, Right => 'AdminCustomRoles' ) ) {
 +        my $roles = $admin->child( 'custom-roles' =>
 +            title       => loc('Custom Roles'),
 +            description => loc('Manage custom roles'),
 +            path        => '/Admin/CustomRoles/',
 +        );
 +        $roles->child( select => title => loc('Select'), path => "/Admin/CustomRoles/" );
 +        $roles->child( create => title => loc('Create'), path => "/Admin/CustomRoles/Modify.html?Create=1" );
 +    }
 +
 +    if ( $current_user->HasRight( Object => RT->System, Right => 'ModifyScrips' ) ) {
 +        my $scrips = $admin->child( 'scrips' =>
 +            title       => loc('Scrips'),
 +            description => loc('Manage scrips'),
 +            path        => '/Admin/Scrips/',
 +        );
 +        $scrips->child( select => title => loc('Select'), path => "/Admin/Scrips/" );
 +        $scrips->child( create => title => loc('Create'), path => "/Admin/Scrips/Create.html" );
 +    }
 +
 +    if ( RT->Config->Get('ShowEditLifecycleConfig')
 +        && $current_user->HasRight( Object => RT->System, Right => 'SuperUser' ) )
 +    {
 +        my $lifecycles = $admin->child(
 +            lifecycles => title => loc('Lifecycles'),
 +            path       => '/Admin/Lifecycles/',
 +        );
 +
 +        $lifecycles->child( select => title => loc('Select'), path => '/Admin/Lifecycles/' );
 +        $lifecycles->child( create => title => loc('Create'), path => '/Admin/Lifecycles/Create.html' );
 +    }
 +
 +    my $admin_global = $admin->child( global =>
 +        title       => loc('Global'),
 +        description => loc('Manage properties and configuration which apply to all queues'),
 +        path        => '/Admin/Global/',
 +    );
 +
 +    my $scrips = $admin_global->child( scrips =>
 +        title       => loc('Scrips'),
 +        description => loc('Modify scrips which apply to all queues'),
 +        path        => '/Admin/Global/Scrips.html',
 +    );
 +    $scrips->child( select => title => loc('Select'), path => "/Admin/Global/Scrips.html" );
 +    $scrips->child( create => title => loc('Create'), path => "/Admin/Scrips/Create.html?Global=1" );
 +
 +    my $conditions = $admin_global->child( conditions =>
 +        title => loc('Conditions'),
 +        description => loc('Edit system conditions'),
 +        path        => '/Admin/Global/Conditions.html',
 +    );
 +    $conditions->child( select => title => loc('Select'), path => "/Admin/Global/Conditions.html" );
 +    $conditions->child( create => title => loc('Create'), path => "/Admin/Conditions/Create.html" );
 +
 +    my $actions   = $admin_global->child( actions =>
 +        title => loc('Actions'),
 +        description => loc('Edit system actions'),
 +        path        => '/Admin/Global/Actions.html',
 +    );
 +    $actions->child( select => title => loc('Select'), path => "/Admin/Global/Actions.html" );
 +    $actions->child( create => title => loc('Create'), path => "/Admin/Actions/Create.html" );
 +
 +    my $templates = $admin_global->child( templates =>
 +        title       => loc('Templates'),
 +        description => loc('Edit system templates'),
 +        path        => '/Admin/Global/Templates.html',
 +    );
 +    $templates->child( select => title => loc('Select'), path => "/Admin/Global/Templates.html" );
 +    $templates->child( create => title => loc('Create'), path => "/Admin/Global/Template.html?Create=1" );
 +
 +    my $cfadmin = $admin_global->child( 'custom-fields' =>
 +        title       => loc('Custom Fields'),
 +        description => loc('Modify global custom fields'),
 +        path        => '/Admin/Global/CustomFields/index.html',
 +    );
 +    $cfadmin->child( users =>
 +        title       => loc('Users'),
 +        description => loc('Select custom fields for all users'),
 +        path        => '/Admin/Global/CustomFields/Users.html',
 +    );
 +    $cfadmin->child( groups =>
 +        title       => loc('Groups'),
 +        description => loc('Select custom fields for all user groups'),
 +        path        => '/Admin/Global/CustomFields/Groups.html',
 +    );
 +    $cfadmin->child( queues =>
 +        title       => loc('Queues'),
 +        description => loc('Select custom fields for all queues'),
 +        path        => '/Admin/Global/CustomFields/Queues.html',
 +    );
 +    $cfadmin->child( tickets =>
 +        title       => loc('Tickets'),
 +        description => loc('Select custom fields for tickets in all queues'),
 +        path        => '/Admin/Global/CustomFields/Queue-Tickets.html',
 +    );
 +    $cfadmin->child( transactions =>
 +        title       => loc('Ticket Transactions'),
 +        description => loc('Select custom fields for transactions on tickets in all queues'),
 +        path        => '/Admin/Global/CustomFields/Queue-Transactions.html',
 +    );
 +    $cfadmin->child( 'custom-fields' =>
 +        title       => loc('Articles'),
 +        description => loc('Select Custom Fields for Articles in all Classes'),
 +        path        => '/Admin/Global/CustomFields/Class-Article.html',
 +    );
 +    $cfadmin->child( 'assets' =>
 +        title       => loc('Assets'),
 +        description => loc('Select Custom Fields for Assets in all Catalogs'),
 +        path        => '/Admin/Global/CustomFields/Catalog-Assets.html',
 +    );
 +
 +    my $article_admin = $admin->child( articles => title => loc('Articles'), path => "/Admin/Articles/index.html" );
 +    my $class_admin = $article_admin->child(classes => title => loc('Classes'), path => '/Admin/Articles/Classes/' );
 +    $class_admin->child( select =>
 +        title       => loc('Select'),
 +        description => loc('Modify and Create Classes'),
 +        path        => '/Admin/Articles/Classes/',
 +    );
 +    $class_admin->child( create =>
 +        title       => loc('Create'),
 +        description => loc('Modify and Create Custom Fields for Articles'),
 +        path        => '/Admin/Articles/Classes/Modify.html?Create=1',
 +    );
 +
 +
 +    my $cfs = $article_admin->child( 'custom-fields' =>
 +        title => loc('Custom Fields'),
 +        path  => '/Admin/CustomFields/index.html?'.$HTML::Mason::Commands::m->comp('/Elements/QueryString', Type => 'RT::Class-RT::Article'),
 +    );
 +    $cfs->child( select =>
 +        title => loc('Select'),
 +        path => '/Admin/CustomFields/index.html?'.$HTML::Mason::Commands::m->comp('/Elements/QueryString', Type => 'RT::Class-RT::Article'),
 +    );
 +    $cfs->child( create =>
 +        title => loc('Create'),
 +        path => '/Admin/CustomFields/Modify.html?'.$HTML::Mason::Commands::m->comp("/Elements/QueryString", Create=>1, LookupType=> "RT::Class-RT::Article" ),
 +    );
 +
 +    my $assets_admin = $admin->child( assets => title => loc("Assets"), path => '/Admin/Assets/' );
 +    my $catalog_admin = $assets_admin->child( catalogs =>
 +        title       => loc("Catalogs"),
 +        description => loc("Modify asset catalogs"),
 +        path        => "/Admin/Assets/Catalogs/"
 +    );
 +    $catalog_admin->child( "select", title => loc("Select"), path => $catalog_admin->path );
 +    $catalog_admin->child( "create", title => loc("Create"), path => "Create.html" );
 +
 +
 +    my $assets_cfs = $assets_admin->child( "cfs",
 +        title => loc("Custom Fields"),
 +        description => loc("Modify asset custom fields"),
 +        path => "/Admin/CustomFields/?Type=" . RT::Asset->CustomFieldLookupType
 +    );
 +    $assets_cfs->child( "select", title => loc("Select"), path => $assets_cfs->path );
 +    $assets_cfs->child( "create", title => loc("Create"), path => "/Admin/CustomFields/Modify.html?Create=1;LookupType=" . RT::Asset->CustomFieldLookupType);
 +
 +    $admin_global->child( 'group-rights' =>
 +        title       => loc('Group Rights'),
 +        description => loc('Modify global group rights'),
 +        path        => '/Admin/Global/GroupRights.html',
 +    );
 +    $admin_global->child( 'user-rights' =>
 +        title       => loc('User Rights'),
 +        description => loc('Modify global user rights'),
 +        path        => '/Admin/Global/UserRights.html',
 +    );
 +    $admin_global->child( 'my-rt' =>
 +        title       => loc('RT at a glance'),
 +        description => loc('Modify the default "RT at a glance" view'),
 +        path        => '/Admin/Global/MyRT.html',
 +    );
 +
 +    if (RT->Config->Get('SelfServiceUseDashboard')) {
 +        if ($current_user->HasRight( Right => 'ModifyDashboard', Object => RT->System ) ) {
 +            my $self_service = $admin_global->child( selfservice_home =>
 +                                                     title       => loc('Self Service Home Page'),
 +                                                     description => loc('Edit self service home page dashboard'),
 +                                                     path        => '/Admin/Global/SelfServiceHomePage.html');
 +            if ( $request_path =~ m{^/Admin/Global/SelfServiceHomePage} ) {
 +                $page->child(content => title => loc('Content'), path => '/Admin/Global/SelfServiceHomePage.html');
 +                $page->child(show    => title => loc('Show'), path => '/SelfService');
 +            }
 +        }
 +    }
 +    $admin_global->child( 'dashboards-in-menu' =>
 +        title       => loc('Modify Reports menu'),
 +        description => loc('Customize dashboards in menu'),
 +        path        => '/Admin/Global/DashboardsInMenu.html',
 +    );
 +    $admin_global->child( 'topics' =>
 +        title       => loc('Topics'),
 +        description => loc('Modify global article topics'),
 +        path        => '/Admin/Global/Topics.html',
 +    );
 +
 +    my $admin_tools = $admin->child( tools =>
 +        title       => loc('Tools'),
 +        description => loc('Use other RT administrative tools'),
 +        path        => '/Admin/Tools/',
 +    );
 +    $admin_tools->child( configuration =>
 +        title       => loc('System Configuration'),
 +        description => loc('Detailed information about your RT setup'),
 +        path        => '/Admin/Tools/Configuration.html',
 +    );
 +    $admin_tools->child( theme =>
 +        title       => loc('Theme'),
 +        description => loc('Customize the look of your RT'),
 +        path        => '/Admin/Tools/Theme.html',
 +    );
 +    if (RT->Config->Get('StatementLog')
 +        && $current_user->HasRight( Right => 'SuperUser', Object => RT->System )) {
 +       $admin_tools->child( 'sql-queries' =>
 +           title       => loc('SQL Queries'),
 +           description => loc('Browse the SQL queries made in this process'),
 +           path        => '/Admin/Tools/Queries.html',
 +       );
 +    }
 +    $admin_tools->child( rights_inspector =>
 +        title => loc('Rights Inspector'),
 +        description => loc('Search your configured rights'),
 +        path  => '/Admin/Tools/RightsInspector.html',
 +    );
 +    $admin_tools->child( shredder =>
 +        title       => loc('Shredder'),
 +        description => loc('Permanently wipeout data from RT'),
 +        path        => '/Admin/Tools/Shredder',
 +    );
 +
 +    if ( $request_path =~ m{^/Admin/(Queues|Users|Groups|CustomFields|CustomRoles)} ) {
 +        my $type = $1;
 +
 +        my %labels = (
 +            Queues       => loc("Queues"),
 +            Users        => loc("Users"),
 +            Groups       => loc("Groups"),
 +            CustomFields => loc("Custom Fields"),
 +            CustomRoles  => loc("Custom Roles"),
 +        );
 +
 +        my $section;
 +        if ( $request_path =~ m|^/Admin/$type/?(?:index.html)?$|
 +             || (    $request_path =~ m|^/Admin/$type/(?:Modify.html)$|
 +                  && $HTML::Mason::Commands::DECODED_ARGS->{'Create'} )
 +           )
 +        {
 +            $section = $page;
 +
 +        } else {
 +            $section = $page->child( select => title => $labels{$type},
 +                                     path => "/Admin/$type/" );
 +        }
 +
 +        $section->child( select => title => loc('Select'), path => "/Admin/$type/" );
 +        $section->child( create => title => loc('Create'), path => "/Admin/$type/Modify.html?Create=1" );
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Queues} ) {
 +        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/
 +                ||
 +              $HTML::Mason::Commands::DECODED_ARGS->{'Queue'} && $HTML::Mason::Commands::DECODED_ARGS->{'Queue'} =~ /^\d+$/
 +                ) {
 +            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'Queue'} || $HTML::Mason::Commands::DECODED_ARGS->{'id'};
 +            my $queue_obj = RT::Queue->new( $current_user );
 +            $queue_obj->Load($id);
 +
 +            if ( $queue_obj and $queue_obj->id ) {
 +                my $queue = $page;
 +                $queue->child( basics => title => loc('Basics'),   path => "/Admin/Queues/Modify.html?id=" . $id );
 +                $queue->child( people => title => loc('Watchers'), path => "/Admin/Queues/People.html?id=" . $id );
 +
 +                my $templates = $queue->child(templates => title => loc('Templates'), path => "/Admin/Queues/Templates.html?id=" . $id);
 +                $templates->child( select => title => loc('Select'), path => "/Admin/Queues/Templates.html?id=".$id);
 +                $templates->child( create => title => loc('Create'), path => "/Admin/Queues/Template.html?Create=1;Queue=".$id);
 +
 +                my $scrips = $queue->child( scrips => title => loc('Scrips'), path => "/Admin/Queues/Scrips.html?id=" . $id);
 +                $scrips->child( select => title => loc('Select'), path => "/Admin/Queues/Scrips.html?id=" . $id );
 +                $scrips->child( create => title => loc('Create'), path => "/Admin/Scrips/Create.html?Queue=" . $id);
 +
 +                my $cfs = $queue->child( 'custom-fields' => title => loc('Custom Fields') );
 +                my $ticket_cfs = $cfs->child( 'tickets' => title => loc('Tickets'),
 +                    path => '/Admin/Queues/CustomFields.html?SubType=RT::Ticket;id=' . $id );
 +
 +                my $txn_cfs = $cfs->child( 'transactions' => title => loc('Transactions'),
 +                    path => '/Admin/Queues/CustomFields.html?SubType=RT::Ticket-RT::Transaction;id='.$id );
 +
 +                $queue->child( 'group-rights' => title => loc('Group Rights'), path => "/Admin/Queues/GroupRights.html?id=".$id );
 +                $queue->child( 'user-rights' => title => loc('User Rights'), path => "/Admin/Queues/UserRights.html?id=" . $id );
 +                $queue->child( 'history' => title => loc('History'), path => "/Admin/Queues/History.html?id=" . $id );
 +                $queue->child( 'default-values' => title => loc('Default Values'), path => "/Admin/Queues/DefaultValues.html?id=" . $id );
 +
 +                # due to historical reasons of always having been in /Elements/Tabs
 +                $HTML::Mason::Commands::m->callback( CallbackName => 'PrivilegedQueue', queue_id => $id, page_menu => $queue, CallbackPage => '/Elements/Tabs' );
 +            }
 +        }
 +    }
 +    if ( $request_path =~ m{^(/Admin/Users|/User/(Summary|History)\.html)} and $admin->child("users") ) {
 +        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
 +            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
 +            my $obj = RT::User->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                $page->child( basics      => title => loc('Basics'),         path => "/Admin/Users/Modify.html?id=" . $id );
 +                $page->child( memberships => title => loc('Memberships'),    path => "/Admin/Users/Memberships.html?id=" . $id );
 +                $page->child( history     => title => loc('History'),        path => "/Admin/Users/History.html?id=" . $id );
 +                $page->child( 'my-rt'     => title => loc('RT at a glance'), path => "/Admin/Users/MyRT.html?id=" . $id );
 +                $page->child( 'dashboards-in-menu' =>
 +                    title => loc('Modify Reports menu'),
 +                    path  => '/Admin/Users/DashboardsInMenu.html?id=' . $id,
 +                );
 +                if ( RT->Config->Get('Crypt')->{'Enable'} ) {
 +                    $page->child( keys    => title => loc('Private keys'),   path => "/Admin/Users/Keys.html?id=" . $id );
 +                }
 +                $page->child( 'summary'   => title => loc('User Summary'),   path => "/User/Summary.html?id=" . $id );
 +
 +                if ( $current_user->HasRight( Right => 'ManageAuthTokens', Object => RT->System ) ) {
 +                    my $auth_tokens = $page->child(
 +                        auth_tokens => title => loc('Auth Tokens'),
 +                        path        => '/Admin/Users/AuthTokens.html?id=' . $id
 +                    );
 +
 +                    if ( $request_path =~ m{^/Admin/Users/AuthTokens\.html} ) {
 +                        $auth_tokens->child(
 +                            select_auth_token => title => loc('Select'),
 +                            path              => '/Admin/Users/AuthTokens.html?id=' . $id,
 +                        );
 +                        $auth_tokens->child(
 +                            create_auth_token => title => loc('Create'),
 +                            raw_html =>
 +                                q[<a class="btn menu-item" href="#create-auth-token" data-toggle="modal" rel="modal:open">]
 +                                . loc("Create") . "</a>"
 +                        );
 +                    }
 +                }
 +            }
 +        }
 +
 +    }
 +
 +    if ( $request_path =~ m{^(/Admin/Groups|/Group/(Summary|History)\.html)} ) {
 +        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
 +            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
 +            my $obj = RT::Group->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                $page->child( basics         => title => loc('Basics'),       path => "/Admin/Groups/Modify.html?id=" . $obj->id );
 +                $page->child( members        => title => loc('Members'),      path => "/Admin/Groups/Members.html?id=" . $obj->id );
 +                $page->child( memberships    => title => loc('Memberships'),  path => "/Admin/Groups/Memberships.html?id=" . $obj->id );
 +                $page->child( 'links'     =>
 +                              title       => loc("Links"),
 +                              path        => "/Admin/Groups/ModifyLinks.html?id=" . $obj->id,
 +                              description => loc("Group links"),
 +                );
 +                $page->child( 'group-rights' => title => loc('Group Rights'), path => "/Admin/Groups/GroupRights.html?id=" . $obj->id );
 +                $page->child( 'user-rights'  => title => loc('User Rights'),  path => "/Admin/Groups/UserRights.html?id=" . $obj->id );
 +                $page->child( history        => title => loc('History'),      path => "/Admin/Groups/History.html?id=" . $obj->id );
 +                $page->child( 'summary'   =>
 +                              title       => loc("Group Summary"),
 +                              path        => "/Group/Summary.html?id=" . $obj->id,
 +                              description => loc("Group summary page"),
 +                );
 +            }
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/CustomFields/} ) {
 +        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
 +            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
 +            my $obj = RT::CustomField->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                $page->child( basics           => title => loc('Basics'),       path => "/Admin/CustomFields/Modify.html?id=".$id );
 +                $page->child( 'group-rights'   => title => loc('Group Rights'), path => "/Admin/CustomFields/GroupRights.html?id=" . $id );
 +                $page->child( 'user-rights'    => title => loc('User Rights'),  path => "/Admin/CustomFields/UserRights.html?id=" . $id );
 +                unless ( $obj->IsOnlyGlobal ) {
 +                    $page->child( 'applies-to' => title => loc('Applies to'),   path => "/Admin/CustomFields/Objects.html?id=" . $id );
 +                }
 +            }
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/CustomRoles} ) {
 +        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
 +            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
 +            my $obj = RT::CustomRole->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                $page->child( basics       => title => loc('Basics'),       path => "/Admin/CustomRoles/Modify.html?id=".$id );
 +                $page->child( 'applies-to' => title => loc('Applies to'),   path => "/Admin/CustomRoles/Objects.html?id=" . $id );
 +                $page->child( 'visibility' => title => loc('Visibility'),   path => "/Admin/CustomRoles/Visibility.html?id=" . $id );
 +            }
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Scrips/} ) {
 +        if ( $HTML::Mason::Commands::m->request_args->{'id'} && $HTML::Mason::Commands::m->request_args->{'id'} =~ /^\d+$/ ) {
 +            my $id = $HTML::Mason::Commands::m->request_args->{'id'};
 +            my $obj = RT::Scrip->new( $current_user );
 +            $obj->Load($id);
 +
 +            my ( $admin_cat, $create_path_arg, $from_query_param );
 +            my $from_arg = $HTML::Mason::Commands::DECODED_ARGS->{'From'} || q{};
 +            my ($from_queue) = $from_arg =~ /^(\d+)$/;
 +            if ( $from_queue ) {
 +                $admin_cat = "Queues/Scrips.html?id=$from_queue";
 +                $create_path_arg = "?Queue=$from_queue";
 +                $from_query_param = ";From=$from_queue";
 +            }
 +            elsif ( $from_arg eq 'Global' ) {
 +                $admin_cat = 'Global/Scrips.html';
 +                $create_path_arg = '?Global=1';
 +                $from_query_param = ';From=Global';
 +            }
 +            else {
 +                $admin_cat = 'Scrips';
 +                $from_query_param = $create_path_arg = q{};
 +            }
 +            my $scrips = $page->child( scrips => title => loc('Scrips'), path => "/Admin/${admin_cat}" );
 +            $scrips->child( select => title => loc('Select'), path => "/Admin/${admin_cat}" );
 +            $scrips->child( create => title => loc('Create'), path => "/Admin/Scrips/Create.html${create_path_arg}" );
 +
 +            $page->child( basics => title => loc('Basics') => path => "/Admin/Scrips/Modify.html?id=" . $id . $from_query_param );
 +            $page->child( 'applies-to' => title => loc('Applies to'), path => "/Admin/Scrips/Objects.html?id=" . $id . $from_query_param );
 +        }
 +        elsif ( $request_path =~ m{^/Admin/Scrips/(index\.html)?$} ) {
 +            HTML::Mason::Commands::PageMenu->child( select => title => loc('Select') => path => "/Admin/Scrips/" );
 +            HTML::Mason::Commands::PageMenu->child( create => title => loc('Create') => path => "/Admin/Scrips/Create.html" );
 +        }
 +        elsif ( $request_path =~ m{^/Admin/Scrips/Create\.html$} ) {
 +            my ($queue) = $HTML::Mason::Commands::DECODED_ARGS->{'Queue'} && $HTML::Mason::Commands::DECODED_ARGS->{'Queue'} =~ /^(\d+)$/;
 +            my $global_arg = $HTML::Mason::Commands::DECODED_ARGS->{'Global'};
 +            if ($queue) {
 +                HTML::Mason::Commands::PageMenu->child( select => title => loc('Select') => path => "/Admin/Queues/Scrips.html?id=$queue" );
 +                HTML::Mason::Commands::PageMenu->child( create => title => loc('Create') => path => "/Admin/Scrips/Create.html?Queue=$queue" );
 +            } elsif ($global_arg) {
 +                HTML::Mason::Commands::PageMenu->child( select => title => loc('Select') => path => "/Admin/Global/Scrips.html" );
 +                HTML::Mason::Commands::PageMenu->child( create => title => loc('Create') => path => "/Admin/Scrips/Create.html?Global=1" );
 +            } else {
 +                HTML::Mason::Commands::PageMenu->child( select => title => loc('Select') => path => "/Admin/Scrips" );
 +                HTML::Mason::Commands::PageMenu->child( create => title => loc('Create') => path => "/Admin/Scrips/Create.html" );
 +            }
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Lifecycles} && $current_user->HasRight( Object => RT->System, Right => 'SuperUser' ) ) {
 +        if (defined($HTML::Mason::Commands::DECODED_ARGS->{'Name'}) && defined($HTML::Mason::Commands::DECODED_ARGS->{'Type'}) ) {
 +            my $lifecycles = $page->child( 'lifecycles' =>
 +                title       => loc('Lifecycles'),
 +                description => loc('Manage lifecycles'),
 +                path        => '/Admin/Lifecycles/',
 +            );
 +            $lifecycles->child( select => title => loc('Select'), path => "/Admin/Lifecycles/" );
 +            $lifecycles->child( create => title => loc('Create'), path => "/Admin/Lifecycles/Create.html" );
 +
 +            my $LifecycleObj = RT::Lifecycle->new();
 +            $LifecycleObj->Load(Name => $HTML::Mason::Commands::DECODED_ARGS->{'Name'}, Type => $HTML::Mason::Commands::DECODED_ARGS->{'Type'});
 +
 +            if ($LifecycleObj->Name && $LifecycleObj->{data}{type} eq $HTML::Mason::Commands::DECODED_ARGS->{'Type'}) {
 +                my $Name_uri = $LifecycleObj->Name;
 +                my $Type_uri = $LifecycleObj->Type;
 +                RT::Interface::Web::EscapeURI(\$Name_uri);
 +                RT::Interface::Web::EscapeURI(\$Type_uri);
 +
 +                unless ( RT::Interface::Web->ClientIsIE ) {
 +                    $page->child( basics => title => loc('Modify'),  path => "/Admin/Lifecycles/Modify.html?Type=" . $Type_uri . ";Name=" . $Name_uri );
 +                }
 +                $page->child( actions => title => loc('Actions'), path => "/Admin/Lifecycles/Actions.html?Type=" . $Type_uri . ";Name=" . $Name_uri );
 +                $page->child( rights => title => loc('Rights'), path => "/Admin/Lifecycles/Rights.html?Type=" . $Type_uri . ";Name=" . $Name_uri );
 +                $page->child( mappings => title => loc('Mappings'),  path => "/Admin/Lifecycles/Mappings.html?Type=" . $Type_uri . ";Name=" . $Name_uri );
 +                $page->child( advanced => title => loc('Advanced'),  path => "/Admin/Lifecycles/Advanced.html?Type=" . $Type_uri . ";Name=" . $Name_uri );
 +            }
 +        }
 +        else {
 +            $page->child( select => title => loc('Select'), path => "/Admin/Lifecycles/" );
 +            $page->child( create => title => loc('Create'), path => "/Admin/Lifecycles/Create.html" );
 +        }
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Global/Scrips\.html} ) {
 +        $page->child( select => title => loc('Select'), path => "/Admin/Global/Scrips.html" );
 +        $page->child( create => title => loc('Create'), path => "/Admin/Scrips/Create.html?Global=1" );
 +    }
 +
 +    if ( $request_path =~ m{^/Admin(?:/Global)?/Conditions} ) {
 +        $page->child( select => title => loc('Select'), path => "/Admin/Global/Conditions.html" );
 +        $page->child( create => title => loc('Create'), path => "/Admin/Conditions/Create.html" );
 +    }
 +
 +    if ( $request_path =~ m{^/Admin(?:/Global)?/Actions} ) {
 +        $page->child( select => title => loc('Select'), path => "/Admin/Global/Actions.html" );
 +        $page->child( create => title => loc('Create'), path => "/Admin/Actions/Create.html" );
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Global/Templates?\.html} ) {
 +        $page->child( select => title => loc('Select'), path => "/Admin/Global/Templates.html" );
 +        $page->child( create => title => loc('Create'), path => "/Admin/Global/Template.html?Create=1" );
 +    }
 +
 +    if ( $request_path =~ m{^/Admin/Articles/Classes/} ) {
 +        if ( my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'} ) {
 +            my $obj = RT::Class->new( $current_user );
 +            $obj->Load($id);
 +
 +            if ( $obj and $obj->id ) {
 +                my $section = $page->child( select => title => loc("Classes"), path => "/Admin/Articles/Classes/" );
 +                $section->child( select => title => loc('Select'), path => "/Admin/Articles/Classes/" );
 +                $section->child( create => title => loc('Create'), path => "/Admin/Articles/Classes/Modify.html?Create=1" );
 +
 +                $page->child( basics          => title => loc('Basics'),        path => "/Admin/Articles/Classes/Modify.html?id=".$id );
 +                $page->child( topics          => title => loc('Topics'),        path => "/Admin/Articles/Classes/Topics.html?id=".$id );
 +                $page->child( 'custom-fields' => title => loc('Custom Fields'), path => "/Admin/Articles/Classes/CustomFields.html?id=".$id );
 +                $page->child( 'group-rights'  => title => loc('Group Rights'),  path => "/Admin/Articles/Classes/GroupRights.html?id=".$id );
 +                $page->child( 'user-rights'   => title => loc('User Rights'),   path => "/Admin/Articles/Classes/UserRights.html?id=".$id );
 +                $page->child( 'applies-to'    => title => loc('Applies to'),    path => "/Admin/Articles/Classes/Objects.html?id=$id" );
 +            }
 +        } else {
 +            $page->child( select => title => loc('Select'), path => "/Admin/Articles/Classes/" );
 +            $page->child( create => title => loc('Create'), path => "/Admin/Articles/Classes/Modify.html?Create=1" );
 +        }
 +    }
 +}
 +
 +sub BuildSelfServiceNav {
 +    my $request_path = shift;
 +    my $top          = shift;
 +    my $widgets      = shift;
 +    my $page         = shift;
 +
 +    my %args = ( @_ );
 +
 +    my $current_user = $HTML::Mason::Commands::session{CurrentUser};
 +
 +    if (   RT->Config->Get('SelfServiceUseDashboard')
 +        && $request_path =~ m{^/SelfService/(?:index\.html)?$}
 +        && $current_user->HasRight(
 +            Right  => 'ShowConfigTab',
 +            Object => RT->System
 +        )
 +        && $current_user->HasRight( Right => 'ModifyDashboard', Object => RT->System )
 +       )
 +    {
 +        $page->child( content => title => loc('Content'), path => '/Admin/Global/SelfServiceHomePage.html' );
 +        $page->child( show    => title => loc('Show'),    path => '/SelfService/' );
 +    }
 +
 +    my $queues = RT::Queues->new( $current_user );
 +    $queues->UnLimit;
 +
 +    my $queue_count = 0;
 +    my $queue_id;
 +
 +    while ( my $queue = $queues->Next ) {
 +        next unless $queue->CurrentUserHasRight('CreateTicket');
 +        $queue_id = $queue->id;
 +        $queue_count++;
 +        last if ( $queue_count > 1 );
 +    }
 +
 +    my $home = $top->child( home => title => loc('Homepage'), path => '/' );
 +
 +    if ( $queue_count > 1 ) {
 +        $home->child( new => title => loc('Create Ticket'), path => '/SelfService/CreateTicketInQueue.html' );
 +    } elsif ( $queue_id ) {
 +        $home->child( new => title => loc('Create Ticket'), path => '/SelfService/Create.html?Queue=' . $queue_id );
 +    }
 +
 +    my $menu_label = loc('Tickets');
 +    my $menu_path = '/SelfService/';
 +    if ( RT->Config->Get('SelfServiceUseDashboard') ) {
 +        $menu_path = '/SelfService/Open.html';
 +    }
 +    my $tickets = $top->child( tickets => title => $menu_label, path => $menu_path );
 +    $tickets->child( open   => title => loc('Open tickets'),   path => '/SelfService/Open.html' );
 +    $tickets->child( closed => title => loc('Closed tickets'), path => '/SelfService/Closed.html' );
 +
 +    $top->child( "assets", title => loc("Assets"), path => "/SelfService/Asset/" )
 +        if $current_user->HasRight( Right => 'ShowAssetsMenu', Object => RT->System );
 +
 +    my $username = '<span class="current-user">'
 +                 . $HTML::Mason::Commands::m->interp->apply_escapes($current_user->Name, 'h')
 +                 . '</span>';
 +    my $about_me = $top->child( preferences =>
 +        title        => loc('Logged in as [_1]', $username),
 +        escape_title => 0,
 +        sort_order   => 99,
 +    );
 +
 +    if ( ( RT->Config->Get('SelfServiceUserPrefs') || '' ) eq 'view-info' ||
 +        $current_user->HasRight( Right => 'ModifySelf', Object => RT->System ) ) {
 +        $about_me->child( prefs => title => loc('Preferences'), path => '/SelfService/Prefs.html' );
 +    }
 +
 +    my $logout_url = RT->Config->Get('LogoutURL');
 +    if ( $current_user->Name
 +         && (   !RT->Config->Get('WebRemoteUserAuth')
 +              || RT->Config->Get('WebFallbackToRTLogin') )) {
 +        $about_me->child( logout => title => loc('Logout'), path => $logout_url );
 +    }
 +
 +    if ( RT->Config->Get('SelfServiceShowArticleSearch') ) {
 +        $widgets->child( 'goto-article' => raw_html => $HTML::Mason::Commands::m->scomp('/SelfService/Elements/SearchArticle') );
 +    }
 +
 +    $widgets->child( goto => raw_html => $HTML::Mason::Commands::m->scomp('/SelfService/Elements/GotoTicket') );
 +
 +    if ($request_path =~ m{^/SelfService/Asset/} and $HTML::Mason::Commands::DECODED_ARGS->{id}) {
 +        my $id   = $HTML::Mason::Commands::DECODED_ARGS->{id};
 +        $page->child("display",     title => loc("Display"),        path => "/SelfService/Asset/Display.html?id=$id");
 +        $page->child("history",     title => loc("History"),        path => "/SelfService/Asset/History.html?id=$id");
 +
 +        if (Menu->child("new")) {
 +            my $actions = $page->child("actions", title => loc("Actions"));
 +            $actions->child("create-linked-ticket", title => loc("Create linked ticket"), path => "/SelfService/Asset/CreateLinkedTicket.html?Asset=$id");
 +        }
 +    }
 +
 +    # due to historical reasons of always having been in /Elements/Tabs
 +    $HTML::Mason::Commands::m->callback( CallbackName => 'SelfService', Path => $request_path, ARGSRef => \%args, CallbackPage => '/Elements/Tabs' );
 +}
 +
 +1;
diff --cc share/html/Admin/Tools/Configuration.html
index 6808bafa3a,913f1f0a9b..37a3a694b2
--- a/share/html/Admin/Tools/Configuration.html
+++ b/share/html/Admin/Tools/Configuration.html
@@@ -152,68 -162,88 +152,91 @@@ for my $type (qw/Tickets Queues Transac
      }
      $index_size++;
  </%PERL>
 -<tr class="<% $index_size%2 ? 'oddline' : 'evenline'%>">
 -<td class="collection-as-table"><% $type %></td>
 -<td class="collection-as-table"><% $count %></td>
 -</tr>
 +      <div class="<% $index_size%2 ? 'oddline' : 'evenline'%> form-row">
 +        <div class="collection-as-table value col-6"><% $type %></div>
 +        <div class="collection-as-table value col-6"><% $count %></div>
 +      </div>
  % }
 -</table>
 -</&>
 -</td>
 -<td valign="top" class="boxcontainer">
 +    </&>
 +
 +  </div>
 +
 +  <div class="col-4">
  
 -<&|/Widgets/TitleBox, title => loc("Mason template search order") &>
 +    <&|/Widgets/TitleBox, title => loc("Mason template search order") &>
+ 
+ % if ( $m->{rt_mason_cache_created} ) {
+ % my $mason_obj_date = RT::Date->new( $session{CurrentUser} );
+ % $mason_obj_date->Set( Format => 'Unix', Value => $m->{rt_mason_cache_created} );
+ 
+ <div class="mason-cache">
 -    <div class="mason-cache-info"><&|/l&>Cache created</&>: <% $mason_obj_date->AsString %></div>
 -    <a class="button clear-mason-cache" href="javascript:;"><&|/l&>Clear Mason Cache</&></a>
++  <div class="mason-cache-info py-1 d-inline-block">
++    <&|/l&>Cache created</&>: <% $mason_obj_date->AsString %>
++  </div>
++  <a class="button btn btn-primary btn-sm clear-mason-cache float-right" href="javascript:;"><&|/l&>Clear Mason Cache</&></a>
+ </div>
+ <script type="text/javascript">
+ jQuery('a.clear-mason-cache').click( function() {
+     jQuery.post(RT.Config.WebHomePath + '/Admin/Helpers/ClearMasonCache', function(data) {
+         jQuery('div.mason-cache div.mason-cache-info').text(data.message);
+     }, 'json');
+     return false;
+ });
+ </script>
+ 
+ % }
+ 
 -<ol>
 +      <ol class="list-group-compact list-group">
  % foreach my $path ( RT::Interface::Web->ComponentRoots ) {
 -<li><% $path %></li>
 +        <li class="list-group-item"><% $path %></li>
  % }
 -</ol>
 -</&>
 +      </ol>
 +    </&>
  
 -<&|/Widgets/TitleBox, title => loc("Static file search order") &>
 -<ol>
 +    <&|/Widgets/TitleBox, title => loc("Static file search order") &>
 +      <ol class="list-group-compact list-group">
  % foreach my $path ( (map {$_->{root}} RT->Config->Get('StaticRoots')),
  %                    RT::Interface::Web->StaticRoots ) {
 -<li><% $path %></li>
 +        <li class="list-group-item"><% $path %></li>
  % }
 -</ol>
 -</&>
 +      </ol>
 +    </&>
  
 -<&|/Widgets/TitleBox, title => loc("Perl library search order") &>
 -<ol>
 +    <&|/Widgets/TitleBox, title => loc("Perl library search order") &>
 +      <ol class="list-group-compact list-group">
  % foreach my $inc (@INC) {
 -<li><% $inc %></li>
 +        <li class="list-group-item"><% $inc %></li>
  % }
 -</ol>
 -</&>
 +      </ol>
 +    </&>
  
 -<&|/Widgets/TitleBox, title=> loc("Loaded config files") &>
 -<ol>
 +    <&|/Widgets/TitleBox, title=> loc("Loaded config files") &>
 +      <ol class="list-group-compact list-group">
  % foreach my $config (RT->Config->LoadedConfigs) {
  %   if ($config->{site}) {
 -<li><strong><% $config->{filename} %></strong></li>
 +        <li class="list-group-item"><strong><% $config->{filename} %></strong></li>
  %   } else {
 -<li><% $config->{filename} %></li>
 +        <li class="list-group-item"><% $config->{filename} %></li>
  %   }
  % }
 -</ol>
 -</&>
 +      </ol>
 +    </&>
  
 -<&|/Widgets/TitleBox, title=> loc("Logging summary") &>
 -  <& /Admin/Elements/LoggingSummary &>
 -</&>
 +    <&|/Widgets/TitleBox, title=> loc("Logging summary") &>
 +      <& /Admin/Elements/LoggingSummary &>
 +    </&>
 +
 +  </div>
  
 -</td>
 -</table>
 +</div>
  
  <&|/Widgets/TitleBox, title => loc("Global Attributes") &>
 -<table border="0" cellspacing="0" cellpadding="5" width="100%" class="collection">
 -<tr class="collection-as-table">
 -<th class="collection-as-table"><&|/l&>Name</&></th>
 -<th class="collection-as-table"><&|/l&>Value</&></th>
 -</tr>
 +  <div class="collection form-row">
 +    <div class="collection-as-table label col-6 text-left"><&|/l&>Name</&></div>
 +    <div class="collection-as-table label col-6 text-left"><&|/l&>Value</&></div>
 +  </div>
  % my $attrs = $RT::System->Attributes;
+ % $m->callback( CallbackName => 'ModifySystemAttributes', Attributes => $attrs );
  % my $index_size = 0;
  % while ( my $attr = $attrs->Next ) {
  % next if $attr->Name eq 'UpgradeHistory';
diff --cc share/html/Admin/Users/Keys.html
index 0bfdbb3c7f,bbef3d376f..7acdd8c853
--- a/share/html/Admin/Users/Keys.html
+++ b/share/html/Admin/Users/Keys.html
@@@ -59,9 -59,9 +59,9 @@@
  <form action="<%RT->Config->Get('WebPath')%>/Admin/Users/Keys.html" method="post" enctype="multipart/form-data">
  <input type="hidden" class="hidden" name="id" value="<% $UserObj->Id %>" />
  
- % if (RT::Crypt->UseForOutgoing eq 'GnuPG') {
+ % if (RT::Config->Get('GnuPG')->{Enable}) {
  <&|/Widgets/TitleBox, title => loc('GnuPG private key') &>
 -<& /Widgets/Form/Select,
 +  <& /Widgets/Form/Select,
      Name         => 'PrivateKey',
      Description  => loc('Private Key'),
      Values       => \@potential_keys,
@@@ -71,13 -71,9 +71,13 @@@
  </&>
  % }
  
- % if (RT::Crypt->UseForOutgoing eq 'SMIME') {
+ % if (RT::Config->Get('SMIME')->{Enable}) {
  <&|/Widgets/TitleBox, title => loc('SMIME Certificate') &>
 -<textarea name="SMIMECertificate"><% $UserObj->SMIMECertificate || '' %></textarea>
 +<div class="form-row">
 +  <div class="col-12">
 +    <textarea rows="25" class="form-control" name="SMIMECertificate"><% $UserObj->SMIMECertificate || '' %></textarea>
 +  </div>
 +</div>
  </&>
  % }
  
diff --cc share/html/Dashboards/Subscription.html
index 2c1e7ab490,79a5d4e4ff..c5b631c74a
--- a/share/html/Dashboards/Subscription.html
+++ b/share/html/Dashboards/Subscription.html
@@@ -194,65 -159,62 +194,92 @@@
  %                  ? 'selected="selected"'
  %                  : '';
  
 -    <option value="<% $value %>" <%$selected|n %>><% $formatted %></option>
 +            <option value="<% $value %>" <%$selected|n %>><% $formatted %></option>
  % }
 -</select>
 -(<%$timezone%>)
 -</td></tr>
 -<tr><td class="label">
 -<&|/l&>Language</&>:
 -</td><td class="value">
 -<& /Elements/SelectLang,
 -    Name => 'Language',
 -    Default => $fields{'Language'},
 -    ShowNullOption => 1,
 - &>
 -</td></tr>
 -<tr><td class="label">
 -<&|/l&>Rows</&>:
 -</td><td class="value">
 -<select name="Rows">
 -%   for my $rows (1, 2, 5, 10, 15, 20, 25, 50, 75, 100, 0) {
 -    <option value="<% $rows %>" <% $fields{'Rows'} eq $rows ? 'selected="selected"' : '' |n %>><% loc($rows || 'Unlimited') %></option>
 -%   }
 -</select>
 -</td></tr>
 +          </select>
 +        </div>
 +        <div class="col-auto">
 +          <span class="current-value form-control">(<%$timezone%>)</span>
 +        </div>
 +      </div>
 +    </div>
 +  </div>
 +
 +  <div class="form-row">
 +    <div class="label col-3">
 +      <&|/l&>Language</&>:
 +    </div>
 +    <div class="value col-9">
 +      <div class="row">
 +        <div class="col-auto">
 +          <& /Elements/SelectLang,
 +              Name => 'Language',
 +              Default => $fields{'Language'},
 +              ShowNullOption => 1,
 +           &>
 +        </div>
 +      </div>
 +    </div>
 +  </div>
 +
 +  <div class="form-row">
 +    <div class="label col-3">
 +      <&|/l&>Rows</&>:
 +    </div>
 +    <div class="value col-9">
 +      <div class="row">
 +        <div class="col-auto">
 +          <select name="Rows" class="form-control selectpicker">
 +% for my $rows (1, 2, 5, 10, 15, 20, 25, 50, 75, 100, 0) {
 +            <option value="<% $rows %>" <% $fields{'Rows'} eq $rows ? 'selected="selected"' : '' |n %>><% loc($rows || 'Unlimited') %></option>
 +% }
 +          </select>
 +        </div>
 +      </div>
 +    </div>
 +  </div>
 +
 +  <div class="form-row">
 +    <div class="label col-3"></div>
 +    <div class="value col-9">
 +      <div class="custom-control custom-checkbox">
 +        <input type="checkbox" id="SuppressIfEmpty" name="SuppressIfEmpty" class="custom-control-input" value="1"  <% $fields{'SuppressIfEmpty'} ? 'checked="checked"' : "" |n %>>
 +        <label class="custom-control-label" for="SuppressIfEmpty"><&|/l&>Suppress if empty (Check this to avoid sending mail if all searches have no results)</&></label>
 +      </div>
 +    </div>
 +  </div>
  
 -<tr><td align="right"><input type="checkbox" id="SuppressIfEmpty" name="SuppressIfEmpty" value="1" <% $fields{'SuppressIfEmpty'} ? 'checked="checked"' : "" |n %> /></td>
 -<td><label for="SuppressIfEmpty"><&|/l&>Suppress if empty (Check this to avoid sending mail if all searches have no results)</&></label><br />
 -<input type="hidden"class="hidden" name="SuppressIfEmpty-Magic" value=1 />
 -</td></tr>
  % $m->callback( %ARGS, CallbackName => 'SubscriptionFormEnd', FieldsRef => \%fields,
  %     SubscriptionObj => $SubscriptionObj, DashboardObj => $Dashboard );
 -</table>
  </&>
  
+ <&| /Widgets/TitleBox, title => loc('Search Context') &>
+ 
 -<p class="description">
 -<&|/l&>Most searches show the same results for all users and can be run as the user who owns the dashboard subscription (Subscription owner).</&>
 -</p>
 -
 -<p class="description">
 -<&|/l&>For searches like "10 highest priority tickets I own" that contain __CurrentUser__ in the query, the results are specific to each recipient. For dashboards with these searches, select "Each dashboard recipient" below to run each search with the recipient set as the "Current User".</&>
 -</p>
 -
 -<table>
 -    <tr><td class="label"><&|/l&>Run Dashboard Searches As</&>:</td>
 -        <td>
 -            <input type="radio" id="context-subscriber" name="Context" value="subscriber" <% ($fields{'Context'} // '') ne 'recipient' ? 'checked="checked"' : "" |n %>></input>
 -            <label for="context-subscriber"><&|/l&>Subscription owner</&>(<% $session{CurrentUser}->Name %>)</label>
 -            <br />
 -            <input type="radio" id="context-recipient" name="Context" value="recipient" <% ($fields{'Context'} // '') eq 'recipient' ? 'checked="checked"' : "" |n %>></input>
 -            <label for="context-recipient"><&|/l&>Each dashboard recipient</&></label>
 -        </td>
 -    </tr>
 -</table>
++  <p class="description mt-3 mt-1 ml-3">
++  <&|/l&>Most searches show the same results for all users and can be run as the user who owns the dashboard subscription (Subscription owner).</&>
++  </p>
++
++  <p class="description mt-3 mt-1 ml-3">
++  <&|/l&>For searches like "10 highest priority tickets I own" that contain __CurrentUser__ in the query, the results are specific to each recipient. For dashboards with these searches, select "Each dashboard recipient" below to run each search with the recipient set as the "Current User".</&>
++  </p>
++
++  <div class="form-row">
++    <div class="label col-3">
++      <&|/l&>Run Dashboard Searches As</&>:
++    </div>
++    <div class="value col-9">
++      <div class="custom-control custom-radio">
++        <input type="radio" id="context-subscriber" name="Context" class="custom-control-input" value="subscriber" <% ($fields{'Context'} // '') ne 'recipient' ? 'checked="checked"' : "" |n %>></input>
++        <label class="custom-control-label" for="context-subscriber"><&|/l&>Subscription owner</&>(<% $session{CurrentUser}->Name %>)</label>
++      </div>
++      <div class="custom-control custom-radio">
++        <input type="radio" id="context-recipient" name="Context" class="custom-control-input" value="recipient" <% ($fields{'Context'} // '') eq 'recipient' ? 'checked="checked"' : "" |n %>></input>
++        <label class="custom-control-label" for="context-recipient"><&|/l&>Each dashboard recipient</&></label>
++      </div>
++    </div>
++  </div>
+ </&>
+ 
  <&| /Widgets/TitleBox, title => loc('Recipients') &>
  <& Elements/SubscriptionRecipients,
      UserField => $UserField, UserString => $UserString, UserOp => $UserOp,
diff --cc share/html/Elements/EmailInput
index 46e9cb249f,6c99144483..6b2b676b5f
--- a/share/html/Elements/EmailInput
+++ b/share/html/Elements/EmailInput
@@@ -81,14 -79,10 +81,18 @@@
      data-autocomplete-include-system
  % }
  
 +% if (@options) {
 +    data-options="<% JSON(\@options) %>"
 +% }
 +
 +% if (@items) {
 +    data-items="<% JSON(\@items) %>"
 +% }
 +
+ % if ($AutocompleteExclude) {
+     data-autocomplete-exclude="<% $AutocompleteExclude %>"
+ % }
+ 
  />
  % if ($EntryHint) {
  <br>
@@@ -146,5 -101,5 +150,6 @@@ $AutocompleteNobody => 
  $AutocompleteSystem => 0
  $EntryHint => ''
  $Placeholder => ''
 +$Options => []
+ $AutocompleteExclude => undef
  </%ARGS>
diff --cc share/html/Elements/ShowSearch
index 81f7529226,a047c4b966..f8c05d5ab2
--- a/share/html/Elements/ShowSearch
+++ b/share/html/Elements/ShowSearch
@@@ -80,24 -76,12 +80,28 @@@ if ($SavedSearch) 
      }
      $SearchArg->{'SavedSearchId'} ||= $SavedSearch;
      $SearchArg->{'SearchType'} ||= 'Ticket';
 -    if ( $SearchArg->{SearchType} ne 'Ticket' ) {
 +    if ( $SearchArg->{SearchType} eq 'Transaction' ) {
 +        $class = $SearchArg->{Class} = 'RT::Transactions';
 +        $customize
 +            = RT->Config->Get('WebPath')
 +            . '/Search/Build.html?'
 +            . $m->comp( '/Elements/QueryString', SavedSearchLoad => $SavedSearch, Class => 'RT::Transactions' );
 +        $ShowCount = RT->Config->Get('TransactionShowSearchResultCount')->{'RT::Ticket'};
 +    }
 +    elsif ( $SearchArg->{SearchType} eq 'Asset' ) {
 +        $class = $SearchArg->{Class} = 'RT::Assets';
 +        $customize
 +            = RT->Config->Get('WebPath')
 +            . '/Search/Build.html?'
 +            . $m->comp( '/Elements/QueryString', SavedSearchLoad => $SavedSearch, Class => 'RT::Assets' );
 +        $ShowCount = RT->Config->Get('AssetShowSearchResultCount');
 +    }
 +    elsif ( $SearchArg->{SearchType} ne 'Ticket' ) {
  
+         if ( $SearchArg->{'SearchType'} eq 'Chart' ) {
+             $SearchArg->{'SavedChartSearchId'} ||= $SavedSearch;
+         }
+ 
          # XXX: dispatch to different handler here
          $query_display_component
              = '/Search/Elements/' . $SearchArg->{SearchType};
diff --cc share/html/Helpers/PreviewScrips
index 03e10b0308,b0531ee915..febbf8abe8
--- a/share/html/Helpers/PreviewScrips
+++ b/share/html/Helpers/PreviewScrips
@@@ -106,26 -104,26 +106,30 @@@ $submitted{$_} = 1 for split /,/, $ARGS
  %             my $action = $scrip->ActionObj->Action;
  %             my @addresses =  $action->$type();
  %             next unless @addresses;
 -              <ul>
 +              <ul class="list-group list-group-compact">
  %             for my $addr (@addresses) {
 -                  <li>
 +                  <li class="list-group-item">
  %                 my $checked = $submitted{$addr->address} ? not $squelched{$addr->address} : $squelched_config;
  %                 $m->callback(CallbackName => 'BeforeAddress', Ticket => $TicketObj, Address => $addr, Type => $type, Checked => \$checked);
- %                 $recips{$addr->address}++;
 -                  <b><%loc($type)%></b>:
 +                    <div class="form-row">
 +                      <div class="col-auto">
 +                        <b><%loc($type)%></b>:
 +                      </div>
 +                      <div class="col-auto">
  %                 my $show_checkbox = 1;
  %                 if ( grep {$_ eq $addr->address} @{$action->{NoSquelch}{$type}} ) {
  %                     $show_checkbox = 0;
  %                 }
 -
 +                        <div class="custom-control custom-checkbox">
  %                 if ( $show_checkbox ) {
+ %                     $recips{$addr->address}++;
 -                      <input type="checkbox" class="checkbox" name="TxnSendMailTo" <% $checked ? 'checked="checked"' : '' |n%> value="<%$addr->address%>" id="TxnSendMailTo-<% $addr->address %>-<% $recips{$addr->address} %>" />
 +                          <input type="checkbox" class="custom-control-input" name="TxnSendMailTo" <% $checked ? 'checked="checked"' : '' |n%> value="<%$addr->address%>" id="TxnSendMailTo-<% $addr->address %>-<% $recips{$addr->address} %>" />
  %                 }
-                           <label <% $show_checkbox ? 'class="custom-control-label"' : '' |n%> for="TxnSendMailTo-<% $addr->address %>-<% $recips{$addr->address} %>"><& /Elements/ShowUser, Address => $addr &></label>
 -                  <label
 -%                   if ( $show_checkbox ) {
 -                        for="TxnSendMailTo-<% $addr->address %>-<% $recips{$addr->address} %>"
 -%                   }
 -                  ><& /Elements/ShowUser, Address => $addr &></label>
++                          <label <% $show_checkbox ? 'class="custom-control-label"' : '' |n%>
++%                         if ( $show_checkbox ) {
++                            for="TxnSendMailTo-<% $addr->address %>-<% $recips{$addr->address} %>"
++%                         }
++                          ><& /Elements/ShowUser, Address => $addr &></label>
  %                 $m->callback(CallbackName => 'AfterAddress', Ticket => $TicketObj, Address => $addr, Type => $type);
  %                 unless ( $show_checkbox ) {
  %                     if ( $type eq 'Cc' ) {
diff --cc share/html/Prefs/Other.html
index f3de2dd298,e2526dbd91..cb7e730b97
--- a/share/html/Prefs/Other.html
+++ b/share/html/Prefs/Other.html
@@@ -69,14 -69,11 +69,14 @@@
  
  % if ( RT->Config->Get('Crypt')->{'Enable'} ) {
  <&|/Widgets/TitleBox, title => loc( 'Cryptography' ) &>
- <&|/l&>Preferred key</&>: <& /Elements/Crypt/SelectKeyForEncryption, EmailAddress => $UserObj->EmailAddress, Default => $UserObj->PreferredKey &>
+ <&|/l&>Preferred GnuPG key</&>: <& /Elements/Crypt/SelectKeyForEncryption, Name => 'PreferredKey', EmailAddress => $UserObj->EmailAddress, Default => $UserObj->PreferredKey, Protocol => 'GnuPG' &>
  </&>
  % }
 -
 -<& /Elements/Submit, Name => 'Update', Label => loc('Save Changes') &>
 +<div class="form-row">
 +  <div class="col-12">
 +    <& /Elements/Submit, Name => 'Update', Label => loc('Save Changes') &>
 +  </div>
 +</div>
  </form>
  <%INIT>
  my @results;
diff --cc share/html/Search/Chart.html
index 985ee2e1c6,e4e04ea001..6adc8356ab
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@@ -57,7 -57,7 +57,7 @@@ $m->callback( ARGSRef => \%ARGS, Callba
  
  my $title = loc( "Grouped search results");
  
- my @search_fields = ( qw(Query GroupBy ChartStyle ChartFunction Width Height ExtraQueryParams), @ExtraQueryParams );
 -my @search_fields = qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height);
++my @search_fields = ( qw(Query GroupBy StackedGroupBy ChartStyle ChartFunction Width Height ExtraQueryParams), @ExtraQueryParams );
  my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
      SearchType   => 'Chart',
      SearchFields => [@search_fields],
@@@ -148,96 -137,66 +148,102 @@@ $m->callback( ARGSRef => \%ARGS, QueryA
  <input type="hidden" class="hidden" name="Query" value="<% $query{Query} %>" />
  <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
  
 -<&| /Widgets/TitleBox, title => loc('Group by'), class => "chart-group-by" &>
 -<fieldset><legend><% loc('Group tickets by') %></legend>
 -<& Elements/SelectGroupBy,
 -    Name => 'GroupBy',
 -    Query => $query{Query},
 -    Default => $query{'GroupBy'}[0],
 -    Stacked => $query{'GroupBy'}[0] eq ($query{StackedGroupBy} // '') ? 1 : 0,
 -    &>
 -</fieldset>
 -<fieldset><legend><% loc('and then') %></legend>
 -<& Elements/SelectGroupBy,
 -    Name => 'GroupBy',
 -    Query => $query{Query},
 -    Default => $query{'GroupBy'}[1] // q{},
 -    ShowEmpty => 1,
 -    Stacked => $query{'GroupBy'}[1] && ($query{'GroupBy'}[1] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
 -    &>
 -</fieldset>
 -<fieldset><legend><% loc('and then') %></legend>
 -<& Elements/SelectGroupBy,
 -    Name => 'GroupBy',
 -    Query => $query{Query},
 -    Default => $query{'GroupBy'}[2] // q{},
 -    ShowEmpty => 1,
 -    Stacked => $query{'GroupBy'}[2] && ($query{'GroupBy'}[2] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
 -    &>
 -</fieldset>
 -</&>
 -
 -<&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>
 -
 -<fieldset><legend><% loc('Calculate values of') %></legend>
 -<& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0] &>
 -</fieldset>
 -<fieldset><legend><% loc('and then') %></legend>
 -<& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1 &>
 -</fieldset>
 -<fieldset><legend><% loc('and then') %></legend>
 -<& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1 &>
 -</fieldset>
 -
 -</&>
 -
 -<&| /Widgets/TitleBox, title => loc('Picture'), class => "chart-picture" &>
 -<input name="ChartStyle" type="hidden" value="<% $query{ChartStyle} %>" />
 -<label><% loc('Style') %>: <& Elements/SelectChartType, Default => $query{ChartStyle} =~ /^(pie|bar|table)\b/ ? $1 : undef &></label>
 -<span class="width">
 -<label><% loc("Width") %>: <input type="text" name="Width" value="<% $query{'Width'} || q{} %>"> <% loc("px") %></label>
 -</span>
 -<span class="height">
 -  &#x00d7;
 -  <label><% loc("Height") %>: <input type="text" name="Height" value="<% $query{'Height'} || q{} %>"> <% loc("px") %></label>
 -</span>
 -<div class="include-table">
 -    <input type="checkbox" name="ChartStyleIncludeTable" <% $query{ChartStyle} =~ /\btable\b/ ? 'checked="checked"' : '' |n %>> <% loc('Include data table') %>
 -</div>
 -<div class="include-sql">
 -    <input type="checkbox" name="ChartStyleIncludeSQL" <% $query{ChartStyle} =~ /\bsql\b/ ? 'checked="checked"' : '' |n %>> <% loc('Include TicketSQL query') %>
 -</div>
 -</&>
 +% if ( $query{ExtraQueryParams} ) {
 +%   for my $input ( ref $query{ExtraQueryParams} eq 'ARRAY' ?  @{$query{ExtraQueryParams}} : $query{ExtraQueryParams} ) {
 +<input type="hidden" class="hidden" name="ExtraQueryParams" value="<% $input %>" />
 +%       if ( defined $query{$input} ) {
 +<input type="hidden" class="hidden" name="<% $input %>" value="<% $query{$input} %>" />
 +%       }
 +%   }
 +% }
 +
 +    <&| /Widgets/TitleBox, title => loc('Group by'), class => "chart-group-by" &>
 +      <fieldset><legend><% loc('Group tickets by') %></legend>
 +        <& Elements/SelectGroupBy,
 +          Name => 'GroupBy',
 +          Query => $query{Query},
 +          Default => $query{'GroupBy'}[0],
++          Stacked => $query{'GroupBy'}[0] eq ($query{StackedGroupBy} // '') ? 1 : 0,
++          StackedId => 'StackedGroupBy-1',
 +        &>
 +        </fieldset>
 +        <fieldset><legend><% loc('and then') %></legend>
 +        <& Elements/SelectGroupBy,
 +          Name => 'GroupBy',
 +          Query => $query{Query},
 +          Default => $query{'GroupBy'}[1] // q{},
 +          ShowEmpty => 1,
++          Stacked => $query{'GroupBy'}[1] && ($query{'GroupBy'}[1] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
++          StackedId => 'StackedGroupBy-2',
 +        &>
 +      </fieldset>
 +      <fieldset><legend><% loc('and then') %></legend>
 +        <& Elements/SelectGroupBy,
 +          Name => 'GroupBy',
 +          Query => $query{Query},
 +          Default => $query{'GroupBy'}[2] // q{},
 +          ShowEmpty => 1,
++          Stacked => $query{'GroupBy'}[2] && ($query{'GroupBy'}[2] eq ($query{StackedGroupBy} // '')) ? 1 : 0,
++          StackedId => 'StackedGroupBy-3',
 +        &>
 +      </fieldset>
 +    </&>
 +
 +    <&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>
 +      <fieldset><legend><% loc('Calculate values of') %></legend>
 +        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[0] &>
 +      </fieldset>
 +      <fieldset><legend><% loc('and then') %></legend>
 +        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[1] // q{}, ShowEmpty => 1 &>
 +      </fieldset>
 +      <fieldset><legend><% loc('and then') %></legend>
 +        <& Elements/SelectChartFunction, Default => $query{'ChartFunction'}[2] // q{}, ShowEmpty => 1 &>
 +      </fieldset>
 +    </&>
 +
 +    <&| /Widgets/TitleBox, title => loc('Picture'), class => "chart-picture" &>
 +      <input name="ChartStyle" type="hidden" value="<% $query{ChartStyle} %>" />
 +      <div class="form-row">
 +        <div class="label col-auto">
 +          <label><% loc('Style') %>:</label>
 +        </div>
 +        <div class="value col-auto">
 +          <& Elements/SelectChartType, Default => $query{ChartStyle} =~ /^(pie|bar(?:-stacked)?|line(?:-stacked)?|table)\b/ ? $1 : undef &>
 +        </div>
 +        <div class="label col-auto">
 +          <label><% loc("Width") %>:</label>
 +        </div>
 +        <div class="value col-auto">
 +          <div class="input-group mb-3">
 +            <input class="form-control" type="text" name="Width" value="<% $query{'Width'} || q{} %>">
 +            <div class="input-group-append">
 +              <span class="input-group-text"><&|/l&>px</&></span>
 +            </div>
 +          </div>
 +        </div>
 +        <div class="label col-auto">
 +          &#x00d7;
 +          <label><% loc("Height") %>:</label>
 +        </div>
 +        <div class="value col-auto">
 +          <div class="input-group mb-3">
 +            <input class="form-control" type="text" name="Height" value="<% $query{'Height'} || q{} %>">
 +            <div class="input-group-append">
 +              <span class="input-group-text"><&|/l&>px</&></span>
 +            </div>
 +          </div>
 +        </div>
 +      </div>
 +      <div class="include-table custom-control custom-checkbox">
 +        <input type="checkbox" id="ChartStyleIncludeTable" name="ChartStyleIncludeTable" class="custom-control-input" <% $query{ChartStyle} =~ /\btable\b/ ? 'checked="checked"' : '' |n %>>
 +        <label class="custom-control-label" for="ChartStyleIncludeTable"><&|/l&>Include data table</&></label>
 +      </div>
 +      <div class="include-sql custom-control custom-checkbox">
 +        <input type="checkbox" id="ChartStyleIncludeSQL" name="ChartStyleIncludeSQL" class="custom-control-input" <% $query{ChartStyle} =~ /\bsql\b/ ? 'checked="checked"' : '' |n %>>
 +        <label class="custom-control-label" for="ChartStyleIncludeSQL"><&|/l&>Include TicketSQL query</&></label>
 +      </div>
 +    </&>
 +
  <script type="text/javascript">
  var updateChartStyle = function() {
      var val = jQuery(".chart-picture [name=ChartType]").val();
@@@ -259,26 -227,57 +274,70 @@@ jQuery(".chart-picture [name=ChartType]
  
  jQuery(".chart-picture [name=ChartStyleIncludeTable]").change( updateChartStyle );
  jQuery(".chart-picture [name=ChartStyleIncludeSQL]").change( updateChartStyle );
+ jQuery("input.stacked-group-checkbox").change( function() {
+     if ( jQuery(this).is(':checked') ) {
+         jQuery("input.stacked-group-checkbox").not(this).prop('checked', false);
+     }
+ });
+ 
+ jQuery("select[name=GroupBy]").change( function() {
+     // "GroupBy-Groups" could be triggered because the they are fully cloned from "GroupBy"
+     if ( jQuery(this).attr('name') == 'GroupBy-Groups' ) {
 -        var elem = jQuery(this).next('select[name=GroupBy]');
++        var elem = jQuery(this).closest('fieldset').find('select[name=GroupBy]');
+         setTimeout( function () {
+             elem.change();
+         }, 100 ); // give it a moment to prepare "GroupBy" options
+     }
+     else {
+         jQuery(this).closest('fieldset').find('input.stacked-group-checkbox').val(jQuery(this).val());
+     }
+ });
+ 
+ jQuery( function() {
+     jQuery("select[name=ChartFunction-Groups]").change( function() {
+         var allow_stacked = jQuery(".chart-picture [name=ChartType]").val() == 'bar';
+ 
+         var value_count;
+         jQuery("select[name=ChartFunction-Groups]").each( function() {
+             if ( jQuery(this).val() ) {
+                 value_count++;
+                 if ( value_count > 1 || !jQuery(this).val().match(/count/i) ) {
+                     allow_stacked = 0;
+                 }
+             }
+         } );
+ 
+         if ( allow_stacked ) {
+             jQuery("span.stacked-group").removeClass('hidden');
+             jQuery("input.stacked-group-checkbox").prop('disabled', false);
+         }
+         else {
+             jQuery("span.stacked-group").addClass('hidden');
+             jQuery("input.stacked-group-checkbox").prop('disabled', true);
+         }
+     }).change();
+ });
+ 
  </script>
  
 -<& /Elements/Submit, Label => loc('Update Chart'), Name => 'Update' &>
 -</form>
 -
 +    <div class="form-row">
 +      <div class="col-12">
 +        <& /Elements/Submit, Label => loc('Update Chart'), Name => 'Update' &>
 +      </div>
 +    </div>
 +    </form>
 +  </div>
 +
 +  <div class="col-xl-6">
 +    <div class="saved-search">
-       <& /Widgets/SavedSearch:show, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts') &>
++      <& /Widgets/SavedSearch:show, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts'), AllowCopy => 1 &>
 +    </div>
 +  </div>
  </div>
 -<div class="saved-search">
 -    <& /Widgets/SavedSearch:show, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts'), AllowCopy => 1 &>
 +
  </div>
  </div>
 +
 +<%ARGS>
 + at ExtraQueryParams => ()
 +</%ARGS>
diff --cc share/html/Search/Elements/EditFormat
index 35a3aaefc0,fbeccbdcd7..ca260ecf32
--- a/share/html/Search/Elements/EditFormat
+++ b/share/html/Search/Elements/EditFormat
@@@ -64,9 -60,10 +64,11 @@@ jQuery( function() 
      jQuery('[name=AddCol], [name=RemoveCol], [name=ColUp], [name=ColDown]').click( function() {
          var name = jQuery(this).attr('name');
          var form = jQuery(this).closest('form');
 +
          jQuery.ajax({
-             url: '<% RT->Config->Get('WebPath') %>/Helpers/BuildFormatString?' + name + '=1&' + form.serialize(),
+             url: '<% RT->Config->Get('WebPath') %>/Helpers/BuildFormatString?' + name + '=1',
+             method: 'POST',
+             data: form.serialize(),
              success: function (data) {
                  if ( data.status == 'success' ) {
                      form.find('input[name=Format]').val(data.Format);
diff --cc share/html/Search/Elements/SelectGroupBy
index 8e8d3ce833,0e2de5cf20..dc0e2e3394
--- a/share/html/Search/Elements/SelectGroupBy
+++ b/share/html/Search/Elements/SelectGroupBy
@@@ -50,6 -50,7 +50,8 @@@ $Name => 'GroupBy
  $Default => 'Status'
  $Query   => ''
  $ShowEmpty => 0
+ $Stacked => 0
++$StackedId => "Stacked$Name"
  </%args>
  <select name="<% $Name %>" class="cascade-by-optgroup">
  % if ( $ShowEmpty ) {
@@@ -74,6 -75,11 +76,14 @@@ while ( my ($label, $value) = splice @o
    </optgroup>
  % }
  </select>
+ 
 -<span class="stacked-group">
 -     <% loc('Stacked?') %> <input name="Stacked<% $Name %>" type="checkbox" class="stacked-group-checkbox" <% $Stacked ? 'checked="checked"' : '' |n %>/>
++<span class="stacked-group d-inline-block">
++  <div class="custom-control custom-checkbox">
++    <input name="Stacked<% $Name %>" id="<% $StackedId %>" type="checkbox" class="custom-control-input stacked-group-checkbox" <% $Stacked ? 'checked="checked"' : '' |n %> />
++    <label for="<% $StackedId %>" class="custom-control-label"><&|/l&>Stacked?</&></label>
++  </div>
+ </span>
+ 
  <%init>
  use RT::Report::Tickets;
  my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
diff --cc share/html/Search/JSChart
index 75c3d58f7f,0000000000..027b14fcd4
mode 100644,000000..100644
--- a/share/html/Search/JSChart
+++ b/share/html/Search/JSChart
@@@ -1,251 -1,0 +1,318 @@@
 +%# BEGIN BPS TAGGED BLOCK {{{
 +%#
 +%# COPYRIGHT:
 +%#
 +%# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
 +%#                                          <sales at bestpractical.com>
 +%#
 +%# (Except where explicitly superseded by other copyright notices)
 +%#
 +%#
 +%# LICENSE:
 +%#
 +%# This work is made available to you under the terms of Version 2 of
 +%# the GNU General Public License. A copy of that license should have
 +%# been provided with this software, but in any event can be snarfed
 +%# from www.gnu.org.
 +%#
 +%# This work 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 or visit their web page on the internet at
 +%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
 +%#
 +%#
 +%# CONTRIBUTION SUBMISSION POLICY:
 +%#
 +%# (The following paragraph is not intended to limit the rights granted
 +%# to you to modify and distribute this software under the terms of
 +%# the GNU General Public License and is only of importance to you if
 +%# you choose to contribute your changes and enhancements to the
 +%# community by submitting them to Best Practical Solutions, LLC.)
 +%#
 +%# By intentionally submitting any modifications, corrections or
 +%# derivatives to this work, or any other work intended for use with
 +%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
 +%# you are the copyright holder for those contributions and you grant
 +%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
 +%# royalty-free, perpetual, license to use, copy, create derivative
 +%# works based on those contributions, and sublicense and distribute
 +%# those contributions and any derivatives thereof.
 +%#
 +%# END BPS TAGGED BLOCK }}}
 +<%args>
 +$Cache => undef
 +$Query => "id > 0"
 + at GroupBy => ()
 +$ChartStyle => 'bar+table+sql'
 + at ChartFunction => 'COUNT'
 +$Width  => undef
 +$Height => undef
 +$SavedSearchId => ''
++$StackedGroupBy => undef
 +</%args>
 +
 +% my $id = join '-', 'search-chart', $SavedSearchId || ();
 +<canvas id="<% $id %>" width="<% $Width %>" height="<% $Height %>"></canvas>
 +<script type="text/javascript">
 +var data_labels = <% JSON( \@data_labels ) |n %>;
 +
 +% if ( $#data == 1 )  {
 +var backgroundColor = Chart.colorschemes[<% $scheme_parts[0] |n,j %>][<% $scheme_parts[1] |n,j %>];
 +if ( backgroundColor && backgroundColor.length ) {
 +    while ( backgroundColor.length < <% scalar @{$data[0]} %>) {
 +        backgroundColor = backgroundColor.concat(backgroundColor);
 +    }
 +}
 +% }
 +
 +var ctx = document.getElementById(<% $id |n,j %>).getContext('2d');
 +var searchChart = new Chart(ctx, {
 +    type: <% $ChartStyle =~ /\b(bar|line|pie)\b/ ? $1 : 'bar' |n,j %>,
 +    data: {
 +        labels: <% JSON( [ map { join ' - ', @$_ } @{$data[0]} ] ) |n %>,
 +        datasets: [
 +% for my $index ( 1 .. $#data ) {
 +          {
 +%   if ( $#data == 1 )  {
 +            backgroundColor: backgroundColor || [],
 +%   }
 +
 +%   if ( $ChartStyle =~ /\bbar|line\b/ ) {
 +            label: <% $labels[$index-1] |n,j %>,
 +%   }
 +            data: <% JSON($data[$index]) |n %>
 +          }
 +%   if ( $index != $#data ) {
 +        ,
 +%   }
 +% }
 +      ]
 +    },
 +    options: {
 +        plugins: {
 +            colorschemes: {
 +                scheme: <% $scheme |n,j %>
 +            }
 +        },
 +% if ( $ChartStyle =~ /\bbar|line\b/ ) {
 +        legend: {
 +            display: <% @data == 2 ? 'false' : 'true' %>
 +        },
 +        tooltips: {
 +            callbacks: {
 +                label: function(tooltipItem, data) {
 +                    var label = data.datasets[tooltipItem.datasetIndex].label || '';
 +
 +                    if (label) {
 +                        label += ': ';
 +                    }
 +                    label += data_labels[tooltipItem.datasetIndex][tooltipItem.index];
 +                    return label;
 +                }
 +            }
 +        },
 +        scales: {
 +            xAxes: [{
++                stacked: <% $stacked ? 'true' : 'false' %>,
 +                scaleLabel: {
 +                    display: true,
 +                    labelString: <% join(' - ', map $report->Label( $_), @{ $columns{'Groups'} }) |n,j %>
 +                },
 +                gridLines: {
 +                    display: false
 +                }
 +            }],
 +            yAxes: [{
++                stacked: <% $stacked ? 'true' : 'false' %>,
 +                scaleLabel: {
 +                    display: true,
 +                    labelString: <% $report->Label( $columns{'Functions'}[0] ) |n,j %>
 +                },
 +                gridLines: {
 +                    drawTicks: true,
 +                    drawOnChartArea: false
 +                },
 +                ticks: {
 +                    beginAtZero: true,
 +                    callback: function(value, index, values) {
 +%                       my $info = $report->ColumnInfo($columns{'Functions'}[0]);
 +%                       if ( $info->{META} && ($info->{META}{Display}//'') eq 'DurationAsString' ) {
 +                            return;
 +%                       } else {
 +                            if (Math.floor(value) === value) {
 +                                return value;
 +                            }
 +%                       }
 +                    }
 +                }
 +            }]
 +        }
 +% }
 +    }
 +});
 +
 +var group_by = <% JSON( \@GroupBy ) |n %>;
 +var data_queries = <% JSON( \@data_queries ) |n %>;
 +
 +jQuery('#search-chart').click(function(e) {
 +    var slice = searchChart.getElementAtEvent(e);
 +    if ( !slice[0] ) return;
 +
 +    var query = <% $Query =~ /^\s*\(.*\)$/ ? $Query : "( $Query )" |n,j %>;
-     if ( data_queries[slice[0]._index] ) {
-         query += ' AND ( ' + data_queries[slice[0]._index] + ')';
++    var extra_query;
++%   if ( $stacked ) { # data_queries is array of array for stacked charts
++        if ( data_queries[slice[0]._datasetIndex][slice[0]._index] ) {
++            extra_query = data_queries[slice[0]._datasetIndex][slice[0]._index];
++        }
++%   } else {
++        if ( data_queries[slice[0]._index] ) {
++            extra_query = data_queries[slice[0]._index];
++        }
++%   }
++    if ( extra_query ) {
++        query += ' AND ( ' + extra_query + ')';
 +    }
 +    window.open(RT.Config.WebPath + '/Search/Results.html?Query=' + encodeURIComponent(query)
 +        + '&' + <% $m->comp('/Elements/QueryString', map { $_ => $DECODED_ARGS->{$_} } grep { $_ ne 'Query' } keys %$DECODED_ARGS) |n,j%>);
 +});
 +
 +</script>
 +
 +<%init>
 +s/\D//g for grep defined, $Width, $Height;
 +$Width  ||= ($ChartStyle =~ /\bpie\b/ ? 400 : 600);
 +$Height ||= ($ChartStyle =~ /\bpie\b/ ? $Width : 400);
 +$Height = $Width if $ChartStyle =~ /\bpie\b/;
 +
 +use RT::Report::Tickets;
 +my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 +
 +# Default GroupBy we use in RT::Report::Tickets, we also need it here to
 +# generate sub queries.
 + at GroupBy = 'Status' unless @GroupBy;
 +
 +my %columns;
 +if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
 +    %columns = %{ $data->{'columns'} };
 +    $report->Deserialize( $data->{'report'} );
 +    $session{'i'}++;
 +} else {
 +    %columns = $report->SetupGroupings(
 +        Query => $Query,
 +        GroupBy => \@GroupBy,
 +        Function => \@ChartFunction,
 +    );
 +
 +    $report->SortEntries;
 +}
 +
 +my @data = ([],[]);
 +my @data_labels;
 +my @data_queries;
 +while ( my $entry = $report->Next ) {
 +    push @{ $data[0] }, [ map $entry->LabelValue( $_ ), @{ $columns{'Groups'} } ];
 +    push @data_queries, $entry->Query;
 +
 +    my @values;
 +    my @label_values;
 +    foreach my $column ( @{ $columns{'Functions'} } ) {
 +        my $v = $entry->RawValue( $column );
 +        my $label_v = $entry->LabelValue( $column );
 +        unless ( ref $v ) {
 +            push @values, $v;
 +            push @label_values, $label_v;
 +            next;
 +        }
 +
 +        my @subs = $report->FindImplementationCode(
 +            $report->ColumnInfo( $column )->{'META'}{'SubValues'}
 +        )->( $report );
 +        push @values, map $v->{$_}, @subs;
 +        push @label_values, map $label_v->{$_}, @subs;
 +    }
 +
 +    my $i = 0;
 +    my $j = 0;
 +    push @{ $data[++$i] }, $_ foreach @values;
 +    push @{ $data_labels[$j++] }, $_ foreach @label_values;
 +}
 +
 +$ChartStyle =~ s/\bpie\b/bar/ if @data > 2;
 +
 +my @labels;
 +if ( $ChartStyle =~ /\b(?:bar|line)\b/ ) {
 +    for my $column ( @{$columns{'Functions'}} ) {
 +        my $info = $report->ColumnInfo( $column );
 +        my @subs;
 +        if ( $info->{'META'}{'SubValues'} ) {
 +            push @labels, map { join ': ', $report->Label($column), $_ } $report->FindImplementationCode( $info->{'META'}{'SubValues'} )->($report);
 +        }
 +        else {
 +            push @labels, $report->Label($column);
 +        }
 +    }
 +}
 +
++my $stacked;
++if ( $StackedGroupBy && $ChartStyle =~ /\bbar\b/ ) {
++    if ( scalar @data > 2 ) {
++        RT->Logger->warning("Invalid stack option: it can't apply to multiple data rows");
++    }
++    else {
++
++        my $labels = $data[0];
++
++        # find the stacked group index
++        require List::MoreUtils;
++        my $stacked_index = List::MoreUtils::first_index { $_ eq $StackedGroupBy } @GroupBy;
++        if ( $stacked_index >= 0 ) {
++            my %rows;
++            my $i = 0;
++            my %new_label;
++
++            for my $label (@$labels) {
++                my @new_label = @$label;
++                splice @new_label, $stacked_index, 1;    # remove the stacked group
++                my $key = join ';;;', @new_label;
++                $new_label{$key}                               = \@new_label;
++                $rows{ $label->[$stacked_index] }{$key}{value} = $data[1][$i];
++                $rows{ $label->[$stacked_index] }{$key}{label} = $data_labels[0][$i];
++                $rows{ $label->[$stacked_index] }{$key}{query} = $data_queries[$i];
++                $i++;
++            }
++
++            @data = [ map { $new_label{$_} } sort { lc $a cmp lc $b } keys %new_label ];
++
++            my @dataset_labels;
++            @data_queries = ();
++            @data_labels  = ();
++            for my $stacked_field ( sort { lc $a cmp lc $b } keys %rows ) {
++                push @dataset_labels, join ' - ', $labels[0], $stacked_field;
++                my ( @new_values, @new_labels, @new_queries );
++                for my $key ( sort { lc $a cmp lc $b } keys %new_label ) {
++                    push @new_values,  $rows{$stacked_field}->{$key}{value} || 0;
++                    push @new_labels,  $rows{$stacked_field}->{$key}{label};
++                    push @new_queries, $rows{$stacked_field}->{$key}{query};
++                }
++                push @data,         \@new_values;
++                push @data_labels,  \@new_labels;
++                push @data_queries, \@new_queries;
++            }
++            @labels  = @dataset_labels;
++            $stacked = 1;
++        }
++        else {
++            RT->Logger->warning("Invalid StackedGroupBy: $StackedGroupBy");
++        }
++    }
++}
++
 +my $scheme = RT->Config->Get('JSChartColorScheme', $session{CurrentUser}) || 'brewer.Paired12';
 +my @scheme_parts = split /\./, $scheme, 2;
 +</%init>
diff --cc share/html/Ticket/Elements/UpdateCc
index 89b23e8eb1,3f8b76059e..b058ecfaca
--- a/share/html/Ticket/Elements/UpdateCc
+++ b/share/html/Ticket/Elements/UpdateCc
@@@ -47,106 -47,67 +47,108 @@@
  %# END BPS TAGGED BLOCK }}}
  % $m->callback(CallbackName => 'BeforeCc', ARGSRef => \%ARGS, Ticket => $TicketObj, one_time_Ccs => \@one_time_Ccs, txn_addresses => \%txn_addresses);
  
 -<tr><td class="label"><&|/l&>One-time Cc</&>:</td><td><& /Elements/EmailInput, Name => 'UpdateCc', Size => undef, Default => $ARGS{UpdateCc}, AutocompleteMultiple => 1 &>
 -<input type="hidden" id="UpdateIgnoreAddressCheckboxes" name="UpdateIgnoreAddressCheckboxes" value="<% $ARGS{UpdateIgnoreAddressCheckboxes} || 0 %>">
 -        <br />
 +<div class="form-row">
 +  <div class="label col-2">
 +    <&|/l&>One-time Cc</&>:
 +  </div>
 +  <div class="value col-9">
 +    <& /Elements/EmailInput, Name => 'UpdateCc', Size => undef, Default => $ARGS{UpdateCc}, AutocompleteMultiple => 1, Options => \@one_time_Ccs &>
-     <input type="hidden" id="UpdateIgnoreAddressCheckboxes" name="UpdateIgnoreAddressCheckboxes" value="0">
++    <input type="hidden" id="UpdateIgnoreAddressCheckboxes" name="UpdateIgnoreAddressCheckboxes" value="<% $ARGS{UpdateIgnoreAddressCheckboxes} || 0 %>">
 +  </div>
 +</div>
  
 +<div class="form-row mt-0">
 +  <div class="label col-2"></div>
 +  <div class="value col-9">
 +    <div class="form-row">
  %if (scalar @one_time_Ccs) {
  %   if ($hide_cc_suggestions) {
 -        <a href="#" class="ToggleSuggestions" data-hide-label="<% $hide_label %>" data-show-label="<% $show_label %>">
 -            <i class="label">(<&|/l&>show suggestions</&>)</i>
 -        </a>
 -        <div class="OneTimeCcs hidden">
 +      <a href="#" class="ToggleSuggestions" data-hide-label="<% $hide_label %>" data-show-label="<% $show_label %>">
 +        <i class="label">(<&|/l&>show suggestions</&>)</i>
 +      </a>
 +      <div class="OneTimeCcs d-flex flex-wrap hidden">
  %   }
 -<i class="label">(<&|/l&>check to add</&>)</i>
 -<input type="checkbox" class="checkbox" id="AllSuggestedCc" name="AllSuggestedCc" value="1" onclick="setCheckbox(this, /^UpdateCc-/); checkboxesToInput('UpdateCc', 'input[name^=UpdateCc-]:checkbox');">
 -<label for="AllSuggestedCc"><% loc('All recipients') %></label>
 +
 +      <div class="col-auto"><span class="far fa-question-circle icon-helper" data-toggle="tooltip" data-placement="top" data-original-title="<&|/l&>check to add</&>"></span></div>
 +      <div class="custom-control custom-checkbox">
-         <input type="checkbox" class="custom-control-input" id="AllSuggestedCc" name="AllSuggestedCc" value="1" onclick="setCheckbox(this, /^UpdateCc-/, null, true)">
++        <input type="checkbox" class="custom-control-input" id="AllSuggestedCc" name="AllSuggestedCc" value="1" onclick="setCheckbox(this, /^UpdateCc-/); checkboxesToInput('UpdateCc', 'input[name^=UpdateCc-]:checkbox');">
 +        <label class="custom-control-label" for="AllSuggestedCc"><% loc('All recipients') %></label>
 +      </div>
  %}
  %foreach my $addr ( @one_time_Ccs ) {
 -<span class="ticket-update-suggested-cc">
 -<input
 -    id="UpdateCc-<%$addr%>"
 -    name="UpdateCc-<%$addr%>"
 -    class="onetime onetimecc"
 -    type="checkbox"
 +      <div class="col-auto">
 +        <span class="ticket-update-suggested-cc">
 +          <div class="custom-control custom-checkbox">
 +            <input
 +              id="UpdateCc-<%$addr%>"
 +              name="UpdateCc-<%$addr%>"
 +              class="onetime onetimecc custom-control-input"
 +              type="checkbox"
 +              autocomplete="off"
  % my $clean_addr = $txn_addresses{$addr}->format;
 -    onClick="checkboxToInput('UpdateCc', <% "UpdateCc-$addr" |n,j%>, <%$clean_addr|n,j%> );"
 -    data-address="<% $clean_addr %>"
 -    <% $ARGS{'UpdateCc-'.$addr} ? 'checked="checked"' : ''%> >
 -      <label for="UpdateCc-<%$addr%>"><& /Elements/ShowUser, Address => $txn_addresses{$addr}&></label>
 -</span>
++              data-address="<% $clean_addr %>"
 +              onClick="checkboxToInput('UpdateCc', <% "UpdateCc-$addr" |n,j%>, <%$clean_addr|n,j%> );"
 +              <% $ARGS{'UpdateCc-'.$addr} ? 'checked="checked"' : ''%> >
 +            <label class="custom-control-label" for="UpdateCc-<%$addr%>"><& /Elements/ShowUser, Address => $txn_addresses{$addr}&></label>
 +          </div>
 +        </span>
 +      </div>
  %}
  %if (@one_time_Ccs && $hide_cc_suggestions) {
 -</div>
 +      </div>
  %}
 -</td></tr>
 -<tr><td class="label"><&|/l&>One-time Bcc</&>:</td><td><& /Elements/EmailInput, Name => 'UpdateBcc', Size => undef, Default => $ARGS{UpdateBcc}, AutocompleteMultiple => 1 &><br />
 +    </div>
 +  </div>
 +</div>
 +
 +<div class="form-row">
 +  <div class="label col-2">
 +    <&|/l&>One-time Bcc</&>:
 +  </div>
 +  <div class="value col-9">
 +    <& /Elements/EmailInput, Name => 'UpdateBcc', Size => undef, Default => $ARGS{UpdateBcc}, AutocompleteMultiple => 1, Options => \@one_time_Ccs &>
 +  </div>
 +</div>
 +
 +<div class="form-row mt-0">
 +  <div class="label col-2"></div>
 +  <div class="value col-9">
 +    <div class="form-row">
  %if (scalar @one_time_Ccs) {
  %   if ($hide_cc_suggestions) {
 -        <a href="#" class="ToggleSuggestions" data-hide-label="<% $hide_label %>" data-show-label="<% $show_label %>">
 -            <i class="label">(<&|/l&>show suggestions</&>)</i>
 -        </a>
 -        <div class="OneTimeCcs hidden">
 +      <a href="#" class="ToggleSuggestions" data-hide-label="<% $hide_label %>" data-show-label="<% $show_label %>">
 +        <i class="label">(<&|/l&>show suggestions</&>)</i>
 +      </a>
 +      <div class="OneTimeCcs d-flex flex-wrap hidden">
  %   }
 -<i class="label">(<&|/l&>check to add</&>)</i>
 -<input type="checkbox" class="checkbox" id="AllSuggestedBcc" name="AllSuggestedBcc" value="1" onclick="setCheckbox(this, /^UpdateBcc-/); checkboxesToInput('UpdateBcc', 'input[name^=UpdateBcc-]:checkbox');">
 -<label for="AllSuggestedBcc"><% loc('All recipients') %></label>
 +
 +      <div class="col-auto"><span class="far fa-question-circle icon-helper" data-toggle="tooltip" data-placement="top" data-original-title="<&|/l&>check to add</&>"></span></div>
 +      <div class="custom-control custom-checkbox">
-         <input type="checkbox" class="checkbox custom-control-input" id="AllSuggestedBcc" name="AllSuggestedBcc" value="1" onclick="setCheckbox(this, /^UpdateBcc-/, null, true)">
++        <input type="checkbox" class="checkbox custom-control-input" id="AllSuggestedBcc" name="AllSuggestedBcc" value="1" onclick="setCheckbox(this, /^UpdateBcc-/); checkboxesToInput('UpdateBcc', 'input[name^=UpdateBcc-]:checkbox');">
 +        <label class="custom-control-label" for="AllSuggestedBcc"><% loc('All recipients') %></label>
 +      </div>
  %}
  %foreach my $addr ( @one_time_Ccs ) {
 -<span class="ticket-update-suggested-cc">
 -<input
 -    id="UpdateBcc-<%$addr%>"
 -    name="UpdateBcc-<%$addr%>"
 -    class="onetime onetimebcc"
 -    type="checkbox"
 +      <div class="col-auto">
 +        <span class="ticket-update-suggested-cc">
 +          <div class="custom-control custom-checkbox">
 +            <input
 +              id="UpdateBcc-<%$addr%>"
 +              name="UpdateBcc-<%$addr%>"
 +              class="onetime onetimebcc custom-control-input"
 +              type="checkbox"
 +              autocomplete="off"
  % my $clean_addr = $txn_addresses{$addr}->format;
 -    onClick="checkboxToInput('UpdateBcc', <% "UpdateBcc-$addr" |n,j%>, <%$clean_addr|n,j%> );"
 -    data-address="<% $clean_addr %>"
 -    <% $ARGS{'UpdateBcc-'.$addr} ? 'checked="checked"' : ''%> >
 -      <label for="UpdateBcc-<%$addr%>"><& /Elements/ShowUser, Address => $txn_addresses{$addr}&></label>
 -</span>
++              data-address="<% $clean_addr %>"
 +              onClick="checkboxToInput('UpdateBcc', <% "UpdateBcc-$addr" |n,j%>, <%$clean_addr|n,j%> );"
 +              <% $ARGS{'UpdateBcc-'.$addr} ? 'checked="checked"' : ''%> >
 +            <label class="custom-control-label" for="UpdateBcc-<%$addr%>"><& /Elements/ShowUser, Address => $txn_addresses{$addr}&></label>
 +          </div>
 +        </span>
 +      </div>
  %}
  %if (@one_time_Ccs && $hide_cc_suggestions) {
 -</div>
 +      </div>
  <script type="text/javascript">
  jQuery(function() {
      jQuery('a.ToggleSuggestions').click(function(e) {
diff --cc share/html/Ticket/Forward.html
index 5a902c8676,b4493b2cfe..6061a417d7
--- a/share/html/Ticket/Forward.html
+++ b/share/html/Ticket/Forward.html
@@@ -51,44 -51,31 +51,44 @@@
  % $m->callback(CallbackName => 'BeforeActionList', Actions => \@results, ARGSRef => \%ARGS, Ticket => $TicketObj);
  <& /Elements/ListActions, actions => \@results &>
  
 -<form action="Forward.html" id="ForwardMessage" name="ForwardMessage" method="post">
 +<form action="Forward.html" id="ForwardMessage" name="ForwardMessage" method="post" class="mx-auto max-width-lg">
  % $m->callback( CallbackName => 'FormStart', ARGSRef => \%ARGS );
 -<input type="hidden" class="hidden" name="id" value="<% $id %>" /><br />
 +<input type="hidden" class="hidden" name="id" value="<% $id %>" />
  <input type="hidden" class="hidden" name="QuoteTransaction" value="<% $ARGS{'QuoteTransaction'} || '' %>" />
  
- <& /Elements/Crypt/SignEncryptWidget:ShowIssues, self => $gnupg_widget &>
+ <& /Elements/Crypt/SignEncryptWidget:ShowIssues, self => $gnupg_widget, Queue => $TicketObj->QueueObj &>
  
 -<table border="0">
 -<tr><td align="right"><&|/l&>From</&>:</td>
 -<td><% $from %></td></tr>
 +<&|/Widgets/TitleBox, title => loc('Message'), class => 'messagedetails' &>
 +<div>
 +  <div class="form-row">
 +    <div class="label col-2"><&|/l&>From</&>:</div>
 +    <div class="value col-9"><% $from %></div>
 +  </div>
  
 -<tr><td align="right"><&|/l&>Subject</&>:</td>
 -<td><input name="Subject" size="60" value="<% $ARGS{'Subject'} || $subject %>" /></td></tr>
 +  <div class="form-row">
 +    <div class="label col-2"><&|/l&>Subject</&>:</div>
 +    <div class="value col-9"><input class="form-control"   type="text" name="Subject" value="<% $ARGS{'Subject'} || $subject %>" /></div>
 +  </div>
  
 -<tr><td align="right"><&|/l&>To</&>:</td>
 -<td><& /Elements/EmailInput, Name => "To", AutocompleteMultiple => 1, Default => $ARGS{'To'} &></td></tr>
 +  <div class="form-row">
 +    <div class="label col-2"><&|/l&>To</&>:</div>
 +    <div class="value col-9"><& /Elements/EmailInput, Name => "To", AutocompleteMultiple => 1, Default => $ARGS{'To'} &></div>
 +  </div>
  
 -<tr><td align="right"><&|/l&>Cc</&>:</td>
 -<td><& /Elements/EmailInput, Name => "Cc", AutocompleteMultiple => 1, Default => $ARGS{'Cc'} &></td></tr>
 +  <div class="form-row">
 +    <div class="label col-2"><&|/l&>Cc</&>:</div>
 +    <div class="value col-9"><& /Elements/EmailInput, Name => "Cc", AutocompleteMultiple => 1, Default => $ARGS{'Cc'} &></div>
 +  </div>
  
 -<tr><td align="right"><&|/l&>Bcc</&>:</td>
 -<td><& /Elements/EmailInput, Name => "Bcc", AutocompleteMultiple => 1, Default => $ARGS{'Bcc'} &></td></tr>
 +  <div class="form-row">
 +    <div class="label col-2"><&|/l&>Bcc</&>:</div>
 +    <div class="value col-9"><& /Elements/EmailInput, Name => "Bcc", AutocompleteMultiple => 1, Default => $ARGS{'Bcc'} &></div>
 +  </div>
  
  % if ( $gnupg_widget ) {
 -<tr><td> </td><td>
 +  <div class="form-row">
 +    <span class="label col-2 empty"> </span>
 +    <div class="value col-9">
  <& /Elements/Crypt/SignEncryptWidget,
      self => $gnupg_widget,
      TicketObj => $TicketObj,
diff --cc share/html/Ticket/Update.html
index 169e373460,b13e1ea0fb..7b532ca240
--- a/share/html/Ticket/Update.html
+++ b/share/html/Ticket/Update.html
@@@ -60,11 -59,11 +60,11 @@@
  <input type="hidden" class="hidden" name="Action" value="<% $Action %>" />
  <input type="hidden" class="hidden" name="Token" value="<% $ARGS{'Token'} %>" />
  
- <& /Elements/Crypt/SignEncryptWidget:ShowIssues, self => $gnupg_widget &>
+ <& /Elements/Crypt/SignEncryptWidget:ShowIssues, self => $gnupg_widget, Queue => $TicketObj->QueueObj &>
  
  <div id="ticket-update-metadata">
 -  <&|/Widgets/TitleBox, title => loc('Ticket and Transaction') &>
 -<table width="100%" border="0">
 +  <&|/Widgets/TitleBox, title => loc('Ticket and Transaction'), class => 'ticket-info-basics' &>
 +<div>
  % $m->callback(CallbackName => 'AfterTableOpens', ARGSRef => \%ARGS, Ticket => $TicketObj);
  
  % my $skip;
diff --cc share/static/js/util.js
index e6195c0459,31122ed331..bd9cc01a5a
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@@ -191,21 -211,37 +193,47 @@@ function checkboxToInput(target,checkbo
          }
      }
      else{
-         tar.val(tar.val().replace(val+', ',''));
-         tar.val(tar.val().replace(val,''));
+         emails = jQuery.grep(emails, function(email) {
+             return email != val;
+         });
      }
      jQuery('#UpdateIgnoreAddressCheckboxes').val(true);
 +
 +    var selectize = tar[0].selectize;
 +    if ( selectize ) {
 +        if( box.prop('checked') ) {
 +            selectize.createItem(val, false);
 +        }
 +        else {
 +            selectize.removeItem(val, true);
 +        }
 +    }
-     tar.change();
+     tar.val(emails.join(', ')).change();
+ }
+ 
+ function checkboxesToInput(target,checkboxes) {
+     var tar = jQuery('#' + escapeCssSelector(target));
+ 
+     var emails = jQuery.grep(tar.val().split(/,\s*/), function(email) {
+         return email.match(/\S/) ? true : false;
+     });
+ 
+     jQuery(checkboxes).each(function(index, checkbox) {
+         var val = jQuery(checkbox).attr('data-address');
+         if(jQuery(checkbox).prop('checked')){
+             if ( emails.indexOf(val) == -1 ) {
+                 emails.unshift(val);
+             }
+         }
+         else{
+             emails = jQuery.grep(emails, function(email) {
+                 return email != val;
+             });
+         }
+     });
+ 
+     jQuery('#UpdateIgnoreAddressCheckboxes').val(true);
+     tar.val(emails.join(', ')).change();
  }
  
  // ahah for back compatibility as plugins may still use it
@@@ -555,37 -565,19 +593,50 @@@ function addprincipal_onchange(ev, ui) 
      }
  }
  
 +function refreshCollectionListRow(tbody, table, success, error) {
 +    var params = {
 +        DisplayFormat : table.data('display-format'),
 +        ObjectClass   : table.data('class'),
 +        MaxItems      : table.data('max-items'),
 +        InlineEdit    : table.hasClass('inline-edit'),
 +
 +        i             : tbody.data('index'),
 +        ObjectId      : tbody.data('record-id'),
 +        Warning       : tbody.data('warning')
 +    };
 +
 +    tbody.addClass('refreshing');
 +
 +    jQuery.ajax({
 +        url    : RT.Config.WebHomePath + '/Helpers/CollectionListRow',
 +        method : 'GET',
 +        data   : params,
 +        success: function (response) {
 +            var index = tbody.data('index');
 +            tbody.replaceWith(response);
 +            // Get the new replaced tbody
 +            tbody = table.find('tbody[data-index=' + index + ']');
 +            initDatePicker(tbody);
 +            tbody.find('.selectpicker').selectpicker();
 +            if (success) { success(response) }
 +        },
 +        error: error
 +    });
 +}
 +
+ // disable submit on enter in autocomplete boxes
+ jQuery(function() {
+     jQuery('input[data-autocomplete], input.ui-autocomplete-input').each(function() {
+         var input = jQuery(this);
+ 
+         input.on('keypress', function(event) {
+             if (event.keyCode === 13 && jQuery('ul.ui-autocomplete').is(':visible')) {
+                 return false;
+             }
+         });
+     });
+ });
+ 
  function escapeCssSelector(str) {
      return str.replace(/([^A-Za-z0-9_-])/g,'\\$1');
  }
diff --cc t/assets/web.t
index 8e6f75fdad,479c02e79b..45080e1006
--- a/t/assets/web.t
+++ b/t/assets/web.t
@@@ -107,6 -107,22 +107,22 @@@ diag "Create with CFs in other groups"
      $m->content_unlike(qr/Purchased.*?must match .*?Year/, "Lacks validation error for Purchased");
  }
  
+ diag "Bulk update";
+ {
 -    $m->follow_link_ok( { id => 'assets-search' }, "Asset search page" );
++    $m->follow_link_ok( { id => 'assets-simple_search' }, "Asset search page" );
+     $m->submit_form_ok( { form_id => 'AssetSearch' }, "Search assets" );
+     $m->follow_link_ok( { text => 'Bulk Update' }, "Asset bulk update page" );
+ 
+     my $form = $m->form_id('BulkUpdate');
+     my $status_input = $form->find_input('UpdateStatus');
+     is_deeply(
+         [ sort $status_input->possible_values ],
+         [ '', 'allocated', 'deleted', 'in-use', 'new', 'recycled', 'stolen' ],
+         'Status options'
+     );
+     # TODO: test more bulk update actions
+ }
+ 
  # XXX TODO: test other modify pages
  
  done_testing;
diff --cc t/web/html_template.t
index d6d68e8a27,17d0c3e634..abb895c75a
--- a/t/web/html_template.t
+++ b/t/web/html_template.t
@@@ -62,10 -61,35 +62,36 @@@ diag('test real mail outgoing') if $ENV
      # $mail is utf8 encoded
      my ($mail) = RT::Test->fetch_caught_mails;
      $mail = Encode::decode("UTF-8", $mail );
-     like( $mail, qr/$template.*$template/s, 'mail has template content $template twice' );
-     like( $mail, qr/$subject.*$subject/s,   'mail has ticket subject $sujbect twice' );
-     like( $mail, qr/$content.*$content/s,   'mail has ticket content $content twice' );
-     like( $mail, qr!<h1>$content</h1>!,     'mail has ticket html content <h1>$content</h1>' );
+     my $quoted_template = MIME::QuotedPrint::encode_qp( Encode::encode( 'UTF-8', $template ), '' );
+     my $quoted_subject = MIME::QuotedPrint::encode_qp( Encode::encode( 'UTF-8', $subject ), '' );
+     my $quoted_content = MIME::QuotedPrint::encode_qp( Encode::encode( 'UTF-8', $content ), '' );
+     like( $mail, qr/$quoted_template.*$quoted_template/s, 'mail has template content $template twice' );
+     like( $mail, qr/$quoted_subject.*$quoted_subject/s,   'mail has ticket subject $sujbect twice' );
+     like( $mail, qr/$quoted_content.*$quoted_content/s,   'mail has ticket content $content twice' );
+     like( $mail, qr!<h1>$quoted_content</h1>!,     'mail has ticket html content <h1>$content</h1>' );
+ }
+ 
+ diag('test long line mails') if $ENV{TEST_VERBOSE};
+ {
+     $m->get_ok( $baseurl . '/Ticket/Create.html?Queue=1' );
+ 
+     $m->submit_form(
+         form_name => 'TicketCreate',
+         fields    => {
+             Subject     => $subject,
+             Content     => 'a' x 1000,
+             ContentType => 'text/html',
+         },
++        button => 'SubmitTicket',
+     );
+     $m->content_like( qr/Ticket \d+ created/i, 'created the ticket' );
 -    $m->follow_link( text => 'Show' );
++    $m->follow_link( url_regex => qr/ShowEmailRecord\.html/ );
+     $m->content_contains( $template, "html has $template" );
+     $m->content_contains( $subject,  "html has ticket subject $subject" );
+     $m->text_contains( 'a' x 1000, "html has 1000 continuous a" );
+     my ($mail) = RT::Test->fetch_caught_mails;
+     ok( $mail =~ /Content-Transfer-Encoding: quoted-printable/, 'mail is quoted-printable encoded' );
+     ok( $mail !~ /a{1000}/,                                     'mail lacks 1000 continuous a' );
  }
  
  done_testing;
diff --cc t/web/ticket_time.t
index 0000000000,52ac777abf..909a55b301
mode 000000,100644..100644
--- a/t/web/ticket_time.t
+++ b/t/web/ticket_time.t
@@@ -1,0 -1,147 +1,149 @@@
+ use strict;
+ use warnings;
+ use RT::Test tests => undef;
+ 
+ my ( $baseurl, $m ) = RT::Test->started_ok;
+ ok( $m->login, "Logged in" );
+ 
+ my $queue = RT::Test->load_or_create_queue( Name => 'General' );
+ ok( $queue->id, "loaded the General queue" );
+ 
+ my %valid = (
+     '0'     => 0,
+     '-8'    => -8,
+     '2'     => 2,
+     '5.'    => 5,
+     '5.5'   => 6,
+     ' 15 '  => 15,
+     '1,000' => 1000,
+     '.5h'   => 30,
+     '1.5h'  => 90,
+     '2h'    => 120,
+ );
+ my @invalid = ( 'a', '3;4', '3+4' );
+ 
+ $m->goto_create_ticket( $queue );
+ for my $time ( @invalid ) {
+     my ( $number, $hour ) = $time =~ /^(.+?)(h?)$/;
+ 
+     $m->submit_form_ok(
+         {
+             form_name => 'TicketCreate',
+             fields    => { TimeEstimated => $number, $hour ? ( 'TimeEstimated-TimeUnits' => 'hours' ) : (), },
++            button    => 'SubmitTicket',
+         },
+         "Submit time $time",
+     );
+     $m->text_contains( 'Invalid TimeEstimated: it should be a number' );
+     $m->text_unlike( qr/Ticket \d+ created in queue/ );
+ }
+ 
+ for my $time ( sort keys %valid ) {
+     my ( $number, $hour ) = $time =~ /^(.+?)(h?)$/;
+ 
+     $m->goto_create_ticket( $queue );
+     $m->submit_form_ok(
+         {
+             form_name => 'TicketCreate',
+             fields    => { TimeEstimated => $number, $hour ? ( 'TimeEstimated-TimeUnits' => 'hours' ) : (), },
++            button    => 'SubmitTicket',
+         },
+         "Submit time $time",
+     );
+     $m->text_lacks( 'Invalid TimeEstimated: it should be a number' );
+     $m->text_like( qr/Ticket \d+ created in queue/ );
+     my $ticket = RT::Test->last_ticket;
+     is( $ticket->TimeEstimated, $valid{$time}, 'TimeEstimated is set' );
+ }
+ 
+ my $ticket = RT::Test->last_ticket;
+ for my $page ( qw/Modify ModifyAll/ ) {
+     $m->goto_ticket( $ticket->id, $page );
+ 
+     for my $time ( @invalid ) {
+         my ( $number, $hour ) = $time =~ /^(.+?)(h?)$/;
+ 
+         $m->submit_form_ok(
+             {
+                 form_name => "Ticket$page",
+                 fields => { map { $_ => $number, $hour ? ( "$_-TimeUnits" => 'hours' ) : () } qw/TimeLeft TimeWorked/ },
+             },
+             "Submit time $time",
+         );
+ 
+         $ticket->Load( $ticket->id );
+         for my $field ( qw/TimeLeft TimeWorked/ ) {
+             $m->text_contains( "Invalid $field: it should be a number" );
+             ok( !$ticket->$field, "$field is not updated" );
+         }
+     }
+ 
+     for my $time ( sort keys %valid ) {
+         my ( $number, $hour ) = $time =~ /^(.+?)(h?)$/;
+ 
+         $m->submit_form_ok(
+             {
+                 form_name => "Ticket$page",
+                 fields => { map { $_ => $number, $hour ? ( "$_-TimeUnits" => 'hours' ) : () } qw/TimeLeft TimeWorked/ },
+             },
+             "Submit time $time",
+         );
+         $ticket->Load( $ticket->id );
+ 
+         for my $field ( qw/TimeLeft TimeWorked/ ) {
+             $m->text_lacks( "Invalid $field: it should be a number" );
+             if ( $field eq 'TimeLeft' ) {
+                 $m->text_like( qr/$field changed/ );
+             }
+             else {
+                 $m->text_like( qr/worked -?[\d.]+ (?:minute|hour)|adjusted time worked/i );
+             }
+             is( $ticket->$field, $valid{$time}, "$field is updated" );
+         }
+     }
+ 
+     for my $field ( qw/TimeLeft TimeWorked/ ) {
+         my $set_method = "Set$field";
+         my ( $ret, $msg ) = $ticket->$set_method( 0 );
+         ok( $ret, 'Reset $field to 0' );
+     }
+ }
+ 
+ $m->goto_ticket( $ticket->id, 'Update' );
+ 
+ for my $time ( @invalid ) {
+     my ( $number, $hour ) = $time =~ /^(.+?)(h?)$/;
+ 
+     $m->submit_form_ok(
+         {
+             form_name => 'TicketUpdate',
+             fields    => { UpdateTimeWorked => $number, $hour ? ( 'UpdateTimeWorked-TimeUnits' => 'hours' ) : (), },
+             button    => 'SubmitTicket',
+         },
+         "Submit time $time",
+     );
+     $m->text_contains( 'Invalid UpdateTimeWorked: it should be a number' );
+     $ticket->Load( $ticket->id );
+     ok( !$ticket->TimeWorked, 'TimeWorked is not updated' );
+ }
+ 
+ my $time_worked = $ticket->TimeWorked;
+ for my $time ( sort keys %valid ) {
+     my ( $number, $hour ) = $time =~ /^(.+?)(h?)$/;
+ 
+     $m->goto_ticket( $ticket->id, 'Update' );
+     $m->submit_form_ok(
+         {
+             form_name => 'TicketUpdate',
+             fields    => { UpdateTimeWorked => $number, $hour ? ( 'UpdateTimeWorked-TimeUnits' => 'hours' ) : (), },
+             button    => 'SubmitTicket',
+         },
+         "Submit time $time",
+     );
+     $m->text_lacks( 'Invalid UpdateTimeWorked: it should be a number' );
+     $ticket->Load( $ticket->id );
+     is( $ticket->TimeWorked, $time_worked + $valid{$time}, 'TimeWorked is updated' );
+     $time_worked = $ticket->TimeWorked;
+ }
+ 
+ done_testing;
diff --cc t/web/ticket_txn_content.t
index 60240c63fb,773f3afe87..d092aa9015
--- a/t/web/ticket_txn_content.t
+++ b/t/web/ticket_txn_content.t
@@@ -112,3 -112,42 +112,44 @@@ for my $type ( 'text/plain', 'text/html
      $m->content_contains("this is main reply content", 'email contains main reply content');
      $m->back;
  }
+ 
+ $m->goto_create_ticket( $qid );
+ $m->submit_form_ok(
+     {
+         form_name => 'TicketCreate',
+         fields    => {
+             Subject => 'with main body',
+             Content => 'this is main body',
+             Attach  => $plain_file,
 -        }
++        },
++        button    => 'SubmitTicket',
+     },
+     'submit TicketCreate form'
+ );
+ $m->text_like( qr/Ticket \d+ created in queue/, 'ticket is created' );
 -$m->content_contains( "Download $plain_name", 'download plain file link' );
 -$m->follow_link_ok( { text => 'Reply', url_regex => qr/QuoteTransaction=/ }, 'reply the create transaction' );
++ok( $m->find_link( text => $plain_name ), 'download plain file link' );
++$m->follow_link_ok( { url_regex => qr/QuoteTransaction=/ }, 'reply the create transaction' );
+ my $form    = $m->form_name( 'TicketUpdate' );
+ my $content = $form->find_input( 'UpdateContent' );
+ like( $content->value, qr/this is main body/, 'has transaction content' );
+ 
+ $m->goto_create_ticket( $qid );
+ $m->submit_form_ok(
+     {
+         form_name => 'TicketCreate',
+         fields    => {
+             Subject => 'without main body',
+             Attach  => $plain_file,
 -        }
++        },
++        button    => 'SubmitTicket',
+     },
+     'submit TicketCreate form'
+ );
+ $m->text_like( qr/Ticket \d+ created in queue/, 'ticket is created' );
 -$m->content_contains( "Download $plain_name", 'download plain file link' );
 -$m->follow_link_ok( { text => 'Reply', url_regex => qr/QuoteTransaction=/ }, 'reply the create transaction' );
++ok( $m->find_link( text => $plain_name ), 'download plain file link' );
++$m->follow_link_ok( { url_regex => qr/QuoteTransaction=/ }, 'reply the create transaction' );
+ $form    = $m->form_name( 'TicketUpdate' );
+ $content = $form->find_input( 'UpdateContent' );
+ like( $content->value, qr/This transaction appears to have no content/, 'no transaction content' );
+ 
+ done_testing;

-----------------------------------------------------------------------


More information about the rt-commit mailing list