Compare commits

..

862 Commits

Author SHA1 Message Date
RitvikSardana
f8ea7765a0 Merge pull request #2989 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-08 01:05:11 +05:30
RitvikSardana
a931adcb3d Merge pull request #2985 from frappe/mergify/bp/main-hotfix/pr-2973
fix: added support for mail with display names and latin characters (backport #2973)
2026-02-07 19:46:49 +05:30
RitvikSardana
101f5214f3 Merge pull request #2984 from frappe/mergify/bp/main-hotfix/pr-2977
chore: sync translations from crowdin (backport #2977)
2026-02-07 19:46:41 +05:30
RitvikSardana
2f32852840 chore: bump version 2026-02-07 19:38:39 +05:30
RitvikSardana
1efeb5f7f9 fix: remove canned response from sidebar 2026-02-07 19:36:52 +05:30
Sydney
42fce2b60e fix: add deboubnce and use zod validation for email
(cherry picked from commit 3b5b2ce9c6)
2026-02-06 09:08:13 +00:00
Sydney
ba8f819eb4 fix: fixed mail backspace bug and added support for multiinput for normalization
(cherry picked from commit d00431f51c)
2026-02-06 09:08:12 +00:00
Sydney
14a2dff554 fix: add regex support for latin characters and input support for MultiInput for mail ids
(cherry picked from commit 257c12e79f)
2026-02-06 09:08:12 +00:00
Sydney
5fcdd736f1 fix: add support for display names in mail
(cherry picked from commit 233b935393)
2026-02-06 09:08:12 +00:00
Jannat Patel
f37a70fab3 chore: Persian translations
(cherry picked from commit e6c267338c)
2026-02-06 09:03:42 +00:00
Jannat Patel
a385e9e05b chore: Portuguese, Brazilian translations
(cherry picked from commit 85a0644593)
2026-02-06 09:03:42 +00:00
Jannat Patel
1724565f7f chore: Swedish translations
(cherry picked from commit a91f4867c4)
2026-02-06 09:03:42 +00:00
Jannat Patel
e18bbd2eeb chore: Dutch translations
(cherry picked from commit 420ef47b66)
2026-02-06 09:03:41 +00:00
Jannat Patel
1ecac6270a chore: Italian translations
(cherry picked from commit cc665c838f)
2026-02-06 09:03:41 +00:00
Jannat Patel
60eb1e39fb chore: Spanish translations
(cherry picked from commit 22901fd402)
2026-02-06 09:03:41 +00:00
RitvikSardana
7ec09103da Merge pull request #2983 from frappe/mergify/bp/main-hotfix/pr-2972
fix: hide captions in text editor (backport #2972)
2026-02-06 14:32:41 +05:30
RitvikSardana
0c8704b08b Merge pull request #2982 from frappe/mergify/bp/main-hotfix/pr-2981
fix: sprint fixes-1 (backport #2981)
2026-02-06 14:32:32 +05:30
RitvikSardana
b05cd37cd3 Merge pull request #2968 from frappe/mergify/bp/main-hotfix/pr-2939
chore: sync translations from crowdin (backport #2939)
2026-02-06 14:25:54 +05:30
RitvikSardana
abc20dd161 Merge pull request #2976 from frappe/mergify/bp/main-hotfix/pr-2956
chore(setup): Add build info in pyproject (backport #2956)
2026-02-06 14:25:01 +05:30
RitvikSardana
39f0545c8f chore: fix merge conflicts 2026-02-06 14:17:59 +05:30
Sydney
4565a76e7c chore: disable image captions all over
(cherry picked from commit 64f94848ea)
2026-02-06 08:44:31 +00:00
Sydney
5dea5e3497 fix: disable input for image captions in textinput
(cherry picked from commit 23571113f6)
2026-02-06 08:44:31 +00:00
Ritvik Sardana
933fe94eac chore: add type hints to api
(cherry picked from commit 4c73593c8a)
2026-02-06 08:44:19 +00:00
Ritvik Sardana
cd6050f5f1 fix: persist count in LV
(cherry picked from commit 4c594535a6)
2026-02-06 08:44:19 +00:00
Ritvik Sardana
81322323c4 fix: add seen functionality in list view
(cherry picked from commit 5a4f152d52)
2026-02-06 08:44:18 +00:00
Ritvik Sardana
54414613ee fix(search): prepare doc if field exists
(cherry picked from commit a7135bc17b)
2026-02-06 08:44:18 +00:00
Ritvik Sardana
3713cd6b2c fix: persist rows count in list view
(cherry picked from commit e92963357f)
2026-02-06 08:44:18 +00:00
Aradhya-Tripathi
3d44b09851 chore(build): Setup out dir and index from project root
(cherry picked from commit a76ff09d6e)

# Conflicts:
#	pyproject.toml
2026-02-05 05:59:27 +00:00
Aradhya-Tripathi
6de40f928b chore(setup): Add build info in pyproject
(cherry picked from commit ce4c738898)

# Conflicts:
#	pyproject.toml
2026-02-05 05:59:26 +00:00
RitvikSardana
b90f20ee21 chore: Arabic translations
(cherry picked from commit 29ca9701c2)
2026-02-02 16:14:56 +00:00
RitvikSardana
f112dc08df chore: Dutch translations
(cherry picked from commit 09552d7e90)
2026-02-02 16:14:55 +00:00
RitvikSardana
637f3879db Merge pull request #2965 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-02 14:07:51 +05:30
RitvikSardana
e156154855 Merge pull request #2962 from frappe/mergify/bp/main-hotfix/pr-2961
fix: add signup auth and redirect route perm (backport #2961)
2026-02-02 13:56:11 +05:30
RitvikSardana
18a2e02525 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2961 2026-02-02 13:49:58 +05:30
RitvikSardana
bd56224691 Merge pull request #2944 from frappe/mergify/bp/main-hotfix/pr-2943
fix: add OAuth2 paths (backport #2943)
2026-02-02 13:49:33 +05:30
RitvikSardana
ed3e27c8c5 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2943 2026-02-02 13:43:13 +05:30
RitvikSardana
fa3bcf27ae Merge pull request #2964 from frappe/mergify/bp/main-hotfix/pr-2941
fix: custom login auth path (backport #2941)
2026-02-02 13:41:36 +05:30
Ritvik Sardana
bead00ad68 fix: custom login auth path
(cherry picked from commit 2dc26b378d)
2026-02-02 08:09:46 +00:00
RitvikSardana
83f4d35ad0 Merge pull request #2963 from frappe/mergify/bp/main-hotfix/pr-2933
fix(security): limit auth hooks (backport #2933)
2026-02-02 13:38:57 +05:30
Ritvik Sardana
4434c20716 fix(security): limit auth hooks
(cherry picked from commit a7596090c4)
2026-02-02 08:08:17 +00:00
Ritvik Sardana
d6d7c1f5dd fix: version bump
(cherry picked from commit a3116603c7)
2026-02-02 08:05:23 +00:00
Ritvik Sardana
94ce44ad02 fix: version bump
(cherry picked from commit 1ce95fcab5)
2026-02-02 08:05:23 +00:00
Ritvik Sardana
2aa9533d26 fix: add signup auth and redirect route perm
(cherry picked from commit b625a95e22)

# Conflicts:
#	helpdesk/auth.py
2026-02-02 08:05:22 +00:00
Shariq Ansari
c9b2fc1291 Merge pull request #2953 from frappe/mergify/bp/main/pr-2951
fix: posthog optional chaining added to fix delete button bug (backport #2951)
2026-01-28 21:01:21 +05:30
Shariq Ansari
862bb94cbc Merge pull request #2952 from frappe/mergify/bp/main-hotfix/pr-2951
fix: posthog optional chaining added to fix delete button bug (backport #2951)
2026-01-28 20:58:33 +05:30
Sydney
ac504d84ff fix: posthog optional chaining added to fix close button bug
(cherry picked from commit 82d3751f80)
2026-01-28 15:27:01 +00:00
Sydney
6e4545a058 fix: posthog optional chaining added to fix close button bug
(cherry picked from commit 82d3751f80)
2026-01-28 15:24:24 +00:00
Ritvik Sardana
060338d3c4 fix: add OAuth2 paths
(cherry picked from commit a94013e8d6)

# Conflicts:
#	helpdesk/auth.py
2026-01-26 07:46:10 +00:00
RitvikSardana
41abffdbbd Merge pull request #2937 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-23 16:57:44 +05:30
RitvikSardana
95825dc9f6 chore: version bump 2026-01-23 16:57:20 +05:30
RitvikSardana
3974b153a8 Merge pull request #2935 from frappe/mergify/bp/main-hotfix/pr-2934
fix: fail install (backport #2934)
2026-01-23 16:56:06 +05:30
RitvikSardana
45273dabe8 Merge pull request #2936 from RitvikSardana/email-box-width
fix: email box height
2026-01-23 16:54:46 +05:30
Ritvik Sardana
e33e9203ed fix: email box height 2026-01-23 16:54:02 +05:30
Ritvik Sardana
c07d5dc043 chore: code cleanup
(cherry picked from commit 402dd042ae)
2026-01-23 11:20:07 +00:00
Ritvik Sardana
ba67d869bc fix: add agent_name field while installing
(cherry picked from commit 012b6b91ea)
2026-01-23 11:20:07 +00:00
RitvikSardana
28e909a9e0 Merge pull request #2931 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-23 13:00:11 +05:30
RitvikSardana
1ebe1503c4 Merge pull request #2920 from frappe/mergify/bp/main-hotfix/pr-2880
chore: sync translations from crowdin (backport #2880)
2026-01-23 12:48:53 +05:30
RitvikSardana
4859978e0a Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2880 2026-01-23 12:48:44 +05:30
RitvikSardana
dc376d7e09 chore: version bump 2026-01-23 12:45:08 +05:30
RitvikSardana
eb605fd7b6 Merge pull request #2928 from frappe/mergify/bp/main-hotfix/pr-2922
chore: sync translations from crowdin (backport #2922)
2026-01-23 12:16:12 +05:30
RitvikSardana
22ce271aa1 chore: Bosnian translations
(cherry picked from commit e4372f72cf)
2026-01-22 18:11:20 +00:00
RitvikSardana
cc64bc1723 chore: Swedish translations
(cherry picked from commit aa70598d7a)
2026-01-22 18:11:20 +00:00
RitvikSardana
3d5692cdc6 chore: Dutch translations
(cherry picked from commit 2c4ec37fb1)
2026-01-22 18:11:20 +00:00
RitvikSardana
a989dc1af4 chore: Norwegian Bokmal translations
(cherry picked from commit 774b9073a1)
2026-01-22 18:11:20 +00:00
RitvikSardana
d61b38c674 chore: Bosnian translations
(cherry picked from commit 41fd854f2d)
2026-01-22 18:11:19 +00:00
RitvikSardana
5d51ccd525 chore: Croatian translations
(cherry picked from commit 78282416d1)
2026-01-22 18:11:19 +00:00
RitvikSardana
0ad7fdaa8d chore: Portuguese, Brazilian translations
(cherry picked from commit 3da40a1fed)
2026-01-22 18:11:19 +00:00
RitvikSardana
3aa0ce46ef chore: Vietnamese translations
(cherry picked from commit c968fdf0f3)
2026-01-22 18:11:19 +00:00
RitvikSardana
5032dc611a chore: Chinese Simplified translations
(cherry picked from commit 52c01e93e9)
2026-01-22 18:11:18 +00:00
RitvikSardana
244838fcef chore: Turkish translations
(cherry picked from commit 4b7a065bae)
2026-01-22 18:11:18 +00:00
RitvikSardana
7eecb4c173 chore: Swedish translations
(cherry picked from commit 7932b40420)
2026-01-22 18:11:18 +00:00
RitvikSardana
488956db07 chore: Slovenian translations
(cherry picked from commit 66b50e3799)
2026-01-22 18:11:18 +00:00
RitvikSardana
9367660af3 chore: Portuguese translations
(cherry picked from commit 71780d5e04)
2026-01-22 18:11:17 +00:00
RitvikSardana
a878780786 chore: Polish translations
(cherry picked from commit 45bf04d592)
2026-01-22 18:11:17 +00:00
RitvikSardana
bac49e41a3 chore: Italian translations
(cherry picked from commit 6f161fd717)
2026-01-22 18:11:17 +00:00
RitvikSardana
84ac4c2f48 chore: German translations
(cherry picked from commit 0ca91888d2)
2026-01-22 18:11:17 +00:00
RitvikSardana
cce5a30496 chore: Arabic translations
(cherry picked from commit c18eb9f361)
2026-01-22 18:11:16 +00:00
RitvikSardana
d44ffe4b24 chore: Spanish translations
(cherry picked from commit f9b2b423af)
2026-01-22 18:11:16 +00:00
RitvikSardana
8b34a4a9e2 chore: French translations
(cherry picked from commit 57d00bb726)

# Conflicts:
#	helpdesk/locale/fr.po
2026-01-22 18:11:16 +00:00
RitvikSardana
8b8c92f5bb chore: Indonesian translations
(cherry picked from commit e60cedd170)
2026-01-22 18:11:15 +00:00
RitvikSardana
e124cb2dcf chore: Russian translations
(cherry picked from commit 1dbec24253)
2026-01-22 18:11:15 +00:00
RitvikSardana
95355c67c4 chore: Persian translations
(cherry picked from commit dc1bb0596a)
2026-01-22 18:11:15 +00:00
RitvikSardana
e945f118a7 chore: Dutch translations
(cherry picked from commit cc9a1638f6)
2026-01-22 18:11:15 +00:00
RitvikSardana
7f8d3be345 chore: Hungarian translations
(cherry picked from commit 9447c8ccd4)
2026-01-22 18:11:14 +00:00
RitvikSardana
93cef0c509 Merge pull request #2924 from frappe/mergify/bp/main-hotfix/pr-2923
fix(patch): rename canned response (backport #2923)
2026-01-22 23:29:27 +05:30
Pratik
c23e8b001e fix: rename canned response patch
(cherry picked from commit 1888f24f46)
2026-01-22 10:14:39 +00:00
RitvikSardana
ca6ae4cdf4 chore: French translations
(cherry picked from commit 79943278e9)
2026-01-20 11:37:59 +00:00
RitvikSardana
54261e3c3e Merge pull request #2919 from frappe/mergify/bp/main-hotfix/pr-2914
fix(HD Agent): make fields mandatory (backport #2914)
2026-01-20 10:42:04 +05:30
Ritvik Sardana
76edb52a1d chore: code cleanup
(cherry picked from commit 8b765ba34d)
2026-01-20 04:51:39 +00:00
Ritvik Sardana
e9aa895159 test: add agent_name field in test utils
(cherry picked from commit 6944796c8e)
2026-01-20 04:51:39 +00:00
Ritvik Sardana
42b1a7862c test: add agent_name field in test utils
(cherry picked from commit 04d289bb95)
2026-01-20 04:51:39 +00:00
Ritvik Sardana
67c59d26de fix(HD Agent): make fields mandatory
(cherry picked from commit c34dfc18ac)
2026-01-20 04:51:39 +00:00
RitvikSardana
07fa11fa87 Merge pull request #2917 from frappe/mergify/bp/main-hotfix/pr-2913
fix: update yarn lock in frontend folder (backport #2913)
2026-01-20 00:41:22 +05:30
RitvikSardana
8b1fb35730 chore: resolve conflicts 2026-01-20 00:32:39 +05:30
Ritvik Sardana
346275d1b1 fix: update yarn lock in frontend folder
(cherry picked from commit b784ea807f)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-01-19 18:59:28 +00:00
RitvikSardana
5a271df560 Merge pull request #2916 from frappe/mergify/bp/main-hotfix/pr-2912
fix: update lock file (backport #2912)
2026-01-20 00:28:13 +05:30
Ritvik Sardana
beefbdce11 fix: update lock file
(cherry picked from commit 6d6d3498cf)
2026-01-19 18:57:24 +00:00
RitvikSardana
1770545fc0 Merge pull request #2915 from frappe/mergify/bp/main-hotfix/pr-2910
fix(reactivity): mentions dropdown (backport #2910)
2026-01-20 00:24:00 +05:30
Ritvik Sardana
9336363968 fix(reactivity): mentions dropdown
(cherry picked from commit 53fe99dc8a)
2026-01-19 18:53:24 +00:00
RitvikSardana
8241dc83e7 Merge pull request #2909 from frappe/revert-2906-mergify/bp/main-hotfix/pr-2905
Revert "fix: email scroll & notifications (backport #2905)"
2026-01-19 15:18:51 +05:30
RitvikSardana
ab9f3bc818 Revert "fix: email scroll & notifications (backport #2905)" 2026-01-19 14:03:47 +05:30
RitvikSardana
8d3b676c03 Merge pull request #2906 from frappe/mergify/bp/main-hotfix/pr-2905
fix: email scroll & notifications (backport #2905)
2026-01-19 13:06:40 +05:30
Ritvik Sardana
96911c65d3 fix: render notifications properly
(cherry picked from commit c696a595dd)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-01-19 05:33:06 +00:00
Ritvik Sardana
b43107add2 fix(EmailBox): load content properly
(cherry picked from commit f4339b51e1)
2026-01-19 05:33:06 +00:00
RitvikSardana
0f62199ae7 Merge pull request #2899 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-16 12:25:09 +05:30
RitvikSardana
aad1a0705d Merge branch 'main' into main-hotfix 2026-01-16 12:12:53 +05:30
RitvikSardana
db8ac693e2 Merge pull request #2889 from frappe/mergify/bp/main-hotfix/pr-2854
fix: saved reply renaming (backport #2854)
2026-01-16 12:11:36 +05:30
RitvikSardana
0408360716 Merge pull request #2898 from frappe/mergify/bp/main-hotfix/pr-2897
test(saved-reply): revert user to admin (backport #2897)
2026-01-16 12:11:19 +05:30
Ritvik Sardana
7ab0cabc1e test(saved-reply): revert user to admin
(cherry picked from commit 6c9d66e06b)
2026-01-16 06:41:02 +00:00
RitvikSardana
25f5c06b70 chore: version bump 2026-01-16 11:54:32 +05:30
RitvikSardana
5dd7bd9513 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2854 2026-01-16 11:52:41 +05:30
RitvikSardana
bc9d51960c chore: handle versioning properly 2026-01-16 10:47:00 +05:30
RitvikSardana
29a3d410b5 Merge pull request #2896 from RitvikSardana/fix-tests
fix: test cases for holidays
2026-01-16 10:22:48 +05:30
RitvikSardana
b4f4078427 Merge pull request #2888 from pratikb64/backport-2708
refactor: move canned responses into settings modal (backport #2708)
2026-01-16 10:22:33 +05:30
Ritvik Sardana
890f27cbb9 fix: test cases for holidays 2026-01-16 10:17:26 +05:30
RitvikSardana
bbd95dafa1 Merge pull request #2892 from frappe/mergify/bp/main-hotfix/pr-2891
fix(dashboard): order by count (backport #2891)
2026-01-15 15:37:21 +05:30
Ritvik Sardana
286c196ba7 fix(dashboard): order by count
(cherry picked from commit 44877bd0e8)
2026-01-15 10:06:55 +00:00
Pratik
eac1280adc fix: saved reply renaming
(cherry picked from commit 819c28a805)

# Conflicts:
#	desk/src/components/Settings/SavedReplies/SavedReplyView.vue
#	helpdesk/helpdesk/doctype/hd_saved_reply/hd_saved_reply.json
2026-01-15 05:43:17 +00:00
Pratik
be7e4f417c fix: CI fix 2026-01-15 04:18:32 +00:00
Pratik
c4d9a25c03 fix: ci lint error 2026-01-14 17:27:01 +00:00
Ritvik Sardana
2e749fc76a fix: add list count 2026-01-14 17:26:42 +00:00
Pratik
e65507e1b9 chore: add translation 2026-01-14 17:24:50 +00:00
Pratik
5042fc718d chore: add translation 2026-01-14 17:24:50 +00:00
Pratik
696802e65b fix: avatar label & loading state in reply selector modal 2026-01-14 17:24:50 +00:00
Ritvik Sardana
857fd3df32 fix(naming): use title field for naming in Saved Reply 2026-01-14 17:24:50 +00:00
Pratik
d7f4fa7711 fix: focus on reply selector modal open 2026-01-14 17:24:48 +00:00
Pratik
81b3757a17 fix: tiptap extension suggestion search 2026-01-14 17:24:12 +00:00
Pratik
0fd4178ef3 test: add test for editing personal reply 2026-01-14 17:23:35 +00:00
Pratik
73377712d3 test: add tests 2026-01-14 17:23:32 +00:00
Pratik
07737b1609 fix: loading indicator fix 2026-01-14 17:22:55 +00:00
Pratik
3effc56d84 fix: minor conditional logic fix 2026-01-14 17:22:55 +00:00
Pratik
1ca7e5c41b refactor: rename canned response to saved reply 2026-01-14 17:22:52 +00:00
Pratik
7414b40d0f fix: bug in saved reply select modal 2026-01-14 17:21:21 +00:00
Pratik
975fea9f93 fix: code cleanup & refactor patches 2026-01-14 17:21:20 +00:00
Pratik
3ac62b1583 refactor: add & use HD Saved Reply doctype 2026-01-14 17:19:55 +00:00
Pratik
5695f86e16 refactor: change file name 2026-01-14 17:19:55 +00:00
Pratik
62b51d8383 fix: add icons in scope select 2026-01-14 17:19:55 +00:00
Pratik
674fdc1194 fix: minor enhancements 2026-01-14 17:19:55 +00:00
Pratik
4252657d17 refactor: rename canned response to saved reply 2026-01-14 17:19:53 +00:00
Pratik
9dccf0c352 fix: improve toast error message in saved replies preview dialog 2026-01-14 17:19:11 +00:00
Pratik
1d2e4bdaa2 fix: improve patch logic 2026-01-14 17:19:11 +00:00
Pratik
9b2c988162 fix: owner field not getting set in patch 2026-01-14 17:19:11 +00:00
Pratik
31ef62dd96 fix: patch order 2026-01-14 17:19:08 +00:00
Pratik
7fa910dee6 fix: add patch for email template fields 2026-01-14 17:18:11 +00:00
Pratik
756618a23b fix: code cleanup & enhancements 2026-01-14 17:18:09 +00:00
Pratik
7fe8d62a6c fix: code cleanup 2026-01-14 17:17:39 +00:00
Pratik
c9d6574acf refactor: move tiptap extension 2026-01-14 17:17:39 +00:00
Pratik
8550ddb52c fix: modal permission & small code cleanup 2026-01-14 17:17:37 +00:00
Pratik
1f54124b45 feat: add ticket based restriction logic 2026-01-14 17:15:01 +00:00
Pratik Badhe
562e34e38b chore: code cleanup 2026-01-14 17:13:08 +00:00
Pratik Badhe
1fd8a17305 feat: add load more button 2026-01-14 17:12:34 +00:00
Pratik Badhe
ee9b70260b fix: handle case when user does not have team assigned 2026-01-14 17:12:34 +00:00
Pratik
6b91cc456e feat: add scope & improve UI 2026-01-14 17:12:32 +00:00
Pratik Badhe
aa214a752a fix: remove subject & add toolbar for response editor 2026-01-14 17:11:55 +00:00
Pratik Badhe
377582a48a chore: add i18n 2026-01-14 17:11:55 +00:00
Pratik Badhe
6dd1779049 refactor: move canned responses into settings modal 2026-01-14 17:11:47 +00:00
Ritvik Sardana
eb69aef769 fix: support frappe v16 2026-01-14 18:27:04 +05:30
RitvikSardana
b414902d7e Merge pull request #2885 from frappe/mergify/bp/main-hotfix/pr-2884
fix: sanitize search query (backport #2884)
2026-01-14 17:09:05 +05:30
RitvikSardana
4eb2d9fe49 Merge pull request #2887 from frappe/mergify/bp/main-hotfix/pr-2886
fix: better support for v16 (backport #2886)
2026-01-14 17:08:55 +05:30
RitvikSardana
27fcf2f0da chore: code cleanup 2026-01-14 17:07:33 +05:30
RitvikSardana
591092e3ed chore: code cleanup 2026-01-14 17:05:09 +05:30
Ritvik Sardana
2a9c9f457e fix: better support for v16
(cherry picked from commit 3b85a3b269)

# Conflicts:
#	helpdesk/test_utils.py
2026-01-14 11:28:31 +00:00
Ritvik Sardana
fae2f361a9 fix: sanitize search query
(cherry picked from commit 5182126876)
2026-01-14 11:14:18 +00:00
RitvikSardana
b02e9a3849 Merge pull request #2883 from frappe/mergify/bp/main-hotfix/pr-2878
test: add tests for ticket split and merge (backport #2878)
2026-01-14 16:41:52 +05:30
RitvikSardana
8ac3f805a5 Merge pull request #2882 from frappe/mergify/bp/main-hotfix/pr-2881
fix: minor UI fixes (backport #2881)
2026-01-14 16:41:39 +05:30
Ritvik Sardana
8ab6848f48 chore: formatting 2026-01-14 16:30:41 +05:30
RitvikSardana
21da9cb643 chore: merge conflict 2026-01-14 16:28:06 +05:30
RitvikSardana
9606e343c4 chore: formatting 2026-01-14 16:25:09 +05:30
RitvikSardana
a7f62a23fb fix: merge conflict 2026-01-14 13:55:56 +05:30
RitvikSardana
e762652860 Merge pull request #2876 from frappe/mergify/bp/main-hotfix/pr-2859
chore: sync translations from crowdin (backport #2859)
2026-01-14 13:52:31 +05:30
Ritvik Sardana
5ccfd82895 fix(tests): add email domain
(cherry picked from commit 1515970c39)
2026-01-14 07:32:00 +00:00
Ritvik Sardana
95d5ad1784 test: add tests for ticket split and merge
(cherry picked from commit b652cb9c65)

# Conflicts:
#	helpdesk/test_utils.py
2026-01-14 07:32:00 +00:00
Ritvik Sardana
09feebce83 chore: code cleanup
(cherry picked from commit 1ce75aa717)
2026-01-14 07:31:59 +00:00
Ritvik Sardana
8a394aeec5 fix: loading state in editor
(cherry picked from commit 6bd424ff83)

# Conflicts:
#	desk/src/components/EmailEditor.vue
2026-01-14 07:31:59 +00:00
Ritvik Sardana
a7af289133 fix: minor style changes
(cherry picked from commit 9602681745)
2026-01-14 07:31:58 +00:00
Ritvik Sardana
eb341e9f58 fix: order_by in ticket_summary report
(cherry picked from commit b5ff021b86)
2026-01-14 07:31:58 +00:00
RitvikSardana
defe28fdc0 Merge pull request #2879 from RitvikSardana/dayjs-import
fix: add remaining imports
2026-01-14 12:15:29 +05:30
Ritvik Sardana
fafd0d0bcd fix: add remaining imports 2026-01-13 19:20:34 +05:30
RitvikSardana
36b359326e chore: Indonesian translations
(cherry picked from commit f2f195e855)
2026-01-13 10:43:06 +00:00
RitvikSardana
543a3fae67 chore: Russian translations
(cherry picked from commit 3fe45bcf7e)
2026-01-13 10:43:05 +00:00
RitvikSardana
e3afef5dcd chore: Hungarian translations
(cherry picked from commit b8a4803824)
2026-01-13 10:43:05 +00:00
RitvikSardana
cc5b4e35fe chore: Dutch translations
(cherry picked from commit 5d8a7ba05c)
2026-01-13 10:43:05 +00:00
RitvikSardana
8727f3075b chore: Hungarian translations
(cherry picked from commit 4004daf191)
2026-01-13 10:43:05 +00:00
RitvikSardana
8304de0e69 Merge pull request #2875 from frappe/mergify/bp/main-hotfix/pr-2874
chore: update readme (backport #2874)
2026-01-13 10:44:36 +05:30
Ritvik Sardana
b780ad4253 chore: update readme
(cherry picked from commit 03fe225df7)
2026-01-13 05:07:55 +00:00
RitvikSardana
d2aa02d0d0 Merge pull request #2868 from frappe/mergify/bp/main-hotfix/pr-2867
fix: rendering @mention in comments (backport #2867)
2026-01-13 09:52:20 +05:30
Vivek Agrawal
34e07f907b fix: rendering @mention in comments
(cherry picked from commit d8228382b0)
2026-01-09 12:12:19 +00:00
RitvikSardana
a3b8bf26c2 Merge pull request #2864 from frappe/mergify/bp/main-hotfix/pr-2863
fix: dont run onClick twice (backport #2863)
2026-01-08 00:06:22 +05:30
RitvikSardana
b008091137 fix: revert translation changes
(cherry picked from commit ec82ee91a9)
2026-01-06 18:54:36 +00:00
Ritvik Sardana
e3587c09e7 fix: dont run onClick twice
(cherry picked from commit 4e1e261a51)
2026-01-06 18:54:36 +00:00
RitvikSardana
09a174f190 chore: Bump version to 1.18.1 2026-01-02 13:05:12 +05:30
RitvikSardana
e09116a523 chore: Update frappe dependency version range 2026-01-02 12:46:13 +05:30
RitvikSardana
f77349faea Merge pull request #2855 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-01 23:44:26 +05:30
RitvikSardana
29d503090f chore: replace IntegrationTestCase with FrappeTestCase 2026-01-01 23:38:36 +05:30
RitvikSardana
b850d733f7 chore: update Frappe branch in install script 2026-01-01 21:49:33 +05:30
RitvikSardana
185442d153 chore: Bump version from 1.17.4 to 1.18.0 2026-01-01 21:26:41 +05:30
RitvikSardana
c56de5904b Merge pull request #2849 from frappe/mergify/bp/main-hotfix/pr-2836
feat(i18n): Add translation wrappers to Vue components (backport #2836)
2026-01-01 21:24:30 +05:30
RitvikSardana
88afe2f413 fix: add translation strings for sidebar label
(cherry picked from commit 4532c42f64)
2025-12-30 21:53:33 +00:00
Jérémy Christillin
6a8447a882 style: apply prettier formatting
(cherry picked from commit 58dfc6d95d)
2025-12-30 21:53:33 +00:00
Jérémy Christillin
253afdc16c feat(i18n): Add French translations for new i18n strings
Add 40+ French translations for settings, SLA, and workflow strings.

(cherry picked from commit 838ffcdf74)
2025-12-30 21:53:32 +00:00
Jérémy Christillin
ce17e05a70 feat(i18n): Add translation wrappers to Vue components
Add __() translation function wrappers to hardcoded strings in Vue/TypeScript
components to enable internationalization support.

Files modified:
- ListViewBuilder, SearchMultiSelect, FilterPopover components
- Settings: NewTeamModal, ExportModal
- Ticket views: TicketAgent, MobileTicketAgent, TicketCustomer, TicketAgentSidebar
- Knowledge Base: Article, NewArticle, KnowledgeBase views
- Contact: ContactDialog, SetContactPhoneModal
- Notifications: MobileNotifications
- Activity: ActivityHeader, CommunicationArea
- Customer: NewCustomerDialog

This enables proper translation of UI strings through PO files.

(cherry picked from commit f6db2f72d7)
2025-12-30 21:53:32 +00:00
RitvikSardana
1b52048b8f Merge pull request #2848 from frappe/mergify/bp/main-hotfix/pr-2839
fix(workspace): helpdesk reports routing (backport #2839)
2025-12-31 03:05:53 +05:30
RitvikSardana
010938804a Merge pull request #2847 from frappe/mergify/bp/main-hotfix/pr-2842
fix: last_synced_at to last_received_at in email sync (backport #2842)
2025-12-31 03:05:39 +05:30
KerollesFathy
1cda760798 fix(workspace): helpdesk reports routing
(cherry picked from commit c395c8b67a)
2025-12-30 21:34:28 +00:00
KerollesFathy
4e5ca82963 revert: helpdesk reports routing"
This reverts commit 7f785be3b8.

(cherry picked from commit ae97eafff5)
2025-12-30 21:34:27 +00:00
KerollesFathy
2854c573dd fix(workspace): helpdesk reports routing
(cherry picked from commit 7f785be3b8)
2025-12-30 21:34:27 +00:00
Elnegren
ddd380d2b4 fix: last_synced_at to last_received_at in email sync
(cherry picked from commit 7432d3e9d3)
2025-12-30 21:33:34 +00:00
RitvikSardana
6f381d745e Merge pull request #2845 from frappe/mergify/bp/main-hotfix/pr-2837
chore: update POT file (backport #2837)
2025-12-30 12:59:56 +05:30
RitvikSardana
b18b81c22e Merge pull request #2844 from frappe/mergify/bp/main-hotfix/pr-2833
chore: sync translations from crowdin (backport #2833)
2025-12-30 12:59:46 +05:30
frappe-pr-bot
3bdda78513 chore: update POT file
(cherry picked from commit 63d4442a54)
2025-12-30 07:25:09 +00:00
RitvikSardana
f007a1be29 chore: Persian translations
(cherry picked from commit 1218e9219a)
2025-12-30 07:15:49 +00:00
RitvikSardana
ee387164d4 chore: Spanish translations
(cherry picked from commit 930e8ec184)
2025-12-30 07:15:49 +00:00
RitvikSardana
65ce2c1333 chore: Persian translations
(cherry picked from commit 847e485fb4)
2025-12-30 07:15:49 +00:00
RitvikSardana
65467272a8 chore: Hungarian translations
(cherry picked from commit 2adbe1cdba)
2025-12-30 07:15:49 +00:00
RitvikSardana
6410ced280 chore: Hungarian translations
(cherry picked from commit 5eddc89075)
2025-12-30 07:15:48 +00:00
RitvikSardana
cc8bde3091 chore: Spanish translations
(cherry picked from commit d0c6e5167a)
2025-12-30 07:15:48 +00:00
RitvikSardana
e4316de66e Merge pull request #2801 from frappe/mergify/bp/main-hotfix/pr-2794
fix: bugs related to frappe-ui update (backport #2794)
2025-12-18 17:25:50 +05:30
Pratik Badhe
7930000998 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2794 2025-12-18 11:32:23 +05:30
RitvikSardana
f3725e53ac Merge pull request #2832 from pratikb64/backport-2664
feat: add profile, general settings page & search bars for lists (backport #2664)
2025-12-17 13:41:06 +05:30
Ritvik Sardana
0ceda51542 chore(list-view): dont show border-b for last element 2025-12-15 11:18:12 +00:00
Pratik
669812d6f9 fix: hover state 2025-12-15 11:18:12 +00:00
Pratik
c19b54df7f fix: agents list filter hover state 2025-12-15 11:18:12 +00:00
Pratik
378214e22c chore: code cleanup 2025-12-15 11:17:37 +00:00
Pratik
5bd3ff389f chore: fix description 2025-12-15 11:17:37 +00:00
Pratik
451772506d fix: dropdown bug & labels 2025-12-15 11:17:37 +00:00
Pratik
8c4b10f567 fix: spacing in agents list view dropdowns 2025-12-15 11:17:37 +00:00
Pratik
1e16241217 chore: add Dropdown component imports 2025-12-15 11:17:37 +00:00
Pratik
399c3f89cb fix: button label & use symbol for inject/provide 2025-12-15 11:17:34 +00:00
Pratik
24089711d2 fix: assignee search bug 2025-12-15 11:16:42 +00:00
Pratik
d079147540 feat: add language setting 2025-12-15 11:15:56 +00:00
Pratik
03a7b5199c fix: ui cleanup & move SettingsLayoutBase component 2025-12-15 11:10:35 +00:00
Pratik Badhe
d8c95bb39a fix: remove i18n for week days 2025-12-15 11:10:35 +00:00
Pratik Badhe
c9300824f0 fix: ui alignment 2025-12-15 11:10:35 +00:00
Pratik Badhe
8c1777f512 fix: order list view's by modified desc 2025-12-15 11:10:35 +00:00
Pratik Badhe
9ad9bea8ac feat: add teams agents selector 2025-12-15 11:10:35 +00:00
Pratik Badhe
b6e480501e ci: remove frappe db commit 2025-12-15 11:10:35 +00:00
Pratik Badhe
cc18255dc9 fix: CI lint error 2025-12-15 11:10:35 +00:00
Pratik Badhe
4f482faf58 refactor: add common settings layout base component 2025-12-15 11:10:32 +00:00
Pratik Badhe
3478361dcb chore: add i18n and code cleanup 2025-12-15 10:58:27 +00:00
Pratik Badhe
cbcd1d96ff fix: improve telephony dirty state logic 2025-12-15 10:55:45 +00:00
Pratik Badhe
f79cd726be fix: favicon icon 2025-12-15 10:55:45 +00:00
Pratik Badhe
950e23983c refactor: remove some setting options & add i18n 2025-12-15 10:55:45 +00:00
Pratik Badhe
e2bbd830ed fix: semgrep lint fix 2025-12-15 10:55:45 +00:00
Pratik Badhe
457d6e1f9c fix: semgrep lint fix 2025-12-15 10:55:45 +00:00
Pratik Badhe
6c54da1d1e refactor: improve assignment rule invite agent handling 2025-12-15 10:55:37 +00:00
Pratik Badhe
5f73ea65c7 fix: internationalization & state re-rendering 2025-12-15 10:52:18 +00:00
Pratik Badhe
a7e5f65422 fix: agent flow test 2025-12-15 10:52:18 +00:00
Pratik Badhe
f05afc8465 fix: improve team edit 2025-12-15 10:52:18 +00:00
Pratik Badhe
a57e2b5faf fix: add empty state for team edit page 2025-12-15 10:52:18 +00:00
Pratik Badhe
3baff61473 fix: semgrep CI error 2025-12-15 10:52:18 +00:00
Pratik Badhe
6ff72d7c33 fix: horizontal line conditional rendering in general settings 2025-12-15 10:52:18 +00:00
Pratik Badhe
afd6b97dd5 fix: import paths 2025-12-15 10:52:18 +00:00
Pratik Badhe
748fef89af fix: layouts & alignments 2025-12-15 10:51:41 +00:00
Pratik Badhe
daeb72b474 fix: improve logo upload functionality 2025-12-15 10:51:22 +00:00
Pratik Badhe
ce8fce52d9 fix: add unsaved changes badge in profile 2025-12-15 10:51:22 +00:00
Pratik Badhe
b2126a4606 fix: handle roles for settings modal tabs 2025-12-15 10:51:22 +00:00
Pratik Badhe
f1663e1edc fix: profile image upload loading state 2025-12-15 10:51:22 +00:00
Pratik Badhe
b7c894cea3 fix: hide search bar when items are less than 10 2025-12-15 10:51:17 +00:00
Pratik Badhe
82b14dd378 fix: assignment rule new button label 2025-12-15 10:47:12 +00:00
Pratik Badhe
97c55258d3 fix: logo upload disable state 2025-12-15 10:47:12 +00:00
Pratik Badhe
c3d411a854 fix: empty state icon alignment & brand logo, favicon edge case 2025-12-15 10:47:12 +00:00
Pratik Badhe
b78c0a63d6 fix: agents dropdown placement 2025-12-15 10:47:12 +00:00
Pratik Badhe
434a5fee9f fix: padding & spacing 2025-12-15 10:47:12 +00:00
Pratik Badhe
d53bb65e3e fix: agent name while creating new agent 2025-12-15 10:47:12 +00:00
Pratik Badhe
d1f399f7d7 fix: add email account empty state & fix agent before_save hook 2025-12-15 10:47:12 +00:00
Pratik Badhe
49a66176a2 fix: handle case where agent_name is not available 2025-12-15 10:47:12 +00:00
Pratik Badhe
804b04c17b feat: add profile, general settings page & search bars for lists 2025-12-15 10:47:06 +00:00
RitvikSardana
6d3da9def5 Merge pull request #2831 from frappe/mergify/bp/main-hotfix/pr-2823
chore: update POT file (backport #2823)
2025-12-15 13:17:26 +05:30
RitvikSardana
760d6f49ad Merge pull request #2830 from frappe/mergify/bp/main-hotfix/pr-2816
chore: sync translations from crowdin (backport #2816)
2025-12-15 13:17:19 +05:30
RitvikSardana
58ffcf76f6 Merge pull request #2829 from frappe/mergify/bp/main-hotfix/pr-2798
fix: preserve query parameters when switching tabs (#2790) (backport #2798)
2025-12-15 13:17:09 +05:30
frappe-pr-bot
4e66acaab8 chore: update POT file
(cherry picked from commit c5b6ab3569)
2025-12-15 07:22:30 +00:00
RitvikSardana
655229ff9c chore: Russian translations
(cherry picked from commit 6cd0163e2d)
2025-12-15 07:21:38 +00:00
RitvikSardana
18bdf0791a chore: Hungarian translations
(cherry picked from commit 3189db9884)
2025-12-15 07:21:38 +00:00
RitvikSardana
0806083ed8 chore: Hungarian translations
(cherry picked from commit f2bacb6200)
2025-12-15 07:21:37 +00:00
RitvikSardana
9057611f8e chore: Hungarian translations
(cherry picked from commit 032e1b0bbe)
2025-12-15 07:21:37 +00:00
RitvikSardana
49be94bb61 chore: Serbian (Latin) translations
(cherry picked from commit a1fd77f1f3)
2025-12-15 07:21:37 +00:00
RitvikSardana
066eb8b72d chore: Serbian (Cyrillic) translations
(cherry picked from commit 39e38c27ec)
2025-12-15 07:21:37 +00:00
RitvikSardana
aff7049d2d chore: Esperanto translations
(cherry picked from commit 09e6d637dd)
2025-12-15 07:21:36 +00:00
RitvikSardana
2ddc720723 chore: Slovenian translations
(cherry picked from commit fee0b713c9)
2025-12-15 07:21:36 +00:00
RitvikSardana
b790f63bb3 chore: French translations
(cherry picked from commit d492740475)
2025-12-15 07:21:36 +00:00
RitvikSardana
653b9044b8 chore: Serbian (Latin) translations
(cherry picked from commit 211781be1c)
2025-12-15 07:21:36 +00:00
RitvikSardana
9e825f5452 chore: Norwegian Bokmal translations
(cherry picked from commit 2fc900c02d)
2025-12-15 07:21:35 +00:00
RitvikSardana
a6bd06db43 chore: Bosnian translations
(cherry picked from commit 0744eb5d03)
2025-12-15 07:21:35 +00:00
RitvikSardana
21ad46f59d chore: Burmese translations
(cherry picked from commit e3661e9836)
2025-12-15 07:21:35 +00:00
RitvikSardana
29aa40de27 chore: Croatian translations
(cherry picked from commit 13aab390b0)
2025-12-15 07:21:35 +00:00
RitvikSardana
3a77ffb593 chore: Thai translations
(cherry picked from commit c64dcea9b8)
2025-12-15 07:21:34 +00:00
RitvikSardana
d7491a72ed chore: Tamil translations
(cherry picked from commit ecb076eec8)
2025-12-15 07:21:34 +00:00
RitvikSardana
cd9a429270 chore: Persian translations
(cherry picked from commit b51a22c2b6)
2025-12-15 07:21:34 +00:00
RitvikSardana
bac9e5aaa2 chore: Indonesian translations
(cherry picked from commit 50fb1c20fc)
2025-12-15 07:21:34 +00:00
RitvikSardana
126c9c1f14 chore: Portuguese, Brazilian translations
(cherry picked from commit abd6d46963)
2025-12-15 07:21:33 +00:00
RitvikSardana
ddca7c4146 chore: Vietnamese translations
(cherry picked from commit da338ef7b0)
2025-12-15 07:21:33 +00:00
RitvikSardana
6ae0023cd8 chore: Chinese Simplified translations
(cherry picked from commit b5cee665b9)
2025-12-15 07:21:33 +00:00
RitvikSardana
c2e3551e4f chore: Turkish translations
(cherry picked from commit e3a7ea1516)
2025-12-15 07:21:33 +00:00
RitvikSardana
f308079f59 chore: Swedish translations
(cherry picked from commit 68ad2efdf9)
2025-12-15 07:21:32 +00:00
RitvikSardana
470fe5949d chore: Serbian (Cyrillic) translations
(cherry picked from commit 8acff3fc51)
2025-12-15 07:21:32 +00:00
RitvikSardana
d53412258b chore: Russian translations
(cherry picked from commit 420c33787e)
2025-12-15 07:21:32 +00:00
RitvikSardana
2f0aaa7188 chore: Portuguese translations
(cherry picked from commit 93f3504009)
2025-12-15 07:21:32 +00:00
RitvikSardana
728ec2fb33 chore: Polish translations
(cherry picked from commit 7b9ff2497f)
2025-12-15 07:21:31 +00:00
RitvikSardana
20579acf55 chore: Dutch translations
(cherry picked from commit f1ad5b8e03)
2025-12-15 07:21:31 +00:00
RitvikSardana
ba7720ff2c chore: Italian translations
(cherry picked from commit 393ad9073b)
2025-12-15 07:21:31 +00:00
RitvikSardana
c2b0e9fa59 chore: Hungarian translations
(cherry picked from commit ecc17e12e7)
2025-12-15 07:21:31 +00:00
RitvikSardana
10d3c9651a chore: German translations
(cherry picked from commit 4357bd544f)
2025-12-15 07:21:30 +00:00
RitvikSardana
2221f8c33d chore: Danish translations
(cherry picked from commit 8c74a69625)
2025-12-15 07:21:30 +00:00
RitvikSardana
47416cdf3c chore: Czech translations
(cherry picked from commit 61b971c0d1)
2025-12-15 07:21:30 +00:00
RitvikSardana
dafd1d7066 chore: Arabic translations
(cherry picked from commit b2776805f6)
2025-12-15 07:21:30 +00:00
RitvikSardana
b6893210b7 chore: Spanish translations
(cherry picked from commit 97e424c56b)
2025-12-15 07:21:29 +00:00
samarth
937d8a0909 fix: preserve query parameters when switching tabs (#2790)
Fixes #2790 - Active View is removed from the query params when the page is reloaded

When navigating to a ticket from an Active View and switching between tabs,
the query parameters (including view parameter) were being removed from the URL.

Changes:
- Updated router.replace() calls in useActiveTabManager.ts to preserve
  route.query alongside route.path
- Ensures view context is maintained during tab navigation
- Applies to changeTabTo(), setActiveTab(), and hash watch handler

This fix ensures that when users navigate to a ticket from an Active View
and switch between tabs, the view parameter remains in the URL for proper
navigation context.

(cherry picked from commit 3f01c575fb)
2025-12-15 07:18:26 +00:00
RitvikSardana
5991711a60 Merge pull request #2828 from frappe/mergify/bp/main-hotfix/pr-2827
fix: use get_list to get count (backport #2827)
2025-12-15 12:47:49 +05:30
Ritvik Sardana
0afd392c53 fix: add fallback value
(cherry picked from commit 8d847962f3)
2025-12-15 06:34:21 +00:00
Ritvik Sardana
71d517fbda chore: code cleanup
(cherry picked from commit 698659f7c4)
2025-12-15 06:34:21 +00:00
Ritvik Sardana
981bb6f4a5 fix: use get_list to get count
(cherry picked from commit 28947034ba)
2025-12-15 06:34:20 +00:00
RitvikSardana
cada6fc48f Merge pull request #2815 from frappe/mergify/bp/main-hotfix/pr-2809
chore: update POT file (backport #2809)
2025-12-09 15:55:08 +05:30
RitvikSardana
0b115a925b Merge pull request #2814 from frappe/mergify/bp/main-hotfix/pr-2805
chore: sync translations from crowdin (backport #2805)
2025-12-09 15:54:50 +05:30
frappe-pr-bot
b056b87c6d chore: update POT file
(cherry picked from commit 0d5d0e2b1d)
2025-12-09 10:21:39 +00:00
RitvikSardana
f8650059d7 chore: Russian translations
(cherry picked from commit a9e35f9b9b)
2025-12-09 10:20:33 +00:00
RitvikSardana
942fb17cec chore: Hungarian translations
(cherry picked from commit 6b44eac9d1)
2025-12-09 10:20:33 +00:00
RitvikSardana
f87cfa33b7 chore: Russian translations
(cherry picked from commit 20fb1b7e42)
2025-12-09 10:20:33 +00:00
RitvikSardana
f4e081850a chore: Hungarian translations
(cherry picked from commit bdce05270a)
2025-12-09 10:20:33 +00:00
RitvikSardana
8983e0b4bd Merge pull request #2802 from frappe/mergify/bp/main-hotfix/pr-2797
chore: sync translations from crowdin (backport #2797)
2025-12-09 15:50:02 +05:30
RitvikSardana
24d64897dc Merge pull request #2804 from frappe/mergify/bp/main-hotfix/pr-2803
fix: add support for button variables in custom buttons (backport #2803)
2025-12-05 22:51:20 +05:30
Ritvik Sardana
6289d66c59 fix: add support for button variables in custom buttons
(cherry picked from commit 7f4ad4387a)
2025-12-05 17:18:06 +00:00
RitvikSardana
1281b54d7d chore: Slovenian translations
(cherry picked from commit 6f13958974)
2025-12-05 15:50:36 +00:00
RitvikSardana
c6aa12a2aa chore: Bosnian translations
(cherry picked from commit c9b969f3fd)
2025-12-05 15:50:36 +00:00
RitvikSardana
9a72503b39 chore: Croatian translations
(cherry picked from commit fa44401f6e)
2025-12-05 15:50:36 +00:00
RitvikSardana
f787395865 chore: Russian translations
(cherry picked from commit b927731bcf)
2025-12-05 15:50:36 +00:00
RitvikSardana
45dae2eb4d chore: Esperanto translations
(cherry picked from commit 39d49bba6d)
2025-12-05 15:50:35 +00:00
RitvikSardana
4899a9e9d5 chore: Slovenian translations
(cherry picked from commit 5908ac8cd1)
2025-12-05 15:50:35 +00:00
RitvikSardana
aebf2caecc chore: French translations
(cherry picked from commit 3d61f226ff)
2025-12-05 15:50:35 +00:00
RitvikSardana
e8b3fe6052 chore: Serbian (Latin) translations
(cherry picked from commit 526828ca87)
2025-12-05 15:50:35 +00:00
RitvikSardana
6c2858102a chore: Norwegian Bokmal translations
(cherry picked from commit 918aa7582f)
2025-12-05 15:50:34 +00:00
RitvikSardana
b17ea62f95 chore: Bosnian translations
(cherry picked from commit ab12cc4017)
2025-12-05 15:50:34 +00:00
RitvikSardana
2ca7d118fc chore: Burmese translations
(cherry picked from commit bb2db66f81)
2025-12-05 15:50:34 +00:00
RitvikSardana
fae8492cf3 chore: Croatian translations
(cherry picked from commit 1398427e65)
2025-12-05 15:50:34 +00:00
RitvikSardana
56461a3428 chore: Thai translations
(cherry picked from commit d329acb0d7)
2025-12-05 15:50:33 +00:00
RitvikSardana
df9c505961 chore: Tamil translations
(cherry picked from commit f39db539e7)
2025-12-05 15:50:33 +00:00
RitvikSardana
6482524705 chore: Persian translations
(cherry picked from commit 6b694352b1)
2025-12-05 15:50:33 +00:00
RitvikSardana
f4c2c12b9d chore: Indonesian translations
(cherry picked from commit 6c356d3a73)
2025-12-05 15:50:33 +00:00
RitvikSardana
299e16f1e2 chore: Portuguese, Brazilian translations
(cherry picked from commit 7c6311eeb1)
2025-12-05 15:50:32 +00:00
RitvikSardana
b099a6658b chore: Vietnamese translations
(cherry picked from commit 9a6e74f23c)
2025-12-05 15:50:32 +00:00
RitvikSardana
7f1d06588e chore: Chinese Simplified translations
(cherry picked from commit d6a96a4c6b)
2025-12-05 15:50:32 +00:00
RitvikSardana
b970d9ed82 chore: Turkish translations
(cherry picked from commit 90e1b75793)
2025-12-05 15:50:32 +00:00
RitvikSardana
712c1b6af7 chore: Swedish translations
(cherry picked from commit 9d6488ac7b)
2025-12-05 15:50:31 +00:00
RitvikSardana
5eb8bb8c05 chore: Serbian (Cyrillic) translations
(cherry picked from commit 38d326294f)
2025-12-05 15:50:31 +00:00
RitvikSardana
47dcb01852 chore: Russian translations
(cherry picked from commit 2c8d353105)
2025-12-05 15:50:31 +00:00
RitvikSardana
7c16051cd3 chore: Portuguese translations
(cherry picked from commit cb80db8bb6)
2025-12-05 15:50:31 +00:00
RitvikSardana
651a3d67e9 chore: Polish translations
(cherry picked from commit fb02488cc2)
2025-12-05 15:50:30 +00:00
RitvikSardana
678df4256e chore: Dutch translations
(cherry picked from commit bdb401cea8)
2025-12-05 15:50:30 +00:00
RitvikSardana
57b077f648 chore: Italian translations
(cherry picked from commit cec8e1c963)
2025-12-05 15:50:30 +00:00
RitvikSardana
0258be7a05 chore: Hungarian translations
(cherry picked from commit 4184f067f7)
2025-12-05 15:50:30 +00:00
RitvikSardana
1f417d0ddc chore: German translations
(cherry picked from commit 07764582bc)
2025-12-05 15:50:29 +00:00
RitvikSardana
bb3983b35d chore: Danish translations
(cherry picked from commit cb5ffcc299)
2025-12-05 15:50:29 +00:00
RitvikSardana
bdad810b4b chore: Czech translations
(cherry picked from commit 1c4b5eb4f5)
2025-12-05 15:50:29 +00:00
RitvikSardana
81c8d24495 chore: Arabic translations
(cherry picked from commit 6126dbef15)
2025-12-05 15:50:29 +00:00
RitvikSardana
09eef74ccb chore: Spanish translations
(cherry picked from commit 9a43e789a3)
2025-12-05 15:50:28 +00:00
Pratik
c80a6c1d12 fix: ticket view tabs alignment
(cherry picked from commit 5171c3bf22)
2025-12-05 15:48:27 +00:00
Pratik
d11258e90b fix: revert select element in gemeral settings
(cherry picked from commit cc42501517)
2025-12-05 15:48:27 +00:00
Pratik
b9457b7448 fix: popover width
(cherry picked from commit 2c2039faa6)

# Conflicts:
#	desk/src/components/Settings/Assignment Rules/AssignmentRuleView.vue
2025-12-05 15:48:27 +00:00
Pratik
5b2b96c5b6 fix: select options width & assigmnent rule bugs
(cherry picked from commit 4b43b6d01f)

# Conflicts:
#	desk/src/components/Settings/Assignment Rules/AssignmentRuleView.vue
2025-12-05 15:48:26 +00:00
Pratik
a94fe5ed1b fix: bugs introduced by frappe-ui update
(cherry picked from commit 8e6848dc23)
2025-12-05 15:48:26 +00:00
Pratik
461edc93aa fix: bugs related to frappe-ui update
(cherry picked from commit e2a2d29b84)

# Conflicts:
#	desk/src/components/SelectDropdown.vue
#	desk/src/components/Settings/General/components/TicketSettings.vue
#	desk/src/components/Settings/Sla/SlaPriorityListItem.vue
#	desk/src/components/Settings/Sla/SlaWorkDaysListItem.vue
2025-12-05 15:48:26 +00:00
RitvikSardana
1a427ef047 Merge pull request #2800 from frappe/mergify/bp/main-hotfix/pr-2795
fix(security): dashboard APIs & realtime events (backport #2795)
2025-12-05 18:27:04 +05:30
RitvikSardana
0ed1d38b37 chore: update dashboard file 2025-12-05 17:00:40 +05:30
RitvikSardana
a653ada9b5 chore: update dashboard file 2025-12-05 16:48:58 +05:30
RitvikSardana
54da393df7 chore: update dashboard file 2025-12-05 16:24:16 +05:30
Ritvik Sardana
7c78e5ed58 fix: dont bombard socket events
(cherry picked from commit b2f7e029be)
2025-12-05 10:36:37 +00:00
Ritvik Sardana
3539979ecc refactor: convert dashboard api to class and add QB support
(cherry picked from commit 1e1cd602d6)

# Conflicts:
#	helpdesk/api/dashboard.py
2025-12-05 10:36:37 +00:00
RitvikSardana
e8f53b9063 Merge pull request #2792 from frappe/mergify/bp/main-hotfix/pr-2788
chore: update POT file (backport #2788)
2025-12-03 14:29:46 +05:30
RitvikSardana
08156fda53 Merge pull request #2793 from frappe/mergify/bp/main-hotfix/pr-2786
chore: sync translations from crowdin (backport #2786)
2025-12-03 14:29:38 +05:30
RitvikSardana
91c9aca479 chore: Persian translations
(cherry picked from commit cf5be1ea67)
2025-12-03 08:58:35 +00:00
RitvikSardana
452aff7ba8 chore: Hungarian translations
(cherry picked from commit 2ed447646d)
2025-12-03 08:58:35 +00:00
RitvikSardana
012fa5e56f chore: Spanish translations
(cherry picked from commit 8a87142618)
2025-12-03 08:58:35 +00:00
RitvikSardana
08e638d598 chore: Persian translations
(cherry picked from commit 12b9fe46ef)
2025-12-03 08:58:35 +00:00
RitvikSardana
6cff520fab chore: Slovenian translations
(cherry picked from commit bca072eb23)
2025-12-03 08:58:34 +00:00
RitvikSardana
8e136a2873 chore: Slovenian translations
(cherry picked from commit 3b2b3a194f)
2025-12-03 08:58:34 +00:00
RitvikSardana
944008b176 chore: Serbian (Latin) translations
(cherry picked from commit 3960d96377)
2025-12-03 08:58:34 +00:00
RitvikSardana
26fc1648ed chore: Norwegian Bokmal translations
(cherry picked from commit b04b4b1aa3)
2025-12-03 08:58:34 +00:00
RitvikSardana
75783677ea chore: Bosnian translations
(cherry picked from commit 11de025c42)
2025-12-03 08:58:33 +00:00
RitvikSardana
55cc6f2640 chore: Croatian translations
(cherry picked from commit 401ee26e3e)
2025-12-03 08:58:33 +00:00
RitvikSardana
2db40ba4f7 chore: Thai translations
(cherry picked from commit 35ae4af61a)
2025-12-03 08:58:33 +00:00
RitvikSardana
a43718427e chore: Tamil translations
(cherry picked from commit 6dee7bf2e2)
2025-12-03 08:58:33 +00:00
RitvikSardana
b5169d0d15 chore: Persian translations
(cherry picked from commit d615d7ae3e)
2025-12-03 08:58:32 +00:00
RitvikSardana
dcdda3b770 chore: Indonesian translations
(cherry picked from commit 9cb6df0bee)
2025-12-03 08:58:32 +00:00
RitvikSardana
227ff783f6 chore: Portuguese, Brazilian translations
(cherry picked from commit 13aff48429)
2025-12-03 08:58:32 +00:00
RitvikSardana
33ffc0abe5 chore: Vietnamese translations
(cherry picked from commit c413ef2e48)
2025-12-03 08:58:32 +00:00
RitvikSardana
561e4ab441 chore: Chinese Simplified translations
(cherry picked from commit b367cdc828)
2025-12-03 08:58:31 +00:00
RitvikSardana
4b67c5f2f5 chore: Turkish translations
(cherry picked from commit 5c7efb6372)
2025-12-03 08:58:31 +00:00
RitvikSardana
29d159dd11 chore: Swedish translations
(cherry picked from commit e3aacdbcdc)
2025-12-03 08:58:31 +00:00
RitvikSardana
7adeed96fe chore: Serbian (Cyrillic) translations
(cherry picked from commit 91dd2f4a54)
2025-12-03 08:58:31 +00:00
RitvikSardana
93362f939b chore: Russian translations
(cherry picked from commit cf629ebd21)
2025-12-03 08:58:31 +00:00
RitvikSardana
a16b701032 chore: Portuguese translations
(cherry picked from commit ce62f05c25)
2025-12-03 08:58:30 +00:00
RitvikSardana
e3a09004c6 chore: Polish translations
(cherry picked from commit 5150c85284)
2025-12-03 08:58:30 +00:00
RitvikSardana
68faaebb37 chore: Dutch translations
(cherry picked from commit bff28f2642)
2025-12-03 08:58:30 +00:00
RitvikSardana
4d72882d40 chore: Italian translations
(cherry picked from commit 70da538f78)
2025-12-03 08:58:30 +00:00
RitvikSardana
f05e66b039 chore: Hungarian translations
(cherry picked from commit a2827630b1)
2025-12-03 08:58:29 +00:00
RitvikSardana
28f44bfe61 chore: German translations
(cherry picked from commit d527448d5b)
2025-12-03 08:58:29 +00:00
RitvikSardana
1e3fc96adc chore: Danish translations
(cherry picked from commit 3cf62a9a2e)
2025-12-03 08:58:29 +00:00
RitvikSardana
4e8ecc5447 chore: Czech translations
(cherry picked from commit e86ee3f5e8)
2025-12-03 08:58:29 +00:00
RitvikSardana
14aefb9b23 chore: Arabic translations
(cherry picked from commit 6789a25986)
2025-12-03 08:58:28 +00:00
frappe-pr-bot
4cb3c57865 chore: update POT file
(cherry picked from commit a27001e934)
2025-12-03 08:57:28 +00:00
RitvikSardana
105da6be52 Merge pull request #2791 from frappe/mergify/bp/main-hotfix/pr-2789
chore: V16 support (backport #2789)
2025-12-03 14:26:25 +05:30
RitvikSardana
994c14e70d Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2789 2025-12-03 14:10:46 +05:30
RitvikSardana
284531ab56 Merge pull request #2774 from frappe/mergify/bp/main-hotfix/pr-2719
refactor: reduce bundle size (backport #2719)
2025-12-03 14:10:08 +05:30
RitvikSardana
484d00e06d chore: solvemerge conflicts 2025-12-03 14:05:19 +05:30
Ritvik Sardana
350275ece1 chore: code cleanup
(cherry picked from commit b800ee03c2)
2025-12-03 08:27:22 +00:00
Ritvik Sardana
c00da37443 fix: add util to check version
(cherry picked from commit d016312f86)
2025-12-03 08:27:21 +00:00
Ritvik Sardana
d2acd5f7e9 fix: better way to check version
(cherry picked from commit 7b9dbe748e)
2025-12-03 08:27:21 +00:00
Ritvik Sardana
98491bb277 chore: revert py project changes
(cherry picked from commit 246462901e)

# Conflicts:
#	pyproject.toml
2025-12-03 08:27:21 +00:00
Ritvik Sardana
8e3189d9a0 fix: add version change for testing in v15
(cherry picked from commit 2309dd9c9d)

# Conflicts:
#	pyproject.toml
2025-12-03 08:27:21 +00:00
Ritvik Sardana
be58389888 fix: add conditional version support
(cherry picked from commit a36e377771)
2025-12-03 08:27:21 +00:00
Ritvik Sardana
3f1299d6ed chore: add v16 support
(cherry picked from commit 825ec55d80)

# Conflicts:
#	desk/src/pages/dashboard/Dashboard.vue
#	desk/tsconfig.json
#	helpdesk/api/dashboard.py
2025-12-03 08:27:20 +00:00
RitvikSardana
d0bc2f5250 chore: code cleanup 2025-12-03 12:11:30 +05:30
RitvikSardana
f8d9828f85 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2719 2025-12-03 11:53:22 +05:30
RitvikSardana
99682ad5fd Merge pull request #2785 from frappe/main-hotfix
chore(release): hotfix to main
2025-11-28 14:59:18 +05:30
RitvikSardana
8cea4bcc45 Merge pull request #2784 from frappe/mergify/bp/main-hotfix/pr-2783
chore: bump version (backport #2783)
2025-11-28 11:32:37 +05:30
Ritvik Sardana
d9f6fdf492 chore: bump version
(cherry picked from commit 8fb56bad25)
2025-11-28 06:01:59 +00:00
RitvikSardana
bb77ed8673 Merge pull request #2782 from frappe/mergify/bp/main-hotfix/pr-2779
chore: sync translations from crowdin (backport #2779)
2025-11-28 11:31:29 +05:30
RitvikSardana
508d6dc5c3 chore: Slovenian translations
(cherry picked from commit c1781bd8b4)
2025-11-28 05:59:50 +00:00
RitvikSardana
9fd72ba073 chore: Burmese translations
(cherry picked from commit 5c4a7ac43c)
2025-11-28 05:59:50 +00:00
RitvikSardana
c24477f90f chore: Esperanto translations
(cherry picked from commit 4ea804b0d0)
2025-11-28 05:59:49 +00:00
RitvikSardana
3555ef8f69 chore: Slovenian translations
(cherry picked from commit a546403f1c)
2025-11-28 05:59:49 +00:00
RitvikSardana
526b030305 chore: French translations
(cherry picked from commit 5947c38d30)
2025-11-28 05:59:49 +00:00
RitvikSardana
caef4a6670 chore: Spanish translations
(cherry picked from commit 863b543e28)
2025-11-28 05:59:49 +00:00
RitvikSardana
e48b138baa Merge pull request #2778 from frappe/mergify/bp/main-hotfix/pr-2761
fix: mandatory feedback bug (backport #2761)
2025-11-27 00:35:41 +05:30
RitvikSardana
ecffab176c Merge pull request #2777 from frappe/mergify/bp/main-hotfix/pr-2765
fix: auto closing bug in reply & comment section (backport #2765)
2025-11-27 00:33:37 +05:30
RitvikSardana
2d9ce87d9c Merge pull request #2776 from frappe/mergify/bp/main-hotfix/pr-2768
fix: Validate that published HD articles contain content (backport #2768)
2025-11-27 00:31:09 +05:30
RitvikSardana
f2e4482c2b Merge pull request #2775 from frappe/mergify/bp/main-hotfix/pr-2758
chore: update POT file (backport #2758)
2025-11-27 00:30:34 +05:30
RitvikSardana
cab852c4d0 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2758 2025-11-27 00:30:27 +05:30
Pratik
8f631a9e33 fix: remove feedback field in validation
(cherry picked from commit 015bb01599)
2025-11-26 19:00:23 +00:00
Pratik
6fef06be3a fix: mandatory feedback bug
(cherry picked from commit 86b4440149)
2025-11-26 19:00:23 +00:00
RitvikSardana
b5a0dca8c5 Merge pull request #2773 from frappe/mergify/bp/main-hotfix/pr-2751
chore: sync translations from crowdin (backport #2751)
2025-11-27 00:30:02 +05:30
RitvikSardana
5521e46696 Merge pull request #2772 from frappe/mergify/bp/main-hotfix/pr-2766
chore: update POT file (backport #2766)
2025-11-27 00:29:53 +05:30
Pratik
b006403eff fix: revert changes & add ignore args
(cherry picked from commit 77eaa45ee7)
2025-11-26 18:58:23 +00:00
Pratik
794303795d fix: auto closing bug in reply & comment section
(cherry picked from commit e197993dbf)
2025-11-26 18:58:23 +00:00
KerollesFathy
00d7cad822 fix: validate published articles have content
(cherry picked from commit 356c88bc1f)
2025-11-26 18:44:36 +00:00
frappe-pr-bot
4a9260ad37 chore: update POT file
(cherry picked from commit 75647b121d)
2025-11-26 18:41:32 +00:00
Ritvik Sardana
62c56faec0 fix: update frappe-ui version
(cherry picked from commit f386c33a09)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
2bce281c7d fix: change PWA version
(cherry picked from commit b457646087)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
98f0460c24 fix: resolve for node 18
(cherry picked from commit d14fc7dd5f)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
8a1cf06df3 chore: update frappe-ui version
(cherry picked from commit 88acdd0a4f)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
da4d1810b7 chore: code cleanup
(cherry picked from commit 09ef6a1d18)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
8d23efeb0c fix: update frappe-ui version
(cherry picked from commit 98c8eb7445)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
03329051a9 fix: frappe-ui changes in sidebar
(cherry picked from commit 15fdea2cd1)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
4e11103806 fix: change frappe-ui commit hash
(cherry picked from commit 97a3be2fc7)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
636607df99 fix: change frappe-ui commit hash
(cherry picked from commit 538674f68b)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
8260debc30 fix: incorporate new changes
(cherry picked from commit c9a6a28af7)

# Conflicts:
#	desk/src/components/ticket-agent/TicketSLA.vue
2025-11-26 18:40:29 +00:00
Ritvik Sardana
0104750c59 chore: update git ignore file
(cherry picked from commit 0e84f92d32)
2025-11-26 18:40:29 +00:00
Ritvik Sardana
cbffb8304b fix: frappe-ui tree shaking compatible
(cherry picked from commit 6f02b27ff6)
2025-11-26 18:40:29 +00:00
Ritvik Sardana
2233c7ed37 chore: bump frappe-ui version
(cherry picked from commit 580ac33487)
2025-11-26 18:40:28 +00:00
Ritvik Sardana
2d2f0cbe68 chore: update yarn.lock
(cherry picked from commit 020bd77358)
2025-11-26 18:40:28 +00:00
Ritvik Sardana
14251eaa61 fix: remove visualizer
(cherry picked from commit 511fa074b4)
2025-11-26 18:40:28 +00:00
Ritvik Sardana
edf3f88136 refactor: reduce bundle size
(cherry picked from commit 01175b0fc8)

# Conflicts:
#	desk/src/components/ticket-agent/TicketActivityPanel.vue
2025-11-26 18:40:28 +00:00
RitvikSardana
0db3a26557 chore: Slovenian translations
(cherry picked from commit 660fd178b9)
2025-11-26 18:39:47 +00:00
RitvikSardana
c72f8c5923 chore: Portuguese, Brazilian translations
(cherry picked from commit 6c29b677cb)
2025-11-26 18:39:47 +00:00
RitvikSardana
0a2ba097df chore: Slovenian translations
(cherry picked from commit 530340a01f)
2025-11-26 18:39:46 +00:00
RitvikSardana
4fe3a05f55 chore: French translations
(cherry picked from commit 6f104d56f4)
2025-11-26 18:39:46 +00:00
RitvikSardana
685fb6d770 chore: Slovenian translations
(cherry picked from commit 9e66c34d99)
2025-11-26 18:39:46 +00:00
RitvikSardana
42347b4114 chore: Slovenian translations
(cherry picked from commit 144f1b9f6a)
2025-11-26 18:39:44 +00:00
RitvikSardana
b68653fc32 chore: Slovenian translations
(cherry picked from commit e93b3d3912)
2025-11-26 18:39:43 +00:00
RitvikSardana
74eb4093c3 chore: Slovenian translations
(cherry picked from commit b922219bb6)
2025-11-26 18:39:43 +00:00
RitvikSardana
be40fbfb53 chore: Indonesian translations
(cherry picked from commit 73adb17b98)
2025-11-26 18:39:43 +00:00
RitvikSardana
190f9801a8 chore: Portuguese, Brazilian translations
(cherry picked from commit 2157d27cbe)
2025-11-26 18:39:43 +00:00
RitvikSardana
9ac3d812ac chore: Spanish translations
(cherry picked from commit 4b0c61fa16)
2025-11-26 18:39:41 +00:00
RitvikSardana
9e87c2526a chore: Persian translations
(cherry picked from commit f4670c5fb2)
2025-11-26 18:39:40 +00:00
RitvikSardana
7f0e7fe4b2 chore: French translations
(cherry picked from commit ce7ccf1730)
2025-11-26 18:39:40 +00:00
RitvikSardana
df185cef71 chore: Serbian (Latin) translations
(cherry picked from commit 4d7601a527)
2025-11-26 18:39:40 +00:00
RitvikSardana
28574f6203 chore: Norwegian Bokmal translations
(cherry picked from commit 6be1784277)
2025-11-26 18:39:38 +00:00
RitvikSardana
f34dcb5974 chore: Bosnian translations
(cherry picked from commit c177472a42)
2025-11-26 18:39:38 +00:00
RitvikSardana
e3ab99bb36 chore: Croatian translations
(cherry picked from commit 7f80b00860)
2025-11-26 18:39:37 +00:00
RitvikSardana
e4a66d68d4 chore: Persian translations
(cherry picked from commit fd74e6311b)
2025-11-26 18:39:36 +00:00
RitvikSardana
090ee1b021 chore: Indonesian translations
(cherry picked from commit 64b8991408)
2025-11-26 18:39:35 +00:00
RitvikSardana
2501bafc46 chore: Chinese Simplified translations
(cherry picked from commit ea37dcfc3a)
2025-11-26 18:39:35 +00:00
RitvikSardana
37161a79e8 chore: Turkish translations
(cherry picked from commit 6be74cad34)
2025-11-26 18:39:35 +00:00
RitvikSardana
fb0edce5b0 chore: Swedish translations
(cherry picked from commit 03556b02b5)
2025-11-26 18:39:34 +00:00
RitvikSardana
fe13c7e383 chore: Serbian (Cyrillic) translations
(cherry picked from commit a7f42d9d14)
2025-11-26 18:39:34 +00:00
RitvikSardana
910f753c61 chore: Polish translations
(cherry picked from commit a83e75a4d3)
2025-11-26 18:39:32 +00:00
RitvikSardana
dcd9361a45 chore: German translations
(cherry picked from commit afb5f2cb34)
2025-11-26 18:39:32 +00:00
RitvikSardana
58638f67e8 chore: Arabic translations
(cherry picked from commit ff30d7d318)
2025-11-26 18:39:31 +00:00
RitvikSardana
423f01c27f chore: Spanish translations
(cherry picked from commit 952fe5daa0)
2025-11-26 18:39:31 +00:00
RitvikSardana
8350870406 chore: German translations
(cherry picked from commit caaf65d63b)
2025-11-26 18:39:31 +00:00
RitvikSardana
737ad7e807 chore: Persian translations
(cherry picked from commit c813319260)
2025-11-26 18:39:30 +00:00
frappe-pr-bot
d03120778a chore: update POT file
(cherry picked from commit 6fcae817d4)
2025-11-26 18:39:24 +00:00
RitvikSardana
0dd85bae82 Merge pull request #2749 from frappe/mergify/bp/main-hotfix/pr-2746
chore: sync translations from crowdin (backport #2746)
2025-11-12 02:30:47 +05:30
RitvikSardana
332a03230a chore: Bosnian translations
(cherry picked from commit c60636e65d)
2025-11-11 20:59:53 +00:00
RitvikSardana
f3122c7001 chore: Croatian translations
(cherry picked from commit 64876b97b3)
2025-11-11 20:59:53 +00:00
RitvikSardana
c46e97c990 chore: Swedish translations
(cherry picked from commit 6544352f17)
2025-11-11 20:59:53 +00:00
RitvikSardana
2a843ea14e chore: French translations
(cherry picked from commit 5d3155301e)
2025-11-11 20:59:53 +00:00
RitvikSardana
c82485520e chore: Serbian (Latin) translations
(cherry picked from commit 8eae98f9fb)
2025-11-11 20:59:52 +00:00
RitvikSardana
b44d833656 chore: Norwegian Bokmal translations
(cherry picked from commit 64730f9c10)
2025-11-11 20:59:52 +00:00
RitvikSardana
58e4314856 chore: Bosnian translations
(cherry picked from commit 4697af4d7b)
2025-11-11 20:59:52 +00:00
RitvikSardana
d9bf8b3787 chore: Croatian translations
(cherry picked from commit 76f45d5ac0)
2025-11-11 20:59:52 +00:00
RitvikSardana
fd49c2d34d chore: Persian translations
(cherry picked from commit 347205c866)
2025-11-11 20:59:51 +00:00
RitvikSardana
9f08a80a25 chore: Indonesian translations
(cherry picked from commit 13ea1ffc8c)
2025-11-11 20:59:51 +00:00
RitvikSardana
522b84e0ec chore: Portuguese, Brazilian translations
(cherry picked from commit 928a9391d0)
2025-11-11 20:59:51 +00:00
RitvikSardana
f8970a6693 chore: Chinese Simplified translations
(cherry picked from commit 240651564a)
2025-11-11 20:59:51 +00:00
RitvikSardana
91b7e98776 chore: Turkish translations
(cherry picked from commit c2a68b6a6b)
2025-11-11 20:59:51 +00:00
RitvikSardana
2eb6e479e6 chore: Swedish translations
(cherry picked from commit 5df19be765)
2025-11-11 20:59:50 +00:00
RitvikSardana
7c5973d56d chore: Serbian (Cyrillic) translations
(cherry picked from commit df85bb2111)
2025-11-11 20:59:50 +00:00
RitvikSardana
dda0438d82 chore: Portuguese translations
(cherry picked from commit df1adc6c6a)
2025-11-11 20:59:50 +00:00
RitvikSardana
1bbbcd0c8e chore: Polish translations
(cherry picked from commit 601b674f8d)
2025-11-11 20:59:50 +00:00
RitvikSardana
77f77073b2 chore: Dutch translations
(cherry picked from commit 79100fe489)
2025-11-11 20:59:49 +00:00
RitvikSardana
506fbca15d chore: Italian translations
(cherry picked from commit b0a8f48dae)
2025-11-11 20:59:49 +00:00
RitvikSardana
d7f5769ead chore: German translations
(cherry picked from commit 2a713fa2a4)
2025-11-11 20:59:49 +00:00
RitvikSardana
160dd3d1e8 chore: Danish translations
(cherry picked from commit abb2c8b8df)
2025-11-11 20:59:49 +00:00
RitvikSardana
de41ce2135 chore: Arabic translations
(cherry picked from commit b016726247)
2025-11-11 20:59:48 +00:00
RitvikSardana
e0f97a9bdb chore: Spanish translations
(cherry picked from commit b019883b91)
2025-11-11 20:59:48 +00:00
RitvikSardana
5f1eaae00b Merge pull request #2748 from frappe/mergify/bp/main-hotfix/pr-2747
fix: parse API Options correctly while creating tickets (backport #2747)
2025-11-12 02:28:43 +05:30
Ritvik Sardana
721866b782 fix: parse API Options correctly while creating tickets
(cherry picked from commit bf144300ba)
2025-11-11 07:03:21 +00:00
RitvikSardana
bfd01b0549 Merge pull request #2745 from frappe/main-hotfix
chore(release): hotfix to main
2025-11-10 15:59:34 +05:30
RitvikSardana
a0ca9daa97 Merge pull request #2742 from frappe/mergify/bp/main-hotfix/pr-2741
fix: validation error when replying on a ticket (backport #2741)
2025-11-10 15:50:58 +05:30
RitvikSardana
129dbb3cd2 Merge pull request #2744 from frappe/mergify/bp/main-hotfix/pr-2743
fix: filter out null/undefined values in apiOptions (backport #2743)
2025-11-10 15:50:42 +05:30
Ritvik Sardana
d06df8aafb chore: code cleanup
(cherry picked from commit 03583e44d5)
2025-11-10 10:19:03 +00:00
Ritvik Sardana
bf46380aae fix: filter out null/undefined values in apiOptions
(cherry picked from commit 4bb8b7695c)
2025-11-10 10:19:03 +00:00
Ritvik Sardana
73b608d133 fix: validation error when replying on a ticket
(cherry picked from commit 643155e4ec)
2025-11-10 10:02:00 +00:00
RitvikSardana
06ce05bcb0 Merge pull request #2739 from frappe/mergify/bp/main-hotfix/pr-2715
refactor: update apiOptions transform to map object labels and values (backport #2715)
2025-11-10 12:29:03 +05:30
KerollesFathy
62d35686b3 refactor: update apiOptions transform to handle object labels and values
(cherry picked from commit f06862057f)
2025-11-09 16:04:57 +00:00
RitvikSardana
7182665584 Merge pull request #2738 from frappe/mergify/bp/main-hotfix/pr-2737
chore: sync translations from crowdin (backport #2737)
2025-11-09 21:34:21 +05:30
RitvikSardana
a42f700154 chore: French translations
(cherry picked from commit 3d22970159)
2025-11-09 15:20:58 +00:00
RitvikSardana
dc41f10fb7 chore: Esperanto translations
(cherry picked from commit 4d39b43f15)
2025-11-09 15:20:58 +00:00
RitvikSardana
6a8cd912bd chore: Serbian (Latin) translations
(cherry picked from commit 5489209a0d)
2025-11-09 15:20:57 +00:00
RitvikSardana
dc2505c1ff chore: Norwegian Bokmal translations
(cherry picked from commit 4845183691)
2025-11-09 15:20:57 +00:00
RitvikSardana
d6a8c17860 chore: Bosnian translations
(cherry picked from commit e17d9f0123)
2025-11-09 15:20:57 +00:00
RitvikSardana
2fc428c25b chore: Burmese translations
(cherry picked from commit 30f4654d0f)
2025-11-09 15:20:57 +00:00
RitvikSardana
32831f9e6c chore: Croatian translations
(cherry picked from commit 6b6dacea76)
2025-11-09 15:20:56 +00:00
RitvikSardana
f74dc38917 chore: Thai translations
(cherry picked from commit 0594cba992)
2025-11-09 15:20:56 +00:00
RitvikSardana
cf09a7e922 chore: Tamil translations
(cherry picked from commit 1cfcbd964b)
2025-11-09 15:20:56 +00:00
RitvikSardana
7ed9d4e13b chore: Persian translations
(cherry picked from commit cffd32dda9)
2025-11-09 15:20:56 +00:00
RitvikSardana
e18e37752e chore: Indonesian translations
(cherry picked from commit 7e8071f33c)
2025-11-09 15:20:56 +00:00
RitvikSardana
6e6ea85c31 chore: Portuguese, Brazilian translations
(cherry picked from commit 926e21d258)
2025-11-09 15:20:55 +00:00
RitvikSardana
e85451d2d6 chore: Vietnamese translations
(cherry picked from commit 8af08fe0fa)
2025-11-09 15:20:55 +00:00
RitvikSardana
a4cc6f59fc chore: Chinese Simplified translations
(cherry picked from commit 0826769b62)
2025-11-09 15:20:55 +00:00
RitvikSardana
d9f3fc8a5c chore: Turkish translations
(cherry picked from commit f35457bdb0)
2025-11-09 15:20:55 +00:00
RitvikSardana
a18a544f2f chore: Swedish translations
(cherry picked from commit 6bc303f434)
2025-11-09 15:20:54 +00:00
RitvikSardana
a973a10297 chore: Serbian (Cyrillic) translations
(cherry picked from commit 12050328a2)
2025-11-09 15:20:54 +00:00
RitvikSardana
5bfd9ea9f8 chore: Russian translations
(cherry picked from commit 5ec5c53024)
2025-11-09 15:20:54 +00:00
RitvikSardana
f5b4e536b4 chore: Portuguese translations
(cherry picked from commit 0388bcaf45)
2025-11-09 15:20:54 +00:00
RitvikSardana
693b88df22 chore: Polish translations
(cherry picked from commit 50faed905e)
2025-11-09 15:20:53 +00:00
RitvikSardana
aff88c6078 chore: Dutch translations
(cherry picked from commit b97d1e8edc)
2025-11-09 15:20:53 +00:00
RitvikSardana
6754511b2a chore: Italian translations
(cherry picked from commit 699ce06906)
2025-11-09 15:20:53 +00:00
RitvikSardana
68272b43e0 chore: Hungarian translations
(cherry picked from commit 28bf477422)
2025-11-09 15:20:53 +00:00
RitvikSardana
a88f68564a chore: German translations
(cherry picked from commit 10c206c37f)
2025-11-09 15:20:53 +00:00
RitvikSardana
0d52ce1a3b chore: Danish translations
(cherry picked from commit 506a5619cb)
2025-11-09 15:20:52 +00:00
RitvikSardana
ca5a9f46dd chore: Czech translations
(cherry picked from commit 56e4a8fa27)
2025-11-09 15:20:52 +00:00
RitvikSardana
b0346cb7d8 chore: Arabic translations
(cherry picked from commit 387e6f65ea)
2025-11-09 15:20:52 +00:00
RitvikSardana
f154f26e39 chore: Spanish translations
(cherry picked from commit 6e94d7668e)
2025-11-09 15:20:52 +00:00
RitvikSardana
220d037088 Merge pull request #2736 from frappe/mergify/bp/main-hotfix/pr-2733
chore: sync translations from crowdin (backport #2733)
2025-11-09 20:49:32 +05:30
RitvikSardana
2ca10d4c75 Merge pull request #2735 from frappe/mergify/bp/main-hotfix/pr-2734
chore: update POT file (backport #2734)
2025-11-09 20:49:22 +05:30
RitvikSardana
7bd6442fb6 chore: Esperanto translations
(cherry picked from commit fbf5bed567)
2025-11-09 12:25:32 +00:00
RitvikSardana
57fa5106cf chore: Serbian (Latin) translations
(cherry picked from commit b7dcf6324c)
2025-11-09 12:25:32 +00:00
RitvikSardana
72c8dbccd7 chore: Norwegian Bokmal translations
(cherry picked from commit fe25d084d9)
2025-11-09 12:25:31 +00:00
RitvikSardana
b5f52079ed chore: Bosnian translations
(cherry picked from commit cb329e9747)
2025-11-09 12:25:31 +00:00
RitvikSardana
7649a9189a chore: Burmese translations
(cherry picked from commit f5720fba05)
2025-11-09 12:25:31 +00:00
RitvikSardana
247a866f3a chore: Croatian translations
(cherry picked from commit 0745ddf8cd)
2025-11-09 12:25:31 +00:00
RitvikSardana
58f34cae64 chore: Thai translations
(cherry picked from commit 4a6ce11692)
2025-11-09 12:25:31 +00:00
RitvikSardana
65573e904c chore: Tamil translations
(cherry picked from commit bed330a7db)
2025-11-09 12:25:30 +00:00
RitvikSardana
2f5c21c45d chore: Persian translations
(cherry picked from commit b939caaf68)
2025-11-09 12:25:30 +00:00
RitvikSardana
fdb2493ae4 chore: Indonesian translations
(cherry picked from commit dae531dd26)
2025-11-09 12:25:30 +00:00
RitvikSardana
f4b4dea414 chore: Portuguese, Brazilian translations
(cherry picked from commit 84af9ba07f)
2025-11-09 12:25:30 +00:00
RitvikSardana
334f9b11ca chore: Vietnamese translations
(cherry picked from commit d32244a877)
2025-11-09 12:25:29 +00:00
RitvikSardana
b794cff536 chore: Chinese Simplified translations
(cherry picked from commit f2b5213e6e)
2025-11-09 12:25:29 +00:00
RitvikSardana
08d830e0bd chore: Turkish translations
(cherry picked from commit 487ea74b51)
2025-11-09 12:25:29 +00:00
RitvikSardana
7d0c147e08 chore: Swedish translations
(cherry picked from commit 124a1a6911)
2025-11-09 12:25:29 +00:00
RitvikSardana
e8e672efcb chore: Serbian (Cyrillic) translations
(cherry picked from commit 2e85998e72)
2025-11-09 12:25:28 +00:00
RitvikSardana
631b53773c chore: Russian translations
(cherry picked from commit 83cefc54a1)
2025-11-09 12:25:28 +00:00
RitvikSardana
abc0ac26d1 chore: Portuguese translations
(cherry picked from commit fb42f359a1)
2025-11-09 12:25:28 +00:00
RitvikSardana
407c259aa7 chore: Polish translations
(cherry picked from commit 0334db5ef9)
2025-11-09 12:25:28 +00:00
RitvikSardana
736e1c404c chore: Dutch translations
(cherry picked from commit 305ef5e560)
2025-11-09 12:25:27 +00:00
RitvikSardana
05c2ccd29e chore: Italian translations
(cherry picked from commit a4f3d1d5b4)
2025-11-09 12:25:27 +00:00
RitvikSardana
32aa3fa9e0 chore: Hungarian translations
(cherry picked from commit 16f72e287a)
2025-11-09 12:25:27 +00:00
RitvikSardana
f2cf5ce054 chore: German translations
(cherry picked from commit de04086da7)
2025-11-09 12:25:27 +00:00
frappe-pr-bot
6d1758c7b7 chore: update POT file
(cherry picked from commit ce4f422335)
2025-11-09 12:25:26 +00:00
RitvikSardana
02f4336b8b chore: Danish translations
(cherry picked from commit 557bd6a498)
2025-11-09 12:25:26 +00:00
RitvikSardana
9279cb567b chore: Czech translations
(cherry picked from commit 4b0704060b)
2025-11-09 12:25:26 +00:00
RitvikSardana
70358a6e3a chore: Arabic translations
(cherry picked from commit 30debfc42b)
2025-11-09 12:25:26 +00:00
RitvikSardana
2498a20a2e chore: Spanish translations
(cherry picked from commit 23568e9b97)
2025-11-09 12:25:26 +00:00
RitvikSardana
0ea469f796 Merge pull request #2732 from frappe/main-hotfix
chore(release): hotfix to main
2025-11-07 17:33:54 +05:30
RitvikSardana
c961f3db39 Merge pull request #2731 from frappe/mergify/bp/main-hotfix/pr-2730
chore: bump version (backport #2730)
2025-11-07 17:04:58 +05:30
Ritvik Sardana
4b6cd0d6df chore: bump version
(cherry picked from commit 260d15de65)
2025-11-07 11:31:23 +00:00
RitvikSardana
42c4d0c0fc Merge pull request #2729 from frappe/mergify/bp/main-hotfix/pr-2705
chore: sync translations from crowdin (backport #2705)
2025-11-07 16:54:29 +05:30
RitvikSardana
bac4d39f51 Merge pull request #2728 from frappe/mergify/bp/main-hotfix/pr-2721
chore: update POT file (backport #2721)
2025-11-07 16:54:16 +05:30
RitvikSardana
12f99c0451 Merge pull request #2727 from frappe/mergify/bp/main-hotfix/pr-2726
fix: remove unnecessary validation while giving feedback for email tickets (backport #2726)
2025-11-07 16:53:52 +05:30
RitvikSardana
3daf50e765 chore: Chinese Simplified translations
(cherry picked from commit 01dd4fb42c)
2025-11-07 11:23:15 +00:00
RitvikSardana
b5576df9a7 chore: Serbian (Latin) translations
(cherry picked from commit 75799fa4a2)
2025-11-07 11:23:15 +00:00
RitvikSardana
a14f3c9e7d chore: Serbian (Cyrillic) translations
(cherry picked from commit 7fdcc42626)
2025-11-07 11:23:15 +00:00
RitvikSardana
b68a9f8be1 chore: Esperanto translations
(cherry picked from commit 2a15bfb7e2)
2025-11-07 11:23:14 +00:00
RitvikSardana
6cd27f5444 chore: Burmese translations
(cherry picked from commit 0722e819c3)
2025-11-07 11:23:14 +00:00
RitvikSardana
18d6eeff1a chore: Tamil translations
(cherry picked from commit 5f49a5afd1)
2025-11-07 11:23:14 +00:00
RitvikSardana
a238dcb20c chore: Serbian (Latin) translations
(cherry picked from commit a3254586e5)
2025-11-07 11:23:14 +00:00
RitvikSardana
66b0632584 chore: Norwegian Bokmal translations
(cherry picked from commit 04c4a57141)
2025-11-07 11:23:13 +00:00
RitvikSardana
5d8928e3b6 chore: Bosnian translations
(cherry picked from commit d7fd5cda70)
2025-11-07 11:23:13 +00:00
RitvikSardana
46ddee1111 chore: Croatian translations
(cherry picked from commit 9850e2da85)
2025-11-07 11:23:13 +00:00
RitvikSardana
21fbb8fe47 chore: Thai translations
(cherry picked from commit a0b7fab8da)
2025-11-07 11:23:13 +00:00
RitvikSardana
1da7dce716 chore: Persian translations
(cherry picked from commit 96fbb18677)
2025-11-07 11:23:12 +00:00
RitvikSardana
b7a38821f3 chore: Indonesian translations
(cherry picked from commit 9b10275f36)
2025-11-07 11:23:12 +00:00
RitvikSardana
fddc8b9b59 chore: Portuguese, Brazilian translations
(cherry picked from commit cb1000f32d)
2025-11-07 11:23:12 +00:00
RitvikSardana
c88310a2d3 chore: Vietnamese translations
(cherry picked from commit c0c9bf2cae)
2025-11-07 11:23:12 +00:00
RitvikSardana
cfcc08fe67 chore: Chinese Simplified translations
(cherry picked from commit 1b64db4792)
2025-11-07 11:23:11 +00:00
RitvikSardana
33f6aa0316 chore: Turkish translations
(cherry picked from commit 7631e7f9a4)
2025-11-07 11:23:11 +00:00
RitvikSardana
eab198c78b chore: Swedish translations
(cherry picked from commit 39f26f0050)
2025-11-07 11:23:11 +00:00
RitvikSardana
485cb2d678 chore: Serbian (Cyrillic) translations
(cherry picked from commit 2c67d72e4f)
2025-11-07 11:23:11 +00:00
RitvikSardana
3f76cf27d0 chore: Russian translations
(cherry picked from commit 006ead4074)
2025-11-07 11:23:10 +00:00
RitvikSardana
9d56115ac8 chore: Portuguese translations
(cherry picked from commit ee57aa5fb0)
2025-11-07 11:23:10 +00:00
RitvikSardana
4b3fdcab54 chore: Polish translations
(cherry picked from commit aec5d229eb)
2025-11-07 11:23:10 +00:00
RitvikSardana
0132e6129b chore: Dutch translations
(cherry picked from commit 3ecc905ae8)
2025-11-07 11:23:10 +00:00
RitvikSardana
712c456527 chore: Italian translations
(cherry picked from commit e1bb325c03)
2025-11-07 11:23:09 +00:00
RitvikSardana
a2fe7a53cc chore: Hungarian translations
(cherry picked from commit 037ae628a1)
2025-11-07 11:23:09 +00:00
RitvikSardana
beeec49ccf chore: German translations
(cherry picked from commit 8af498aa8b)
2025-11-07 11:23:09 +00:00
RitvikSardana
eda0ce2cfd chore: Danish translations
(cherry picked from commit 3adddec261)
2025-11-07 11:23:09 +00:00
RitvikSardana
34f9e1a2e4 chore: Czech translations
(cherry picked from commit 4225f50f0c)
2025-11-07 11:23:08 +00:00
RitvikSardana
2ee895491f chore: Arabic translations
(cherry picked from commit ef8b183c88)
2025-11-07 11:23:08 +00:00
RitvikSardana
e8948ce2f1 chore: Spanish translations
(cherry picked from commit 682a25a677)
2025-11-07 11:23:08 +00:00
RitvikSardana
2d9561796f chore: French translations
(cherry picked from commit 21c3a00c78)
2025-11-07 11:23:08 +00:00
RitvikSardana
543bf922a6 Merge pull request #2707 from frappe/mergify/bp/main-hotfix/pr-2706
fix(perm): agent should see the assigned tickets (backport #2706)
2025-11-07 16:52:30 +05:30
frappe-pr-bot
a270cf565c chore: update POT file
(cherry picked from commit 7c29161b3b)
2025-11-07 11:22:18 +00:00
RitvikSardana
e72c64b1a9 Merge pull request #2717 from frappe/mergify/bp/main-hotfix/pr-2716
fix: remove index delete from hooks (backport #2716)
2025-11-07 16:52:06 +05:30
Ritvik Sardana
87cf42ddea fix: remove unnecessary validation while giving feedback for email tickets
(cherry picked from commit f054d9e209)
2025-11-07 11:22:05 +00:00
Ritvik Sardana
2f1c2de00c fix: remove index delete from hooks
(cherry picked from commit 84fce9936e)
2025-10-31 07:32:29 +00:00
RitvikSardana
6857d2be4e Merge pull request #2713 from frappe/main-hotfix
chore(release): hotfix to main
2025-10-30 10:45:05 +05:30
RitvikSardana
b91a82eecb Bump version to 1.17.1 2025-10-30 10:43:22 +05:30
RitvikSardana
e139fa12df Merge pull request #2711 from frappe/mergify/bp/main-hotfix/pr-2710 2025-10-30 10:40:39 +05:30
Pratik Badhe
8a6568dcce fix: undefined ref
(cherry picked from commit 7a1545ef1b)
2025-10-30 04:59:03 +00:00
Ritvik Sardana
3d5f93dc4e fix(search): add ref ticket only if doc is HD Ticket
(cherry picked from commit 38d0a0b76e)
2025-10-29 11:35:20 +00:00
Ritvik Sardana
15ab7e2adc fix: type coercion while setting value
(cherry picked from commit 40f0032380)
2025-10-29 11:35:20 +00:00
Ritvik Sardana
26cf3ffd90 fix: show ticket to agent is they are assigned to it
(cherry picked from commit f564284d96)
2025-10-29 11:35:20 +00:00
RitvikSardana
3fe5020aa4 Merge pull request #2704 from frappe/main-hotfix
chore(release): hotfix to main
2025-10-29 13:30:31 +05:30
RitvikSardana
27dc26b81a Merge pull request #2703 from frappe/mergify/bp/main-hotfix/pr-2699
chore: bump version (backport #2699)
2025-10-29 12:59:54 +05:30
RitvikSardana
5efea5e456 chore: code cleanup 2025-10-29 12:58:40 +05:30
Ritvik Sardana
af5f937c6d chore: update yarn.lock
(cherry picked from commit c583974b74)
2025-10-29 07:24:21 +00:00
Ritvik Sardana
2f2055d760 chore: bump version
(cherry picked from commit 295c1fded4)
2025-10-29 07:24:21 +00:00
RitvikSardana
9331955bcb Merge pull request #2702 from RitvikSardana/url-hash-hotfix
fix: tab url hash & make a call button (backport #2576)
2025-10-29 12:53:19 +05:30
Pratik Badhe
7114e80e9f refactor: change loading state handling 2025-10-29 12:44:29 +05:30
Pratik Badhe
463026c2ac fix: edge case where tab wont change if call is disabled 2025-10-29 12:44:29 +05:30
Pratik Badhe
7f46ba1191 fix: reset tab state on navigation 2025-10-29 12:44:29 +05:30
Pratik Badhe
60686f151e fix: use correct variable 2025-10-29 12:44:26 +05:30
Pratik Badhe
ca4c6a7128 fix: url bug for custom view 2025-10-29 12:43:53 +05:30
Pratik
5f73663797 fix: tab url hash & make a call button 2025-10-29 12:43:39 +05:30
RitvikSardana
f7e1100d75 Merge pull request #2701 from frappe/mergify/bp/main-hotfix/pr-2689
fix: handle edge case for cc & to field (backport #2689)
2025-10-29 12:28:54 +05:30
Pratik Badhe
13cda63eb6 fix: handle edge case for cc & to field
(cherry picked from commit ffbb1c5e8f)
2025-10-29 06:42:20 +00:00
RitvikSardana
ea9f190683 Merge pull request #2691 from frappe/mergify/bp/main-hotfix/pr-2690
fix: minor fixes (backport #2690)
2025-10-29 10:45:51 +05:30
RitvikSardana
8511d032eb Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2690 2025-10-28 19:34:49 +05:30
RitvikSardana
02b32388dc Merge pull request #2698 from frappe/mergify/bp/main-hotfix/pr-2636
fix: assign history message (backport #2636)
2025-10-28 19:30:28 +05:30
Ritvik Sardana
e2171096b5 fix: reload activities
(cherry picked from commit 046aa8e085)
2025-10-28 13:59:38 +00:00
Ritvik Sardana
3ce3d85ef1 fix: assign history message
(cherry picked from commit 46f0faca0b)
2025-10-28 13:59:38 +00:00
RitvikSardana
35ef3c51dc Merge pull request #2697 from RitvikSardana/shortcuts-hotfix
feat: Shortcuts (backport #2676)
2025-10-28 19:18:35 +05:30
Ritvik Sardana
6e162b95fc fix: delete search index when a ticket is deleted 2025-10-28 19:03:01 +05:30
Ritvik Sardana
b475380508 fix: add shortcuts only in Agent Portal 2025-10-28 19:03:01 +05:30
Ritvik Sardana
20391a7d93 chore: code cleanup 2025-10-28 19:02:59 +05:30
Ritvik Sardana
fe3973c376 chore: code cleanup 2025-10-28 19:02:27 +05:30
Ritvik Sardana
650ca7035e fix: add shortcut for showing help panel 2025-10-28 19:02:27 +05:30
Ritvik Sardana
9e15c94d5e fix: handle special cases in shortcuts 2025-10-28 19:02:27 +05:30
Ritvik Sardana
5a3462f9d5 fix: persist redirect URL 2025-10-28 19:02:27 +05:30
Ritvik Sardana
4bf4ac05a4 chore: UI cleanup 2025-10-28 19:02:27 +05:30
Ritvik Sardana
7f7a934382 chore: code cleanup 2025-10-28 19:02:27 +05:30
Ritvik Sardana
0cafac8f38 refactor: shortcut composable 2025-10-28 19:02:27 +05:30
Ritvik Sardana
38cbb7c717 fix: UI for shortcuts 2025-10-28 19:02:27 +05:30
Ritvik Sardana
90d0303b3b fix: add proper handling to remove key bindings 2025-10-28 19:02:27 +05:30
Ritvik Sardana
62168f7d4f refactor: shortcut handling 2025-10-28 19:02:27 +05:30
Ritvik Sardana
6892132128 fix: handle delete_doc in sqlite search 2025-10-28 19:02:27 +05:30
Ritvik Sardana
cb715e8a2a fix: replace old shortcuts with new API 2025-10-28 19:02:25 +05:30
Ritvik Sardana
64fe77005e fix: add shortcuts for ticket navigation 2025-10-28 19:01:53 +05:30
Ritvik Sardana
33089a6a0c fix: add shortcuts for reply&comment 2025-10-28 19:01:53 +05:30
Ritvik Sardana
79f04ff750 fix: add shortcuts for core fields 2025-10-28 19:01:53 +05:30
Ritvik Sardana
59e57b8d56 fix: better way to disable shortcuts 2025-10-28 19:01:53 +05:30
Ritvik Sardana
46b74ae70d feat: key bindings 2025-10-28 19:01:21 +05:30
RitvikSardana
a6cbabbaba Merge pull request #2696 from frappe/mergify/bp/main-hotfix/pr-2678
chore: update POT file (backport #2678)
2025-10-28 18:46:39 +05:30
RitvikSardana
ece2092932 Merge pull request #2695 from frappe/mergify/bp/main-hotfix/pr-2679
chore: sync translations from crowdin (backport #2679)
2025-10-28 18:46:35 +05:30
RitvikSardana
a0bd028496 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2678 2025-10-28 18:37:38 +05:30
RitvikSardana
f214c73b02 Merge pull request #2694 from frappe/mergify/bp/main-hotfix/pr-2687
chore: update POT file (backport #2687)
2025-10-28 18:36:50 +05:30
RitvikSardana
99d43706cc Merge pull request #2693 from frappe/mergify/bp/main-hotfix/pr-2657
fix: handle json parse error & json validation on doctype save (backport #2657)
2025-10-28 18:36:42 +05:30
RitvikSardana
0845564356 Merge pull request #2683 from efeone/main-hotfix
fix: update required_apps to include frappe/telephony
2025-10-28 18:35:20 +05:30
frappe-pr-bot
955dc75078 chore: update POT file
(cherry picked from commit 012cfd179b)
2025-10-28 13:02:14 +00:00
RitvikSardana
3b48f660b2 Merge pull request #2692 from frappe/mergify/bp/main-hotfix/pr-2688
fix: prevent usermenu button overflow in sidebar (backport #2688)
2025-10-28 18:31:42 +05:30
RitvikSardana
8e5986b6b9 chore: Polish translations
(cherry picked from commit 5300f24b10)
2025-10-28 13:01:14 +00:00
RitvikSardana
040c4569d5 chore: Polish translations
(cherry picked from commit 03bf47d082)
2025-10-28 13:01:14 +00:00
RitvikSardana
8b052f2764 chore: Persian translations
(cherry picked from commit e2ed80369c)
2025-10-28 13:01:14 +00:00
frappe-pr-bot
eafd4ee634 chore: update POT file
(cherry picked from commit da4008d6ab)
2025-10-28 13:00:10 +00:00
Ritvik Sardana
e53d52c2b7 chore: format error message in assignment rule
(cherry picked from commit 2b62a32f55)
2025-10-28 12:58:06 +00:00
Pratik Badhe
d3fbfb5169 fix: handle long email and name in assignee search
(cherry picked from commit aa0c887504)
2025-10-28 12:58:06 +00:00
Pratik Badhe
34f64f2d33 fix: bug in assignment rule overwrite handling
(cherry picked from commit 4fd9a38860)
2025-10-28 12:58:06 +00:00
Pratik Badhe
0d39e36a46 fix: assignment rule conditions
(cherry picked from commit 22d06eea8a)
2025-10-28 12:58:06 +00:00
Pratik Badhe
bc70ca90d5 fix: show toast if conditions are invalid
(cherry picked from commit 5cce62efef)
2025-10-28 12:58:05 +00:00
Pratik Badhe
b66717731c fix: handle json parse error & json validation on doctype save
(cherry picked from commit 746fb39d1c)
2025-10-28 12:58:05 +00:00
siduck
76ee72c0ea fix: prevent usermenu button overflow in sidebar
Related to https://github.com/frappe/frappe-ui/issues/298

(cherry picked from commit 856a161164)
2025-10-28 12:49:02 +00:00
Ritvik Sardana
e31833a486 fix(sqlite_search): convert to int only when doctype is HD Ticket
(cherry picked from commit 652188cc64)
2025-10-28 10:53:12 +00:00
Ritvik Sardana
0ec14e4ed6 fix: add handling bad_emails in process_mail
(cherry picked from commit 943da1be93)
2025-10-28 10:53:12 +00:00
Ritvik Sardana
bd57310ee6 fix: decoding the receiving email properly with try except
(cherry picked from commit d7225a82c0)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
2c3b1d3a22 fix: dont add build_index function in hooks.py of helpdesk
(cherry picked from commit a25c9c0a8a)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
c6b23de4f1 chore: cleanup
(cherry picked from commit e64fe01ed8)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
998449b71c fix: add support for numberFields in side panel
(cherry picked from commit 9e1d493ef5)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
5c79767406 fix: remove unwanted imports
(cherry picked from commit 8b1bd75348)

# Conflicts:
#	desk/src/components/ticket-agent/AssignTo.vue
#	desk/src/components/ticket-agent/TicketDetailsTab.vue
#	desk/src/components/ticket-agent/TicketNavigation.vue
#	desk/src/pages/SearchAgent.vue
2025-10-28 10:53:11 +00:00
MochaMind
b29bc60aa9 Merge pull request #2685 from frappe/main-hotfix
chore: Merge hotfix into main
2025-10-24 02:53:48 -07:00
Sherin KR
81af8b5e98 fix: update required_apps to include frappe/telephony 2025-10-24 10:00:23 +05:30
RitvikSardana
7b1c9152fa Merge pull request #2674 from frappe/mergify/bp/main-hotfix/pr-2667
chore: update POT file (backport #2667)
2025-10-15 15:28:11 +05:30
RitvikSardana
f432260f65 Merge pull request #2673 from frappe/mergify/bp/main-hotfix/pr-2652
chore: sync translations from crowdin (backport #2652)
2025-10-15 15:28:05 +05:30
frappe-pr-bot
a1a37e2102 chore: update POT file
(cherry picked from commit f6eec48671)
2025-10-15 09:51:18 +00:00
RitvikSardana
b85c38fc5d chore: Serbian (Latin) translations
(cherry picked from commit f9158f4bcf)
2025-10-15 09:50:22 +00:00
RitvikSardana
7623835cd0 chore: Serbian (Cyrillic) translations
(cherry picked from commit 246044776f)
2025-10-15 09:50:22 +00:00
RitvikSardana
c1eeaf3748 chore: Polish translations
(cherry picked from commit 25f33001ae)
2025-10-15 09:50:21 +00:00
RitvikSardana
f94c0fa609 chore: Polish translations
(cherry picked from commit de096bf1bc)
2025-10-15 09:50:21 +00:00
RitvikSardana
a8807155d7 chore: Burmese translations
(cherry picked from commit aac3b105d9)
2025-10-15 09:50:21 +00:00
RitvikSardana
d740c208e7 chore: Tamil translations
(cherry picked from commit 98e4852f43)
2025-10-15 09:50:21 +00:00
RitvikSardana
e951f5df1a chore: Esperanto translations
(cherry picked from commit f7ede8edb0)
2025-10-15 09:50:20 +00:00
RitvikSardana
8012c5c3a7 chore: Serbian (Latin) translations
(cherry picked from commit 76dc90c563)
2025-10-15 09:50:20 +00:00
RitvikSardana
ee13da2bb3 chore: Norwegian Bokmal translations
(cherry picked from commit 24ab3d735b)
2025-10-15 09:50:20 +00:00
RitvikSardana
70b08d2f0a chore: Bosnian translations
(cherry picked from commit 75348d5266)
2025-10-15 09:50:19 +00:00
RitvikSardana
8b57956505 chore: Croatian translations
(cherry picked from commit 037742eacb)
2025-10-15 09:50:19 +00:00
RitvikSardana
1aacf5b75f chore: Thai translations
(cherry picked from commit 31ac3b4bbb)
2025-10-15 09:50:19 +00:00
RitvikSardana
240b1b0a64 chore: Persian translations
(cherry picked from commit 3afcd2f43c)
2025-10-15 09:50:19 +00:00
RitvikSardana
9fef721e60 chore: Indonesian translations
(cherry picked from commit db71ab4744)
2025-10-15 09:50:18 +00:00
RitvikSardana
cabfd3499d chore: Portuguese, Brazilian translations
(cherry picked from commit b6fb4a1024)
2025-10-15 09:50:18 +00:00
RitvikSardana
be8a78a795 chore: Vietnamese translations
(cherry picked from commit 11da05c81e)
2025-10-15 09:50:18 +00:00
RitvikSardana
2e6f00cc1f chore: Chinese Simplified translations
(cherry picked from commit 95dcd882db)
2025-10-15 09:50:18 +00:00
RitvikSardana
b872bbab30 chore: Turkish translations
(cherry picked from commit 6b010d34d4)
2025-10-15 09:50:17 +00:00
RitvikSardana
ccfa7c4c72 chore: Swedish translations
(cherry picked from commit e4f0f7ace0)
2025-10-15 09:50:17 +00:00
RitvikSardana
df544c114e chore: Serbian (Cyrillic) translations
(cherry picked from commit 42b89e65cf)
2025-10-15 09:50:17 +00:00
RitvikSardana
2f21abd777 chore: Russian translations
(cherry picked from commit 131f03a0f6)
2025-10-15 09:50:17 +00:00
RitvikSardana
1581970179 chore: Portuguese translations
(cherry picked from commit d667d15ee9)
2025-10-15 09:50:16 +00:00
RitvikSardana
48164ca420 chore: Polish translations
(cherry picked from commit f3cc7920bd)
2025-10-15 09:50:16 +00:00
RitvikSardana
989ff6b2ab chore: Dutch translations
(cherry picked from commit 790bbcd772)
2025-10-15 09:50:16 +00:00
RitvikSardana
39391ab607 chore: Italian translations
(cherry picked from commit 11a608ead6)
2025-10-15 09:50:15 +00:00
RitvikSardana
ca48068aa4 chore: Hungarian translations
(cherry picked from commit 785e087e95)
2025-10-15 09:50:15 +00:00
RitvikSardana
4496bbdbed chore: German translations
(cherry picked from commit 0c1ad5e944)
2025-10-15 09:50:15 +00:00
RitvikSardana
4bbfe8e792 chore: Danish translations
(cherry picked from commit 29f6a71881)
2025-10-15 09:50:15 +00:00
RitvikSardana
13a29cae8a chore: Czech translations
(cherry picked from commit 04a05dd50b)
2025-10-15 09:50:14 +00:00
RitvikSardana
81314c422a chore: Arabic translations
(cherry picked from commit 2e2ae7e7db)
2025-10-15 09:50:14 +00:00
RitvikSardana
0b5f733c55 chore: Spanish translations
(cherry picked from commit 23cfb9afd8)
2025-10-15 09:50:14 +00:00
RitvikSardana
0ab6c17ef2 chore: French translations
(cherry picked from commit ee3d1e2dea)
2025-10-15 09:50:14 +00:00
RitvikSardana
f09e519b47 chore: Burmese translations
(cherry picked from commit 23260de0d7)
2025-10-15 09:50:13 +00:00
RitvikSardana
5276814156 chore: Portuguese, Brazilian translations
(cherry picked from commit 5959048cae)
2025-10-15 09:50:13 +00:00
RitvikSardana
c5b34bda25 Merge pull request #2663 from frappe/mergify/bp/main-hotfix/pr-2656
fix: reply all 'To' email bug (backport #2656)
2025-10-15 15:19:50 +05:30
RitvikSardana
ab34ac8fff Merge pull request #2662 from frappe/mergify/bp/main-hotfix/pr-2649
chore: update POT file (backport #2649)
2025-10-15 15:19:43 +05:30
Pratik Badhe
56b85aada5 fix: reply all 'To' email bug
(cherry picked from commit 4636ffab67)
2025-10-10 07:15:51 +00:00
RitvikSardana
07e79e2572 Merge pull request #2661 from frappe/mergify/bp/main-hotfix/pr-2647
fix: docker bash file (backport #2647)
2025-10-10 12:41:58 +05:30
frappe-pr-bot
bb9927d252 chore: update POT file
(cherry picked from commit dee4cdf688)
2025-10-10 07:11:51 +00:00
Pratik Badhe
e08e1c94fd fix: docker bash file
(cherry picked from commit 043780c85d)
2025-10-10 07:01:19 +00:00
RitvikSardana
b2934885b1 Merge pull request #2660 from frappe/mergify/bp/main-hotfix/pr-2659
fix: bump vueuse to fix builds (backport #2659)
2025-10-10 12:14:44 +05:30
18alantom
b2fac92338 fix: bump vueuse to fix builds
(cherry picked from commit 4099373b62)
2025-10-10 06:08:32 +00:00
RitvikSardana
52bfc91781 Merge pull request #2645 from frappe/mergify/bp/main-hotfix/pr-2641
chore: sync translations from crowdin (backport #2641)
2025-10-03 12:28:03 +05:30
RitvikSardana
5507ee420d Merge pull request #2644 from frappe/mergify/bp/main-hotfix/pr-2639
fix: disable command palette when text editor is enabled (backport #2639)
2025-10-03 12:27:44 +05:30
RitvikSardana
8d7201c477 chore: Tamil translations
(cherry picked from commit 9afc83e633)
2025-10-03 06:04:57 +00:00
RitvikSardana
0b3174be90 chore: Esperanto translations
(cherry picked from commit 9f017522f0)
2025-10-03 06:04:57 +00:00
RitvikSardana
b3eeba27b0 chore: Serbian (Latin) translations
(cherry picked from commit 4bddc7f0ef)
2025-10-03 06:04:56 +00:00
RitvikSardana
35b5a2e82d chore: Norwegian Bokmal translations
(cherry picked from commit b470a17913)
2025-10-03 06:04:56 +00:00
RitvikSardana
4a56f45da9 chore: Bosnian translations
(cherry picked from commit 00a2efbfbb)
2025-10-03 06:04:56 +00:00
RitvikSardana
2443223a91 chore: Croatian translations
(cherry picked from commit 54b1854bdf)
2025-10-03 06:04:55 +00:00
RitvikSardana
9cc28b6789 chore: Thai translations
(cherry picked from commit dbe03e6fd0)
2025-10-03 06:04:55 +00:00
RitvikSardana
36c3dd6509 chore: Persian translations
(cherry picked from commit 309c084222)
2025-10-03 06:04:55 +00:00
RitvikSardana
abf2248d3e chore: Indonesian translations
(cherry picked from commit 51f2a5e428)
2025-10-03 06:04:55 +00:00
RitvikSardana
ac9e854b34 chore: Portuguese, Brazilian translations
(cherry picked from commit 5343a006ef)
2025-10-03 06:04:54 +00:00
RitvikSardana
6a943f0000 chore: Vietnamese translations
(cherry picked from commit 2caf0e94b8)
2025-10-03 06:04:54 +00:00
RitvikSardana
1cf28aab92 chore: Chinese Simplified translations
(cherry picked from commit 8c9eab3d71)
2025-10-03 06:04:54 +00:00
RitvikSardana
7f9ab5eb96 chore: Turkish translations
(cherry picked from commit 8790567a50)
2025-10-03 06:04:53 +00:00
RitvikSardana
d40f71ff5c chore: Swedish translations
(cherry picked from commit 7a75306222)
2025-10-03 06:04:52 +00:00
RitvikSardana
7936c1243d chore: Serbian (Cyrillic) translations
(cherry picked from commit a1e4bb2555)
2025-10-03 06:04:52 +00:00
RitvikSardana
dba45334c0 chore: Russian translations
(cherry picked from commit 0a0fc03f70)
2025-10-03 06:04:52 +00:00
RitvikSardana
51e35a5838 chore: Portuguese translations
(cherry picked from commit 24b30fb9cb)
2025-10-03 06:04:52 +00:00
RitvikSardana
a9d3a39c56 chore: Polish translations
(cherry picked from commit 6c40f9b2a5)
2025-10-03 06:04:51 +00:00
RitvikSardana
f490014415 chore: Dutch translations
(cherry picked from commit 6e59e71c7c)
2025-10-03 06:04:51 +00:00
RitvikSardana
5733c5fff1 chore: Italian translations
(cherry picked from commit 95e10e43a8)
2025-10-03 06:04:51 +00:00
RitvikSardana
8e395cdeab chore: Hungarian translations
(cherry picked from commit 88396d22a9)
2025-10-03 06:04:50 +00:00
RitvikSardana
3f97ae78b3 chore: German translations
(cherry picked from commit 207b8efcc5)
2025-10-03 06:04:50 +00:00
RitvikSardana
eef3ea7a6f chore: Danish translations
(cherry picked from commit 309cd9e417)
2025-10-03 06:04:50 +00:00
RitvikSardana
325d6ec506 chore: Czech translations
(cherry picked from commit 69fd0d17ac)
2025-10-03 06:04:50 +00:00
RitvikSardana
69ae56d923 chore: Arabic translations
(cherry picked from commit 02cf1d1375)
2025-10-03 06:04:49 +00:00
RitvikSardana
ef830ebbb6 chore: Spanish translations
(cherry picked from commit faade64fb5)
2025-10-03 06:04:49 +00:00
RitvikSardana
6591d5aec5 chore: French translations
(cherry picked from commit 9deb301619)
2025-10-03 06:04:49 +00:00
Ritvik Sardana
5027b876a3 fix: disable command palette when text editor is enabled
(cherry picked from commit 105e8daff6)
2025-10-03 06:04:37 +00:00
RitvikSardana
f34d744f36 Merge pull request #2640 from frappe/mergify/bp/main-hotfix/pr-2625
chore: update POT file (backport #2625)
2025-10-03 11:32:46 +05:30
frappe-pr-bot
e61525de50 chore: update POT file
(cherry picked from commit 5bbfeaff35)
2025-10-01 15:47:14 +00:00
RitvikSardana
1fcc8f7a28 Merge pull request #2638 from frappe/mergify/bp/main-hotfix/pr-2624
chore: sync translations from crowdin (backport #2624)
2025-10-01 21:16:59 +05:30
RitvikSardana
a0fe934e36 chore: Tamil translations
(cherry picked from commit 5d9bdedb22)
2025-10-01 15:37:01 +00:00
RitvikSardana
0bd667223b chore: Persian translations
(cherry picked from commit add112ff9a)
2025-10-01 15:37:01 +00:00
RitvikSardana
62f1d6a3f8 chore: Portuguese, Brazilian translations
(cherry picked from commit 9bc28bec9e)
2025-10-01 15:36:59 +00:00
RitvikSardana
a69b1259ca chore: Vietnamese translations
(cherry picked from commit 5b768c0e4f)
2025-10-01 15:36:58 +00:00
RitvikSardana
36a9252ce3 chore: Chinese Simplified translations
(cherry picked from commit 9d7bcfcbec)
2025-10-01 15:36:58 +00:00
RitvikSardana
48f8c79898 chore: Polish translations
(cherry picked from commit f658054549)
2025-10-01 15:36:57 +00:00
RitvikSardana
ce7b2ec996 chore: Hungarian translations
(cherry picked from commit 1919abfcea)
2025-10-01 15:36:57 +00:00
RitvikSardana
8b548d699e chore: Arabic translations
(cherry picked from commit d058962498)
2025-10-01 15:36:57 +00:00
RitvikSardana
c8971ed41f chore: Spanish translations
(cherry picked from commit 0434986a00)
2025-10-01 15:36:55 +00:00
RitvikSardana
44d28502f4 chore: French translations
(cherry picked from commit 6c6941d53c)
2025-10-01 15:36:54 +00:00
RitvikSardana
8bd6b02233 chore: Norwegian Bokmal translations
(cherry picked from commit 32447b2e77)
2025-10-01 15:36:54 +00:00
RitvikSardana
04ebf1c056 chore: Hungarian translations
(cherry picked from commit 557e0c1d61)
2025-10-01 15:36:54 +00:00
RitvikSardana
fe508b6114 chore: Norwegian Bokmal translations
(cherry picked from commit 6ea1fb316a)
2025-10-01 15:36:53 +00:00
RitvikSardana
b5793d0f2c Merge pull request #2637 from frappe/mergify/bp/main-hotfix/pr-2594
feat: Full Text Search using SQLite (backport #2594)
2025-10-01 21:05:11 +05:30
RitvikSardana
019ca9c58f chore: bump version 2025-10-01 16:30:18 +05:30
Ritvik Sardana
923241e450 chore: version bump
(cherry picked from commit 74a434acac)

# Conflicts:
#	helpdesk/__init__.py
2025-10-01 07:07:10 +00:00
Ritvik Sardana
32527cbf59 fix: search icon and placeholder
(cherry picked from commit ecabb8d55d)
2025-10-01 07:07:10 +00:00
Ritvik Sardana
aca4a01aed fix: persist filters and remove dump code from hooks and search_sqlite
(cherry picked from commit e9e5a58154)
2025-10-01 07:07:09 +00:00
Ritvik Sardana
b78d2e0b1b fix: dont index same doc thrice
(cherry picked from commit 741200e217)
2025-10-01 07:07:09 +00:00
Ritvik Sardana
d794ecc8b9 fix: hide dialog after deleting a comment
(cherry picked from commit 5029510766)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
9ecdd96ee4 fix: add shortcut to focus on search bar
(cherry picked from commit 16b358b599)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
09446a7ec7 fix: show white icon for online and offline
(cherry picked from commit 0248fcfc48)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
9ea3ea71d9 chore: update yarn.lock
(cherry picked from commit 232b30ebdd)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
c094193b59 fix: linters
(cherry picked from commit 7ade7b602b)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
ca0d59f7e6 fix: add scrollbar in custom fields of customer portal
(cherry picked from commit da7bf3d861)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
a0cf892ea8 fix: dont show count
(cherry picked from commit f8280ac203)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
7aed2a563f fix: convert reference_ticket value to int
(cherry picked from commit aad177fc73)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
7640087fb5 fix: search results with permission query filters
(cherry picked from commit ed06002cfc)
2025-10-01 07:07:06 +00:00
Ritvik Sardana
26e547aa36 fix: ticket not found error
(cherry picked from commit 063c8aeea4)
2025-10-01 07:07:06 +00:00
Ritvik Sardana
88b9b9a28b fix: convert command pallete to composition API from options API
(cherry picked from commit efa9ca438e)
2025-10-01 07:07:06 +00:00
Ritvik Sardana
c0b661d9a8 fix: ticket agent scroll
(cherry picked from commit 02080dd4c0)
2025-10-01 07:07:05 +00:00
Faris Ansari
fa2c323ee0 feat: Full Text Search using SQLite
- dedicated search page
- route to ticket and scroll to relevant comment or email
- wip

(cherry picked from commit 0c2967f503)
2025-10-01 07:07:05 +00:00
RitvikSardana
bebcde4144 Merge pull request #2622 from frappe/mergify/bp/main-hotfix/pr-2621
fix: respect read_only fields (backport #2621)
2025-09-29 23:17:01 +05:30
Ritvik Sardana
a071cd7a67 fix: respect read_only fields
(cherry picked from commit 3317f1446b)
2025-09-26 10:43:01 +00:00
RitvikSardana
4ec1299d74 Merge pull request #2620 from frappe/mergify/bp/main-hotfix/pr-2617
fix: add disable field to Feedback Option (backport #2617)
2025-09-26 16:11:28 +05:30
RitvikSardana
fc4a9ac96b Merge pull request #2619 from frappe/mergify/bp/main-hotfix/pr-2618
fix: fetch notifications after the user is fetched (backport #2618)
2025-09-26 16:11:18 +05:30
Ritvik Sardana
abcab05fbe fix: validate for old options only
(cherry picked from commit 0bf79c7be8)
2025-09-26 10:29:35 +00:00
Ritvik Sardana
aed4198add fix: fetch notifications after the user is fetched
(cherry picked from commit 916f039ed3)
2025-09-26 10:29:34 +00:00
Ritvik Sardana
315ae1dd34 fix: add disable field to Feedback Option
(cherry picked from commit 8520a225d1)
2025-09-26 10:29:34 +00:00
RitvikSardana
868304ffc8 Merge pull request #2616 from frappe/main-hotfix
chore: merge hotfix to main
2025-09-26 13:46:27 +05:30
RitvikSardana
0c5c96c3fe Merge pull request #2613 from frappe/mergify/bp/main-hotfix/pr-2610
fix: search in Canned responses  (backport #2610)
2025-09-25 21:06:29 +05:30
RitvikSardana
1a408be504 Merge pull request #2612 from frappe/mergify/bp/main-hotfix/pr-2595
chore: sync translations from crowdin (backport #2595)
2025-09-25 20:37:52 +05:30
Ritvik Sardana
fff8e5346b fix: add debounce in search
(cherry picked from commit f0060c24c2)
2025-09-25 15:07:32 +00:00
MhmdSinanKT
deee7efbce feat: enhance search with or filter
(cherry picked from commit 2b6e54a941)
2025-09-25 15:07:32 +00:00
MhmdSinanKT
cd5f05c059 feat: search for canned responses in hd view
(cherry picked from commit 85e8d9d2e9)
2025-09-25 15:07:32 +00:00
RitvikSardana
3ecbd4a555 Merge pull request #2611 from frappe/mergify/bp/main-hotfix/pr-2604
chore: update POT file (backport #2604)
2025-09-25 20:37:12 +05:30
RitvikSardana
6b76acbaa2 chore: Norwegian Bokmal translations
(cherry picked from commit d67ad04e89)
2025-09-25 15:02:43 +00:00
RitvikSardana
a22f73b93d chore: Norwegian Bokmal translations
(cherry picked from commit 5ea83dccdd)
2025-09-25 15:02:42 +00:00
RitvikSardana
c05b9a3aac chore: Persian translations
(cherry picked from commit db9426279e)
2025-09-25 15:02:42 +00:00
RitvikSardana
a33e34d2fa chore: Norwegian Bokmal translations
(cherry picked from commit f64e305f91)
2025-09-25 15:02:42 +00:00
RitvikSardana
2e8b172dcc chore: Norwegian Bokmal translations
(cherry picked from commit a55a148a78)
2025-09-25 15:02:41 +00:00
RitvikSardana
773ae4c476 chore: Norwegian Bokmal translations
(cherry picked from commit b93128d390)
2025-09-25 15:02:41 +00:00
RitvikSardana
229bf8a2a7 chore: Serbian (Latin) translations
(cherry picked from commit d4936a833f)
2025-09-25 15:02:41 +00:00
RitvikSardana
7566c761ef chore: Serbian (Cyrillic) translations
(cherry picked from commit 781fa723e4)
2025-09-25 15:02:40 +00:00
RitvikSardana
1f4b565bb7 chore: Esperanto translations
(cherry picked from commit d07a8765ed)
2025-09-25 15:02:40 +00:00
RitvikSardana
fe61cb822d chore: Serbian (Latin) translations
(cherry picked from commit f513485016)
2025-09-25 15:02:40 +00:00
RitvikSardana
df1ba3bb7f chore: Norwegian Bokmal translations
(cherry picked from commit e866951197)
2025-09-25 15:02:39 +00:00
RitvikSardana
7e70bef84e chore: Bosnian translations
(cherry picked from commit 7bc623051c)
2025-09-25 15:02:39 +00:00
RitvikSardana
536201602e chore: Croatian translations
(cherry picked from commit 998b277216)
2025-09-25 15:02:39 +00:00
RitvikSardana
61c1c58c41 chore: Thai translations
(cherry picked from commit dbcf6fe866)
2025-09-25 15:02:38 +00:00
RitvikSardana
a2e1fe50b1 chore: Persian translations
(cherry picked from commit 0bd75cec41)
2025-09-25 15:02:38 +00:00
RitvikSardana
7b67a03b90 chore: Indonesian translations
(cherry picked from commit 4e37c4c1d5)
2025-09-25 15:02:38 +00:00
RitvikSardana
168d296d73 chore: Portuguese, Brazilian translations
(cherry picked from commit 6f6b04e6a9)
2025-09-25 15:02:38 +00:00
RitvikSardana
c1c183f2fd chore: Vietnamese translations
(cherry picked from commit 1410bd82cf)
2025-09-25 15:02:37 +00:00
RitvikSardana
1a03488849 chore: Chinese Simplified translations
(cherry picked from commit 6eecc8d8aa)
2025-09-25 15:02:37 +00:00
RitvikSardana
ee48296c16 chore: Turkish translations
(cherry picked from commit 58b27e0261)
2025-09-25 15:02:37 +00:00
RitvikSardana
9b0620b5cd chore: Swedish translations
(cherry picked from commit 4e6a00823f)
2025-09-25 15:02:36 +00:00
RitvikSardana
81674811d8 chore: Serbian (Cyrillic) translations
(cherry picked from commit e599fd2e5a)
2025-09-25 15:02:36 +00:00
RitvikSardana
a985f72651 chore: Russian translations
(cherry picked from commit cee68f1b0d)
2025-09-25 15:02:36 +00:00
RitvikSardana
2264700c53 chore: Portuguese translations
(cherry picked from commit 6358533179)
2025-09-25 15:02:35 +00:00
RitvikSardana
e2fabb1a15 chore: Polish translations
(cherry picked from commit 117aa24a0a)
2025-09-25 15:02:35 +00:00
RitvikSardana
71a0628dab chore: Dutch translations
(cherry picked from commit ff91bd0c15)
2025-09-25 15:02:35 +00:00
RitvikSardana
aa25a2c521 chore: Italian translations
(cherry picked from commit 647fa443fc)
2025-09-25 15:02:34 +00:00
RitvikSardana
db95c35cbb chore: Hungarian translations
(cherry picked from commit 5be3593a13)
2025-09-25 15:02:34 +00:00
RitvikSardana
5cbefdb710 chore: German translations
(cherry picked from commit 93dd13deaf)
2025-09-25 15:02:34 +00:00
RitvikSardana
7535a2d6a6 chore: Danish translations
(cherry picked from commit d86fdba523)
2025-09-25 15:02:34 +00:00
RitvikSardana
9fce60e6fe chore: Czech translations
(cherry picked from commit a53450f740)
2025-09-25 15:02:33 +00:00
RitvikSardana
51c81d8111 chore: Arabic translations
(cherry picked from commit f7929ca621)
2025-09-25 15:02:33 +00:00
RitvikSardana
214d04d69f chore: Spanish translations
(cherry picked from commit dab44e62d5)
2025-09-25 15:02:33 +00:00
frappe-pr-bot
6163562235 chore: update POT file
(cherry picked from commit b125c63d7b)
2025-09-25 15:02:32 +00:00
RitvikSardana
5699356dba chore: French translations
(cherry picked from commit dd81a4cdad)
2025-09-25 15:02:32 +00:00
RitvikSardana
5d1bcef3c2 Merge pull request #2609 from frappe/mergify/bp/main-hotfix/pr-2608
fix: email reply all bug (backport #2608)
2025-09-25 20:31:30 +05:30
Pratik Badhe
1d9c75119b fix: email reply all bug
(cherry picked from commit 14f1acea19)
2025-09-25 14:35:37 +00:00
RitvikSardana
6bc673302f Merge pull request #2597 from frappe/mergify/bp/main-hotfix/pr-2596
fix(desk): don't call mark seen api on new ticket (backport #2596)
2025-09-21 15:31:39 +05:30
RitvikSardana
e8a384dacd chore: Bump version to 1.15.1 2025-09-20 17:47:07 +05:30
RitvikSardana
5f16f0f49e Merge pull request #2603 from frappe/mergify/bp/main-hotfix/pr-2602
fix: add telephony in easy install script (backport #2602)
2025-09-20 17:38:50 +05:30
Ritvik Sardana
705820ffad fix: add telephony in easy install script
(cherry picked from commit 096381bb5c)
2025-09-20 12:07:09 +00:00
Ritvik Sardana
283fc81091 fix(desk): dont call mark seen api on new ticket
(cherry picked from commit 96efa8624f)
2025-09-18 09:52:19 +00:00
RitvikSardana
83b7a94756 Merge pull request #2589 from frappe/main-hotfix
chore(release): hotfix to main
2025-09-17 00:24:06 +05:30
RitvikSardana
bf6372b570 Merge pull request #2588 from frappe/mergify/bp/main-hotfix/pr-2587
fix: update portal url param (backport #2587)
2025-09-17 00:22:31 +05:30
Ritvik Sardana
7a34ac4423 chore: bump version
(cherry picked from commit ee4f111f05)
2025-09-16 18:52:06 +00:00
Ritvik Sardana
a83dc0417d fix: update portal url param
(cherry picked from commit f671d05ba2)
2025-09-16 18:52:06 +00:00
RitvikSardana
efb7c6e21e chore: update frappe version 2025-09-16 17:15:37 +05:30
754 changed files with 45863 additions and 200941 deletions

View File

@@ -9,7 +9,7 @@ sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
git clone "https://github.com/frappe/frappe" --branch "develop" --depth 1
git clone "https://github.com/frappe/frappe" --branch "version-15" --depth 1
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site
@@ -40,7 +40,6 @@ sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
bench get-app erpnext --branch "develop"
bench get-app telephony
bench get-app helpdesk "${GITHUB_WORKSPACE}"
bench setup requirements --dev
@@ -49,7 +48,3 @@ bench setup requirements --dev
bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
bench --verbose --site test_site install-app erpnext
bench --verbose --site test_site install-app telephony
bench --verbose --site test_site install-app helpdesk

View File

@@ -5,7 +5,7 @@ cd ~ || exit
echo "Setting Up Bench..."
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch develop
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
cd ./frappe-bench || exit
@@ -38,4 +38,4 @@ gh auth setup-git
git push -u upstream "${branch_name}"
echo "Creating a PR..."
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/helpdesk
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/helpdesk

View File

@@ -27,13 +27,13 @@ jobs:
steps:
- name: Update notes
run: |
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/generate-notes -f tag_name=$RELEASE_TAG \
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/generate-notes -f tag_name=$RELEASE_TAG \
| jq -r '.body' \
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
| sed -E 's/by @mergify //'
)
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/$RELEASE_ID -f body="$NEW_NOTES"
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/$RELEASE_ID -f body="$NEW_NOTES"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,15 +0,0 @@
name: Pre-build assets
on:
push:
branches: [develop, main]
jobs:
assets:
name: Pre-build assets
uses: frappe/pilot/.github/workflows/app-assets.yml@develop
with:
# main supports Frappe v15 and v16; Pilot benches run v16 or later.
frappe-branch: ${{ github.ref_name == 'main' && 'version-16' || 'develop' }}
permissions:
contents: write

View File

@@ -13,15 +13,21 @@ jobs:
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
matrix:
arch: [amd64, arm64]
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/${{ matrix.arch }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
@@ -32,7 +38,8 @@ jobs:
- name: Set Branch
run: |
echo '[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]' > apps.json
export APPS_JSON='[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
- name: Set Image Tag
@@ -44,10 +51,10 @@ jobs:
repository: frappe/frappe_docker
path: builds
- name: Build
- name: Build and push
uses: docker/build-push-action@v6
with:
load: true
push: true
context: builds
file: builds/images/layered/Containerfile
tags: >
@@ -55,14 +62,4 @@ jobs:
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
secret-files: |
apps_json=apps.json
- name: Verify apps are installed
run: |
apps=$(docker run --rm --entrypoint ls ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} apps)
echo "$apps"
grep -qx helpdesk <<< "$apps" && grep -qx telephony <<< "$apps"
- name: Push
run: docker push --all-tags ghcr.io/${{ github.repository }}
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"

View File

@@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop", "main-hotfix"]
branch: ["develop"]
permissions:
contents: write
@@ -25,12 +25,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
python-version: "3.12"
- name: Run script to update POT file
run: |

View File

@@ -17,7 +17,7 @@ jobs:
fetch-depth: 200
- uses: actions/setup-node@v3
with:
node-version: 24
node-version: 18
check-latest: true
- name: Check commit titles
run: |
@@ -35,7 +35,7 @@ jobs:
- name: Set up Python 3.10
uses: actions/setup-python@v2
with:
python-version: '3.14'
python-version: '3.10'
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.1

View File

@@ -1,32 +0,0 @@
name: Generate Semantic Release
on:
workflow_dispatch:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"
GIT_AUTHOR_EMAIL: "developers@frappe.io"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "developers@frappe.io"
run: npx semantic-release

View File

@@ -40,7 +40,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.14'
python-version: '3.12'
- name: Check for valid Python & Merge Conflicts
if: github.event.pull_request.base.ref == 'develop'
@@ -54,7 +54,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 24
node-version: 18
check-latest: true
- name: Install Yarn
@@ -104,32 +104,7 @@ jobs:
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
- name: Run Tests
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app helpdesk --coverage'
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app helpdesk'
env:
TYPE: server
CI_BUILD_ID: ${{ github.run_id }}
- name: Upload coverage data
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: tests
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v4
- name: Upload coverage data
uses: codecov/codecov-action@v4
with:
name: MariaDB
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true

View File

@@ -1,187 +0,0 @@
name: UI Tests
on:
pull_request:
branches:
- develop
paths:
- "desk/**"
- "e2e/**"
- "playwright.config.ts"
- "package.json"
- "yarn.lock"
- ".github/workflows/ui-tests.yml"
workflow_dispatch:
concurrency:
group: ui-tests-helpdesk-${{ github.event.number || github.ref }}
cancel-in-progress: true
env:
FRAPPE_BRANCH: develop
SITE: helpdesk.test
jobs:
ui-tests:
name: Playwright E2E Tests (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2]
services:
redis-cache:
image: redis/redis-stack-server:latest
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379
mariadb:
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-
- name: Cache yarn
uses: actions/cache@v4
with:
path: ~/.cache/yarn
key: ${{ runner.os }}-yarn-ui-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-ui-
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install MariaDB Client
run: |
sudo apt update
sudo apt install -y mariadb-client
- name: Setup Bench
run: |
pip install frappe-bench
bench init --skip-redis-config-generation --skip-assets --frappe-branch "$FRAPPE_BRANCH" --python "$(which python)" ~/frappe-bench
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
- name: Install Helpdesk
working-directory: /home/runner/frappe-bench
run: |
bench get-app telephony
bench get-app helpdesk $GITHUB_WORKSPACE
bench setup requirements --dev
bench new-site --db-root-password root --admin-password admin --install-app helpdesk $SITE
bench use $SITE
bench --site $SITE set-config host_name "http://localhost:8000"
bench build --apps frappe,helpdesk
env:
CI: "Yes"
- name: Start Frappe Server
working-directory: /home/runner/frappe-bench
run: |
sed -i 's/^watch:/# watch:/g' Procfile
sed -i 's/^schedule:/# schedule:/g' Procfile
bench start &> bench_start.log &
timeout 120 bash -c "until curl -s http://localhost:8000 > /dev/null; do sleep 2; done"
- name: Install Playwright
run: |
yarn install --frozen-lockfile --ignore-scripts
npx playwright install --with-deps chromium
- name: Run Playwright Tests
run: npx playwright test --shard=${{ matrix.shard }}/2
env:
# localhost is a secure context, which crypto.randomUUID in frappe/ui needs.
BASE_URL: http://localhost:8000
ADMIN_PASSWORD: admin
- name: Upload Blob Report
uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 1
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-${{ matrix.shard }}
path: test-results/
retention-days: 7
- name: Show Bench Logs on Failure
if: failure()
working-directory: /home/runner/frappe-bench
run: |
cat bench_start.log || true
cat logs/*.log || true
merge-reports:
name: Merge Playwright Reports
needs: ui-tests
if: always()
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install Playwright
run: yarn install --frozen-lockfile --ignore-scripts
- name: Download Blob Reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge Into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

12
.gitignore vendored
View File

@@ -29,12 +29,6 @@ share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
.playwright-mcp
.claude-flow
.claude
.claude-worktrees
CLAUDE.md
helpdesk/docs/current
helpdesk/public/desk
@@ -42,8 +36,4 @@ helpdesk/public/frontend
helpdesk/public/node_modules
helpdesk/www/helpdesk/index.html
desk/components.d.ts
desk/stats.html
# Playwright
e2e/.auth
playwright-report
test-results
desk/stats.html

View File

@@ -1,42 +1,45 @@
pull_request_rules:
- name: Auto-close PRs on stable branch
conditions:
- and:
- author != RitvikSardana
- author != frappe-pr-bot
- author != mergify[bot]
- author != aerodeval
- or:
- base = main
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
- name: backport to develop
conditions:
- label = "backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to main-hotfix
conditions:
- label = "backport main-hotfix"
actions:
backport:
branches:
- main-hotfix
assignees:
- "{{ author }}"
- name: backport to main
conditions:
- label = "backport main"
actions:
backport:
branches:
- main
assignees:
- "{{ author }}"
- name: Auto-close PRs on stable branch
conditions:
- and:
- and:
- author!=RitvikSardana
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=main
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
- name: backport to develop
conditions:
- label="backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to main-hotfix
conditions:
- label="backport main-hotfix"
actions:
backport:
branches:
- main-hotfix
assignees:
- "{{ author }}"
- name: backport to main
conditions:
- label="backport main"
actions:
backport:
branches:
- main
assignees:
- "{{ author }}"

View File

@@ -4,19 +4,19 @@ fail_fast: false
repos:
- repo: https://github.com/psf/black
rev: 25.11.0
rev: 22.6.0
hooks:
- id: black
types_or: [python, pyi]
- repo: https://github.com/PyCQA/flake8
rev: 7.3.0
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies:
- flake8-bugbear
- flake8-comprehensions
- flake8-simplify==0.30.0
- flake8-simplify
args: ['--config', '.github/helpers/.flake8_strict']
- repo: https://github.com/pre-commit/mirrors-prettier

View File

@@ -1,21 +0,0 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" helpdesk/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["helpdesk/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
}
],
"@semantic-release/github"
]
}

150
AGENTS.md
View File

@@ -1,18 +1,5 @@
# Copilot Instructions for Frappe Helpdesk
## Skills
For Frappe development tasks, reference the frappe-dev skill:
- **Location**: `~/.agents/skills/frappe-dev/`
- **Usage**: When working on DocType, controllers, whitelisted APIs, background jobs, permissions, or frontend tasks, read the relevant skill reference files:
- `doctype.md` — Creating/modifying DocTypes and fields
- `controllers.md` — Document lifecycle and server logic
- `api.md` — REST endpoints with `@frappe.whitelist()`
- `database.md` — `frappe.db`, queries, raw SQL
- `background-jobs.md` — `frappe.enqueue`, scheduled jobs
- `permissions.md` — Roles, DocType permissions
- `frontend-desk.md`, `frontend-vue.md`, `frontend-portal.md` — Frontend references
## Project Overview
Frappe Helpdesk is an open-source ticket management tool built on the [Frappe Framework](https://github.com/frappe/frappe) (Python backend) and [Frappe UI](https://github.com/frappe/frappe-ui) (Vue.js frontend). It provides agent/customer portals, SLAs, assignment rules, a knowledge base, and saved replies.
@@ -157,12 +144,8 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
### Icons
- **Primary icon system**: Lucide icons via unplugin-icons for consistency and modern design
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage only)
- **Rule**: Never introduce `FeatherIcon` in new code. New components must use Lucide
icons (`<LucideInfo />`, `<LucidePlus />`, …) — no exceptions, including small
helper icons like tooltip/info glyphs.
- Use Lucide icons for all new components. They are globally auto-registered
(see `desk/components.d.ts`), so no import is needed when used as a template tag:
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage, prefer Lucide for new components)
- Use Lucide icons for all new components:
```vue
<template>
<LucidePlus class="size-4" />
@@ -215,9 +198,7 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
**Data Query Patterns**:
- Always prefer `frappe.get_list()` over `frappe.get_all()` for new code. It handles perms out of the box and is more flexible.
- `frappe.get_all()` is fine for child tables (e.g. `Has Role`, `HD Customer Member`), which have no permissions of their own.
- When `frappe.get_all()` is used on a parent DocType, make sure the caller may see the result some other way, for example a `frappe.has_permission(...)` check first, or `@agent_only` / `@agent_manager_only` (`helpdesk/utils.py`) on the endpoint.
- Always prefer `frappe.qb.get_query()` over `frappe.db.get_all()` for new code
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
- Example query patterns from helpdesk context
@@ -234,48 +215,6 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
- Use proper field types and validation in DocType definitions
- Leverage DocType methods for business logic encapsulation
## Controller Code Structure
All controller logic must follow this structure strictly:
**Rule: Lifecycle hooks call methods. Methods are defined below on the class.**
```python
class HDFoo(Document):
# 1. Lifecycle hooks at the top — each hook calls methods, no inline logic
def after_insert(self):
self.do_a()
self.do_b()
def on_update(self):
self.do_a()
# 2. Methods below — one responsibility each
def do_a(self):
pass
def do_b(self):
pass
```
**Rules:**
- Lifecycle hooks (`after_insert`, `on_update`, `before_save`, `on_trash`, `after_rename`, etc.) contain **no inline logic** — they only call `self.method()`.
- All logic lives in methods on the class, not in module-level helper functions.
- Methods that mutate a related document (e.g. an Assignment Rule) receive that doc as an argument and mutate it in memory. The **caller** is responsible for the final `doc.save()` — this keeps saves to a minimum (one save per hook).
- Never fetch the same document twice in one hook. Fetch once, pass the doc through the call chain.
- `@staticmethod` is allowed for pure transformations (e.g. building a condition string) that don't touch `self` or any Frappe doc.
- If there is an issue in framework/ui or frappe-ui or frappe framework, dont do a workaround. Instead, raise a blocker in the spec and wait for a fix. Do not implement workarounds that will be removed later. If possible, raise a PR to fix the issue in the framework instead of implementing a workaround in helpdesk.
**Save discipline:**
- Mutate all fields on a document before calling `.save()` once.
- Never call `.save()` inside a helper method — save in the hook or the method that owns the full mutation.
- Exception: if a method truly owns a complete document lifecycle (e.g. `create_assignment_rule` creates and fully initialises a new doc), it may save internally.
Take inspiration from "helpdesk.doctype.hd_team.hd_team.HDTeam" for examples of this pattern in practice.
## Conventions & Patterns
- **API Design:** Python modules in `helpdesk/api/` follow RESTful patterns. Extend via `helpdesk/extends/`.
@@ -374,7 +313,7 @@ const ticket = createDocumentResource({
- Use ESLint with Vue 3 recommended rules and Prettier for consistency
- Write meaningful commit messages and maintain clean git history
- Document complex logic with JSDoc/TSDoc comments
- Keep a comment to 2 lines at most. If it needs more to explain, simplify the code instead
- Only add comments that explain why something is done, not what is done
- Keep dependencies updated and audit for security vulnerabilities
## References
@@ -387,85 +326,4 @@ const ticket = createDocumentResource({
---
## Agent Working Rules
These rules apply to all agents working in this codebase. Follow them without exception.
### 1. Site Data — Read Before You Write
When a Frappe site is referenced in the conversation (e.g. `erp-hd.localhost`, `hd-tests`),
treat it as the **source of truth for current data state**.
- Before assuming anything about existing records, custom fields, settings, or DocType state —
query the site first using the bench venv Python or `bench execute`.
- **Never write data to a site unless the user explicitly asks you to.**
Querying is always safe. Mutations (insert, update, delete, migrate, patch) require explicit
user instruction.
- If you need to verify a fix worked, re-query the site — do not assume success from code
inspection alone.
```python
# Safe pattern for querying a site
import sys, os
sys.path.insert(0, 'apps/frappe')
os.chdir('sites')
import frappe
frappe.init(site='<site-name>', sites_path='.')
frappe.connect()
# ... read-only queries here ...
frappe.destroy()
```
Run via: `cd /path/to/frappe-bench && env/bin/python /tmp/your_script.py`
---
### 2. Spec-Driven Development — Keep the Spec Current
When a feature has a spec document (e.g. `docs/erpnext-customer-sync.md`), the spec is the
**living source of truth** for that feature. It must be kept in sync with the actual
implementation at all times.
**Rules:**
- **Update the spec in the same session as the code change.** Never defer spec updates to a
later session.
- **The spec reflects what is implemented, not what was originally planned.** If a decision
changes mid-build (e.g. removing a scheduler job, changing how a field is set), update the
spec immediately after the code change.
- **Removed features must be removed from the spec.** Do not leave stale sections, blockers, or
file manifest entries for things that no longer exist.
- **New features must be added to the spec.** If a new API, component, or behaviour is
introduced, add a corresponding section.
- **Decisions and their rationale belong in the spec.** If something was done a specific way to
avoid a bug or framework limitation (e.g. not setting `customer_group` to avoid the group-node
error), document that in the relevant Blocker section.
- When in doubt about whether a spec update is needed — update it.
---
### 3. Test Utilities — Always Use `helpdesk/test_utils.py`
All backend test helpers must live in `helpdesk/test_utils.py` and be imported from there.
Never define helper functions (factories, enable/disable toggles, fixture builders) directly
inside individual test files.
**Rules:**
- **Before writing a helper in a test file**, check `helpdesk/test_utils.py` first — it may
already exist.
- **If a helper doesn't exist**, add it to `helpdesk/test_utils.py` and import it in the test
file. Do not leave it inline.
- **If you write helpers inline during development**, move them to `test_utils.py` before
finishing the task.
- Helper functions in `test_utils.py` must have a docstring explaining what they create or do. Only if the function is completely self-explanatory (e.g. `enable_erpnext_sync`) can the docstring be omitted.
- Naming conventions:
- Factories: `make_<doctype>(...)` — e.g. `make_hd_customer`, `make_ticket`, `make_team`
- Toggle helpers: `enable_<feature>()` / `disable_<feature>()` — e.g. `enable_erpnext_sync`
- Query helpers: descriptive verb — e.g. `get_latest_ticket_communication`
**For updates, merge new conventions here. If anything is unclear or missing, ask for clarification.**
**In the end your code will be reviewed by Codex, but following these rules will help ensure a smooth review process and a high-quality codebase.**

View File

@@ -7,8 +7,7 @@
export {}
declare global {
const LucideAlignJustify: typeof import('~icons/lucide/align-justify')['default']
const LucideCornerUpLeft: typeof import('~icons/lucide/corner-up-left')['default']
const LucideFileSearch: typeof import('~icons/lucide/file-search')['default']
const LucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
const LucideTag: typeof import('~icons/lucide/tag')['default']
const LucideKeyboard: typeof import('~icons/lucide/keyboard')['default']
const LucideSearch: typeof import("~icons/lucide/search")["default"]
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ boot.lang or 'en' }}" dir="{{ boot.dir or 'ltr' }}">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -6,56 +6,41 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"postinstall": "patch-package"
"serve": "vite preview"
},
"dependencies": {
"@framework/ui": "link:../../frappe/ui",
"@headlessui/vue": "^1.7.22",
"@tailwindcss/typography": "^0.5.9",
"@tiptap/core": "^3.26.0",
"@tiptap/extension-table": "^3.26.0",
"@tiptap/extension-text-align": "^3.26.0",
"@tiptap/extension-text-style": "^3.26.0",
"@tiptap/pm": "^3.26.0",
"@tiptap/starter-kit": "^3.26.0",
"@tiptap/core": "^2.2.4",
"@twilio/voice-sdk": "^2.15.0",
"@vee-validate/zod": "^4.8.2",
"@vitejs/plugin-vue": "^4.6.2",
"@vueuse/core": "^14.1.0",
"@vueuse/integrations": "^14.1.0",
"@vitejs/plugin-vue": "^4.2.3",
"@vueuse/core": "^13.8.0",
"@vueuse/integrations": "^13.8.0",
"dayjs": "^1.11.7",
"echarts": "^6.1.0",
"grid-layout-plus": "^1.1.0",
"frappe-ui": "1.0.0-rc.1",
"frappe-ui": "0.1.235",
"gemoji": "^8.1.0",
"mime": "^3.0.0",
"pinia": "^2.0.33",
"sanitize-html": "^2.10.0",
"socket.io-client": "^4.7.2",
"tailwindcss-rtl": "^0.9.0",
"vue": "^3.5.14",
"vue-router": "^4.2.2",
"vuedraggable": "^4.1.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vitejs/plugin-vue-jsx": "^3.0.1",
"autoprefixer": "^10.4.13",
"patch-package": "^8.0.1",
"postcss": "^8.4.5",
"postinstall-postinstall": "^2.1.0",
"prettier": "2.8.4",
"tailwindcss": "^3.4.15",
"typescript": "^5.0.2",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.21",
"vite-plugin-pwa": "0.17.5"
"vite": "^4.4.9",
"vite-plugin-pwa": "0.15.0"
},
"resolutions": {
"cheerio": "1.0.0-rc.12",
"prosemirror-model": "1.25.9",
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.41.9",
"prosemirror-transform": "1.12.0"
"prosemirror-model": "1.25.1"
}
}

View File

@@ -1,70 +0,0 @@
diff --git a/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts b/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
index 91c8dc4..2883b93 100644
--- a/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
+++ b/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
@@ -5,6 +5,7 @@ import type {
} from '@tiptap/suggestion'
import type { Component } from 'vue'
import {
+ autoUpdate,
computePosition,
flip,
offset,
@@ -40,6 +41,9 @@ export function createSuggestionRenderer(
let isActive = false
// Monotonic token; a stale onUpdate from a superseded query bails.
let renderToken = 0
+ // Monotonic token; a stale computePosition settling later bails.
+ let positionToken = 0
+ let cleanupAutoUpdate: (() => void) | null = null
function getListExpose(): SuggestionListExpose | null {
const ref = renderer?.ref as SuggestionListExpose | null | undefined
@@ -52,6 +56,7 @@ export function createSuggestionRenderer(
const rect = getReferenceClientRect()
if (!rect) return
const reference = { getBoundingClientRect: () => rect }
+ const token = ++positionToken
void computePosition(reference, floatingEl, {
placement: floatingOptions?.placement ?? 'bottom-start',
middleware: [
@@ -60,7 +65,7 @@ export function createSuggestionRenderer(
shift({ padding: 8 }),
],
}).then(({ x, y }) => {
- if (!floatingEl) return
+ if (!floatingEl || token !== positionToken) return
Object.assign(floatingEl.style, {
position: 'absolute',
left: `${x}px`,
@@ -93,7 +98,19 @@ export function createSuggestionRenderer(
floatingEl.style.position = 'absolute'
document.body.appendChild(floatingEl)
getReferenceClientRect = props.clientRect as () => DOMRect | null
- updatePosition()
+
+ // Positions once, then keeps the popup on the caret through scroll, window
+ // resize and its own height changes (`flip()` measures a height that async
+ // rows and late fonts change after the fact).
+ cleanupAutoUpdate = autoUpdate(
+ {
+ getBoundingClientRect: () =>
+ getReferenceClientRect?.() ?? new DOMRect(),
+ contextElement: props.editor?.view?.dom as HTMLElement | undefined,
+ },
+ floatingEl,
+ updatePosition,
+ )
}
return {
@@ -139,6 +156,9 @@ export function createSuggestionRenderer(
onExit() {
isActive = false
renderToken++
+ positionToken++
+ cleanupAutoUpdate?.()
+ cleanupAutoUpdate = null
floatingEl?.remove()
renderer?.destroy()
floatingEl = null

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
<template>
<FrappeUIProvider>
<router-view />
<PortalRoot />
</FrappeUIProvider>
<Dialogs />
</template>
@@ -8,38 +8,54 @@
<script setup lang="ts">
import { Dialogs } from "@/components/dialogs";
import { useConfigStore } from "@/stores/config";
import { useFavicon } from "@vueuse/core";
import { FrappeUIProvider, setConfig, toast, useColorScheme } from "frappe-ui";
import { storeToRefs } from "pinia";
import { h, onMounted } from "vue";
import { stopSession } from "@/telemetry";
import { FrappeUIProvider, toast } from "frappe-ui";
import { computed, defineAsyncComponent, h, onMounted, onUnmounted } from "vue";
import Wifi from "~icons/lucide/wifi";
import WifiOff from "~icons/lucide/wifi-off";
import { useAuthStore } from "./stores/auth";
import { useFavicon } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { __ } from "./translation";
import { isCustomerPortal } from "./utils";
const configStore = useConfigStore();
const { favicon } = storeToRefs(configStore);
useFavicon(favicon);
if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "light");
}
useColorScheme();
onMounted(() => {
window.addEventListener("online", () => {
toast(__("You are now online."), {
icon: () => h(Wifi, { class: "text-ink-base" }),
toast.create({
message: __("You are now online"),
icon: h(Wifi, { class: "text-white" }),
});
});
window.addEventListener("offline", () => {
toast(__("You are now offline."), {
icon: () => h(WifiOff, { class: "text-ink-base" }),
toast.create({
message: __("You are now offline"),
icon: h(WifiOff, { class: "text-white" }),
});
});
!isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
setConfig("systemTimezone", window.timezone?.system || null);
});
const AgentPortalRoot = defineAsyncComponent(
() => import("@/pages/desk/AgentRoot.vue")
);
const CustomerPortalRoot = defineAsyncComponent(
() => import("@/pages/CustomerPortalRoot.vue")
);
const PortalRoot = computed(() => {
const authStore = useAuthStore();
if (authStore.hasDeskAccess && authStore.isAgent) {
return AgentPortalRoot;
} else {
return CustomerPortalRoot;
}
});
onUnmounted(() => {
stopSession();
});
</script>

View File

@@ -0,0 +1,66 @@
<template>
<Popover placement="right-start" class="flex w-full">
<template #target="{ togglePopover }">
<button
:class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
]"
@click.prevent="togglePopover()"
>
<div class="flex gap-2">
<AppsIcon />
<span class="whitespace-nowrap">Apps</span>
</div>
<ChevronRight class="h-4 w-4 stroke-1.5" />
</button>
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
>
<div v-for="app in apps.data" key="name">
<a
:href="app.route"
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
>
<img class="size-8" :src="app.logo" />
<div class="text-sm" @click="app.onClick">
{{ app.title }}
</div>
</a>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover, createResource } from "frappe-ui";
import ChevronRight from "~icons/lucide/chevron-right";
import AppsIcon from "./icons/AppsIcon.vue";
const apps = createResource({
url: "frappe.apps.get_apps",
cache: "apps",
auto: true,
transform: (data) => {
let _apps = [
{
name: "frappe",
logo: "/assets/helpdesk/desk/desk.png",
title: "Desk",
route: "/desk/helpdesk",
},
];
data.map((app) => {
if (app.name === "helpdesk") return;
_apps.push({
name: app.name,
logo: app.logo,
title: app.title,
route: app.route,
});
});
return _apps;
},
});
</script>

View File

@@ -0,0 +1,201 @@
<template>
<Dialog
v-model="show"
:options="{
title: 'Assign To',
size: 'xl',
}"
>
<template #body-content>
<AutocompleteNew
v-if="showRestrictedMembers"
placeholder="Search agents"
:model-value="search"
:options="members"
@update:model-value="
({ _, value }) => {
addAssignee(value);
}
"
>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :name="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</AutocompleteNew>
<SearchComplete
v-else
class="form-control"
doctype="HD Agent"
:custom-filters="customFilters"
:reset-input="true"
@change="
(option) => {
addAssignee(option.value);
}
"
>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :name="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
{{ getUser(option.value).full_name }}
</Tooltip>
</template>
</SearchComplete>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Tooltip
v-for="currentAssignee in assignees"
:key="currentAssignee.name"
:text="currentAssignee.name"
>
<Button
:label="getUser(currentAssignee.name).full_name"
theme="gray"
variant="outline"
>
<template #prefix>
<UserAvatar :name="currentAssignee.name" size="sm" />
</template>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeCurrentAssignee(currentAssignee.name)"
/>
</template>
</Button>
</Tooltip>
</div>
<ErrorMessage v-if="error" class="mt-2" :message="error" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import { AutocompleteNew, SearchComplete, UserAvatar } from "@/components";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { useUserStore } from "@/stores/user";
import { call, createResource } from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, onMounted, ref } from "vue";
const props = defineProps({
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
assignees: {
type: Array,
required: true,
},
team: {
type: String,
default: "",
},
});
const show = defineModel();
const emit = defineEmits(["update"]);
const { getUser } = useUserStore();
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
const { teamRestrictionApplied, assignWithinTeam } = useConfigStore();
const error = ref("");
const addAssignee = (value) => {
error.value = "";
createResource({
url: "frappe.desk.form.assign_to.add",
auto: true,
params: {
doctype: props.doctype,
name: props.docname,
assign_to: [value],
},
onSuccess: () => {
emit("update");
if (isManager) {
updateOnboardingStep("assign_to_agent");
}
members.value = members.value.filter((m) => m.value !== value);
},
});
emit("update");
};
const removeCurrentAssignee = (value) => {
createResource({
url: "frappe.desk.form.assign_to.remove",
auto: true,
params: {
doctype: props.doctype,
name: props.docname,
assign_to: value,
},
onSuccess: () => {
emit("update");
members.value.push({
label: value,
value: value,
});
},
});
};
const customFilters = computed(() => {
const filters = {};
filters["is_active"] = ["=", 1];
if (Boolean(props.assignees?.length)) {
filters["name"] = ["not in", [...props.assignees.map((a) => a.name)]];
}
return filters;
});
const search = ref("");
const members = ref([]);
async function getMembers() {
let teamMembers = await call(
"helpdesk.helpdesk.doctype.hd_team.hd_team.get_team_members",
{
team: props.team,
}
);
let assignedMembers = props.assignees.map((a) => a.name);
teamMembers = teamMembers.filter((member: string) => {
return !assignedMembers.includes(member);
});
members.value = teamMembers.map((member: string) => {
return {
label: member,
value: member,
};
});
}
const showRestrictedMembers = computed(() => {
return teamRestrictionApplied && assignWithinTeam && props.team;
});
onMounted(() => {
if (showRestrictedMembers.value) {
getMembers();
}
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<span>
<a :href="preview ? undefined : url" target="_blank">
<a :href="isShowable ? null : url" target="_blank">
<Button
:label="label"
theme="gray"
@@ -8,113 +8,72 @@
@click="toggleDialog()"
>
<template #prefix>
<component :is="icon" class="h-4 w-4" />
<component :is="getIcon()" class="h-4 w-4" />
</template>
<template #suffix>
<slot name="suffix" />
</template>
</Button>
</a>
<Dialog v-model:open="showDialog" :title="label" size="4xl">
<template #default>
<Dialog
v-model="showDialog"
:options="{
title: label,
size: '4xl',
}"
>
<template #body-content>
<div
v-if="preview === 'text'"
v-if="isText"
class="prose prose-sm max-w-none whitespace-pre-wrap"
>
{{ content }}
</div>
<img
v-if="preview === 'image'"
:src="url"
class="m-auto rounded-4 border"
/>
<video
v-if="preview === 'video'"
:src="url"
controls
class="m-auto max-h-[70vh] rounded-4 border"
/>
<img v-if="isImage" :src="url" class="m-auto rounded border" />
</template>
</Dialog>
</span>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Button, Dialog } from "frappe-ui";
import { getType as getMime } from "mime";
import { markRaw, ref, type Component } from "vue";
import LucideFile from "~icons/lucide/file";
import LucideFileImage from "~icons/lucide/file-image";
import LucideFileSpreadsheet from "~icons/lucide/file-spreadsheet";
import LucideFileText from "~icons/lucide/file-text";
import LucideFileType from "~icons/lucide/file-type";
import LucideFileVideo from "~icons/lucide/file-video";
import LucideFileImage from "~icons/lucide/file-image";
import LucideFileText from "~icons/lucide/file-text";
import LucideFileSpreadsheet from "~icons/lucide/file-spreadsheet";
import LucideFile from "~icons/lucide/file";
interface P {
label: string;
url?: string | null;
url?: string;
}
const props = withDefaults(defineProps<P>(), {
url: null,
});
type AttachmentKind =
| "image"
| "video"
| "pdf"
| "spreadsheet"
| "text"
| "file";
const ICONS: Record<AttachmentKind, Component> = {
image: markRaw(LucideFileImage),
video: markRaw(LucideFileVideo),
pdf: markRaw(LucideFileText),
spreadsheet: markRaw(LucideFileSpreadsheet),
text: markRaw(LucideFileType),
file: markRaw(LucideFile),
};
// browsers render only some image/video mimes (.dwg is image/vnd.dwg, .avi is
// video/x-msvideo) — anything absent here becomes a download link like pdf
type Preview = "image" | "video" | "text";
const PREVIEWS: Record<string, Preview> = {
"image/png": "image",
"image/jpeg": "image",
"image/gif": "image",
"image/webp": "image",
"image/svg+xml": "image",
"image/avif": "image",
"image/bmp": "image",
"image/apng": "image",
"image/vnd.microsoft.icon": "image",
"video/mp4": "video",
"video/webm": "video",
"video/ogg": "video",
"video/x-m4v": "video",
"text/plain": "text",
};
function getKind(mime: string): AttachmentKind {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
if (mime.includes("spreadsheet")) return "spreadsheet";
if (mime === "text/plain") return "text";
return "file";
}
const showDialog = ref(false);
const mimeType = getMime(props.label) || "";
const icon = ICONS[getKind(mimeType)];
const preview = props.url ? PREVIEWS[mimeType] : undefined;
const isImage = mimeType.startsWith("image/");
const isPdf = mimeType === "application/pdf";
const isSpreadsheet = mimeType.includes("spreadsheet");
const isText = mimeType === "text/plain";
const isShowable = props.url && (isText || isImage);
const content = ref("");
function getIcon() {
if (isText) return LucideFileType;
else if (isImage) return LucideFileImage;
else if (isPdf) return LucideFileText;
else if (isSpreadsheet) return LucideFileSpreadsheet;
else return LucideFile;
}
function toggleDialog() {
if (!preview) return;
if (preview === "text") {
if (!isShowable) return;
if (isText) {
fetch(props.url).then((res) => res.text().then((t) => (content.value = t)));
}
showDialog.value = !showDialog.value;

View File

@@ -1,33 +0,0 @@
<template>
<div class="flex flex-wrap gap-2">
<AttachmentItem
v-for="attachment in attachments"
:key="attachment.file_url"
:label="attachment.file_name"
:url="attachment.file_url"
>
<template #suffix>
<LucideX
class="size-3.5 cursor-pointer text-ink-gray-6 transition-colors hover:text-ink-gray-9"
@click.stop.prevent="emit('remove', attachment)"
/>
</template>
</AttachmentItem>
</div>
</template>
<script setup lang="ts">
import { AttachmentItem } from "@/components";
import LucideX from "~icons/lucide/x";
interface Attachment {
file_url: string;
file_name: string;
}
withDefaults(defineProps<{ attachments?: Attachment[] }>(), {
attachments: () => [],
});
const emit = defineEmits<{ remove: [attachment: Attachment] }>();
</script>

View File

@@ -0,0 +1,284 @@
<template>
<Combobox v-slot="{ open: isComboboxOpen }" v-model="selectedValue" nullable>
<Popover v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full -ml-0.5">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="
() => {
!disabled && togglePopover();
}
"
>
<div class="flex items-center">
<slot name="prefix" />
<span
v-if="selectedValue"
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
>
{{ displayValue(selectedValue) }}
</span>
<span v-else class="text-base leading-5 text-gray-500">
{{ placeholder || "" }}
</span>
</div>
<FeatherIcon
name="chevron-down"
class="h-4 w-4 text-gray-600"
aria-hidden="true"
/>
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
:value="query"
autocomplete="off"
placeholder="Search"
@change="
(e) => {
query = e.target.value;
}
"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<FeatherIcon name="x" class="w-4" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
v-for="group in groups"
v-show="group.items.length > 0"
:key="group.key"
class="mt-1.5"
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
>
{{ group.group }}
</div>
<ComboboxOption
v-for="option in group.items"
:key="option.value"
v-slot="{ active, selected }"
as="template"
:value="option"
>
<li
:class="[
'flex items-center rounded px-2.5 py-1.5 text-base',
{ 'bg-gray-100': active },
]"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
{{ option.label }}
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
>
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</div>
</div>
</template>
</Popover>
</Combobox>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from "@headlessui/vue";
import { Popover, Button, FeatherIcon } from "frappe-ui";
import { ref, computed, useAttrs, useSlots, watch, nextTick } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
options: {
type: Array,
default: () => [],
},
size: {
type: String,
default: "md",
},
variant: {
type: String,
default: "subtle",
},
placeholder: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
filterable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue", "update:query", "change"]);
const query = ref("");
const showOptions = ref(false);
const search = ref(null);
const attrs = useAttrs();
const slots = useSlots();
const valuePropPassed = computed(() => "value" in attrs);
const selectedValue = computed({
get() {
return valuePropPassed.value ? attrs.value : props.modelValue;
},
set(val) {
query.value = "";
if (val) {
showOptions.value = false;
}
emit(valuePropPassed.value ? "change" : "update:modelValue", val);
},
});
function close() {
showOptions.value = false;
}
const groups = computed(() => {
if (!props.options || props.options.length == 0) return [];
let groups = props.options[0]?.group
? props.options
: [{ group: "", items: props.options }];
return groups
.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
};
})
.filter((group) => group.items.length > 0);
});
function filterOptions(options) {
if (!query.value) {
return options;
}
return options.filter((option) => {
let searchTexts = [option.label, option.value];
return searchTexts.some((text) =>
(text || "").toString().toLowerCase().includes(query.value.toLowerCase())
);
});
}
function displayValue(option) {
if (typeof option === "string") {
let allOptions = groups.value.flatMap((group) => group.items);
let selectedOption = allOptions.find((o) => o.value === option);
return selectedOption?.label || option;
}
return option?.label;
}
watch(query, (q) => {
emit("update:query", q);
});
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus();
});
}
});
const textColor = computed(() => {
return props.disabled ? "text-gray-600" : "text-gray-800";
});
const inputClasses = computed(() => {
let sizeClasses = {
sm: "text-base rounded h-7",
md: "text-base rounded h-8",
lg: "text-lg rounded-md h-10",
xl: "text-xl rounded-md h-10",
}[props.size];
let paddingClasses = {
sm: "py-1.5 px-2",
md: "py-1.5 px-2.5",
lg: "py-1.5 px-3",
xl: "py-1.5 px-3",
}[props.size];
let variant = props.disabled ? "disabled" : props.variant;
let variantClasses = {
subtle:
"border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400",
outline:
"border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400",
disabled: [
"border bg-gray-50 placeholder-gray-400",
props.variant === "outline" ? "border-gray-300" : "border-transparent",
],
}[variant];
return [
sizeClasses,
paddingClasses,
variantClasses,
textColor.value,
"transition-colors w-full",
];
});
defineExpose({ query });
</script>

View File

@@ -1,32 +0,0 @@
<template>
<Select
:model-value="agentStatusStore.myStatus"
:options="dropdownOptions"
:placeholder="__('Set status')"
variant="subtle"
@update:model-value="agentStatusStore.setMyStatus"
>
<template #item-prefix="{ item }">
<div
class="size-2 rounded-full flex-shrink-0"
:class="agentStatusStore.statusColor(item.value)"
/>
</template>
</Select>
</template>
<script setup lang="ts">
import { useAgentStatusStore } from "@/stores/agentStatus";
import { __ } from "@/translation";
import { Select } from "frappe-ui";
import { computed } from "vue";
const agentStatusStore = useAgentStatusStore();
const dropdownOptions = computed(() =>
agentStatusStore.statusOptions.map((statusOption: string) => ({
label: __(statusOption),
value: statusOption,
}))
);
</script>

View File

@@ -1,26 +0,0 @@
<template>
<Button
variant="ghost"
icon-left="lucide-chevron-left"
:label="label"
:size="size"
class="!justify-start truncate font-medium text-ink-gray-8 hover:bg-transparent hover:opacity-70 focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 active:bg-transparent active:outline-none active:ring-0 rtl:flex-row-reverse ps-1"
@click="$emit('back')"
/>
</template>
<script setup lang="ts">
import { Button } from "frappe-ui";
interface P {
label: string;
size?: string;
}
interface E {
(event: "back"): void;
}
withDefaults(defineProps<P>(), { size: "md" });
defineEmits<E>();
</script>

View File

@@ -1,283 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden">
<CardBase
:title="title"
:text="chartData.text"
:percentageChange="chartData.percentageChange"
:chartConfig="hasData ? chartConfig : placeholderChartConfig"
:currentDuration="currentDuration"
:orientation="orientation"
:timelineFilter="timelineFilter"
@changeDuration="changeDuration"
>
<template v-if="$slots.text" #text>
<slot name="text" v-bind="{ text: chartData.text }" />
</template>
</CardBase>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { buildPercentageChange } from "@/utils";
import { EChartsOption } from "echarts";
import { createResource } from "frappe-ui";
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "./ChartCardBase.vue";
interface BarChartData {
percentage_change?: number;
total?: number;
average?: number;
data: { date: string; count: number }[] | Record<number, number>;
total_reviews?: number;
}
const props = defineProps({
title: {
type: String,
required: true,
},
apiUrl: {
type: String,
required: false,
},
data: {
type: Object as PropType<BarChartData>,
required: true,
},
barColor: {
type: String,
default: "#3BBDE5",
},
dt: {
type: String,
required: false,
},
dn: {
type: String,
required: false,
},
orientation: {
type: String as PropType<"vertical" | "horizontal">,
default: "vertical",
},
measure: {
type: String as PropType<"total" | "average">,
default: "total",
},
negativeIsBetter: {
type: Boolean,
default: true,
},
timelineFilter: {
type: Boolean,
default: true,
},
});
// the untranslated key: it is lowercased straight into the API period
const currentDuration = ref("Last month");
const chartData = computed(() => {
const isDataFetched = resource.fetched;
const _data: BarChartData = isDataFetched ? resource.data : props.data;
// Star-distribution shape: data is { 1: n, 2: n, ... }
if (!props.timelineFilter && _data?.data && !Array.isArray(_data.data)) {
const distribution = _data.data as Record<number, number>;
const values = [1, 2, 3, 4, 5].map((s) => distribution[s] ?? 0);
return {
labels: ["1", "2", "3", "4", "5"],
counts: values,
percentageChange: buildPercentageChange(0, props.negativeIsBetter),
text: _data?.average ?? 0,
isStarDistribution: true,
};
}
// Time-series shape: data is [{ date, count }, ...]
const timeData = Array.isArray(_data?.data)
? (_data.data as { date: string; count: number }[])
: [];
const labels = timeData.map((item) => item.date);
const counts = timeData.map((item) => item.count);
const _percentageChange = _data?.percentage_change || 0;
const percentageChange = buildPercentageChange(
_percentageChange,
props.negativeIsBetter
);
return {
labels,
counts,
percentageChange,
text: props.measure === "average" ? _data?.average ?? 0 : _data?.total ?? 0,
isStarDistribution: false,
};
});
const hasData = computed(() => {
const counts = chartData.value.counts as number[];
return counts.length > 0 && counts.some((c) => c > 0);
});
const chartConfig = computed<EChartsOption>(() => {
const color = props.barColor;
if (chartData.value.isStarDistribution) {
const values = chartData.value.counts as number[];
// rank by value descending: index 0 = highest, 4 = lowest
const opacities = [1, 0.7, 0.5, 0.3, 0.2];
const baseColor = props.barColor; // #E79913
// unique sorted counts descending (for tie-aware rank lookup)
const uniqueSorted = [...new Set(values)].sort((a, b) => b - a);
const opacityByIndex: number[] = values.map((v) => {
const rank = uniqueSorted.indexOf(v); // same count → same rank
return opacities[Math.min(rank, opacities.length - 1)];
});
// helper: hex + opacity → rgba
const hexToRgba = (hex: string, alpha: number) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
};
return {
grid: { left: 4, right: 4, top: 16, bottom: 20, containLabel: false },
xAxis: {
type: "category",
data: ["1", "2", "3", "4", "5"],
axisLine: { show: true, lineStyle: { color: "#ededed" } },
axisTick: { show: false },
axisLabel: { color: "#6b7280", fontSize: 11 },
},
yAxis: { type: "value", show: false, min: 0 },
series: [
{
type: "bar",
data: values.map((value, i) => ({
value: value === 0 ? 0 : value,
itemStyle: {
color: hexToRgba(baseColor, opacityByIndex[i]),
borderRadius: [4, 4, 0, 0],
},
label: {
show: true,
position: "top" as const,
formatter: value > 0 ? String(value) : "",
color: "#6b7280",
fontSize: 11,
},
})),
barWidth: 16,
emphasis: { disabled: true },
},
],
tooltip: { show: false },
};
}
return {
xAxis: {
type: "category",
data: chartData.value.labels,
show: false,
boundaryGap: true,
},
yAxis: {
type: "value",
show: false,
},
series: [
{
data: (chartData.value.counts as number[]).map((value) => ({
value,
itemStyle: {
color: value === 0 ? "#e5e7eb" : color,
borderRadius: [3, 3, 0, 0],
},
})),
type: "bar",
barMaxWidth: 12,
barMinHeight: 4,
emphasis: { disabled: true },
label: { show: false },
},
],
grid: {
left: 2,
right: 2,
top: 2,
bottom: 2,
},
tooltip: {
show: true,
trigger: "axis",
appendToBody: true,
position: (point: number[]) => [point[0] + 12, point[1] - 50],
axisPointer: { type: "none" },
formatter: (params: any) => {
const p = Array.isArray(params) ? params[0] : params;
if (!p.value) return "";
return `<span style="font-size:12px;color:#6b7280">${p.name}: <b style="color:#374151">${p.value}</b></span>`;
},
},
};
});
const placeholderChartConfig = computed<EChartsOption>(() => {
const placeholderValues = Array.from(
{ length: 7 },
() => Math.floor(Math.random() * 8) + 3
);
return {
xAxis: { type: "category", show: false, boundaryGap: true },
yAxis: { type: "value", show: false },
series: [
{
data: placeholderValues.map((value) => ({
value,
itemStyle: { color: "#f1f1f1", borderRadius: [3, 3, 0, 0] },
})),
type: "bar",
barMaxWidth: 12,
emphasis: { disabled: true },
},
],
grid: { left: 2, right: 2, top: 2, bottom: 2 },
tooltip: { show: false },
animation: false,
};
});
const resource = createResource({
url: props.apiUrl || "",
type: "GET",
makeParams: () => {
const params: Record<string, any> = {
period: currentDuration.value.toLowerCase(),
};
if (props.dt && props.dn) {
params.dt = props.dt;
params.dn = props.dn;
}
return params;
},
});
const changeDuration = (period: string) => {
currentDuration.value = period;
if (props.apiUrl) resource.submit();
};
onMounted(() => {
if (props.timelineFilter && props.apiUrl && !props.data?.data) {
resource.submit();
}
});
</script>

View File

@@ -5,7 +5,7 @@
alt="Brand Logo"
class="h-8 w-8 shrink-0 object-cover"
/>
<HDLogo v-else class="h-8 w-8 shrink-0 rounded-4" />
<HDLogo v-else class="h-8 w-8 shrink-0 rounded" />
</template>
<script setup lang="ts">

View File

@@ -7,7 +7,7 @@
:label="activity?._caller?.label"
size="md"
/>
<span class="font-medium text-ink-gray-8 ms-1">
<span class="font-medium text-ink-gray-8 ml-1">
{{ activity?._caller?.label }}
</span>
<span>{{
@@ -16,7 +16,7 @@
: __("has made a call")
}}</span>
</div>
<div class="ms-auto whitespace-nowrap">
<div class="ml-auto whitespace-nowrap">
<Tooltip :text="dateFormat(activity.creation, 'MMM D, dddd')">
<div class="text-sm text-ink-gray-5">
{{ __(timeAgo(activity.creation)) }}
@@ -26,10 +26,10 @@
</div>
<div
@click="showCallLogDetailModal = true"
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-5 bg-surface-elevation-1 px-3 py-2.5 text-ink-gray-9"
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
>
<div class="flex items-center justify-between">
<div class="inline-flex gap-2 items-center text-base-medium">
<div class="inline-flex gap-2 items-center text-base font-medium">
<div>
{{
activity.call_type == "Incoming"

View File

@@ -1,173 +0,0 @@
<template>
<div
class="flex flex-col w-full h-full rounded-5 p-4"
:class="[orientation == 'horizontal' && 'pt-3']"
>
<slot name="title">
<div class="text-ink-gray-5 text-base mb-2">
{{ title }}
</div>
</slot>
<div
class="flex flex-col gap-2 h-full w-full"
v-if="orientation === 'vertical'"
>
<div class="flex items-end w-full gap-2">
<slot name="text">
<div
class="text-2xl-medium text-center text-ink-gray-8 whitespace-nowrap"
>
{{ text }}
</div>
</slot>
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
<div class="flex items-center gap-1" :class="percentageChange.color">
<span
v-if="percentageChange.icon"
:class="percentageChange.icon"
class="size-4"
/>
<div>{{ percentageChange.value }}%</div>
</div>
<Dropdown :options="durationOptions">
<div
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
>
<div class="flex gap-1">
<span>vs</span>
<span>{{ __(currentDuration).toLowerCase() }}</span>
</div>
<LucideChevronDown class="size-4" />
</div>
<template #item-label="{ item }">
<div
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
>
<span>
{{ item.label }}
</span>
</div>
</template>
<template #item-suffix="{ item }">
<LucideCheck
v-if="item.label == __(currentDuration)"
class="size-4"
/>
</template>
</Dropdown>
</div>
</div>
<slot name="chart">
<div v-if="chartConfig" class="w-full h-full">
<ECharts :options="chartConfig" class="w-full h-full" />
</div>
</slot>
</div>
<div class="flex flex-col gap-2 h-full w-full" v-else>
<div class="flex items-end w-full gap-2 justify-between flex-1">
<slot name="text">
<span
class="text-2xl-medium text-center text-ink-gray-8 whitespace-nowrap"
>
{{ text }}
</span>
</slot>
<slot name="chart">
<div v-if="chartConfig" class="h-full max-w-[50%] w-[50%]">
<ECharts :options="chartConfig" class="w-full h-full" />
</div>
</slot>
</div>
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
<div class="flex items-center gap-1" :class="percentageChange.color">
<span
v-if="percentageChange.icon"
:class="percentageChange.icon"
class="size-4"
/>
<div>{{ percentageChange.value }}%</div>
</div>
<Dropdown :options="durationOptions">
<div
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
>
vs {{ __(currentDuration).toLowerCase() }}
<LucideChevronDown class="size-4" />
</div>
<template #item-label="{ item }">
<div
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
>
<span>
{{ item.label }}
</span>
</div>
</template>
<template #item-suffix="{ item }">
<LucideCheck
v-if="item.label == __(currentDuration)"
class="size-4"
/>
</template>
</Dropdown>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import LucideCheck from "~icons/lucide/check";
import LucideChevronDown from "~icons/lucide/chevron-down";
import { __ } from "@/translation";
import { EChartsOption } from "echarts";
import { Dropdown } from "frappe-ui";
import { ECharts } from "frappe-ui/experimental";
import { computed, type PropType } from "vue";
const props = defineProps({
title: {
type: String,
},
text: {
type: [Number, String] as PropType<number | string>,
default: "",
},
percentageChange: {
type: Object,
required: true,
},
chartConfig: {
type: Object as PropType<EChartsOption>,
required: true,
},
currentDuration: {
type: String,
default: __("Last month"),
},
orientation: {
type: String as PropType<"vertical" | "horizontal">,
default: "vertical",
},
timelineFilter: {
type: Boolean,
default: true,
},
});
const chartConfig = computed<EChartsOption>(() => props.chartConfig);
const currentDuration = computed(() => props.currentDuration);
const emit = defineEmits(["changeDuration"]);
// the emitted value is the untranslated key: it travels to the API as the
// period, so only the label goes through __()
const DURATIONS = ["Last week", "Last month", "Last 3 months"];
const durationOptions = DURATIONS.map((duration) => ({
label: __(duration),
onClick: () => {
if (currentDuration.value !== duration) emit("changeDuration", duration);
},
}));
</script>

View File

@@ -0,0 +1,204 @@
<template>
<div class="flex-col text-base flex-1" ref="commentBoxRef">
<div class="mb-1 ml-0.5 flex items-center justify-between">
<div class="text-gray-600 flex items-center gap-2">
<Avatar
size="md"
:label="commenter"
:image="getUser(commentedBy).user_image"
/>
<p>
<span class="font-medium text-gray-800">
{{ commenter }}
</span>
<span> added a</span>
<span class="max-w-xs truncate font-medium text-gray-800">
comment
</span>
</p>
</div>
<div class="flex items-center gap-1">
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<span class="pl-0.5 text-sm text-gray-600">
{{ timeAgo(creation) }}
</span>
</Tooltip>
<div v-if="authStore.userId === commentedBy && !editable">
<Dropdown
:placement="'right'"
:options="[
{
label: 'Edit',
onClick: () => handleEditMode(),
icon: 'edit-2',
condition: () => !isTicketMergedComment,
},
{
label: 'Delete',
onClick: () => (showDialog = true),
icon: 'trash-2',
},
]"
>
<Button
icon="more-horizontal"
class="text-gray-600"
variant="ghost"
/>
</Dropdown>
</div>
</div>
</div>
<div
:id="`comment-${name}`"
class="rounded bg-gray-50 transition-colors px-4 py-3"
>
<TextEditor
ref="editorRef"
:editor-class="[
'prose-f shrink text-p-sm transition-all duration-300 ease-in-out block w-full content',
getFontFamily(_content),
]"
:content="_content"
:editable="editable"
:bubble-menu="textEditorMenuButtons"
:mentions="[]"
@change="(event:string) => {_content = event}"
>
<template #bottom v-if="editable">
<div class="flex flex-row-reverse gap-2">
<Button label="Save" @click="handleSaveComment" variant="solid" />
<Button label="Discard" @click="handleDiscard" />
</div>
</template>
</TextEditor>
<div class="flex flex-wrap gap-2" v-if="!editable">
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
:url="a.file_url"
/>
</div>
</div>
</div>
<Dialog
v-model="showDialog"
:options="{
title: 'Delete Comment',
message: 'Are you sure you want to confirm this action?',
actions: [
{ label: 'Cancel', onClick: () => (showDialog = false) },
{
label: 'Delete',
onClick: () => deleteComment.submit(),
variant: 'solid',
},
],
}"
/>
</template>
<script setup lang="ts">
import { AttachmentItem } from "@/components";
import { useAuthStore } from "@/stores/auth";
import { updateRes as updateComment } from "@/stores/knowledgeBase";
import { useUserStore } from "@/stores/user";
import { CommentActivity } from "@/types";
import {
dateFormat,
dateTooltipFormat,
getFontFamily,
isContentEmpty,
textEditorMenuButtons,
timeAgo,
} from "@/utils";
import {
Avatar,
Dialog,
Dropdown,
TextEditor,
createResource,
toast,
} from "frappe-ui";
import { PropType, computed, onMounted, ref } from "vue";
const authStore = useAuthStore();
const props = defineProps({
activity: {
type: Object as PropType<CommentActivity>,
required: true,
},
});
const { getUser } = useUserStore();
const { name, creation, content, commenter, commentedBy, attachments } =
props.activity;
const isTicketMergedComment = computed(() => {
const regex = /has been merged with ticket #\d+/;
return regex.test(content);
});
const emit = defineEmits(["update"]);
const showDialog = ref(false);
const editable = ref(false);
const _content = ref(content);
// HTML refs
const commentBoxRef = ref(null);
const editorRef = ref(null);
function handleEditMode() {
editable.value = true;
editorRef.value.editor.chain().focus("start");
}
function handleDiscard() {
_content.value = content;
editable.value = false;
}
const deleteComment = createResource({
url: "frappe.client.delete",
makeParams: () => ({
doctype: "HD Ticket Comment",
name: name,
}),
onSuccess() {
emit("update");
showDialog.value = false;
toast.success("Comment deleted");
},
});
function handleSaveComment() {
if (content === _content.value) {
editable.value = false;
return;
}
if (isContentEmpty(_content.value)) {
toast.error("Comment cannot be empty");
return;
}
updateComment.submit(
{
doctype: "HD Ticket Comment",
name: name,
fieldname: "content",
value: _content.value,
},
{
onSuccess: () => {
editable.value = false;
emit("update");
toast.success("Comment updated");
},
}
);
}
onMounted(() => {
commentBoxRef.value.style.width = "0px";
});
</script>

View File

@@ -1,142 +1,132 @@
<template>
<Editor
<TextEditor
v-if="agentsList.data"
ref="editorRef"
v-model="newComment"
:extensions="extensions"
:editable="editable"
:editor-class="[
'prose-sm max-w-none',
editable &&
'min-h-[7rem] mx-6 md:ml-10 md:mr-9 max-h-[50vh] overflow-y-auto border-t py-3',
getFontFamily(newComment),
]"
:content="newComment"
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
:placeholder="placeholder"
:upload-function="
(file: any, options: any) =>
track(uploadFunction(file, doctype, ticketId, true, options))
"
:editable="editable"
:mentions="dropdown"
@change="editable ? (newComment = $event) : null"
:extensions="[PreserveVideoControls]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
>
<template #default="{ isEmpty }">
<!-- Scroll here so selected nodes aren't clipped. -->
<div :class="editable && 'max-h-[44vh] overflow-y-auto'">
<EditorContent
:class="[
'prose-sm max-w-none',
editable && 'min-h-[7rem] mx-5 border-t py-3',
getFontFamily(newComment),
]"
/>
</div>
<!-- Attachments -->
<AttachmentList
class="my-2 ms-5"
:attachments="attachments"
@remove="removeAttachment"
/>
<div v-if="editable" class="flex flex-col gap-2 border-t">
<div class="px-4">
<!-- Fixed Menu -->
<div class="flex justify-between gap-2 overflow-hidden py-2.5">
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
<div class="inline-flex items-center gap-1.5 p-1">
<FileUploader
:doctype="doctype"
:docname="ticketId"
private
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector, uploading }">
{{ void (loading = uploading) }}
<button
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
@click="openFileSelector()"
:disabled="uploading"
>
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
<AttachmentIcon
v-else
class="h-4 w-4"
style="stroke-width: 1.5 !important"
/>
</button>
</template>
</FileUploader>
<div class="h-4 w-[2px] border-s ml-1" />
</div>
<EditorFixedMenu :items="fullToolbar" />
<EditorTableMenu />
</div>
<div class="flex shrink-0 items-center justify-end gap-x-2">
<Button
label="Discard"
@click="
() => {
newComment = '';
attachments = [];
dropUnused(null);
emit('discard');
}
"
<template #bottom>
<div v-if="editable" class="flex flex-col gap-2 px-6 md:pl-10 md:pr-9">
<!-- Attachments -->
<div class="flex flex-wrap gap-2">
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
:url="!['MOV', 'MP4'].includes(a.file_type) ? a.file_url : null"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeAttachment(a)"
/>
<!-- A disabled button fires no pointer events, so the span
carries the hover for the tooltip -->
<Tooltip
:text="isUploading ? __('Please wait, media is uploading') : ''"
>
<span class="inline-flex">
<Button
variant="solid"
:label="label"
:disabled="isDisabled"
:loading="loading"
@click="submitComment()"
/>
</span>
</Tooltip>
</div>
</template>
</AttachmentItem>
</div>
<!-- Fixed Menu -->
<div class="flex justify-between overflow-hidden border-t py-2.5">
<div class="flex items-center overflow-x-auto w-[60%]">
<FileUploader
:upload-args="{
doctype: doctype,
docname: ticketId,
private: true,
}"
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector, uploading }">
{{ void (loading = uploading) }}
<Button
theme="gray"
variant="ghost"
@click="openFileSelector()"
>
<template #icon>
<AttachmentIcon
class="h-4"
style="color: #000000; stroke-width: 1.5 !important"
/>
</template>
</Button>
</template>
</FileUploader>
<TextEditorFixedMenu
class="-ml-0.5"
:buttons="textEditorMenuButtons"
/>
</div>
<div class="flex items-center justify-end space-x-2 w-[40%]">
<Button
label="Discard"
@click="
() => {
newComment = '';
attachments = [];
emit('discard');
}
"
/>
<Button
variant="solid"
:label="label"
:disabled="isDisabled"
:loading="loading"
@click="
() => {
loading = true;
submitComment();
newComment = '';
}
"
/>
</div>
</div>
</div>
</template>
</Editor>
</TextEditor>
</template>
<script setup lang="ts">
import {
FileUploader,
LoadingIndicator,
Tooltip,
TextEditor,
TextEditorFixedMenu,
createResource,
dayjs,
toast,
} from "frappe-ui";
import {
Editor,
EditorContent,
EditorFixedMenu,
EditorTableMenu,
} from "frappe-ui/editor";
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
import { useOnboarding } from "@framework/ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AttachmentList } from "@/components/";
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
import { AttachmentItem } from "@/components/";
import { AttachmentIcon } from "@/components/icons/";
import { useTyping } from "@/composables/realtime";
import { useUploadTracker } from "@/composables/useUploadTracker";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { capture } from "@/telemetry";
import { PreserveVideoControls } from "@/tiptap-extensions";
import {
getFontFamily,
isContentEmpty,
removeAttachmentFromServer,
textEditorMenuButtons,
uploadFunction,
} from "@/utils";
import { useStorage } from "@vueuse/core";
import { storeToRefs } from "pinia";
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
const authStore = useAuthStore();
const { isManager } = authStore;
const { getUser } = useUserStore();
const { isManager } = useAuthStore();
const props = defineProps({
ticketId: {
@@ -161,25 +151,34 @@ const props = defineProps({
},
});
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
const emit = defineEmits(["submit", "discard"]);
const newComment = useStorage("commentBoxContent" + props.ticketId, null);
// Mentions as a reactive getter so the `@` list stays in sync as agents load.
const extensions = buildEditorExtensions({
mentions: () => dropdown.value ?? [],
});
// Initialize typing composable
const { onUserType, cleanup } = useTyping(props.ticketId);
const attachments = ref([]);
const { isUploading, track, dropUnused } = useUploadTracker();
const isDisabled = computed(() => {
return isContentEmpty(newComment.value) || loading.value || isUploading.value;
return isContentEmpty(newComment.value) || loading.value;
});
const loading = ref(false);
// Watch for changes in comment content to trigger typing events
watch(newComment, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
});
onBeforeUnmount(() => {
cleanup();
});
const label = computed(() => {
return loading.value ? "Sending..." : props.label;
});
function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment);
removeAttachmentFromServer(attachment.name);
@@ -189,58 +188,28 @@ async function submitComment() {
if (isContentEmpty(newComment.value)) {
return false;
}
// The keyboard shortcut reaches here without passing the disabled button
if (isUploading.value || loading.value) return false;
const content = newComment.value;
const sentAttachments = attachments.value;
const pendingRow = addPendingActivity(props.doctype, props.ticketId, {
type: "comment",
timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
author: {
email: getUser(authStore.userId).email,
// the users list may still be a stub with a guessed name and no avatar
fullname: authStore.userName,
image: authStore.userImage,
},
data: { name: "", content, attachments: sentAttachments },
});
// drop before clearing, or an unmount mid-send deletes what the comment carries
dropUnused(content);
newComment.value = null;
attachments.value = [];
loading.value = true;
emit("sending");
const comment = createResource({
url: "run_doc_method",
makeParams: () => ({
dt: props.doctype,
dn: props.ticketId,
method: "new_comment",
args: { content, attachments: sentAttachments },
args: {
content: newComment.value,
attachments: attachments.value,
},
}),
onSuccess: (res: { message?: string } | string) => {
// run_doc_method answers with the whole body, since it always carries `docs`
const name = typeof res === "string" ? res : res?.message;
// the real row replaces the pending one the moment it arrives; unkeyed,
// it would outlive it
name ? pendingRow.resolve(`comment:${name}`) : pendingRow.drop();
capture("comment_added");
onSuccess: () => {
if (isManager) {
updateOnboardingStep?.("comment_on_ticket");
updateOnboardingStep("comment_on_ticket");
}
emit("submit");
loading.value = false;
attachments.value = [];
newComment.value = null;
},
onError: () => {
toast.error(__("Could not add the comment"));
pendingRow.drop();
newComment.value = content;
attachments.value = sentAttachments;
loading.value = false;
emit("restore");
},
});
@@ -250,13 +219,6 @@ async function submitComment() {
const editorRef = ref(null);
const editor = computed(() => editorRef.value?.editor);
// Watch for changes in comment content to trigger typing events
watch(newComment, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
});
onMounted(() => {
if (
agentsList.value.loading ||
@@ -268,15 +230,6 @@ onMounted(() => {
agentsList.value.fetch();
});
onBeforeUnmount(() => {
cleanup();
// the saved draft still references what it shows, so only what it dropped goes
dropUnused(newComment.value);
if (isContentEmpty(newComment.value)) {
localStorage.removeItem("commentBoxContent" + props.ticketId);
}
});
defineExpose({
submitComment,
editor,

View File

@@ -1,16 +1,14 @@
<template>
<div class="comm-area" resizable="true">
<div class="comm-area">
<div
class="flex justify-between gap-3 border-t px-6 md:px-5 py-4 md:py-2.5"
class="flex justify-between gap-3 border-t px-6 md:px-10 py-4 md:py-2.5"
>
<div class="flex gap-1.5 items-center">
<Button
ref="sendEmailRef"
variant="ghost"
label="Reply"
:class="[
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
:class="[showEmailBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
@click="toggleEmailBox()"
>
<template #prefix>
@@ -20,9 +18,7 @@
<Button
variant="ghost"
label="Comment"
:class="[
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
:class="[showCommentBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
@click="toggleCommentBox()"
>
<template #prefix>
@@ -32,87 +28,69 @@
<TypingIndicator :ticketId="ticketId" />
</div>
</div>
<Transition name="slide">
<div
v-show="showEmailBox"
ref="emailBoxRef"
@keydown.ctrl.enter.capture.stop="submitEmail"
@keydown.meta.enter.capture.stop="submitEmail"
@keydown.esc.capture.stop="showEmailBox = false"
>
<div class="overflow-hidden">
<EmailEditor
ref="emailEditorRef"
:label="
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
"
placeholder="Hi John, we are looking into this issue."
:ticketId="ticketId"
:to-emails="toEmails"
:cc-emails="ccEmails"
:bcc-emails="bccEmails"
@sending="
() => {
showEmailBox = false;
}
"
@submit="() => emit('update')"
@restore="
() => {
showEmailBox = true;
}
"
@discard="
() => {
showEmailBox = false;
}
"
/>
</div>
</div>
</Transition>
<Transition name="slide">
<div
v-show="showCommentBox"
ref="commentBoxRef"
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
@keydown.esc.capture.stop="showCommentBox = false"
>
<div class="overflow-hidden">
<CommentTextEditor
ref="commentTextEditorRef"
:label="
isMobileView
? 'Comment'
: isMac
? 'Comment (⌘ + ⏎)'
: 'Comment (Ctrl + ⏎)'
"
:ticketId="ticketId"
:editable="showCommentBox"
:doctype="doctype"
placeholder="@John could you please look into this?"
@sending="
() => {
showCommentBox = false;
}
"
@submit="() => emit('update')"
@restore="
() => {
showCommentBox = true;
}
"
@discard="
() => {
showCommentBox = false;
}
"
/>
</div>
</div>
</Transition>
<div
ref="emailBoxRef"
v-show="showEmailBox"
class="flex gap-1.5 flex-1"
@keydown.ctrl.enter.capture.stop="submitEmail"
@keydown.meta.enter.capture.stop="submitEmail"
>
<EmailEditor
ref="emailEditorRef"
:label="
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
"
v-model:content="content"
placeholder="Hi John, we are looking into this issue."
:ticketId="ticketId"
:to-emails="toEmails"
:cc-emails="ccEmails"
:bcc-emails="bccEmails"
@submit="
() => {
showEmailBox = false;
emit('update');
}
"
@discard="
() => {
showEmailBox = false;
}
"
/>
</div>
<div
ref="commentBoxRef"
v-show="showCommentBox"
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
>
<CommentTextEditor
ref="commentTextEditorRef"
:label="
isMobileView
? 'Comment'
: isMac
? 'Comment (⌘ + ⏎)'
: 'Comment (Ctrl + ⏎)'
"
:ticketId="ticketId"
:editable="showCommentBox"
:doctype="doctype"
placeholder="@John could you please look into this?"
@submit="
() => {
showCommentBox = false;
emit('update');
}
"
@discard="
() => {
showCommentBox = false;
}
"
/>
</div>
</div>
</template>
@@ -123,8 +101,8 @@ import { useDevice } from "@/composables";
import { useScreenSize } from "@/composables/screen";
import { useShortcut } from "@/composables/shortcuts";
import { showCommentBox, showEmailBox } from "@/pages/ticket/modalStates";
import { onClickOutside } from "@vueuse/core";
import { ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
const emit = defineEmits(["update"]);
const content = defineModel("content");
@@ -151,14 +129,16 @@ function toggleCommentBox() {
showCommentBox.value = !showCommentBox.value;
}
// the editors emit `submit` once the write lands; reloading here too would
// fetch twice, and the guards' `false` never reaches this side anyway
function submitEmail() {
emailEditorRef.value.submitMail();
if (emailEditorRef.value.submitMail()) {
emit("update");
}
}
function submitComment() {
commentTextEditorRef.value.submitComment();
if (commentTextEditorRef.value.submitComment()) {
emit("update");
}
}
function splitIfString(str: string | string[]) {
@@ -206,7 +186,7 @@ watch(
() => showEmailBox.value,
(value) => {
if (value) {
emailEditorRef.value?.editor?.commands?.focus("start");
emailEditorRef.value?.editor?.commands?.focus();
}
}
);
@@ -234,48 +214,27 @@ defineExpose({
editor: emailEditorRef,
});
const IGNORED_SELECTORS = [
".tippy-box",
".tippy-content",
".PopoverContent",
'[role="dialog"]',
'[role="presentation"]',
'[role="menu"]',
".dialog-overlay",
// Grammarly suggestions appear outside the box, allow them to stop collapsing.
"grammarly-extension",
"grammarly-popups",
"[data-grammarly-part]",
];
// `ignore` is only consulted on pointerdown, which dialogs stop, so the click
// through has to be checked too. Without this a dialog button closes the box.
function isIgnored(event: Event): boolean {
const target = event.target as HTMLElement | null;
return Boolean(target?.closest?.(IGNORED_SELECTORS.join(", ")));
}
onClickOutside(
emailBoxRef,
(event) => {
if (showEmailBox.value && !isIgnored(event)) {
() => {
if (showEmailBox.value) {
showEmailBox.value = false;
}
},
{
ignore: IGNORED_SELECTORS,
ignore: [".tippy-box", ".tippy-content"],
}
);
onClickOutside(
commentBoxRef,
(event) => {
if (showCommentBox.value && !isIgnored(event)) {
() => {
if (showCommentBox.value) {
showCommentBox.value = false;
}
},
{
ignore: IGNORED_SELECTORS,
ignore: [".tippy-box", ".tippy-content"],
}
);
</script>
@@ -286,18 +245,4 @@ onClickOutside(
width: 100vw;
}
}
.slide-enter-active,
.slide-leave-active {
display: grid;
transition: grid-template-rows 0.25s ease;
}
.slide-enter-from,
.slide-leave-to {
grid-template-rows: 0fr;
}
.slide-enter-to,
.slide-leave-from {
grid-template-rows: 1fr;
}
</style>

View File

@@ -1,206 +0,0 @@
<template>
<div class="flex flex-col">
<Editor
ref="editorRef"
v-model="internalContent"
:extensions="extensions"
v-bind="uploadFn ? { uploadFunction: uploadFn } : {}"
:placeholder="placeholder"
>
<template #default>
<EditorBubbleMenu :items="commentToolbar" />
<div
class="flex items-center overflow-x-auto rounded-t-4 border border-b-0 border-outline-gray-1 bg-surface-gray-1 px-1 py-1"
>
<div
v-if="showAttachments"
class="inline-flex items-center gap-1.5 pe-1"
>
<FileUploader private @success="addAttachment">
<template #default="{ openFileSelector, uploading }">
{{ syncUploadingState(uploading) }}
<button
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors hover:bg-surface-gray-3 disabled:opacity-40"
:disabled="uploading"
@click="openFileSelector()"
>
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
<AttachmentIcon
v-else
class="h-4 w-4"
style="stroke-width: 1.5 !important"
/>
</button>
</template>
</FileUploader>
<div class="h-4 w-[2px] border-s ml-1" />
</div>
<EditorFixedMenu :items="fullToolbar" />
<EditorTableMenu />
</div>
<EditorContent :class="editorClass" />
</template>
</Editor>
<AttachmentList
v-if="showAttachments && attachments?.length"
class="mt-2"
:attachments="attachments ?? []"
@remove="removeAttachment"
/>
</div>
</template>
<script setup lang="ts">
import { AttachmentList } from "@/components";
import {
buildEditorExtensions,
commentToolbar,
fullToolbar,
} from "@/components/editor/config";
import { AttachmentIcon } from "@/components/icons";
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
import { isContentEmpty } from "@/utils";
import { FileUploader, LoadingIndicator, type UploadedFile } from "frappe-ui";
import {
Editor,
EditorBubbleMenu,
EditorContent,
EditorFixedMenu,
EditorTableMenu,
} from "frappe-ui/editor";
import { computed, nextTick, onMounted, ref, watch } from "vue";
type UploadFunction = (file: File) => Promise<UploadedFile>;
const props = withDefaults(
defineProps<{
placeholder?: string;
minHeight?: string;
maxHeight?: string;
extensions?: any[];
showSignature?: boolean;
showAttachments?: boolean;
uploadFn?: UploadFunction;
}>(),
{
placeholder: "",
minHeight: "min-h-[180px]",
maxHeight: "max-h-80",
extensions: () => [],
showSignature: false,
showAttachments: false,
}
);
const internalContent = defineModel<string>({ default: "" });
const editorRef = ref<any>(null);
const attachments = defineModel<UploadedFile[] | null>("attachments", {
default: () => [],
});
const isUploading = ref(false);
const extensions = buildEditorExtensions({ extra: props.extensions });
const contentClass = [
"!prose-sm max-w-full overflow-auto py-1.5 px-3",
"rounded-b-4 border border-outline-gray-1 bg-surface-base",
"placeholder-ink-gray-4 text-ink-gray-8 -mt-0.5",
];
const editorClass = computed(() => [
...contentClass,
props.minHeight,
props.maxHeight,
]);
const userResource = getUserEmailInfo();
function getDefaultContent(signature: string): string {
return `<p></p><p></p>${signature}`;
}
function applySignature(signature: string) {
if (!isContentEmpty(internalContent.value)) return;
const commands = editorRef.value?.editor?.commands;
if (commands) {
commands.setContent(signature);
setTimeout(() => {
editorRef.value?.editor?.commands?.focus("start");
}, 0);
} else {
internalContent.value = signature;
}
}
function addAttachment(file: UploadedFile) {
attachments.value = [...(attachments.value ?? []), file];
}
function removeAttachment(attachment: UploadedFile) {
attachments.value = (attachments.value ?? []).filter((a) => a !== attachment);
}
// FileUploader exposes `uploading` only via slot scope, so mirror it into a
// ref that the parent can read through the exposed `isUploading`.
function syncUploadingState(uploading: boolean): string {
isUploading.value = uploading;
return "";
}
function getContent(): string {
return editorRef.value?.editor?.getHTML() ?? "";
}
function isEmpty(): boolean {
return !editorRef.value?.editor?.state?.doc?.textContent?.trim()?.length;
}
function reset() {
const commands = editorRef.value?.editor?.commands;
if (!commands) {
internalContent.value = "";
return;
}
const data = userResource?.data as { email_signature?: string } | null;
const sig = data?.email_signature
? getDefaultContent(data.email_signature)
: "";
commands.setContent(sig || "<p></p>");
internalContent.value = sig;
attachments.value = null;
setTimeout(() => {
editorRef.value?.editor?.commands?.focus("start");
}, 0);
}
if (props.showSignature) {
// Late arrival: resource resolves after mount
watch(
() => userResource.data,
(data: { email_signature?: string } | null) => {
if (!data?.email_signature) return;
applySignature(getDefaultContent(data.email_signature));
}
);
}
onMounted(() => {
if (!props.showSignature) return;
nextTick(() => {
const data = userResource.data as { email_signature?: string } | null;
if (data?.email_signature) {
applySignature(getDefaultContent(data.email_signature));
}
});
});
defineExpose({
getContent,
isEmpty,
reset,
editorRef,
isUploading,
});
</script>

View File

@@ -1,12 +1,8 @@
<template>
<Dialog v-model:open="isOpen" :title="title" @close="closeDialog">
<template #default>
<Dialog v-model="showDialog" :options="{ title }" @close="closeDialog">
<template #body-content>
<div class="space-y-4">
<p
class="text-p-base text-ink-gray-8"
v-if="message"
v-html="message"
/>
<p class="text-p-base text-gray-800" v-if="message" v-html="message" />
</div>
</template>
<template #actions>
@@ -32,12 +28,6 @@ export default {
message: {
type: String,
},
// Optional v-model; when absent, visibility falls back to the internal
// state (open on mount), preserving the `v-if` + `show()`/`hide()` mode.
modelValue: {
type: Boolean,
default: undefined,
},
onConfirm: {
type: Function,
default: null,
@@ -47,7 +37,6 @@ export default {
default: null,
},
},
emits: ["update:modelValue"],
expose: ["show", "hide"],
components: {
Dialog,
@@ -59,19 +48,6 @@ export default {
isLoading: false,
};
},
computed: {
isOpen: {
get() {
return this.modelValue !== undefined
? this.modelValue
: this.showDialog;
},
set(value) {
this.showDialog = value;
this.$emit("update:modelValue", value);
},
},
},
methods: {
async handleConfirmation() {
if (this.isLoading) return;
@@ -90,14 +66,14 @@ export default {
}
},
show() {
this.isOpen = true;
this.showDialog = true;
},
closeDialog() {
this.hide();
this.onCancel?.();
},
hide() {
this.isOpen = false;
this.showDialog = false;
},
},
};

View File

@@ -1,14 +1,14 @@
<template>
<div v-if="normalActions.length" class="flex gap-2">
<!-- onClick rides along in `action`; binding it twice would run it twice -->
<Button
v-for="action in normalActions"
:key="action.label"
:label="action.label"
@click="action.onClick()"
v-bind="action"
>
<template v-if="action.icon" #prefix>
<Icon :icon="action.icon" class="h-4 w-4" />
<FeatherIcon :name="action.icon" class="h-4 w-4" />
</template>
</Button>
</div>
@@ -17,8 +17,8 @@
<Dropdown v-slot="{ open }" :options="g.action">
<Button :label="g.label">
<template #suffix>
<component
:is="open ? LucideChevronUp : LucideChevronDown"
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
@@ -27,14 +27,11 @@
</div>
</div>
<Dropdown v-if="groupedActions.length" :options="groupedActions">
<Button icon="lucide-more-horizontal" variant="ghost" />
<Button icon="more-horizontal" variant="ghost" />
</Dropdown>
</template>
<script setup>
import LucideChevronUp from "~icons/lucide/chevron-up";
import LucideChevronDown from "~icons/lucide/chevron-down";
import Icon from "@/components/Icon.vue";
import { Dropdown } from "frappe-ui";
import { computed } from "vue";

View File

@@ -1,89 +0,0 @@
<template>
<Dialog v-model:open="open" :title="dialogTitle" size="md">
<template #default>
<div class="flex flex-col gap-4">
<p class="text-p-base text-ink-gray-7">{{ message }}</p>
<Checkbox
v-if="count"
size="sm"
v-model="deleteLinkedTickets"
:label="__('Delete {0} ticket(s)', [count])"
/>
</div>
</template>
<template #actions>
<div class="flex justify-end">
<Button
variant="solid"
theme="red"
icon-left="lucide-trash-2"
:label="__('Delete')"
:loading="isDeleting"
@click="confirmDelete"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { Button, Checkbox, createResource, Dialog } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps<{
/** Name of the record being deleted. */
name: string;
/** HD Ticket link field that ties tickets to this record. */
linkField: "customer" | "contact";
/** Confirmation copy shown above the checkbox. */
message: string;
title?: string;
/** Async delete handler; the button stays in a loading state until it settles. */
onDelete: (payload: { deleteLinkedTickets: boolean }) => Promise<void> | void;
}>();
const open = defineModel<boolean>({ default: false });
const resource = createResource({
url: "frappe.client.get_count",
params: {
doctype: "HD Ticket",
filters: { [props.linkField]: props.name },
},
auto: true,
});
const count = computed<number>(() => {
if (resource.loading) return 0;
return resource.data ?? 0;
});
const deleteLinkedTickets = ref(false);
const isDeleting = ref(false);
const dialogTitle = computed(() => props.title ?? __("Delete"));
async function confirmDelete() {
if (isDeleting.value) return;
isDeleting.value = true;
try {
await props.onDelete({ deleteLinkedTickets: deleteLinkedTickets.value });
open.value = false;
} catch {
// Keep the dialog open on failure; the handler surfaces the error.
} finally {
isDeleting.value = false;
}
}
watch(
open,
(isOpen) => {
if (isOpen) {
resource.reload();
}
},
{ immediate: true }
);
</script>

View File

@@ -34,11 +34,9 @@ function handleDiscard() {
},
actions: [
{
label: "Delete",
theme: "red",
iconLeft: "lucide-trash-2",
label: "Confirm",
variant: "solid",
onClick({ close }: { close: () => void }) {
onClick(close: Function) {
emit("discard");
close();
},

View File

@@ -0,0 +1,286 @@
<template>
<div
:id="`communication-${name}`"
v-bind="$attrs"
class="grow cursor-pointer border-transparent bg-white rounded-md shadow text-base leading-6 transition-all duration-300 ease-in-out"
>
<div
class="flex items-center justify-between gap-2"
:class="isMobileView && 'items-start'"
>
<!-- email design for mobile -->
<div v-if="isMobileView" class="flex items-center gap-2 text-sm">
<div class="leading-tight">
<p>{{ sender.full_name || "No name found" }}</p>
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<p class="text-xs md:text-sm text-gray-600">
{{ timeAgo(creation) }}
</p>
</Tooltip>
<p class="sm:flex hidden text-sm text-gray-600" v-if="sender.name">
{{ "<" + sender.name + ">" }}
</p>
</div>
</div>
<!-- email design for desktop -->
<div v-else class="flex items-center gap-1">
<span>{{ sender.full_name || "No name found" }}</span>
<span class="sm:flex hidden text-sm text-gray-600" v-if="sender.name">{{
"<" + sender.name + ">"
}}</span>
</div>
<div class="flex gap-0.5 items-center">
<Badge
v-if="status.label"
:label="__(status.label)"
variant="subtle"
:theme="status.color"
class="mr-1.5"
/>
<Tooltip
:text="dateFormat(creation, dateTooltipFormat)"
v-if="!isMobileView"
>
<p class="text-xs md:text-sm text-gray-600">
{{ timeAgo(creation) }}
</p>
</Tooltip>
<Button variant="ghost" class="text-gray-700" @click="reply">
<ReplyIcon class="h-4 w-4" />
</Button>
<Button variant="ghost" class="text-gray-700" @click="replyAll">
<ReplyAllIcon class="h-4 w-4" />
</Button>
<Dropdown
v-if="showSplitOption"
:placement="'right'"
:options="[
{
label: 'Split Ticket',
icon: LucideSplit,
onClick: () => (showSplitModal = true),
},
]"
>
<Button
icon="more-horizontal"
class="text-gray-600"
variant="ghost"
/>
</Dropdown>
</div>
</div>
<!-- <div class="text-sm leading-5 text-gray-600">
{{ subject }}
</div> -->
<div class="text-sm leading-5 text-gray-600">
<span v-if="to" class="text-2xs mr-1 font-bold text-gray-500">TO:</span>
<span v-if="to"> {{ to }} </span>
<span v-if="cc">, </span>
<span v-if="cc" class="text-2xs mr-1 font-bold text-gray-500"> CC: </span>
<span v-if="cc">{{ cc }}</span>
<span v-if="bcc">, </span>
<span v-if="bcc" class="text-2xs mr-1 font-bold text-gray-500">
BCC:
</span>
<span v-if="bcc">{{ bcc }}</span>
</div>
<div class="border-0 border-t my-3 border-outline-gray-modals" />
<EmailContent :content="content" />
<div class="flex flex-wrap gap-2">
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
:url="a.file_url"
/>
</div>
</div>
<TicketSplitModal
v-model="showSplitModal"
:ticket_id="name"
:communication_id="name"
/>
</template>
<script setup lang="ts">
import { AttachmentItem } from "@/components";
import { useScreenSize } from "@/composables/screen";
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
import { Dropdown } from "frappe-ui";
import { computed, ref } from "vue";
import LucideSplit from "~icons/lucide/split";
import { ReplyAllIcon, ReplyIcon } from "./icons";
import TicketSplitModal from "./ticket/TicketSplitModal.vue";
import { useAuthStore } from "@/stores/auth";
import { storeToRefs } from "pinia";
const props = defineProps({
activity: {
type: Object,
required: true,
},
showSplitOption: {
type: Boolean,
default: false,
},
});
const {
sender,
to,
cc,
bcc,
creation,
subject,
attachments,
content,
name,
deliveryStatus,
} = props.activity;
const emit = defineEmits(["reply"]);
const auth = storeToRefs(useAuthStore());
const { isMobileView } = useScreenSize();
const showSplitModal = ref(false);
const status = computed(() => {
let _status = deliveryStatus;
let indicator_color = "red";
if (["Sent", "Clicked"].includes(_status)) {
indicator_color = "green";
} else if (["Sending", "Scheduled"].includes(_status)) {
indicator_color = "orange";
} else if (["Opened", "Read"].includes(_status)) {
indicator_color = "blue";
} else if (_status == "Error") {
indicator_color = "red";
}
return { label: _status, color: indicator_color };
});
const reply = () => {
const user = auth.user.value;
emit("reply", {
content: content,
to: user === sender.name ? to : sender.name,
});
};
const replyAll = () => {
const user = auth.user.value;
const normalizeAndFilter = (field) => {
let arr = [];
let current = "";
let inQuotes = false;
if (typeof field === "string") {
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
arr.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current) arr.push(current.trim());
} else {
arr = field || [];
}
return arr.filter((item) => item !== user && item !== sender.name);
};
const filteredTo = normalizeAndFilter(to);
const filteredCc = normalizeAndFilter(cc);
const filteredBcc = normalizeAndFilter(bcc);
let _to, _cc, _bcc;
if (user === sender.name) {
// User is the sender, reply to all original recipients
_to = filteredTo.join(", ");
_cc = filteredCc;
_bcc = filteredBcc;
} else {
// User is a recipient, reply to sender with all other recipients in cc
_to = sender.name;
_cc = [...filteredTo, ...filteredCc];
_bcc = filteredBcc;
}
emit("reply", {
content: content,
to: _to,
cc: _cc.filter(Boolean),
bcc: _bcc.filter(Boolean),
});
};
// TODO: Implement reply functionality using this way instead of emit drillup
// function reply(email, reply_all = false) {
// emailBox.toggleEmailBox();
// let editor = emailBox.editor;
// let message = email.content;
// let recipients = sender.name;
// editor.toEmails = [email.sender];
// editor.cc = editor.bcc = false;
// editor.ccEmails = [];
// editor.bccEmails = [];
// console.log(recipients);
// if (!email.subject.startsWith("Re:")) {
// editor.subject = `Re: ${email.subject}`;
// } else {
// editor.subject = email.subject;
// }
// if (reply_all) {
// let cc = email.cc?.split(",").map((r) => r.trim());
// let bcc = email.bcc?.split(",").map((r) => r.trim());
// if (cc?.length) {
// recipients = recipients.filter((r) => !cc?.includes(r));
// cc.push(...recipients);
// } else {
// cc = recipients;
// }
// editor.cc = cc ? true : false;
// editor.bcc = bcc ? true : false;
// editor.ccEmails = cc;
// editor.bccEmails = bcc;
// }
// let repliedMessage = `<blockquote>${message}</blockquote>`;
// editor.editor
// .chain()
// .clearContent()
// .insertContent("<p>.</p>")
// .updateAttributes("paragraph", { class: "reply-to-content" })
// .insertContent(repliedMessage)
// .focus("all")
// .insertContentAt(0, { type: "paragraph" })
// .focus("start")
// .run();
// }
</script>
<style>
.email-content {
max-width: 100%;
}
.email-content > * {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
</style>

View File

@@ -7,7 +7,7 @@
</template>
<script setup lang="ts">
import { dataTheme, getFontFamily, stripEmailColors } from "@/utils";
import { getFontFamily } from "@/utils";
import { computed, ref, watch } from "vue";
const props = defineProps({
@@ -20,10 +20,6 @@ const props = defineProps({
const iframeRef = ref<HTMLIFrameElement | null>(null);
const _content = ref(props.content);
const themedContent = computed(() =>
dataTheme.value === "dark" ? stripEmailColors(_content.value) : _content.value
);
// Get CSS path - in dev Vite serves it directly, in prod we need the built path
const cssHref = computed(() => {
if (import.meta.env.DEV) {
@@ -48,11 +44,7 @@ const gmailReplyToContent = doc.querySelectorAll("div.gmail_quote");
const outlookReplyToContent = doc.querySelectorAll("div#appendonsend");
const replyToContent = doc.querySelectorAll("p.reply-to-content");
if (doc.body.children.length === 0) {
// Plain-text emails (e.g. from Thunderbird) arrive as text with newlines and no spacing losing formatting.
const plainText = doc.body.innerHTML.replace(/\n\n/g, "\n");
_content.value = `<div class="whitespace-pre-wrap">${plainText}</div>`;
} else if (gmailReplyToContent.length) {
if (gmailReplyToContent.length) {
_content.value = parseReplyToContent(doc, "div.gmail_quote", true);
} else if (outlookReplyToContent.length) {
_content.value = parseReplyToContent(doc, "div#appendonsend");
@@ -159,7 +151,6 @@ const htmlContent = computed(
font-weight: 700;
height: 12px;
line-height: 0.1;
color: #383838;
background: #e8eaed;
width: 23px;
justify-content: center;
@@ -168,13 +159,6 @@ const htmlContent = computed(
.replied-content .collapse:hover {
background: #dadce0;
}
[data-theme='dark'] .replied-content .collapse {
color: #e2e2e2;
background: #424242;
}
[data-theme='dark'] .replied-content .collapse:hover {
background: #525252;
}
.replied-content .collapse + input {
display: none;
}
@@ -187,8 +171,31 @@ const htmlContent = computed(
.email-content {
word-break: break-word;
}
.email-content :is(:where(table):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
table-layout: auto;
}
.email-content :where(table):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
width: unset;
table-layout: auto;
text-align: unset;
margin-top: unset;
margin-bottom: unset;
font-size: unset;
line-height: unset;
}
.email-content :where(tbody tr):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
border-bottom-width: 0;
border-bottom-color: transparent;
}
.email-content :is(:where(td):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
position: unset;
border-width: 0;
border-color: transparent;
padding: 0;
}
.email-content :where(tbody td):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
vertical-align: revert;
}
.email-content :is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
border-width: 0;
}
@@ -201,11 +208,10 @@ const htmlContent = computed(
.email-content :where(blockquote p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::after {
content: none;
}
</style>
</head>
<body>
<div class="email-content prose-f">${themedContent.value}</div>
<div class="email-content prose-f">${_content.value}</div>
</body>
</html>
`
@@ -220,35 +226,14 @@ watch(iframeRef, (iframe) => {
const parent = emailContent.closest("html");
if (!parent) return;
parent.setAttribute("data-theme", dataTheme.value);
let theme = document.documentElement.getAttribute("data-theme");
parent.setAttribute("data-theme", theme);
const font = getFontFamily(_content.value);
if (font) emailContent.classList.add(font);
iframe.style.height = parent.offsetHeight + 1 + "px";
// Clicks inside the iframe don't bubble to the parent document, popovers/dropdowns that close on outside-click never fire.
iframe.contentDocument?.addEventListener("pointerdown", () => {
document.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true })
);
});
// This is to ensure that keyboard shortcuts work even when the iframe is focused. For example, pressing "r" to reply to an email should work even if the user has clicked inside the email content.
iframe.contentDocument?.addEventListener("keydown", (e) => {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: e.key,
code: e.code,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
bubbles: true,
})
);
});
const replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => {
@@ -260,9 +245,4 @@ watch(iframeRef, (iframe) => {
};
}
});
watch(dataTheme, (theme) => {
const html = iframeRef.value?.contentDocument?.documentElement;
if (html) html.setAttribute("data-theme", theme);
});
</script>

View File

@@ -1,222 +1,159 @@
<template>
<Editor
<TextEditor
ref="editorRef"
v-model="newEmail"
:editor-class="[
'prose-sm max-w-full mx-6 md:mx-10 max-h-[50vh] py-3',
'min-h-[7rem]',
getFontFamily(newEmail),
editable && '!max-h-[35vh] overflow-y-auto',
]"
:content="newEmail"
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
:placeholder="placeholder"
:editable="editable"
:extensions="extensions"
:upload-function="
(file: any, options: any) =>
track(uploadFunction(file, doctype, ticketId, true, options))
"
@change="editable ? (newEmail = $event) : null"
:extensions="[PreserveVideoControls]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
>
<template #default>
<div @keydown.capture="handleKeydown">
<!-- Email headers -->
<div
v-if="hasMultipleSenders"
class="mx-6 md:mx-5 flex items-center gap-2 border-t py-2.5 h-12.5"
>
<span class="text-p-xs text-ink-gray-4">{{ __("From") }}:</span>
<FormControl
v-model="fromEmail"
type="select"
variant="ghost"
class="w-full"
:placeholder="__('')"
:options="from"
/>
</div>
<div class="mx-6 md:mx-5 flex items-center gap-2 border-y py-2.5">
<span class="text-p-xs text-ink-gray-4">{{ __("To") }}:</span>
<EmailMultiSelect
v-model="toEmailsClone"
class="flex-1"
scope="contact"
variant="ghost"
allow-custom-email
copy-on-click
:validate="validateEmailWithZod"
:custom-email-label="__('Add to recipients')"
/>
<div class="flex gap-1.5">
<Button
:label="__('Cc')"
variant="ghost"
:class="[
cc || showCC
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
: '!text-ink-gray-4',
]"
@click="toggleCC()"
/>
<Button
:label="__('Bcc')"
variant="ghost"
:class="[
bcc || showBCC
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
: '!text-ink-gray-4',
]"
@click="toggleBCC()"
/>
</div>
</div>
<div
v-if="showCC || cc"
class="mx-5 flex items-center gap-2 py-2.5"
:class="cc || showCC ? 'border-b' : ''"
>
<span class="text-xs text-ink-gray-4">{{ __("Cc:") }}</span>
<EmailMultiSelect
ref="ccInput"
v-model="ccEmailsClone"
class="flex-1"
scope="contact"
variant="ghost"
allow-custom-email
copy-on-click
:validate="validateEmailWithZod"
:custom-email-label="__('Add to recipients')"
/>
</div>
<div
v-if="showBCC || bcc"
class="mx-5 flex items-center gap-2 py-2.5"
:class="bcc || showBCC ? 'border-b' : ''"
>
<span class="text-xs text-ink-gray-4">{{ __("Bcc:") }}</span>
<EmailMultiSelect
ref="bccInput"
v-model="bccEmailsClone"
class="flex-1"
scope="contact"
variant="ghost"
allow-custom-email
copy-on-click
:validate="validateEmailWithZod"
:custom-email-label="__('Add to recipients')"
/>
</div>
<!-- Editor content + quoted reply -->
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col">
<div class="flex-1">
<EditorContent
:class="[
'prose-sm max-w-full mx-6 md:mx-5 py-3',
getFontFamily(newEmail),
'[&_p.reply-to-content]:hidden',
]"
/>
</div>
<div
v-if="quotedContent"
class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
>
<label class="collapse" for="quoted-toggle">...</label>
<input
id="quoted-toggle"
class="replyCollapser"
type="checkbox"
:checked="isQuoteExpanded"
/>
<div
ref="quotedContentRef"
contenteditable="true"
class="prose !max-w-full mx-1 my-2 border-s-4 border-outline-gray-2 ps-4 text-sm focus:outline-none"
@input="onQuotedInput"
/>
</div>
</div>
<!-- Attachments -->
<AttachmentList
class="px-5 my-2"
:attachments="attachments"
@remove="removeAttachment"
<template #top>
<div class="mx-6 md:mx-10 flex items-center gap-2 border-y py-2.5">
<span class="text-xs text-gray-500">TO:</span>
<MultiSelectInput
v-model="toEmailsClone"
class="flex-1"
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
/>
<!-- Saved reply actions, applied once the reply is sent -->
<SavedReplyActions
ref="savedReplyActionsRef"
class="mx-5 my-2"
:ticket-id="ticketId"
:doctype="doctype"
<Button
:label="'CC'"
:class="[cc ? 'bg-gray-300 hover:bg-gray-200' : '']"
@click="toggleCC()"
/>
<!-- Fixed Menu -->
<div
class="flex justify-between gap-2 overflow-scroll px-4 py-2.5 items-center border-t"
<Button
:label="'BCC'"
:class="[bcc ? 'bg-gray-300 hover:bg-gray-200' : '']"
@click="toggleBCC()"
/>
</div>
<div
v-if="showCC || cc"
class="mx-10 flex items-center gap-2 py-2.5"
:class="cc || showCC ? 'border-b' : ''"
>
<span class="text-xs text-gray-500">CC:</span>
<MultiSelectInput
ref="ccInput"
v-model="ccEmailsClone"
class="flex-1"
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
/>
</div>
<div
v-if="showBCC || bcc"
class="mx-10 flex items-center gap-2 py-2.5"
:class="bcc || showBCC ? 'border-b' : ''"
>
<span class="text-xs text-gray-500">BCC:</span>
<MultiSelectInput
ref="bccInput"
v-model="bccEmailsClone"
class="flex-1"
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
/>
</div>
</template>
<!-- <template v-slot:editor="{ _editor }">
<EditorContent
:class="[editable && 'max-h-[35vh] overflow-y-auto']"
:editor="_editor"
/>
</template> -->
<template #bottom>
<!-- Attachments -->
<div class="flex flex-wrap gap-2 px-10">
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
:url="!['MOV', 'MP4'].includes(a.file_type) ? a.file_url : null"
>
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
<div class="inline-flex items-center gap-1.5 p-1">
<FileUploader
:doctype="doctype"
:docname="ticketId"
private
@success="
(f) => {
attachments.push(f);
}
"
>
<template #default="{ openFileSelector, uploading }">
{{ void (attachmentUploading = uploading) }}
<Tooltip :text="__('Attach file')">
<button
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
@click="openFileSelector()"
:disabled="uploading"
>
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
<AttachmentIcon
v-else
class="h-4 w-4"
style="stroke-width: 1.5 !important"
/>
</button>
</Tooltip>
</template>
</FileUploader>
<Tooltip :text="__('Saved replies')">
<button
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
@click="showSavedRepliesSelectorModal = true"
>
<ZapIcon class="h-4 w-4" />
</button>
</Tooltip>
<div class="h-4 w-[2px] border-s ml-1" />
</div>
<EditorFixedMenu :items="fullToolbar" />
<EditorTableMenu />
</div>
<div class="flex shrink-0 items-center justify-end gap-x-2">
<Button label="Discard" @click="handleDiscard" />
<!-- A disabled button fires no pointer events, so the span
carries the hover for the tooltip -->
<Tooltip
:text="isUploading ? __('Please wait, media is uploading') : ''"
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.self.stop="removeAttachment(a)"
/>
</template>
</AttachmentItem>
</div>
<!-- TextEditor Fixed Menu -->
<div
class="flex justify-between overflow-scroll pl-10 py-2.5 items-center"
>
<div class="flex items-center overflow-x-auto w-[60%]">
<div class="flex gap-1">
<FileUploader
:upload-args="{
doctype: doctype,
docname: ticketId,
private: true,
}"
@success="
(f) => {
attachments.push(f);
}
"
>
<span class="inline-flex">
<template #default="{ openFileSelector, uploading }">
{{ void (isUploading = uploading) }}
<Button
variant="solid"
:disabled="isDisabled"
:loading="sendMail.loading"
:label="label"
@click="
() => {
submitMail();
}
"
/>
</span>
</Tooltip>
variant="ghost"
@click="openFileSelector()"
:loading="uploading"
>
<template #icon>
<AttachmentIcon
class="h-4"
style="color: #000000; stroke-width: 1.5 !important"
/>
</template>
</Button>
</template>
</FileUploader>
<Button
variant="ghost"
@click="showSavedRepliesSelectorModal = true"
>
<template #icon>
<SavedReplyIcon class="h-4" />
</template>
</Button>
</div>
<TextEditorFixedMenu class="ml-1" :buttons="textEditorMenuButtons" />
</div>
<div
class="flex items-center justify-end space-x-2 sm:mt-0 w-[40%] mr-9"
>
<Button label="Discard" @click="handleDiscard" />
<Button
variant="solid"
:disabled="isDisabled"
:loading="sendMail.loading"
:label="label"
@click="
() => {
submitMail();
}
"
/>
</div>
</div>
</template>
</Editor>
</TextEditor>
<SavedRepliesSelectorModal
v-if="showSavedRepliesSelectorModal"
v-model="showSavedRepliesSelectorModal"
:doctype="doctype"
@apply="applySavedReplies"
@@ -225,56 +162,39 @@
</template>
<script setup lang="ts">
import { AttachmentList, SavedRepliesSelectorModal } from "@/components";
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
import EmailMultiSelect from "@/components/EmailMultiSelect.vue";
import { createDialog } from "@/components/dialogs";
import {
AttachmentItem,
SavedRepliesSelectorModal,
MultiSelectInput,
} from "@/components";
import { AttachmentIcon } from "@/components/icons";
import SavedReplyActions from "@/components/SavedReplyActions/SavedReplyActions.vue";
import { useTyping } from "@/composables/realtime";
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
import { useUploadTracker } from "@/composables/useUploadTracker";
import { replyComposer } from "@/pages/ticket/modalStates";
import { useAuthStore } from "@/stores/auth";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { RenderedSavedReply } from "@/types";
import { PreserveVideoControls } from "@/tiptap-extensions";
import {
getFontFamily,
htmlToText,
isContentEmpty,
removeAttachmentFromServer,
textEditorMenuButtons,
uploadFunction,
validateEmailWithZod,
} from "@/utils";
// import { EditorContent } from "@tiptap/vue-3";
import { useStorage } from "@vueuse/core";
import {
FileUploader,
LoadingIndicator,
Tooltip,
TextEditor,
TextEditorFixedMenu,
createResource,
dayjs,
toast,
} from "frappe-ui";
import {
Editor,
EditorContent,
EditorFixedMenu,
EditorTableMenu,
} from "frappe-ui/editor";
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
import { useOnboarding } from "@framework/ui";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import ZapIcon from "~icons/lucide/zap";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
const editorRef = ref(null);
const showSavedRepliesSelectorModal = ref(false);
// ─── Props & Emits ────────────────────────────────────────────
const props = defineProps({
ticketId: {
type: String,
@@ -310,53 +230,37 @@ const props = defineProps({
},
});
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
const label = computed(() => {
return sendMail.loading ? "Sending..." : props.label;
});
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const authStore = useAuthStore();
const { isManager } = authStore;
const { getUser } = useUserStore();
const emit = defineEmits(["submit", "discard"]);
const newEmail = useStorage("emailBoxContent" + props.ticketId, null);
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
// Initialize typing composable
const { onUserType, cleanup } = useTyping(props.ticketId);
const extensions = buildEditorExtensions();
const attachments = ref([]);
const isUploading = ref(false);
const isDisabled = computed(() => {
return (
isContentEmpty(newEmail.value) || sendMail.loading || isUploading.value
);
});
const editorRef = ref(null);
const editor = computed(() => editorRef.value?.editor);
// Watch for changes in email content to trigger typing events
watch(newEmail, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
});
function focusEditorAtStart() {
setTimeout(() => {
editorRef.value?.editor?.commands?.focus("start");
}, 0);
}
const cachedEmail = useStorage<null | string>(
"emailBoxContent" + props.ticketId,
null
);
const newEmail = ref<null | string>(cachedEmail.value);
const emailSignature = ref<string | null>(null);
function isOnlySignature(content: string | null) {
if (!content || !emailSignature.value) return false;
return htmlToText(content) === htmlToText(emailSignature.value);
}
const userResource = getUserEmailInfo();
const quotedContent = useStorage<null | string>(
"quotedEmailBoxContent" + props.ticketId,
null
);
const quotedContentRef = ref<HTMLElement | null>(null);
const isQuoteExpanded = ref(false);
function onQuotedInput() {
const el = quotedContentRef.value;
if (!el) return;
quotedContent.value = el.innerHTML || null;
}
onBeforeUnmount(() => {
cleanup();
});
const toEmailsClone = ref([...props.toEmails]);
const ccEmailsClone = ref([...props.ccEmails]);
@@ -368,8 +272,53 @@ const bcc = computed(() => (bccEmailsClone.value?.length ? true : false));
const ccInput = ref(null);
const bccInput = ref(null);
function applySavedReplies(template) {
newEmail.value = template;
showSavedRepliesSelectorModal.value = false;
}
const sendMail = createResource({
url: "run_doc_method",
makeParams: () => ({
dt: props.doctype,
dn: props.ticketId,
method: "reply_via_agent",
args: {
attachments: attachments.value.map((x) => x.name),
to: toEmailsClone.value.join(","),
cc: ccEmailsClone.value?.join(","),
bcc: bccEmailsClone.value?.join(","),
message: newEmail.value,
},
}),
onSuccess: () => {
resetState();
emit("submit");
if (isManager) {
updateOnboardingStep("reply_on_ticket");
}
},
debounce: 300,
});
function submitMail() {
if (isContentEmpty(newEmail.value)) {
return false;
}
if (!toEmailsClone.value.length) {
toast.warning(
"Email has no recipients. Please add at least one email address in the 'TO' field."
);
return false;
}
sendMail.submit();
}
function toggleCC() {
showCC.value = !showCC.value;
showCC.value &&
nextTick(() => {
ccInput.value.setFocus();
@@ -384,224 +333,11 @@ function toggleBCC() {
});
}
const fromEmail = useStorage<string | "">("from-email", "");
const outgoingEmails = computed<{ email_account: string; email_id: string }[]>(
() => userResource.data?.outgoing_emails ?? []
);
// selected mail from the outgoing emails list
const selectedFromEmail = computed(() =>
outgoingEmails.value.find((e) => e.email_id === fromEmail.value)
);
const from = computed(() => {
if (!outgoingEmails.value.length) return [];
if (
outgoingEmails.value.length === 1 &&
outgoingEmails.value[0].email_id === userResource.data?.email
)
return [];
return outgoingEmails.value.map((e) => ({
label: e.email_account + " <" + e.email_id + ">",
value: e.email_id,
}));
});
const hasMultipleSenders = computed(() => (from?.value.length ?? 0) > 1);
const attachments = ref([]);
const attachmentUploading = ref(false);
const { isUploading: editorUploading, track, dropUnused } = useUploadTracker();
// The paperclip and the editor's own media buttons upload by different routes
const isUploading = computed(
() => attachmentUploading.value || editorUploading.value
);
async function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment);
await removeAttachmentFromServer(attachment.name);
}
const showSavedRepliesSelectorModal = ref(false);
const savedReplyActionsRef = ref<InstanceType<typeof SavedReplyActions>>();
/** A reply is only replaced when another one is already applied. */
function applySavedReplies(reply: RenderedSavedReply) {
const staged = savedReplyActionsRef.value?.stagedSummary();
if (!staged) {
insertSavedReply(reply);
return;
}
createDialog({
title: __("Replace saved reply"),
message: __(
'Applying "{0}" discards the reply you have now, along with its {1} action(s).',
[reply.title, staged.count]
),
actions: [
{ label: __("Cancel") },
{
label: __("Replace"),
variant: "solid",
onClick: ({ close }: { close: () => void }) => {
replaceSavedReply(reply);
close();
},
},
],
});
}
/** First reply of a draft: added to whatever the agent has already written. */
function insertSavedReply(reply: RenderedSavedReply) {
const textEditor = editorRef.value?.editor;
if (!textEditor) return;
textEditor.chain().focus("start").insertContent(reply.message).run();
savedReplyActionsRef.value?.add(reply);
}
/** Confirmed replace: the new reply's body and actions stand alone. */
function replaceSavedReply(reply: RenderedSavedReply) {
newEmail.value = reply.message + (emailSignature.value ?? "");
savedReplyActionsRef.value?.add(reply);
focusEditorAtStart();
}
/** Set by submitMail, so the composer can be cleared before the request goes out. */
let pendingRow: ReturnType<typeof addPendingActivity> | null = null;
let sentDraft: {
content: string | null;
attachments: any[];
quoted: string | null;
quoteExpanded: boolean;
} | null = null;
const sendMail = createResource({
url: "run_doc_method",
makeParams: (params) => params,
onSuccess: (res: { message?: string } | string) => {
// run_doc_method answers with the whole body, since it always carries `docs`
const name = typeof res === "string" ? res : res?.message;
// the real row replaces the pending one the moment it arrives; unkeyed,
// it would outlive it
name ? pendingRow?.resolve(`email:${name}`) : pendingRow?.drop();
pendingRow = null;
sentDraft = null;
savedReplyActionsRef.value?.submit();
emit("submit");
if (isManager) {
updateOnboardingStep?.("reply_on_ticket");
}
},
onError: () => {
toast.error(__("Could not send the reply"));
pendingRow?.drop();
pendingRow = null;
if (sentDraft) {
newEmail.value = sentDraft.content;
attachments.value = sentDraft.attachments;
quotedContent.value = sentDraft.quoted;
isQuoteExpanded.value = sentDraft.quoteExpanded;
sentDraft = null;
}
emit("restore");
},
debounce: 300,
});
const label = computed(() => (sendMail.loading ? "Sending..." : props.label));
const isDisabled = computed(
() =>
(isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) ||
sendMail.loading ||
isUploading.value
);
function submitMail() {
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
return false;
}
// The keyboard shortcut reaches here without passing the disabled button
if (isUploading.value || sendMail.loading) return false;
if (
!toEmailsClone.value.length &&
!ccEmailsClone.value.length &&
!bccEmailsClone.value.length
) {
toast.warning(
"Email has no recipients. Please add at least one recipient (To, Cc, or Bcc) before sending."
);
return false;
}
const message =
newEmail.value +
(quotedContentRef.value
? `<p class="reply-to-content"></p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
: "");
const sender = selectedFromEmail.value?.email_id ?? authStore.userId;
const ownEmail = getUser(authStore.userId).email;
pendingRow?.drop();
pendingRow = addPendingActivity(props.doctype, props.ticketId, {
type: "email",
timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
author: {
email: sender,
// the users list may still be a stub with a guessed name and no avatar
fullname: authStore.userName,
image: sender === ownEmail ? authStore.userImage : undefined,
},
data: {
name: "",
sender,
to: toEmailsClone.value.join(", "),
cc: ccEmailsClone.value?.join(", "),
bcc: bccEmailsClone.value?.join(", "),
content: message,
attachments: attachments.value.map((a) => ({
file_url: a.file_url,
file_name: a.file_name,
is_private: a.is_private,
})),
},
});
sentDraft = {
content: newEmail.value,
attachments: attachments.value,
quoted: quotedContent.value,
quoteExpanded: isQuoteExpanded.value,
};
const params = {
dt: props.doctype,
dn: props.ticketId,
method: "reply_via_agent",
args: {
attachments: attachments.value.map((x) => x.name),
from_email: selectedFromEmail.value,
to: toEmailsClone.value.join(","),
cc: ccEmailsClone.value?.join(","),
bcc: bccEmailsClone.value?.join(","),
message,
},
};
// drop before resetState clears, or an unmount mid-send deletes what it carries
dropUnused(message);
resetState();
emit("sending");
sendMail.submit(params);
}
function getInitialContent() {
return emailSignature.value ? emailSignature.value : "<p></p>";
}
function addToReply(
body: string,
toEmails: string[],
@@ -611,192 +347,42 @@ function addToReply(
toEmailsClone.value = toEmails;
ccEmailsClone.value = ccEmails;
bccEmailsClone.value = bccEmails;
// Plain-text emails (e.g. Thunderbird) have no HTML tags, so their newlines/spacing
// thus lose formatting when added to replied content
const doc = new DOMParser().parseFromString(body, "text/html");
if (doc.body.children.length === 0) {
body = `<div style="white-space: pre-wrap; line-height: 1.5">${doc.body.innerHTML}</div>`;
}
if (body !== quotedContent.value) {
//trigger change for watch when replied to body data is different from current quoted content
quotedContent.value = null;
isQuoteExpanded.value = false;
nextTick(() => {
quotedContent.value = body;
});
}
nextTick(() => {
newEmail.value = getInitialContent();
});
focusEditorAtStart();
editorRef.value.editor
.chain()
.clearContent()
.insertContent(body)
.focus("all")
.setBlockquote()
.insertContentAt(0, { type: "paragraph" })
.focus("start")
.run();
}
// Staged actions are left to `submit()`, which unstages them either way
function resetState() {
newEmail.value = emailSignature.value ? emailSignature.value : null;
newEmail.value = null;
attachments.value = [];
quotedContent.value = null;
isQuoteExpanded.value = false;
focusEditorAtStart();
}
function handleDiscard() {
attachments.value = [];
savedReplyActionsRef.value?.clear();
newEmail.value = getInitialContent();
quotedContent.value = null;
newEmail.value = null;
ccEmailsClone.value = [];
bccEmailsClone.value = [];
ccEmailsClone.value = [];
showCC.value = false;
showBCC.value = false;
isQuoteExpanded.value = false;
dropUnused(null);
focusEditorAtStart();
emit("discard");
}
function handleSelectAll(e: KeyboardEvent) {
const active = document.activeElement;
const editorContext = editorRef.value?.editor;
const editorDom = editorContext?.view?.dom as HTMLElement | undefined;
const quotedEl = quotedContentRef.value;
const sel = window.getSelection();
if (!sel || !editorDom) return;
if (!editorDom.contains(active) && !(quotedEl && quotedEl.contains(active))) {
return;
}
// after the focus check: select-all from a recipient field must not unfold
// the quoted reply
isQuoteExpanded.value = true;
e.preventDefault();
editorContext?.commands.selectAll();
sel.removeAllRanges();
const range = document.createRange();
if (quotedEl) {
range.setStartBefore(editorDom);
range.setEndAfter(quotedEl);
} else {
range.selectNodeContents(editorDom);
}
sel.addRange(range);
}
function handleDelete(e: KeyboardEvent) {
const sel = window.getSelection();
const quotedEl = quotedContentRef.value;
const editorDom = editorRef.value?.editor?.view?.dom as
| HTMLElement
| undefined;
if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return;
const isSelectingEntireEditor = sel.containsNode(editorDom, true);
const isSelectingEntireQuote = sel.containsNode(quotedEl, true);
if (isSelectingEntireEditor && isSelectingEntireQuote) {
e.preventDefault();
editorRef.value?.editor?.commands?.clearContent();
newEmail.value = null;
quotedContent.value = null;
sel.removeAllRanges();
}
}
function handleKeydown(e: KeyboardEvent) {
const key = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && key === "a") {
handleSelectAll(e);
return;
}
if (key === "backspace" || key === "delete") {
handleDelete(e);
return;
}
}
watch(newEmail, (newValue, oldValue) => {
// The signature drops into an empty editor on load and on every reply. That
// is not the agent typing, and broadcasting it would show a typing indicator
// to everyone else on the ticket.
if (
newValue !== oldValue &&
!isContentEmpty(newValue) &&
!isOnlySignature(newValue)
) {
onUserType();
}
cachedEmail.value = isOnlySignature(newValue) ? null : newValue;
});
watch(quotedContent, (newVal, oldVal) => {
if (!oldVal && newVal) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = newVal;
}
});
}
});
watch(
() => userResource.data,
(data: { email_signature?: string } | null) => {
if (!data?.email_signature) return;
emailSignature.value = `<br>${data.email_signature}`;
if (isOnlySignature(cachedEmail.value)) {
cachedEmail.value = null;
}
if (isContentEmpty(newEmail.value) && !quotedContent.value) {
newEmail.value = emailSignature.value;
focusEditorAtStart();
}
},
{ immediate: true }
);
watch(
from,
(fromOptions) => {
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
fromEmail.value = fromOptions.length ? fromOptions[0].value : "";
}
},
{ immediate: true }
);
onMounted(() => {
// Published for the command palette, which cannot reach `editorRef` from
// module scope. See modalStates.ts.
replyComposer.value = applySavedReplies;
if (quotedContent.value) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = quotedContent.value;
}
});
}
});
onBeforeUnmount(() => {
replyComposer.value = null;
cleanup();
// the saved draft still references what it shows, so only what it dropped goes
dropUnused(`${newEmail.value ?? ""}${quotedContent.value ?? ""}`);
const editor = computed(() => {
return editorRef.value.editor;
});
defineExpose({
addToReply,
editor,
isUploading,
submitMail,
});
</script>

View File

@@ -1,397 +0,0 @@
<template>
<div>
<div ref="pills" class="flex flex-wrap gap-1">
<Button
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:tooltip="copyOnClick ? __('Click to copy') : undefined"
:class="[
{
'rounded-4 !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
},
copyOnClick
? 'cursor-pointer transition-transform active:scale-[0.98]'
: '',
]"
@click="copyOnClick ? copy(value) : null"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<LucideX class="size-3.5" @click.stop="removeValue(value)" />
</template>
</Button>
<div class="flex-1 min-w-32">
<ComboboxRoot
:model-value="tempSelection"
:open="showOptions"
@update:open="(o) => (showOptions = o)"
@update:modelValue="onSelect"
:ignore-filter="true"
highlight-on-hover
>
<ComboboxAnchor
class="flex h-7 max-w-full w-auto items-center gap-2 rounded-4 px-2 py-1 border border-transparent"
:class="[
variant == 'ghost'
? 'bg-surface-base hover:bg-surface-base'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
>
<ComboboxInput
ref="search"
:value="query"
autocomplete="off"
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
:placeholder="placeholder"
@focus="showOptions = true"
@input="onInput"
@keydown.delete.capture.stop="removeLastValue"
@keydown="onKeydown"
@paste="onPaste"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
class="z-[100] mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-6 shadow-2xl ring-1 ring-black ring-opacity-5"
position="popper"
:align="'start'"
@openAutoFocus.prevent
@closeAutoFocus.prevent
>
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
<ComboboxEmpty
class="flex gap-2 rounded-4 px-2 py-1 text-base text-ink-gray-5"
>
<LucideSearch class="size-4" />
{{ __(emptyPlaceholder) }}
</ComboboxEmpty>
<ComboboxItem
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-base leading-none text-ink-gray-7 rounded-4 flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
@mousedown.prevent="onSelect(option.value)"
>
<UserAvatar
class="mr-0.5 shrink-0"
:name="getUsernameLabel(option.label)"
size="lg"
/>
<div class="flex min-w-0 flex-col gap-1 p-1 text-ink-gray-8">
<div class="truncate text-base-medium">
{{ getUsernameLabel(option.label) }}
</div>
<div class="truncate text-sm text-ink-gray-5">
{{ option.isCustom ? customEmailLabel : option.value }}
</div>
</div>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-6 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
import LucideSearch from "~icons/lucide/search";
import LucideX from "~icons/lucide/x";
import UserAvatar from "@/components/UserAvatar.vue";
import { useUserStore } from "@/stores/user";
import { copy } from "@/utils";
import { watchDebounced } from "@vueuse/core";
import { createResource } from "frappe-ui";
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxViewport,
} from "reka-ui";
import { computed, nextTick, onMounted, ref, watch } from "vue";
const props = defineProps({
// "contact" searches HD contacts; "agents" picks from the user store.
scope: { type: String, default: "contact" },
validate: { type: Function, default: null },
emptyPlaceholder: { type: String, default: __("No results found") },
variant: { type: String, default: "subtle" },
placeholder: { type: String, default: "" },
inputClass: { type: String, default: "" },
existingUsers: { type: Array, default: () => [] },
allowCustomEmail: { type: Boolean, default: false },
additionalFilters: { type: Array, default: () => [] },
copyOnClick: { type: Boolean, default: false },
customEmailLabel: { type: String, default: __("Invite via email") },
});
const errorMessage = (value) => __("{0} is an Invalid Email Address", [value]);
// v-model values
const values = defineModel();
// Common state
const pills = ref(null);
const search = ref(null);
const error = ref(null);
const info = ref(null);
const query = ref("");
const showOptions = ref(false);
const tempSelection = ref(null);
const { users } = useUserStore();
// --- Contacts resource (used when scope = "contact") ---
const filterContacts = createResource({
url: "helpdesk.api.contact.search_contacts",
method: "GET",
params: contactSearchParams(""),
transform: (data) =>
data.map(({ full_name, email_id, name }) => ({
label: full_name || email_id || name,
value: email_id || name,
email: email_id,
name,
})),
});
watchDebounced(
query,
(newVal) => {
if (props.scope === "agents") return;
filterContacts.update({ params: contactSearchParams(newVal) });
filterContacts.reload();
},
{ debounce: 250, immediate: true }
);
function contactSearchParams(txt) {
const params = { txt: txt || "" };
if (props.additionalFilters?.length) {
params.additional_filters = JSON.stringify(props.additionalFilters);
}
return params;
}
// --- Options ---
const options = computed(() => {
if (props.scope === "agents") {
let list = (users?.data || []).map((u) => ({
label: u.full_name || u.name,
value: u.name,
}));
if (values.value?.length) {
list = list.filter((o) => !values.value.includes(o.value));
}
if (props.existingUsers?.length) {
list = list.filter((o) => !props.existingUsers.includes(o.value));
}
return list.filter(matchesQuery);
}
// contacts mode; the search is debounced, so narrow the last results to the
// typed text, or a quick Enter picks a contact that no longer matches
let list = (filterContacts.data || []).filter(matchesQuery);
if (values.value?.length) {
list = list.filter((o) => !values.value.includes(o.value));
}
if (props.existingUsers?.length) {
list = list.filter((o) => !isExistingUser(o.email));
}
// offer the typed text itself when nothing matches, like the email To field
if (
props.allowCustomEmail &&
query.value &&
!list.length &&
!isExistingUser(query.value)
) {
list = [{ label: query.value, value: query.value, isCustom: true }];
}
return list;
});
function matchesQuery(option) {
const q = query.value.toLowerCase();
return [option.label, option.value, option.name].some((field) =>
field?.toLowerCase().includes(q)
);
}
function isExistingUser(email) {
if (!email) return false;
return props.existingUsers?.some(
(user) => user?.toLowerCase?.() === email.toLowerCase()
);
}
function addValue(input) {
if (!input) return;
error.value = null;
info.value = null;
const parts = splitRecipients(input);
for (const email of parts) {
if (values.value?.includes(email)) continue;
const inList = options.value.some((o) => o.value === email && !o.isCustom);
if (!inList && !canAddCustomValue(email)) break;
if (!values.value) values.value = [email];
else values.value.push(email);
}
}
function canAddCustomValue(email) {
if (!props.allowCustomEmail) {
error.value =
props.scope === "agents"
? __("{0} is not an existing user", [email])
: __("{0} is not an existing contact", [email]);
query.value = email;
return false;
}
if (isExistingUser(email)) {
error.value = __("{0} is already added", [email]);
query.value = email;
return false;
}
if (props.validate && !props.validate(email)) {
error.value = errorMessage(email);
query.value = email;
return false;
}
return true;
}
// Split on commas/semicolons/newlines while keeping a quoted display name
// intact, so '"Doe, John" <a@b.com>' stays a single token.
function splitRecipients(input) {
const parts = [];
let current = "";
let inQuotes = false;
for (const char of input) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if ((char === "," || char === ";" || char === "\n") && !inQuotes) {
if (current.trim()) parts.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
}
// Reduce a "Name <email>" value to just the display name for rendering.
function getUsernameLabel(value) {
if (!value) return "";
return value.split("<")[0].replace(/"/g, "").trim();
}
function removeValue(value) {
values.value = values.value.filter((v) => v !== value);
}
// Read the pills from the DOM: a Button with a tooltip renders extra root
// nodes, so its `$el` is not the <button>.
function lastPill() {
const buttons = pills.value?.querySelectorAll(":scope > button");
return buttons?.[buttons.length - 1] ?? null;
}
function removeLastValue() {
if (query.value) return;
const pill = lastPill();
if (document.activeElement === pill) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
lastPill()?.focus();
} else {
setFocus();
}
});
} else {
pill?.focus();
}
}
// TODO: use single contact component. This refocus also fires when focus has
// already moved on, so a click elsewhere within 200ms is pulled back here.
function setFocus() {
setTimeout(() => {
search.value?.$el?.focus();
}, 200);
}
function onInput(e) {
query.value = e.target.value;
error.value = null;
info.value = null;
showOptions.value = true;
}
function onSelect(val) {
if (!val) return;
addValue(val);
if (error.value) {
showOptions.value = false;
return;
}
query.value = "";
tempSelection.value = null;
showOptions.value = true;
nextTick(() => setFocus());
}
// Commit the current query as a pill on Enter, comma or space.
function onKeydown(e) {
const isCommitKey = e.key === "Enter" || e.key === "," || e.key === " ";
if (!isCommitKey || !query.value.trim()) return;
e.preventDefault();
onSelect(query.value);
}
// Split a pasted, comma/space/newline-separated list into multiple pills.
function onPaste(e) {
const text = e.clipboardData?.getData("text") ?? "";
if (!/[\s,;]/.test(text)) return;
e.preventDefault();
addValue(text);
if (!error.value) {
query.value = "";
tempSelection.value = null;
nextTick(() => setFocus());
}
}
watch(options, (list) => {
tempSelection.value = list[0]?.value ?? null;
});
onMounted(() => {
// reka-ui hardcodes autocomplete="off", which Chrome ignores for fields it
// reads as email/address. "new-password" is the value Chrome won't autofill,
// so its native address dropdown stops covering our options. (No password UI:
// the input is type=text and not inside a form.)
search.value?.$el?.setAttribute("autocomplete", "new-password");
});
defineExpose({ setFocus });
</script>

View File

@@ -1,94 +1,33 @@
<template>
<div
v-if="variant === 'badge'"
class="flex flex-col items-center justify-center gap-4 absolute inset-x-0 top-5.5 bottom-0 pointer-events-none"
>
<div class="flex h-full items-center justify-center">
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
class="flex flex-col items-center gap-3 text-xl font-medium text-ink-gray-4"
>
<component v-if="icon" :is="icon" class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base-medium text-ink-gray-6">
{{ __(title) }}
</div>
<div
v-if="descriptionText"
class="text-p-sm text-ink-gray-5 max-w-60 text-center"
>
{{ __(descriptionText) }}
</div>
</div>
</div>
<div
v-else
class="flex h-full items-center justify-center absolute inset-x-0 top-0 pointer-events-none"
>
<div
class="flex flex-col items-center gap-2 text-xl-medium text-ink-gray-4 w-9/12 md:w-4/12"
>
<!-- overlay variant (for charts) -->
<div
v-if="variant === 'overlay'"
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -z-10"
:style="{
backgroundImage:
'radial-gradient(ellipse at center, var(--surface-base) 10%, color-mix(in srgb, var(--surface-base) 90%, transparent) 25%, transparent 70%)',
}"
/>
<!-- Icon -->
<component v-if="icon" :is="icon" class="h-10 w-10" />
<!-- title -->
<div class="flex flex-col items-center justify-center gap-0.5">
<span
:class="{
'text-sm font-medium text-ink-gray-8': text === 'sm',
'text-base font-medium text-ink-gray-8': text === 'md' || !text,
'text-md font-medium text-ink-gray-8': text === 'lg',
}"
>
{{ __(title) }}
</span>
<span
v-if="descriptionText"
:class="{
'text-center text-p-xs text-ink-gray-6 mt-1': text === 'sm',
'text-center text-p-sm text-ink-gray-6 mt-1':
text === 'md' || !text,
'text-center text-p-base text-ink-gray-6 mt-1': text === 'lg',
}"
>
{{ __(descriptionText) }}
</span>
</div>
<span>{{ title }}</span>
<!-- Button which emits Empty State Action -->
<Button label="Create" @click="emit('emptyStateAction')" variant="subtle">
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { VNode, computed } from "vue";
import { VNode } from "vue";
interface Props {
title: string;
icon?: VNode | string;
description?: string;
variant?: "default" | "overlay" | "badge";
text?: "sm" | "md" | "lg";
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
title: "No Data Found",
icon: "",
variant: "default",
text: "lg",
});
const descriptionText = computed(() =>
props.description !== undefined && props.description !== ""
? props.description
: `Create new ${props.title
.split(" ")[1]
.toLocaleLowerCase()} using the Create button.`
);
const emit = defineEmits(["emptyStateAction"]);
</script>
<style lang="scss" scoped></style>

View File

@@ -1,18 +0,0 @@
<template>
<div class="w-[106px] shrink-0 truncate text-base text-ink-gray-5">
<Tooltip :text="__(label)">
<span>{{ __(label) }}</span>
</Tooltip>
<span v-if="required" class="text-ink-red-6"> * </span>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from "frappe-ui";
defineProps<{
label: string;
// Frappe fields carry 1 / 0, not true / false
required?: boolean | number;
}>();
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="flex-1">
<div
class="mt-1.5 flex justify-between text-base items-start md:items-center"
>
<div
v-if="relatedActivities.length > 1"
class="inline-flex flex-wrap gap-1.5 text-ink-gray-8 font-medium w-4/5"
>
<span>{{ `${show_others ? "Hide " : "Show "}` }}</span>
<span>+{{ relatedActivities.length }} </span>
<span>changes from </span>
<span>{{ user }}</span>
<Button
class="!size-4"
variant="ghost"
@click="show_others = !show_others"
>
<template #icon>
<SelectIcon />
</template>
</Button>
</div>
<div class="text-gray-600 w-4/6 text-p-base" v-else>
<span class="font-medium text-gray-800">
{{ user }}
</span>
<span> {{ content }}</span>
</div>
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<div class="text-gray-600 text-sm w-2/6 flex justify-end">
{{ timeAgo(creation) }}
</div>
</Tooltip>
</div>
<div v-if="show_others">
<div
v-for="relatedActivity in relatedActivities"
:key="relatedActivity.creation"
class="mt-2 flex justify-between text-base"
>
<div class="text-gray-600 w-4/6">
<span class="font-medium text-gray-800">
{{ relatedActivity.user }}
</span>
<span> {{ relatedActivity.content }}</span>
</div>
<Tooltip
:text="dateFormat(relatedActivity.creation, dateTooltipFormat)"
>
<div class="text-gray-600 text-sm w-2/6 flex justify-end">
{{ timeAgo(relatedActivity.creation) }}
</div>
</Tooltip>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SelectIcon } from "@/components/icons";
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
import { ref } from "vue";
const props = defineProps({
activity: {
type: Object,
required: true,
},
});
const { user, content, creation, relatedActivities } = props.activity;
let show_others = ref(false);
</script>

View File

@@ -2,18 +2,15 @@
<div v-if="isEmoji(icon)" v-bind="$attrs">
{{ icon }}
</div>
<SpriteIcon
<FeatherIcon
v-else-if="typeof icon == 'string'"
:name="icon.replace('lucide-', '')"
:name="icon"
v-bind="$attrs"
/>
<component v-else :is="icon" v-bind="$attrs" />
</template>
<script setup>
import { isEmoji } from "@/utils";
// Sprite, not a `lucide-*` class: these names come from stored view icons, and
// Tailwind only emits CSS for class names it can see in source.
import { Icon as SpriteIcon } from "frappe-ui/experimental";
const props = defineProps({
icon: {

View File

@@ -0,0 +1,118 @@
<template>
<Popover transition="default">
<template #target="{ togglePopover, isOpen }">
<slot v-bind="{ isOpen, togglePopover }">
<span class="text-base"> {{ modelValue || "" }} </span>
</slot>
</template>
<template #body="{ togglePopover }">
<div
v-if="reaction"
class="px-2 py-1 flex items-center justify-center gap-2 rounded-full bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div
class="size-5 cursor-pointer rounded-full bg-surface-transparent text-xl"
v-for="r in reactionEmojis"
:key="r"
@click="() => (emoji = r) && togglePopover()"
>
<button>
{{ r }}
</button>
</div>
<Button
class="rounded-full"
icon="plus"
@click.stop="() => (reaction = false)"
/>
</div>
<div
v-else
class="my-3 max-w-max transform bg-surface-white px-4 sm:px-0"
>
<div
class="relative max-h-96 pb-3 overflow-y-auto min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div class="flex gap-2 px-3 pb-1 pt-3">
<div class="flex-1">
<FormControl
type="text"
placeholder="Search by keyword"
v-model="search"
:debounce="300"
/>
</div>
<Button @click="setRandom">Random</Button>
</div>
<div class="w-96"></div>
<div class="px-3" v-for="(emojis, group) in emojiGroups" :key="group">
<div
class="sticky top-0 bg-surface-modal pb-2 pt-3 text-sm text-ink-gray-7"
>
{{ group }}
</div>
<div class="grid w-96 grid-cols-12 place-items-center">
<button
class="h-8 w-8 rounded-md p-1 text-2xl hover:bg-surface-gray-2 focus:outline-none focus:ring focus:ring-blue-200"
v-for="_emoji in emojis"
:key="_emoji.description"
@click="() => (emoji = _emoji.emoji) && togglePopover()"
:title="_emoji.description"
>
{{ _emoji.emoji }}
</button>
</div>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { ref, computed } from "vue";
import { Popover } from "frappe-ui";
import { gemoji } from "gemoji";
const search = ref("");
const emoji = defineModel();
const reaction = defineModel("reaction");
const reactionEmojis = ref(["👍", "❤️", "😂", "😮", "😢", "🙏"]);
const emojiGroups = computed(() => {
let groups = {};
for (let _emoji of gemoji) {
if (search.value) {
let keywords = [_emoji.description, ..._emoji.names, ..._emoji.tags]
.join(" ")
.toLowerCase();
if (!keywords.includes(search.value.toLowerCase())) {
continue;
}
}
let group = groups[_emoji.category];
if (!group) {
groups[_emoji.category] = [];
group = groups[_emoji.category];
}
group.push(_emoji);
}
if (!Object.keys(groups).length) {
groups["No results"] = [];
}
return groups;
});
function setRandom() {
let total = gemoji.length;
let index = randomInt(0, total - 1);
emoji.value = gemoji[index].emoji;
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
defineExpose({ setRandom });
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div class="flex items-center gap-3 mb-2">
<FileUploader :fileTypes="['image/*']" @success="onSuccess">
<template #default="{ openFileSelector, uploading }">
<!-- Filled: image with hover-to-replace + remove badge -->
<div v-if="image" class="group relative size-11.5 shrink-0">
<Avatar
:image="image"
:label="fallbackLabel"
:shape="shape"
size="3xl"
/>
<button
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/40 text-white opacity-0 outline-none transition focus:outline-none focus-visible:outline-none group-hover:opacity-100"
:class="roundedClass"
:aria-label="__('Replace image')"
@click.prevent="openFileSelector()"
>
<LucidePencil class="size-4" />
</button>
<button
type="button"
class="absolute -right-2.5 -top-2.5 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-surface-base text-ink-gray-4 opacity-0 outline outline-black-overlay-50 duration-300 ease-in-out focus:outline-none focus-visible:outline-none hover:bg-surface-gray-2 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
:aria-label="__('Remove image')"
@click.prevent="image = ''"
>
<LucideX class="size-2.5" />
</button>
</div>
<!-- Empty: upload box -->
<button
v-else
type="button"
class="flex size-11.5 shrink-0 cursor-pointer items-center justify-center border border-outline-gray-2 text-ink-gray-4 outline-none transition focus:outline-none focus-visible:outline-none hover:border-outline-gray-3"
:class="roundedClass"
:disabled="uploading"
:aria-label="label || __('Upload image')"
@click.prevent="openFileSelector()"
>
<LucideUpload class="size-4.5" />
</button>
</template>
</FileUploader>
<div v-if="label || description" class="flex flex-col gap-0.5">
<span v-if="label" class="text-p-sm-medium text-ink-gray-8">
{{ __(label) }}
</span>
<span v-if="description" class="text-p-sm text-ink-gray-5">
{{ __(description) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import type { File as FileType } from "@/types";
import { Avatar, FileUploader } from "frappe-ui";
import { computed } from "vue";
import LucidePencil from "~icons/lucide/pencil";
import LucideUpload from "~icons/lucide/upload";
import LucideX from "~icons/lucide/x";
const image = defineModel<string>({ default: "" });
const props = withDefaults(
defineProps<{
label?: string;
description?: string;
fallbackLabel?: string;
shape?: "circle" | "square";
}>(),
{
label: "",
description: __("Upload an image in PNG or JPG format"),
fallbackLabel: "",
shape: "square",
}
);
const roundedClass = computed(() =>
props.shape === "circle" ? "rounded-full" : "rounded-[10px]"
);
function onSuccess(file: FileType) {
image.value = file.file_url;
}
</script>

View File

@@ -1,13 +1,11 @@
<template>
<Teleport to="#app-header" v-if="showHeader">
<slot>
<header
class="flex h-10.5 items-center justify-between mx-4 md:ms-5 md:me-0"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<header class="flex h-10.5 items-center justify-between mx-4 md:mr-0">
<div class="flex items-center gap-2 max-w-[50%]">
<slot name="left-header" />
</div>
<div class="flex items-center gap-2 shrink-0">
<div class="flex items-center gap-2">
<slot name="right-header" class="flex items-center gap-2" />
</div>
</header>
@@ -15,7 +13,7 @@
</Teleport>
</template>
<script setup>
import { nextTick, ref } from "vue";
import { ref, nextTick } from "vue";
const showHeader = ref(false);
nextTick(() => {

View File

@@ -1,210 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden">
<CardBase
:title="title"
:text="chartData.text"
:percentageChange="chartData.percentageChange"
:chartConfig="chartConfig"
:currentDuration="currentDuration"
:orientation="orientation"
:timelineFilter="timelineFilter"
@changeDuration="changeDuration"
/>
</div>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { buildPercentageChange, formatTime } from "@/utils";
import { EChartsOption } from "echarts";
import { createResource } from "frappe-ui";
import { computed, onMounted, ref, type PropType } from "vue";
import CardBase from "./ChartCardBase.vue";
interface AverageResponseData {
percentage_change: number;
average?: number;
total?: number;
data: { date: string; avg_time?: number; count?: number }[];
}
const props = defineProps({
title: {
type: String,
required: true,
},
apiUrl: {
type: String,
required: false,
},
timelineFilter: {
type: Boolean,
default: true,
},
data: {
type: Object as PropType<AverageResponseData>,
required: true,
},
chartColor: {
type: Object as PropType<{
lineColor: string;
gradientColor: { start: string; end: string };
}>,
required: true,
},
dt: {
type: String,
required: false,
},
dn: {
type: String,
required: false,
},
orientation: {
type: String as PropType<"vertical" | "horizontal">,
default: "vertical",
},
type: {
type: String as PropType<"Time" | "Count">,
default: "Time",
},
});
// the untranslated key: it is lowercased straight into the API period
const currentDuration = ref("Last month");
const chartData = computed(() => {
const isDataFetched = resource.fetched;
const _data: AverageResponseData = isDataFetched ? resource.data : props.data;
const dates = _data?.data?.map((item) => item.date) || [];
const seriesData =
_data?.data?.map((item) =>
props.type === "Time" ? item.avg_time : item.count
) || [];
const _percentageChange = _data?.percentage_change || 0;
const percentageChange = buildPercentageChange(_percentageChange);
// for time take average and for count take total
const text =
props.type === "Time"
? formatTime(_data?.average || 0, {
day: true,
hour: true,
minute: true,
maxUnits: 2,
}) || "0m"
: _data?.total || 0;
return {
data: seriesData,
dates,
percentageChange,
text,
};
});
const chartConfig = computed<EChartsOption>(() => {
const color = props.chartColor.lineColor;
const gradientColor = props.chartColor.gradientColor;
return {
xAxis: {
type: "category",
data: chartData.value.dates,
show: false,
boundaryGap: false,
},
yAxis: {
type: "value",
show: false,
},
series: [
{
data: chartData.value.data,
type: "line",
symbol: "none",
lineStyle: {
width: 1.25,
},
areaStyle: {
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: gradientColor.start,
},
{
offset: 1,
color: gradientColor.end,
},
],
global: false,
},
},
},
],
color: color,
grid: {
left: 2,
right: 2,
top: 2,
bottom: 2,
},
tooltip: {
show: true,
trigger: "axis",
appendToBody: true,
// position: (point: number[]) => [point[0] + 12, point[1] - 50],
axisPointer: { type: "none" },
formatter: (params: any) => {
const p = Array.isArray(params) ? params[0] : params;
if (p.value === undefined || p.value === null) return "";
const value =
props.type === "Time"
? formatTime(Number(p.value) || 0, {
day: true,
hour: true,
minute: true,
maxUnits: 2,
}) || "0m"
: p.value;
return `<span style="font-size:12px;color:#6b7280">${p.name}: <b style="color:#374151">${value}</b></span>`;
},
},
};
});
const resource = createResource({
url: props.apiUrl || "",
type: "GET",
makeParams: () => {
const params: Record<string, any> = {
period: currentDuration.value.toLowerCase(),
};
if (props.dt && props.dn) {
params.dt = props.dt;
params.dn = props.dn;
}
return params;
},
});
const changeDuration = (period: string) => {
currentDuration.value = period;
if (props.apiUrl) resource.submit();
};
onMounted(() => {
if (props.timelineFilter && props.apiUrl && !props.data?.data) {
resource.submit();
}
});
</script>

View File

@@ -3,7 +3,7 @@
<div v-for="group in groupedRows" :key="group.group">
<ListGroupHeader :group="group">
<div
class="my-2 flex items-center gap-2 text-base-medium text-ink-gray-8 justify-between w-full me-1"
class="my-2 flex items-center gap-2 text-base font-medium text-ink-gray-8 justify-between w-full mr-1"
>
<div class="flex items-center gap-2 w-full">
<component v-if="group.icon" :is="group.icon" />
@@ -11,7 +11,7 @@
v-if="group.group.label != ''"
class="flex items-end gap-1 w-full"
>
<span>{{ __(group.group.label) }}</span>
<span>{{ group.group.label }}</span>
<span class="text-xs text-ink-gray-5"
>{{
group.rows.length +
@@ -57,13 +57,14 @@
</template>
<script setup>
import { Button, Dropdown } from "frappe-ui";
import {
Button,
Dropdown,
ListGroupHeader,
ListGroupRows,
ListRow,
ListRows,
} from "frappe-ui/experimental";
} from "frappe-ui";
import { computed, ref, watch } from "vue";
import IconMoreHorizontal from "~icons/lucide/more-horizontal";

View File

@@ -1,25 +1,18 @@
<template>
<!-- View Controls -->
<div
:class="[
'flex items-center justify-between gap-2 px-5 pb-4 pt-4',
list?.data?.data?.length > 0 ? 'relative' : 'absolute inset-x-0',
]"
class="flex items-center justify-between gap-2 px-5 pb-4 pt-3 pl-6"
v-if="showViewControls"
>
<QuickFilters v-if="!isMobileView" />
<div v-if="!isMobileView" class="-ms-2 h-5 border-s"></div>
<div
class="flex items-start gap-2 justify-end h-full py-1 ps-0.5"
v-if="!isMobileView"
>
<QuickFilters v-if="!isMobileView" class="flex-1" />
<div class="flex items-start gap-2 justify-end h-full" v-if="!isMobileView">
<Button
:label="__('Save Changes')"
v-if="isViewUpdated && canSaveView"
@click="handleViewUpdate"
/>
<Reload @click="handleReload" :loading="list.loading" />
<Filter />
<Filter :default_filters="defaultParams.filters" />
<SortBy :hide-label="isMobileView" />
<ColumnSettings
:hide-label="isMobileView"
@@ -27,7 +20,7 @@
/>
</div>
<div v-else class="flex justify-between items-center w-full">
<Filter />
<Filter :default_filters="defaultParams.filters" />
<div class="flex items-center gap-2">
<Reload @click="handleReload" :loading="list.loading" />
<SortBy :hide-label="isMobileView" />
@@ -35,16 +28,9 @@
</div>
</div>
<!-- Loading State -->
<div
v-if="list.loading && !list.data?.data?.length"
class="flex items-center justify-center h-full w-full absolute top-0 z-100"
>
<LoadingIndicator class="size-6 text-ink-gray-4" />
</div>
<!-- List View -->
<ListView
v-else-if="list.data?.data.length > 0"
v-if="list.data?.data.length > 0"
class="flex-1"
:columns="columns"
:rows="rows"
@@ -52,7 +38,7 @@
:options="{
selectable: options.selectable,
showTooltip: false,
resizeColumn: true,
resizeColumn: false,
getRowRoute: (row) => ({
name: options.rowRoute?.name,
params: { [options.rowRoute?.prop]: row.name },
@@ -66,7 +52,7 @@
v-for="column in columns"
:key="column.key"
:item="column"
@columnWidthUpdated="handleColumnResize"
@columnWidthUpdated="(width) => console.log(width)"
/>
</ListHeader>
<ListRows
@@ -86,19 +72,9 @@
</ListRows>
<ListSelectBanner v-if="options.showSelectBanner">
<template #actions="{ selections, unselectAll }">
<div class="flex items-center gap-1">
<Button
v-for="action in selectBannerOptions(selections, unselectAll, true)"
:key="action.label"
:label="action.label"
:icon-left="action.icon"
variant="ghost"
@click="action.onClick"
/>
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
</div>
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
<Button icon="more-horizontal" variant="ghost" />
</Dropdown>
</template>
</ListSelectBanner>
</ListView>
@@ -121,24 +97,21 @@
handlePageLength(count);
}
"
>
<!-- frappe-ui's ListFooter still feeds TabButtons the removed `buttons`
prop, so its own page-length picker renders nothing. -->
<template #left>
<TabButtons
:model-value="defaultParams.page_length_count"
:options="[20, 50, 100].map((o) => ({ label: String(o), value: o }))"
@update:model-value="(count) => handlePageLength(count)"
/>
</template>
</ListFooter>
/>
</div>
<!-- Loading State -->
<div
v-else-if="list.loading"
class="w-full h-full flex items-center justify-center -mt-48"
>
<LoadingIndicator :scale="10" />
</div>
<!-- Empty State -->
<EmptyState
v-else-if="!list.loading"
v-else
:title="emptyState.title"
:icon="emptyState.icon"
:description="emptyState.description"
@emptyStateAction="emit('emptyStateAction')"
/>
</template>
@@ -146,11 +119,11 @@
import { MultipleAvatar, StarRating } from "@/components";
import {
ColumnSettings,
Filter,
QuickFilters,
Reload,
SortBy,
} from "@/components/view-controls";
import { Filter, normalizeFilters } from "@/components/view-controls/filter";
import { useScreenSize } from "@/composables/screen";
import {
currentView as headerView,
@@ -159,35 +132,31 @@ import {
} from "@/composables/useView";
import { useAuthStore } from "@/stores/auth";
import { globalStore } from "@/stores/globalStore";
import { useTicketStatusStore } from "@/stores/ticketStatus";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { View, ViewType } from "@/types";
import { getIcon } from "@/utils";
import { formatTimeShort, getIcon } from "@/utils";
import { useStorage } from "@vueuse/core";
import { useTicketStatusStore } from "@/stores/ticketStatus";
import { __ } from "@/translation";
import {
call,
createResource,
dayjsLocal,
Dropdown,
frappeRequest,
LoadingIndicator,
TabButtons,
toast,
} from "frappe-ui";
import {
Icon as SpriteIcon,
FeatherIcon,
ListFooter,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
ListView,
} from "frappe-ui/experimental";
LoadingIndicator,
toast,
} from "frappe-ui";
import {
computed,
h,
onMounted,
onUnmounted,
provide,
reactive,
ref,
@@ -197,7 +166,6 @@ import {
import { useRoute, useRouter } from "vue-router";
import EmptyState from "./EmptyState.vue";
import { listFilters } from "./listViewFilters";
import ListRows from "./ListRows.vue";
interface P {
@@ -209,7 +177,6 @@ interface P {
// type of a h componnt
icon?: string | VNode;
title: string;
description?: string;
};
hideViewControls?: boolean;
hideColumnSetting?: boolean;
@@ -225,6 +192,7 @@ interface P {
}
interface E {
(event: "emptyStateAction"): void;
(event: "rowClick", row: any): void;
}
const props = defineProps<P>();
@@ -232,7 +200,7 @@ const emit = defineEmits<E>();
const route = useRoute();
const router = useRouter();
const { isManager } = useAuthStore();
const { $dialog, $socket } = globalStore();
const { $dialog } = globalStore();
const { getStatus } = useTicketStatusStore();
const listSelections = ref(new Set());
@@ -256,7 +224,7 @@ const defaultOptions = reactive({
selectBannerActions: [
{
label: __("Delete"),
icon: "lucide-trash-2",
icon: "trash-2",
onClick: (selections: Set<string>) => {
$dialog({
title: __("Delete"),
@@ -265,10 +233,8 @@ const defaultOptions = reactive({
]),
actions: [
{
label: __("Delete"),
label: __("Confirm"),
variant: "solid",
theme: "red",
iconLeft: "lucide-trash-2",
onClick({ close }) {
handleBulkDelete(close, selections);
},
@@ -283,69 +249,11 @@ const defaultOptions = reactive({
function handleBulkDelete(hide: Function, selections: Set<string>) {
capture("bulk_delete" + props.options.doctype);
const requested = Array.from(selections);
const requestedCount = requested.length;
const failureMessages: string[] = [];
let successMessage = "";
let failedCount = 0;
const onBulkResult = (data: { message: string; title: string }) => {
const isFailure =
data.title === __("Bulk Operation Failed") ||
data.title === "Bulk Operation Failed";
const isSuccess =
data.title === __("Bulk Operation Successful") ||
data.title === "Bulk Operation Successful";
if (!isFailure && !isSuccess) return;
if (isFailure) {
// Parse how many items failed from the message (Frappe includes the count)
const match = data.message.match(/Failed to delete (\d+) documents?/);
if (match) {
failedCount = parseInt(match[1], 10);
}
failureMessages.push(data.message);
} else {
successMessage = data.message;
}
};
$socket.on("msgprint", onBulkResult);
// Use frappeRequest (not `call`) so per-item delete errors surfaced in
// `_server_messages` get routed through the app's serverMessagesHandler.
// `call` silently drops them on 200 responses.
frappeRequest({
url: "frappe.desk.reportview.delete_items",
params: {
items: JSON.stringify(requested),
doctype: props.options.doctype,
},
}).finally(() => {
$socket.off("msgprint", onBulkResult);
const deletedCount = requestedCount - failedCount;
if (failureMessages.length > 0 && deletedCount > 0) {
// Partial success: some deleted, some failed — show both toasts
toast.success(__("{0} item(s) deleted successfully", [deletedCount]));
for (const msg of failureMessages) {
toast.error(msg);
}
} else if (failureMessages.length > 0) {
// All failed
for (const msg of failureMessages) {
toast.error(msg);
}
} else if (successMessage) {
// All succeeded
toast.success(successMessage);
} else {
// Fallback: no socket messages received (e.g. enqueued for >10 items)
toast.success(__("{0} item(s) queued for deletion", [requestedCount]));
}
call("frappe.desk.reportview.delete_items", {
items: JSON.stringify(Array.from(selections)),
doctype: props.options.doctype,
}).then(() => {
toast.success(__("Item(s) deleted successfully"));
hide();
reset();
});
@@ -417,12 +325,7 @@ const exposeFunctions = {
unselectAll: () => {},
};
/** Banner actions. `inline` picks the ones shown as buttons; the rest fill the "..." menu. */
function selectBannerOptions(
selections: Set<string>,
unselectAll = () => {},
inline = false
) {
function selectBannerOptions(selections: Set<string>, unselectAll = () => {}) {
exposeFunctions["unselectAll"] = unselectAll;
// Get the user-provided actions
@@ -445,10 +348,8 @@ function selectBannerOptions(
onClick: () => action.onClick?.(selections),
}));
return [...userActions, ...defaultActions].filter(
(action) =>
Boolean(action.inline) === inline && (action.condition?.() ?? true)
);
// Return combined actions
return [...userActions, ...defaultActions];
}
const rows = computed(() => {
@@ -478,7 +379,7 @@ function getGroupedByRows(listRows, groupByField) {
group: option || " ",
collapsed: false,
rows: filteredRows,
icon: h(SpriteIcon, {
icon: h(FeatherIcon, {
name: "folder",
class: "h-4 w-4 flex-shrink-0 text-ink-gray-6",
}),
@@ -561,15 +462,15 @@ function listCell(column: any, row: any, item: any, idx: number) {
}
if (column.type === "Datetime") {
return h("span", {
class: "text-base",
textContent: dayjsLocal(item).fromNow(),
class: "text-p-xs",
textContent: formatTimeShort(item),
});
}
if (column.type === "MultipleAvatar") {
return h(MultipleAvatar, {
avatars: item,
hideName: false,
class: "flex items-center flex-1 min-w-0",
hideName: true,
class: "flex items-center truncate flex-1 flex-row-reverse justify-end",
});
}
if (column.type === "Rating") {
@@ -601,22 +502,19 @@ function handleFieldClick(e: MouseEvent, column, row, item) {
}
if (column.type === "MultipleAvatar") {
const avatar = (e.target as HTMLElement).closest(".user-avatar");
// the gap between the faces, and the "+n" chip, name nobody to filter by
const name = avatar?.getAttribute("data-name");
if (!name) return;
applyColumnFilter(column.key, "LIKE", `%${name}%`);
if (item.length > 1) {
let target = e.target as HTMLElement;
target = target.closest(".user-avatar");
if (target) {
item = target.getAttribute("data-name");
}
} else {
item = item[0].name;
}
applyFilters({ ...defaultParams.filters, [column.key]: ["LIKE", item] });
return;
}
applyColumnFilter(column.key, "=", item);
}
function applyColumnFilter(key: string, operator: string, value: any) {
const conditions = normalizeFilters(defaultParams.filters).filter(
(condition) => condition[0] !== key
);
conditions.push([key, operator, value]);
applyFilters(conditions);
applyFilters({ ...defaultParams.filters, [column.key]: item });
}
const showViewControls = computed(() => {
@@ -644,17 +542,9 @@ provide("listViewActions", {
reload,
});
// Also published module-scope, for the command palette: it renders in the
// sidebar, outside this provide chain.
listFilters.value = {
current: () => normalizeFilters(list?.params?.filters),
apply: applyFilters,
};
onUnmounted(() => (listFilters.value = null));
function applyFilters(filters) {
isViewUpdated.value = true;
defaultParams.filters = normalizeFilters(filters);
defaultParams.filters = { ...filters };
list.submit({ ...defaultParams });
// automatically update filters for default view
@@ -667,9 +557,6 @@ function applySort(order_by: string) {
isViewUpdated.value = true;
defaultParams.order_by = order_by;
list.submit({ ...defaultParams, order_by });
if (!defaultParams.is_default) return;
handleViewUpdate();
isViewUpdated.value = false;
}
function updateColumns(obj) {
@@ -686,7 +573,7 @@ function updateColumns(obj) {
function reload(reset: boolean = false) {
if (reset) {
defaultParams.filters = normalizeFilters(options.value.defaultFilters);
defaultParams.filters = options.value.defaultFilters || {};
defaultParams.order_by = "modified desc";
defaultParams.page_length = options.value.default_page_length;
pageLengthCount.value = options.value.default_page_length;
@@ -727,45 +614,15 @@ function handleViewUpdate() {
route_name: route.name,
is_customer_portal: options.value.isCustomerPortal,
};
const currentView = findView(route.query.view as string).value;
if (currentView && currentView.public) {
$dialog({
title: __("Confirm Changes"),
message: __(
"This view is public. Changes made will be visible to everyone."
),
actions: [
{
label: __("Save"),
variant: "solid",
onClick({ close }) {
updateView(view, () => {
isViewUpdated.value = false;
});
close();
},
},
{
label: __("Cancel"),
variant: "outline",
onClick({ close }) {
close();
},
},
],
});
} else {
updateView(view, () => {
isViewUpdated.value = false;
});
}
updateView(view, () => {
isViewUpdated.value = false;
});
}
const { findView, updateView, defaultView } = useView(options.value.doctype);
const canSaveView = computed(() => {
let currentView: View = findView(route.query.view as string).value;
if (currentView?.is_standard) return false;
if (!currentView || !currentView.public) return true;
if (currentView.public && isManager) {
return true;
@@ -779,66 +636,18 @@ function handleReload() {
}
function handleViewChanges() {
if (!switchToView(route.query.view as string)) return;
applyUrlFilters();
list.submit({ ...defaultParams });
}
/** Base for URL filters, so repeated pushes layer on the view, not each other. */
let viewFilters = [];
/** Owns sort, columns and rows. False means a redirect is in flight. */
function switchToView(view: string): boolean {
defaultParams.view.name = view;
const currentView: View = findCurrentView();
if (currentView) {
// normalize so legacy dict-format saved views become list conditions
viewFilters = normalizeFilters(currentView.filters);
defaultParams.order_by = currentView.order_by || "modified desc";
defaultParams.columns = currentView.columns;
defaultParams.rows = currentView.rows;
return true;
}
if (view) {
// Stale ?view: drop it but keep the filters, unlike a push to the bare route.
router.replace({
name: route.name,
query: { ...route.query, view: undefined },
});
return false;
}
viewFilters = normalizeFilters(options.value.defaultFilters);
defaultParams.order_by = "modified desc";
defaultParams.columns = [];
defaultParams.rows = [];
defaultParams.is_default = true;
headerView.value.label = __("List");
headerView.value.icon = LucideAlignJustify;
return true;
}
/** Touches `filters` only, so filtering never resets the user's sort. */
function applyUrlFilters() {
const urlFilters = parseUrlFilters();
if (!urlFilters) {
defaultParams.filters = viewFilters;
let currentView: View = findCurrentView();
if (!currentView) {
router.push({ name: route.name });
reload(true);
return;
}
const overriddenFields = new Set(urlFilters.map((c) => c[0]));
defaultParams.filters = urlFilters.length
? [...viewFilters.filter((c) => !overriddenFields.has(c[0])), ...urlFilters]
: [];
}
defaultParams.filters = currentView.filters;
defaultParams.order_by = currentView.order_by || "modified desc";
defaultParams.columns = currentView.columns;
defaultParams.rows = currentView.rows;
/** null when the URL carries no filters; [] means "clear them". */
function parseUrlFilters() {
if (!route.query.filters) return null;
try {
return normalizeFilters(JSON.parse(route.query.filters as string));
} catch (e) {
console.error("Failed to parse filters from URL", e);
return null;
}
list.submit({ ...defaultParams });
}
function findCurrentView() {
@@ -853,14 +662,15 @@ function findCurrentView() {
return currentView;
}
// The view is re-read only when it changes; re-reading it on every filter
// change is what let a filter silently reset the sort.
watch(
[() => route.query.view as string, () => route.query.filters as string],
([view], [previousView]) => {
if (view !== previousView && !switchToView(view)) return;
applyUrlFilters();
list.submit({ ...defaultParams });
() => route.query.view,
(val: string) => {
defaultParams.view.name = val;
handleViewChanges();
if (!val) {
headerView.value.label = __("List");
headerView.value.icon = LucideAlignJustify;
}
}
);
@@ -879,17 +689,6 @@ function handleScrollPosition() {
}, 200);
}
function handleColumnResize({ key, width, save } = {}) {
const column = columns.value.find((c) => c.key === key);
if (column) column.width = width;
if (!save) return;
isViewUpdated.value = true;
defaultParams.columns = columns.value;
if (!defaultParams.is_default) return;
handleViewUpdate();
isViewUpdated.value = false;
}
onMounted(async () => {
handleScrollPosition();

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-wrap gap-2 rounded-6 bg-surface-gray-2 p-2">
<div class="flex flex-wrap gap-2 rounded-lg bg-gray-100 p-2">
<Pill
v-for="item in items"
:key="item.value"
@@ -7,7 +7,7 @@
@click="(i) => remove(i)"
/>
<TextInput
<Input
v-model="input"
class="w-full"
:placeholder="placeholder"
@@ -17,7 +17,7 @@
</template>
<script setup lang="ts">
import { TextInput, toast } from "frappe-ui";
import { Input, toast } from "frappe-ui";
import { ref, toRefs } from "vue";
import Pill from "./Pill.vue";

View File

@@ -0,0 +1,269 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
v-for="value in values"
ref="emails"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<Combobox v-model="selectedValue" nullable>
<Popover v-model:show="showOptions" class="w-full">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
type="text"
:value="query"
autocomplete="off"
@change="
(e) => {
query = e.target.value;
showOptions = true;
}
"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOption
v-for="option in options"
:key="option.value"
v-slot="{ active }"
:value="option"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-gray-100': active },
]"
>
<UserAvatar
class="mr-2"
:name="getUsernameLabel(option.value)"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-gray-800">
<div class="text-base font-medium">
{{ getUsernameLabel(option.label) }}
</div>
<div class="text-sm text-gray-600">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<ErrorMessage v-if="error" class="mt-2 pl-2" :message="error" />
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from "@headlessui/vue";
import { UserAvatar } from "@/components/";
import { Popover, createResource } from "frappe-ui";
import { ref, computed, nextTick } from "vue";
import { watchDebounced } from "@vueuse/core";
const props = defineProps({
validate: {
type: Function,
default: null,
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
});
const values = defineModel();
const emails = ref([]);
const search = ref(null);
const error = ref(null);
const query = ref("");
const text = ref("");
const showOptions = ref(false);
const selectedValue = computed({
get: () => query.value || "",
set: (val) => {
query.value = "";
if (val) {
showOptions.value = false;
}
val?.value && addValue(val.value);
},
});
watchDebounced(
query,
(val) => {
val = val || "";
if (text.value === val) return;
text.value = val;
reload(val);
},
{ debounce: 50, immediate: true }
);
const filterOptions = createResource({
url: "helpdesk.api.contact.search_contacts",
method: "GET",
cache: ["Contact", text.value],
params: {
txt: text.value,
},
transform: (data: Record<"full_name" | "name" | "email_id", string>[]) => {
return data.map(({ full_name, email_id, name }) => ({
label: full_name || name || email_id,
value: email_id,
}));
},
});
const options = computed(() => {
let searchedContacts = filterOptions.data || [];
if (!searchedContacts.length && query.value) {
searchedContacts.push({
label: query.value,
value: query.value,
});
}
return searchedContacts;
});
function reload(val) {
filterOptions.update({
params: {
txt: val,
},
});
filterOptions.reload();
}
const normalizeAndFilter = (field) => {
let arr = [];
let current = "";
let inQuotes = false;
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
if (current.trim()) {
arr.push(current.trim());
}
current = "";
} else {
current += char;
}
}
if (current.trim()) {
arr.push(current.trim());
}
return arr;
};
const addValue = (value) => {
error.value = null;
if (value) {
const splitValues = normalizeAndFilter(value);
splitValues.forEach((value) => {
value = value.trim();
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value);
return;
}
// add value to values array
if (!values.value) {
values.value = [value];
} else {
values.value.push(value);
}
value = value.replace(value, "");
}
}
});
!error.value && (value = "");
}
};
const getUsernameLabel = (value: string) => {
if (!value) return "";
// Take everything before '<'
const beforeAngle = value.split("<")[0];
// Remove quotes and trim
return beforeAngle.replace(/"/g, "").trim();
};
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value);
};
function removeLastValue() {
if (query.value) return;
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
if (document.activeElement === emailRef) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef;
emailRef?.focus();
} else {
setFocus();
}
});
} else {
emailRef?.focus();
}
}
function setFocus() {
search.value?.focus?.();
}
defineExpose({ setFocus });
</script>

View File

@@ -1,13 +1,6 @@
<template>
<div
v-if="_avatars?.length"
class="isolate me-1.5 flex min-w-0 cursor-pointer items-center"
>
<div
v-if="_avatars?.length == 1"
class="user-avatar flex min-w-0 items-center gap-2 text-base line-clamp-1"
:data-name="_avatars[0].name"
>
<div v-if="_avatars?.length" class="mr-1.5 flex cursor-pointer items-center">
<div v-if="_avatars?.length == 1" class="flex items-center gap-2 text-base">
<Tooltip :text="_avatars[0].name">
<Avatar
shape="circle"
@@ -15,34 +8,24 @@
:label="_avatars[0].label"
size="sm"
/>
<div class="min-w-0 truncate" v-if="!hideName">
{{ _avatars[0].label }}
</div>
<div class="truncate" v-if="!hideName">{{ _avatars[0].label }}</div>
</Tooltip>
</div>
<template v-else>
<Tooltip
v-for="avatar in visibleAvatars"
:key="avatar.name"
:text="avatar.name"
>
<Avatar
class="user-avatar -mr-1.5 ring-2 ring-[var(--surface-base)] transition hover:z-20 hover:scale-110"
shape="circle"
:image="avatar.image"
:label="avatar.label"
:size="size"
:data-name="avatar.name"
/>
</Tooltip>
<Tooltip v-if="overflowCount" :text="overflowNames">
<div
class="user-avatar relative z-10 -mr-1.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-surface-gray-3 text-xs text-ink-gray-7 ring-2 ring-[var(--surface-base)]"
>
+{{ overflowCount }}
</div>
</Tooltip>
</template>
<Tooltip
v-for="avatar in _avatars"
v-else
:key="avatar.name"
:text="avatar.name"
>
<Avatar
class="user-avatar -mr-1.5 ring-2 ring-white transition hover:z-10 hover:scale-110"
shape="circle"
:image="avatar.image"
:label="avatar.label"
:size="size"
:data-name="avatar.name"
/>
</Tooltip>
</div>
</template>
<script setup lang="ts">
@@ -51,25 +34,17 @@ import { Avatar, Tooltip } from "frappe-ui";
import { computed } from "vue";
const props = defineProps({
// Frappe hands `_assign` over as a JSON string; callers with the list
// already parsed pass the array itself.
avatars: {
type: [String, Array],
type: String,
},
size: {
type: String,
default: "sm",
default: "md",
},
hideName: {
type: Boolean,
default: false,
},
// Cap the number of avatars shown; the rest collapse into a "+n" chip.
// 0 (default) shows all, so existing callers are unaffected.
max: {
type: Number,
default: 0,
},
});
const { getUser } = useUserStore();
const _avatars = computed(() => {
@@ -93,25 +68,4 @@ const _avatars = computed(() => {
});
return result;
});
const capped = computed(
() => props.max > 0 && (_avatars.value?.length || 0) > props.max
);
const visibleAvatars = computed(() =>
capped.value ? _avatars.value.slice(0, props.max) : _avatars.value
);
const overflowCount = computed(() =>
capped.value ? _avatars.value.length - props.max : 0
);
const overflowNames = computed(() =>
capped.value
? _avatars.value
.slice(props.max)
.map((a: { name: string }) => a.name)
.join(", ")
: ""
);
</script>

View File

@@ -0,0 +1,60 @@
<template>
<Popover v-slot="{ open }">
<PopoverButton
ref="reference"
v-slot="{ open: o }"
as="div"
@click="updatePosition"
@focusin="updatePosition"
@keydown="updatePosition"
>
<slot name="target" :open="o" />
</PopoverButton>
<div v-show="open">
<PopoverPanel
v-slot="{ open: o, close }"
ref="popover"
static
class="z-[100]"
>
<slot name="body" :open="o" :close="close" />
</PopoverPanel>
</div>
</Popover>
</template>
<script setup>
import { nextTick, ref, onBeforeUnmount } from "vue";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
import { createPopper } from "@popperjs/core";
const props = defineProps({
placement: {
type: String,
default: "bottom-start",
},
});
const reference = ref(null);
const popover = ref(null);
let popper = ref(null);
function setupPopper() {
if (!popper.value) {
popper.value = createPopper(reference.value.el, popover.value.el, {
placement: props.placement,
});
} else {
popper.value.update();
}
}
function updatePosition() {
nextTick(() => setupPopper());
}
onBeforeUnmount(() => {
popper.value?.destroy();
});
</script>

View File

@@ -1,93 +0,0 @@
<template>
<div class="flex items-center justify-between p-5 pb-0">
<div class="gap-x-3 flex h-full">
<Avatar
:size="avatar.size ?? '3xl'"
:shape="avatar.shape ?? 'square'"
:label="avatar.label"
:image="avatar.image"
class="h-[52px] w-[52px]"
/>
<div
class="flex flex-col h-full gap-1.5"
:class="[
(isMobileView || !hasDocInfo) && 'flex-1 items-center justify-center',
]"
>
<div class="flex gap-2 items-center">
<p class="text-ink-gray-8 text-xl-medium">
{{ avatar.label }}
</p>
<Tooltip v-if="badge" :text="badge.tooltip ?? ''">
<Badge :label="badge.label" :theme="badge.theme ?? 'amber'" />
</Tooltip>
</div>
<div v-if="!isMobileView" class="flex items-center gap-x-1.5">
<template v-for="(item, index) in docInfo" :key="index">
<template v-if="item.condition !== false">
<span
v-if="
docInfo.slice(0, index).some((i) => i.condition !== false)
"
class="text-ink-gray-4"
>
•
</span>
<div class="flex items-center gap-x-1">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 text-ink-gray-6"
/>
<span v-if="item.value" class="text-sm text-ink-gray-8">{{
item.value
}}</span>
<template v-else-if="item.component">
<component :is="item.component" />
</template>
</div>
</template>
</template>
</div>
</div>
</div>
<slot name="actions" />
</div>
</template>
<script setup lang="ts">
import { useScreenSize } from "@/composables/screen";
import { Avatar, Badge, Tooltip, type AvatarProps } from "frappe-ui";
import { computed } from "vue";
interface DocInfoItem {
icon?: any;
value?: string;
component?: any;
condition?: boolean;
}
interface BadgeInfo {
label: string;
theme?: "gray" | "blue" | "green" | "amber" | "red";
tooltip?: string;
}
const props = withDefaults(
defineProps<{
avatar: AvatarProps;
docInfo?: DocInfoItem[];
badge?: BadgeInfo | null;
}>(),
{
docInfo: () => [],
badge: null,
}
);
const { isMobileView } = useScreenSize();
const hasDocInfo = computed(() => {
return props.docInfo?.some((item) => item.value) ?? false;
});
</script>

View File

@@ -2,7 +2,7 @@
<header class="border-b px-5 py-2.5">
<div class="flex items-center justify-between">
<slot name="title">
<div v-if="title" class="text-md-medium text-ink-gray-9">
<div v-if="title" class="text-lg font-medium text-gray-900">
{{ title }}
</div>
</slot>

View File

@@ -10,10 +10,10 @@
<slot name="prefix" />
</template>
<template #suffix>
<Tooltip bare>
<template #content>
<Tooltip>
<template #body>
<div
class="rounded-4 bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
>
<span class="flex items-center gap-1">
{{ show ? "Hide Password" : "Show Password" }}
@@ -24,7 +24,7 @@
<component
v-show="showEye"
:is="show ? LucideEyeOff : LucideEye"
class="h-3 cursor-pointer me-1"
class="h-3 cursor-pointer mr-1"
@click="show = !show"
/>
</div>

View File

@@ -1,6 +1,5 @@
<template>
<Button
ref="buttonRef"
:key="label"
theme="gray"
variant="outline"
@@ -14,9 +13,7 @@
</template>
<script setup>
import { Button } from "frappe-ui";
import { computed, ref } from "vue";
import IconX from "~icons/lucide/x";
defineProps({
label: {
type: String,
@@ -31,13 +28,7 @@ defineProps({
const emit = defineEmits(["click"]);
const buttonRef = ref(null);
const handleClick = (label) => {
emit("click", label);
};
defineExpose({
rootRef: computed(() => buttonRef.value?.$el),
});
</script>

View File

@@ -1,35 +0,0 @@
export interface Option {
label: string;
value: string;
}
// A dropdown rendered beneath a text question (e.g. org name + company size).
export interface DropdownConfig {
key: string;
label: string;
placeholder: string;
options: Option[];
}
export interface TextQuestion {
key: string;
title: string;
type: "text";
label?: string;
placeholder?: string;
multiline?: boolean;
required?: boolean;
dropdown?: DropdownConfig;
}
export interface ChoiceQuestion {
key: string;
title: string;
type: "choice";
options: Option[];
multiple?: boolean;
// Placeholder for the free-text box the "Other" option reveals.
otherPlaceholder?: string;
}
export type Question = TextQuestion | ChoiceQuestion;

View File

@@ -1,279 +0,0 @@
<template>
<div class="relative mx-auto w-full max-w-md flex flex-col gap-5">
<HDLogo class="size-8" />
<!-- Fixed height keeps the panel from resizing as questions vary in length. -->
<div class="relative">
<Button
v-if="current > 0"
variant="ghost"
icon="lucide-chevron-left"
class="!size-7 shrink-0 !rounded-5 absolute -left-11 mt-4"
@click="back"
/>
<div
:key="question.key"
class="mx-0 min-w-0 border-0 p-0 flex gap-5 flex-col"
>
<div>
<p class="p-0 text-p-xl font-bold text-ink-gray-9">
{{ question.title
}}<span v-if="titleRequired" class="ms-0.5 text-ink-red-6">*</span>
</p>
<p class="text-base pt-1.5">
{{ __("This helps us personalize your Helpdesk experience.") }}
</p>
</div>
<template v-if="question.type === 'text'">
<div class="flex flex-col gap-3">
<!-- Cap textarea resize: vertical only, between one row block and ~2x default. -->
<div
class="[&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
>
<span
v-if="question.label"
class="mb-1.5 block text-p-sm text-ink-gray-5"
>
{{ question.label
}}<span v-if="question.required" class="ms-0.5 text-ink-red-6"
>*</span
>
</span>
<Textarea
v-if="question.multiline"
v-model="answers[question.key]"
size="sm"
variant="outline"
:rows="3"
:placeholder="question.placeholder"
/>
<TextInput
v-else
v-model="answers[question.key]"
size="sm"
type="text"
variant="outline"
:placeholder="question.placeholder"
@keyup.enter="proceed"
/>
</div>
<div v-if="dropdown">
<span class="mb-1.5 block text-p-sm text-ink-gray-5">
{{ dropdown.label }}
</span>
<Select
v-model="answers[dropdown.key]"
class="w-full"
size="sm"
variant="outline"
:placeholder="dropdown.placeholder"
:options="dropdown.options"
/>
</div>
</div>
</template>
<template v-else>
<div class="flex flex-wrap gap-3 !text-ink-gray-7 text-p-base">
<Button
v-for="option in choiceOptions"
:key="option.value"
:label="option.label"
:variant="isSelected(option) ? 'subtle' : 'outline'"
size="sm"
class="!rounded-5 border border-solid"
:class="
isSelected(option)
? '!bg-surface-gray-3 hover:!bg-surface-gray-4 !border-outline-gray-3'
: 'hover:!bg-surface-alpha-gray-2'
"
@click="select(option)"
/>
</div>
<div
class="-mt-5 grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
:class="
isOtherSelected
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div
class="transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] [&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
:class="isOtherSelected ? 'translate-y-0' : '-translate-y-full'"
>
<Textarea
ref="otherInput"
v-model="answers[otherKey]"
class="mt-5"
variant="outline"
:rows="3"
:placeholder="otherPlaceholder"
:tabindex="isOtherSelected ? 0 : -1"
:error="otherError"
@update:model-value="otherError = ''"
/>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="flex flex-col items-center gap-2">
<Button
class="w-full"
variant="solid"
size="sm"
:label="isLast ? __('Finish') : __('Next')"
:disabled="!canProceed"
@click="proceed"
/>
</div>
</div>
<Button
class="mt-auto !bg-transparent !text-ink-gray-5 hover:!text-ink-gray-8"
variant="ghost"
:label="__('Skip for now')"
@click="finish"
/>
</template>
<script setup lang="ts">
import HDLogo from "@/assets/logos/HDLogo.vue";
import { __ } from "@/translation";
import { Button, Select, Textarea, TextInput } from "frappe-ui";
import { computed, nextTick, reactive, ref, watch } from "vue";
import type { Option, Question } from "./Questionnaire.types";
type Answers = Record<string, string | string[]>;
const props = defineProps<{ questions: Question[] }>();
const emit = defineEmits<{ submit: [answers: Answers] }>();
const current = ref(0);
const completed = ref(false);
const answers = reactive<Answers>({});
const otherInput = ref<InstanceType<typeof Textarea>>();
const total = computed(() => props.questions.length);
const question = computed(() => props.questions[current.value] as Question);
const isLast = computed(() => current.value === total.value - 1);
// Next stays disabled until the current question has an answer.
const canProceed = computed(() => {
const q = question.value;
const value = answers[q.key];
if (q.type === "text")
return !q.required || (typeof value === "string" && value.trim() !== "");
if (q.multiple) return Array.isArray(value) && value.length > 0;
return typeof value === "string" && value !== "";
});
// Narrow the discriminated union so the template stays declarative: choice
// questions render pills, text questions may carry a dropdown beneath them.
const choiceOptions = computed(() =>
question.value.type === "choice" ? question.value.options : []
);
const dropdown = computed(() =>
question.value.type === "text" ? question.value.dropdown : undefined
);
// "Other" free-text is stored under a `<key>_other` sibling answer key.
const OTHER_SUFFIX = "_other";
const otherKey = computed(() => `${question.value.key}${OTHER_SUFFIX}`);
const isOtherSelected = computed(() => {
const otherOption = choiceOptions.value.find((o) => o.value === "other");
return otherOption ? isSelected(otherOption) : false;
});
const otherPlaceholder = computed(
() =>
(question.value.type === "choice" && question.value.otherPlaceholder) ||
__("Tell us more")
);
// The asterisk normally rides on the field label; questions whose title is
// itself the prompt have no label to hang it on.
const titleRequired = computed(
() =>
question.value.type === "text" &&
!!question.value.required &&
!question.value.label
);
const isOtherFilled = computed(() => {
const other = answers[otherKey.value];
return typeof other === "string" && other.trim() !== "";
});
// Cleared on navigation and on toggling "Other" off, so re-opening the box
// never shows a stale error the user hasn't earned yet.
const otherError = ref("");
watch([current, isOtherSelected], () => (otherError.value = ""));
function isSelected(option: Option) {
const q = question.value;
if (q.type !== "choice") return false;
const value = answers[q.key];
if (q.multiple) {
return Array.isArray(value) && value.includes(option.value);
}
return value === option.value;
}
function select(option: Option) {
if (completed.value) return;
const q = question.value;
if (q.type !== "choice") return;
const key = q.key;
if (q.multiple) {
const value = Array.isArray(answers[key])
? [...(answers[key] as string[])]
: [];
const index = value.indexOf(option.value);
if (index === -1) {
value.push(option.value);
} else {
value.splice(index, 1);
}
answers[key] = value;
} else {
// A click only sets the selection; advancing is always via the Next button.
answers[key] = option.value;
}
if (!isOtherSelected.value) {
delete answers[otherKey.value];
} else if (option.value === "other") {
nextTick(() => otherInput.value?.focus({ preventScroll: true }));
}
}
function advance() {
if (current.value < total.value - 1) current.value += 1;
}
function back() {
if (current.value > 0) current.value -= 1;
}
function proceed() {
if (!canProceed.value) return;
// "Other" stays selectable with Next enabled; the ask happens on click.
if (isOtherSelected.value && !isOtherFilled.value) {
otherError.value = __("Add a few words so we can continue");
otherInput.value?.focus({ preventScroll: true });
return;
}
if (isLast.value) finish();
else advance();
}
function finish() {
if (completed.value) return;
completed.value = true;
setTimeout(() => emit("submit", { ...answers }), 700);
}
</script>

View File

@@ -2,7 +2,7 @@
<div class="relative" :style="{ width: `${sidebarWidth}px` }">
<slot v-bind="{ sidebarResizing, sidebarWidth }" />
<div
class="absolute z-10 h-full w-1 cursor-col-resize bg-surface-gray-4 opacity-0 transition-opacity hover:opacity-100"
class="absolute z-1 h-full w-1 cursor-col-resize bg-surface-gray-4 opacity-0 transition-opacity hover:opacity-100"
:class="{
'opacity-100': sidebarResizing,
'left-0': side == 'right',

View File

@@ -1,11 +1,18 @@
<template>
<Dialog v-model:open="show" size="4xl" bare @after-leave="onAfterLeave">
<template #default>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
@vue:unmounted="resetFilter"
>
<template #body>
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
<div class="flex items-center justify-between w-full p-4 pb-2">
<div class="text-2xl-semibold">{{ __("Saved Replies") }}</div>
<div class="text-2xl font-semibold">{{ __("Saved Replies") }}</div>
<Button
icon-left="lucide-plus"
variant="solid"
icon-left="plus"
:label="__('New')"
@click="onNewSavedReplyClick"
/>
@@ -13,31 +20,35 @@
<div class="p-4">
<div class="flex items-center gap-2">
<div class="relative w-full">
<TextInput
<Input
ref="searchInput"
:model-value="search"
@update:model-value="search = $event"
@input="search = $event"
:placeholder="__('Search')"
type="text"
class="focus:ring-0 border-outline-gray-2"
:debounce="300"
autofocus
>
<template #prefix>
<LucideSearch class="size-4" />
</template>
</TextInput>
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="search"
icon="lucide-x"
icon="x"
variant="ghost"
@click="search = ''"
class="absolute end-1 top-1/2 -translate-y-1/2"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="filters" align="end">
<Button :label="activeFilterLabel">
<template #prefix><FilterIcon class="h-4" /></template>
<Dropdown :options="filters" placement="right">
<Button :label="activeFilter" icon-left="filter" class="p-4">
<template #suffix>
<p
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-white pt-px text-xs font-medium text-ink-gray-8 shadow-sm"
v-if="savedReplyListResource?.data?.length"
>
{{ savedReplyListResource?.data?.length }}
</p>
</template>
</Button>
</Dropdown>
</div>
@@ -54,67 +65,34 @@
!savedReplyListResource?.list?.loading &&
savedReplyListResource?.data?.length
"
class="grid grid-cols-1 md:grid-cols-3 gap-3 pb-36"
class="grid grid-cols-1 md:grid-cols-3 gap-2 pb-36"
>
<div
v-for="template in savedReplyListResource?.data"
:key="template.name"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-6 border border-outline-gray-1 bg-surface-base p-3 hover:border-outline-gray-3 relative"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100 relative"
@click="onTemplateSelect(template)"
>
<div class="flex items-center gap-2 border-b pb-2">
<div class="text-base-semibold truncate max-w-[75%]">
{{ template.title }}
</div>
<Tooltip
v-if="actionCounts[template.name]"
:text="actionTooltip(actionCounts[template.name])"
>
<Badge
class="ms-auto shrink-0"
theme="gray"
variant="subtle"
:label="actionLabel(actionCounts[template.name])"
/>
</Tooltip>
<div class="text-base font-semibold truncate border-b pb-2">
{{ template.title }}
</div>
<div
<TextEditor
v-if="template.message"
class="flex-1 overflow-hidden pointer-events-none [mask-image:linear-gradient(to_bottom,black_80%,transparent)]"
>
<Editor
:model-value="template.message"
:extensions="extensions"
:editable="false"
>
<template #default>
<EditorContent
class="max-w-none text-p-sm text-ink-gray-5 focus:outline-none"
/>
</template>
</Editor>
</div>
:content="template.message"
:editable="false"
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
class="flex-1 overflow-hidden pointer-events-none"
/>
<div
v-if="
selectedTemplate.name === template.name &&
selectedTemplate.isLoading
"
class="flex items-center justify-center absolute top-0 start-0 w-full h-full rounded-6"
class="flex items-center justify-center absolute top-0 left-0 w-full h-full bg-black/20 rounded-lg"
>
<LoadingIndicator class="size-4" />
</div>
</div>
<div class="col-span-full flex justify-center">
<Button
v-if="
!savedReplyListResource.list.loading &&
savedReplyListResource.hasNextPage
"
:label="__('Load More')"
icon-left="lucide-refresh-cw"
@click="savedReplyListResource.next()"
/>
</div>
</div>
<div
v-if="
@@ -124,8 +102,8 @@
class="mt-2"
>
<div class="flex h-56 flex-col items-center justify-center">
<div class="text-p-sm text-ink-gray-4">
{{ __("No saved replies found") }}
<div class="text-p-sm text-gray-500">
{{ __("No Saved Replies found") }}
</div>
</div>
</div>
@@ -136,33 +114,27 @@
</template>
<script setup lang="ts">
import { recordSavedReplyUse } from "@/components/command-palette/savedReplyCommands";
import { buildEditorExtensions } from "@/components/editor/config";
import FilterIcon from "@/components/icons/FilterIcon.vue";
import { useConfigStore } from "@/stores/config";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { RenderedSavedReply, SavedReply } from "@/types";
import { SavedReply } from "@/types";
import { useStorage } from "@vueuse/core";
import {
Badge,
Button,
createListResource,
createResource,
Dialog,
Dropdown,
Input,
LoadingIndicator,
TextInput,
Tooltip,
TextEditor,
} from "frappe-ui";
import { Editor, EditorContent } from "frappe-ui/editor";
import { storeToRefs } from "pinia";
import { computed, h, nextTick, ref, watch } from "vue";
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { showEmailBox } from "../pages/ticket/modalStates";
import {
setActiveSettingsTab,
showSettingsModal,
} from "./Settings/settingsModal";
const extensions = buildEditorExtensions();
const props = defineProps({
doctype: {
@@ -181,42 +153,34 @@ const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
storeToRefs(useConfigStore());
const filters = computed(() => {
const scopes = [
{ label: __("All"), value: "All" },
{ label: __("Personal"), value: "Personal" },
{ label: __("My Team(s)"), value: "Team" },
{ label: __("Global"), value: "Global" },
const options = [
{
label: __("All"),
value: "All",
onClick: () => (activeFilter.value = "All"),
},
{
label: __("Personal"),
value: "Personal",
onClick: () => (activeFilter.value = "Personal"),
},
{
label: __("My Team"),
value: "Team",
onClick: () => (activeFilter.value = "My Team"),
},
{
label: __("Global"),
value: "Global",
onClick: () => (activeFilter.value = "Global"),
},
];
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
scopes.pop();
options.pop();
}
return scopes.map((scope) => ({
...scope,
selected: activeFilter.value === scope.value,
onClick: () => (activeFilter.value = scope.value),
slots: {
suffix: () =>
h(
"span",
{ class: "text-sm text-ink-gray-5" },
String(scopeCounts.data?.[scope.value] ?? 0)
),
},
}));
return options;
});
const activeFilterLabel = computed(() => {
return (
filters.value.find((filter) => filter.value === activeFilter.value)
?.label ?? activeFilter.value
);
});
// Older sessions stored the label instead of the value
if (activeFilter.value == "My Team") {
activeFilter.value = "Team";
}
// Set default filter to Personal if Global is disabled
if (
teamRestrictionApplied.value &&
@@ -229,27 +193,19 @@ if (
const emit = defineEmits(["apply"]);
const search = ref("");
const searchInput = ref<InstanceType<typeof TextInput>>();
const searchInput = ref<InstanceType<typeof Input>>();
const selectedTemplate = ref({
name: "",
isLoading: false,
});
const pendingTemplate = ref<RenderedSavedReply | null>(null);
function onAfterLeave() {
if (pendingTemplate.value !== null) {
emit("apply", pendingTemplate.value);
pendingTemplate.value = null;
}
}
const scope = computed(() => {
return filters.value.find((f) => f.value === activeFilter.value)?.value;
return filters.value.find((f) => f.label === activeFilter.value)?.value;
});
const savedReplyListResource = createListResource({
doctype: "HD Saved Reply",
fields: ["name", "title", "owner", "scope", "message", "actions"],
fields: ["name", "title", "owner", "scope", "message"],
filters: {
scope: scope.value == "All" ? undefined : ["=", scope.value],
},
@@ -257,46 +213,13 @@ const savedReplyListResource = createListResource({
auto: true,
orderBy: "modified desc",
start: 0,
pageLength: 20,
pageLength: 999,
});
// One grouped query answers every scope at once, so the menu can say where the
// replies live before an agent switches to find out
const scopeCounts = createResource({
url: "frappe.client.get_list",
params: {
doctype: "HD Saved Reply",
fields: ["scope", { COUNT: "name" }],
group_by: "scope",
limit_page_length: 0,
},
transform: (rows: { scope: string }[]) => {
const counts: Record<string, number> = { All: 0 };
for (const row of rows) {
const count = row["COUNT(`name`)"] ?? 0;
counts[row.scope] = count;
counts.All += count;
}
return counts;
},
onUnmounted(() => {
showEmailBox.value = true;
});
const actionCounts = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {};
for (const row of savedReplyListResource.data || []) {
counts[row.name] = JSON.parse(row.actions || "[]").length;
}
return counts;
});
const actionLabel = (count: number) =>
count === 1 ? __("1 action") : __("{0} actions", count);
const actionTooltip = (count: number) =>
count === 1
? __("Applies 1 action when sent")
: __("Applies {0} actions when sent", count);
const onTemplateSelect = (template: SavedReply) => {
if (selectedTemplate.value.isLoading) return;
selectedTemplate.value = {
@@ -309,18 +232,12 @@ const onTemplateSelect = (template: SavedReply) => {
saved_reply_id: template.name,
ticket_id: props.ticketId,
},
onSuccess: (data: RenderedSavedReply) => {
onSuccess: (data: string) => {
selectedTemplate.value = {
name: "",
isLoading: false,
};
// If user cancelled (Escape/outside click) while API was in flight, discard
if (!show.value) return;
pendingTemplate.value = data;
show.value = false;
// Shared with the palette: modal picks count toward its most-used rows.
recordSavedReplyUse(template.name, template.title);
capture("saved_reply_applied", { data: { source: "composer" } });
emit("apply", data);
},
});
renderResponse.submit().catch(() => {
@@ -337,12 +254,18 @@ const onNewSavedReplyClick = () => {
setActiveSettingsTab("Saved Replies");
};
// A reply is as findable by its wording as by its name, now that only a page loads
watch(search, (query) => {
savedReplyListResource.orFilters = query
? { title: ["like", `%${query}%`], message: ["like", `%${query}%`] }
: undefined;
savedReplyListResource.start = 0;
const resetFilter = () => {
savedReplyListResource.filters = {
...savedReplyListResource.filters,
title: undefined,
};
};
watch(search, (newValue) => {
savedReplyListResource.filters = {
...savedReplyListResource.filters,
title: ["like", `%${newValue}%`],
};
savedReplyListResource.list.reload();
});
@@ -351,22 +274,18 @@ watch(activeFilter, () => {
...savedReplyListResource?.filters,
scope: scope.value == "All" ? undefined : ["=", scope.value],
};
savedReplyListResource.start = 0;
savedReplyListResource.list.reload();
});
watch(
show,
(newValue) => {
if (!newValue) return;
if (search.value) {
search.value = "";
} else {
savedReplyListResource.list.reload();
if (newValue) {
nextTick(() => {
const inputEl = searchInput.value?.$el?.querySelector("input");
inputEl?.focus();
});
}
// Counts don't move with the selected scope, so this is the only refetch
scopeCounts.reload();
nextTick(() => searchInput.value?.focus());
},
{ immediate: true }
);

View File

@@ -1,132 +0,0 @@
<template>
<component
:is="config.doctype ? Link : Combobox"
v-bind="controlProps"
:disabled="!isEditable"
:model-value="action.value"
:placeholder="__('Select {0}', label.toLowerCase())"
@update:model-value="pick(($event ?? '') as string)"
>
<template #trigger="{ setOpen }">
<!-- The anchor forwards its click to this element, so it has to be a plain
one: Tooltip and Combobox both drop inherited attrs -->
<div
class="inline-flex"
:role="isEditable ? 'button' : undefined"
:tabindex="isEditable ? 0 : undefined"
@keydown.enter.self.prevent="setOpen(true)"
>
<Tooltip :text="value ? `${label}: ${value}` : label">
<Badge
class="bg-surface-base transition-colors"
:class="isEditable ? 'cursor-pointer hover:bg-surface-gray-2' : ''"
theme="gray"
variant="outline"
size="lg"
>
<template #prefix>
<component
:is="config.icon"
class="size-3 shrink-0 text-ink-gray-6"
/>
</template>
<span class="flex min-w-0 items-center gap-1">
<span v-if="value" class="shrink-0 text-ink-gray-6">
{{ label }}
</span>
<span class="max-w-40 truncate text-ink-gray-8">
{{ value || label }}
</span>
</span>
<template #suffix>
<!-- Removing is not picking: the click stops here -->
<button
type="button"
:aria-label="__('Remove {0}', label)"
class="flex items-center rounded-1 text-ink-gray-6 transition-colors hover:text-ink-gray-9 focus-visible:ring-2 focus-visible:ring-outline-gray-3 mt-[1px]"
@click.stop="emit('remove')"
@pointerdown.stop
>
<LucideX class="size-3" />
</button>
</template>
</Badge>
</Tooltip>
</div>
</template>
</component>
</template>
<script setup lang="ts">
import { ACTION_TYPES } from "@/components/Settings/SavedReplies/components/actionTypes";
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
import { Link } from "@framework/ui";
import { __ } from "@/translation";
import { SavedReplyAction } from "@/types";
import { Badge, Combobox, Tooltip } from "frappe-ui";
import { computed } from "vue";
import LucideX from "~icons/lucide/x";
const props = defineProps<{
action: SavedReplyAction;
/** Values the other chips hold, so a repoint can't collide with them. */
taken?: string[];
}>();
const emit = defineEmits<{
update: [action: SavedReplyAction];
remove: [];
}>();
const { valueOptions, linkFilters } = useSavedReplyActionOptions();
const config = computed(() => ACTION_TYPES[props.action.action_type]);
const label = computed(() => config.value.chipLabel);
// Empty for no-value actions like "Assign to Me"; the noun then stands alone
const value = computed(() => props.action.label || props.action.value);
// A tag chip holds one tag, so it picks like the rest. Excluded rather than
// listed, so a new control type is editable by default instead of silently
// read-only: no-value actions have nothing to pick, and a comment is too long
// for a chip.
const isEditable = computed(
() => !["none", "editor"].includes(config.value.control)
);
// Combobox takes string values; the shared options allow numbers too
const pickerOptions = computed(() =>
valueOptions(props.action.action_type)
.filter((option) => !props.taken?.includes(String(option.value)))
.map((option) => ({
label: option.label,
value: String(option.value),
}))
);
// A #trigger slot puts Combobox in button mode, and Link forwards that slot,
// so the badge below serves both controls.
const controlProps = computed(() =>
config.value.doctype
? {
doctype: config.value.doctype,
filters: linkFilters(props.action.action_type),
title: value.value,
}
: { options: pickerOptions.value }
);
/** `label` carries the display name (an agent's, not their id), so both move. */
function pick(picked: string) {
// A link picker offers no option list to read a label from; for these
// doctypes the record name is the label.
if (config.value.doctype) {
emit("update", { ...props.action, value: picked, label: picked });
return;
}
const option = pickerOptions.value.find(
(candidate) => candidate.value === picked
);
if (!option) return;
emit("update", { ...props.action, value: option.value, label: option.label });
}
</script>

View File

@@ -1,80 +0,0 @@
<template>
<!-- Full-bleed white section, so the block reads as one box with a gray top
and a white bottom rather than a card floating inside another card -->
<div class="border-t border-outline-gray-1 bg-surface-base">
<div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5 text-sm">
<LucideMessageSquare class="size-3 shrink-0 text-ink-gray-6" />
<span class="shrink-0 text-ink-gray-6">{{ __("Comment") }}</span>
<Button
class="ms-auto shrink-0"
variant="ghost"
size="xs"
icon="lucide-x"
:label="__('Remove comment')"
@click="emit('remove')"
/>
</div>
<Editor
:model-value="action.value"
:extensions="extensions"
:placeholder="__('Comment')"
@update:model-value="emit('update:value', $event ?? '')"
>
<template #default>
<EditorContent
class="prose prose-v3 [--prose-font-size:13px] max-w-none max-h-[140px] min-h-16 overflow-y-auto px-3.5 py-2"
/>
</template>
</Editor>
<!-- Fixed height so the reset button appearing doesn't grow the footer -->
<div
class="flex h-9 items-center gap-2 ps-3.5 pe-1.5 pb-2 text-xs text-ink-gray-5"
>
<span class="cursor-default">
{{ __("Visible to your team only, never sent to the customer.") }}
</span>
<Button
v-if="isEdited"
class="ms-auto"
variant="ghost"
size="sm"
:label="__('Reset to default')"
@click="emit('update:value', action.original_value ?? '')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { buildEditorExtensions } from "@/components/editor/config";
import { useAgentStore } from "@/stores/agent";
import { __ } from "@/translation";
import { SavedReplyAction } from "@/types";
import { Button } from "frappe-ui";
import { Editor, EditorContent } from "frappe-ui/editor";
import { storeToRefs } from "pinia";
import { computed } from "vue";
import LucideMessageSquare from "~icons/lucide/message-square";
const props = defineProps<{
action: SavedReplyAction;
}>();
const emit = defineEmits<{
"update:value": [value: string];
remove: [];
}>();
/** Only actions staged with the original text can report an edit. */
const isEdited = computed(
() =>
props.action.original_value !== undefined &&
props.action.original_value !== props.action.value
);
// Same editor the Comment tab uses: rich text with @-mentions
const { dropdown: agentDropdown } = storeToRefs(useAgentStore());
const extensions = buildEditorExtensions({
mentions: () => agentDropdown.value ?? [],
});
</script>

View File

@@ -1,264 +0,0 @@
<template>
<div
v-if="pendingActions.length"
ref="container"
class="overflow-hidden rounded-6 border border-outline-gray-1 bg-surface-gray-1"
>
<div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5">
<LucideInfo class="size-3 shrink-0 text-ink-gray-6" />
<template v-if="sourceLabel">
<Tooltip :text="sourceLabel">
<span class="min-w-0 truncate text-sm-medium text-ink-gray-7">
{{ sourceLabel }}
</span>
</Tooltip>
<span class="shrink-0 text-ink-gray-3">·</span>
</template>
<span class="shrink-0 whitespace-nowrap text-sm text-ink-gray-5">
{{ countSentence }}
</span>
<div class="ms-auto flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="xs"
:aria-expanded="expanded"
:icon="expanded ? 'lucide-chevron-down' : 'lucide-chevron-right'"
:label="expanded ? __('Hide actions') : __('Show actions')"
@click="toggleExpanded"
/>
<Button
variant="ghost"
size="xs"
icon="lucide-x"
:label="__('Clear all actions')"
@click="clear"
:tooltip="__('Clear all actions')"
/>
</div>
</div>
<div v-if="expanded">
<div
v-if="chipActions.length"
class="flex flex-wrap items-center gap-1.5 px-1.5 pb-2"
>
<SavedReplyActionChip
v-for="action in visibleChips"
:key="actionKey(action)"
:action="action"
:taken="takenTags(action)"
@update="updateAction(action, $event)"
@remove="removeAction(action)"
/>
<button v-if="overflowCount" type="button" @click="showAllChips = true">
<Badge theme="gray" variant="outline" size="lg">
{{ __("+{0} more", overflowCount) }}
</Badge>
</button>
</div>
<SavedReplyActionComment
v-if="noteAction"
:action="noteAction"
@update:value="setNoteValue"
@remove="removeAction(noteAction)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { isTagAction } from "@/components/Settings/SavedReplies/components/actionTypes";
import { reloadTicket, reloadTicketFeed } from "@/composables/useTicket";
import { userStorage } from "@/composables/userStorage";
import { __ } from "@/translation";
import { RenderedSavedReply, SavedReplyAction } from "@/types";
import { useElementSize } from "@vueuse/core";
import { Badge, Button, Tooltip, createResource, toast } from "frappe-ui";
import { computed, ref } from "vue";
import LucideInfo from "~icons/lucide/info";
import SavedReplyActionChip from "./SavedReplyActionChip.vue";
import SavedReplyActionComment from "./SavedReplyActionComment.vue";
/** Chips beyond this stay hidden until the agent asks for the rest. */
const MAX_VISIBLE_CHIPS = 4;
/** Failures hold long enough to read: one asks for a decision, the other for work. */
const FAILURE_TOAST_DURATION = 15000;
/** Below this the long sentence squeezes the reply name to nothing. */
const NARROW_WIDTH = 470;
const props = defineProps<{
ticketId: string;
doctype: string;
}>();
// Persisted like the draft body, so a refresh keeps text and actions in sync.
// Scoped to the agent: a staged comment must not survive a shift handover.
const pendingActions = userStorage<SavedReplyAction[]>(
"pendingSavedReplyActions" + props.ticketId,
[]
);
const expanded = userStorage(
"pendingSavedReplyActionsExpanded" + props.ticketId,
false
);
const showAllChips = ref(false);
// Not reactive: it only gates the next failure's retry
let isRetry = false;
const container = ref<HTMLElement>();
const { width } = useElementSize(container);
const noteAction = computed(() =>
pendingActions.value.find((action) => action.action_type === "Add Comment")
);
const chipActions = computed(() =>
pendingActions.value.filter((action) => action.action_type !== "Add Comment")
);
const visibleChips = computed(() =>
showAllChips.value || chipActions.value.length <= MAX_VISIBLE_CHIPS + 1
? chipActions.value
: chipActions.value.slice(0, MAX_VISIBLE_CHIPS)
);
const overflowCount = computed(
() => chipActions.value.length - visibleChips.value.length
);
/** Collapsing puts the chip list back to its capped state. */
function toggleExpanded() {
expanded.value = !expanded.value;
if (!expanded.value) showAllChips.value = false;
}
// Only one reply is ever staged, so its title is the whole label
const sourceLabel = computed(() => pendingActions.value[0]?.source ?? "");
const countSentence = computed(() => {
const count = pendingActions.value.length;
if (width.value && width.value < NARROW_WIDTH) {
return __("{0} actions after sending", count);
}
return count === 1
? __("1 action will be applied after sending the email")
: __("{0} actions will be applied after sending the email", count);
});
/** Stage the actions of an applied saved reply, replacing whatever was staged. */
function add(reply: RenderedSavedReply) {
pendingActions.value = (reply.actions ?? []).map((action) => ({
...action,
source: reply.title,
// Kept so the staged comment can report edits and restore
...(action.action_type === "Add Comment"
? { original_value: action.value }
: {}),
}));
}
/** What is staged right now, for the replace confirmation. Null when nothing is. */
function stagedSummary(): { count: number; source: string } | null {
if (!pendingActions.value.length) return null;
return { count: pendingActions.value.length, source: sourceLabel.value };
}
// Tags share an action type, so they key on their value too
function actionKey(action: SavedReplyAction): string {
return isTagAction(action.action_type)
? `${action.action_type}:${action.value}`
: action.action_type;
}
// Dont re-use duplicate tags
function takenTags(action: SavedReplyAction): string[] {
if (!isTagAction(action.action_type)) return [];
return pendingActions.value
.filter((other) => other !== action && isTagAction(other.action_type))
.map((other) => other.value);
}
/** Repoint an action, e.g. an escalation routed to a different team. */
function updateAction(action: SavedReplyAction, updated: SavedReplyAction) {
const index = pendingActions.value.indexOf(action);
if (index !== -1) pendingActions.value[index] = updated;
}
function removeAction(action: SavedReplyAction) {
pendingActions.value = pendingActions.value.filter(
(pending) => actionKey(pending) !== actionKey(action)
);
}
// Whatever is in the editor at send time is what gets applied
function setNoteValue(value: string) {
if (noteAction.value) noteAction.value.value = value;
}
function reloadFeed() {
reloadTicket(props.ticketId);
reloadTicketFeed(props.ticketId);
}
const applyActions = createResource({
url: "helpdesk.api.saved_replies.apply_saved_reply_actions",
onSuccess: (result: {
applied: SavedReplyAction[];
skipped: SavedReplyAction[];
}) => {
if (result.applied?.length) {
toast.success(
__("Applied {0} saved reply action(s)", result.applied.length)
);
}
if (result.skipped?.length) {
toast.warning(
__(
"Skipped {0} saved reply action(s) that are no longer valid",
result.skipped.length
)
);
}
pendingActions.value = [];
reloadFeed();
},
onError: (error: { status?: number }) => {
const failed = [...pendingActions.value];
// Only a response proves it rolled back, and one retry is enough
const canRetry = Boolean(error?.status) && !isRetry;
// Unstaged always: a kept batch would ride the next, unrelated send
pendingActions.value = [];
toast.error(__("Could not apply actions from the saved reply."), {
duration: FAILURE_TOAST_DURATION,
...(canRetry
? { action: { label: __("Retry"), onClick: () => retry(failed) } }
: { description: __("Please apply the actions manually") }),
});
reloadFeed();
},
});
/** Re-send a batch the agent kept from the failure toast. */
function retry(actions: SavedReplyAction[]) {
isRetry = true;
applyActions.submit({ ticket_id: props.ticketId, actions });
}
/** Apply the staged actions to the ticket; call after the email is sent. */
function submit() {
if (!pendingActions.value.length || props.doctype !== "HD Ticket") return;
// Actions stay staged during the request, so guard the second call
if (applyActions.loading) return;
isRetry = false;
applyActions.submit({
ticket_id: props.ticketId,
actions: [...pendingActions.value],
});
}
/** Drop the staged actions without applying them. */
function clear() {
pendingActions.value = [];
}
defineExpose({ add, stagedSummary, submit, clear });
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div
v-if="Boolean(articles.data?.length) && query.length > 2"
class="rounded-4 border p-4 text-base"
class="rounded border p-4 text-base"
>
<div class="mb-2 font-medium ps-2" v-if="!hideViewAll">
<div class="mb-2 font-medium pl-2" v-if="!hideViewAll">
These articles may already cover what you are looking for
<RouterLink
class="group cursor-pointer space-x-1 hover:text-ink-gray-9"
class="group cursor-pointer space-x-1 hover:text-gray-900"
:to="{
name: 'CustomerKnowledgeBase',
}"
@@ -22,10 +22,10 @@
<div
v-for="a in articles.data"
:key="a.id"
class="rounded-5 border-2 p-2 border-hidden hover:bg-surface-gray-2"
class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2"
>
<RouterLink
class="group cursor-pointer hover:text-ink-gray-9 flex flex-col gap-1"
class="group cursor-pointer hover:text-gray-900 flex flex-col gap-1"
:to="{
name: 'ArticlePublic',
params: {
@@ -39,7 +39,7 @@
<dt class="font-base">{{ a.subject }} - {{ a.headings }}</dt>
<!-- eslint-disable-next-line vue/no-v-html -->
<dd
class="font-base text-p-sm text-ink-gray-5 line-clamp-1"
class="font-base text-p-sm text-gray-600 line-clamp-1"
v-html="a.description"
></dd>
</RouterLink>
@@ -50,24 +50,24 @@
v-else-if="
!articles.loading && articles.data?.length === 0 && query.length > 2
"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
>
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
<p class="font-base">No answers found</p>
<span class="font-base text-p-sm text-ink-gray-5 text-center"
<span class="font-base text-p-sm text-gray-600 text-center"
>Rephrase the question and try again with some keywords</span
>
</div>
</div>
<div
v-else-if="articles.loading"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
>
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
<p class="font-base">Searching...</p>
<span class="font-base text-p-sm text-ink-gray-5 text-center"
<span class="font-base text-p-sm text-gray-600 text-center"
>Please wait while we search for the answers</span
>
</div>
@@ -99,9 +99,7 @@ watch(
},
});
articles.reload();
},
// Mounted after the third character is typed.
{ immediate: true }
}
);
function handleSearchArticleClick(article) {

View File

@@ -0,0 +1,101 @@
<template>
<Autocomplete
placeholder="Select an option"
:options="options"
:value="selection"
@update:query="(q) => onUpdateQuery(q)"
@change="
(v) => {
if (!resetInput) {
selection = v;
}
emit('change', v);
}
"
/>
</template>
<script setup lang="ts">
import { Autocomplete } from "@/components";
import { createListResource } from "frappe-ui";
import { computed, ref } from "vue";
const emit = defineEmits(["change"]);
const props = defineProps({
value: {
type: String,
required: false,
default: "",
},
doctype: {
type: String,
required: true,
},
searchField: {
type: String,
required: false,
default: "name",
},
labelField: {
type: String,
required: false,
default: "name",
},
valueField: {
type: String,
required: false,
default: "name",
},
pageLength: {
type: Number,
required: false,
default: 100,
},
resetInput: {
type: Boolean,
required: false,
default: false,
},
customFilters: {
type: Object,
required: false,
default: {},
},
});
const r = createListResource({
doctype: props.doctype,
pageLength: props.pageLength,
auto: true,
fields: [props.labelField, props.searchField, props.valueField],
filters: {
[props.searchField]: ["like", `%${props.value}%`],
...props.customFilters,
},
onSuccess: () => {
selection.value = props.value
? options.value.find((o) => o.value === props.value)
: null;
},
});
const options = computed(
() =>
r.data?.map((result) => ({
label: result[props.labelField],
value: result[props.valueField],
})) || []
);
const selection = ref(null);
function onUpdateQuery(query: string) {
r.update({
filters: {
[props.searchField]: ["like", `%${query}%`],
...props.customFilters,
},
});
r.reload();
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative inline-block text-start">
<div class="relative inline-block text-left">
<div class="flex">
<Button
variant="outline"
@@ -12,7 +12,7 @@
>
<template v-if="selectedCount > 0">
<!-- Stacked Avatars -->
<div class="flex -space-x-2 rtl:space-x-reverse isolate">
<div class="flex -space-x-2 isolate">
<div
v-for="(option, index) in selectedOptions.slice(0, 3)"
:key="`avatar-${option.value}`"
@@ -23,7 +23,7 @@
v-if="option.image"
:image="option.image"
:label="option.label"
class="border-2 border-[var(--surface-base)] flex-shrink-0"
class="border-2 border-white flex-shrink-0"
size="sm"
/>
</div>
@@ -41,7 +41,7 @@
</div>
<template #suffix>
<LucideChevronDown
class="ms-2 h-4 w-4 transition-transform duration-200"
class="ml-2 h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
/>
</template>
@@ -50,12 +50,12 @@
<div
v-if="isOpen"
class="absolute z-50 mt-2 w-64 divide-y divide-outline-elevation-2 rounded-6 bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none start-0 origin-top-left"
class="absolute z-50 mt-2 w-64 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none left-0 origin-top-left"
>
<!-- Header -->
<div class="py-1.5 px-1.5">
<div
class="flex h-7 items-center text-sm-medium text-ink-gray-6 justify-between"
class="flex h-7 items-center text-sm font-medium text-ink-gray-6 justify-between"
>
<input
ref="inputRef"
@@ -85,13 +85,13 @@
<button
v-for="option in filteredIndividualOptions"
:key="`individual-${option.value}`"
class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
@click="toggleOption(option)"
>
<Checkbox
:modelValue="props.modelValue.includes(option.value)"
class="me-4 flex-shrink-0"
class="mr-2 flex-shrink-0"
/>
<component v-if="option.icon" :is="renderIcon(option.icon)" />
@@ -100,17 +100,17 @@
v-else-if="hasAnyVisualElements"
:image="option.image"
:label="option.label"
class="me-2 flex-shrink-0"
class="mr-2 flex-shrink-0"
size="sm"
/>
<span class="text-ink-gray-7 flex-1 text-start truncate">
<span class="text-ink-gray-7 flex-1 text-left truncate">
{{ option.label }}
</span>
<span
v-if="(option.count ?? 0) > 0"
class="text-xs text-ink-gray-5 ms-auto"
class="text-xs text-ink-gray-5 ml-auto"
>
{{ option.count }}
</span>
@@ -121,7 +121,7 @@
<div v-for="group in filteredGroups" :key="`group-${group.group}`">
<!-- Group Header -->
<div
class="flex h-7 pt-1.5 sticky top-0 bg-surface-base items-center px-2 text-sm-medium text-ink-gray-6"
class="flex h-7 pt-1.5 sticky top-0 bg-surface-white items-center px-2 text-sm font-medium text-ink-gray-6"
>
{{ group.group }}
</div>
@@ -130,13 +130,13 @@
<button
v-for="option in group.items"
:key="`grouped-${option.value}`"
class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
@click="toggleOption(option)"
>
<Checkbox
:modelValue="props.modelValue.includes(option.value)"
class="me-4 flex-shrink-0"
class="mr-2 flex-shrink-0"
/>
<component v-if="option.icon" :is="renderIcon(option.icon)" />
@@ -145,23 +145,23 @@
v-else-if="option.image"
:image="option.image"
:label="option.label"
class="me-2 flex-shrink-0"
class="mr-2 flex-shrink-0"
size="sm"
/>
<!-- Spacer for alignment when other options have visual elements -->
<div
v-else-if="hasAnyVisualElements"
class="me-2 w-6 h-6 flex-shrink-0"
class="mr-2 w-6 h-6 flex-shrink-0"
></div>
<span class="text-ink-gray-7 flex-1 text-start truncate">
<span class="text-ink-gray-7 flex-1 text-left truncate">
{{ option.label }}
</span>
<span
v-if="(option.count ?? 0) > 0"
class="text-xs text-ink-gray-5 ms-auto"
class="text-xs text-ink-gray-5 ml-auto"
>
{{ option.count }}
</span>
@@ -391,7 +391,7 @@ function renderIcon(icon: string | any) {
"span",
{
class:
"flex-shrink-0 w-4 h-4 inline-flex items-center justify-center me-2",
"flex-shrink-0 w-4 h-4 inline-flex items-center justify-center mr-2",
},
[iconContent]
);

View File

@@ -1,41 +1,35 @@
<template>
<Popover>
<template #trigger="{ close, setOpen }">
<!--
Typing opens this, not clicking. reka turns the trigger's root into the
toggle, so the field stops its own click before it reaches that root.
-->
<div>
<div class="flex flex-col gap-1 w-full" @click.stop>
<slot name="label"></slot>
<FormControl
type="text"
class="w-full focus:outline-none outline-none border-inherit shadow-none"
v-bind="$attrs"
v-model="query"
@update:model-value="
(e: string) => {
if (e.length >= 3) {
setOpen(true);
} else {
close();
}
<Popover
:popover-class="popoverClass.length > 0 ? popoverClass : ''"
class="flex w-full"
>
<template #target="{ open, close }">
<div class="flex flex-col gap-1 w-full">
<slot name="label"></slot>
<FormControl
type="text"
class="w-full focus:outline-none outline-none border-inherit shadow-none"
v-bind="$attrs"
v-model="query"
@update:model-value="
(e: string) => {
if (e.length >= 3) {
open();
} else {
close();
}
"
>
<template #prefix>
<LucideSearch class="size-4 text-ink-gray-4" />
</template>
</FormControl>
</div>
}
"
>
<template #prefix>
<LucideSearch class="size-4 text-ink-gray-4" />
</template>
</FormControl>
</div>
</template>
<template #default>
<template #body-main>
<!-- Searched Articles -->
<div
class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col"
:class="popoverClass"
>
<div class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col">
<SearchArticles
:query="query"
:hideViewAll="true"

View File

@@ -1,45 +1,42 @@
<template>
<!-- px-4 lives once here so the header and content share a single horizontal
padding instead of each declaring their own. -->
<div class="px-4">
<div>
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
<div
v-if="!hide"
class="section-header sticky top-0 z-10 flex cursor-pointer select-none items-center gap-1 bg-surface-base py-3.5"
class="section-header flex items-center justify-between"
:class="headerClass"
@click="collapsible && toggle()"
>
<!-- Figma: text/base/medium, ink-gray-8 -->
<Tooltip v-if="tooltip" :text="__(tooltip)">
<span
class="text-base font-medium text-ink-gray-8"
:class="labelClass"
>
{{ __(label) || "Untitled" }}
</span>
</Tooltip>
<span
v-else
class="text-base font-medium text-ink-gray-8"
<div
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
:class="labelClass"
@click="collapsible && toggle()"
>
{{ __(label) || "Untitled" }}
</span>
<LucideChevronRight
v-if="collapsible"
class="size-3.5 text-ink-gray-5 transition-transform"
:class="{ 'rotate-90': opened }"
/>
<FeatherIcon
v-if="collapsible && collapseIconPosition === 'left'"
name="chevron-right"
class="h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
<span>
{{ label || "Untitled" }}
</span>
<FeatherIcon
v-if="collapsible && collapseIconPosition === 'right'"
name="chevron-right"
class="h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
</div>
<slot name="actions"></slot>
</div>
</slot>
<transition
enter-active-class="transition-all duration-300 ease-in"
leave-active-class="transition-all duration-300 ease-[cubic-bezier(0,1,0.5,1)]"
enter-to-class="max-h-[2000px] overflow-hidden"
leave-from-class="max-h-[2000px] overflow-hidden"
enter-from-class="max-h-0 opacity-0"
leave-to-class="max-h-0 opacity-0"
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden"
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div class="columns" v-bind="$attrs" v-show="opened">
<slot v-bind="{ opened, open, close, toggle }" />
@@ -48,20 +45,13 @@
</div>
</template>
<script setup>
import { __ } from "@/translation";
import { Tooltip } from "frappe-ui";
import { ref, watch } from "vue";
import LucideChevronRight from "~icons/lucide/chevron-right";
import { ref } from "vue";
const props = defineProps({
label: {
type: String,
default: "",
},
tooltip: {
type: String,
default: "",
},
hideLabel: {
type: Boolean,
default: false,
@@ -74,6 +64,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
collapseIconPosition: {
type: String,
default: "left",
},
labelClass: {
type: [String, Object, Array],
default: "",
@@ -84,33 +78,19 @@ const props = defineProps({
},
});
const emit = defineEmits(["update:opened"]);
const hide = ref(props.hideLabel);
const opened = ref(props.opened);
watch(
() => props.opened,
(val) => {
opened.value = val;
}
);
function setOpened(value) {
opened.value = value;
emit("update:opened", value);
}
function toggle() {
setOpened(!opened.value);
opened.value = !opened.value;
}
function open() {
setOpened(true);
opened.value = true;
}
function close() {
setOpened(false);
opened.value = false;
}
</script>
<script>

View File

@@ -1,14 +1,11 @@
<template>
<Popover
bare
:side="splitPlacement(props.placement).side"
:align="splitPlacement(props.placement).align"
>
<template #trigger>
<Popover :placement="props.placement">
<template #target="{ togglePopover }">
<Button
class="flex items-center justify-between min-w-36"
@click="togglePopover()"
:class="targetClass"
icon-right="lucide-chevron-down"
icon-right="chevron-down"
>
<div class="w-full truncate">
{{
@@ -17,36 +14,40 @@
</div>
</Button>
</template>
<template #default="{ close: closePopover }">
<template #body="{ togglePopover }">
<div
class="mt-1 p-1 text-ink-gray-6 w-[--reka-popper-anchor-width] bg-surface-base shadow-2xl rounded-4"
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-white shadow-2xl rounded"
:class="bodyClass"
>
<div class="max-h-52 overflow-y-auto">
<div
v-for="option in options"
:key="option.value"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded-4"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded"
@click="
() => {
onChange(option.value);
closePopover();
togglePopover();
}
"
>
<div class="w-full truncate">
{{ option.label }}
</div>
<LucideCheck v-if="model == option.value" class="size-4 ms-2" />
<FeatherIcon
v-if="model == option.value"
name="check"
class="size-4 ml-2"
/>
</div>
</div>
<hr class="my-1" />
<Button
variant="ghost"
:label="__('Reset')"
icon-left="lucide-refresh-ccw"
icon-left="refresh-ccw"
class="w-full focus-visible:ring-0"
@click="onReset(closePopover)"
@click="onReset(togglePopover)"
/>
</div>
</template>
@@ -54,9 +55,7 @@
</template>
<script setup lang="ts">
import { splitPlacement } from "@/utils";
import LucideCheck from "~icons/lucide/check";
import { Button, Popover } from "frappe-ui";
import { Button, FeatherIcon, Popover } from "frappe-ui";
const model = defineModel();
@@ -65,17 +64,15 @@ interface Props {
targetClass?: string;
bodyClass?: string;
placement?: string;
defaultValue?: string;
}
const props = withDefaults(defineProps<Props>(), {
placement: "bottom-start",
defaultValue: undefined,
});
const onReset = (closePopover: () => void) => {
model.value = props.defaultValue !== undefined ? props.defaultValue : null;
closePopover();
const onReset = (togglePopover: () => void) => {
model.value = null;
togglePopover();
};
const onChange = (value: string) => {

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex items-center justify-between py-2" v-bind:class="$attrs">
<div class="flex items-center gap-x-3 w-4/5">
<div class="flex items-center space-x-3 w-4/5">
<Avatar :image="agent.user_image" :label="agent.agent_name" size="xl" />
<div>
<div class="flex items-center gap-2">

View File

@@ -8,7 +8,6 @@
@click="() => setActiveSettingsTab('Invite Agents')"
label="New"
variant="solid"
class="rtl:flex-row-reverse"
>
<template #prefix>
<LucidePlus class="h-4 w-4 stroke-1.5" />
@@ -18,48 +17,50 @@
<template #header-bottom>
<div class="flex items-center gap-2 justify-between">
<div class="relative grow">
<TextInput
<Input
:model-value="search"
@update:model-value="search = $event"
@input="search = $event"
:placeholder="__('Search')"
type="text"
class="focus:ring-0 border-outline-gray-2"
:debounce="300"
>
<template #prefix>
<LucideSearch class="size-4" />
</template>
</TextInput>
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="search"
icon="lucide-x"
icon="x"
variant="ghost"
@click="search = ''"
class="absolute end-1 top-1/2 -translate-y-1/2"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="dropdownOptions" align="end">
<Dropdown :options="dropdownOptions" placement="right">
<template #default="{ open }">
<Button :label="activeFilter">
<Button
:label="activeFilter"
class="flex items-center justify-between w-fit p-4"
>
<template #suffix>
<component
:is="open ? LucideChevronUp : LucideChevronDown"
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
</Button>
</template>
<template #item-label="{ item }">
<template #item="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded-4 text-base"
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
@click="item.onClick"
>
<div class="flex items-center justify-between flex-1">
<span class="whitespace-nowrap">
{{ item.label }}
</span>
<LucideCheck
<FeatherIcon
v-if="activeFilter === item.label"
name="check"
class="size-4 text-ink-gray-7"
/>
</div>
@@ -79,35 +80,52 @@
:loading="agents.loading"
variant="ghost"
class="w-full"
size="lg"
size="2xl"
/>
</div>
<!-- Empty State -->
<EmptyState
<div
v-if="!agents.loading && !agents.data?.length"
variant="badge"
:icon="AgentIcon"
title="No agent found"
:description="
activeFilter.length
? 'Change your search terms or filters'
: 'Add one to get started.'
"
/>
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex items-center justify-center"
>
<AgentIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No agent found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{
activeFilter.length
? __("Change your search terms or filters")
: __("Add one to get started.")
}}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="setActiveSettingsTab('Invite Agents')"
/>
</div>
<!-- Agent List -->
<div
class="w-full"
v-if="!agents.loading && Boolean(agents.data?.length)"
>
<div
class="grid grid-cols-8 items-center gap-3 text-sm text-ink-gray-5"
class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600"
>
<div class="col-span-6 text-p-sm">{{ __("Agent name") }}</div>
<div class="col-span-6 text-p-sm">{{ __("Agent Name") }}</div>
</div>
<hr class="mt-2" />
<div v-for="(agent, index) in agents.data" :key="agent.agent_name">
<div class="flex items-center justify-between h-14 group rounded-4">
<div class="flex items-center gap-x-3 grow">
<div class="flex items-center justify-between h-14 group rounded">
<div class="flex items-center space-x-3 grow">
<Avatar
:image="agent.user_image"
:label="agent.agent_name"
@@ -138,18 +156,23 @@
:label="getUserRole(agent.name)"
:button="{
label: getUserRole(agent.name),
iconRight: 'lucide-chevron-down',
iconRight: 'chevron-down',
iconLeft:
getUserRole(agent.name) === 'Agent'
? 'lucide-user'
? 'user'
: getUserRole(agent.name) === 'Manager'
? 'lucide-briefcase'
? 'briefcase'
: null,
}"
align="end"
placement="right"
/>
<Dropdown :options="getOptions(agent)" :key="agent" align="end">
<Button icon="lucide-more-horizontal" variant="ghost" />
<Dropdown
:options="getOptions(agent)"
:key="agent"
class="ml-2"
placement="right"
>
<Button icon="more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>
@@ -163,7 +186,7 @@
@click="() => agents.next()"
:loading="agents.loading"
:label="__('Load More')"
icon-left="lucide-refresh-cw"
icon-left="refresh-cw"
/>
</div>
</div>
@@ -173,19 +196,15 @@
</template>
<script setup lang="ts">
import LucideChevronUp from "~icons/lucide/chevron-up";
import LucideChevronDown from "~icons/lucide/chevron-down";
import LucideCheck from "~icons/lucide/check";
import EmptyState from "@/components/EmptyState.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { useAuthStore } from "@/stores/auth";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { Avatar, Button, call, Dropdown, toast } from "frappe-ui";
import { Avatar, Button, call, Dropdown, FeatherIcon, toast } from "frappe-ui";
import { h, onUnmounted } from "vue";
import AgentIcon from "../icons/AgentIcon.vue";
import LucideCheck from "~icons/lucide/check";
import { activeFilter, useAgents } from "./agents";
import AgentIcon from "../icons/AgentIcon.vue";
import { setActiveSettingsTab } from "./settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const { getUserRole, updateUserRoleCache } = useUserStore();
const { isManager } = useAuthStore();
@@ -196,20 +215,71 @@ const agents = agentStore.agents;
function getRoles(agent: string) {
const agentRole = getUserRole(agent);
const role = (label: string, icon: string) => ({
label,
icon,
selected: agentRole === label,
onClick: () => updateRole(agent, label),
});
const roles = [role("Agent", "lucide-user")];
const roles = [
{
label: "Agent",
component: (props) =>
RoleOption({
role: "Agent",
active: props.active,
selected: agentRole === "Agent",
icon: "user",
onClick: () => {
updateRole(agent, "Agent");
},
}),
},
];
if (isManager) {
roles.unshift(role("Manager", "lucide-briefcase"));
roles.unshift({
label: "Manager",
component: (props) =>
RoleOption({
role: "Manager",
active: props.active,
selected: agentRole === "Manager",
icon: "briefcase",
onClick: () => {
updateRole(agent, "Manager");
},
}),
});
}
return roles;
}
function RoleOption({ active, role, onClick, selected, icon = null }) {
return h(
"button",
{
class: [
active ? "bg-surface-gray-2" : "text-ink-gray-7",
"group flex w-full text-ink-gray-8 justify-between items-center rounded-md px-2 py-2 text-sm hover:bg-surface-gray-3",
],
onClick: !selected ? onClick : null,
},
[
h("div", { class: "flex gap-2" }, [
icon
? h(FeatherIcon, {
name: icon,
class: ["h-4 w-4 shrink-0"],
"aria-hidden": true,
})
: null,
h("span", { class: "whitespace-nowrap" }, role),
]),
selected
? h(LucideCheck, {
class: ["h-4 w-4 shrink-0 text-ink-gray-7"],
"aria-hidden": true,
})
: null,
]
);
}
function updateRole(agent: string, newRole: string) {
const currentRole = getUserRole(agent);
if (currentRole === newRole) {
@@ -221,7 +291,7 @@ function updateRole(agent: string, newRole: string) {
new_role: newRole,
}).then(() => {
updateUserRoleCache(agent, newRole);
toast.success(__(`Role updated to ${newRole} successfully.`));
toast.success(`Role updated to ${newRole}`);
});
}
@@ -230,7 +300,7 @@ function getOptions(agent) {
return [
{
label: "Disable Agent",
icon: "lucide-x-circle",
icon: "x-circle",
onClick: async () => {
await agentStore.updateAgent(agent.name, 0);
agents.reload({ ...filters, search: search.value });
@@ -239,7 +309,7 @@ function getOptions(agent) {
},
{
label: "Enable Agent",
icon: "lucide-check-circle",
icon: "check-circle",
onClick: async () => {
await agentStore.updateAgent(agent.name, 1);
agents.reload({ ...filters, search: search.value });

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="flex flex-col gap-1">
<span class="text-md-semibold text-ink-gray-8">{{
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignee Rules")
}}</span>
<span class="text-p-sm text-ink-gray-6">
@@ -14,7 +14,7 @@
</div>
<div class="mt-8 flex items-center justify-between gap-2">
<div>
<div class="text-base-medium text-ink-gray-8">
<div class="text-base font-medium text-ink-gray-8">
{{ __("Ticket Routing") }}
</div>
<div class="text-p-sm text-ink-gray-6 mt-1">
@@ -24,10 +24,11 @@
</div>
</div>
<div>
<Popover bare side="bottom" align="end">
<template #trigger>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded-4 h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
@click="togglePopover()"
>
<div>
{{
@@ -36,29 +37,30 @@
)?.label
}}
</div>
<LucideChevronDown class="size-4" />
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #default="{ close: closePopover }">
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-7 mt-1 bg-surface-base shadow-xl rounded-4 w-[--reka-popper-anchor-width]"
class="p-1 text-ink-gray-7 mt-1 bg-white shadow-xl rounded w-[--reka-popper-anchor-width]"
>
<div
v-for="option in ticketRoutingOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded-4"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded"
@click="
() => {
assignmentRuleData.rule = option.value;
closePopover();
togglePopover();
}
"
>
<span>
{{ option.label }}
</span>
<LucideCheck
<FeatherIcon
v-if="assignmentRuleData.rule == option.value"
name="check"
class="size-4"
/>
</div>
@@ -69,7 +71,7 @@
</div>
<div class="mt-7 flex items-center justify-between gap-2">
<div>
<div class="text-base-medium text-ink-gray-8">
<div class="text-base font-medium text-ink-gray-8">
{{ __("Assignees") }}
</div>
<div class="text-p-sm text-ink-gray-6 mt-1">
@@ -79,56 +81,44 @@
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button
<div
v-for="user in users"
:key="user.name"
variant="outline"
size="sm"
:label="user.full_name"
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
>
<template #prefix>
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
</template>
<template #suffix>
<Tooltip
v-if="user.email == assignmentRuleData.lastUser"
:text="__('Last user assigned by this rule')"
:hover-delay="350"
side="top"
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
<div class="text-ink-gray-7">
{{ user.full_name }}
</div>
<Tooltip
v-if="user.email == assignmentRuleData.lastUser"
:text="__('Last user assigned by this rule')"
:hover-delay="0.35"
:placement="'top'"
>
<div
class="text-xs rounded-full select-none bg-blue-600 text-white p-0.5 px-2"
>
<Badge theme="blue" variant="solid" :label="__('Last')" />
</Tooltip>
<LucideX
class="size-4 text-ink-gray-4 transition-colors hover:text-ink-gray-7"
@click.stop="removeAssignedUser(user)"
/>
</template>
</Button>
{{ __("Last") }}
</div>
</Tooltip>
<Button variant="ghost" icon="x" @click="removeAssignedUser(user)" />
</div>
</div>
<ErrorMessage :message="assignmentRulesErrors.users" />
</div>
</template>
<script setup lang="ts">
import LucideCheck from "~icons/lucide/check";
import LucideChevronDown from "~icons/lucide/chevron-down";
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from "frappe-ui";
import {
assignmentRuleData,
assignmentRulesErrors,
validateAssignmentRule,
} from "@/stores/assignmentRules";
import { useUserStore } from "@/stores/user";
import {
Avatar,
Badge,
Button,
ErrorMessage,
Popover,
Tooltip,
} from "frappe-ui";
import LucideX from "~icons/lucide/x";
import { computed } from "vue";
import AssigneeSearch from "./AssigneeSearch.vue";
import { computed } from "vue";
import { useUserStore } from "@/stores/user";
const { getUser } = useUserStore();

View File

@@ -1,22 +1,20 @@
<template>
<Combobox :multiple="true">
<Popover bare side="bottom" align="end">
<template #trigger>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<Button
variant="subtle"
icon-left="lucide-plus"
icon-left="plus"
@click="togglePopover()"
:label="__('Add Assignee')"
class="rtl:flex-row-reverse"
/>
</template>
<template #default>
<div
class="mt-1 rounded-6 bg-surface-base py-1 text-base shadow-2xl w-60"
>
<template #body="{ togglePopover }">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl w-60">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full bg-transparent"
class="form-input w-full"
type="text"
@change="
(e) => {
@@ -28,10 +26,10 @@
:placeholder="__('Search')"
/>
<button
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="query = ''"
>
<LucideX class="w-4" />
<FeatherIcon name="x" class="w-4" />
</button>
</div>
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
@@ -50,8 +48,8 @@
"
>
<li
class="flex items-center rounded-4 p-1.5 w-full text-base"
:class="{ 'bg-surface-gray-2': active }"
class="flex items-center rounded p-1.5 w-full text-base"
:class="{ 'bg-gray-100': active }"
>
<div class="flex gap-2 items-center w-full select-none">
<Avatar
@@ -73,7 +71,7 @@
</ComboboxOption>
<li
v-if="users.length == 0"
class="mt-1.5 rounded-5 p-1.5 text-base text-ink-gray-5"
class="mt-1.5 rounded-md p-1.5 text-base text-gray-600"
>
{{ __("No results found") }}
</li>
@@ -81,8 +79,8 @@
<div class="border-t p-1.5 pb-0.5">
<Button
variant="ghost"
class="w-full rtl:flex-row-reverse"
icon-left="lucide-plus"
class="w-full"
icon-left="plus"
:label="__('Invite agent')"
@click="inviteAgents"
/>
@@ -100,7 +98,6 @@
</template>
<script setup lang="ts">
import LucideX from "~icons/lucide/x";
import { assignmentRuleData } from "@/stores/assignmentRules";
import {
Combobox,

View File

@@ -1,12 +1,12 @@
<template>
<div
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
>
<div
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
class="w-full ps-2 col-span-7 h-14 flex flex-col justify-center"
class="w-full pl-2 col-span-7 h-14 flex flex-col justify-center"
>
<div class="text-base-medium text-ink-gray-7">{{ data.name }}</div>
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
<div
v-if="data.description && data.description.length > 0"
class="text-sm w-full text-ink-gray-5 mt-1 truncate"
@@ -16,7 +16,7 @@
</div>
<div class="col-span-3">
<select
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-5 p-0 ps-2 pe-5 bg-transparent -ms-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
v-model="data.priority"
@update:modelValue="onPriorityChange"
@change="onPriorityChange"
@@ -30,7 +30,7 @@
</option>
</select>
</div>
<div class="flex justify-between items-center w-full pe-2 col-span-2">
<div class="flex justify-between items-center w-full pr-2 col-span-2">
<div>
<Switch
size="sm"
@@ -39,26 +39,26 @@
/>
</div>
<div>
<Dropdown
align="end"
:options="dropdownOptions"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<Button icon="lucide-more-horizontal" variant="ghost" />
<Dropdown placement="right" :options="dropdownOptions">
<Button
icon="more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
</Dropdown>
</div>
</div>
</div>
<Dialog
:title="__('Duplicate Assignment Rule')"
v-model:open="duplicateDialog.show"
:options="{ title: __('Duplicate Assignment Rule') }"
v-model="duplicateDialog.show"
>
<template #default>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
:label="__('New Assignment Rule Name')"
type="text"
v-model="duplicateDialog.newName"
v-model="duplicateDialog.name"
/>
</div>
</template>
@@ -79,7 +79,6 @@
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
import { __ } from "@/translation";
import { AssignmentRuleListResourceSymbol } from "@/types";
import { AssignmentRule } from "@/types/doctypes";
import { ConfirmDelete } from "@/utils";
import {
Button,
@@ -87,6 +86,7 @@ import {
Dialog,
Dropdown,
FormControl,
Select,
Switch,
toast,
} from "frappe-ui";
@@ -111,7 +111,6 @@ const priorityOptions = [
const duplicateDialog = ref({
show: false,
newName: "",
name: "",
});
@@ -125,9 +124,9 @@ const deleteAssignmentRule = () => {
name: props.data.name,
},
onSuccess: () => {
assignmentRulesListData?.reload();
assignmentRulesListData.reload();
isConfirmingDelete.value = false;
toast.success(__("Assignment rule deleted successfully."));
toast.success(__("Assignment rule deleted"));
},
auto: true,
});
@@ -139,11 +138,10 @@ const dropdownOptions = [
onClick: () => {
duplicateDialog.value = {
show: true,
newName: props.data.name + " (Copy)",
name: props.data.name,
name: props.data.name + " (Copy)",
};
},
icon: "lucide-copy",
icon: "copy",
},
...ConfirmDelete({
onConfirmDelete: () => deleteAssignmentRule(),
@@ -153,37 +151,22 @@ const dropdownOptions = [
const duplicate = () => {
createResource({
url: "frappe.client.get",
url: "helpdesk.api.assignment_rule.duplicate_assignment_rule",
params: {
doctype: "Assignment Rule",
name: duplicateDialog.value.name,
docname: props.data.name,
new_name: duplicateDialog.value.name,
},
onSuccess: (data: AssignmentRule) => {
createResource({
url: "frappe.client.insert",
params: {
doc: {
...data,
name: duplicateDialog.value.newName,
},
onSuccess: (data) => {
assignmentRulesListData.reload();
toast.success(__("Assignment rule duplicated"));
duplicateDialog.value.show = false;
duplicateDialog.value.name = "";
assignmentRulesActiveScreen.value = {
screen: "view",
data: {
name: data.name,
},
auto: true,
onSuccess(newAssignmentRuleData: AssignmentRule) {
assignmentRulesListData?.reload();
toast.success(__("Assignment rule duplicated successfully."));
duplicateDialog.value = {
show: false,
newName: "",
name: "",
};
setTimeout(() => {
assignmentRulesActiveScreen.value = {
screen: "view",
data: newAssignmentRuleData,
};
}, 250);
},
});
};
},
auto: true,
});
@@ -193,26 +176,15 @@ const onPriorityChange = () => {
setAssignmentRuleValue("priority", props.data.priority);
};
const onToggle = (enabled: boolean) => {
const onToggle = () => {
if (!props.data.users_exists && props.data.disabled) {
toast.error(__("Cannot enable rule without adding users in it"));
return;
}
// Optimistically flip so the switch reflects the new state immediately;
// revert if the backend update fails.
const previous = props.data.disabled;
props.data.disabled = enabled ? 0 : 1;
setAssignmentRuleValue("disabled", props.data.disabled, "status", () => {
props.data.disabled = previous;
});
setAssignmentRuleValue("disabled", !props.data.disabled, "status");
};
const setAssignmentRuleValue = (
key: string,
value: any,
fieldName?: string,
onError?: () => void
) => {
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
createResource({
url: "frappe.client.set_value",
params: {
@@ -222,15 +194,8 @@ const setAssignmentRuleValue = (
value: value,
},
onSuccess: () => {
toast.success(
__("Assignment rule {0} updated successfully.", fieldName || key)
);
},
onError: () => {
onError?.();
toast.error(
__("Failed to update assignment rule {0}.", fieldName || key)
);
assignmentRulesListData.reload();
toast.success(__("Assignment rule {0} updated", fieldName || key));
},
auto: true,
});

View File

@@ -1,11 +1,26 @@
<template>
<SettingsLayoutBase
:back-label="
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
"
:on-back="goBack"
:dirty="isDirty"
>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-lg hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
</template>
<template #header-actions>
<div class="flex items-center gap-4">
<div
@@ -32,7 +47,7 @@
<template #content>
<div
v-if="getAssignmentRuleData.loading"
class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
class="flex items-center h-full justify-center"
>
<LoadingIndicator class="w-4" />
</div>
@@ -57,10 +72,11 @@
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Priority')" />
<Popover bare>
<template #trigger>
<Popover>
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded-4 h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
@click="togglePopover()"
>
<div>
{{
@@ -69,25 +85,26 @@
)?.label
}}
</div>
<LucideChevronDown class="size-4" />
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #default="{ close: closePopover }">
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-base shadow-2xl rounded-4 w-[--reka-popper-anchor-width]"
class="p-1 text-ink-gray-6 top-1 absolute bg-white shadow-2xl rounded w-[--reka-popper-anchor-width]"
>
<div
v-for="option in priorityOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-surface-sidebar text-base flex items-center justify-between rounded-4"
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
@click="
assignmentRuleData.priority = option.value;
closePopover();
togglePopover();
"
>
{{ option.label }}
<LucideCheck
<FeatherIcon
v-if="assignmentRuleData.priority == option.value"
name="check"
class="size-4"
/>
</div>
@@ -116,8 +133,8 @@
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-md-semibold text-ink-gray-8">{{
__("Assignment Condition")
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment condition")
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
@@ -136,29 +153,29 @@
<div
v-if="isAssignConditionOld && assignmentRulesActiveScreen.data"
>
<HoverCard :hoverDelay="250" side="top" align="end">
<template #trigger>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span>{{ __("Old Condition") }}</span>
<LucideInfo class="size-4" />
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #default>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
</template>
</HoverCard>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="
!useNewUIForAssignCondition &&
assignmentRuleData.assignCondition
@@ -193,8 +210,8 @@
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-md-semibold text-ink-gray-8">{{
__("Unassignment Condition")
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Unassignment condition")
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
@@ -217,29 +234,29 @@
assignmentRuleData.unassignCondition
"
>
<HoverCard :hoverDelay="250" side="top" align="end">
<template #trigger>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span> {{ __("Old Condition") }} </span>
<LucideInfo class="size-4" />
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #default>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
</template>
</HoverCard>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="
!useNewUIForUnassignCondition &&
assignmentRuleData.unassignCondition
@@ -270,7 +287,7 @@
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-md-semibold text-ink-gray-8">{{
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment Schedule")
}}</span>
<span class="text-p-sm text-ink-gray-6">
@@ -309,9 +326,6 @@
</template>
<script setup lang="ts">
import LucideCheck from "~icons/lucide/check";
import LucideChevronDown from "~icons/lucide/chevron-down";
import LucideInfo from "~icons/lucide/info";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import {
Badge,
@@ -321,7 +335,6 @@ import {
ErrorMessage,
FormControl,
FormLabel,
HoverCard,
LoadingIndicator,
Popover,
Switch,
@@ -544,7 +557,7 @@ const createAssignmentRule = () => {
const createAssignmentRuleResource = createResource({
url: "frappe.client.insert",
onSuccess(data) {
toast.success(__("Assignment rule created successfully."));
toast.success(__("Assignment rule created"));
assignmentRulesActiveScreen.value = {
screen: "view",
data: data,
@@ -631,7 +644,7 @@ const updateAssignmentRule = async () => {
}
isLoading.value = false;
toast.success(__("Assignment rule updated successfully."));
toast.success(__("Assignment rule updated"));
};
watch(

View File

@@ -1,7 +1,7 @@
<template>
<SettingsLayoutBase>
<template #title>
<h1 class="text-md-semibold text-ink-gray-8">
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Assignment Rules") }}
</h1>
</template>
@@ -20,8 +20,7 @@
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="lucide-plus"
class="rtl:flex-row-reverse"
icon-left="plus"
/>
</template>
<template
@@ -32,24 +31,22 @@
#header-bottom
>
<div class="relative">
<TextInput
<Input
:model-value="assignmentRuleSearchQuery"
@update:model-value="assignmentRuleSearchQuery = $event"
@input="assignmentRuleSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="focus:ring-0 border-outline-gray-2"
:debounce="300"
>
<template #prefix>
<LucideSearch class="size-4" />
</template>
</TextInput>
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="assignmentRuleSearchQuery"
icon="lucide-x"
icon="x"
variant="ghost"
@click="assignmentRuleSearchQuery = ''"
class="absolute end-1 top-1/2 -translate-y-1/2"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</template>

View File

@@ -1,11 +1,11 @@
<template>
<div
v-if="assignmentRulesListData.loading && !assignmentRulesListData.data"
class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div v-else class="-ms-2 grow">
<div v-else class="-ml-2 grow">
<div
v-if="!assignmentRulesListData.loading && !assignmentRulesList?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
@@ -16,17 +16,23 @@
<Settings class="size-6 text-ink-gray-6 rotate-90" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base-medium text-ink-gray-6">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No assignment rule found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="goToNew()"
/>
</div>
<div v-else>
<div class="grid grid-cols-12 items-center gap-4 text-sm text-ink-gray-5">
<div class="col-span-7 ms-2">{{ __("Assignment rule") }}</div>
<div class="grid grid-cols-12 items-center gap-4 text-sm text-gray-600">
<div class="col-span-7 ml-2">{{ __("Assignment rule") }}</div>
<div class="col-span-3">{{ __("Priority") }}</div>
<div class="col-span-2">{{ __("Enabled") }}</div>
</div>

View File

@@ -7,13 +7,13 @@
/>
<div
v-if="props.conditions.length == 0"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-ink-gray-5 rounded-5"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-gray-300 text-gray-600 rounded-md"
@click="
props.conditions.push(['', '', '']);
validateAssignmentRule(props.name);
"
>
<LucidePlus class="size-4" />
<FeatherIcon name="plus" class="h-4" />
{{ __("Add a condition") }}
</div>
<div class="flex items-center justify-between mt-2">
@@ -25,7 +25,7 @@
>
<Button
:disabled="props.errors !== ''"
:icon-right="open ? 'lucide-chevron-up' : 'lucide-chevron-down'"
:icon-right="open ? 'chevron-up' : 'chevron-down'"
:label="__('Add condition')"
/>
</Dropdown>
@@ -38,11 +38,10 @@
</template>
<script setup lang="ts">
import LucidePlus from "~icons/lucide/plus";
import CFConditions from "@/components/conditions-filter/CFConditions.vue";
import { validateConditions } from "@/utils";
import { watchDebounced } from "@vueuse/core";
import { Button, Dropdown, ErrorMessage } from "frappe-ui";
import { Button, Dropdown, ErrorMessage, FeatherIcon } from "frappe-ui";
import { validateAssignmentRule } from "@/stores/assignmentRules";
import { __ } from "@/translation";

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-5 border px-2 border-outline-gray-2 text-sm">
<div class="rounded-md border px-2 border-gray-300 text-sm">
<div
class="grid p-2 px-4 items-center"
style="grid-template-columns: 3fr 1fr"
@@ -7,7 +7,7 @@
<div
v-for="column in columns"
:key="column.key"
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ column.label }}
</div>

View File

@@ -5,15 +5,7 @@
>
<div class="text-ink-gray-7 font-medium">{{ data.day }}</div>
<div class="flex justify-start">
<Switch
:model-value="Boolean(data.active)"
@update:model-value="
(value) => {
data.active = value;
toggleDay(value);
}
"
/>
<Switch v-model="data.active" @update:model-value="toggleDay" />
</div>
</div>
<hr v-if="!isLast" />

View File

@@ -1,16 +1,16 @@
<template>
<div
class="flex justify-between items-center border-outline-elevation-2 p-2 cursor-pointer hover:bg-surface-sidebar rounded-4 h-14"
class="flex justify-between items-center border-gray-200 p-2 cursor-pointer hover:bg-gray-50 rounded h-14"
>
<!-- avatar and name -->
<div class="flex justify-between items-center gap-2">
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
<div class="rtl:text-right">
<p class="text-p-base-medium text-ink-gray-7">
{{ __(emailAccount.email_account_name) }}
<div>
<p class="text-base text-ink-gray-7 font-medium">
{{ emailAccount.email_account_name }}
</p>
<div class="text-p-sm w-full text-ink-gray-5 mt-1">
{{ __(emailAccount.email_id) }}
<div class="text-sm w-full text-ink-gray-5 mt-1">
{{ emailAccount.email_id }}
</div>
</div>
</div>

View File

@@ -11,20 +11,19 @@
<Button
:label="__('New')"
theme="gray"
class="rtl:flex-row-reverse"
variant="solid"
@click="emit('update:step', 'email-add')"
icon-left="lucide-plus"
icon-left="plus"
/>
</template>
<template #content>
<!-- list accounts -->
<div
class="-ms-2 grow"
class="-ml-2 grow"
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
>
<div class="flex text-sm text-ink-gray-5">
<div class="ms-2">{{ __("Email account name") }}</div>
<div class="flex text-sm text-gray-600">
<div class="ml-2">{{ __("Email Account name") }}</div>
</div>
<hr class="mx-2 mt-2" />
<div
@@ -35,34 +34,43 @@
:emailAccount="emailAccount"
@click="emit('update:step', 'email-edit', emailAccount)"
/>
<hr
class="mx-2"
v-if="
emailAccount !==
emailAccounts?.data[emailAccounts?.data?.length - 1]
"
/>
<hr class="mx-2" />
</div>
</div>
<!-- fallback if no email accounts -->
<EmptyState
v-else-if="!emailAccounts.loading"
variant="badge"
:icon="EmailIcon"
:title="__('No email account found')"
:description="__('Add one to get started.')"
/>
<div
v-else
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<EmailIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No email account found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="emit('update:step', 'email-add')"
/>
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import EmptyState from "@/components/EmptyState.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { EmailAccount } from "@/types";
import { createListResource } from "frappe-ui";
import { EmailIcon } from "../icons";
import EmailAccountCard from "./EmailAccountCard.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const emit = defineEmits(["update:step"]);

View File

@@ -27,19 +27,20 @@
<!-- email service provider info -->
<div>
<div
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
/>
<div class="text-wrap text-xs text-ink-gray-7">
<div class="text-wrap text-xs text-gray-700">
{{ selectedService.info }}
<a
:href="selectedService.link"
target="_blank"
class="text-ink-blue-5 underline"
class="text-blue-500 underline"
>here</a
>.
>
.
</div>
</div>
</div>
@@ -51,29 +52,7 @@
:key="field.name"
class="flex flex-col gap-1"
>
<div
v-if="field.name === 'domain'"
class="flex flex-col gap-2"
>
<Link
v-model="customState.domain"
:label="field.label"
:placeholder="field.placeholder"
doctype="Email Domain"
:onCreate="handleCreateDomainClick"
/>
<div v-if="customState.domain" class="flex gap-2">
<Button
size="sm"
variant="subtle"
theme="gray"
:label="__('Edit Domain')"
@click="handleEditDomainClick"
/>
</div>
</div>
<FormControl
v-else
v-model="state[field.name]"
:label="field.label"
:name="field.name"
@@ -82,41 +61,6 @@
/>
</div>
</div>
<div
v-if="isCustomSelected"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="flex flex-col gap-4">
<div
v-for="field in customIncomingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="field in customOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
@@ -129,12 +73,10 @@
:name="field.name"
:type="field.type"
/>
<p class="text-ink-gray-4 text-p-sm">
{{ field.description }}
</p>
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ms-1" :message="error" />
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
</div>
</div>
@@ -161,60 +103,21 @@
</template>
<script setup lang="ts">
import { Link } from "@/components";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { EmailAccount, EmailService, EmailStep } from "@/types";
import { call, createResource, toast } from "frappe-ui";
import { useOnboarding } from "@framework/ui";
import { computed, reactive, Ref, ref, watch } from "vue";
import { createResource, toast } from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, Reactive, reactive, Ref, ref } from "vue";
import CircleAlert from "~icons/lucide/circle-alert";
import {
customIncomingFields,
customOutgoingFields,
customProviderTopFields,
frappeMailFields,
customProviderFields,
incomingOutgoingFields,
popularProviderFields,
services,
validateInputs,
} from "./emailConfig";
import EmailProviderIcon from "./EmailProviderIcon.vue";
interface EmailAccountBaseState {
email_account_name: string;
email_id: string;
service: string;
enable_incoming: boolean;
enable_outgoing: boolean;
default_incoming: boolean;
default_outgoing: boolean;
}
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
password?: string;
api_key?: string;
api_secret?: string;
frappe_mail_site?: string;
}
type CustomEmailAccountState = {
domain?: string;
email_server?: string;
incoming_port?: string | number;
smtp_server?: string;
smtp_port?: string | number;
use_ssl?: boolean | number;
use_starttls?: boolean | number;
use_tls?: boolean | number;
use_ssl_for_outgoing?: boolean | number;
validate_ssl_certificate?: boolean | number;
validate_ssl_certificate_for_outgoing?: boolean | number;
attachment_limit?: string | number;
append_emails_to_sent_folder?: boolean | number;
sent_folder_name?: string;
};
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
interface E {
(event: "update:step", value: EmailStep): void;
@@ -222,9 +125,9 @@ interface E {
const emit = defineEmits<E>();
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const { updateOnboardingStep } = useOnboarding("helpdesk");
const state = reactive<EmailAccountProviderAuthState>({
const state: Reactive<EmailAccount> = reactive({
service: "",
email_account_name: "",
email_id: "",
@@ -238,78 +141,27 @@ const state = reactive<EmailAccountProviderAuthState>({
default_outgoing: false,
});
const getDefaultCustomState = (): CustomEmailAccountState => ({
domain: "",
email_server: "",
smtp_server: "",
incoming_port: "",
smtp_port: "",
use_ssl: false,
use_starttls: false,
use_tls: false,
use_ssl_for_outgoing: false,
validate_ssl_certificate: true,
validate_ssl_certificate_for_outgoing: true,
attachment_limit: "",
append_emails_to_sent_folder: false,
sent_folder_name: "",
});
const customState = reactive<CustomEmailAccountState>(getDefaultCustomState());
function resetCustomState() {
Object.assign(customState, getDefaultCustomState());
}
const selectedService: Ref<EmailService> = ref(null);
const fields = computed(() => {
if (!selectedService.value) return [];
if (selectedService.value.name === "Frappe Mail") {
return frappeMailFields;
}
if (selectedService.value.custom) {
return customProviderTopFields;
}
return popularProviderFields;
});
const isCustomSelected = computed(
() => selectedService.value?.name === "Custom"
);
watch(
() => customState.domain,
async (val) => {
if (state.service !== "Custom") return;
if (!isCustomSelected.value || !val) return;
try {
const doc = await call("frappe.client.get", {
doctype: "Email Domain",
name: val,
});
syncAccountFieldsFromDomain(doc);
} catch (err) {
console.warn("Failed to load Email Domain", err);
}
}
const fields = computed(() =>
selectedService.value.custom ? customProviderFields : popularProviderFields
);
function handleSelect(service: EmailService) {
selectedService.value = service;
state.service = service.name;
if (service.name !== "Custom") {
resetCustomState();
}
}
const addEmailRes = createResource({
url: "helpdesk.api.settings.email.create_email_account",
makeParams: (val: EmailAccount) => ({ ...val }),
makeParams: (val: EmailAccount) => {
return {
...val,
};
},
onSuccess: () => {
toast.success(__("Email account created"));
emit("update:step", "email-list");
updateOnboardingStep?.("setup_email_account");
capture("email_account_created", { data: { service: state.service } });
updateOnboardingStep("setup_email_account");
},
onError: () => {
error.value = __("Failed to create email account, Invalid credentials");
@@ -318,94 +170,10 @@ const addEmailRes = createResource({
const error = ref<string | undefined>();
function createEmailAccount() {
const validationState = { ...state, ...customState };
error.value = validateInputs(validationState, state.service);
error.value = validateInputs(state, selectedService.value.custom);
if (error.value) return;
addEmailRes.submit({ data: buildCreatePayload() });
}
function buildCreatePayload() {
const commonPayload = {
email_account_name: state.email_account_name,
email_id: state.email_id,
service: state.service,
enable_incoming: state.enable_incoming,
enable_outgoing: state.enable_outgoing,
default_incoming: state.default_incoming,
default_outgoing: state.default_outgoing,
};
if (state.service === "Frappe Mail") {
return {
...commonPayload,
frappe_mail_site: state.frappe_mail_site,
api_key: state.api_key,
api_secret: state.api_secret,
};
}
if (state.service === "Custom") {
return {
...commonPayload,
password: state.password,
...customState,
};
}
return {
...commonPayload,
password: state.password,
};
}
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
customState.email_server = payload.email_server ?? customState.email_server;
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
customState.incoming_port =
payload.incoming_port ?? customState.incoming_port;
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
customState.use_starttls = Boolean(
payload.use_starttls ?? customState.use_starttls
);
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
customState.use_ssl_for_outgoing = Boolean(
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
);
customState.validate_ssl_certificate = Boolean(
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
);
customState.validate_ssl_certificate_for_outgoing = Boolean(
payload.validate_ssl_certificate_for_outgoing ??
customState.validate_ssl_certificate_for_outgoing
);
customState.append_emails_to_sent_folder = Boolean(
payload.append_emails_to_sent_folder ??
customState.append_emails_to_sent_folder
);
customState.sent_folder_name =
payload.sent_folder_name || customState.sent_folder_name;
customState.attachment_limit =
payload.attachment_limit || customState.attachment_limit;
}
function handleCreateDomainClick(value: any, close?: () => void) {
close?.();
window.open(
"/desk/email-domain/new-email-domain",
"_blank",
"noopener,noreferrer"
);
}
function handleEditDomainClick() {
if (!customState.domain) return;
window.open(
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
"_blank",
"noopener,noreferrer"
);
addEmailRes.submit({ data: state });
}
</script>

View File

@@ -8,38 +8,29 @@
<div class="overflow-y-auto flex flex-col gap-4 p-0.5">
<div class="w-fit">
<EmailProviderIcon
:logo="emailIcon[state.service || accountData.service]"
:service-name="state.service || accountData.service"
:logo="emailIcon[accountData.service]"
:service-name="accountData.service"
/>
</div>
<!-- banner for setting up email account -->
<div
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
/>
<div class="text-wrap text-xs text-ink-gray-7 flex flex-col gap-1">
<span>
{{ info.description }}
<a
:href="info.link"
target="_blank"
class="text-ink-blue-5 underline"
>here</a
>.
</span>
<span v-if="deskEditUrl" class="flex items-center gap-1">
<a
:href="deskEditUrl"
target="_blank"
class="text-ink-blue-5 underline"
>
{{ __("Open in Desk") }}
</a>
</span>
<div class="text-wrap text-xs text-gray-700">
{{ info.description }}
<a
:href="info.link"
target="_blank"
class="text-ink-blue-2 underline"
>here</a
>
.
</div>
</div>
<!-- fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
@@ -47,26 +38,7 @@
:key="field.name"
class="flex flex-col gap-1"
>
<div v-if="field.name === 'domain'" class="flex flex-col gap-2">
<Link
v-model="customState.domain"
:label="field.label"
:placeholder="field.placeholder"
doctype="Email Domain"
:onCreate="handleCreateDomainClick"
/>
<div v-if="customState.domain" class="flex gap-2">
<Button
size="sm"
variant="subtle"
theme="gray"
:label="__('Edit Domain')"
@click="handleEditDomainClick"
/>
</div>
</div>
<FormControl
v-else
v-model="state[field.name]"
:label="field.label"
:name="field.name"
@@ -75,41 +47,6 @@
/>
</div>
</div>
<div
v-if="isCustomProvider"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="flex flex-col gap-4">
<div
v-for="field in customIncomingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="field in customOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
@@ -122,13 +59,14 @@
:name="field.name"
:type="field.type"
/>
<p class="text-p-sm text-ink-gray-4">{{ field.description }}</p>
<p class="text-p-sm text-gray-500">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ms-1" :message="error" />
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
</div>
<!-- action buttons -->
<div class="mt-auto flex justify-between -mb-8">
<Button
:label="__('Back')"
@@ -139,7 +77,6 @@
/>
<div class="flex gap-2">
<Button
v-if="hasChanges"
:label="__('Update Account')"
variant="solid"
@click="updateAccount"
@@ -161,60 +98,22 @@
</template>
<script setup lang="ts">
import { Link } from "@/components";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { __ } from "@/translation";
import { EmailAccount, EmailStep } from "@/types";
import { useStorage } from "@vueuse/core";
import { call, toast } from "frappe-ui";
import { computed, h, reactive, ref, watch } from "vue";
import { computed, h, reactive, ref } from "vue";
import CircleAlert from "~icons/lucide/circle-alert";
import EmailProviderIcon from "./EmailProviderIcon.vue";
import {
customIncomingFields,
customOutgoingFields,
customProviderTopFields,
customProviderFields,
emailIcon,
frappeMailFields,
incomingOutgoingFields,
popularProviderFields,
services,
validateInputs,
} from "./emailConfig";
interface EmailAccountBaseState {
email_account_name: string;
service: string;
email_id: string;
enable_incoming: boolean;
enable_outgoing: boolean;
default_outgoing: boolean;
default_incoming: boolean;
}
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
api_key?: string | null;
api_secret?: string | null;
password?: string | null;
frappe_mail_site?: string;
}
type CustomEmailAccountState = {
domain?: string;
email_server?: string;
smtp_server?: string;
incoming_port?: string | number;
smtp_port?: string | number;
use_ssl?: boolean | number;
use_starttls?: boolean | number;
use_tls?: boolean | number;
use_ssl_for_outgoing?: boolean | number;
validate_ssl_certificate?: boolean | number;
validate_ssl_certificate_for_outgoing?: boolean | number;
attachment_limit?: string | number;
append_emails_to_sent_folder?: boolean | number;
sent_folder_name?: string;
};
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
interface P {
accountData: EmailAccount;
@@ -230,7 +129,7 @@ const props = withDefaults(defineProps<P>(), {
const emit = defineEmits<E>();
const getInitialState = (): EmailAccountProviderAuthState => ({
const state = reactive({
email_account_name: props.accountData.email_account_name || "",
service: props.accountData.service || "",
email_id: props.accountData.email_id || "",
@@ -244,75 +143,18 @@ const getInitialState = (): EmailAccountProviderAuthState => ({
default_incoming: props.accountData.default_incoming || false,
});
const state = reactive<EmailAccountProviderAuthState>(getInitialState());
const getInitialCustomState = (): CustomEmailAccountState => ({
domain: props.accountData?.domain || "",
email_server: props.accountData?.email_server || "",
smtp_server: props.accountData?.smtp_server || "",
incoming_port: props.accountData?.incoming_port || "",
smtp_port: props.accountData?.smtp_port || "",
use_ssl: props.accountData?.use_ssl || false,
use_starttls: props.accountData?.use_starttls || false,
use_tls: props.accountData?.use_tls || false,
use_ssl_for_outgoing: props.accountData?.use_ssl_for_outgoing || false,
validate_ssl_certificate: props.accountData?.validate_ssl_certificate ?? true,
validate_ssl_certificate_for_outgoing:
props.accountData?.validate_ssl_certificate_for_outgoing ?? true,
attachment_limit: (props.accountData as any)?.attachment_limit || "",
append_emails_to_sent_folder:
(props.accountData as any)?.append_emails_to_sent_folder || false,
sent_folder_name: (props.accountData as any)?.sent_folder_name || "",
});
const customState = reactive<CustomEmailAccountState>(getInitialCustomState());
const info = {
description: __("To know more about setting up email accounts, click"),
link: "https://docs.frappe.io/erpnext/user/manual/en/email-domain",
link: "https://docs.erpnext.com/docs/user/manual/en/email-account",
};
const deskEditUrl = computed(() => {
const name = state.email_account_name || props.accountData.email_account_name;
if (!name) return "";
return `/desk/email-account/${encodeURIComponent(name)}`;
});
const currentServiceName = computed(
() => state.service || props.accountData.service || ""
);
const serviceDef = computed(() => {
const currentService = currentServiceName.value;
return services.find((s) => s.name === currentService);
});
const isCustomProvider = computed(() => {
const currentService = currentServiceName.value;
if (currentService === "Custom") {
return true;
}
if (currentService) {
return false;
}
// Many existing custom accounts store service as blank; detect by server fields.
return Boolean(
customState.email_server ||
customState.smtp_server ||
props.accountData.email_server ||
props.accountData.smtp_server
);
const isCustomService = computed(() => {
return services.find((s) => s.name === props.accountData.service).custom;
});
const fields = computed(() => {
if (isCustomProvider.value) {
return customProviderTopFields;
}
if (!serviceDef.value) {
return popularProviderFields;
}
if (serviceDef.value.name === "Frappe Mail") {
return frappeMailFields;
if (isCustomService.value) {
return customProviderFields;
}
return popularProviderFields;
});
@@ -323,16 +165,29 @@ const loadingPull = useStorage(
`loading-emails-${state.email_account_name}`,
false
);
async function updateAccount() {
const validationState = { ...state, ...customState };
const validationService = isCustomProvider.value
? "Custom"
: currentServiceName.value;
error.value = validateInputs(validationState, validationService, true);
error.value = validateInputs(state, isCustomService.value);
if (error.value) return;
const old = { ...props.accountData };
const updatedEmailAccount = { ...state };
if (nameChanged.value) {
const nameChanged =
old.email_account_name !== updatedEmailAccount.email_account_name;
delete old.email_account_name;
delete updatedEmailAccount.email_account_name;
const otherFieldsChanged = isDirty.value;
const values = updatedEmailAccount;
if (!nameChanged && !otherFieldsChanged) {
toast.create({
message: __("No changes made"),
icon: h(CircleAlert, { class: "text-ink-blue-2" }),
});
return;
}
if (nameChanged) {
try {
loading.value = true;
await callRenameDoc();
@@ -341,10 +196,10 @@ async function updateAccount() {
errorHandler();
}
}
if (isDirty.value) {
if (otherFieldsChanged) {
try {
loading.value = true;
await callSetValue(buildUpdatePayload());
await callSetValue(values);
succesHandler();
} catch (err) {
errorHandler();
@@ -352,44 +207,12 @@ async function updateAccount() {
}
}
function buildUpdatePayload() {
const commonPayload = {
email_id: state.email_id,
service: state.service,
enable_incoming: state.enable_incoming,
enable_outgoing: state.enable_outgoing,
default_incoming: state.default_incoming,
default_outgoing: state.default_outgoing,
};
if (currentServiceName.value === "Frappe Mail") {
return {
...commonPayload,
frappe_mail_site: state.frappe_mail_site,
api_key: state.api_key,
api_secret: state.api_secret,
};
}
if (isCustomProvider.value) {
return {
...commonPayload,
password: state.password,
...customState,
};
}
return {
...commonPayload,
password: state.password,
};
}
function pullEmails() {
loadingPull.value = true;
toast(__("Pulling emails, this may take a few minutes."), {
icon: () => h(CircleAlert, { class: "text-ink-blue-5" }),
toast.create({
message: __("Pulling emails, this may take a few minutes."),
icon: h(CircleAlert, { class: "text-blue-500" }),
});
call("frappe.email.doctype.email_account.email_account.pull_emails", {
@@ -407,41 +230,30 @@ function pullEmails() {
});
}
// Compare against the initial values rather than the account itself. The form
// normalises what the server sends (a 0 becomes false, a null becomes ""), so
// comparing straight to the account reported every custom account as changed.
// Renaming goes through a separate API, so it is tracked by nameChanged.
const isDirty = computed(() => {
const initialState = getInitialState();
const baseChanged = (
Object.keys(initialState) as (keyof EmailAccountProviderAuthState)[]
).some(
(field) =>
field !== "email_account_name" && state[field] !== initialState[field]
);
if (baseChanged || !isCustomProvider.value) return baseChanged;
const initialCustomState = getInitialCustomState();
return (
Object.keys(initialCustomState) as (keyof CustomEmailAccountState)[]
).some((field) => customState[field] !== initialCustomState[field]);
state.email_id !== props.accountData.email_id ||
state.api_key !== props.accountData.api_key ||
state.api_secret !== props.accountData.api_secret ||
state.password !== props.accountData.password ||
state.enable_incoming !== props.accountData.enable_incoming ||
state.enable_outgoing !== props.accountData.enable_outgoing ||
state.default_outgoing !== props.accountData.default_outgoing ||
state.default_incoming !== props.accountData.default_incoming ||
state.frappe_mail_site !== props.accountData.frappe_mail_site
);
});
const nameChanged = computed(
() => state.email_account_name !== getInitialState().email_account_name
);
const hasChanges = computed(() => nameChanged.value || isDirty.value);
async function callRenameDoc() {
return call("frappe.client.rename_doc", {
const d = await call("frappe.client.rename_doc", {
doctype: "Email Account",
old_name: props.accountData.email_account_name,
new_name: state.email_account_name,
});
return d;
}
async function callSetValue(values: Record<string, any>) {
async function callSetValue(values) {
const d = await call("frappe.client.set_value", {
doctype: "Email Account",
name: state.email_account_name,
@@ -459,86 +271,4 @@ function errorHandler() {
loading.value = false;
error.value = __("Failed to update email account, Invalid credentials");
}
watch(
() => props.accountData,
(val) => {
if (!val) return;
if (val.email_id && state.email_id !== val.email_id) {
state.email_id = val.email_id;
}
if (!customState.domain && val.domain) {
customState.domain = val.domain;
}
},
{ deep: true, immediate: true }
);
watch(
() => customState.domain,
async (val) => {
if (!isCustomProvider.value) return;
if (!val) return;
try {
const doc = await call("frappe.client.get", {
doctype: "Email Domain",
name: val,
});
syncAccountFieldsFromDomain(doc);
} catch (err) {
console.warn("Failed to load Email Domain", err);
}
}
);
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
customState.email_server = payload.email_server ?? customState.email_server;
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
customState.incoming_port =
payload.incoming_port ?? customState.incoming_port;
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
customState.use_starttls = Boolean(
payload.use_starttls ?? customState.use_starttls
);
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
customState.use_ssl_for_outgoing = Boolean(
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
);
customState.validate_ssl_certificate = Boolean(
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
);
customState.validate_ssl_certificate_for_outgoing = Boolean(
payload.validate_ssl_certificate_for_outgoing ??
customState.validate_ssl_certificate_for_outgoing
);
customState.append_emails_to_sent_folder = Boolean(
payload.append_emails_to_sent_folder ??
customState.append_emails_to_sent_folder
);
customState.sent_folder_name =
payload.sent_folder_name || customState.sent_folder_name;
customState.attachment_limit =
payload.attachment_limit || customState.attachment_limit;
}
function handleCreateDomainClick(value: any, close?: () => void) {
close?.();
window.open(
"/desk/email-domain/new-email-domain",
"_blank",
"noopener,noreferrer"
);
}
function handleEditDomainClick() {
if (!customState.domain) return;
window.open(
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
"_blank",
"noopener,noreferrer"
);
}
</script>
<style scoped></style>

View File

@@ -18,9 +18,8 @@
<script setup lang="ts">
import { ref } from "vue";
import Notification from "./Notification.vue";
import { createResource, toast } from "frappe-ui";
import { createResource } from "frappe-ui";
import type { BaseSettings, Notification as NotificationType } from "./types";
import { __ } from "@/translation";
const props = defineProps<{
onBack: () => void;
@@ -44,7 +43,6 @@ const updateSettings = createResource({
});
function onSubmit() {
toast.success(__("Settings updated successfully."));
return updateSettings.submit({
enabled: enabled.value,
content: content.value,

View File

@@ -3,21 +3,27 @@
<template #title>
<div class="flex items-center">
<div
class="ps-5 pe-2 relative text-ink-gray-7 hover:opacity-70 min-h-8 flex items-center"
class="pl-5 pr-2 relative text-ink-gray-7 hover:opacity-70 min-h-8 flex items-center"
>
<button
type="button"
@click="internalOnBack"
class="absolute top-0 -start-[0.375rem] w-full h-full"
class="absolute top-0 -left-[0.375rem] w-full h-full"
>
<span class="sr-only">{{ __("back to email event list") }}</span>
<LucideChevronLeft class="w-4.5 h-4.5 rtl:rotate-180" />
<LucideChevronLeft class="w-4.5 h-4.5" />
</button>
<h1 class="text-xl-semibold">
<h1 class="font-semibold text-xl">
{{ props.title }}
</h1>
</div>
<UnsavedBadge :show="unsavedChanges" />
<Badge
v-if="unsavedChanges"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
</div>
</template>
<template #header-actions>
@@ -30,9 +36,9 @@
size="sm"
:label="__('Enabled')"
v-model="enabled"
@update:model-value="(val) => setUnsavedChanges(val)"
@update:model-value="setUnsavedChanges"
:style="{ background: 'transparent', padding: '0px' }"
class="flex-row-reverse gap-x-2 ps-0"
class="flex-row-reverse gap-x-2 pl-0"
/>
<Button
type="button"
@@ -62,12 +68,10 @@
:required="true"
:rows="10"
v-model="content"
@update:model-value="
(value) => setUnsavedChanges(undefined, value)
"
:oninput="setUnsavedChanges"
/>
<div class="flex gap-x-1 items-start justify-between">
<p class="text-sm text-ink-gray-7 leading-5">
<p class="text-sm text-gray-700 leading-5">
{{
__(
"Find out all of the variables that can be used in the content"
@@ -131,11 +135,10 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import type { NotificationName } from "./types";
import { createResource, Switch, LoadingIndicator, Button } from "frappe-ui";
import { createResource, Switch, LoadingIndicator } from "frappe-ui";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import UnsavedBadge from "@/components/UnsavedBadge.vue";
const props = defineProps<{
title: string;
@@ -153,7 +156,6 @@ const content = defineModel<string>("content", { required: true });
const enabled = defineModel<boolean>("enabled", { required: true });
const unsavedChanges = ref(false);
const slotFieldChanged = ref(false);
const showUnsavedConfirm = ref(false);
const showContentChangeConfirm = ref(false);
@@ -167,32 +169,14 @@ const notificationDataResource = createResource({
onSuccess: props.onGetDataSuccess,
});
function setUnsavedChanges(
newEnabled = enabled.value,
newContent = content.value
) {
unsavedChanges.value =
slotFieldChanged.value ||
newEnabled !== notificationDataResource.data.enabled ||
newContent !== notificationDataResource.data.content;
}
/** For fields a notification adds through `#formFields`, which this component
* cannot diff itself. */
function markUnsavedChanges() {
slotFieldChanged.value = true;
function setUnsavedChanges() {
unsavedChanges.value = true;
}
function resetUnsavedChanges() {
notificationDataResource.data = {
...notificationDataResource.data,
enabled: enabled.value,
content: content.value,
};
slotFieldChanged.value = false;
unsavedChanges.value = false;
}
function resetContent() {
content.value = props.defaultContent;
setUnsavedChanges();
@@ -222,7 +206,6 @@ watch(
defineExpose({
setUnsavedChanges,
markUnsavedChanges,
resetUnsavedChanges,
});
</script>

View File

@@ -8,49 +8,48 @@
"
>
<template #content>
<ul class="isolate -ms-3">
<div
v-for="(notification, index) in notifications"
<ul class="isolate -ml-3">
<li
v-for="notification in notifications"
:key="notification.name"
class="flex items-center justify-between p-3 rounded relative"
>
<li class="flex items-center justify-between p-3 rounded-4 relative">
<div class="flex flex-col gap-1">
<h2
class="text-base-medium text-ink-gray-7 relative z-10 pointer-events-none"
>
{{ __(notification.label) }}
</h2>
<p
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
>
{{ __(notification.description) }}
</p>
</div>
<LucideChevronRight
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none rtl:rotate-180"
/>
<div
class="w-full h-full absolute top-0 start-0 hover:bg-surface-sidebar rounded-[inherit]"
@click="
() => {
props.onSelect(notification);
}
"
<div class="flex flex-col gap-1">
<h2
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
>
<span class="sr-only">{{
__("customize {0}", notification.name)
}}</span>
</div>
</li>
<hr v-if="index < notifications.length - 1" class="mx-2" />
</div>
{{ notification.label }}
</h2>
<p
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
>
{{ notification.description }}
</p>
</div>
<FeatherIcon
name="chevron-right"
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
/>
<button
type="button"
class="w-full h-full absolute top-0 left-0 hover:bg-gray-50 rounded-[inherit]"
@click="
() => {
props.onSelect(notification);
}
"
>
<span class="sr-only">{{
__("customize {0}", notification.name)
}}</span>
</button>
</li>
</ul>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import LucideChevronRight from "~icons/lucide/chevron-right";
import { __ } from "@/translation";
import type { AtLeastOneNotifcation, Notification } from "./types";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
@@ -64,26 +63,26 @@ const notifications: AtLeastOneNotifcation = [
name: "share_feedback",
label: __("Share feedback"),
description: __(
"Sent to the user who has raised the ticket after the ticket is closed or resolved."
"Sent to the user who has raised the ticket after the ticket is closed or resolved"
),
},
{
name: "acknowledgement",
label: __("Acknowledgement"),
description: __("Sent to the user right after creating an email ticket."),
description: __("Sent to the user right after creating an email ticket"),
},
{
name: "reply_to_agents",
label: __("Reply from contact"),
description: __(
"Sent to all of the agents assigned to the ticket whenever a contact has replied."
"Sent to all of the assigned agents after a reply from one of the contacts"
),
},
{
name: "reply_via_agent",
label: __("Reply from agent"),
description: __(
"Sent to all of the recipients associated with the ticket whenever an agent has replied."
"Sent to all of the recipients associated with an agent's reply"
),
},
];

View File

@@ -18,9 +18,8 @@
<script setup lang="ts">
import { ref } from "vue";
import Notification from "./Notification.vue";
import { createResource, toast } from "frappe-ui";
import { createResource } from "frappe-ui";
import type { BaseSettings, Notification as NotificationType } from "./types";
import { __ } from "@/translation";
const props = defineProps<{
onBack: () => void;
@@ -44,8 +43,6 @@ const updateSettings = createResource({
});
function onSubmit() {
toast.success(__("Settings updated successfully."));
return updateSettings.submit({
enabled: enabled.value,
content: content.value,

View File

@@ -18,9 +18,8 @@
<script setup lang="ts">
import { ref } from "vue";
import Notification from "./Notification.vue";
import { createResource, toast } from "frappe-ui";
import { createResource } from "frappe-ui";
import type { BaseSettings, Notification as NotificationType } from "./types";
import { __ } from "@/translation";
const props = defineProps<{
onBack: () => void;
@@ -44,7 +43,6 @@ const updateSettings = createResource({
});
function onSubmit() {
toast.success(__("Settings updated successfully."));
return updateSettings.submit({
enabled: enabled.value,
content: content.value,

View File

@@ -15,17 +15,16 @@
>
<template #formFields>
<FormControl
type="combobox"
type="autocomplete"
size="sm"
:label="__('On ticket status')"
:label="__('On Ticket Status')"
:options="statusOptions"
:required="true"
:model-value="ticketStatus?.value ?? null"
:model-value="ticketStatus"
@update:model-value="
(value) => {
ticketStatus =
statusOptions.find((option) => option.value === value) ?? null;
compRef.markUnsavedChanges();
(val) => {
ticketStatus = val;
compRef.setUnsavedChanges();
}
"
/>
@@ -36,7 +35,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import Notification from "./Notification.vue";
import { createResource, toast } from "frappe-ui";
import { createResource } from "frappe-ui";
import type { BaseSettings, Notification as NotificationType } from "./types";
import { useTicketStatusStore } from "@/stores/ticketStatus";
import { __ } from "@/translation";
@@ -75,7 +74,6 @@ const updateSettings = createResource({
});
function onSubmit() {
toast.success(__("Settings updated successfully."));
return updateSettings.submit({
ticket_status: ticketStatus.value,
enabled: enabled.value,

Some files were not shown because too many files have changed in this diff Show More