Compare commits

..

923 Commits

Author SHA1 Message Date
RitvikSardana
794c7b0895 Merge pull request #3841 from frappe/ci/pilot-prebuilt-assets
ci: Publish prebuilt assets for Pilot
2026-10-06 17:34:15 +05:30
RitvikSardana
85d7d17f8d Merge pull request #3840 from RitvikSardana/fix/filter-paste-in-values
fix(filter): select every value pasted into the in filter
2026-10-05 14:00:23 +05:30
Ritvik Sardana
e08dd865b8 fix(filter): keep typed text when closing the filter, drop it only on clear all 2026-10-05 12:27:14 +05:30
Ritvik Sardana
a2d1c45cb4 fix(filter): drop typed text when closing the filter or switching operators 2026-10-05 12:20:46 +05:30
Ritvik Sardana
a0eb9ce5bf fix(filter): apply typed values before leaving the filter 2026-10-05 12:14:05 +05:30
Tanmoy Sarkar
dbc5d90bda ci: Publish prebuilt assets for Pilot
Build the app's assets in CI for each push and publish them on the release assets-<branch>, so Pilot benches download them instead of building on the server.
2026-10-05 12:12:56 +05:30
Ritvik Sardana
e2682b9a45 test(filter): cover pasting lists and every field type 2026-10-05 12:10:54 +05:30
Ritvik Sardana
e21dedd9ba fix(filter): keep the filter open when switching operators 2026-10-05 12:10:52 +05:30
Ritvik Sardana
9ade3ce18e fix(filter): clear the search after a paste and drop empty in lists 2026-10-05 11:45:02 +05:30
RitvikSardana
e938163cca Merge pull request #3839 from RitvikSardana/fix/customer-portal-label-translations
fix: show translated field labels in the customer portal
2026-10-05 11:44:02 +05:30
RitvikSardana
ab18470596 Merge pull request #3836 from frappe/pot_develop_2026-10-04
chore: update POT file
2026-10-05 11:29:50 +05:30
Ritvik Sardana
cd542d452a fix(filter): close the filter when clearing all filters 2026-10-05 11:24:56 +05:30
Ritvik Sardana
e9b92254a7 fix(filter): show the first three values and a count for long lists 2026-10-05 11:24:55 +05:30
Ritvik Sardana
7a2d47fbee fix(filter): select every value pasted into the in filter 2026-10-05 11:24:52 +05:30
Ritvik Sardana
3a575e21e5 fix: show translated field labels in the customer portal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:54:32 +05:30
Sydney Gomes
fb19d721c8 Merge pull request #3815 from aerodeval/fix/user-email-permlevel-reset
fix(profile): save agent email links through the helpdesk api
2026-10-05 02:02:07 +05:30
Sydney
ee9c0ff8ba Merge remote-tracking branch 'upstream/develop' into fix/user-email-permlevel-reset
# Conflicts:
#	helpdesk/test_utils.py
2026-10-05 01:46:36 +05:30
Sydney
88dd98da71 test(profile): move email link helpers to test_utils 2026-10-05 01:36:37 +05:30
Sydney
57cbd6e121 fix(profile): keep unsaved email links and skip needless user save 2026-10-05 01:18:45 +05:30
Sydney Gomes
0ec451d274 Merge pull request #3823 from kaulith/fix/dashboard-sla-trend-percentage
fix(dashboard): ticket trend sla line shows a count, not a percentage
2026-10-04 22:43:42 +05:30
frappe-pr-bot
2658d10d3a chore: update POT file 2026-10-04 15:27:12 +00:00
RitvikSardana
be13e430dc Merge pull request #3820 from RitvikSardana/fix/pinned-jump-shift
fix: small layout fixes on the ticket page
2026-10-02 16:20:47 +05:30
Ritvik Sardana
3f89ecb03b fix: fade the timeline top instead of cutting it when there are no pins 2026-10-02 02:22:46 +05:30
Kaushal Shriwas
cac55d63d1 test(dashboard): format the sla trend test 2026-10-01 18:32:31 +05:30
Kaushal Shriwas
9643e178dc test(dashboard): freeze the clock in the sla trend test 2026-10-01 18:29:35 +05:30
Kaushal Shriwas
cb63d5b19f test(dashboard): sla line on ticket trend plots a percentage 2026-10-01 18:18:54 +05:30
Kaushal Shriwas
b674f9a068 fix(dashboard): ticket trend sla line shows a count, not a percentage 2026-10-01 18:18:51 +05:30
Ritvik Sardana
343dbb7490 test(e2e): stop the cc reply test from losing keystrokes to the cc picker 2026-10-01 15:07:12 +05:30
Ritvik Sardana
e95a63befd fix: match the recent tickets spacing to the other sidebar sections 2026-10-01 13:45:48 +05:30
Ritvik Sardana
63098d52cb fix(feedback): align show more with the comment text 2026-10-01 13:45:47 +05:30
Ritvik Sardana
a3546ecfa5 fix(pinned): stop the timeline from shifting after jumping to a pinned comment 2026-10-01 13:14:29 +05:30
Sydney Gomes
516700c45f Merge pull request #3819 from aerodeval/refactor/banners-composable
refactor(banners)!: serve sidebar banners from one API
2026-10-01 02:04:57 +05:30
Sydney
661e636f40 refactor(ticket): rename apply_portal_insert_rules to apply_ticket_insert_rules 2026-10-01 01:03:30 +05:30
Sydney
9615e9d465 chore: skip semgrep guest check on get_config 2026-10-01 01:01:30 +05:30
Sydney
c49341c740 fix(banners): reword the custom agent roles hint 2026-10-01 01:01:29 +05:30
Sydney
41b4b7d612 test(banners): move helpers to test_utils 2026-10-01 01:01:28 +05:30
Sydney
41ff44ecfc fix(banners): clear the promotion choice when the dialog closes 2026-10-01 01:01:27 +05:30
Sydney
aeefe064a4 fix(banners): treat a missing notice flag as off 2026-09-30 22:58:10 +05:30
Sydney
61e60518bd fix(banners): simplify field permission notice text 2026-09-30 22:42:30 +05:30
Sydney
66c8d6208c refactor(banners)!: serve sidebar banners from one API 2026-09-30 22:22:20 +05:30
Sydney Gomes
21fa9cdf6a Merge pull request #3544 from aerodeval/fix/ticket-field-level-perms
fix!: field-level permissions for agent-only ticket fields
2026-09-30 16:37:45 +05:30
Sydney
8642c3a699 test(e2e): cover the field permission notice and template field visibility 2026-09-30 15:54:57 +05:30
Sydney
e0e041dea7 fix(perms): hide the field permission notice in every open tab once dismissed 2026-09-30 15:54:56 +05:30
Sydney Gomes
c1d0010c4a Merge pull request #3814 from aerodeval/fix/sent-email-author-name
fix(ticket): name a reply sent from a shared email after the agent
2026-09-30 15:27:03 +05:30
Sydney
85d7d2a87f Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms
# Conflicts:
#	e2e/tests/customer/portal.spec.ts
2026-09-30 15:21:48 +05:30
RitvikSardana
7f54082cfa Merge pull request #3817 from RitvikSardana/refactor/e2e-page-layout
refactor(e2e): group specs by page and shard UI tests
2026-09-30 14:59:14 +05:30
Sydney
d6ce0e15bb fix(perms): read the field permission notice flag outside the guest config 2026-09-30 14:56:25 +05:30
Sydney
e7dd63887e feat(perms): tell managers once that ticket fields follow permission levels 2026-09-30 14:26:34 +05:30
Ritvik Sardana
4044a1a309 ci(e2e): shard Playwright tests across two runners 2026-09-30 14:24:25 +05:30
Ritvik Sardana
0a538f3274 docs(e2e): describe the page based layout 2026-09-30 14:23:55 +05:30
Ritvik Sardana
c1beb6d14c refactor(e2e): split portal spec by page 2026-09-30 14:23:37 +05:30
Ritvik Sardana
b6775d0543 refactor(e2e): group specs by page instead of role 2026-09-30 14:23:06 +05:30
RitvikSardana
611fbfc558 Merge pull request #3813 from RitvikSardana/feat/pinned-comments
feat(ticket): pinned comments
2026-09-30 14:14:24 +05:30
Ritvik Sardana
ba0e0a7b8f fix(pinned): keep the scroll helper doc comment to two lines 2026-09-30 13:30:57 +05:30
Ritvik Sardana
0b24393480 fix: pick the typed contact when enter is pressed before search results load 2026-09-30 13:26:38 +05:30
Sydney
40cd3bea08 fix(perms): grant level 7 to every role that reads the ticket 2026-09-30 13:13:04 +05:30
Ritvik Sardana
f1b3f9ac25 docs: remove the why-only comment rule from AGENTS.md 2026-09-30 12:51:50 +05:30
Ritvik Sardana
081f7c6241 fix(template): disable renaming ticket templates on the doctype 2026-09-30 04:07:37 +05:30
Ritvik Sardana
ab8d0e85d2 fix(perms): let customers close a ticket but not resolve it 2026-09-30 03:47:22 +05:30
Ritvik Sardana
33ced6aba0 refactor(ticket): move customer edit rules into CustomerEditController 2026-09-30 03:47:09 +05:30
Ritvik Sardana
27dec9adf8 refactor(perms): move write rules to HDTicket and name TicketFields for what it does 2026-09-30 03:26:42 +05:30
Sydney
b372f0a7f8 fix(profile): save agent email links through the helpdesk api
User Emails is permlevel 1, so agents never got it from frappe.client.get
and their saves were silently reverted. The settings screen now reads the
list from get_current_user_email_info and saves it through a new agent-only
set_user_email_accounts endpoint, which only touches outgoing accounts.
2026-09-30 03:06:26 +05:30
Sydney
b80f3b9995 fix(ticket): name a reply sent from a shared email after the agent 2026-09-30 02:31:36 +05:30
Sydney
bfd5bd45f1 fix(perms): return readable fields from new and honour the opened template 2026-09-30 01:42:28 +05:30
Sydney
22a581ff5f chore(ticket): remove unused attachment and content_type fields 2026-09-30 00:50:11 +05:30
Sydney
b9c9d9d187 Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms
# Conflicts:
#	helpdesk/api/doc.py
2026-09-30 00:39:32 +05:30
Ritvik Sardana
3304889e39 fix(pinned): keep the pinned bar doc comment to two lines 2026-09-30 00:06:46 +05:30
RitvikSardana
13a813c703 Merge pull request #3812 from RitvikSardana/test/api-coverage
test(api): cover untested API modules and fix what they found
2026-09-30 00:02:46 +05:30
Ritvik Sardana
285b82d76e test(e2e): close invite suggestions with Escape instead of an outside click 2026-09-29 23:51:12 +05:30
Ritvik Sardana
5b32269e81 fix(pinned): remove the comment that restates the bar transition 2026-09-29 23:30:00 +05:30
Ritvik Sardana
11a9f56d5a refactor(article): rename only_published to filter_public_articles 2026-09-29 23:23:04 +05:30
Ritvik Sardana
4257a0b809 refactor(email): move service name map above its caller 2026-09-29 23:23:04 +05:30
Ritvik Sardana
8572b0b5e4 fix(pinned): make the pinned badge gray 2026-09-29 23:11:02 +05:30
Ritvik Sardana
d76264b05d fix(pinned): float the pinned bar over the activity 2026-09-29 23:11:00 +05:30
Ritvik Sardana
4a2f1d19a6 fix: dont carry pinned comments from the source ticket while merging 2026-09-29 22:45:29 +05:30
Ritvik Sardana
6fd24be817 refactor(ticket): drop the activity header and move call actions to the tab row 2026-09-29 22:19:33 +05:30
Sydney
f9b9248d45 fix(perms): keep the email sender when a system manager pulls emails 2026-09-29 21:56:47 +05:30
Ritvik Sardana
7e94cef894 fix(pinned): show pinned comment with badge instead of just icon 2026-09-29 21:20:56 +05:30
Ritvik Sardana
d3b2f61b09 refactor(ticket): read pinned comments from the comment extras 2026-09-29 21:05:03 +05:30
Ritvik Sardana
0aa6e01571 fix(ticket): pass the class through to the activity heading 2026-09-29 21:05:01 +05:30
Ritvik Sardana
f72ebdecd6 refactor(ticket): keep the pin marker as a plain indicator 2026-09-29 21:04:59 +05:30
Sydney
8b9c901f8c fix(perms): address review feedback 2026-09-29 19:42:12 +05:30
Sydney
bc77387f58 fix(template): match template fieldnames case-sensitively 2026-09-29 15:38:06 +05:30
Sydney
b3885c16e3 refactor(template): rename Customer can edit to Editable after creation 2026-09-29 14:33:21 +05:30
Ritvik Sardana
61838ed0dc test(e2e): cover unpinning from the marker and the five pin limit 2026-09-29 14:13:54 +05:30
Ritvik Sardana
c3c28d6b3b fix(ticket): stretch the pinned bar track only past two pins 2026-09-29 14:13:52 +05:30
Ritvik Sardana
bedbdba511 fix(icons): cut the unpin slash out of the pin instead of painting over it 2026-09-29 14:13:51 +05:30
Ritvik Sardana
2e62e31e0c feat(ticket): unpin a comment from its pin marker 2026-09-29 14:13:49 +05:30
Sydney
d735d1d4b3 test: restore every template column after permission tests 2026-09-29 14:12:19 +05:30
Sydney
744f7133d9 fix(template): widen the Customer can edit column 2026-09-29 14:06:02 +05:30
Ritvik Sardana
7617ab7bfd test(e2e): stop ticket list tests racing the default view autosave 2026-09-29 13:59:59 +05:30
Ritvik Sardana
7127d2aca5 test(e2e): close invite suggestions before clicking Invite 2026-09-29 13:59:59 +05:30
Ritvik Sardana
070e01720d test(email): reject bad credentials when incoming is off 2026-09-29 13:57:16 +05:30
Ritvik Sardana
ab3caff2d0 fix(email): check credentials for every service with an incoming server 2026-09-29 13:57:16 +05:30
Ritvik Sardana
c8c140a9d6 docs(agents): allow get_all for child tables and guard it elsewhere 2026-09-29 13:57:16 +05:30
Ritvik Sardana
634784840e test: move api test helpers into test_utils 2026-09-29 13:57:16 +05:30
Ritvik Sardana
923ee81055 docs(tests): keep all test helpers in test_utils 2026-09-29 13:57:16 +05:30
Ritvik Sardana
24e2773198 fix(email): show provider icons for Frappe's service names 2026-09-29 13:57:16 +05:30
Ritvik Sardana
e087af08b2 fix(article): check published articles with get_list 2026-09-29 13:57:16 +05:30
Ritvik Sardana
b3158671ee test(settings): cover email accounts and email notifications 2026-09-29 13:57:16 +05:30
Ritvik Sardana
a0e6ece014 test(portal-notice): cover dismissing and restoring ticket access 2026-09-29 13:57:16 +05:30
Ritvik Sardana
8a63cf6f63 test(article): cover article stats and search 2026-09-29 13:57:16 +05:30
Ritvik Sardana
f5e0598738 test(doc): cover list data, filters and filterable fields 2026-09-29 13:57:16 +05:30
Ritvik Sardana
b867e9dd82 fix(email): save Outlook, Yahoo and Yandex under Frappe's service names 2026-09-29 13:57:16 +05:30
Ritvik Sardana
9d0aa81004 fix(email-notifications): validate the feedback status before saving 2026-09-29 13:57:16 +05:30
Ritvik Sardana
ea2dd0819a fix(email): create outgoing-only email accounts without an incoming check 2026-09-29 13:57:16 +05:30
Ritvik Sardana
b064ae303f fix(portal-notice): dismiss the notice when ticket access is restored 2026-09-29 13:57:16 +05:30
Ritvik Sardana
3f248973ad fix(article): hide stats of unpublished articles from non-agents 2026-09-29 13:57:16 +05:30
Ritvik Sardana
11f7531346 fix(article): keep unpublished articles out of search results 2026-09-29 13:57:16 +05:30
Ritvik Sardana
029f319454 fix(doc): check permissions before the cached filterable fields 2026-09-29 13:57:16 +05:30
Ritvik Sardana
554a76dd97 fix(doc): stop customers removing ticket assignments 2026-09-29 13:57:16 +05:30
Ritvik Sardana
0d401ccf69 fix(doc): apply @me and assigned on to a view's default filters 2026-09-29 13:57:16 +05:30
Ritvik Sardana
b5bdc783bb fix(doc): replace every @me in list and nested filters 2026-09-29 13:57:16 +05:30
Ritvik Sardana
c2dec3ff19 fix(doc): compare the "assigned on" filter by whole days 2026-09-29 13:57:16 +05:30
Ritvik Sardana
20a7422cca test(dashboard): cover the tag charts 2026-09-29 13:57:16 +05:30
Ritvik Sardana
810c47681d test(assignment-rule): cover the rules list and who may read it 2026-09-29 13:57:16 +05:30
Ritvik Sardana
cb2e008c75 fix(assignment-rule): list only ticket assignment rules 2026-09-29 13:57:16 +05:30
Ritvik Sardana
caf769a1cc test: move api tests to helpdesk/tests/api 2026-09-29 13:57:16 +05:30
Ritvik Sardana
20e12d68da docs(tests): add backend test conventions 2026-09-29 13:57:16 +05:30
Sydney
3fbd0587ac feat(template): let a template row open a field to customer edits 2026-09-29 13:41:52 +05:30
Ritvik Sardana
bfd00a53a9 fix(ticket): group the pinned bar icon with its text 2026-09-29 13:41:35 +05:30
Ritvik Sardana
6c1b0c380c refactor(comments): drop the row lock around the pin limit check 2026-09-29 13:27:09 +05:30
Ritvik Sardana
336ad9383e feat(ticket): disable pinning once a ticket has five pins 2026-09-29 13:27:08 +05:30
Ritvik Sardana
6ce7553230 test(e2e): cover pinning comments from the timeline 2026-09-29 13:11:06 +05:30
Ritvik Sardana
a7514171da feat(ticket): pin comments to a bar above the timeline 2026-09-29 13:11:04 +05:30
Ritvik Sardana
a89e4c67a0 feat(icons): redraw the pin and unpin icons from Figma 2026-09-29 13:11:03 +05:30
Ritvik Sardana
53f95b78c6 feat(comments): cap pinned comments at five per ticket 2026-09-29 13:11:01 +05:30
Sydney
df721618f3 fix(perms): say permission, not timing, when a customer cannot write a field 2026-09-29 13:07:10 +05:30
Sydney
870be0ebb8 fix(perms): refuse a customer's edit instead of silently reverting it 2026-09-29 12:38:17 +05:30
RitvikSardana
483f718012 Merge pull request #3811 from RitvikSardana/test/e2e-customers-contacts
test(e2e): cover customers and contacts, fix manager edits on user contacts
2026-09-29 11:58:53 +05:30
Sydney
9f128ba5b7 fix(template): link the helpdesk permission levels guide 2026-09-29 11:28:29 +05:30
Ritvik Sardana
6ed1c9ac6b fix(contact): keep a user's photo and timezone when an edit omits them 2026-09-29 11:17:44 +05:30
Ritvik Sardana
7b5d30bab4 test(e2e): cover customers and contacts for managers and agents 2026-09-29 11:01:42 +05:30
Ritvik Sardana
1bf173ed19 fix(contact): let agent managers edit a contact that has a user 2026-09-29 11:01:41 +05:30
RitvikSardana
488992b1b2 Merge pull request #3810 from RitvikSardana/feat/playwright-e2e
test(e2e): Playwright suite for the core user journeys
2026-09-29 03:13:01 +05:30
Ritvik Sardana
cfeabb11b3 docs(e2e): document how to write a spec 2026-09-29 02:52:09 +05:30
Ritvik Sardana
73e030549e test(e2e): add a ticket fixture for tests that need any one ticket 2026-09-29 02:52:09 +05:30
Ritvik Sardana
1810916414 refactor(e2e): group specs into area folders under tests/ 2026-09-29 02:45:49 +05:30
Ritvik Sardana
d77599f4a0 test(e2e): cover every non-integration settings page 2026-09-29 02:40:56 +05:30
Ritvik Sardana
eb858b00f6 fix(profile): give the remove email button an accessible name 2026-09-29 02:40:56 +05:30
Ritvik Sardana
a5dcd7ee42 fix(holiday): open the calendar on the list's first year 2026-09-29 02:40:55 +05:30
Ritvik Sardana
c1f0016bcf fix(holiday): count recurring weeks as the nth weekday of the month 2026-09-29 02:40:54 +05:30
Ritvik Sardana
dd311d8d29 fix(editor): let a mouse click pick a field suggestion 2026-09-29 02:25:12 +05:30
Ritvik Sardana
e7cc710016 fix(settings): revert only the toggles a failed save sent 2026-09-29 01:49:23 +05:30
Sydney
3cc8c89ddb Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms
# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.json
#	helpdesk/helpdesk/doctype/hd_ticket/test_hd_ticket.py
2026-09-29 01:42:49 +05:30
Ritvik Sardana
1cc683cd14 fix(settings): keep unsaved edits on a failed toggle and hide other teams' assignees 2026-09-29 01:39:30 +05:30
Ritvik Sardana
8d8f9e57d4 fix(merge): close the merge dialog when a lost response still merged 2026-09-29 01:27:31 +05:30
Ritvik Sardana
c9e0becfc8 fix(merge): reject merging a ticket that is already merged 2026-09-29 01:27:31 +05:30
Ritvik Sardana
0915d78f2e test: limit bulk_reply cleanup to the files it attached 2026-09-29 01:27:31 +05:30
Ritvik Sardana
0081152d42 fix(merge): move emails and every attachment when an agent merges 2026-09-29 01:27:31 +05:30
Ritvik Sardana
085eedc314 test(e2e): cover saved views end to end 2026-09-29 00:42:06 +05:30
Ritvik Sardana
bf9023d2ce test(e2e): run the browser in UTC so timezone bugs surface locally 2026-09-29 00:31:55 +05:30
Ritvik Sardana
ccdd68d4ff ci(e2e): serve the site on localhost and build frappe assets 2026-09-29 00:31:55 +05:30
Ritvik Sardana
25b354fbbe fix(list): show datetime columns in the viewer's timezone 2026-09-29 00:31:54 +05:30
Ritvik Sardana
6a6fe28645 test(e2e): cover the core agent, admin and customer journeys 2026-09-29 00:13:03 +05:30
Ritvik Sardana
4d2424d326 fix(assignment): offer only team members when assignment is restricted to the team 2026-09-29 00:12:26 +05:30
Ritvik Sardana
09c9d96313 fix(settings): save general toggles on their own and one at a time 2026-09-29 00:12:25 +05:30
Ritvik Sardana
c61c30355f fix(onboarding): complete the invite agents step on invite 2026-09-29 00:12:25 +05:30
Ritvik Sardana
f5fa276383 fix(editor): treat a body with only an image as content 2026-09-29 00:12:25 +05:30
Ritvik Sardana
9ebf9f577d fix(form-script): read createToast duration in seconds 2026-09-29 00:12:17 +05:30
Ritvik Sardana
faadfa79a9 test(e2e): document local runs and keep persona passwords stable 2026-09-28 23:25:15 +05:30
Ritvik Sardana
75e698ea9b test(e2e): add Playwright setup, personas and CI workflow 2026-09-28 23:23:34 +05:30
Ritvik Sardana
8650d04715 fix(router): send customers from an agent ticket link to their ticket 2026-09-28 23:23:34 +05:30
RitvikSardana
f2641606a6 Merge pull request #3803 from frappe/pot_develop_2026-09-27
chore: update POT file
2026-09-28 20:32:45 +05:30
RitvikSardana
b273218637 Merge pull request #3807 from RitvikSardana/fix/sla-paused-flips-to-failed
fix(sla): keep a ticket paused past its deadlines while on hold
2026-09-28 20:32:30 +05:30
Ritvik Sardana
4b67db0dd8 test(sla): cover a resolution breach from before the hold 2026-09-28 20:18:09 +05:30
Ritvik Sardana
56cadd897a docs(agents): cap comments at two lines 2026-09-28 19:28:38 +05:30
Ritvik Sardana
9ca4b1098b fix(sla): keep a ticket paused past its deadlines while on hold 2026-09-28 19:28:37 +05:30
RitvikSardana
208e32eb00 Merge pull request #3806 from RitvikSardana/fix/agent-ticket-perms
fix(perms): agents & tickets
2026-09-28 19:19:54 +05:30
RitvikSardana
25ee45e4c3 Merge pull request #3805 from RitvikSardana/fix/timeline-optimistic-updates
fix(ticket): pending row avatar and comment delete toast
2026-09-28 19:13:33 +05:30
Ritvik Sardana
6e7d5dabb9 fix(perms): agents & tickets 2026-09-28 18:14:59 +05:30
Ritvik Sardana
63fffa9843 fix(ticket): take the pending row's name and avatar from the session 2026-09-28 18:10:33 +05:30
Sydney Gomes
d05e5b5d4f Merge pull request #3800 from aerodeval/fix/docker-image-missing-apps
fix(ci): include helpdesk and telephony in docker image
2026-09-28 18:08:43 +05:30
Sydney
f53fecc699 fix(ci): match hrms job permissions 2026-09-28 17:43:40 +05:30
Sydney
2c189b3c93 fix(ci): build with frappe_docker main 2026-09-28 17:39:28 +05:30
frappe-pr-bot
c304643b86 chore: update POT file 2026-09-27 15:13:16 +00:00
Sydney
e0f2398a31 fix(ci): include helpdesk and telephony in docker image
frappe_docker's layered Containerfile dropped the APPS_JSON_BASE64
build-arg in ae275df1 and now reads apps.json from a BuildKit secret.
The ignored build-arg made every image since then ship with frappe
only, while the build still passed.

Pin frappe_docker to a release, check the image contains helpdesk and
telephony before pushing, and drop the arch matrix, which built amd64
twice and raced both images onto the same tags.

Fixes #3227
2026-09-26 03:12:08 +05:30
Sydney Gomes
bf92d47c2c Merge pull request #3776 from aerodeval/fix/sla-timezone-and-hold-time
fix(sla): correct SLA deadline timezone and hold-time deduction
2026-09-26 02:31:18 +05:30
Sydney Gomes
ae8c540013 Merge pull request #3792 from aerodeval/fix/skip-persona-landing
fix(onboarding): open the welcome ticket after skipping the questionnaire
2026-09-26 02:11:21 +05:30
Sydney Gomes
903a535dd6 Merge branch 'develop' into fix/sla-timezone-and-hold-time 2026-09-26 02:02:46 +05:30
Sydney
57b5bfc30d fix(onboarding): open the welcome ticket after skipping the questionnaire 2026-09-26 02:00:56 +05:30
Sydney Gomes
417c2862e9 Merge pull request #3793 from aerodeval/chore/frappe-ui-rc1
chore: migrate frappe-ui to v1.0.0-rc.1
2026-09-26 01:09:35 +05:30
Sydney
0bb8070779 fix(sla): count resolved time as hold only on reopen 2026-09-25 01:31:49 +05:30
Sydney
1f0dc529ce fix: close editor suggestion menus on outside click 2026-09-24 15:32:50 +05:30
Sydney
2aa4644825 fix: refresh mobile timeline after an update 2026-09-24 14:25:09 +05:30
Sydney
41f77fba8c fix: draw form-script toast icons from the sprite only 2026-09-24 14:21:07 +05:30
Sydney
6c62424ce4 refactor: style mobile tab panels directly 2026-09-24 14:05:33 +05:30
Sydney
7213e51e3c chore: shorten comments 2026-09-24 13:49:04 +05:30
Ritvik Sardana
96ece7bbd8 fix(ticket): track a comment delete in one toast 2026-09-24 13:41:02 +05:30
Ritvik Sardana
8ea319a68f fix(ticket): show your avatar on a comment or reply before it is saved 2026-09-24 13:41:01 +05:30
Sydney
97a6be8dc9 fix: load kb search results on the third character 2026-09-24 13:36:56 +05:30
Sydney
4fc61d4d44 fix: show field dependency borders in dark mode 2026-09-24 13:36:53 +05:30
Sydney
10c7c15894 fix: close mobile sidebar on outside tap 2026-09-24 13:36:45 +05:30
Sydney
1f914ebadb fix: widen mobile ticket pickers and scroll its tab bar 2026-09-24 13:36:42 +05:30
Sydney
0251b3d8f9 fix: stop editor toolbar overlapping send and discard 2026-09-24 13:36:40 +05:30
Sydney
3bd28aa269 fix: keep selected editor nodes from being clipped 2026-09-24 13:36:25 +05:30
RitvikSardana
9ff6a864a4 Merge pull request #3791 from RitvikSardana/fix/ticket-field-saved-value
fix(ticket): show a field value the fetched options do not carry
2026-09-24 11:08:42 +05:30
RitvikSardana
3e0512ba59 Merge pull request #3782 from frappe/pot_develop_2026-09-20
chore: update POT file
2026-09-24 11:06:56 +05:30
Sydney
d3f51690ed chore: declare unplugin-icons as a dev dependency 2026-09-24 02:52:51 +05:30
Sydney
b496da2e50 fix: render form-script toast icons from the sprite 2026-09-24 02:47:35 +05:30
Sydney
4768eb27a1 fix: show hover-only controls on touch devices 2026-09-24 02:47:33 +05:30
Sydney
b8c1e0ec38 fix: label icon-only tab buttons 2026-09-24 02:32:59 +05:30
Sydney
322697d1e0 fix: restore checkbox spacing in multi-selects 2026-09-24 02:32:57 +05:30
Sydney
775797732d fix: restore rating filter star size 2026-09-24 02:32:55 +05:30
Sydney
70f261c79e fix: keep the mobile sidebar drawer expanded 2026-09-24 02:23:18 +05:30
Sydney
c69fcda6b7 fix: convert tooltip and hovercard delays to milliseconds 2026-09-24 02:13:39 +05:30
Sydney
fc8ba633c4 fix: restore saved-reply field autocomplete and table of contents 2026-09-24 02:05:00 +05:30
Sydney
b2f3de5eff fix: pass mention items as label and value 2026-09-24 01:58:45 +05:30
Sydney
25e2981ef7 fix: use setOpen and close from popover slots 2026-09-24 01:50:07 +05:30
Sydney
e138ed2e26 chore: bump frappe-ui to 1.0.0-rc.1 2026-09-23 23:59:15 +05:30
Sydney
15d717ec6c chore: bump vite to 5 2026-09-23 23:41:38 +05:30
Sydney
2967bf400d fix(template): plain words for the Visible to messages 2026-09-23 15:31:06 +05:30
Sydney
a0ed458050 refactor(perms): let TicketFields filter every list
visible() replaces the by-hand subtractions in the list API and in the
class itself; details becomes layout, the list a layout editor will grow;
the template endpoint reads its row as a dict.
2026-09-23 14:35:57 +05:30
Sydney
1792df7570 fix(perms): strip unreadable fields from every ticket echo 2026-09-23 12:57:01 +05:30
Sydney
296dd33d4d refactor(perms): move field visibility into TicketFields
Also trims the portal ticket payload to what the page reads and picks
the new-ticket form script by audience.
2026-09-23 12:22:51 +05:30
Sydney
3f27c7b789 fix(portal): show falsy field values and fix the date tooltip
Test helpers moved to test_utils.
2026-09-22 16:06:10 +05:30
Sydney
06ecfb52b0 fix: disallow subject in template, tidy mobile rows 2026-09-22 15:23:46 +05:30
RitvikSardana
1436448b68 Merge pull request #3790 from RitvikSardana/fix/dialog-legacy-close-context
fix(form-script): keep the pre-v1 dialog and grouped action APIs working
2026-09-22 15:09:39 +05:30
Sydney Gomes
544c091179 Merge pull request #3785 from aerodeval/fix/portal-email-styles
fix: render email HTML correctly in the customer portal
2026-09-22 15:08:09 +05:30
Ritvik Sardana
7baf3f5ebb fix(ticket): show a field value the fetched options do not carry 2026-09-22 14:59:51 +05:30
RitvikSardana
fb3bc88edb Merge pull request #3789 from RitvikSardana/fix/ticket-list-view-polish
fix(ticket): assignee filter, unseen subject weight, loading indicator
2026-09-22 14:57:41 +05:30
Sydney
6a401a8233 refactor: improve code quality 2026-09-22 14:56:06 +05:30
Ritvik Sardana
97ecc8c871 fix(form-script): render a grouped action that still uses items 2026-09-22 14:38:39 +05:30
Ritvik Sardana
3eaf1b5d18 fix(dialog): keep the legacy close callback working in form scripts 2026-09-22 14:38:39 +05:30
Sydney
512c40a97b refactor(perms): read the template rows once, uncached
@redis_cache returned None whenever a concurrent request stored the key
between the wrapper's get_value and exists, so get_list_data raised a
TypeError. The query is one indexed child-table read.
2026-09-22 14:13:31 +05:30
Sydney
9e01f775d5 fix(portal): show the full date on hover for custom date fields
A Date or Datetime template field on the customer sidebar now carries the
full date in a tooltip, the way the SLA rows above it do. Custom rows also
get their `fieldname`, which the list key was already reading.
2026-09-22 13:08:46 +05:30
Ritvik Sardana
64f41c7173 fix(ticket): use the same loading indicator as the ticket page 2026-09-22 13:04:00 +05:30
Ritvik Sardana
e39a38ab66 fix(ticket): show an unseen ticket's subject in semibold 2026-09-22 13:04:00 +05:30
Ritvik Sardana
25aa1b3132 fix(ticket): filter by assignee when a ticket has only one 2026-09-22 13:03:49 +05:30
Sydney
575d57efd4 fix(perms): warn only about the template rows a save hides
The template's info dialog listed every Agents-only row on any save; it
now names just the rows the save turned Agents-only. Trims the comments
on the reply-reopen flags and marks `_comments` for removal from
`AGENT_WORKFLOW_FIELDS`.
2026-09-22 12:43:56 +05:30
Sydney
8c0ed88dbe Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms 2026-09-22 12:24:48 +05:30
Sydney
e08852d817 refactor(perms): return the agent form fields as one filtered list
`get_ticket_customizations` now returns every field the details tab may
show, already filtered: visible template rows plus a bare entry for each
visible core field without a row. The pages partition it by name instead
of reading a separate hidden list.
2026-09-22 12:21:28 +05:30
Sydney
9523b6b392 refactor(perms): make the template helper answer for the caller's audience
`get_template_hidden_fields(audience)` returns what the template hides from
that audience, so callers stop asking for the other side's rows.
2026-09-22 11:41:25 +05:30
Sydney
d1d5934cee refactor(perms): compute unreadable fields inline off the meta
`get_unreadable_fields` had one caller and a constant argument. The meta's
permlevel helpers make the same test `apply_fieldlevel_read_permissions` does.
2026-09-22 11:40:25 +05:30
Sydney
ed499bd9df refactor(perms): share the customer-visible template fields lookup
`customer_fillable_template_fields` and `get_visible_custom_fields` ran the
same query on the Default template's rows and applied the same filter.
One helper in `field_visibility.py`, which already owns who sees which
template row, replaces both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGwyVdR1M2WGd9bex8TxRR
2026-09-21 14:28:07 +05:30
Sydney
8f8e463a28 fix(email): strip email colours only in dark mode 2026-09-21 13:06:30 +05:30
Sydney
3b752b0fb5 fix(portal): keep inline styles when sanitizing email HTML 2026-09-21 13:06:28 +05:30
Sydney
428f0b3517 fix(perms): drop the per-user cache on get_filterable_fields
The result became role-dependent, so the cache went `user=True`. That
splits one entry into one per user, and `clear_cache()` only reaches the
calling user's key, so a Visible to change left everyone else stale for an
hour. The frontend fetches this once per session under frappe-ui's own
cache, so nothing is lost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGwyVdR1M2WGd9bex8TxRR
2026-09-21 03:05:11 +05:30
Sydney
fa419b9892 refactor(perms): filter hidden list fields once, at construction
`get_list_data` looked the hidden set up twice and filtered `rows` twice:
once before the query, and again after the standard fields loop put
`_assign` back. Compute it once, keep hidden standard fields out of that
loop, and drop the `fields` cleanup pass. The set is `hidden_fields` at
every site now; `hidden` named an adjective.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGwyVdR1M2WGd9bex8TxRR
2026-09-21 03:05:08 +05:30
Sydney
df89609b3e refactor(perms): prefix the visibility fetchers with get_
`hidden_ticket_fields`, `unreadable_fields` and `fields_visible_to` were the
only noun-named fetchers beside seventy-odd `get_` ones, including the
framework's `get_permitted_fields` they wrap. The docstring on
`get_hidden_ticket_fields` restated the new name, so it states the contract
instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGwyVdR1M2WGd9bex8TxRR
2026-09-21 03:05:04 +05:30
RitvikSardana
a8e858bb15 Merge pull request #3780 from RitvikSardana/fix/ticket-field-duplicate-set-value
fix(ticket): save a link field once when the value is cleared then picked
2026-09-21 00:27:11 +05:30
frappe-pr-bot
97e9aaf6db chore: update POT file 2026-09-20 14:17:53 +00:00
Ritvik Sardana
de56caaba2 fix(ticket): save a link field once when the value is cleared then picked 2026-09-19 19:42:24 +05:30
RitvikSardana
59c32bc4bb Merge pull request #3777 from RitvikSardana/fix/activity-timeline-live-updates
fix(ticket): add optimistic reply and comment in the timeline
2026-09-19 16:11:36 +05:30
Ritvik Sardana
2e04c70855 fix(ticket): delete a pasted image's file when the editor no longer shows it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJHtad4acgJRq843yyCAxq
2026-09-19 14:54:50 +05:30
Sydney
34d08bdea9 chore: drop the SLA check script and its runner 2026-09-18 19:12:16 +05:30
Sydney
500c848422 Merge remote-tracking branch 'upstream/develop' into fix/sla-timezone-and-hold-time
# Conflicts:
#	desk/src/utils.ts
2026-09-18 19:10:57 +05:30
Sydney
e686680a81 test(sla): check deadline comparisons across timezones
useSLA imports frappe-ui, whose index loads .vue files that tsx cannot,
so the check runs through Vite via a small runner.
2026-09-18 19:03:19 +05:30
Sydney
79b9cce118 test(perms): drop the set_value override test 2026-09-18 17:24:25 +05:30
Sydney
37b925f974 test(perms): cover the set_value reply, reset the user in tearDown
tearDown runs before every addCleanup, so the deletions no longer need a
wrapper that switches back to Administrator first.
2026-09-18 17:21:14 +05:30
RitvikSardana
3431b3a007 Merge pull request #3768 from frappe/pot_develop_2026-09-13
chore: update POT file
2026-09-18 15:11:48 +05:30
Ritvik Sardana
cd99cf6079 fix(timeline): retry the activity deep link until the row renders
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJHtad4acgJRq843yyCAxq
2026-09-18 14:04:35 +05:30
Ritvik Sardana
0efb8bc2e2 fix(ticket): keep the quoted reply when a send fails 2026-09-18 12:51:21 +05:30
Ritvik Sardana
74b5570411 fix(ticket): cap the header breadcrumb at 60% width 2026-09-18 12:44:34 +05:30
Ritvik Sardana
79d8c489ae fix(ticket): no attachment chip for a comment's inline images 2026-09-18 12:44:34 +05:30
Ritvik Sardana
34b50346a0 fix(ticket): show a sent reply or comment without waiting for the server
The composer's @update emit was never wired, so the feed only moved when the
socket happened to deliver a doc_update. With the socket down a sent message
stayed invisible until a page refresh.

Wire @update to reload the feed, and send optimistically through the timeline's
addPendingActivity: the row renders on submit, the composer clears, and the
draft comes back if the call fails. new_comment and reply_via_agent now return
the new document's name so the pending row can be swapped for the real one by
key instead of lingering beside it. A pending comment carries its attachments on
the row, since the extras lookup is keyed by a name it does not have yet.

Drop the emit("update") in CommunicationArea's keyboard handlers.
CommentTextEditor.submitComment is async, so the truthiness check around it was
always true: ctrl+enter reloaded once before the write returned and again on
success, fetching comment extras twice, and reloaded even when the guards had
rejected the send. Both editors already emit submit once the write lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJHtad4acgJRq843yyCAxq
2026-09-18 11:58:53 +05:30
RitvikSardana
3cb81c506c Merge pull request #3769 from RitvikSardana/fix/bump-frappe-ui-beta
fix: bump frappe-ui to 1.0.0-beta.63
2026-09-17 18:41:28 +05:30
Ritvik Sardana
9d276403f4 fix: hold send until editor media uploads finish 2026-09-17 18:26:22 +05:30
Ritvik Sardana
bdc5fd50ac fix: let the team and ticket type chips be picked again 2026-09-17 17:38:11 +05:30
Ritvik Sardana
f512817f02 fix: show the sidebar notification dot only when collapsed 2026-09-17 16:34:13 +05:30
Ritvik Sardana
cb771d8c9a fix: better ux for link 2026-09-17 16:07:27 +05:30
Ritvik Sardana
8dc29681ff refactor: drop the tailwind theme extends the preset already covers 2026-09-17 15:48:49 +05:30
Ritvik Sardana
2a2058f789 fix: use link fields for saved reply team and ticket type 2026-09-17 15:48:48 +05:30
Ritvik Sardana
858d800d6d fix: handle field variant 2026-09-17 15:09:04 +05:30
Ritvik Sardana
f877dd80da fix: use the right control for each ticket field type 2026-09-17 12:38:06 +05:30
Sydney
935dcd2876 fix(sla): bank hold time before computing resolution time
set_resolution_time subtracts total_hold_time, and set_hold_time is what
accumulates it, but the latter only banks a hold window when the ticket
leaves a paused status. Resolving straight out of a paused status does
both in one save, and the old order computed the resolution time first,
off a total that did not yet include the window closing in that very
save. The final hold was never deducted.

Paused -> Open -> Resolved was unaffected, because the intermediate save
banked the hold before the resolve read it, which is why the existing
tests pass either way. A ticket held for months and then resolved in one
transition reported the whole span as its resolution time.

Running set_hold_time first also means the into-paused branch clears
resolution_date before set_resolution_time reads it, so that call now
no-ops instead of computing a value the old order immediately nulled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:43:17 +05:30
Sydney
a8d38e4d48 fix(sla): compare SLA deadlines in the site timezone
response_by and resolution_by come off the API as naive strings in the
site's timezone, but plain dayjs() parses them as browser-local. When an
agent's browser sits east of the site, every deadline reads earlier than
it is, so a new ticket can show a red Failed badge while the backend
still has agreement_status as due. West of the site it fails the other
way: a breached SLA keeps showing a countdown.

dayjsLocal() parses in systemTimezone and converts to the viewer's, so
both sides of each comparison land on the same clock. Only comparisons
against "now" were wrong; server-to-server ones shifted equally and are
switched over for consistency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:43:11 +05:30
Ritvik Sardana
5893a3d95d fix(editor): patch frappe-ui suggestion popup positioning 2026-09-17 11:11:53 +05:30
Sydney
99d251cbb1 refactor(perf): update copy and show toast only on child table change 2026-09-17 01:35:30 +05:30
Sydney
102b9605bf refactor(perms): name the customer-fillable helpers for who they serve
creation_fillable_template_fields -> customer_fillable_template_fields,
customer_may_fill_at_creation -> can_customer_fill_at_creation, and the
permlevel tuple they read to match.
2026-09-16 18:01:38 +05:30
Ritvik Sardana
129d0c26cf fix: show a link field's saved value when the search does not return it 2026-09-16 17:58:12 +05:30
Sydney
6957f2c901 feat(perms): link the permission level docs from the hide warning
The warning tells an admin to raise a permission level but not what that
means. The link opens in a new tab so an unsaved template is not lost.
2026-09-16 17:51:47 +05:30
Sydney
73fada0560 refactor(perms): drop client-side visible_to filtering
get_fields_meta already strips agent-only rows for customers, so the three
portal filters never removed anything. Visibility now has one owner on the
server and the payload no longer carries visible_to.
2026-09-16 17:51:12 +05:30
Ritvik Sardana
eda11d24b8 fix: unseen ticket font weight 2026-09-16 14:50:14 +05:30
Ritvik Sardana
ecdb8a73bd fix: raise the agent and email pickers above the dialog layer 2026-09-16 14:23:21 +05:30
Ritvik Sardana
be2e78fe8a fix: open the preference dropdowns above their fields 2026-09-16 14:08:43 +05:30
Ritvik Sardana
5d2bc5055e fix: align the language dropdown and name its placeholder 2026-09-16 14:05:00 +05:30
Ritvik Sardana
bf7ed63282 fix: keep the timezone dropdown inside the settings dialog 2026-09-16 14:02:49 +05:30
Ritvik Sardana
2a36299f1d fix: put a space between vs and the comparison period 2026-09-16 13:56:07 +05:30
Ritvik Sardana
1ee15c55e9 fix: keep the add column dropdown inside the window 2026-09-16 13:49:21 +05:30
Ritvik Sardana
89d5eade51 fix: give lucide icons an explicit width so their labels line up 2026-09-16 13:49:20 +05:30
Ritvik Sardana
595532922e fix: keep grouped form script actions in the ticket kebab menu 2026-09-16 13:23:52 +05:30
Ritvik Sardana
84e679c871 fix: keep the real fieldtype when adding a list column 2026-09-16 13:23:52 +05:30
Ritvik Sardana
aea0cc6fd0 fix: align the dashboard filter row and use the framework link field 2026-09-16 13:20:54 +05:30
Sydney
7ed25d10b4 fix: update doctype.ts 2026-09-15 21:03:12 +05:30
Sydney
5163d60f67 fix(perms): repair ticket permissions on customised sites
Custom DocPerm rows make Frappe ignore the JSON perms, so these sites
never get the new permlevel grants and read the protected fields as empty.

- write the level 7 and 8 rows for roles that can already read a ticket
- add the customer roles, which a June 2026 JSON-only change never gave them
- hide template rows the validator would refuse to save after the backfill

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwkkywUhXWkgjQuf5ad9XV
2026-09-15 13:14:34 +05:30
Sydney
05d96777cb fix(perms): close field visibility gaps found in review
- prune permlevel-hidden fields in hidden_ticket_fields, so every caller
  drops both sets and not just the template tier
- keep the contact card when raised_by is hidden
- let a portal reply reopen a replied or resolved ticket
- let the closing email's rating reach the closed ticket
- keep the merge link when the agent has no write at the internal level
- stop treating System Manager as an agent
- refuse renaming the Default template

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwkkywUhXWkgjQuf5ad9XV
2026-09-15 12:40:58 +05:30
Ritvik Sardana
a4edff132e fix: colour the declined call status in the call log modal 2026-09-14 16:09:28 +05:30
Ritvik Sardana
67de9922b1 perf: drop the tailwind safelist in favour of literal status colors 2026-09-14 16:09:27 +05:30
Ritvik Sardana
6c33986659 fix: stop call() responses from raising server message toasts 2026-09-14 02:08:55 +05:30
frappe-pr-bot
bb678e1987 chore: update POT file 2026-09-13 14:30:54 +00:00
Ritvik Sardana
b5c870bdb4 fix: restore the tab row's pre-upgrade vertical spacing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 18:35:06 +05:30
Ritvik Sardana
2c0899220e fix: give the condition row three equal-width controls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 18:27:33 +05:30
Ritvik Sardana
7f5edf116e fix: stop a loading field list from blanking the settings detail pages
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 18:15:25 +05:30
Ritvik Sardana
7737d04fcc refactor: replace NestedPopover with the frappe-ui Popover
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 18:06:44 +05:30
Ritvik Sardana
4d53e43dee fix: match single-title page headers to the breadcrumb text size
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:39:51 +05:30
Ritvik Sardana
b39f7d7a31 fix: give the tab rows their pre-upgrade height back
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:39:49 +05:30
Ritvik Sardana
fbd5cb1098 fix: match the view breadcrumb text size to every other breadcrumb
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:32:58 +05:30
Ritvik Sardana
fab46b2725 fix: close the view menu before its dialog, and settle its row hover
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:23:00 +05:30
Ritvik Sardana
16ab46898b fix: move the profile status dot to the bottom right
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:12:41 +05:30
Ritvik Sardana
dd8d968506 fix: close the remaining dead ends the audit confirmed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:08:36 +05:30
Ritvik Sardana
cb30673e37 fix: repair the data-loss and dead-control paths the audit confirmed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 13:01:51 +05:30
Ritvik Sardana
8e9e853a49 fix: close the emoji picker once a reaction is picked
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:53:30 +05:30
Ritvik Sardana
4774d6168b fix: keep the ticket kebab menu when merge is unavailable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:53:29 +05:30
Ritvik Sardana
5dd6e87c42 fix: let a changed feedback status enable the save button
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:53:29 +05:30
Ritvik Sardana
8622b7b88c fix: let the SLA enabled switch toggle once per click
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:53:28 +05:30
Ritvik Sardana
f2b47ebc34 fix: stop a custom field committing the text typed into its search box
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:53:28 +05:30
Ritvik Sardana
0455163a92 fix: put the width where a custom trigger slot cannot drop it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:53:27 +05:30
Ritvik Sardana
0aaf1966dd fix: let MultipleAvatar take the array its callers pass
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:44:18 +05:30
Ritvik Sardana
f3fd4f20d5 fix: drop the dead clear button from the language field
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:37:07 +05:30
Ritvik Sardana
fa3d78eb0a fix: stop the tags row from opening the colour picker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:37:05 +05:30
Ritvik Sardana
f50e0957f2 fix: give the saved reply and signature editors an uploader
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:24:27 +05:30
Ritvik Sardana
5f12a70c41 feat: mount the table menu so inserted tables can be edited
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:23:00 +05:30
Ritvik Sardana
7cb87d7185 fix: hand the editor's upload signal and progress callback to the uploader
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:20:49 +05:30
Ritvik Sardana
5439daad78 fix: pass form-script toast icons through as components
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:17:37 +05:30
Ritvik Sardana
4fde6a2a57 chore: drop the QA screenshots that slipped into the branch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:15:34 +05:30
Ritvik Sardana
e20c5f0eb9 fix: make the SLA card hover-only and give AssignTo its layout box back
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:13:48 +05:30
Ritvik Sardana
28f60f059d fix: drop the dead bindings the date pickers and preset menu carried
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:09:21 +05:30
Ritvik Sardana
67c446f8b2 fix: clear the second round of audit findings
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:07:43 +05:30
Ritvik Sardana
20a7b137b6 fix: replace the props and tokens beta.63 no longer has
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:02:47 +05:30
Ritvik Sardana
a0ebcc200d fix: reset the right delete flags and stop the KB search opening on click
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 12:01:25 +05:30
Ritvik Sardana
d8a103495f fix: repair the beta.63 contract breaks the audit found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 11:59:23 +05:30
Ritvik Sardana
963e82ec03 fix: focus inputs through the component API, not $el
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 11:55:31 +05:30
Ritvik Sardana
24438c9f3e fix: give Switch real booleans where the source is a doctype check field
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 11:45:16 +05:30
Ritvik Sardana
845cefcb67 fix: clear the runtime warnings the beta.63 QA sweep surfaced
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 11:40:58 +05:30
Ritvik Sardana
808c84df15 refactor: replace the vendored autocompletes with frappe-ui Combobox
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 11:28:46 +05:30
Ritvik Sardana
55b02def4e fix: stop popover triggers from toggling twice
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 11:19:41 +05:30
Ritvik Sardana
c60d549184 fix: close dialogs from the action context and restore the page length picker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 01:03:03 +05:30
Ritvik Sardana
db8851aa9a fix: reset the delete confirmation from the dropdown open state
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 00:57:49 +05:30
Ritvik Sardana
a62daf7c8d fix: restore button element refs, date quick filters and combobox query resets
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 00:56:10 +05:30
Ritvik Sardana
f6cf255d19 fix: make component-based dropdown rows real menu options again
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 00:54:09 +05:30
Ritvik Sardana
abbc0430f8 fix: restore tab labels, input focus refs and the change password fields
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 00:46:14 +05:30
Ritvik Sardana
8a21e8291f fix: import HoverCard so the hover panels resolve
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-12 00:40:20 +05:30
Ritvik Sardana
78160ca54b fix: rename dropdown group items to options so grouped menus render
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:31:40 +05:30
Ritvik Sardana
99863e6c7c fix: repair an inert ink class on the reaction button
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:24:32 +05:30
Ritvik Sardana
312e6a9240 fix: drop props v1 overlays no longer read
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:20:07 +05:30
Ritvik Sardana
bfb6fac112 chore: drop a screenshot committed by mistake
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:17:56 +05:30
Ritvik Sardana
8729f9cdf3 fix: scan frappe-ui experimental for Tailwind classes and bind date pickers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:17:03 +05:30
Ritvik Sardana
fd6e22de75 fix: keep alias icon names rendering and pin prosemirror to tiptap's floor
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:14:44 +05:30
Ritvik Sardana
6dd1d4f1e3 fix: render the call log and article list icons again
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 23:10:45 +05:30
Ritvik Sardana
5fdff36113 fix: restore tab panel layout and the grouped list icon
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:58:59 +05:30
Ritvik Sardana
c65b1aee20 fix: replace toast.create, prefix lucide icon names and dedupe prosemirror
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:47:20 +05:30
Ritvik Sardana
938de94638 fix: guard useOnboarding for guests and restore the tag search shortcut
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:38:31 +05:30
Ritvik Sardana
d513667c3f fix: migrate Tabs, HoverCard, Menu groups and Badge themes to v1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:36:26 +05:30
Ritvik Sardana
59fadaefa7 fix: migrate Popover, Tooltip and Dropdown to the v1 slot and prop names
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:32:06 +05:30
Ritvik Sardana
eb67904747 refactor: replace FeatherIcon with lucide icons
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:25:16 +05:30
Ritvik Sardana
a56fd4d430 refactor: move frappe-ui imports to their beta.63 homes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 22:13:55 +05:30
Ritvik Sardana
43d9ae3f2b chore: bump frappe-ui to 1.0.0-beta.63
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qR2bFcCSBEXQXtXws8oas
2026-09-11 21:59:55 +05:30
Sydney
795b75c861 Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms 2026-09-11 21:18:59 +05:30
Sydney
1b9166bb24 refactor(perms): clarify the visibility lookup name and comments 2026-09-11 21:02:25 +05:30
Sydney
50d8596104 refactor(perms): improve code quality and trim comments 2026-09-11 20:52:55 +05:30
Sydney
05f3b9b416 refactor: code quality 2026-09-11 15:25:22 +05:30
RitvikSardana
3ab76d795f Merge pull request #3765 from RitvikSardana/fix/assignment-notification-backlog
fix: assignment notification backlog, deprecate replaced doctypes
2026-09-10 02:09:03 +05:30
Ritvik Sardana
e6b7a6d74e chore: reduce comments in the assignment backlog patch 2026-09-10 01:56:59 +05:30
Ritvik Sardana
f91eec298d test: move the notification log seeder into test_utils
AGENTS.md requires backend test helpers to live there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFVLhhv1VXBVfSP6gZ71Lp
2026-09-10 01:47:33 +05:30
Ritvik Sardana
3b2296960d refactor: stop writing to the doctypes core replaced
Bounce and auto-reply notes still went to HD Ticket Comment, which nothing
displays, so agents never saw that their reply had bounced. They are core
Comments now, and the test that covered them was querying the old table.

Assignment was logged as an HD Ticket Activity row from two places in the
SPA. Core assign_to already writes that entry to the timeline, so both were
invisible duplicates. Both call sites also destructured `activities` off
useTicket, which no longer provides it; they use reloadTicketFeed now.

Also drops the dead HD Notification type from the SPA, the unused
log_ticket_activity, and the split and on_trash blocks that only ever
touched legacy rows. Core delete_doc already clears a document's Comment
and Notification Log rows, and split moves the Comment rows the activities
were migrated into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFVLhhv1VXBVfSP6gZ71Lp
2026-09-10 01:46:21 +05:30
Ritvik Sardana
b947a41ec3 fix: make the sidebar notification dot black
Blue read as a link affordance next to a bell that is not one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFVLhhv1VXBVfSP6gZ71Lp
2026-09-10 01:00:13 +05:30
Ritvik Sardana
1ba03d358a chore: deprecate the doctypes replaced by core
HD Ticket Comment, HD Ticket Activity and HD Notification are no longer
written to. Nothing calls log_ticket_activity, and comments and
notifications go to Comment and Notification Log. The rows stay readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFVLhhv1VXBVfSP6gZ71Lp
2026-09-10 00:57:01 +05:30
Ritvik Sardana
607c5babcb fix: mark pre-upgrade assignment notifications as read
Helpdesk only notified on manual assignment. Core assign_to now owns
assignments and notifies on every one, so the first load after the upgrade
shows an unread row for every ticket the Assignment Rule had ever routed.

Clears that backlog once. Assignments made after the patch still arrive
unread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFVLhhv1VXBVfSP6gZ71Lp
2026-09-10 00:56:55 +05:30
RitvikSardana
d96e9e39a0 Merge pull request #3700 from RitvikSardana/refactor/comments-notifications-migration
refactor: comments and notifications doctype
2026-09-09 23:53:12 +05:30
Ritvik Sardana
a979e843df feat: print per-chunk progress while migrating comments and notifications 2026-09-09 23:35:49 +05:30
Ritvik Sardana
dd9911a883 fix: delete a comment's notifications along with it 2026-09-09 16:48:06 +05:30
Ritvik Sardana
8d20dae571 fix: remount the ticket page when navigating to an already visited ticket
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLRT2UCFYoGRxyahBphFbY
2026-09-09 16:25:29 +05:30
Ritvik Sardana
298a69758c fix: fetch comment extras and calls once per ticket instead of on every tab change
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLRT2UCFYoGRxyahBphFbY
2026-09-09 16:02:50 +05:30
Ritvik Sardana
265a8c90b3 refactor: drop the desk form wrappers now that core honours Comment permissions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLRT2UCFYoGRxyahBphFbY
2026-09-09 13:28:59 +05:30
Ritvik Sardana
bb998cce65 docs: prefer framework fixes over helpdesk workarounds in agent guidelines 2026-09-09 11:03:40 +05:30
Ritvik Sardana
d4fa0b2b61 fix: lock the comment row so concurrent reaction toggles cannot double insert 2026-09-09 11:02:34 +05:30
Ritvik Sardana
84f6b9ae52 fix: address review findings in comment migration and notifications 2026-09-09 10:41:50 +05:30
Sydney
f88a61bd8c refactor(perms): replace the role ladder with audiences 2026-09-08 16:27:58 +05:30
Sydney
bfd72acdb7 style(perms): trim comments to a line or two 2026-09-08 15:26:00 +05:30
RitvikSardana
c0017a446a Merge pull request #3697 from RitvikSardana/fix/dynamic-timeline-legend
fix: render ticket timeline legend from what the rail draws
2026-09-08 12:12:17 +05:30
RitvikSardana
2487a3f499 Merge pull request #3710 from RitvikSardana/fix/ui-changes
fix: polish UI in a few screens
2026-09-08 12:11:08 +05:30
Ritvik Sardana
ab9fbeae92 fix: resolve session user in the Comment permission hook 2026-09-08 11:23:53 +05:30
Ritvik Sardana
c8553a953d fix: gate comment extras behind the Comment permission hook 2026-09-08 10:57:38 +05:30
Ritvik Sardana
5619ce706f fix: comment tests 2026-09-07 17:46:58 +05:30
Ritvik Sardana
79e9b39964 Merge branch 'develop' of https://github.com/frappe/helpdesk into refactor/comments-notifications-migration 2026-09-07 16:52:56 +05:30
RitvikSardana
41ad71c69f Merge pull request #3760 from frappe/pot_develop_2026-09-06
chore: update POT file
2026-09-07 16:32:07 +05:30
Sydney
f1126f96fc refactor(perms): read the visibility tier for creation-fillable fields
creation_fillable_template_fields keyed on hide_from_customer, the flag
synced from visible_to. Read the tier directly so the write path uses the
same source of truth as the rest of the field-visibility code.
2026-09-07 14:11:53 +05:30
frappe-pr-bot
7a46faa747 chore: update POT file 2026-09-06 13:44:55 +00:00
Sydney
fc51371e55 refactor(perms): drive ticket field visibility from template tiers
The Default template used to write custom-field permission levels directly,
which raced with Customize Form and needed a base_permlevel column to undo it.
Permission levels now stay a static security floor; the template's visible_to
tier only narrows what each role sees on helpdesk pages, enforced by the
HDTicket read override and the app's own endpoints. Agent-only fields are
dropped from hardcoded portal rows via a _hidden_fields list, and a cosmetic
patch backfills visible_to on rows that predate the column.
2026-09-04 19:54:12 +05:30
Sydney Gomes
9b1023c861 Merge pull request #3628 from aerodeval/fix/bounce-notification-loop
fix: stop mail loops from bounces and autoresponders
2026-09-03 16:40:57 +05:30
Sydney
4592c40f4e chore: shorten comments 2026-09-03 16:19:27 +05:30
Sydney Gomes
567965ed50 Merge pull request #3746 from aerodeval/fix/portal-settings-patch-link-validation
fix: write portal defaults without saving the whole document
2026-09-03 12:41:25 +05:30
Sydney
acd2e28600 fix: let auto-replied mail thread onto its ticket
An out of office should reach agents on the ticket, and auto-replied
senders rate-limit themselves so they cannot sustain a loop the way
bounces and auto-generated feeds can. Those still get parked.
2026-09-03 02:53:36 +05:30
Sydney
f2ac96a4c8 fix: write portal defaults without saving the whole document
Saving Portal Settings validates every link on it, so a menu row left
behind by a deleted doctype failed the write and stopped migrate. Write
the two Single values directly, from one function install and the patch
now share.
2026-09-03 02:41:26 +05:30
Sydney
6a1693dcc6 Revert "refactor: move overrides into per-doctype folders"
This reverts commit b3b717d183.
2026-09-03 02:12:57 +05:30
Sydney
b3b717d183 refactor: move overrides into per-doctype folders 2026-09-03 02:04:58 +05:30
Sydney
8fdbf36add fix: ci lint error 2026-09-03 01:46:27 +05:30
Sydney
f80a004ef6 fix: record parked mail and note it on the ticket
Record Unhandled Email on every transport, not just IMAP, and leave an
internal comment on the matched ticket when a bounce or auto-reply is
parked, so agents still see failed deliveries and out-of-office replies.
2026-09-03 01:41:35 +05:30
Sydney Gomes
36b7490f07 Merge pull request #3433 from aerodeval/handle-disabled-status
fix: move agents off an agent status when it is disabled
2026-09-02 21:49:24 +05:30
Sydney
295d13a421 fix: import userStorage from its new composables home 2026-09-02 20:17:15 +05:30
Sydney
2711f8a613 Merge remote-tracking branch 'upstream/develop' into handle-disabled-status 2026-09-02 20:17:03 +05:30
Sydney
41f7cd80b6 feat: greet a login whose status was changed while away
The toast only reaches agents who are online when it happens. Stamp who
changed the availability, send it with the login payload, and greet on
the next visit if it was someone else. First visit on a device only
records the baseline.
2026-09-02 20:09:53 +05:30
Sydney
9a5267893e fix: show the default agent status below the priority section 2026-09-02 19:17:32 +05:30
Sydney
bd526715ea fix(build): dedupe vue-router and dompurify for the linked @framework/ui
They are peers of @framework/ui that nothing installs next to its
source, so a build where the frameworkUI plugin's dedupe is missing
cannot resolve them.
2026-09-02 17:15:10 +05:30
Sydney Gomes
7b5d512e30 Merge pull request #3743 from aerodeval/fix/desk-build-vue-router
fix(build): dedupe vue-router and dompurify for the linked @framework/ui
2026-09-02 17:13:21 +05:30
Sydney
02d708f961 fix(build): dedupe vue-router and dompurify for the linked @framework/ui
They are peers of @framework/ui that nothing installs next to its
source, so a build where the frameworkUI plugin's dedupe is missing
cannot resolve them.
2026-09-02 16:56:18 +05:30
Sydney
8fa4f9d555 style: tighten and simplify comments 2026-09-02 12:26:54 +05:30
Sydney
8eb157d11d test: pin that force-delete cannot remove the default status 2026-09-02 03:35:28 +05:30
Sydney
6a8971baab fix: harden the disable path against two edge cases
A status disabled behind the controller's back could not save unrelated
fields once it was the default; guard on the transition, matching HD
Agent's availability check. And on_update runs on insert too, so a
status created disabled consulted the default for agents it cannot
have; query the agents first.
2026-09-02 03:21:46 +05:30
Sydney
4953b2a860 fix: spell out the ordering the migration freezes
The patch reproduced the old lookup's implicit ORDER BY, which is the
right value to freeze but the wrong thing to leave unsaid in a change
about not relying on implicit ordering. Pinned with a test.
2026-09-02 02:57:04 +05:30
Sydney
a8e10a11f5 fix: name the default agent status in HD Settings
Picking it with an implicit ORDER BY decided where agents get moved when
their status is retired. HD Settings names it now, like it already does
for the ticket statuses.
2026-09-02 02:48:17 +05:30
Sydney Gomes
bf050e7301 Merge pull request #3742 from aerodeval/fix/grammarly-click-outside
fix: avoid  improper reply box closing for Grammarly suggestions
2026-09-02 02:02:44 +05:30
Sydney
beacb925f4 fix: keep reply box open when using a Grammarly suggestion
Grammarly renders its suggestion card outside the reply box, so accepting one counted as a click outside and closed the box.
2026-09-02 01:30:25 +05:30
Sydney Gomes
6f32222675 Merge pull request #3741 from aerodeval/fix/settings-ui-polish
fix(settings): blank empty states, always-visible Update Account, false typing indicator
2026-09-01 14:46:59 +05:30
Sydney
e2e5a3c198 fix(email): don't broadcast typing when the signature loads
The watcher on newEmail called onUserType() for any change, including
programmatic ones. An agent with a signature configured has it dropped
into the empty editor on load, which emitted helpdesk_ticket_typing - so
merely opening a ticket showed "is typing" to everyone else viewing it.

Skip content that is empty or only the signature. This also covers agents
with no signature, whose editor is filled with an empty paragraph.
2026-09-01 12:20:12 +05:30
Sydney
a989a6bafc fix(settings): show Update Account only when the account changed
The button was always visible, so clicking it with no edits only produced
a "No changes made" toast.

isDirty could not be reused as it stood: the form normalises what the
server sends (a 0 becomes false, a null becomes ""), then compared those
values back against the raw account, so every custom account reported
itself as changed. Compare against values built by the same initialisers
instead, and iterate the fields rather than listing them - the old chain
omitted use_tls, use_ssl_for_outgoing and four others, so editing only
those said "No changes made" and dropped the edit.
2026-09-01 12:20:02 +05:30
Sydney
b8d701a56b fix(settings): import EmptyState so empty screens render
Four settings screens use <EmptyState> in their template but never import
it. There is no component auto-import plugin, so Vue cannot resolve the tag
and renders nothing, leaving a blank pane where the empty state should be.

Email Accounts also showed its empty state while the list was still
loading; guard it on loading like the other three screens do.
2026-09-01 12:19:52 +05:30
Sydney Gomes
2574d2f1f7 Merge pull request #3739 from aerodeval/fix/attachment-download-fallback
fix: download attachments the browser cannot preview
2026-08-31 22:22:23 +05:30
Sydney
56947b47b8 fix: download mov attachments instead of previewing 2026-08-31 17:23:32 +05:30
Sydney
8fe4b398cd fix: keep mov, m4v, ico and apng previewable 2026-08-31 16:29:58 +05:30
Sydney
5a3bff0630 fix: download attachments the browser cannot preview 2026-08-31 16:25:24 +05:30
Sydney Gomes
27f459fff2 Merge pull request #3737 from frappe/pot_develop_2026-08-30
chore: update POT file
2026-08-31 12:51:17 +05:30
frappe-pr-bot
f954dac6a3 chore: update POT file 2026-08-30 14:54:54 +00:00
Sydney
c1eace1d2e fix: never show the ticket key and lift shown fields at migrate
The template refuses to show key at any permission level, since it
authenticates guest feedback links. The migration also lifts shown
custom fields below the visible level to 7, still never lowering.
2026-08-30 00:40:46 +05:30
RitvikSardana
081e9d9204 Merge pull request #3718 from frappe/pot_develop_2026-08-27
chore: update POT file
2026-08-29 02:40:34 +05:30
Ritvik Sardana
5b81fd1ae3 fix: revoke comment permission for customer user 2026-08-29 00:31:22 +05:30
Sydney
895d09d04a fix: never lower a permlevel when a template row is removed
A stale base could undo a restriction the admin set while the field
was listed. Removal now restores the base only when that raises the
level, so exposing a removed field always takes Customize Form.
2026-08-28 19:46:06 +05:30
Sydney
a0f7599aa5 fix: keep customize form levels on template field removal
Removing a row now only undoes the sync's own write. A level an
admin set in Customize Form after the field was added is newer
than the recorded base, so it stands.
2026-08-28 19:31:57 +05:30
Sydney
003f07b6a2 feat: sync custom field permlevels with template visibility
Hiding a custom field in the default template raises it to the
internal level and showing it lowers it to the visible level.
Removing a row returns the field to the level it arrived with,
remembered on the row. The migration patch records those base
levels and stays raise only, so an upgrade never exposes a field.
2026-08-28 19:14:52 +05:30
Sydney
5d04af5d8d refactor: make hide from customer form-only for custom fields
Hiding a custom field no longer raises its permission level. Like
standard fields, it only comes off the form now, and Customize Form is
the one place that changes what customers may read. Templates never
touch a permission level.
2026-08-28 17:03:24 +05:30
Sydney
45422c0cf1 refactor: rename customer_writable_now to customer_editable_fields 2026-08-28 17:03:13 +05:30
Sydney
d7a27be0f2 fix: raise the permlevel of custom fields hidden in the template
Hiding only moved the field off the form, so the API still served it.
Raise only: a template save can never expose a field, and showing one
again means lowering its level in Customize Form first.
2026-08-28 14:26:37 +05:30
Sydney
1546a0bf23 refactor: name the permission guards for what they do
is_staff -> is_agent_staff, CUSTOMER_WRITABLE_AFTER_CREATION ->
CUSTOMER_EDIT_EXEMPT_FIELDS, and shorter comments throughout.
2026-08-28 13:45:41 +05:30
Sydney
9a429e48b2 test: cover the form script and template permission rules 2026-08-28 13:15:17 +05:30
Sydney
5f4a7b6514 refactor: stop templates writing field permission levels
Saving the default template moved custom field permlevels, so one doctype's
save changed another's schema. Setting a permission level is a customization
and belongs in Customize Form.

The template now only validates: an internal field cannot be shown to
customers, standard and custom alike. Hiding a field means form only, which
the fields table now says.
2026-08-28 13:15:10 +05:30
Sydney
22da73bc0a refactor: move ticket permission constants to consts
PORTAL_INSERT_EXEMPT_FIELDS was SERVER_OWNED_INSERT_FIELDS, which described
neither what the list does nor when. It exempts fields from the permlevel
reset, and only on insert.
2026-08-28 13:15:01 +05:30
Sydney
f6482d5563 revert: drop the read check on the outside hours banner 2026-08-28 13:14:51 +05:30
Sydney
1fa8b45542 fix: keep agent form scripts off the customer portal
Customers held read on HD Form Script, so a portal login could list every
script including agent-only ones. get_one also took is_customer_portal from
the caller, so asking for the agent scripts produced them.

Delivery is unchanged: get_form_script reads through the query builder, so
portal scripts still reach customers without the doctype grant.
2026-08-28 13:14:43 +05:30
Sydney
4f9a505e4c Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms
check_update_perms was refactored on both sides. Kept develop's null-safe
old_doc guard and this branch's is_staff check, which drops the
via_customer_portal clause so an agent-raised ticket is frozen once it is
closed or rated.
2026-08-28 02:06:49 +05:30
Sydney
3c930bc172 refactor: limit template permlevel sync to custom fields
Saving a template rewrote permission levels on shipped HD Ticket fields
through Property Setters. That overlay competes with Customize Form over
the same value, and nothing clears a stale one on migrate.

Standard field levels are now static in the doctype and nothing writes
them at runtime. Custom fields keep taking their level from the template,
written straight onto the Custom Field row: that column is the field's
own definition, so there is no overlay and nothing to race. Hiding a
custom field still removes it from API responses, which is the point.

Hiding a standard field is now a form choice only. The levels the app
ships already say what customers may read, and Customize Form is where
that is changed.

No Property Setter is written or deleted anywhere any more.
2026-08-28 01:34:27 +05:30
Sydney
11120774c3 feat: stop customers editing tickets after they are raised
Permission levels cannot do this on their own. A customer needs write
access at level 0 to raise a ticket at all, which leaves subject,
description, feedback_rating and five more editable for the life of the
ticket. The level check is also skipped entirely when a save passes
ignore_permissions, so a whitelisted method that saves that way hands a
customer every field regardless of level.

prevent_customer_edits runs in before_validate and refuses any change a
customer did not make while raising the ticket. They may still close it
and rate it, nothing else. Closing means moving to a status whose
category is Resolved: Replied means an agent answered, so accepting it
would let a customer misrepresent the queue.

Server-side writers acting for the customer set ignore_customer_edit_guard.
The one on on_communication_update is required rather than defensive, as
after_insert reaches it on every ticket that has a description.

check_update_perms no longer skips tickets an agent raised, which let a
customer revise their rating afterwards. is_staff covers System Managers,
who are not agents but hold write at every level.
2026-08-28 01:34:11 +05:30
Sydney
532ac17049 fix: check read permission on the outside hours banner
Every other endpoint in this module checks it. Without it any signed-in
user could pass a ticket name they have no access to and learn that it
exists, that it was raised outside working hours, and the response-time
message.
2026-08-28 01:33:33 +05:30
frappe-pr-bot
891abbef44 chore: update POT file 2026-08-27 07:56:22 +00:00
Sydney
30af657041 fix: show response and resolution times on tickets with no SLA
The sidebar hid both rows unless an SLA metric existed, so a ticket
raised without a priority showed nothing. Fall back to the timestamp.
2026-08-27 13:06:06 +05:30
Sydney
5441b3050f fix: give HD Customer Manager the reads HD Customer has
The portal loads these three doctypes for every customer, but only the
HD Customer role could read them, so a customer manager hit a permission
error on the ticket form.
2026-08-27 13:06:01 +05:30
Sydney
63e8c3d11f feat: let customers read SLA timings
Moves the SLA display fields to the customer-readable permlevel so the
portal can show first response and resolution. That puts them at a level
customers may write at creation, so SERVER_COMPUTED_FIELDS keeps them out
of the creation exemption and off the template form.
2026-08-27 13:05:55 +05:30
Sydney
b5b7c10e94 fix: parenthesize the exception tuple 2026-08-27 13:05:23 +05:30
Sydney Gomes
715e368e80 Merge pull request #3711 from aerodeval/fix/outside-hours-holiday
fix: consider holidays for outside working hours banner
2026-08-20 16:47:30 +05:30
Sydney
54e4587eb4 fix: consider holidays for outside working hours banner 2026-08-20 15:33:41 +05:30
Ritvik Sardana
c2a454ee0d fix: polish UI in a few screens 2026-08-20 13:38:51 +05:30
Ritvik Sardana
7e6a94b163 fix(ticket): keep one loading state on the agent ticket page
The page gated on prefetchActivityTimeline, which no longer exists — the
timeline store is only reachable through useActivityTimeline. TicketAgent now
starts the feed itself and latches its loading flag, so the doc and the feed
load behind a single spinner instead of two in sequence. Latched because a
doc_update reloads the feed, which must not blank the page.

Saved reply actions reload the feed through the mounted timeline, which
registers its refresh on the ticketMap entry, keyed per ticket so a reload
after a ticket switch hits the right feed.

Also moves the timeline imports to the @framework/ui/ActivityTimeline entry
point and picks up AttachmentChip for comment attachments.
2026-08-18 02:06:14 +05:30
Ritvik Sardana
03a8d28289 fix: update VERSION_FIELDS 2026-08-17 23:45:54 +05:30
Ritvik Sardana
d71eb249ba fix: saved reply comment actions with core comments
- assert the Add Comment action lands in core Comment, not HD Ticket Comment
- reload the timeline after actions apply: the send-triggered reload races
  the action comment's socket append and can drop it until a refresh
- drop contact from the version fields worth a feed row
2026-08-17 23:40:23 +05:30
Ritvik Sardana
69fb59c899 Merge branch 'develop' of https://github.com/frappe/helpdesk into refactor/comments-notifications-migration 2026-08-17 23:02:05 +05:30
Ritvik Sardana
787d9b2dac refactor(timeline): use framework/ui ActivityTimeline component
Replace the hand-rolled ticket activity feed (TicketAgentActivities,
EmailArea, CommentBox, HistoryBox, FeedbackBox) with the framework's
ActivityTimeline + useActivityTimeline on both desktop and mobile.

- one shared fetch per ticket (SHARED_VISIBLE_TYPES); tabs filter client-side
- TimelineContainer bounds the feed; mobile page is a clamped column
- comment extras (reactions/attachments) via helpdesk.api.timeline
- deep links map ?highlight=comment-/communication- to timeline row keys
- drop dead server endpoints (get_ticket_activities, get_history,
  get_version_history, get_views)
2026-08-17 23:01:28 +05:30
Pratham Sharma
20eb1fd7c1 Merge pull request #3701 from ps173/auto-generate-pot
chore: auto generate POT files for main-hotfix
2026-08-17 16:01:05 +05:30
Pratham
9c709ac2f8 chore: auto generate POT files for main-hotfix 2026-08-17 15:31:01 +05:30
Ritvik Sardana
c1b8c2c534 fix: pop out comments form api get_one 2026-08-17 00:57:46 +05:30
Ritvik Sardana
0db605ef36 fix(comments): guard reactions to ticket comments, handle missing search index, port reaction tests 2026-08-16 00:31:22 +05:30
Ritvik Sardana
462f1e30f3 test: cover the search index relabel patch 2026-08-16 00:05:02 +05:30
Ritvik Sardana
a4bed869dd fix: don't bump modified when back-filling erpnext_customer 2026-08-15 22:12:30 +05:30
Ritvik Sardana
b4e97540f3 refactor: read notifications from core Notification Log 2026-08-15 22:11:59 +05:30
Ritvik Sardana
e02179371b refactor: codebase to use core comments instead of hd_ticket_comment 2026-08-15 21:38:55 +05:30
Ritvik Sardana
ec828d9993 fix: set up core comments schema on fresh install 2026-08-15 14:30:17 +05:30
Ritvik Sardana
aed32274ad refactor: migrate ticket comments and notifications to core doctypes 2026-08-15 14:28:21 +05:30
Ritvik Sardana
fe2ebaed5b fix: render timeline legend from what the rail draws 2026-08-14 05:18:13 +05:30
RitvikSardana
34e0e8f41a Merge pull request #3696 from RitvikSardana/fix/hide-bulk-actions-for-customers
fix: hide bulk reply, assign and edit from customer roles
2026-08-14 00:07:06 +05:30
Ritvik Sardana
c3ae467da1 fix: hide bulk reply, assign and edit from customer roles
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:59:54 +05:30
RitvikSardana
c74c98e4ff Merge pull request #3693 from RitvikSardana/feat/macros
feat: actions in saved replies
2026-08-13 14:08:40 +05:30
Ritvik Sardana
ea1a302fb1 fix: add telemetry event for saved reply actions 2026-08-13 13:23:43 +05:30
Ritvik Sardana
b32a2f0eac fix(ux): adding actions 2026-08-13 13:11:43 +05:30
Ritvik Sardana
729607fb68 fix: unified collapsed state for saved reply actions 2026-08-13 11:20:29 +05:30
Ritvik Sardana
7e06f188c4 fix: reject non string action values on save 2026-08-12 22:39:22 +05:30
Ritvik Sardana
f574a2caf6 fix: paginate saved reply selector 2026-08-12 22:11:34 +05:30
Ritvik Sardana
dccc7acc73 fix: skip shadowed saved reply actions and silence ssti lint 2026-08-12 22:05:13 +05:30
Ritvik Sardana
908c408847 fix: clear stale assignment rule cache after saved reply action tests 2026-08-12 21:21:31 +05:30
Ritvik Sardana
c19afe3496 refactor: sla style table for saved reply actions 2026-08-12 20:46:43 +05:30
Ritvik Sardana
a8addfb8b7 feat: editable comments from actions 2026-08-12 20:46:34 +05:30
Ritvik Sardana
8dc39e144b chore: truncate comment text 2026-08-12 14:05:45 +05:30
Sydney
e19b72965d feat: confirm customer access for the fields a template save touched 2026-08-12 14:05:12 +05:30
Ritvik Sardana
17e9393e96 fix: ux for saved reply action failures 2026-08-12 13:59:50 +05:30
Sydney
11072dcee3 refactor: plain-english template permission messages 2026-08-12 13:48:36 +05:30
Sydney
dc31abeedd refactor: extract customer_may_fill_at_creation helper 2026-08-12 13:15:52 +05:30
Ritvik Sardana
ede91aee21 fix(styles): settings modal 2026-08-12 12:58:23 +05:30
Ritvik Sardana
1365b139ba fix: keep staged saved reply actions when apply rolls back 2026-08-12 12:58:13 +05:30
Ritvik Sardana
972ad7012d fix: drop staged saved reply actions on failed apply, scope per agent 2026-08-12 12:29:21 +05:30
Ritvik Sardana
491573946f refactor: move replyComposer into modalStates 2026-08-12 11:42:32 +05:30
Ritvik Sardana
3f05c90c14 fix: update saved reply icon 2026-08-12 02:36:18 +05:30
Ritvik Sardana
2d17b29909 fix(styles): saved reply selector filter 2026-08-12 02:28:49 +05:30
Ritvik Sardana
8e1c071f14 fix: select field saving on every keystroke 2026-08-12 02:01:41 +05:30
Ritvik Sardana
169820f8e7 feat: open the action picker from the whole chip 2026-08-12 02:01:39 +05:30
Ritvik Sardana
199a43302e refactor: saved reply modal fetching 2026-08-12 02:01:31 +05:30
Ritvik Sardana
fd5704d128 chore: code cleanup 2026-08-12 00:21:54 +05:30
Ritvik Sardana
2c8a7c0e7d refactor: cut saved reply action network calls
Build the action options once instead of per component: frappe-ui reloads a
cached list resource on every createListResource call, so each action chip
fired its own tags, ticket type and team requests.

Team and ticket type are now searched like link fields (debounced name query)
rather than fetched whole.
2026-08-12 00:18:41 +05:30
RitvikSardana
83c5121b39 Merge pull request #3692 from RitvikSardana/fix/ticket-create-crash-on-missing-old-doc
fix(hd-ticket): don't check update perms without a previous version
2026-08-12 00:07:33 +05:30
Ritvik Sardana
e4589968d5 fix: replacing a saved reply closes the reply editor 2026-08-11 22:24:07 +05:30
Sydney
9da6353a0a fix: tighten which fields customers can fill at creation 2026-08-11 20:00:49 +05:30
Sydney
27650682f8 fix: make visible template fields fillable only at creation 2026-08-11 19:26:18 +05:30
Ritvik Sardana
1642385558 fix(hd-ticket): don't check update perms without a previous version
check_update_perms() ran off self.is_new() as a proxy for "there is a
previous version to compare against". __islocal can be cleared during
insert() (any before_insert hook that persists the doc calls db_insert,
which sets it to False), so is_new() was False on create while
_doc_before_save was still None, and old_doc.status raised AttributeError.

Guard on the previous doc itself, which is what the check actually needs.
2026-08-11 19:04:26 +05:30
Sydney
c53a78e4d7 fix: drive ticket permlevels from the default template only 2026-08-11 18:56:01 +05:30
Ritvik Sardana
72a3d5623b fix: make saved reply actions field read only in desk 2026-08-11 18:44:43 +05:30
Ritvik Sardana
10145f63c0 fix: actions clearing bug 2026-08-11 18:43:08 +05:30
Ritvik Sardana
3f66824512 feat: edit actions from email editor 2026-08-11 18:23:33 +05:30
Ritvik Sardana
7ddc78a7d0 feat: add actions in email editor
Saved reply actions are staged in the composer and applied once the reply
is sent. Chips are individually removable, the staged comment is editable,
and applying another reply replaces the staged set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:36:41 +05:30
Sydney
0f2ffc0856 feat: restore shipped permlevels when a template drops a field 2026-08-11 14:27:37 +05:30
Sydney
d09f7abd2d fix: leave desk inserts by system managers unstamped 2026-08-11 14:26:40 +05:30
Sydney
8260b84477 fix: let templates open only safe fields to customers 2026-08-11 14:25:33 +05:30
Sydney
71d7d1f163 Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms 2026-08-11 14:01:07 +05:30
Ritvik Sardana
3e87c14cb7 fix: add grouping in actions 2026-08-11 12:52:01 +05:30
Ritvik Sardana
e37d817b19 feat(saved-replies): add actions table in Saved Reply
Saved replies can now carry actions (macros) that update the ticket when
the reply is sent: set status, priority, team or ticket type, assign an
agent or yourself, add/remove tags, and add a comment.

Backend
- HD Saved Reply gets an `actions` JSON field, with validation for the
  action type, its value, duplicates, and the one-assignment-per-reply
  and no-tag-in-both-lists rules
- `get_rendered_saved_reply` returns title, rendered message and the
  reply's still-valid actions
- `apply_saved_reply_actions` applies them to a ticket, skipping (not
  raising on) actions that went stale, since the email is already sent
- tests covering validation, permissions and application

Frontend
- Actions table in the saved reply settings form: one row per action,
  action type and its value edited in cells
- Rendered with the shared Grid component from @framework/ui

Note: the Grid props used here (emptyText, addLabel, selectable,
reorderable, rowAction) ship in a companion frappe PR.
2026-08-11 12:44:02 +05:30
RitvikSardana
6d54842fd7 Merge pull request #3690 from RitvikSardana/fix/permission-checks-use-passed-user
fix: use the passed-in user for team permission checks
2026-08-11 12:25:04 +05:30
RitvikSardana
e7ac9a0b8c Merge pull request #3689 from RitvikSardana/feat/ticket-analysis
feat: ticket analytics tab
2026-08-11 12:08:00 +05:30
Ritvik Sardana
9a8adfa445 fix: use the passed-in user for team permission checks 2026-08-11 12:07:29 +05:30
Sydney
72ad711cab refactor: declare server-owned permlevel exemption fields 2026-08-11 11:25:59 +05:30
Sydney
4abe45a8e4 feat: warn when template save changes ticket permlevels 2026-08-11 11:25:49 +05:30
Ritvik Sardana
67df48e170 fix: always label a late first response as Responded late 2026-08-11 01:53:30 +05:30
Ritvik Sardana
2f8fbf8a3e fix: place the resolution in the timeline instead of always last 2026-08-11 01:32:40 +05:30
Sydney
7ef57e5ad7 feat: drive ticket field permlevels from template visibility
Template save syncs field permlevels (hidden -> 8, visible -> 0),
replacing the insert-time exemption dance. Tiers move to 7/8/9 to
stay clear of site-defined levels. get_one reads through the doc
with field-level read permissions applied.
2026-08-11 00:18:31 +05:30
Ritvik Sardana
99ae84ade7 fix: keep resolution date and redden the overdue leg 2026-08-11 00:15:23 +05:30
Ritvik Sardana
3f17dded78 refactor: variable names in TicketTimeline 2026-08-10 23:34:48 +05:30
Ritvik Sardana
422c39f6a1 fix: shorten Average to Avg. in home page widgets
Widget titles read Average while the values inside them already read
Avg., so the same metric was labelled two ways on one screen.
2026-08-10 21:23:26 +05:30
Ritvik Sardana
c45594a3b5 refactor: build agents_involved with get_user_info_for_avatar
The desk was resolving each agent through its user store. Use the standard
avatar helper so the payload carries name and image, matching how the rest
of the ticket APIs return users.
2026-08-10 21:23:15 +05:30
Ritvik Sardana
33635792e8 refactor: ticket_analytics api
- exclude SLA-paused time from the agent response gap so the metric card
  and the milestone badge measure the same interval
- drop the payload left over from the deleted SLA timeline and gap chart
  (exchanges node, leg_label, progress, first_response_target)
- include assignees in agents_involved, resolving sender emails to user ids
  so one person cannot appear twice
- return user ids only, the desk resolves names and avatars from its store
- move the resource into useTicket with a per-ticket cache, fetched by the
  tab rather than on every ticket open
- show an error state with retry instead of a permanently blank pane
2026-08-10 21:19:08 +05:30
Ritvik Sardana
016c5eb159 fix: isolate churn test from HD Team assignment rules
An HD Team insert leaks its auto-created assignment rule across test
transactions, breaking unrelated suites with 'Assignment Rule not
found'. The team change is now recorded directly as the Version diff
churn() reads. Also moves add_message and timeline_node helpers to
test_utils per repo convention.
2026-08-10 19:37:53 +05:30
Ritvik Sardana
b2cbc94357 feat: reload analytics on status or priority change 2026-08-10 18:58:55 +05:30
Ritvik Sardana
30f6b829a0 feat: show year on timeline date labels when it changes
Also removes the planning document from the repo.
2026-08-10 18:57:33 +05:30
Ritvik Sardana
8dcc83b732 feat: unified ticket timeline
Replaces the SLA stepper and response gap chart with a single event rail:
split breach legs with a deadline tick, overdue stretch ending at a Today
marker, reworked metric cards (agent/customer gaps, hold time) and a
trimmed conversation summary.
2026-08-10 18:23:45 +05:30
Sydney
3e516d7f9b refactor: drop hidden field warning from template save 2026-08-10 16:18:47 +05:30
Sydney
756e7034dc fix: leave custom field permlevels to the user 2026-08-10 15:50:38 +05:30
Sydney
58bfe3f4a6 fix: keep permlevel protection until explicitly lowered 2026-08-10 14:17:23 +05:30
Sydney
27ef110197 test: add template factory that replaces leftovers 2026-08-10 14:17:21 +05:30
RitvikSardana
041502c285 Merge pull request #3688 from frappe/pot_develop_2026-08-09
chore: update POT file
2026-08-10 14:12:58 +05:30
Sydney
24d38e58ca fix: scope permlevel release to unhide and template delete 2026-08-10 14:03:20 +05:30
Sydney
2271cc74fd test: move field permission helpers to test_utils 2026-08-10 13:47:03 +05:30
Sydney
7e940791a4 refactor: batch queries in field permission patch 2026-08-10 13:47:01 +05:30
Sydney
b30e808784 fix: drop permlevel when template field is no longer hidden 2026-08-10 13:46:53 +05:30
Sydney
1fa0e2463b feat: reserve permlevel 3 for system manager 2026-08-10 13:43:01 +05:30
Sydney
dedd99fed0 Merge remote-tracking branch 'upstream/develop' into fix/ticket-field-level-perms
# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.json
#	helpdesk/patches.txt
2026-08-10 13:14:21 +05:30
Sydney
46e6aa621f chore: shorten permlevel comments 2026-08-10 13:00:00 +05:30
Sydney
b24d7d686b test: cover creation response and customer link guard 2026-08-10 12:59:54 +05:30
Sydney
4fc624bf6f refactor: strip ticket creation response via read permissions 2026-08-10 12:59:48 +05:30
Sydney
fe0944c057 fix: validate customer link even when auto-set is off 2026-08-10 12:59:35 +05:30
frappe-pr-bot
712b630331 chore: update POT file 2026-08-09 10:59:25 +00:00
RitvikSardana
e17cb5601c Merge pull request #3680 from RitvikSardana/feat/bulk-actions2
feat(tickets): bulk edit and bulk assign from the selection banner
2026-08-07 21:33:59 +05:30
Ritvik Sardana
bf5a1bd83d fix(bulk-edit): match progress by task id, make status mandatory 2026-08-07 20:35:13 +05:30
Ritvik Sardana
852e58e62d refactor(tickets): surface Assign in the selection banner
Reply and Assign are the actions agents reach for on a selection, so they get
the banner buttons; Export, Edit and Delete move into the overflow menu.

Drops the icon scale classes: correcting one axis leaves the other wrong, and
correcting both distorts the glyph. Nothing CSS can do here.
2026-08-07 18:50:08 +05:30
Ritvik Sardana
f3c25b4524 feat(tickets): bulk edit and bulk assign from the selection banner
Adds two actions to the ticket list selection banner alongside the existing
bulk reply:

- Bulk Edit: sets a field on every selected ticket via the framework's
  `submit_cancel_or_update_docs`.
- Bulk Assign: assigns agents to every selected ticket via `add_multiple`.

Bulk Assign reuses the ticket view's own agent picker. `AssignTo` now treats a
missing ticket injection as standalone mode: it reports its selection through
v-model and leaves saving to the parent, so both call sites share one picker
instead of a second, worse one.

The banner shows Reply and Edit as buttons and keeps the rest in the overflow
menu; actions opt in with `inline`. Labels drop the redundant "Bulk" prefix
since the banner already states the selection count.

Also hardens the SLA cells to check `first_responded_on` / `resolution_date`
before the target date, matching `useSLA`, so a cleared target no longer
renders as a countdown.
2026-08-07 18:37:56 +05:30
Ritvik Sardana
5e097c4cb5 fix(list-view): fetch SLA fields for custom ticket views
Saved views send only their own column keys as `rows`, so `first_responded_on`
and `resolution_date` never reached the client. The First Response / Resolution
cells then fell through to the live-countdown branch, showing a ticking deadline
for tickets that had already been responded to or resolved.

Append the whole set of fields the SLA cells read, the way `sla` alone was
already being appended.
2026-08-07 18:37:13 +05:30
Sydney Gomes
2583ef1f90 Merge pull request #3677 from aerodeval/fix/email-recipient-popover-width
fix(email-select): truncate long recipient names in the popover
2026-08-06 13:08:41 +05:30
RitvikSardana
f71c556db8 Merge pull request #3679 from RitvikSardana/fix/ui-framework-plugin
fix: use framework ui plugin
2026-08-06 12:59:12 +05:30
RitvikSardana
587d6547bd chore: update package.json
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-06 12:39:08 +05:30
Ritvik Sardana
ae78b48f1b fix: remove framework ui installation script from dev command 2026-08-06 12:24:10 +05:30
Ritvik Sardana
a91e862358 fix: remove framework ui installation script 2026-08-06 12:18:13 +05:30
Ritvik Sardana
648f1f0c4d fix: use framework ui plugin 2026-08-06 12:14:49 +05:30
Sydney
dd22535ef8 fix: linter issues 2026-08-06 11:42:30 +05:30
Sydney Gomes
beb1de9545 Merge branch 'develop' into fix/email-recipient-popover-width 2026-08-06 01:12:29 +05:30
Sydney
0127ca4a5d fix(email-select): truncate long recipient names in the popover
A long contact name or address made the option row wider than the
popover's max-width, so the list scrolled sideways. Let the text column
shrink and truncate instead.
2026-08-06 01:04:48 +05:30
Sydney
5340d893e1 fix: key the availability toast on who made the change
Comparing the incoming value against the local one mistook an agent's own
change made in another tab for an admin change, because only the initiating
session applies the optimistic update. The event now carries changed_by, so
a client toasts only when someone else moved its status — which is also the
question the toast is actually asking.

Drops the pre-seed guard the value comparison needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 03:55:32 +05:30
Sydney
d528569268 fix: translate the status name in the availability toast
The picker renders status names through __(), so the toast that reports a
forced change should match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 03:44:30 +05:30
Sydney
3ff3c1cbef fix: move agents off an agent status when it is disabled
An agent left on a disabled status keeps one that is no longer offered.
The picker filters disabled statuses out, so their own menu renders empty
while everyone else still sees the retired status against their name.

HDAgentStatus.on_update now moves those agents to the Active status. They
are saved one at a time rather than bulk-updated, so HD Agent's controller
does the validation, the availability_changed_on stamp and the realtime
broadcast — the same path every other availability change takes, and no
change to the socket payload. helpdesk/patches/set_default_agent_statuses
already resets agents on an invalid status the same way.

Which Active status: get_active_status resolves ties by creation, the
frappe.db.get_value default. Not status_order — the shipped statuses are
10/20/30 and a new one defaults to 0, so it would promote any custom
status above "Active". Documented on the function.

The affected agent now gets a toast, since their menu changes under them.
An in-app notification waits on the notification refactor: HD Notification
is ticket-shaped (notification_type is a Select of Assignment/Mention/
Reaction, user_from is mandatory). Left as a TODO on the reset.

Also renames the doctype's `enable` field to `enabled`, matching HD Ticket
Status and HD Service Level Agreement — it was the odd one out in its own
app. Migrated with rename_field in post_model_sync, which needs the synced
field in meta and no-ops on sites installed after the rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 03:41:16 +05:30
Sydney Gomes
9146da919c Merge pull request #3635 from aerodeval/refactor/agent-availability
refactor: agent availability through the document lifecycle
2026-08-05 03:18:22 +05:30
Sydney
c037dcf502 test: reset session user between HD Agent tests
frappe restores the session user per class, not per test, so the four
tests that call frappe.set_user leaked into every later one — the whole
class ran as test_user@test.com after the first of them.

Nothing fails today because the helpers save with ignore_permissions,
but test_manager_can_set_another_agents_availability calls add_roles(),
a permission-checked User.save(), under whatever session the previous
test left behind.

setUp/tearDown now restore Administrator, matching test_contact,
test_onboarding, test_tags, test_ticket_stats and test_agent_home.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:46:35 +05:30
Sydney Gomes
903abe0b16 Merge pull request #3638 from aerodeval/fix/agent-reply-email-threading
fix(email): give agent replies their own Message-Id so threads hold
2026-08-05 01:40:21 +05:30
Sydney
4d2afa9dbe refactor(email): name the send result, trim the comments around it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169cvSxbDNjSX57e12eGY9j
2026-08-05 01:24:42 +05:30
Sydney
974f01f3b6 fix(email): save a reply's Message-Id only once the mail has gone out
reply_via_agent saved the id before sending, so a reply that failed to send
kept an id the customer never received. bulk_reply logs failures instead of
raising, so that reply survived and the next reply answered it, putting an
In-Reply-To on the wire that resolves to nothing in the customer's mailbox.

Generate the id up front and pass it to sendmail as before, but write it to
the communication only after sendmail returns, and only if it queued
something - it returns nothing when every recipient is filtered out, which
is not a send either. A reply that never went out now keeps no id, so it can
never be answered. The row stays on the ticket so the agent still sees the
attempt.

A savepoint around each ticket was tried first and dropped: with HD Settings
instantly_send_email on, sendmail commits inside SendMailContext before the
SMTP attempt, which destroys the savepoint and makes the rollback throw,
hiding the real error.

Also reorder bulk_reply so nothing is written before everything is checked.
Permissions and get_doc ran per ticket inside the loop, so one bad id raised
out of the request and rolled back the replies that had already worked, and
attachments were linked ahead of the dedupe and the permission check.

Reported by Greptile on #3638.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:02:47 +05:30
RitvikSardana
f7aa34bda1 Merge pull request #3671 from RitvikSardana/telemetry/new-features
fix(telemetry): capture events for new features
2026-08-04 23:45:23 +05:30
Ritvik Sardana
efe0688b29 fix(telemetry): capture events for new features 2026-08-04 23:12:56 +05:30
RitvikSardana
04719048ea Merge pull request #3661 from RitvikSardana/fix/build-framework-ui
fix: install @framework/ui deps before dev and build
2026-08-04 22:57:57 +05:30
RitvikSardana
2e4699b6a6 Merge pull request #3668 from RitvikSardana/fix/snug-safari
fix: handle snug
2026-08-04 22:56:34 +05:30
Ritvik Sardana
4026cc3a8b fix: handle snug 2026-08-04 22:56:06 +05:30
Sydney
201f3187ae test(email): cover the email workflow, drop a duplicate test helper
Tickets raised by email are the common case and had no threading test.
Add one: the first agent reply answers the customer's mail, later replies
follow the reply before them. Add a second test for replies that are never
emailed, which must not store an id at all.

Drop the local get_ticket_communications helper. It duplicated
get_latest_ticket_communication and its docstring claimed the canonical
helper could not order results - that is wrong. frappe.get_all falls back
to the doctype sort meta, and Communication sorts by creation desc, so the
existing helper was always correct. Callers lose the trailing [-1].

Trim the comments in reply_via_agent and the test docstrings to plain
English. The PR archaeology they carried is in the commit messages already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 22:01:04 +05:30
Sydney
c1a56258e0 refactor(email): carry the thread anchor on the Communication itself
get_last_threadable_communication returned a two-field dict of which only
the name was ever read, and the caller parked it in a local that stayed
live across ~75 lines and an early return, re-deriving its nullability in
both an if and a ternary.

Return just the name and assign it straight onto communication.in_reply_to
- the row frappe reads it back from anyway - then pass that field to
sendmail. The local, the branch and the ternary all go. Making the helper
private stops it reading as a variant of get_last_communication, which
differs from it in mechanism, return type and error handling.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:51:00 +05:30
Sydney
6bd8b705be fix(email): only store a Message-Id when the reply is actually sent
reply_via_agent stamped every Communication with a fresh Message-Id before
the early return for skip_email_workflow / enable_reply_email_via_agent. A
reply that was never emailed then qualified as a thread anchor, so the next
reply pointed In-Reply-To at an id that had reached no mailbox - failing in
exactly the same silent way as storing no id at all.

Gate the assignment on the same condition as the send, and name it
should_send_email, which also drops a duplicate HD Settings read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:16:16 +05:30
RitvikSardana
7d8f3e3e2c Merge pull request #3666 from RitvikSardana/fix/sr-line-height
fix: line-height saved reply
2026-08-04 16:58:11 +05:30
Ritvik Sardana
5db4bb9c83 fix: line-height saved reply 2026-08-04 16:55:32 +05:30
RitvikSardana
cbd4ceb48b Merge pull request #3663 from RitvikSardana/fix/email-rec
fix: email editor closing on selecting recipients
2026-08-04 14:02:30 +05:30
Ritvik Sardana
d289faead6 fix: email editor closing on selecting recipients 2026-08-04 14:01:31 +05:30
RitvikSardana
75f880efca Merge pull request #3662 from RitvikSardana/fix/cp-align
fix(cp): vertical axis alignment
2026-08-04 12:31:01 +05:30
Ritvik Sardana
5d16951788 fix(cp): vertical axis alignment 2026-08-04 12:22:28 +05:30
Ritvik Sardana
d763279734 fix: dedupe reka-ui so linked @framework/ui resolves it 2026-08-04 00:36:11 +05:30
Ritvik Sardana
f021dcc365 refactor: import Link from the @framework/ui package entry 2026-08-04 00:13:29 +05:30
Sydney
88348f3231 refactor: single predicate for "availability changed"
The controller was asking "did availability change?" in three different idioms:
a hand-rolled old_doc comparison in before_save, has_value_changed in validate
and publish, and has_value_changed plus a get_doc_before_save null check in the
telemetry capture. Collapse them onto one named predicate.

publish_availability_update previously fired on insert while the telemetry
capture did not, justified as letting clients learn a new agent's presence
without a refetch. That justification does not hold: liveStatuses is only ever
read as an overlay on agents already loaded from the list resource
(AssignTo.vue liveAvailability), and nothing iterates it, so an entry for an
agent the client has not fetched renders nowhere. Dropping the asymmetry lets
both side effects share availability_changed() and removes the comment that
existed only to explain the difference.

validate_availability deliberately keeps the raw has_value_changed — a new
agent must still be rejected for a disabled status — and now says so.

Tests: hoist the private helpers to the top of the class rather than leaving
one mid-file between tests, and collapse three copies of the client.set_value
payload onto a single helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuJruCwPQFwBA3HyXogjD7
2026-08-04 00:10:25 +05:30
Ritvik Sardana
50f15f6f83 fix: install @framework/ui deps before dev and build
`@framework/ui` is a `link:` dependency, so yarn never installs its own
dependencies and the build fails on missing imports. Run `yarn install`
in `frappe/ui` first, skipping it when `.yarn-integrity` shows a complete
install is already there.
2026-08-04 00:02:01 +05:30
Sydney
8c4914c2eb test: drop telemetry assertions for agent availability
The capture_event calls are fire-and-forget analytics; asserting on a mock
adds little over the guard being readable in the controller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuJruCwPQFwBA3HyXogjD7
2026-08-03 23:50:52 +05:30
Sydney
c3c1afa1cd feat: capture telemetry on agent availability change
Adds an agent_availability_updated telemetry event, following the existing
capture_update_telemetry_events shape in HD Ticket. Because it lives on the
controller it covers every write path, not just the desk UI.

Guarded on get_doc_before_save() so inserts are excluded: has_value_changed
returns True when there is no previous doc, so without it every agent created
would be counted as a status change. The realtime broadcast deliberately keeps
firing on insert — clients learn a new agent's presence without a refetch.

Also drops the unused trailing `return` from sent_invites, per review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuJruCwPQFwBA3HyXogjD7
2026-08-03 19:46:41 +05:30
Ritvik Sardana
999fca4bda feat: response gap chart
Line chart of agent response gaps over the ticket, business-hours
durations on round tick steps. Espresso oklch tokens crash echarts'
hover restyle, so the series disables the emphasis state.
2026-08-03 19:39:34 +05:30
Ritvik Sardana
3c0615ea4a feat: SLA timeline stepper
Figma-styled stepper for the analytics tab: donut milestones for
created, first response, hold, exchanges and resolution; dashed blue
SLA-paused leg with duration; progress gradients for pending and
breached legs; hover badges with SLA verdicts.
2026-08-03 19:39:33 +05:30
Ritvik Sardana
38a2289995 feat: analytics tab with metrics and conversation summary
New Analytics tab on the agent ticket view: metric cards (avg agent
response gap, time to resolution, longest agent silence) and a
conversation summary with churn flags. Restores the hash tab on soft
navigation via an immediate watcher in useActiveTabManager.
2026-08-03 19:39:16 +05:30
Ritvik Sardana
394a77e611 feat: per-ticket analytics API
get_ticket_analytics returns SLA timeline nodes, response-gap series,
metrics and conversation summary for the agent-facing Analytics tab.
Business-hours math via the ticket SLA calendar, wall-clock fallback
without one; churn from Version diffs and ToDo assignees.
2026-08-03 19:38:52 +05:30
RitvikSardana
cc0758268e Merge pull request #3639 from RitvikSardana/refactor/default-sla
refactor: allow tickets to not have any SLA
2026-08-03 18:13:14 +05:30
Ritvik Sardana
e80ee038a8 refactor: rename the shipped sla to "Standard" 2026-08-03 17:26:55 +05:30
Ritvik Sardana
c38af44f77 fix: keep sla fulfilled ratio within bounds, index ticket sla 2026-08-03 17:18:31 +05:30
Ritvik Sardana
321c3d55ee fix: clear fields when no sla is matched 2026-08-03 15:05:23 +05:30
Ritvik Sardana
d9a7a6975c fix: sla analytics should only include tickets with sla 2026-08-03 14:53:49 +05:30
Ritvik Sardana
ed0942d768 fix: list views and customer ticket view according to NO SLA 2026-08-03 14:23:24 +05:30
Ritvik Sardana
c06bf4cf65 fix: keep pause anchor when a paused ticket detaches 2026-08-03 13:34:05 +05:30
Ritvik Sardana
27e738bcc7 fix: remove on_hold_since if SLA is detached 2026-08-03 12:58:18 +05:30
Ritvik Sardana
9e33bdc029 fix: clear resolution_time if SLA is not attached 2026-08-03 12:42:41 +05:30
RitvikSardana
b2574a7388 Merge pull request #3640 from frappe/pot_develop_2026-08-02
chore: update POT file
2026-08-03 11:34:26 +05:30
frappe-pr-bot
102dc88eef chore: update POT file 2026-08-02 11:39:52 +00:00
Ritvik Sardana
b75761f044 refactor: allow tickets to not have any SLA
Removes three limitations that forced an SLA onto every ticket:

- every priority had to be present in every SLA
- exactly one SLA had to be marked default, and it could not be
  disabled or deleted
- every ticket had to carry an SLA

A policy now applies only when its condition matches and it includes the
ticket's priority. The Default SLA is optional, and is considered last.
When no policy applies, the ticket detaches: sla, response_by,
resolution_by and agreement_status are cleared, while the clock start and
any recorded breach are kept so flapping a condition buys no time.

Adds a rank field so the winner between two matching policies is explicit
rather than creation order. Removing a priority or unticking the Default
SLA now warns about the tickets that will detach, instead of blocking.

resolution_date is stamped by the ticket rather than the SLA, since a
ticket knows when it was resolved with or without a policy.
2026-08-02 04:03:09 +05:30
Sydney
67cd1dc16d fix(email): give agent replies their own Message-Id so threads hold
Every agent reply shipped without an In-Reply-To header, so each one landed
as a separate thread in the customer's mail client.

frappe#32651 made In-Reply-To resolve through Communication.message_id.
helpdesk#2308, merged 77 minutes later, removed the only code that wrote
that column - correctly, since it was copying the parent's Message-Id onto
every reply in the ticket. Together they left the column permanently empty,
so no reply could ever anchor the next one and the chain died after a single
hop off an inbound customer email.

Mint a fresh id per reply, store it on the Communication, and send that same
value, which is what communication.email._make already does for
desk-composed mail. Pick the parent from the newest communication that
actually has an id, so a portal reply - which sends no email and therefore
has none - doesn't break the chain for every reply after it.

Measured on an affected site before the fix: 1,752 of 1,911 agent replies in
one week carried no threading header at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:38:16 +05:30
RitvikSardana
d51b186dc5 Merge pull request #3637 from RitvikSardana/fix/frequent-replies-label
fix(command-palette): rename saved reply group to Frequent replies
2026-07-31 03:53:33 +05:30
Ritvik Sardana
d1c2c06e65 fix(command-palette): rename saved reply group to Frequent replies
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:44:19 +05:30
RitvikSardana
f452618612 Merge pull request #3636 from RitvikSardana/fix/line-height
fix: line height in feedback component
2026-07-31 03:03:25 +05:30
Ritvik Sardana
027f36d790 fix: line height in feedback component 2026-07-31 03:02:31 +05:30
RitvikSardana
aaf4d85521 Merge pull request #3626 from RitvikSardana/feat/command-palette
feat(command-palette): contextual Cmd+K palette for the agent portal
2026-07-31 02:09:41 +05:30
Ritvik Sardana
4d04820aa9 chore: code cleanup 2026-07-31 01:58:26 +05:30
Ritvik Sardana
70dc3edb70 fix: update readme 2026-07-31 01:51:58 +05:30
Ritvik Sardana
a29ddb0872 chore: remove animations 2026-07-31 01:46:28 +05:30
Ritvik Sardana
03feb833c2 fix(command-palette): keep rows on screen through the close fade
Emptying groups the moment isOpen flips false flashed "No commands found"
during the exit animation on an outside click.
2026-07-31 01:21:41 +05:30
Sydney
36c04bdc1f refactor: route agent availability through the document lifecycle
set_my_availability wrote with frappe.db.set_value, which skips the HD Agent
controller entirely. That put three invariants in one API function and left
every other write path to HD Agent.availability broken:

- availability_changed_on was stamped by hand in the API, duplicating
  HDAgent.before_save, which never ran
- the "status must be enabled" check lived in the API, so the desk form, REST
  and frappe.client.set_value could all write a disabled status silently
- the agent_availability_updated broadcast fired only from that API, so a
  status changed from the desk form never reached connected clients

Move all three onto the controller — validate() rejects a disabled or unknown
status (guarded on has_value_changed so an agent already on a since-disabled
status can still save unrelated fields), before_save keeps the timestamp, and
on_update publishes the broadcast. The endpoint then has nothing left to do,
so the store calls frappe.client.set_value directly, matching how Settings
already writes HD Agent.is_active.

Dropping the endpoint also drops its agent_only + get_agent_name() self-scoping,
and the Agent role has blanket write on HD Agent, so add a has_permission hook:
an agent may only modify their own record, reads stay open for presence dots
and assignment pickers, managers are unrestricted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuJruCwPQFwBA3HyXogjD7
2026-07-31 01:16:50 +05:30
Ritvik Sardana
08f175634b fix(command-palette): polish context chip, tag flow, view icons and highlight tracking
- render the context chip with frappe-ui Badge, id dark and subject light
- pop back to the parent level after toggling a tag
- resolve Switch view icons with the sidebar's getIcon resolver
- track the highlighted row by identity so async rebuilds can't jump it
2026-07-31 01:06:19 +05:30
Sydney Gomes
820f32a875 Merge pull request #3634 from aerodeval/fix/onboarding-questionnaire-polish
fix: onboarding questionnaire surface, button sizing and team-size default
2026-07-31 00:23:15 +05:30
Sydney
535db2216d feat: report an unanswered team size as prefer_not_to_say
Team size is the only optional answer, so skipping it dropped the key
entirely and the persona event came through incomplete. Default it at
submitPersona, the one point both telemetry and routing read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:03:44 +05:30
Sydney
5d8b42a381 fix: put the onboarding screen on the base surface
surface-gray-1 read as an off-white tint behind the questionnaire.
surface-base is pure white in light mode and still flips with the theme,
which bg-white would not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:03:17 +05:30
Sydney
83f25c9462 refactor: size the questionnaire Next button with the size prop
size="sm" already yields h-7 and the theme's button radius, so the !h-7
and !rounded-lg overrides were restating the default with !important.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:02:52 +05:30
Ritvik Sardana
d0441a3409 chore(command-palette): tighten comments
Every comment keeps only the constraint it protects; essays, product
citations and metric rationales go. ponytail: markers stay.
2026-07-30 18:21:54 +05:30
Ritvik Sardana
2295a1328a fix(auth): drop the unused in-SPA login resource
Nothing calls it: the router guard exits logged-out users to the
server-rendered login page, and returning is a full page load. Removing
it also closes the review concern that an SPA login could leave
module-scope userStorage keys bound to the pre-login identity — that
path now provably does not exist.
2026-07-30 18:00:03 +05:30
Ritvik Sardana
534a5772a9 feat(command-palette): saved reply promotion polish and right-arrow drill
- Promoted saved replies get their own "Most used" section at root; only
  the generic "Ticket" group stays headerless under the context chip
- The composer's saved-reply modal now feeds the same usage record, so
  promotion reflects how an agent actually works, not just palette picks
- ArrowRight drills into the highlighted row's children when the caret
  is at the input's end, mirroring Backspace-back; inert elsewhere
- Group containers drop their margin so rows keep one 36px rhythm across
  headerless group boundaries
2026-07-30 17:55:54 +05:30
RitvikSardana
0975880eba Merge pull request #3542 from RitvikSardana/fix/embed-private-inline-images
fix: drop img src after setting embed so inline images render in emails
2026-07-30 16:19:29 +05:30
Ritvik Sardana
c519ec2a1e fix(command-palette): let a command's own focus survive the close
Reka's close auto-focus restored the pre-open element on unmount, which
ran after a saved reply had already focused the composer and yanked focus
away. Prevent it and route through restoreFocus, which only restores when
the closing command left focus nowhere.
2026-07-30 15:56:49 +05:30
Ritvik Sardana
4348e36a75 fix(command-palette): drop the redundant already-set toast
The drill-down already ticks the current value, so picking it again
closes quietly instead of toasting.
2026-07-30 14:10:20 +05:30
Ritvik Sardana
8e7df85e57 fix(command-palette): polish flat row matching and search fallback styling
- Hidden flat rows (quick-set, saved replies) surface only on a
  substring-or-better match, so a scattered hit like "sett" no longer
  un-hides every option row above an exact match on Settings
- "(advanced search)" renders inline in muted ink via a titleSuffix field
2026-07-30 13:37:53 +05:30
Ritvik Sardana
91d35ff15f Merge branch 'develop' of https://github.com/frappe/helpdesk into feat/command-palette 2026-07-29 17:22:07 +05:30
Ritvik Sardana
800f7f5361 chore: code cleanup 2026-07-29 17:17:38 +05:30
Ritvik Sardana
340cd7a6df fix(command-palette): row and drill-down polish
- Fixed row height (h-9) so rows with shortcut hints match plain rows
- Availability rows show status color dots (reuses ticket status parseColor)
  and await the statuses fetch, fixing the empty drill-down on a cold store
- "Set availability" matches "my status", presence words and live status names
- Reset palette state on open instead of close, so the dismissed context chip
  no longer flashes back during the close animation
- Single-group drill-downs drop their header, which only repeated the breadcrumb
- Team and type rows lose their repeated static icons; agent rows show avatars
- "Recent" group renamed to "Recent tickets"
2026-07-29 17:08:07 +05:30
Ritvik Sardana
8e47ca01a2 fix(command-palette): derive filter keywords from live status and priority options 2026-07-29 16:30:23 +05:30
Ritvik Sardana
a3cc232da8 fix(command-palette): label the search fallback as advanced search 2026-07-29 13:24:41 +05:30
Ritvik Sardana
deb74eaf87 refactor(command-palette): footer hints use ShortcutKey 2026-07-29 13:12:02 +05:30
Ritvik Sardana
e0e3413209 fix(command-palette): tick the active view in switch view 2026-07-29 12:39:57 +05:30
Ritvik Sardana
4c8aad4ebb fix(command-palette): chip shows subject again, capped at 30% width 2026-07-29 02:29:51 +05:30
Ritvik Sardana
fdf98c1c21 fix(command-palette): move the context chip back above the input 2026-07-29 02:28:58 +05:30
Ritvik Sardana
813a717ce9 fix(command-palette): tighten chip spacing 2026-07-29 02:26:53 +05:30
Ritvik Sardana
2e3cd38034 fix(command-palette): chip shows only the ticket id 2026-07-29 02:22:06 +05:30
Ritvik Sardana
438a6f03b1 fix(command-palette): inline the context chip with the input 2026-07-29 02:20:50 +05:30
RitvikSardana
563d22a209 Merge pull request #3631 from RitvikSardana/refactor/feedback-in-sidebar
fix: move ticket's feedback to sidepanel
2026-07-29 02:05:24 +05:30
Ritvik Sardana
6947d41101 docs(command-palette): add a how-it-works walkthrough 2026-07-29 02:01:20 +05:30
Ritvik Sardana
2e1f38f4b4 fix: add feedback secton to right 2026-07-29 01:53:01 +05:30
Ritvik Sardana
553761a8a7 fix(command-palette): reply and comment rows use the composer's icons 2026-07-28 22:40:13 +05:30
Ritvik Sardana
97848b7018 refactor(command-palette): trim recents to three and note stack shape 2026-07-28 22:37:23 +05:30
Ritvik Sardana
e6a4121918 refactor(command-palette): route-to-commands lookup for root rows 2026-07-28 22:36:17 +05:30
Ritvik Sardana
13352db959 fix(command-palette): tick the current availability 2026-07-28 22:08:59 +05:30
Ritvik Sardana
cfaa913877 fix(command-palette): optimistic tag ticks, run guard, recents in context 2026-07-28 20:11:56 +05:30
Ritvik Sardana
c66928f4f0 docs(command-palette): cover keepOpen rows and fallback behaviour 2026-07-28 20:01:05 +05:30
Ritvik Sardana
b16d89862d fix(command-palette): tighten search fallback copy 2026-07-28 19:58:15 +05:30
Ritvik Sardana
a0741af53c fix(command-palette): fallback and create polish
- Create ticket "…" now actually pre-fills the subject on TicketNew
- no Create ticket fallback for #-queries, that's a ticket lookup
- drop the Create group header, "New ticket" already says it
2026-07-28 19:47:40 +05:30
Ritvik Sardana
30bca0279e fix(command-palette): chip hugs its content instead of stretching full width 2026-07-28 19:42:27 +05:30
Ritvik Sardana
1f7d82a0bd feat(command-palette): fade between levels
Drilling in or out, and removing the chip, now get the same brief fade
the chip animation already set the tone for. Typing never re-keys the
list, so filtering stays instant. Honors prefers-reduced-motion.
2026-07-28 19:37:58 +05:30
Ritvik Sardana
1b31f2ccc9 feat(command-palette): Linear-style tag toggling
The tag drill-down listed only unapplied tags and closed after adding
one. It now lists every tag with the applied ones ticked; Enter toggles
add/remove and the palette stays open, so several tags change in one
visit. keepOpen commands rebuild their level in place after performing.

Row renamed "Add tag" -> "Tags", since it removes now too.
2026-07-28 19:35:20 +05:30
Ritvik Sardana
65229b7d29 fix(command-palette): give Change type its own icon
It shared the tag glyph with Add tag; type rows now use the shapes icon.
2026-07-28 19:32:47 +05:30
Ritvik Sardana
0fbdbc5a86 fix(command-palette): drop the remove glyph from the context chip
esc's footer label already reads "Remove context" while the chip is up.
2026-07-28 19:32:02 +05:30
Ritvik Sardana
bfc500dfe7 fix(ticket): shorten copy toasts to match the palette
"Ticket #X copied to clipboard" and "Ticket URL copied to clipboard"
now read "… copied", the same copy the command palette uses.
2026-07-28 19:30:59 +05:30
Ritvik Sardana
86a51d1684 fix(command-palette): drop the Search-elsewhere heading and all-caps headers
The fallback rows explain themselves; group headers now render in
sentence case instead of uppercase.
2026-07-28 19:30:44 +05:30
Ritvik Sardana
0eac87ba25 fix(command-palette): make the context chip inert, removal is backspace-only
Clicking the chip removed the context, which is far too easy to hit for
an action that strips the ticket's own commands out of the palette.
2026-07-28 19:20:08 +05:30
Ritvik Sardana
85f08bf062 fix(command-palette): show the global root after the context chip is removed
Removing the chip on an empty query returned the scoped list — now empty —
instead of falling through to recents, navigation and settings.
2026-07-28 19:17:56 +05:30
Ritvik Sardana
837f6d07fd Merge remote-tracking branch 'upstream/develop' into feat/command-palette 2026-07-28 19:01:43 +05:30
RitvikSardana
3f16756d35 Merge pull request #3629 from RitvikSardana/perf/search-permission-scale
perf(search): keep permission filtering bounded at any ticket count
2026-07-28 18:55:17 +05:30
Ritvik Sardana
6295521022 refactor(perf): grant the search test user HD Customer
A role-less user has no doctype-level read on HD Ticket, so get_list
raises PermissionError on a clean CI site. Real searching users are
agents or HD Customers; the test now models the latter.
2026-07-28 17:04:32 +05:30
Ritvik Sardana
fb11a89635 refactor(perf): trim comments, replace map() with a comprehension
The map() call tripped frappe's no-functional-code semgrep rule.
2026-07-28 16:47:55 +05:30
Ritvik Sardana
4a8bdb61af refactor(perf): bind facet permission list via json_each, not 3N placeholders
get_filter_options pasted the accessible-ticket list into the query three
times (name / reference_name / reference_ticket each carry the ticket for
a different row type), binding 3N variables — so it hit SQLite's
bound-variable ceiling at a third of the ticket count the search query
does.

The list now binds as one JSON string per column: 3 variables total,
regardless of site size. Same rows match, the ceiling is gone.
2026-07-28 16:47:24 +05:30
Ritvik Sardana
292269e4a1 refactor(perf): bound the search permission prefilter, post-filter past the cap
frappe.get_list("HD Ticket", pluck="name") ran unbounded on every search
call, feeding a SQLite IN (...): linear cost per keystroke (108ms at 200k
tickets) and a hard OperationalError past the bound-variable ceiling,
which core's except sqlite3.Error turns into silently empty results.

get_search_filters now probes at most PREFILTER_LIMIT + 1 names. Under
the limit the exact prefilter binds as before; over it, the prefilter is
skipped and _drop_unpermitted permission-checks the returned results
(<=100) through one get_list, which applies every layer including User
Permissions. Rows without a resolvable ticket fail closed; Communication
rows resolve via reference_name.
2026-07-28 16:46:44 +05:30
Sydney
253623c263 fix: stop mail loops from bounces and autoresponders
Inbound mail was only screened with the X-Auto-Generated header, which
only helpdesk's own acks set. A real bounce or out-of-office therefore
reached ticket creation and could start three separate loops: the ack in
HD Ticket.after_insert replies to raised_by (the address that just
bounced), threading onto a portal ticket makes frappe CC the parent doc's
owner on every inbound mail, and an account with enable_auto_reply
answers mailer-daemon directly.

Detect machine-generated mail by the standard markers instead: RFC 3834
Auto-Submitted, the RFC 3464 multipart/report delivery-status type, and
the RFC 5321 null return-path. Matches are routed to handle_bad_emails so
they land in Unhandled Email rather than disappearing -- the fetch has
already marked them seen either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:31:09 +05:30
Ritvik Sardana
5b3b789726 docs(command-palette): mark §14 shipped on perf/search-permission-scale 2026-07-28 14:29:56 +05:30
Ritvik Sardana
8155afaa4e refactor(ticket): move feedback from timeline to sidebar
Feedback no longer renders as a timeline entry on desktop. It now shows as
its own collapsible section under Ticket SLA, with Rating / Feedback /
Comment rows matching the customer portal labels.

Mobile keeps feedback in its activity list.
2026-07-28 13:54:39 +05:30
Ritvik Sardana
095be4dd83 refactor(command-palette): order Add comment above Reply to ticket
Source order is the tie-breaker on an empty query, so this is the order
an agent sees when they open the palette on a ticket.

"Saved Replies" moves with the reply row rather than staying put — the
two belong together, and leaving it under "Add comment" would have put a
reply action below the comment it is not related to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:46:41 +05:30
Ritvik Sardana
85a0e7f9ce refactor(command-palette): name the saved-reply rows "Saved Replies"
Drops the verb entirely. The row opens a category, the same shape as the
existing `Settings` row, and it is what the product calls the feature in
the settings tab and the doctype — so there is no invented verb to keep
in sync. "Reply with saved reply" stuttered and "Insert" read as jargon.

Root rows lose the title prefix: the reply's own name is the title and
"Saved Reply" moves to the subtitle, muted and right-aligned the way
search rows already carry "Open · #0484". A prefix spent the first
characters on chrome before the word the agent had just typed.

Two things the checks caught while renaming, both of which would have
shipped silently:

- "reply" is not a substring of "replies", so the plural title was
  unreachable by the obvious singular query — the scorer found nothing at
  all. It is now carried in `keywords`.
- My stated reason for the subtitle was wrong. A title prefix only buries
  the category when the category hides its distinguishing word mid-title
  ("Insert saved reply"). Leading with "Saved" means both rows get a
  prefix match and `CONTEXT_WEIGHT` decides, so "Saved Reply: X" would
  have ranked fine too. The subtitle is a readability choice, not a
  ranking fix, and the check now says so.

The invariant worth keeping is the general one: a category title must not
bury its own distinguishing word mid-title, because prefix (1000) against
word-boundary (~950) is wider than 1.2 vs 1.15 of weight can close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:32:00 +05:30
Ritvik Sardana
1f8978e61e refactor(command-palette): rename saved-reply rows to "Insert"
"Reply with saved reply" stuttered. "Insert saved reply" also says what
actually happens — the reply lands in the composer and the agent still
has to send it, which was the whole point of not sending on select. It is
the category-standard verb too (Front, Help Scout).

Root rows become "Insert: Refund policy", matching the parent's verb.
That pairing is not cosmetic: scoring the alternatives showed the prefix
decides the hierarchy.

- "Saved reply: X" beats the parent on any "saved" query, because a
  leading-word match scores 1000 against the parent's ~950 word-boundary
  match, and FLAT_OPTION_WEIGHT 1.15 vs CONTEXT_WEIGHT 1.2 cannot close
  a 50-point gap. One saved reply would bury the drill-down.
- "Reply: X" under an "Insert" parent inverts it the same way on "reply".
- Sharing the parent's verb keeps the parent ahead on "insert", "saved"
  and "saved reply", while "refund" still reaches the reply directly.

Side benefit: typing "reply" used to surface three rows from the reply
family at once. Now "Reply to ticket" leads it alone.

`fuzzyScore.check.ts` locks the ordering, and asserts the rejected
"Saved reply: X" pairing really does invert it, so the reason the titles
are worded this way survives the next rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:24:42 +05:30
Ritvik Sardana
aafbd91dc4 docs(command-palette): record the shipped items in the plan
Everything in "What can be done now" is done. The section is now a
record of what was built and what turned out differently, and the
"later" list is unchanged: §1 undo, §6 selection, §7 and §15 behind
Custom Side Panel, §13 SLA, §14 search cost.

Two dependencies are discharged: §9 landed before §5's root rows and
before any §7 work, so content-shaped words at root now rank above search
hits instead of replacing them. Custom Side Panel is the only hard
dependency left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:07:55 +05:30
Ritvik Sardana
5e6d05da53 docs(command-palette): trim the README from 380 lines to 95
It had become a liability rather than documentation. Two of its claims
were cited by reviewers *instead of* reading the code:

- that the palette is not rendered in the customer portal — it was not
  actually guarded until recently
- that `title_only` is a title-search fast path — it narrows the SELECT
  clause, not the MATCH, which is a materially different thing and the
  reason a body phrase can return "No results"

Both are now stated correctly, along with the pinned fallback that makes
the second survivable.

Keeps the `Command` shape, the ranking rules including scope-ranks-not-
gates, and the three non-obvious decisions: module-scope singleton state,
frappe-ui's `useShortcut` over the local one, and publishing list and
composer state module-scope rather than routing it through the URL.

Dropped: the branch narrative, the file-by-file line counts (stale the
moment anything changes), the environment workaround, and the hardcoded
`bench --site` commands. Those belong in a PR description. The open-items
list is superseded by `CP-improvements-plan.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:06:43 +05:30
Ritvik Sardana
0e80f7bc36 fix(translation): accept an array of replacements
~50 call sites pass replacements as one array — `__("{0} d", [days])`.
That reads as a single argument, so `{1}` onwards silently kept its
placeholder and `{0}` rendered the whole comma-joined array. Single
substitutions worked by accident, which is why it went unnoticed.

Accepted as an overload rather than rewritten at every call site, and
numbers are now stringified rather than assumed to be strings. Drops the
repo's `tsc` error count from 952 to 709.

Also clears the palette's own remaining type errors, all of the same
shape — an index or optional field used without a guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:06:01 +05:30
Ritvik Sardana
daea3045d8 feat(command-palette): polish pass
Ten small things, none of which change what the palette does:

- **Matched text is bold again.** `stripTags` was throwing away the
  `<mark>` runs the FTS index already computed, so search rows never
  showed *why* they matched. Rendered as text nodes, so no `v-html` and
  no sanitiser — ticket content cannot inject markup.
- **Results go stale, not blank.** `onQueryChange` cleared synchronously
  then debounced 200ms, so the list emptied on every keystroke and the
  panel resized under the cursor. Old rows now stay until new ones land;
  the token still invalidates anything in flight.
- **Loading is visible mid-list.** It only surfaced on an empty list, so
  a drill-down on a cold store showed 300ms of nothing. A 2px
  indeterminate bar sits over the input's border, so nothing shifts.
- **Group headers read as structure**, `text-xs uppercase` instead of
  `text-sm` in a different colour.
- **Per-level empty state.** A drilled-in level with nothing left showed
  the root's "No commands found", which is false.
- **Focus restore.** There is no `DialogTrigger`, so closing dropped a
  keyboard user on `<body>`. Restored only when the close left focus
  nowhere, since a command may claim it — the saved-reply rows hand focus
  to the composer, and that must win.
- **Home/End and Ctrl+N/P.**
- **640px**, up from 560, which truncated subjects around 40 characters.
- **"Keyboard shortcuts" row**, since the palette is the discovery
  surface for the shortcut system and `Mod+/` was undiscoverable from it.
  Its open flag moves module-scope alongside the other shared modals.
- Reduced-motion covers the new loading bar.

`titleRuns` is covered in `fuzzyScore.check.ts`: run splitting, adjacent
marks leaving no empty run, and other tags coming out inert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:03:49 +05:30
Ritvik Sardana
6b0cd3142b feat(command-palette): saved replies, ordered by the agent's own usage
The gap that decided whether this is a shortcut or a workflow. Composing
replies is the bulk of an agent's day and the palette did not touch it —
`Reply to ticket` opened an empty box and you typed the whole message.

"Reply with saved reply" drills into the replies, renders the picked one
against the ticket and opens the composer pre-filled. Never
send-on-select: the agent has to see what goes to a customer.

Ordering is frequency, per agent, and it applies on an **empty query
only** — once you type, `scoreCommand` decides and usage survives as the
tie-breaker, because `groupCommands` falls back to source order at equal
score. Typing "refund" must return the refund reply, never the most-used
reply that happens to fuzzy-match it. `sort` being stable makes cold
start `modified desc` for an agent with no history.

The top replies also get root-level rows, earned rather than guessed: a
reply is promoted only once this agent has used it 5 times, top 3. An
agent with long-tail habits accumulates nothing and sees no root rows,
which is the right outcome — it degrades to nothing rather than to noise.
Titles are stored with the counts, so a root row costs no fetch and
cannot block the root build. Rows carry `hideWhenEmpty`, composed titles
("Reply: Refund policy", since a bare title reads as a ticket) and
`FLAT_OPTION_WEIGHT`, so they rank under the drill-down parent. Safe at
root only because scope now ranks instead of gating.

These stay a drill-down rather than going fully flat: cardinality is
unbounded and the names are pure content, so they would collide with
ticket search on nearly every useful word. Inside the level the query
filters replies only — a scoped search, which is the correct shape here.

`replyComposer.ts` publishes the mounted composer's insert action module
scope, the same way `listViewFilters.ts` does for the list: the palette
lives in AppSidebar and cannot inject from the ticket page. An
already-open composer is left open rather than toggled, which would
discard a draft.

`saved_reply_applied` now carries `source` on both surfaces.
Ranking rules live in `savedReplyRanking.ts`, import-free, and are
covered by `savedReplyRanking.check.ts` — threshold boundaries, sort
stability, no-mutation, and losing a row when a reply is deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:57:27 +05:30
Ritvik Sardana
fe88c1f418 fix(command-palette): scope recent tickets to the signed-in agent
`hd_recent_tickets` was a bare key and `logout()` cleared it, so a shift
handover on a shared support machine handed the previous agent's ticket
**subjects** — which routinely carry customer names — to whoever logged
in next.

`userStorage.ts` suffixes the key with the session user id. It reads the
cookie rather than the auth store because this runs at module load,
before pinia is installed, and the key has to be right on the first read.
Saved-reply usage counts will want the same helper.

Also removes the two now-unscoped keys from disk on load, since scoping
only prevents the next leak, not the one already stored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:50:22 +05:30
Ritvik Sardana
cc1a262757 refactor(command-palette): delete the recent-commands MRU
A deletion, not a fix. It was broken four ways in four lines, over a
~15-command corpus where an MRU has no job:

- every leaf run was tracked, ticket-search ids included
- the window was 3 slots, so three ticket opens emptied it permanently
- ids resolved only against the static list, so misses were dropped
- drill-down commands never entered it, excluding the five most-used
  actions

Recent **tickets** stay — an agent bounces between ~5 a session and it is
the only zero-typing action in the product. The ticket currently open no
longer lists itself as somewhere to go next.

Buys back three rows for the polish batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:49:00 +05:30
Ritvik Sardana
1f6d319959 refactor(command-palette): derive the broadcast field label from meta
`FIELD_LABELS` was a hardcoded copy of `getField(fieldname).label`, so a
site that relabels Team to "Squad" would have had the palette announce
"Team" over the socket while its own UI said "Squad" — the exact drift
the upcoming Custom Side Panel work makes reachable.

`getMeta` is safe from module scope and cached per doctype, so this
shares the fetch `tiptap-extensions.ts` already makes.

Row titles stay literal on purpose: `getField` returns null until meta
lands, so deriving them would flash "Change agent_group" on a cold load.
That belongs with the rest of §15, where which fields exist and whether
they are editable get designed together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:47:23 +05:30
Ritvik Sardana
3ab9ce57a4 fix(command-palette): restore listbox roles and unbind keys from the input
Two accessibility defects, both regressions from dropping headless-ui
`Combobox` — which supplied for free what was never written back.

ARIA (§3): the list was plain `<div>`s, so a screen-reader user could
arrow ten times and hear nothing. Adds `role="combobox"` on the input
with `aria-controls` and `aria-activedescendant`, `role="listbox"` on the
list, `role="group"` per section, `role="option"` with `aria-selected`
per row, and a polite live region for the result count. Rows now carry
their flat index, which supplies the stable option id and lets
highlighting compare indexes instead of object identity — a "Recent" row
is a clone sharing its original's id.

Keyboard (§4): `@keydown` was bound to the `<input>`, but Reka's focus
trap lets Tab reach the context chip and the back button, where arrows
and Enter did nothing until the user found Shift+Tab. Bound to
`DialogContent` instead, with Enter on a focused button left alone so it
does not fire both the click and the highlighted command.

Helpdesk sells into procurement that gates on this, so it is commercial
as well as right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:46:10 +05:30
Ritvik Sardana
1630b3f4fa feat(command-palette): telemetry for opens, runs and assign source
There was not one telemetry call in the palette, so every ranking and
cut argument was taste against taste.

- `command_palette_opened`, carrying the route it was opened from
- `command_palette_command_run`, carrying command id, query length and
  drill-down depth: `runs ÷ opens` below 0.5 means agents open, miss and
  press Esc, and a long query or non-zero depth on a common action means
  the ranking is wrong
- `source` on `ticket_assigned` in both the palette and the popover, so
  the two land in one funnel and substitution is distinguishable from
  addition

Cmd+K now goes through `openPalette()`/`closePalette()` instead of
poking `isOpen` directly, or the open rate would have missed the path
most agents use.

Fixes the popover's existing call while there: it passed
`{ doctype }` where `{ data: { doctype } }` was expected, so it had been
reporting no properties at all. Plain `tsc` cannot resolve `.vue`, which
is why the shape error went unseen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:43:34 +05:30
Ritvik Sardana
5692e56287 fix(command-palette): scope must rank, not gate
Typing on a ticket page returned only that ticket's commands whenever
any of them matched, and global search never ran. `Set status: Open`
matches "open", so a ticket whose subject contains "open" was
unreachable without dismissing the context chip first.

Scoped rows already outrank global ones via `CONTEXT_WEIGHT`, so append
the global list below them instead of returning early. An empty query
still shows the ticket's commands alone — that is what the chip is for.

Also pin the fallback group whenever the query is long enough to search,
rather than only when nothing matched. `fuzzyScore` matches
subsequences, so almost any real word matched something and the escape
hatch stayed hidden exactly when it was needed — which is most visible
with `title_only: true`, where a phrase from an email body finds
nothing. Retitled from "No results" now that it sits below results.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:41:44 +05:30
Ritvik Sardana
b2c9d541a4 fix(command-palette): review fixes, flat option rows and ticket scope
Addresses the blockers from the design and PM reviews of the palette:

- assign is add-only and never drops an existing assignee; logs the
  activity and fires `capture` like the popover does
- "Assign to me" root row, hidden once the agent holds the ticket
- customer-portal gate lives with the state (`isPaletteAvailable`), not
  the mount site, since AppSidebar has two parents
- flat option rows: typing `urgent` sets the priority with no drill-down
- current value ticked; a no-op pick toasts instead of closing silently
- palette writes broadcast `notifyTicketUpdate` so co-viewers see them
- highlight follows its row across async result arrival
- filters merge through the list's own `applyFilters` rather than a
  one-way URL write (`listViewFilters.ts`)
- context chip scopes the palette to the open ticket
- `esc` moved to the footer, labelled by what it actually does

Runnable checks: `fuzzyScore.check.ts` (ranking, incl. flat-row vs
parent in both directions), `listViewFilters.check.ts` (merge,
same-field replace, clear, no-list fallback).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:39:12 +05:30
Ritvik Sardana
2ba939f586 refactor(command-palette): name components in full
CP.vue -> CommandPalette.vue, CPRow.vue -> CommandPaletteRow.vue.
The abbreviations were the only ones in the folder and read as noise
next to commands.ts / searchCommands.ts.
2026-07-28 01:48:15 +05:30
Ritvik Sardana
538e5224e1 feat(command-palette): contextual Cmd+K palette for the agent portal
Rebuilds the command palette around what the agent is currently looking
at, rather than a flat command list.

- Ticket context: status, priority, assignee, team, type, tags, reply,
  comment, merge, next/previous ticket, copy id/link. Drill-downs load
  their options on demand.
- Ticket list context: filter by status or priority, assigned to me,
  switch view, clear filters. Filters travel via the URL query so the
  palette stays decoupled from ListViewBuilder.
- Server-side FTS search over tickets, comments and communications,
  merged into the same ranked list as commands.
- Recents (MRU commands and visited tickets) on an empty query, plus a
  settings drill-down derived from the permission-filtered tabs list.

Supporting changes:

- ListViewBuilder: split view loading from filter application, so a
  filter pushed onto the URL no longer resets the user's sort, columns
  and rows. An unresolvable ?view now redirects while keeping filters.
- useTicketNavigation: functions hoisted to module scope so the palette
  can reuse them; the composable is now shortcut registration only.
- modalStates: merge modal state shared, so the palette can open it.
- layoutSettings: navigate commands derive from the sidebar's own nav
  list, so labels and icons cannot drift. Contacts icon unified.
- Removed the CommandPalette barrel export; the palette is a singleton
  that registers its own global shortcut.
2026-07-28 01:46:00 +05:30
RitvikSardana
b6f660ee26 Merge pull request #3620 from frappe/pot_develop_2026-07-26
chore: update POT file
2026-07-27 21:08:33 +05:30
RitvikSardana
4f7abdc58b Merge pull request #3625 from RitvikSardana/fix/link-filters-format
fix(customisation): convert link_filters to dict before search_link
2026-07-27 21:08:04 +05:30
Ritvik Sardana
6d48587320 refactor(home): render recent activity rows as router links
Rows were divs with a click handler, so they were not real anchors. Middle
click and cmd click did nothing and the destination never showed on hover.
router-link renders an <a href> and keeps in-app navigation.
2026-07-27 20:56:58 +05:30
Ritvik Sardana
85592330e2 fix(customisation): convert link_filters to dict before search_link
Customize Form stores link_filters as [[doctype, fieldname, operator,
value]], and parseField passed that list straight through as the filters
param of frappe.desk.search.search_link.

Most doctypes tolerate it because search_widget hands list filters to
frappe.get_list. Doctypes registered in standard_queries (User) skip that
path and receive filters untouched, and those queries only read dicts, so
user_query raises AttributeError on filters.get(). Any Link-to-User field
with link_filters set breaks the New Ticket page.

Convert to the dict form search_link expects, matching what the desk does
with get_filter_from_json.
2026-07-27 20:52:14 +05:30
RitvikSardana
e8114d5d3e Merge pull request #3624 from RitvikSardana/fix/recent-activity-fade
fix(home): keep the last recent activity row out of the bottom fade
2026-07-27 20:40:56 +05:30
RitvikSardana
fc5dc103b8 Merge pull request #3623 from RitvikSardana/dashboard-copy
fix(dashboard): show chart-specific empty states
2026-07-27 20:40:34 +05:30
Ritvik Sardana
9e787d4c31 fix(dashboard): show tag charts to managers only
Tag mix is org level insight. An agent's view is scoped to their own
tickets, where the tag breakdown reflects routing rather than anything
they can act on, so skip the section and its fetch for non managers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:53:29 +05:30
Ritvik Sardana
487f4d1c22 fix(home): keep the last recent activity row out of the bottom fade
The scroll fade is a fixed overlay, so the final row (and its hover state)
always sat under it. Pad the list by the fade height so content scrolls
clear of the gradient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:44:23 +05:30
Ritvik Sardana
b6fc94d2e0 fix(dashboard): key empty states on a stable chart key
Chart titles are translated server side, so an English-keyed lookup misses
on non-English sites. Emit a stable key from the chart config builders and
match on it. Carry the chart title in the dashboard-wide empty state too,
so those cards render headers instead of placeholder bars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:44:03 +05:30
Ritvik Sardana
327315068c fix(dashboard): show chart-specific empty states
Only the empty-state title was overridden per chart, so every empty chart
fell back to the generic "Dashboard charts will appear here" message.
Map the copy by chart title and render the real chart header instead of
skeleton bars on empty cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:55:18 +05:30
RitvikSardana
d26c4cc450 Merge pull request #3621 from RitvikSardana/feat/tag-analytics
feat: First Ticket tag and tag charts on the dashboard
2026-07-27 17:39:57 +05:30
Ritvik Sardana
465e14530f fix: track hd_agent changes 2026-07-27 17:29:33 +05:30
Ritvik Sardana
27dbf50d80 refactor(tags): check for an earlier ticket instead of counting
`raised_by` is unindexed, so counting every ticket for a requester was a
full scan on each insert. An existence check stops at the first match.
2026-07-27 16:53:54 +05:30
Ritvik Sardana
1198b0e922 fix(style): change sla due color 2026-07-27 16:31:13 +05:30
Ritvik Sardana
d5354838e6 fix(tags): fill in a missing colour on an existing tag
A Tag master minted before colours existed, or by desk's tag sidebar,
keeps a null colour and renders gray forever. Treat a missing colour
the same as a claim and fill it in; a tag that already has one still
keeps it.
2026-07-27 16:22:30 +05:30
Ritvik Sardana
8d7aabb29e feat(dashboard): add Top Tags and Tag Trend charts
Two tag charts on the dashboard, reusing the existing date, team and
agent filters. Top Tags ranks the busiest tags on a horizontal bar so
long tag names get the long axis; Tag Trend stacks the top five tags
per day so a spike in one is visible. Tags live in Tag Link rather
than a scalar column, so these join instead of reusing the group-by
helpers the other master charts use.

Bar chart value axes now step by whole numbers. Every bar chart on
this dashboard counts tickets, and half a ticket is not a thing.
2026-07-27 16:15:22 +05:30
Ritvik Sardana
55afea6641 feat(tags): tag a requester's first ticket
Tag the first ticket ever raised by a requester with "First Ticket"
instead of setting a custom field, so it filters, reports and slices
like any other tag. The count runs in after_insert, where a count of
one means this is the first, and goes through apply_tag so the Tag
master is claimed for helpdesk and shows in the picker.
2026-07-27 16:15:03 +05:30
RitvikSardana
0aa9b3882c Merge pull request #3613 from RitvikSardana/feat/ticket-layout
feat: new ticket sidepanel layout
2026-07-27 03:14:05 +05:30
Ritvik Sardana
b6636c1b33 fix: add tags in Ticket Filters 2026-07-27 02:54:38 +05:30
Ritvik Sardana
dd198e2246 fix: framework/ui build 2026-07-27 02:25:12 +05:30
Ritvik Sardana
22b15e7cc1 fix(build): resolve @framework/ui imports from the desk root
@framework/ui is linked from source, so rollup resolves its imports
starting at apps/frappe/ui, and desk/node_modules is never on that
lookup chain. dompurify and reka-ui arrive there as frappe-ui deps, so
`bench build --app helpdesk` failed on CI where apps/frappe/ui has
nothing installed.

Deduping both forces them to resolve from the project root, the same
reason vue and frappe-ui are already listed.
2026-07-27 02:04:50 +05:30
Ritvik Sardana
34229ac4c3 refactor: tags component API 2026-07-27 01:41:41 +05:30
Ritvik Sardana
4308f38d53 feat(tags): batch ticket tag edits into one request
Tag editing now runs as a session: chips update optimistically while the
picker is open, and the whole edit lands as a single update_tags call when
it closes, logging one activity instead of one per tag.

The colour step moves into its own TagColorPicker component, and the tag
components are grouped under ticket-agent/tag/.
2026-07-26 18:25:12 +05:30
frappe-pr-bot
d2c5016988 chore: update POT file 2026-07-26 11:43:09 +00:00
Ritvik Sardana
950209e4c7 fix: remove dead code 2026-07-26 16:54:16 +05:30
Ritvik Sardana
2810cb4f3f Merge branch 'develop' of https://github.com/frappe/helpdesk into feat/ticket-layout 2026-07-26 14:43:35 +05:30
Ritvik Sardana
83a55e6d42 fix: remove chevron 2026-07-25 22:20:33 +05:30
Ritvik Sardana
4171740a65 fix: hide navigation buttons 2026-07-24 18:43:33 +05:30
Ritvik Sardana
eb27fb81c9 fix: polish sidebar and priority 2026-07-24 18:36:24 +05:30
Sydney
98c998b838 refactor: reorder and clarify ticket portal insert rules 2026-07-24 16:22:32 +05:30
Sydney Gomes
feb3dc53cb Merge pull request #3618 from aerodeval/fix/comment-attachment-upload-feedback
fix: attachment upload feedback + uploads while editing comments
2026-07-24 15:19:42 +05:30
Sydney
049935b60b fix: exit comment edit mode reliably on save/discard
Toggling `editable` on an existing comment crashed Vue's patch ("Cannot read properties of null (reading 'insertBefore')" in processCommentNode) because the v-if edit affordances sit next to the renderless Editor's ProseMirror-managed DOM. The aborted update left the Save/Discard buttons stuck on screen and the comment never left edit mode.

Key the Editor on edit/view mode so switching cleanly remounts it instead of patching across ProseMirror's DOM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:03:49 +05:30
Sydney
9d68631a74 fix: allow file uploads when editing a ticket comment
The edit-comment Editor had no upload-function, so pasted or dropped media fell back to an orphaned private file (no attached_to) that 403s for other viewers. Wire up uploadFunction bound to the ticket, matching the new-comment editor.

Closes #3617

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:07:21 +05:30
Sydney
8b7958bbb6 fix: show a spinner on the attachment button while uploading
The FileUploader attach button only disabled itself during an upload with no visible indicator, so users had no way to tell why the send button was disabled. Swap the paperclip for a LoadingIndicator while uploading in the comment, email, and compact editors.

Closes #3616

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:07:20 +05:30
Ritvik Sardana
b403afc692 fix: spacing in side panel 2026-07-24 12:59:28 +05:30
Sydney
383e395535 refactor: use get_query to enforce ticket read permissions 2026-07-24 10:27:36 +05:30
Ritvik Sardana
e1bb4efbda fix: polish SLA section 2026-07-24 02:26:46 +05:30
Ritvik Sardana
7cbdd88364 chore: copy cleanup 2026-07-23 18:37:05 +05:30
Ritvik Sardana
02c6d57e76 fix: restore add_priority_integer patch
This 2023 patch was dropped in fce245316 when integer_value was replaced
by level. Old patches must never be deleted from the ledger; the new
backfill_priority_level supersedes it by addition, not removal.
2026-07-23 18:30:37 +05:30
Ritvik Sardana
67d0601fef chore: untrack stray .claude-worktrees gitlink
An accidental 160000 gitlink to the embed-private-images worktree was
committed in 865fdb4c9. Remove it and ignore .claude-worktrees so it
can't be re-added.
2026-07-23 18:30:30 +05:30
Ritvik Sardana
a15be81f08 fix: add chevron in status field 2026-07-23 18:25:11 +05:30
Ritvik Sardana
b192881de0 fix: ux for max tags 2026-07-23 18:12:30 +05:30
Ritvik Sardana
509d81b012 fix: SLA popover spacing 2026-07-23 17:30:30 +05:30
Ritvik Sardana
9cfcc1f72a fix: tags UX 2026-07-23 17:20:48 +05:30
RitvikSardana
6018181a35 Merge pull request #3610 from RitvikSardana/fix/default-portal-settings-existing-sites
fix: set default portal settings for existing sites
2026-07-23 17:08:25 +05:30
Ritvik Sardana
fbeeb0a5c3 fix: set default portal settings for existing sites
Sets Portal Settings default_role to HD Customer and
default_portal_home to /helpdesk on existing sites, only when the
values are not already set. Previously this ran on fresh installs only.
2026-07-23 16:57:26 +05:30
Ritvik Sardana
7fdcbd70d0 fix: SLA time style 2026-07-23 16:13:21 +05:30
Ritvik Sardana
aad724d1a8 chore: code cleanup 2026-07-23 14:00:03 +05:30
Ritvik Sardana
ebf49e189b fix: spacing in sections 2026-07-23 13:43:27 +05:30
Ritvik Sardana
d63a356da5 chore: update labels 2026-07-23 13:25:33 +05:30
Ritvik Sardana
274271f80a fix: polish side panel 2026-07-23 13:16:01 +05:30
Sydney
4c03293c97 refactor: derive ticket read fields from permissions 2026-07-23 10:08:54 +05:30
Sydney
89afe5614b fix: hide internal fields in ticket creation response 2026-07-23 10:08:26 +05:30
Ritvik Sardana
c61bb6df99 feat: add colors in Tags 2026-07-23 00:29:03 +05:30
RitvikSardana
146bfe33d0 Merge pull request #3608 from RitvikSardana/fix/attachment-remove
fix: unable to remove attachment
2026-07-22 22:54:40 +05:30
RitvikSardana
313e4620a4 Merge pull request #3607 from RitvikSardana/feat/home-recent-activity
feat: Recent Activity card on Home dashboard
2026-07-22 21:56:16 +05:30
Ritvik Sardana
0cc6f5d23a fix(ux): ticket value update 2026-07-22 21:25:21 +05:30
Ritvik Sardana
28a2385104 fix: line height 2026-07-22 21:00:18 +05:30
Ritvik Sardana
5038dd93a1 refactor: shared AttachmentList with lucide icon and video preview
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:50:17 +05:30
Ritvik Sardana
316a1b6292 fix: unable to remove attachment 2026-07-22 20:14:59 +05:30
Ritvik Sardana
06fc790387 fix: flaky tests in hd_agent 2026-07-22 20:01:37 +05:30
Ritvik Sardana
381073c764 refactor: API function 2026-07-22 19:29:57 +05:30
Ritvik Sardana
ae5702abf0 fix: dedupe recent activity per ticket before limiting
Group each source's events by ticket in SQL (MAX(creation)) so a single
busy ticket cannot consume the row limit and starve other eligible
tickets. Backfill the updated-action text for winning tickets, filter
inaccessible tickets before the cap, and add a regression test.
2026-07-22 18:51:07 +05:30
Ritvik Sardana
0415c75e9d fix: better list of events 2026-07-22 18:37:52 +05:30
Ritvik Sardana
2c3a354009 feat: tags in sidepanel 2026-07-22 17:52:47 +05:30
Ritvik Sardana
28465bb1a6 fix: better empty state 2026-07-22 17:49:58 +05:30
Ritvik Sardana
fd6e34b0c4 feat: recent activity 2026-07-22 17:33:07 +05:30
Ritvik Sardana
6dd3a6af57 feat: show priority level icon in agent ticket field
Render the HD Ticket Priority level icon before the selected value and
each option in the agent side panel priority Link (via the combobox
item-prefix slot, reused for the control). Keep the customer portal
priority as plain text for now.
2026-07-22 13:05:44 +05:30
Ritvik Sardana
78167274b1 fix: make patch better 2026-07-22 12:55:46 +05:30
Ritvik Sardana
7f6c42b853 chore: code cleanup 2026-07-22 12:55:46 +05:30
Ritvik Sardana
fce245316a feat: display ticket priority as icon and level
Replace the arbitrary integer_value on HD Ticket Priority with a purposeful
`level` Select field, and render priorities as an icon + name across the
agent ticket list, home pending tickets, customer portal, and SLA settings.

- Add `level` (Urgent/High/Medium/Low), remove integer_value
- Backfill patch: builtins map by name, custom priorities inherit the level
  of the nearest builtin above them (Medium when a site has no builtins)
- New TicketPriority.vue component and ticketPriority store
- Drop the now-unused priority-range colour pipeline in agent_home
2026-07-22 12:55:46 +05:30
RitvikSardana
77981e1f01 Merge pull request #3604 from RitvikSardana/feat/priority-level-icons
feat: display ticket priority as icon and level
2026-07-22 12:50:14 +05:30
Ritvik Sardana
700c48816c fix: assignee component width 2026-07-22 12:49:38 +05:30
Ritvik Sardana
04ac2c476f fix: make patch better 2026-07-22 12:13:27 +05:30
Ritvik Sardana
a48e9e5a49 chore: code cleanup 2026-07-22 11:47:06 +05:30
Ritvik Sardana
2d124b8f36 feat: display ticket priority as icon and level
Replace the arbitrary integer_value on HD Ticket Priority with a purposeful
`level` Select field, and render priorities as an icon + name across the
agent ticket list, home pending tickets, customer portal, and SLA settings.

- Add `level` (Urgent/High/Medium/Low), remove integer_value
- Backfill patch: builtins map by name, custom priorities inherit the level
  of the nearest builtin above them (Medium when a site has no builtins)
- New TicketPriority.vue component and ticketPriority store
- Drop the now-unused priority-range colour pipeline in agent_home
2026-07-22 02:07:20 +05:30
Ritvik Sardana
a16c83a51c fix: assignTo focus state and animation 2026-07-22 00:11:09 +05:30
Ritvik Sardana
20f65744a4 fix: use link field from framework 2026-07-21 18:54:28 +05:30
Ritvik Sardana
53284913fa fix: better SLA layout 2026-07-21 17:09:50 +05:30
Ritvik Sardana
865fdb4c9a fix: reposition SLA layout 2026-07-21 12:48:50 +05:30
Sydney Gomes
8ba5f214fb Merge pull request #3569 from aerodeval/feat/mobile-team-switcher
feat(mobile): team switcher in agent ticket header + mobile fixes
2026-07-21 02:27:52 +05:30
Sydney
cd63d475ad fix(desk): dark-mode styling for collapsed quote toggle in EmailEditor
The "..." collapse toggle used a hardcoded light background (#e8eaed) with
no dark variant, rendering as a bright white pill in dark mode. Add the
dark rules already used by the read-only EmailContent view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 02:11:22 +05:30
RitvikSardana
f3e4d311d3 Merge pull request #3599 from RitvikSardana/editor/paste
fix: editor link and edit comment
2026-07-20 13:00:30 +05:30
Sydney
23597cec0a refactor: clarify permlevel exemptions in portal insert rules 2026-07-20 12:59:46 +05:30
Ritvik Sardana
641ad86f1d fix(patch): editor link and edit comment 2026-07-20 12:51:45 +05:30
Sydney
d2e7ee7232 fix(mobile): ticket header row px-3 to sit on the same gutter as tabs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:41:34 +05:30
Sydney
8b359ac378 fix(mobile): align tab lists with the header hamburger icon
The hamburger icon sits 12px from the screen edge (ms-1 + button
px-2) while tab lists used 20px (px-5), so the tab row started
visibly right of the header icon. Tab lists are now px-3 on mobile
across all tabbed views: agent ticket and customer portal ticket
(mobile-only tab bars, unconditional) and Contact/Customer pages
(max-sm variant so desktop keeps the default px-5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:39:11 +05:30
Sydney
b2f01d90c9 refactor: move activity deep-links from URL hash to ?highlight param
The hash was doing two jobs on ticket pages: useActiveTabManager
stores the active tab in it, and notification/search links used it to
point at one activity. The prefix allowlist only guarded the reader;
the writer side still interleaved: the post-highlight cleanup stripped
whatever hash existed 3s later (snapping desktop tabs picked during
the highlight window back to tab 0) and dropped ?view=, and on mobile
a deep-link hash was destroyed by the tab manager before its target
could ever mount.

Deep-link targets now travel as ?highlight=<element id> while the
hash carries only tab state. useActiveTabManager needs no changes --
it already preserves route.query in every navigation, so the two
writers own disjoint fields. Generators emit #activity (renders all
activity types on both form factors) plus the highlight param, making
mobile notification/search deep-links functional for the first time.
Cleanup removes only the consumed param, keeping ?view= and the tab
the user is on.

Supersedes the deepLinkPrefixes allowlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:20:51 +05:30
Sydney
068a41d8e6 refactor: allowlist deep-link prefixes instead of tab-name denylist
Deep-link ids are always prefixed (comment-, communication-), so match
on those instead of maintaining a list of tab names that must track
every tab rename or addition. Also prefixes MobileNotifications'
mention hash the way desktop Notifications.vue already does — the bare
comment name could never match the comment-<name> element ids.

Addresses greptile review on #3569.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:54:00 +05:30
Sydney
70051fe5f2 fix(mobile): align ticket header padding with tab list (px-5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:32:55 +05:30
Sydney
af9aa8df76 fix: truncate long single-assignee name in AssignTo trigger
A single assignee's full name rendered without ellipsis because every
flex ancestor kept min-width:auto; long names overflowed the trigger.
min-w-0 down the chain (Button label span, trigger row, MultipleAvatar)
lets the existing truncate class clip with an ellipsis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:30:10 +05:30
Sydney
b5592d6f91 fix(mobile): agent availability as native dropdown submenu
AvailabilityMenuMobile nested a right-placed Popover inside the
profile Dropdown; on narrow screens there is no room to the right so
the status panel landed on top of the menu items below it (theme
toggle). Replaced with a native Dropdown submenu option (same pattern
as the Apps entry) so reka-ui keeps it positioned inside the viewport,
and deleted the now-unused component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:25:10 +05:30
Sydney
ea219b8c5b fix(mobile): match team switcher styling with assignee control
Renders the team Link through its target slot as the same outline
button AssignTo uses, and splits the header row evenly between the
two controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:44:12 +05:30
Sydney
df1787a833 fix(customer-portal): consistent text-p-sm in mobile details
Labels and values in the details tab now all use text-p-sm; the SLA
label width matches the other labels at 150px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:22:45 +05:30
Sydney
9417f76547 fix(mobile): opaque sidebar background
frappe-ui Sidebar is transparent in dark mode by design; as a fixed
overlay on mobile the page content showed through. Same fix as
frappe/suite#108.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:22:45 +05:30
Sydney
b8b8b85c8b fix(mobile): don't treat active-tab hash as activity deep-link
useActiveTabManager stores the active tab in the URL hash, while
notification/search links use the hash to deep-link to one activity.
scrollToHash() treated any hash as a deep-link and stripped it from
the URL after highlighting, so opening the Activity tab on mobile
snapped back to Details a few seconds later (the lucide sprite's
<symbol id="activity"> even gave it an element to "scroll" to).
Tab-name hashes are now excluded from deep-link handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:22:45 +05:30
Sydney
39af981753 fix: skip get_apps fetch on customer portal
Customers lack permission for frappe.apps.get_apps and the call 403s.
The sidebar remounts on portal switch, so the flag re-evaluates for
agents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:22:45 +05:30
Sydney
02810a821f feat(mobile): team switcher in agent ticket header
Adds an HD Team dropdown next to AssignTo in the mobile ticket
header so agents can reassign a ticket's team without opening the
details tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:22:45 +05:30
RitvikSardana
9c742fc313 Merge pull request #3568 from RitvikSardana/ui/sidebar
fix: SidebarItem overflow
2026-07-18 04:02:02 +05:30
Ritvik Sardana
984e5aa953 fix: rerendering of mention popover 2026-07-18 03:39:18 +05:30
Ritvik Sardana
1264551c8a fix: SidebarItem overflow 2026-07-18 03:26:13 +05:30
RitvikSardana
41561b1dc1 Merge pull request #3565 from RitvikSardana/ux/tickets-customer-contact
fix: make headers sticky in TicketsTab
2026-07-18 00:10:53 +05:30
Ritvik Sardana
db2971a5d9 chore: code cleanup 2026-07-17 19:20:51 +05:30
Ritvik Sardana
134142453c fix: make headers sticky in TicketsTab 2026-07-17 19:07:09 +05:30
Sydney Gomes
29c4dc2f2b Merge pull request #3538 from aerodeval/feat/onboarding-persona-questionnaire
feat: onboarding persona questionnaire
2026-07-17 18:59:36 +05:30
Sydney
8e3bbea28b feat: route the explore goal to the seeded welcome ticket
Falls back to the tickets list when the welcome ticket is gone or the
lookup fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:34:08 +05:30
Sydney
fbda167795 fix: cap questionnaire textarea resize
Vertical-only, between one row block and roughly twice the default height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:34:05 +05:30
Sydney
eb9089a3ec refactor: read telemetry state from boot and simplify the persona guard
Ship the pulse client config as a boot value (the same channel as lang/dir)
so telemetryEnabled() is synchronous and the guard never hits the network.
Collapse personaInterrupt onto canViewPersona, move the Persona-route check
inside it, and report persona_captured truthfully for all users — the guard
alone owns eligibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:33:54 +05:30
Sydney
cf1c863525 fix: keep the app title in branded invitation subjects
The subject now reads "You've been invited to join <brand> on <app title>"
so recipients still learn which product invited them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:33:44 +05:30
Sydney Gomes
49902e915b Merge pull request #3563 from aerodeval/fix/outlook-mso-comments
fix: strip html comments from agent reply email content
2026-07-17 15:43:41 +05:30
Sydney
2b83c4bb42 fix: translate create button label on tickets page 2026-07-17 15:23:40 +05:30
RitvikSardana
0afefdefec Merge pull request #3562 from RitvikSardana/fix/customer-primary-contact
fix: reuse existing contact as customer primary contact & accurate ticket counts
2026-07-17 14:47:44 +05:30
Sydney
513bf1c312 fix: strip html comments from agent reply email content 2026-07-17 14:20:47 +05:30
Ritvik Sardana
9fe0925f92 chore: match prettier indentation, drop unused doc prop, note tablist height coupling 2026-07-17 13:32:04 +05:30
Ritvik Sardana
b2cd9f35ce fix: ticket count in customer & contact page 2026-07-17 13:18:41 +05:30
Sydney
d9018ff7bb fix: keep brand name out of other apps' invitation emails
HelpdeskUserInvitation is registered via override_doctype_class, which
is site-wide — every User Invitation got the HD brand name as its email
title once onboarding set one, so on a multi-app site an ERPNext invite
would arrive titled with the helpdesk brand.

Defer to the framework title (per-app via the app_title hook) unless
the invitation's app_name is helpdesk. Covered both ways by tests:
helpdesk invitations get the brand, frappe invitations keep their own
title with a brand set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:09:34 +05:30
Sydney
2567f75a55 refactor: standardize onboarding routing
Access control moves onto the route: /onboarding gets a beforeEnter
gate (canViewPersona), and the global guard keeps only the
once-per-session interrupt (personaInterrupt). persona.ts sheds its
homegrown { done, to } protocol for plain vue-router idioms.

Layout moves into the route tree: portal pages nest as children of a
PortalRoot parent that picks the agent or customer shell from the
session, while /onboarding stays top-level and renders bare. That
deletes meta.fullScreen and App.vue's layout branching — the root
template is a single router-view, and the no-flash behavior becomes
structural: an unresolved route renders nothing because there is
nothing else to render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:21:26 +05:30
Sydney
c500bc53ee fix: translate and reword the questionnaire subtitle
The subtitle was the only user-facing string in the component not wrapped
in __(), so it stayed English in every locale while its siblings (Next,
Finish, Skip for now, every question title) translated.

It also renders under each question rather than just the first, so "A few
quick questions to tailor Helpdesk to how you work" narrated a flow the
reader was already several screens into — and promised tailoring under
questions that configure nothing. State the reason instead, so it holds up
on any question.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:25:06 +05:30
Sydney
7c9dc58791 feat: ask how they heard about helpdesk as chips
The referral question was a required free-text box, which is hard to
aggregate. Offer the channels people actually arrive through, Frappe
ecosystem first, and keep "Other" so the free-text nuance isn't lost.

Single-select: attribution is one answer, and it keeps the Pulse property
a scalar rather than an array.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:11:51 +05:30
Sydney
8e5cee8d06 fix: align questionnaire chip states with the select's tokens
The chips jumped from surface-gray-4 when selected to surface-gray-5 on
hover, while unselected chips had no bg hover at all — frappe-ui's outline
variant only moves the border.

Reuse the Select's own recipe (shared/selection/utils.ts) so selection
feedback is consistent across the form: alpha-gray-2 on hover, gray-3 when
selected, gray-4 when both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:11:41 +05:30
Sydney
c0cca6f238 fix: don't render the portal shell before the route resolves
App.vue picks its layout from route.meta.fullScreen, but rendered before
the first navigation resolved. At START_LOCATION meta is empty, so v-else
made PortalRoot the default and the sidebar painted for the length of the
guard's round trips before a fullScreen route (onboarding) replaced it.

Render neither branch until route.name exists. Fixing it here rather than
deferring the mount keeps theme, favicon and config init at their current
timing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:11:31 +05:30
Ritvik Sardana
534bc291bc fix: if new contact invited via customer show in toast 2026-07-16 18:30:01 +05:30
RitvikSardana
b2faee1248 Merge pull request #3555 from frappe/view-icons
fix: replace emojis with icons for custom views
2026-07-16 18:15:36 +05:30
Ritvik Sardana
36eb9fd89a fix: use clock-alert icon for SLA Alerts view
triangle-alert reads as a generic error and hourglass is already used by
the Pending Tickets view. clock-alert conveys time-based urgency, which
matches SLA breach tracking. Existing sites pick it up via the
rename_and_update_std_views patch.
2026-07-16 17:21:31 +05:30
Ritvik Sardana
4767ca00e2 fix: restore customer portal permission banner in sidebar
The sidebar refactor to AppSidebar dropped CustomerPortalPermissionBanner
along with its showPermissionNoticeBanner computed. Restore it in the
footer slot with the original visibility logic (agent portal only,
manager/admin, HD Settings notice flag).
2026-07-16 17:21:30 +05:30
Sydney
2e450082be feat: ask for "Other" detail on next instead of disabling it
Selecting "Other" reveals a free-text box. Rather than silently disabling
Next, keep it enabled and ask on click: proceed() blocks, focuses the box
and surfaces an error via frappe-ui's Textarea error prop.

The error clears on navigation and on toggling "Other" off, so re-opening
the box never shows an error the user hasn't earned.

Also make referral_source required, and carry the asterisk on the title
for questions whose title is the prompt and so have no label to hang it
on. "Please specify" served as both placeholder and error; replace it
with per-question placeholder copy and an actionable error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:11:04 +05:30
Ritvik Sardana
755ac8de7a fix: dont invite primary contact if already exists 2026-07-16 15:52:07 +05:30
Sydney
8099e23dfb fix: only gate next on text questions marked required
canProceed treated every text question as mandatory, so referral_source
blocked Next until answered even though it carries no required flag and
renders no asterisk. required was consulted for the asterisk only, never
during validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:35:40 +05:30
Sydney
deb2c7f921 test: cover persona capture and invitation email title
Adds tests for mark_persona_captured (brand name written to HD Settings
and Website Settings, site_name default for the welcome email subject,
persona_captured flag, blank brand left untouched) and for the
HelpdeskUserInvitation title override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:42:15 +05:30
Ritvik Sardana
a296498102 fix: SavedReply filter 2026-07-16 14:20:11 +05:30
Ritvik Sardana
8828d53fd7 fix: notification panel wrt sidebar 2026-07-16 13:47:03 +05:30
Sydney Gomes
1b3ccb41c0 Merge pull request #3554 from ews-pgasser/chore/german-translations
added missing msgstr for translation
2026-07-16 13:16:53 +05:30
Philipp Gasser
eb802e01a6 chore: added missing msgstr for translation 2026-07-16 09:32:18 +02:00
Ritvik Sardana
698e6d274a fix: styles 2026-07-16 12:39:46 +05:30
Sydney
d5b65d2910 feat: disable next until the current question is answered 2026-07-16 12:11:41 +05:30
Sydney
dd7bb45af8 fix: hide internal ticket fields from customers 2026-07-16 11:33:42 +05:30
Ritvik Sardana
aaa6cdf574 fix: update sidebar code with new API 2026-07-16 02:44:36 +05:30
RitvikSardana
91163b657a Merge pull request #3531 from ews-pgasser/chore/german-translations 2026-07-16 02:32:49 +05:30
Sydney
be07eb606b fix: only expose creation-form fields from templates 2026-07-16 02:05:57 +05:30
Sydney
d03db34406 fix: stamp response times on portal replies 2026-07-16 02:05:27 +05:30
Ritvik Sardana
827d865b9b fix: remove memoji package 2026-07-16 01:25:51 +05:30
Ritvik Sardana
8fa03b6478 fix(views): use icons instead of emojis 2026-07-16 01:25:11 +05:30
Sydney
b4cb599c27 fix: await onboarding navigation chain and un-fade on failure 2026-07-15 20:02:38 +05:30
Sydney
b939428949 refactor: isolate persona routing and drop guard import cycle 2026-07-15 19:47:14 +05:30
ews-pgasser
afed0f86ea chore: added newly introduced german translations by pot-file 2026-07-15 16:00:29 +02:00
Sydney
fa1430f1a8 feat: use brand name in invitation email subject 2026-07-15 19:30:17 +05:30
Sydney
b57525fabe feat: set site_name from onboarding org name for welcome email 2026-07-15 19:21:58 +05:30
Sydney
8dd9905606 perf: read persona flag from auth boot to drop guard round-trip 2026-07-15 19:09:28 +05:30
Philipp Gasser
976c195a71 Merge branch 'develop' into chore/german-translations 2026-07-15 15:15:51 +02:00
Sydney
cdc1e286a4 feat: set site-wide app name from onboarding org name 2026-07-15 18:23:35 +05:30
Sydney
5dd984e21f feat: add 'how did you hear' question to onboarding 2026-07-15 18:23:09 +05:30
Sydney
39f94615c6 feat: set brand name from onboarding org name 2026-07-15 17:57:52 +05:30
Sydney
c5f44440b3 feat: route to selected goal and refine onboarding questionnaire 2026-07-15 17:40:28 +05:30
RitvikSardana
737533d3db Merge pull request #3540 from kaulith/fix/agent-reply-no-prior-communication
fix: agent reply fails on tickets with no prior communication
2026-07-15 13:31:43 +05:30
Sydney
90ca4ef236 chore: rename onboarding event to onboarding_persona_hd 2026-07-15 12:48:35 +05:30
Sydney
3fb08b35ab refactor: persist persona flag via onboarding endpoint 2026-07-15 12:42:34 +05:30
Sydney
13212ec6e5 feat: add organization size to onboarding questionnaire 2026-07-15 12:41:51 +05:30
Sydney Gomes
f04e19a001 Merge pull request #3547 from aerodeval/fix/sla-default-blank-condition-catchall
fix: ticket SLA policy application
2026-07-15 12:11:50 +05:30
Sydney
d73f46742a test: blank SLA condition must not match ahead of the default SLA
Covers both halves of the catch-all fix: a non-default SLA with a
blank ("" or NULL) condition never matches a ticket, and demoting the
seeded "Default" SLA by promoting a new default doesn't leave the
demoted one winning every ticket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:58:46 +05:30
Sydney
5fa9d980f3 Merge upstream/develop into onboarding branch 2026-07-15 01:25:25 +05:30
RitvikSardana
06e3a4eb4c Merge pull request #3539 from frappe/pot_develop_2026-07-12
chore: update POT file
2026-07-15 00:59:41 +05:30
RitvikSardana
d632f6e95f Merge branch 'develop' into pot_develop_2026-07-12 2026-07-14 22:31:04 +05:30
RitvikSardana
78d83a9f24 Merge pull request #3522 from frappe/pot_develop_2026-07-05
chore: update POT file
2026-07-14 22:30:33 +05:30
RitvikSardana
584a1182c0 Merge pull request #3517 from frappe/l10n_develop
chore: sync translations from crowdin
2026-07-14 21:28:21 +05:30
RitvikSardana
ddf5acc9ef Merge pull request #3549 from frappe/upgrade-fui
fix: bump frappe UI to beta-24
2026-07-14 21:18:48 +05:30
Ritvik Sardana
49f14fdd98 fix: allow spaces in SavedReply 2026-07-14 17:49:45 +05:30
Ritvik Sardana
80fb83a431 fix: filter operator popover 2026-07-14 16:33:10 +05:30
Ritvik Sardana
bd2102ac20 fix: media upload in articles 2026-07-14 15:09:57 +05:30
Ritvik Sardana
ecb0a564fe fix: spacing in attachment icon 2026-07-14 14:55:34 +05:30
Sydney Gomes
9da4452311 Merge pull request #3548 from aerodeval/chore/raise-frappe-upper-bound
chore: raise upper bound limit for Frappe dependency
2026-07-14 14:39:36 +05:30
Ritvik Sardana
5c8eb2603f fix: patch beta24 suggestion-renderer 2026-07-14 14:39:16 +05:30
Sydney
6ea221f67e chore: raise upper bound limit for Frappe dependency 2026-07-14 14:26:18 +05:30
Ritvik Sardana
9b77397563 fix: commonify editor component 2026-07-14 13:36:56 +05:30
Sydney
5f63b65017 fix: default SLA checkbox double-toggles and never actually flips 2026-07-14 13:11:01 +05:30
Ritvik Sardana
1f8b5e583f chore: change frappe dependency version 2026-07-14 12:45:08 +05:30
Ritvik Sardana
c6bb72ca85 fix: migrate editor 2026-07-14 12:25:38 +05:30
Sydney
00d7bb5c84 fix: don't let a blank SLA condition act as an implicit catch-all
An enabled, non-default SLA with no condition was matching every
ticket ahead of the actual default SLA, since a blank condition
short-circuited to "always match" in the loop. This is most visible
when the seeded "Default" SLA is demoted (a different SLA is flagged
default_sla) but keeps its blank condition — it then silently wins
for every ticket regardless of which SLA is now marked default.

Only SLAs with a real condition can be matched this way now; a blank
condition no longer qualifies as a candidate, leaving default_sla as
the only legitimate catch-all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 11:12:49 +05:30
Sydney
0f33cfb647 fix: keep customer selectable at ticket creation 2026-07-14 10:43:36 +05:30
Sydney
5013fb703a feat: add company name question to onboarding 2026-07-14 10:27:12 +05:30
Ritvik Sardana
92fc5a65ee fix: label styles 2026-07-13 20:21:08 +05:30
Ritvik Sardana
3b31cb444b fix: update old colors 2026-07-13 18:32:49 +05:30
Sydney
fa266c315e fix: add back button for questions 2026-07-13 18:17:48 +05:30
Sydney Gomes
99d685a27a Merge branch 'develop' into fix/ticket-field-level-perms 2026-07-13 17:57:15 +05:30
Ritvik Sardana
79ebba3319 chore: upgrade frappe-ui to 1.0.0-beta.24
- Bump frappe-ui beta.3 -> beta.24 and @tiptap/* -> ^3.26.0
- Run espresso v2 token codemod across desk/src (surface/ink/outline
  renames, type-scale shift, and text-size+font-weight class merges)
- Dedupe tiptap/prosemirror in vite.config so the editor resolves a
  single prosemirror instance (fixes "Adding different instances of a
  keyed plugin" RangeError that blanked all rich-text editors)
- Fix TicketSLA Badge crash by returning a valid theme ("gray") during
  ticket load; beta.24 Badge rejects unknown/empty themes
- Re-apply follow-ups from the reverted bump PR: MobileSidebar i18n,
  hide TicketCustomerSidebar actions when Closed, TicketFeedback
  StarRating -> frappe-ui Rating, TicketCustomer editor optional
  chaining, vite-helpers editor-style.css submodule alias

Known issue: editor @mention and / slash-command popups do not open on
beta.24 (suggestion item list is empty at first render, so the popup
element never mounts); tracked for a follow-up fix.
2026-07-13 17:52:44 +05:30
RitvikSardana
482ebf07cf Merge pull request #3541 from RitvikSardana/customer-portal-permission-banner
fix: Customer portal permission banner
2026-07-13 16:33:42 +05:30
Ritvik Sardana
c8fc81655b fix: dont let contact field change by end user 2026-07-13 16:23:44 +05:30
RitvikSardana
65e0500094 Merge pull request #3543 from RitvikSardana/ux-alignment
fix: recent tickets alignment
2026-07-13 16:04:54 +05:30
Ritvik Sardana
0635405c64 fix: copy of dialog 2026-07-13 16:00:13 +05:30
Ritvik Sardana
9475349028 fix: bring back guest api call for config 2026-07-13 15:55:59 +05:30
Ritvik Sardana
c26cf84d15 fix: recent tickets alignment 2026-07-13 15:50:15 +05:30
Sydney
6e444c2129 test: field level permission coverage for HD Ticket 2026-07-13 15:39:13 +05:30
Sydney
c184868ee4 fix: migrate field permissions on existing sites 2026-07-13 15:39:12 +05:30
Sydney
764468333a fix: raise hidden template custom fields to permlevel 1 2026-07-13 15:39:10 +05:30
Sydney
6040ddebd3 fix: restrict agent-only ticket fields to permlevel 1 2026-07-13 15:39:08 +05:30
Sydney
ffb9cd9ed4 fix: rework onboarding questionnaire ui 2026-07-13 15:23:35 +05:30
Ritvik Sardana
a96b6ff8cc fix: add owner and creation field in bulk creation 2026-07-13 14:28:35 +05:30
Ritvik Sardana
10e6f73dc3 fix(UI): linked customers in contact page 2026-07-13 13:46:14 +05:30
Ritvik Sardana
4606250025 fix: remove guest access from get_config 2026-07-13 13:27:36 +05:30
Ritvik Sardana
0b37b43932 fix: null check in field options 2026-07-13 13:04:49 +05:30
Sydney Gomes
8f6042d342 Merge pull request #3530 from aerodeval/fix/thunderbird-formatting
fix: preserve formatting for thunderbird received emails
2026-07-13 13:02:15 +05:30
Ritvik Sardana
28eb577ee9 perf: promote contacts to managers with set-based queries
Replace the per-customer document loop with a single child-table
update and a bulk Has Role insert, evicting stale role caches since
bulk_insert skips the ORM. Flip the notice flag via set_single_value
and publish the settings-updated event explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:47:28 +05:30
Sydney
dbac204a35 fix: add pre-wrap to maintain spacing formatting 2026-07-13 12:15:39 +05:30
Ritvik Sardana
be0b6f7e48 refactor: use $socket from global store in config store
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:07:41 +05:30
Ritvik Sardana
b108f47c7b fix: update perms on banner 2026-07-13 10:42:00 +05:30
Ritvik Sardana
a28ad43a50 fix: restrict non-agents to their own contact on tickets 2026-07-13 10:25:42 +05:30
Ritvik Sardana
1891b404c0 feat: scope ticket customer field to contact's customers on customer portal 2026-07-13 10:25:33 +05:30
Kaushal Shriwas
18b59372aa fix: agent reply fails on tickets with no prior communication 2026-07-12 18:48:28 +05:30
frappe-pr-bot
b18851a0ff chore: update POT file 2026-07-12 11:35:30 +00:00
Sydney
eb6fcd5229 fix: refine onboarding persona questionnaire 2026-07-12 16:33:20 +05:30
Ritvik Sardana
a8ca6f442f fix: drop img src after setting embed so inline images render in emails
Pasted images are uploaded as private files, and parse_content kept the
original src alongside the embed attribute. The framework's cid
replacement then produced a duplicate-src img tag, so email clients
could resolve the login-gated /private/files URL instead of the inline
cid attachment. Swap src for embed (site files only, external URLs and
data URIs untouched) so the inline attachment always wins.
2026-07-12 12:53:39 +05:30
Ritvik Sardana
c81c2dfe19 chore: emphasize customer managers term in notice dialog 2026-07-12 01:59:19 +05:30
Ritvik Sardana
82765be52d refactor: guard notice endpoints with agent_manager_only decorator 2026-07-12 01:45:52 +05:30
Ritvik Sardana
5666f55117 chore: state previous portal behaviour and shorten checkbox label 2026-07-12 01:44:19 +05:30
Ritvik Sardana
0557912621 chore: simplify notice dialog copy and state what managers can do 2026-07-12 01:35:09 +05:30
Sydney
1733b75837 feat: add onboarding persona questionnaire 2026-07-11 00:54:49 +05:30
Sydney
101fc39e0f feat: add Questionnaire stepper component 2026-07-11 00:54:48 +05:30
Sydney
2cf2cfac45 feat: add persona_captured flag to HD Settings 2026-07-11 00:54:39 +05:30
Sydney Gomes
2ac1caa944 Merge pull request #3533 from frappe/mergify/aerodeval/I89bf640cfda25c6518dd8a913dec524b9b80bd52
Update workflow automation rules
2026-07-10 12:33:42 +05:30
Sydney Gomes
47d5c70d02 Update workflow automation rules
Signed-off-by: Sydney Gomes <null>
2026-07-10 12:11:11 +05:30
ews-pgasser
2cbe4355fc chore: added german translations 2026-07-09 18:19:51 +02:00
Sydney
c087f6fe10 fix: add consistent variable naming 2026-07-09 17:46:50 +05:30
Sydney
18ca7518c3 fix: add support for thunderbird mails in replied content 2026-07-09 17:36:01 +05:30
Sydney
0939bf8ff7 fix: preserve line-height for thunderbird mails 2026-07-09 16:55:30 +05:30
Sydney Gomes
77a0119309 Merge pull request #3529 from aerodeval/fix/display-time-elapsed-labels
fix: display proper labels for time elapsed
2026-07-09 15:15:48 +05:30
Ritvik Sardana
5f7a2e671c fix: sidebar banner layout 2026-07-09 14:25:32 +05:30
Sydney
bd22cf47df fix: preserve formatting for thunderbird received emails 2026-07-09 13:25:10 +05:30
Sydney
64d4499627 fix: display proper labels for time elapsed 2026-07-09 12:33:45 +05:30
Sydney Gomes
4d250f6ee8 Merge pull request #3506 from aerodeval/fix-ticket-merge
fix: redirect responses to correct merged ticket
2026-07-08 18:55:07 +05:30
Sydney
4951c74712 fix: correct invalid Check field default in HD Ticket
raised_outside_working_hours had default "False", which Frappe rejects
for Check fields (must be "0"/"1"). This broke any save_customization()
on HD Ticket, failing the field dependency form-script tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:41:53 +05:30
Sydney
6610c58c7c fix: handle replies when merge target can't be resolved
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:57:21 +05:30
Ritvik Sardana
1e16325daa feat: link permission update documentation in notice dialog 2026-07-07 22:09:34 +05:30
Ritvik Sardana
8492e6be56 feat: one-time notice for changed customer portal permissions
Agent managers see a sidebar banner explaining the new customer portal
permission model introduced by the customer contact management refactor.
The dialog offers to restore the previous behaviour by promoting every
existing customer contact to customer manager, so contacts can again see
all tickets of their customer.

The notice is enabled by a patch only on sites that have non-manager
customer members (i.e. sites migrated from the old contact model).
2026-07-07 21:22:11 +05:30
frappe-pr-bot
c80ce12f93 chore: update POT file 2026-07-05 11:10:55 +00:00
RitvikSardana
2b2d20e06e Merge pull request #3516 from RitvikSardana/editor-upload-fn
fix: pass uploadFunction prop
2026-06-30 03:16:48 +05:30
Ritvik Sardana
8fbf5c2b41 fix: null guard check 2026-06-30 01:04:45 +05:30
Ritvik Sardana
44cfd1d5cb fix: null guard check 2026-06-30 01:00:26 +05:30
Ritvik Sardana
1b26e17dc9 fix: pass uploadFunction prop 2026-06-30 00:54:34 +05:30
RitvikSardana
e093d73077 Merge pull request #3515 from RitvikSardana/route-by-customer
fix: add auto set customer setting
2026-06-29 18:47:06 +05:30
RitvikSardana
a82eabfe9d Merge pull request #3514 from RitvikSardana/fix/lv-design
fix: listview column resize & phone column
2026-06-29 18:41:40 +05:30
Ritvik Sardana
06b488221e fix: auto set customer setting 2026-06-29 18:32:42 +05:30
Ritvik Sardana
c4b07a935d fix: listview column resize, phone column & max capping 2026-06-29 18:02:33 +05:30
RitvikSardana
9d7e75c02a Merge pull request #3513 from RitvikSardana/fix/restore-doctype-permissions
fix: restore doctype permissions reverted in #3512
2026-06-29 15:27:05 +05:30
Ritvik Sardana
a5d4c45cda fix: query HD Ticket instead of Issue in support hour distribution
Restores the report query change from #3502 (reverted in #3512),
matching the report's ref_doctype change to HD Ticket.
2026-06-29 15:13:49 +05:30
Ritvik Sardana
c5d09133e5 fix: use get_list for permission-aware article category queries
Restores the knowledge base API change from #3502 (reverted in #3512):
get_list enforces user permissions where get_all bypasses them.
2026-06-29 15:13:27 +05:30
Ritvik Sardana
209bda2bac fix: restore doctype permissions reverted in #3512
Re-applies the DocType permission changes that were bundled into #3502
and lost when it was reverted (#3512):

- HD Ticket: replace 'All' with 'HD Customer' and 'HD Customer Manager'
- HD View: replace 'Guest' with 'HD Customer' and 'HD Customer Manager'
- HD Article / HD Article Category / HD Ticket Type: add 'HD Customer Manager'
- HD Ticket Template: add 'Agent Manager'

Also restores the Support Hour Distribution report export.
2026-06-29 15:10:00 +05:30
RitvikSardana
4a072f4664 Merge pull request #3512 from frappe/revert-3502-fix/bump-frappe-ui
Revert "fix: bump frappe UI"
2026-06-29 15:00:00 +05:30
RitvikSardana
3c4b6b84a9 Revert "fix: bump frappe UI" 2026-06-29 13:30:41 +05:30
Sydney
4819dfca35 fix: correctly route replies to merged tickets to the merge target 2026-06-29 00:01:27 +05:30
Sydney
076cdc759f test: add test cases for routing to correct communication 2026-06-28 22:59:02 +05:30
Sydney
644ce7926c fix: redirect replies on merged tickets to the merge target 2026-06-28 22:58:08 +05:30
517 changed files with 37997 additions and 15028 deletions

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 "${BASE_BRANCH}"
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch develop
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

15
.github/workflows/assets.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
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,21 +13,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
permissions:
packages: write
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
@@ -38,8 +32,7 @@ jobs:
- name: Set Branch
run: |
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 '[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]' > apps.json
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
- name: Set Image Tag
@@ -51,10 +44,10 @@ jobs:
repository: frappe/frappe_docker
path: builds
- name: Build and push
- name: Build
uses: docker/build-push-action@v6
with:
push: true
load: true
context: builds
file: builds/images/layered/Containerfile
tags: >
@@ -62,4 +55,14 @@ jobs:
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
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 }}

View File

@@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop"]
branch: ["develop", "main-hotfix"]
permissions:
contents: write

187
.github/workflows/ui-tests.yml vendored Normal file
View File

@@ -0,0 +1,187 @@
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

7
.gitignore vendored
View File

@@ -32,6 +32,7 @@ share/python-wheels/
.playwright-mcp
.claude-flow
.claude
.claude-worktrees
CLAUDE.md
@@ -41,4 +42,8 @@ helpdesk/public/frontend
helpdesk/public/node_modules
helpdesk/www/helpdesk/index.html
desk/components.d.ts
desk/stats.html
desk/stats.html
# Playwright
e2e/.auth
playwright-report
test-results

View File

@@ -1,45 +1,42 @@
pull_request_rules:
- 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 }}"
- 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 }}"

View File

@@ -157,8 +157,12 @@ 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, prefer Lucide for new components)
- Use Lucide icons for all new components:
- **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:
```vue
<template>
<LucidePlus class="size-4" />
@@ -211,7 +215,9 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
**Data Query Patterns**:
- Always prefer `frappe.qb.get_query()` over `frappe.db.get_all()` for new code
- 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.
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
- Example query patterns from helpdesk context
@@ -261,6 +267,7 @@ class HDFoo(Document):
- 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:**
@@ -367,7 +374,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
- Only add comments that explain why something is done, not what is done
- Keep a comment to 2 lines at most. If it needs more to explain, simplify the code instead
- Keep dependencies updated and audit for security vulnerabilities
## References

View File

@@ -7,5 +7,8 @@
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']
}

View File

@@ -6,9 +6,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
"serve": "vite preview",
"postinstall": "patch-package"
},
"dependencies": {
"@framework/ui": "link:../../frappe/ui",
"@headlessui/vue": "^1.7.22",
"@tailwindcss/typography": "^0.5.9",
"@tiptap/core": "^3.26.0",
@@ -19,12 +21,13 @@
"@tiptap/starter-kit": "^3.26.0",
"@twilio/voice-sdk": "^2.15.0",
"@vee-validate/zod": "^4.8.2",
"@vitejs/plugin-vue": "^4.2.3",
"@vueuse/core": "^13.8.0",
"@vueuse/integrations": "^13.8.0",
"@vitejs/plugin-vue": "^4.6.2",
"@vueuse/core": "^14.1.0",
"@vueuse/integrations": "^14.1.0",
"dayjs": "^1.11.7",
"frappe-ui": "1.0.0-beta.14",
"gemoji": "^8.1.0",
"echarts": "^6.1.0",
"grid-layout-plus": "^1.1.0",
"frappe-ui": "1.0.0-rc.1",
"mime": "^3.0.0",
"pinia": "^2.0.33",
"sanitize-html": "^2.10.0",
@@ -36,17 +39,23 @@
"zod": "^3.21.4"
},
"devDependencies": {
"@vitejs/plugin-vue-jsx": "^3.0.1",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"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",
"vite": "^4.4.9",
"vite-plugin-pwa": "0.15.0"
"unplugin-icons": "^22.1.0",
"vite": "^5.4.21",
"vite-plugin-pwa": "0.17.5"
},
"resolutions": {
"cheerio": "1.0.0-rc.12",
"prosemirror-model": "1.25.4"
"prosemirror-model": "1.25.9",
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.41.9",
"prosemirror-transform": "1.12.0"
}
}

View File

@@ -0,0 +1,70 @@
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

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<span>
<a :href="isShowable ? undefined : url" target="_blank">
<a :href="preview ? undefined : url" target="_blank">
<Button
:label="label"
theme="gray"
@@ -18,12 +18,22 @@
<Dialog v-model:open="showDialog" :title="label" size="4xl">
<template #default>
<div
v-if="isText"
v-if="preview === 'text'"
class="prose prose-sm max-w-none whitespace-pre-wrap"
>
{{ content }}
</div>
<img v-if="isImage" :src="url" class="m-auto rounded border" />
<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"
/>
</template>
</Dialog>
</span>
@@ -66,6 +76,27 @@ const ICONS: Record<AttachmentKind, Component> = {
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";
@@ -77,16 +108,13 @@ function getKind(mime: string): AttachmentKind {
const showDialog = ref(false);
const mimeType = getMime(props.label) || "";
const kind = getKind(mimeType);
const isImage = kind === "image";
const isText = kind === "text";
const isShowable = props.url && (isText || isImage);
const icon = ICONS[kind];
const icon = ICONS[getKind(mimeType)];
const preview = props.url ? PREVIEWS[mimeType] : undefined;
const content = ref("");
function toggleDialog() {
if (!isShowable) return;
if (isText) {
if (!preview) return;
if (preview === "text") {
fetch(props.url).then((res) => res.text().then((t) => (content.value = t)));
}
showDialog.value = !showDialog.value;

View File

@@ -0,0 +1,33 @@
<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

@@ -1,287 +0,0 @@
<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 -ms-0.5">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="
() => {
!disabled && togglePopover();
}
"
>
<div class="flex items-center truncate">
<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-ink-gray-4">
{{ placeholder || "" }}
</span>
</div>
<span
class="lucide-chevron-down h-4 w-4 text-ink-gray-7"
aria-hidden="true"
/>
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-base py-1 text-base shadow-2xl"
>
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full pe-6 bg-transparent"
type="text"
:value="query"
autocomplete="off"
placeholder="Search"
@change="
(e) => {
query = e.target.value;
}
"
/>
<button
class="absolute inset-y-0 end-3 top-px flex items-center"
@click="selectedValue = null"
>
<span class="lucide-x size-4" aria-hidden="true" />
</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-medium text-ink-gray-4"
>
{{ 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-surface-gray-2': 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-ink-gray-5"
>
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,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { Popover } from "frappe-ui";
import { computed, nextTick, ref, useAttrs, useSlots, watch } 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-ink-gray-5" : "text-ink-gray-8";
});
const inputClasses = computed(() => {
let sizeClasses = {
sm: "text-base rounded h-7",
md: "text-base rounded h-8",
lg: "text-md 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-outline-gray-1 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",
outline:
"border border-outline-gray-2 bg-surface-base placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm 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",
disabled: [
"border bg-surface-sidebar placeholder-ink-gray-3",
props.variant === "outline"
? "border-outline-gray-2"
: "border-transparent",
],
}[variant];
return [
sizeClasses,
paddingClasses,
variantClasses,
textColor.value,
"transition-colors w-full",
];
});
defineExpose({ query });
</script>

View File

@@ -6,10 +6,10 @@
variant="subtle"
@update:model-value="agentStatusStore.setMyStatus"
>
<template #item-prefix="{ option }">
<template #item-prefix="{ item }">
<div
class="size-2 rounded-full flex-shrink-0"
:class="agentStatusStore.statusColor(option.value)"
:class="agentStatusStore.statusColor(item.value)"
/>
</template>
</Select>

View File

@@ -1,63 +0,0 @@
<template>
<Popover
placement="right-start"
class="flex w-full"
trigger="hover"
:hoverDelay="0.1"
:leaveDelay="0.1"
>
<template #target="{ togglePopover }">
<button
class="group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2"
@click.prevent="togglePopover()"
>
<div class="flex min-w-0 items-center gap-2">
<div class="flex items-center size-4">
<span
class="size-2.5 mx-auto shrink-0 rounded-full"
:class="agentStatusStore.statusColor(agentStatusStore.myStatus)"
/>
</div>
<span class="truncate">
{{
agentStatusStore.myStatus
? __(agentStatusStore.myStatus)
: __("Set status")
}}
</span>
</div>
</button>
</template>
<template #body="{ close }">
<div
class="flex flex-col mx-3 p-1.5 rounded-lg border border-outline-gray-1 bg-surface-base shadow-xl min-w-[140px]"
>
<button
v-for="option in agentStatusStore.statusOptions"
:key="option"
class="flex items-center gap-2 rounded p-1.5 text-sm text-ink-gray-8 hover:bg-surface-gray-2"
@click="selectStatus(option, close)"
>
<span
class="size-2.5 shrink-0 rounded-full"
:class="agentStatusStore.statusColor(option)"
/>
<span class="whitespace-nowrap">{{ __(option) }}</span>
</button>
</div>
</template>
</Popover>
</template>
<script setup lang="ts">
import { Popover } from "frappe-ui";
import { useAgentStatusStore } from "@/stores/agentStatus";
import { __ } from "@/translation";
const agentStatusStore = useAgentStatusStore();
function selectStatus(status: string, close: () => void) {
agentStatusStore.setMyStatus(status);
close();
}
</script>

View File

@@ -76,7 +76,8 @@ const props = defineProps({
},
});
const currentDuration = ref(__("Last month"));
// the untranslated key: it is lowercased straight into the API period
const currentDuration = ref("Last month");
const chartData = computed(() => {
const isDataFetched = resource.fetched;

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" />
<HDLogo v-else class="h-8 w-8 shrink-0 rounded-4" />
</template>
<script setup lang="ts">

View File

@@ -26,7 +26,7 @@
</div>
<div
@click="showCallLogDetailModal = true"
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-md 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-5 bg-surface-elevation-1 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">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col w-full h-full rounded-md p-4"
class="flex flex-col w-full h-full rounded-5 p-4"
:class="[orientation == 'horizontal' && 'pt-3']"
>
<slot name="title">
@@ -22,21 +22,26 @@
</slot>
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
<div class="flex items-center gap-1" :class="percentageChange.color">
<span :class="percentageChange.icon" class="size-4" />
<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="rtl:flex rtl:gap-1">
<span>vs</span> <span>{{ currentDuration.toLowerCase() }}</span>
<div class="flex gap-1">
<span>vs</span>
<span>{{ __(currentDuration).toLowerCase() }}</span>
</div>
<span class="lucide-chevron-down size-4" aria-hidden="true" />
<LucideChevronDown class="size-4" />
</div>
<template #item-label="{ item }">
<div
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded 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"
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 }}
@@ -44,10 +49,9 @@
</div>
</template>
<template #item-suffix="{ item }">
<span
<LucideCheck
v-if="item.label == __(currentDuration)"
class="lucide-check size-4"
aria-hidden="true"
class="size-4"
/>
</template>
</Dropdown>
@@ -76,19 +80,23 @@
</div>
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
<div class="flex items-center gap-1" :class="percentageChange.color">
<span :class="percentageChange.icon" class="size-4" />
<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() }}
<span class="lucide-chevron-down size-4" aria-hidden="true" />
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 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"
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 }}
@@ -96,10 +104,9 @@
</div>
</template>
<template #item-suffix="{ item }">
<span
<LucideCheck
v-if="item.label == __(currentDuration)"
class="lucide-check size-4"
aria-hidden="true"
class="size-4"
/>
</template>
</Dropdown>
@@ -109,9 +116,12 @@
</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, ECharts } from "frappe-ui";
import { Dropdown } from "frappe-ui";
import { ECharts } from "frappe-ui/experimental";
import { computed, type PropType } from "vue";
const props = defineProps({
@@ -150,27 +160,14 @@ const currentDuration = computed(() => props.currentDuration);
const emit = defineEmits(["changeDuration"]);
const durationOptions = [
{
label: __("Last week"),
onClick: () => {
if (currentDuration.value == __("Last week")) return;
emit("changeDuration", __("Last week"));
},
// 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);
},
{
label: __("Last month"),
onClick: () => {
if (currentDuration.value == __("Last month")) return;
emit("changeDuration", __("Last month"));
},
},
{
label: __("Last 3 months"),
onClick: () => {
if (currentDuration.value == __("Last 3 months")) return;
emit("changeDuration", __("Last 3 months"));
},
},
];
}));
</script>

View File

@@ -1,357 +0,0 @@
<template>
<div class="flex-col text-base flex-1" ref="commentBoxRef">
<div class="mb-2 flex items-center justify-between">
<div class="text-ink-gray-5 flex items-center gap-2">
<Avatar
size="md"
:label="commenter"
:image="getUser(commentedBy).user_image"
/>
<p>
<span class="font-medium text-ink-gray-8">
{{ commenter }}
</span>
<span> {{ __(" commented") }}</span>
</p>
</div>
<div class="flex items-center gap-1">
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<span class="ps-0.5 text-sm text-ink-gray-5">
{{ timeAgo(creation) }}
</span>
</Tooltip>
<div v-if="authStore.userId === commentedBy && !editable">
<Dropdown
:placement="'right'"
:options="dropdownOptions"
@click="isConfirmingDelete = false"
>
<Button
icon="lucide-more-horizontal"
class="text-ink-gray-5"
variant="ghost"
/>
</Dropdown>
</div>
</div>
</div>
<div
:id="`comment-${name}`"
class="rounded-md bg-surface-gray-1 transition-colors px-3 py-1.5"
>
<Editor v-model="_content" :extensions="extensions" :editable="editable">
<template #default="{ editor }">
<EditorBubbleMenu :editor="editor" :items="textEditorMenuItems" />
<EditorContent
:editor="editor"
:class="[
'prose-f shrink text-p-sm transition-all duration-300 ease-in-out block w-full content',
getFontFamily(_content),
]"
@keydown.ctrl.enter.capture.stop="handleSaveComment"
@keydown.meta.enter.capture.stop="handleSaveComment"
/>
<div v-if="editable" class="flex flex-row-reverse gap-2">
<div>
<Button
:label="
isMobileView
? 'Save'
: isMac
? 'Save (⌘ + ⏎)'
: 'Save (Ctrl + ⏎)'
"
@click="handleSaveComment"
variant="solid"
/>
</div>
<Button label="Discard" @click="handleDiscard" />
</div>
</template>
</Editor>
<div
class="flex flex-wrap gap-2 mb-2"
v-if="!editable && Boolean(attachments.length)"
>
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
:url="a.file_url"
/>
</div>
<div
class="flex items-center gap-2 my-2"
v-if="!editable && enableCommentReactions"
>
<Popover>
<template #target="{ togglePopover }">
<button
class="flex h-full items-center justify-center rounded-full bg-surface-gray-2 px-1 py-1 text-ink-gray-6 transition hover:bg-surface-gray-3"
@click="togglePopover()"
>
<ReactionIcon class="w-4 h-4" />
</button>
</template>
<template #body>
<div
class="bg-surface-base rounded-lg shadow-lg p-2 border border-outline-gray-2"
>
<div class="grid grid-cols-6 gap-2">
<button
v-for="emoji in emojiList"
:key="emoji"
class="size-6 flex items-center justify-center rounded hover:bg-surface-gray-2 text-md transition-colors"
@click="handleReaction(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</template>
</Popover>
<template v-for="reaction in reactionsList" :key="reaction.emoji">
<Tooltip>
<template #body>
<div
class="bg-surface-gray-10 px-2 py-1 text-center text-p-xs text-ink-base shadow-xl rounded"
>
<span v-for="(user, idx) in reaction.users" :key="user.user"
>{{ user.full_name
}}<span v-if="idx < reaction.users.length - 1">, </span></span
>
</div>
</template>
<button
class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors"
:class="
reaction.current_user_reacted
? 'bg-surface-blue-2 text-ink-blue-6 hover:bg-surface-blue-3'
: 'bg-surface-gray-3 text-ink-gray-6 hover:bg-surface-gray-4'
"
v-if="reaction.count !== 0"
@click="handleReaction(reaction.emoji)"
>
<span>{{ reaction.emoji }}</span>
<span class="font-medium">{{ reaction.count }}</span>
</button>
</Tooltip>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { AttachmentItem } from "@/components";
import ReactionIcon from "@/components/icons/ReactionIcon.vue";
import { useDevice } from "@/composables";
import { useScreenSize } from "@/composables/screen";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { updateRes as updateComment } from "@/stores/knowledgeBase";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { CommentActivity } from "@/types";
import { textEditorMenuItems } from "@/editor-menu";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import {
ConfirmDelete,
dateFormat,
dateTooltipFormat,
getFontFamily,
isContentEmpty,
timeAgo,
} from "@/utils";
import {
Avatar,
Dropdown,
Popover,
Tooltip,
createResource,
toast,
} from "frappe-ui";
import {
Editor,
EditorBubbleMenu,
EditorContent,
RichTextKit,
} from "frappe-ui/editor";
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 { enableCommentReactions } = useConfigStore();
const { name, creation, content, commenter, commentedBy, attachments } =
props.activity;
const { isMac } = useDevice();
const { isMobileView } = useScreenSize();
const isTicketMergedComment = computed(() => {
const regex = /has been merged with ticket #\d+/;
return regex.test(content);
});
const agentStore = useAgentStore();
const userMentions = computed(() => agentStore.dropdown ?? []);
const extensions = [
RichTextKit.configure({ mention: { items: userMentions } }),
ComponentUtils,
HandleExcelPaste,
CleanStyles,
];
const emit = defineEmits(["update"]);
const isConfirmingDelete = ref(false);
const editable = ref(false);
const _content = ref(content);
const emojiList = ["👍", "👎", "❤️", "🎉", "👀", "✅"];
const dropdownOptions = computed(() => [
{
label: "Edit",
onClick: () => handleEditMode(),
icon: "lucide-edit-2",
condition: () => !isTicketMergedComment.value,
},
...ConfirmDelete({
onConfirmDelete: () => deleteComment.submit(),
isConfirmingDelete,
}),
]);
// editor.commands.focus('end')
const reactions = ref<
Array<{
emoji: string;
count: number;
users: Array<{ user: string; full_name: string }>;
current_user_reacted: boolean;
}>
>([]);
const reactionsList = computed(() => reactions.value || []);
const fetchReactions = createResource({
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.get_reactions",
makeParams: () => ({ comment: name }),
auto: true,
onSuccess(data) {
reactions.value = data || [];
},
});
const toggleReaction = createResource({
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.toggle_reaction",
makeParams: (emoji: string) => ({ comment: name, emoji }),
onSuccess() {
fetchReactions.reload();
},
});
function handleReaction(emoji: string) {
const previousReaction = reactions.value.find(
(r) => r.current_user_reacted && r.emoji !== emoji
);
if (previousReaction) {
previousReaction.count = Math.max(0, previousReaction.count - 1);
previousReaction.current_user_reacted = false;
}
const existingReaction = reactions.value.find((r) => r.emoji === emoji);
if (existingReaction) {
if (existingReaction.current_user_reacted) {
existingReaction.count = Math.max(0, existingReaction.count - 1);
existingReaction.current_user_reacted = false;
} else {
existingReaction.count += 1;
existingReaction.current_user_reacted = true;
}
} else {
reactions.value.push({
emoji,
count: 1,
current_user_reacted: true,
users: [
{
user: authStore.userId,
full_name: getUser(authStore.userId).full_name,
},
],
});
}
toggleReaction.submit(emoji);
}
const commentBoxRef = ref(null);
const lastSavedContent = ref(content);
const commentBoxState = ref(content);
function handleEditMode() {
editable.value = true;
commentBoxState.value = _content.value;
}
function handleDiscard() {
_content.value = commentBoxState.value;
editable.value = false;
}
const deleteComment = createResource({
url: "frappe.client.delete",
makeParams: () => ({
doctype: "HD Ticket Comment",
name: name,
}),
onSuccess() {
emit("update");
toast.success(__("Comment deleted successfully."));
},
});
function handleSaveComment() {
if (lastSavedContent.value === _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;
lastSavedContent.value = _content.value;
emit("update");
toast.success(__("Comment updated successfully."));
},
}
);
}
onMounted(() => {
// hack to persist the width of comment box to prevent it from resizing when the content is updated
commentBoxRef.value.style.width = "0px";
});
</script>

View File

@@ -1,96 +1,92 @@
<template>
<Editor
v-if="agentsList.data"
ref="editorRef"
v-model="newComment"
:extensions="extensions"
:placeholder="placeholder"
:editable="editable"
:upload-function="(file:any)=>uploadFunction(file, doctype, ticketId)"
:placeholder="placeholder"
:upload-function="
(file: any, options: any) =>
track(uploadFunction(file, doctype, ticketId, true, options))
"
>
<template #default="{ editor }">
<EditorContent
:editor="editor"
:class="[
'prose-sm max-w-none',
editable &&
'min-h-[7rem] mx-5 max-h-[44vh] overflow-y-auto border-t py-3',
getFontFamily(newComment),
]"
/>
<!-- Attachments -->
<div class="flex flex-wrap gap-2 my-2 ms-5">
<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>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.stop="removeAttachment(a)"
/>
</template>
</AttachmentItem>
<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 overflow-hidden py-2.5">
<div class="flex items-center overflow-x-auto w-[60%]">
<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
:upload-args="{
doctype: doctype,
docname: ticketId,
private: true,
}"
:doctype="doctype"
:docname="ticketId"
private
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector, uploading }">
{{ void (loading = uploading) }}
<button
class="flex rounded p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
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="ml-0.5 mr-1 h-5 w-px bg-surface-gray-3" />
<div class="h-4 w-[2px] border-s ml-1" />
</div>
<EditorFixedMenu :editor="editor" :items="textEditorMenuItems" />
<EditorFixedMenu :items="fullToolbar" />
<EditorTableMenu />
</div>
<div class="flex items-center justify-end gap-x-2 w-[40%]">
<div class="flex shrink-0 items-center justify-end gap-x-2">
<Button
label="Discard"
@click="
() => {
newComment = '';
attachments = [];
dropUnused(null);
emit('discard');
}
"
/>
<Button
variant="solid"
:label="label"
:disabled="isDisabled"
:loading="loading"
@click="
() => {
loading = true;
submitComment();
newComment = '';
}
"
/>
<!-- 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>
</div>
</div>
@@ -99,27 +95,34 @@
</Editor>
</template>
<script setup lang="ts">
import { FileUploader, createResource } from "frappe-ui";
import {
FileUploader,
LoadingIndicator,
Tooltip,
createResource,
dayjs,
toast,
} from "frappe-ui";
import {
Editor,
EditorContent,
EditorFixedMenu,
RichTextKit,
EditorTableMenu,
} from "frappe-ui/editor";
import { useOnboarding } from "frappe-ui/frappe";
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
import { useOnboarding } from "@framework/ui";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AttachmentItem } from "@/components/";
import { AttachmentList } from "@/components/";
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
import { AttachmentIcon } from "@/components/icons/";
import { useTyping } from "@/composables/realtime";
import { textEditorMenuItems } from "@/editor-menu";
import { useUploadTracker } from "@/composables/useUploadTracker";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { capture } from "@/telemetry";
import {
getFontFamily,
isContentEmpty,
@@ -129,9 +132,11 @@ import {
import { useStorage } from "@vueuse/core";
import { storeToRefs } from "pinia";
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
const { isManager } = useAuthStore();
const authStore = useAuthStore();
const { isManager } = authStore;
const { getUser } = useUserStore();
const props = defineProps({
ticketId: {
@@ -156,34 +161,25 @@ const props = defineProps({
},
});
const emit = defineEmits(["submit", "discard"]);
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
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;
return isContentEmpty(newComment.value) || loading.value || isUploading.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);
@@ -193,28 +189,58 @@ 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: newComment.value,
attachments: attachments.value,
},
args: { content, attachments: sentAttachments },
}),
onSuccess: () => {
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");
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");
},
});
@@ -224,15 +250,12 @@ async function submitComment() {
const editorRef = ref(null);
const editor = computed(() => editorRef.value?.editor);
const extensions = [
RichTextKit.configure({
heading: { levels: [2, 3, 4, 5, 6] },
mention: { items: dropdown },
}),
ComponentUtils,
HandleExcelPaste,
CleanStyles,
];
// Watch for changes in comment content to trigger typing events
watch(newComment, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
});
onMounted(() => {
if (
@@ -246,6 +269,9 @@ onMounted(() => {
});
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);
}

View File

@@ -51,10 +51,15 @@
:to-emails="toEmails"
:cc-emails="ccEmails"
:bcc-emails="bccEmails"
@submit="
@sending="
() => {
showEmailBox = false;
emit('update');
}
"
@submit="() => emit('update')"
@restore="
() => {
showEmailBox = true;
}
"
@discard="
@@ -88,10 +93,15 @@
:editable="showCommentBox"
:doctype="doctype"
placeholder="@John could you please look into this?"
@submit="
@sending="
() => {
showCommentBox = false;
emit('update');
}
"
@submit="() => emit('update')"
@restore="
() => {
showCommentBox = true;
}
"
@discard="
@@ -141,16 +151,14 @@ 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() {
if (emailEditorRef.value.submitMail()) {
emit("update");
}
emailEditorRef.value.submitMail();
}
function submitComment() {
if (commentTextEditorRef.value.submitComment()) {
emit("update");
}
commentTextEditorRef.value.submitComment();
}
function splitIfString(str: string | string[]) {
@@ -226,39 +234,48 @@ 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,
() => {
if (showEmailBox.value) {
(event) => {
if (showEmailBox.value && !isIgnored(event)) {
showEmailBox.value = false;
}
},
{
ignore: [
".tippy-box",
".tippy-content",
".PopoverContent",
'[role="dialog"]',
".dialog-overlay",
],
ignore: IGNORED_SELECTORS,
}
);
onClickOutside(
commentBoxRef,
() => {
if (showCommentBox.value) {
(event) => {
if (showCommentBox.value && !isIgnored(event)) {
showCommentBox.value = false;
}
},
{
ignore: [
".tippy-box",
".tippy-content",
".PopoverContent",
'[role="dialog"]',
".dialog-overlay",
],
ignore: IGNORED_SELECTORS,
}
);
</script>

View File

@@ -3,82 +3,70 @@
<Editor
ref="editorRef"
v-model="internalContent"
:extensions="editorExtensions"
:upload-function="uploadFn"
:extensions="extensions"
v-bind="uploadFn ? { uploadFunction: uploadFn } : {}"
:placeholder="placeholder"
>
<template #default="{ editor }">
<template #default>
<EditorBubbleMenu :items="commentToolbar" />
<div
class="flex items-center overflow-x-auto rounded-t border border-b-0 border-[--surface-gray-2] px-2 py-1"
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
:upload-args="{ private: true }"
@success="addAttachment"
>
<FileUploader private @success="addAttachment">
<template #default="{ openFileSelector, uploading }">
{{ syncUploadingState(uploading) }}
<button
class="flex rounded p-1 text-ink-gray-8 transition-colors hover:bg-surface-gray-3 disabled:opacity-40"
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" />
<div class="h-4 w-[2px] border-s ml-1" />
</div>
<EditorFixedMenu :editor="editor" :items="savedReplyMenuItems" />
<EditorFixedMenu :items="fullToolbar" />
<EditorTableMenu />
</div>
<EditorContent :editor="editor" :class="editorClass" />
<EditorContent :class="editorClass" />
</template>
</Editor>
<div
<AttachmentList
v-if="showAttachments && attachments?.length"
class="flex flex-wrap gap-2 mt-2"
>
<AttachmentItem
v-for="attachment in attachments ?? []"
:key="attachment.file_url"
:label="attachment.file_name"
:url="attachment.file_url"
>
<template #suffix>
<span
class="lucide-x h-3.5 cursor-pointer"
aria-hidden="true"
@click.self.stop="removeAttachment(attachment)"
/>
</template>
</AttachmentItem>
</div>
class="mt-2"
:attachments="attachments ?? []"
@remove="removeAttachment"
/>
</div>
</template>
<script setup lang="ts">
import { AttachmentItem } from "@/components";
import { AttachmentIcon } from "@/components/icons";
import { savedReplyMenuItems } from "@/editor-menu";
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
import { AttachmentList } from "@/components";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { isContentEmpty, uploadFunction } from "@/utils";
import { FileUploader, type UploadedFile } from "frappe-ui";
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,
RichTextKit,
EditorTableMenu,
} from "frappe-ui/editor";
import { computed, nextTick, onMounted, ref, watch } from "vue";
@@ -91,7 +79,6 @@ const props = withDefaults(
maxHeight?: string;
extensions?: any[];
showSignature?: boolean;
type?: "Saved Reply" | "Email";
showAttachments?: boolean;
uploadFn?: UploadFunction;
}>(),
@@ -99,11 +86,9 @@ const props = withDefaults(
placeholder: "",
minHeight: "min-h-[180px]",
maxHeight: "max-h-80",
extensions: () => [ComponentUtils, HandleExcelPaste, CleanStyles],
extensions: () => [],
showSignature: false,
type: "Saved Reply",
showAttachments: false,
uploadFn: (file: File) => uploadFunction(file),
}
);
@@ -115,31 +100,20 @@ const attachments = defineModel<UploadedFile[] | null>("attachments", {
});
const isUploading = ref(false);
const savedReplyClass = [
"!prose-sm max-w-full overflow-auto py-1.5 px-2",
"rounded-b border border-[--surface-gray-2] bg-surface-gray-2",
"placeholder-ink-gray-4",
"hover:border-outline-elevation-2 hover:shadow-sm",
"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 -mt-0.5",
];
const extensions = buildEditorExtensions({ extra: props.extensions });
const emailClass = [
"!prose-sm max-w-full overflow-auto py-1.5 px-2",
"rounded-b border border-[--surface-gray-2] bg-surface-base",
"placeholder-ink-gray-4",
"text-ink-gray-8 transition-colors -mt-0.5",
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(() => [
...(props.type === "Email" ? emailClass : savedReplyClass),
...contentClass,
props.minHeight,
props.maxHeight,
]);
const editorExtensions = computed(() => [RichTextKit, ...props.extensions]);
const userResource = getUserEmailInfo();
function getDefaultContent(signature: string): string {

View File

@@ -1,17 +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>
<span
:class="['h-4 w-4', lucideClass(action.icon)]"
aria-hidden="true"
/>
<Icon :icon="action.icon" class="h-4 w-4" />
</template>
</Button>
</div>
@@ -20,12 +17,9 @@
<Dropdown v-slot="{ open }" :options="g.action">
<Button :label="g.label">
<template #suffix>
<span
:class="[
'h-4',
open ? 'lucide-chevron-up' : 'lucide-chevron-down',
]"
aria-hidden="true"
<component
:is="open ? LucideChevronUp : LucideChevronDown"
class="h-4"
/>
</template>
</Button>
@@ -38,9 +32,11 @@
</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";
import { lucideClass } from "@/utils";
const props = defineProps({
actions: {

View File

@@ -16,7 +16,7 @@
<Button
variant="solid"
theme="red"
icon-left="trash-2"
icon-left="lucide-trash-2"
:label="__('Delete')"
:loading="isDeleting"
@click="confirmDelete"
@@ -28,7 +28,7 @@
<script setup lang="ts">
import { __ } from "@/translation";
import { Button, Checkbox, createListResource, Dialog } from "frappe-ui";
import { Button, Checkbox, createResource, Dialog } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps<{
@@ -45,16 +45,18 @@ const props = defineProps<{
const open = defineModel<boolean>({ default: false });
const resource = createListResource({
doctype: "HD Ticket",
filters: { [props.linkField]: props.name },
fields: ["name"],
limit: 99999, // We just want the count
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?.length ?? 0;
return resource.data ?? 0;
});
const deleteLinkedTickets = ref(false);

View File

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

View File

@@ -1,300 +0,0 @@
<template>
<div
:id="`communication-${name}`"
v-bind="$attrs"
class="grow cursor-pointer bg-surface-base rounded-md text-base leading-6 transition-all duration-300 ease-in-out border border-outline-gray-2"
>
<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 || "Guest" }}</p>
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<p class="text-xs md:text-sm text-ink-gray-5">
{{ timeAgo(creation) }}
</p>
</Tooltip>
<p class="sm:flex hidden text-sm text-ink-gray-5" 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 || "Guest" }}</span>
<span
class="sm:flex hidden text-sm text-ink-gray-5"
v-if="sender.name"
>{{ "<" + sender.name + ">" }}</span
>
</div>
<div class="flex gap-2 items-center">
<div class="gap-0.5 flex items-center">
<Badge
v-if="status.label && !ticket?.doc?.via_customer_portal"
:label="__(status.label)"
variant="subtle"
:theme="status.color"
class="me-1.5"
/>
<Tooltip
:text="dateFormat(creation, dateTooltipFormat)"
v-if="!isMobileView"
>
<p class="text-xs md:text-sm text-ink-gray-5">
{{ timeAgo(creation) }}
</p>
</Tooltip>
</div>
<div class="flex items-center gap-1">
<Button :tooltip="__('Reply')" variant="ghost" @click="reply">
<template #icon>
<ReplyIcon class="text-ink-gray-7" />
</template>
</Button>
<Button :tooltip="__('Reply All')" variant="ghost" @click="replyAll">
<template #icon>
<ReplyAllIcon class="text-ink-gray-7" />
</template>
</Button>
<Dropdown
v-if="showSplitOption"
:placement="'right'"
:options="[
{
label: 'Split Ticket',
icon: LucideSplit,
onClick: () => (showSplitModal = true),
},
]"
>
<Button
icon="lucide-more-horizontal"
class="!text-ink-gray-7"
variant="ghost"
/>
</Dropdown>
</div>
</div>
</div>
<!-- <div class="text-sm leading-5 text-ink-gray-5">
{{ subject }}
</div> -->
<div class="text-p-sm text-ink-gray-5">
<template
v-for="(val, label) in { To: to, cc: cc, bcc: bcc }"
:key="label"
>
<span v-if="val" class="me-1.5">
<span class="me-1 text-ink-gray-7">{{ label }}:</span>
<span> {{ normalizeAndFilter(val).join(", ") }}</span>
</span>
</template>
</div>
<div class="border-0 border-t my-3 border-outline-elevation-2 !-mx-3" />
<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 { useAuthStore } from "@/stores/auth";
import { TicketSymbol } from "@/types";
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
import { Dropdown } from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, inject, ref } from "vue";
import LucideSplit from "~icons/lucide/split";
import { ReplyAllIcon, ReplyIcon } from "./icons";
import TicketSplitModal from "./ticket/TicketSplitModal.vue";
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 ticket = inject(TicketSymbol)!;
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 normalizeAndFilter = (
field: string | string[],
valuesToExclude: string[] = []
) => {
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(Boolean).filter((item) => !valuesToExclude.includes(item));
};
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 exclude = [user, sender.name];
const filteredTo = normalizeAndFilter(to, exclude);
const filteredCc = normalizeAndFilter(cc, exclude);
const filteredBcc = normalizeAndFilter(bcc, exclude);
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

@@ -18,7 +18,11 @@ const props = defineProps({
});
const iframeRef = ref<HTMLIFrameElement | null>(null);
const _content = ref(stripEmailColors(props.content));
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(() => {
@@ -44,7 +48,11 @@ const gmailReplyToContent = doc.querySelectorAll("div.gmail_quote");
const outlookReplyToContent = doc.querySelectorAll("div#appendonsend");
const replyToContent = doc.querySelectorAll("p.reply-to-content");
if (gmailReplyToContent.length) {
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) {
_content.value = parseReplyToContent(doc, "div.gmail_quote", true);
} else if (outlookReplyToContent.length) {
_content.value = parseReplyToContent(doc, "div#appendonsend");
@@ -179,6 +187,7 @@ const htmlContent = computed(
.email-content {
word-break: break-word;
}
.email-content :is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
border-width: 0;
@@ -196,7 +205,7 @@ const htmlContent = computed(
</style>
</head>
<body>
<div class="email-content prose-f">${_content.value}</div>
<div class="email-content prose-f">${themedContent.value}</div>
</body>
</html>
`

View File

@@ -2,206 +2,216 @@
<Editor
ref="editorRef"
v-model="newEmail"
:extensions="extensions"
:placeholder="placeholder"
:editable="editable"
:upload-function="(file:any)=>uploadFunction(file, doctype, ticketId)"
:extensions="extensions"
:upload-function="
(file: any, options: any) =>
track(uploadFunction(file, doctype, ticketId, true, options))
"
>
<template #default="{ editor }">
<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')"
<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="[
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()"
class="w-full"
:placeholder="__('')"
:options="from"
/>
</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>
<EditorBubbleMenu :editor="editor" :items="articleToolbar" />
<div
class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col"
@keydown.capture="handleKeydown"
>
<div class="flex-1">
<EditorContent
:editor="editor"
:class="[
'prose-sm max-w-full mx-6 md:mx-5 py-3',
getFontFamily(newEmail),
'[&_p.reply-to-content]:hidden',
]"
<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="quotedContent"
class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
v-if="showBCC || bcc"
class="mx-5 flex items-center gap-2 py-2.5"
:class="bcc || showBCC ? 'border-b' : ''"
>
<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"
<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>
</div>
<!-- Attachments -->
<div class="flex flex-wrap gap-2 px-5 my-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>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.self.stop="removeAttachment(a)"
<!-- 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"
/>
</template>
</AttachmentItem>
</div>
<!-- Editor Fixed Menu -->
<div
class="flex justify-between overflow-scroll px-4 py-2.5 items-center border-t"
>
<div class="flex items-center overflow-x-auto w-[60%]">
<div class="inline-flex items-center gap-1.5 p-1">
<FileUploader
:upload-args="{
doctype: doctype,
docname: ticketId,
private: true,
}"
@success="
(f) => {
attachments.push(f);
}
"
>
<template #default="{ openFileSelector, uploading }">
{{ void (isUploading = uploading) }}
<button
class="flex rounded p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
@click="openFileSelector()"
:disabled="uploading"
>
<AttachmentIcon
class="h-4 w-4"
style="stroke-width: 1.5 !important"
/>
</button>
</template>
</FileUploader>
<button
class="flex rounded p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
@click="showSavedRepliesSelectorModal = true"
>
<SavedReplyIcon class="h-4 w-4" />
</button>
<div class="ml-0.5 mr-1 h-5 w-px bg-surface-gray-3" />
</div>
<EditorFixedMenu :editor="editor" :items="textEditorMenuItems" />
</div>
<div class="flex items-center justify-end gap-x-2 sm:mt-0 w-[40%]">
<Button label="Discard" @click="handleDiscard" />
<Button
variant="solid"
:disabled="isDisabled"
:loading="sendMail.loading"
:label="label"
@click="
() => {
submitMail();
}
"
/>
<!-- Attachments -->
<AttachmentList
class="px-5 my-2"
:attachments="attachments"
@remove="removeAttachment"
/>
<!-- Saved reply actions, applied once the reply is sent -->
<SavedReplyActions
ref="savedReplyActionsRef"
class="mx-5 my-2"
:ticket-id="ticketId"
:doctype="doctype"
/>
<!-- Fixed Menu -->
<div
class="flex justify-between gap-2 overflow-scroll px-4 py-2.5 items-center border-t"
>
<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') : ''"
>
<span class="inline-flex">
<Button
variant="solid"
:disabled="isDisabled"
:loading="sendMail.loading"
:label="label"
@click="
() => {
submitMail();
}
"
/>
</span>
</Tooltip>
</div>
</div>
</div>
</template>
@@ -215,18 +225,20 @@
</template>
<script setup lang="ts">
import { AttachmentItem, SavedRepliesSelectorModal } from "@/components";
import { AttachmentList, SavedRepliesSelectorModal } from "@/components";
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
import EmailMultiSelect from "@/components/EmailMultiSelect.vue";
import { createDialog } from "@/components/dialogs";
import { AttachmentIcon } from "@/components/icons";
import SavedReplyActions from "@/components/SavedReplyActions/SavedReplyActions.vue";
import { useTyping } from "@/composables/realtime";
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
import { textEditorMenuItems } from "@/editor-menu";
import { useUploadTracker } from "@/composables/useUploadTracker";
import { replyComposer } from "@/pages/ticket/modalStates";
import { useAuthStore } from "@/stores/auth";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { RenderedSavedReply } from "@/types";
import {
getFontFamily,
htmlToText,
@@ -236,16 +248,22 @@ import {
validateEmailWithZod,
} from "@/utils";
import { useStorage } from "@vueuse/core";
import { FileUploader, createResource, toast } from "frappe-ui";
import {
FileUploader,
LoadingIndicator,
Tooltip,
createResource,
dayjs,
toast,
} from "frappe-ui";
import {
Editor,
EditorBubbleMenu,
EditorContent,
EditorFixedMenu,
RichTextKit,
articleToolbar,
EditorTableMenu,
} from "frappe-ui/editor";
import { useOnboarding } from "frappe-ui/frappe";
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
import { useOnboarding } from "@framework/ui";
import {
computed,
nextTick,
@@ -254,7 +272,7 @@ import {
ref,
watch,
} from "vue";
import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
import ZapIcon from "~icons/lucide/zap";
// ─── Props & Emits ────────────────────────────────────────────
const props = defineProps({
@@ -292,22 +310,19 @@ const props = defineProps({
},
});
const emit = defineEmits(["submit", "discard"]);
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const authStore = useAuthStore();
const { isManager } = authStore;
const { getUser } = useUserStore();
const { onUserType, cleanup } = useTyping(props.ticketId);
const extensions = buildEditorExtensions();
const editorRef = ref(null);
const editor = computed(() => editorRef.value?.editor);
const extensions = [
RichTextKit.configure({ heading: { levels: [2, 3, 4, 5, 6] } }),
ComponentUtils,
HandleExcelPaste,
CleanStyles,
];
function focusEditorAtStart() {
setTimeout(() => {
editorRef.value?.editor?.commands?.focus("start");
@@ -330,13 +345,6 @@ function isOnlySignature(content: string | null) {
const userResource = getUserEmailInfo();
watch(newEmail, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
cachedEmail.value = isOnlySignature(newValue) ? null : newValue;
});
const quotedContent = useStorage<null | string>(
"quotedEmailBoxContent" + props.ticketId,
null
@@ -350,42 +358,6 @@ function onQuotedInput() {
quotedContent.value = el.innerHTML || null;
}
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 }
);
onMounted(() => {
if (quotedContent.value) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = quotedContent.value;
}
});
}
});
const toEmailsClone = ref([...props.toEmails]);
const ccEmailsClone = ref([...props.ccEmails]);
const bccEmailsClone = ref([...props.bccEmails]);
@@ -438,18 +410,14 @@ const from = computed(() => {
const hasMultipleSenders = computed(() => (from?.value.length ?? 0) > 1);
watch(
from,
(fromOptions) => {
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
fromEmail.value = fromOptions.length ? fromOptions[0].value : "";
}
},
{ immediate: true }
);
const attachments = ref([]);
const isUploading = ref(false);
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);
@@ -457,40 +425,90 @@ async function removeAttachment(attachment) {
}
const showSavedRepliesSelectorModal = ref(false);
const savedReplyActionsRef = ref<InstanceType<typeof SavedReplyActions>>();
function applySavedReplies(template: string) {
/** 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(template).run();
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: () => ({
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:
newEmail.value +
(quotedContentRef.value
? `<p class="reply-to-content"></p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
: ""),
},
}),
onSuccess: () => {
resetState();
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");
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,
});
@@ -507,6 +525,8 @@ 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 &&
@@ -518,7 +538,64 @@ function submitMail() {
return false;
}
sendMail.submit();
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() {
@@ -535,6 +612,13 @@ function addToReply(
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;
@@ -550,6 +634,7 @@ function addToReply(
focusEditorAtStart();
}
// Staged actions are left to `submit()`, which unstages them either way
function resetState() {
newEmail.value = emailSignature.value ? emailSignature.value : null;
attachments.value = [];
@@ -560,6 +645,7 @@ function resetState() {
function handleDiscard() {
attachments.value = [];
savedReplyActionsRef.value?.clear();
newEmail.value = getInitialContent();
quotedContent.value = null;
ccEmailsClone.value = [];
@@ -568,6 +654,7 @@ function handleDiscard() {
showBCC.value = false;
isQuoteExpanded.value = false;
dropUnused(null);
focusEditorAtStart();
emit("discard");
}
@@ -582,6 +669,9 @@ function handleSelectAll(e: KeyboardEvent) {
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();
@@ -623,7 +713,6 @@ function handleKeydown(e: KeyboardEvent) {
const key = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && key === "a") {
isQuoteExpanded.value = true;
handleSelectAll(e);
return;
}
@@ -634,13 +723,80 @@ function handleKeydown(e: KeyboardEvent) {
}
}
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 ?? ""}`);
});
defineExpose({
addToReply,
editor,
isUploading,
submitMail,
});
</script>

View File

@@ -1,8 +1,7 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<div ref="pills" class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
@@ -11,7 +10,7 @@
:tooltip="copyOnClick ? __('Click to copy') : undefined"
:class="[
{
'rounded border !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
'rounded-4 !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
},
copyOnClick
@@ -22,11 +21,7 @@
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.stop="removeValue(value)"
/>
<LucideX class="size-3.5" @click.stop="removeValue(value)" />
</template>
</Button>
<div class="flex-1 min-w-32">
@@ -39,7 +34,7 @@
highlight-on-hover
>
<ComboboxAnchor
class="flex h-7 max-w-full w-auto items-center gap-2 rounded px-2 py-1 border border-transparent"
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'
@@ -62,7 +57,7 @@
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
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
@@ -70,28 +65,28 @@
>
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
<ComboboxEmpty
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
class="flex gap-2 rounded-4 px-2 py-1 text-base text-ink-gray-5"
>
<span class="lucide-search h-4" aria-hidden="true" />
<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 flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
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"
class="mr-0.5 shrink-0"
:name="getUsernameLabel(option.label)"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base-medium">
<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="text-sm text-ink-gray-5">
<div class="truncate text-sm text-ink-gray-5">
{{ option.isCustom ? customEmailLabel : option.value }}
</div>
</div>
@@ -113,6 +108,8 @@
</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";
@@ -151,7 +148,7 @@ const errorMessage = (value) => __("{0} is an Invalid Email Address", [value]);
const values = defineModel();
// Common state
const emails = ref([]);
const pills = ref(null);
const search = ref(null);
const error = ref(null);
const info = ref(null);
@@ -171,6 +168,7 @@ const filterContacts = createResource({
label: full_name || email_id || name,
value: email_id || name,
email: email_id,
name,
})),
});
@@ -205,19 +203,12 @@ const options = computed(() => {
if (props.existingUsers?.length) {
list = list.filter((o) => !props.existingUsers.includes(o.value));
}
if (query.value) {
const q = query.value.toLowerCase();
list = list.filter(
(o) =>
o.label?.toLowerCase().includes(q) ||
o.value?.toLowerCase().includes(q)
);
}
return list;
return list.filter(matchesQuery);
}
// contacts mode
let list = filterContacts.data || [];
// 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));
}
@@ -236,6 +227,13 @@ const options = computed(() => {
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(
@@ -310,24 +308,32 @@ 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;
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
if (document.activeElement === emailRef) {
const pill = lastPill();
if (document.activeElement === pill) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef;
emailRef?.focus();
lastPill()?.focus();
} else {
setFocus();
}
});
} else {
emailRef?.focus();
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();

View File

@@ -52,9 +52,10 @@
<span
v-if="descriptionText"
:class="{
'text-center text-xs text-ink-gray-6 mt-1': text === 'sm',
'text-center text-sm text-ink-gray-6 mt-1': text === 'md' || !text,
'text-center text-base text-ink-gray-6 mt-1': text === 'lg',
'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) }}

View File

@@ -0,0 +1,18 @@
<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

@@ -1,77 +0,0 @@
<template>
<div class="flex-1">
<div
class="mt-0.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-ink-gray-5 w-4/6 text-p-base" v-else>
<span class="font-medium text-ink-gray-8">
{{ user }}
</span>
<span> {{ content }}</span>
</div>
<div class="text-ink-gray-5 text-sm w-2/6 flex justify-end">
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<span>{{ timeAgo(creation) }}</span>
</Tooltip>
</div>
</div>
<div v-if="show_others" class="space-y-2.5 mt-3.5">
<div
v-for="relatedActivity in relatedActivities"
:key="relatedActivity.creation"
class="flex justify-between text-base"
>
<div class="text-ink-gray-5 w-4/6">
<span class="font-medium text-ink-gray-8">
{{ relatedActivity.user }}
</span>
<span> {{ relatedActivity.content }}</span>
</div>
<Tooltip
:text="dateFormat(relatedActivity.creation, dateTooltipFormat)"
>
<div class="text-ink-gray-5 text-sm 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 { computed, ref } from "vue";
const props = defineProps({
activity: {
type: Object,
required: true,
},
});
const { user, content, creation } = props.activity;
const relatedActivities = computed(() => props.activity.relatedActivities);
let show_others = ref(false);
</script>

View File

@@ -2,20 +2,18 @@
<div v-if="isEmoji(icon)" v-bind="$attrs">
{{ icon }}
</div>
<span
v-else-if="typeof icon == 'string' && icon.startsWith('lucide-')"
:class="icon"
v-bind="$attrs"
/>
<span
<SpriteIcon
v-else-if="typeof icon == 'string'"
:class="`lucide-${icon}`"
:name="icon.replace('lucide-', '')"
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

@@ -1,115 +0,0 @@
<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-elevation-2 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="lucide-plus"
@click.stop="() => (reaction = false)"
/>
</div>
<div v-else class="my-3 max-w-max transform bg-surface-base px-4 sm:px-0">
<div
class="relative max-h-96 pb-3 overflow-y-auto min-w-40 rounded-lg bg-surface-elevation-2 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-elevation-2 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-outline-blue-2"
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

@@ -12,7 +12,7 @@
/>
<button
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/40 text-ink-base opacity-0 outline-none transition focus:outline-none focus-visible:outline-none group-hover:opacity-100"
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()"
@@ -21,7 +21,7 @@
</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"
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 = ''"
>

View File

@@ -71,7 +71,8 @@ const props = defineProps({
},
});
const currentDuration = ref(__("Last month"));
// the untranslated key: it is lowercased straight into the API period
const currentDuration = ref("Last month");
const chartData = computed(() => {
const isDataFetched = resource.fetched;

View File

@@ -57,14 +57,13 @@
</template>
<script setup>
import { Button, Dropdown } from "frappe-ui";
import {
Button,
Dropdown,
ListGroupHeader,
ListGroupRows,
ListRow,
ListRows,
} from "frappe-ui";
} from "frappe-ui/experimental";
import { computed, ref, watch } from "vue";
import IconMoreHorizontal from "~icons/lucide/more-horizontal";

View File

@@ -40,7 +40,7 @@
v-if="list.loading && !list.data?.data?.length"
class="flex items-center justify-center h-full w-full absolute top-0 z-100"
>
<LoadingIndicator :scale="8" />
<LoadingIndicator class="size-6 text-ink-gray-4" />
</div>
<!-- List View -->
<ListView
@@ -86,9 +86,19 @@
</ListRows>
<ListSelectBanner v-if="options.showSelectBanner">
<template #actions="{ selections, unselectAll }">
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
<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>
</template>
</ListSelectBanner>
</ListView>
@@ -111,7 +121,17 @@
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>
<!-- Empty State -->
<EmptyState
@@ -147,22 +167,27 @@ import { getIcon } from "@/utils";
import { useStorage } from "@vueuse/core";
import {
createResource,
dayjsLocal,
Dropdown,
frappeRequest,
LoadingIndicator,
TabButtons,
toast,
} from "frappe-ui";
import {
Icon as SpriteIcon,
ListFooter,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
ListView,
LoadingIndicator,
dayjs,
toast,
} from "frappe-ui";
} from "frappe-ui/experimental";
import {
computed,
h,
onMounted,
onUnmounted,
provide,
reactive,
ref,
@@ -172,6 +197,7 @@ import {
import { useRoute, useRouter } from "vue-router";
import EmptyState from "./EmptyState.vue";
import { listFilters } from "./listViewFilters";
import ListRows from "./ListRows.vue";
interface P {
@@ -242,7 +268,7 @@ const defaultOptions = reactive({
label: __("Delete"),
variant: "solid",
theme: "red",
iconLeft: "trash-2",
iconLeft: "lucide-trash-2",
onClick({ close }) {
handleBulkDelete(close, selections);
},
@@ -391,7 +417,12 @@ const exposeFunctions = {
unselectAll: () => {},
};
function selectBannerOptions(selections: Set<string>, unselectAll = () => {}) {
/** Banner actions. `inline` picks the ones shown as buttons; the rest fill the "..." menu. */
function selectBannerOptions(
selections: Set<string>,
unselectAll = () => {},
inline = false
) {
exposeFunctions["unselectAll"] = unselectAll;
// Get the user-provided actions
@@ -414,8 +445,10 @@ function selectBannerOptions(selections: Set<string>, unselectAll = () => {}) {
onClick: () => action.onClick?.(selections),
}));
// Return combined actions
return [...userActions, ...defaultActions];
return [...userActions, ...defaultActions].filter(
(action) =>
Boolean(action.inline) === inline && (action.condition?.() ?? true)
);
}
const rows = computed(() => {
@@ -445,9 +478,9 @@ function getGroupedByRows(listRows, groupByField) {
group: option || " ",
collapsed: false,
rows: filteredRows,
icon: h("span", {
class: ["lucide-folder", "h-4 w-4 flex-shrink-0 text-ink-gray-6"],
"aria-hidden": "true",
icon: h(SpriteIcon, {
name: "folder",
class: "h-4 w-4 flex-shrink-0 text-ink-gray-6",
}),
};
groupedRows.push(groupDetail);
@@ -529,7 +562,7 @@ function listCell(column: any, row: any, item: any, idx: number) {
if (column.type === "Datetime") {
return h("span", {
class: "text-base",
textContent: dayjs(item).fromNow(),
textContent: dayjsLocal(item).fromNow(),
});
}
if (column.type === "MultipleAvatar") {
@@ -568,16 +601,11 @@ function handleFieldClick(e: MouseEvent, column, row, item) {
}
if (column.type === "MultipleAvatar") {
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;
}
applyColumnFilter(column.key, "LIKE", `%${item}%`);
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}%`);
return;
}
applyColumnFilter(column.key, "=", item);
@@ -616,6 +644,14 @@ 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);
@@ -743,38 +779,66 @@ function handleReload() {
}
function handleViewChanges() {
let currentView: View = findCurrentView();
if (!currentView) {
router.push({ name: route.name });
reload(true);
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;
return;
}
// normalize so legacy dict-format saved views become list conditions
defaultParams.filters = normalizeFilters(currentView.filters);
defaultParams.order_by = currentView.order_by || "modified desc";
defaultParams.columns = currentView.columns;
defaultParams.rows = currentView.rows;
const overriddenFields = new Set(urlFilters.map((c) => c[0]));
defaultParams.filters = urlFilters.length
? [...viewFilters.filter((c) => !overriddenFields.has(c[0])), ...urlFilters]
: [];
}
if (route.query.filters) {
try {
const parsedFilters = normalizeFilters(
JSON.parse(route.query.filters as string)
);
if (parsedFilters.length > 0) {
const overriddenFields = new Set(parsedFilters.map((c) => c[0]));
defaultParams.filters = [
...normalizeFilters(defaultParams.filters).filter(
(c) => !overriddenFields.has(c[0])
),
...parsedFilters,
];
}
} catch (e) {
console.error("Failed to parse filters from URL", e);
}
/** 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() {
@@ -789,15 +853,14 @@ 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,
(val: string) => {
defaultParams.view.name = val;
handleViewChanges();
if (!val) {
headerView.value.label = __("List");
headerView.value.icon = LucideAlignJustify;
}
[() => route.query.view as string, () => route.query.filters as string],
([view], [previousView]) => {
if (view !== previousView && !switchToView(view)) return;
applyUrlFilters();
list.submit({ ...defaultParams });
}
);

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-wrap gap-2 rounded-lg bg-surface-gray-2 p-2">
<div class="flex flex-wrap gap-2 rounded-6 bg-surface-gray-2 p-2">
<Pill
v-for="item in items"
:key="item.value"

View File

@@ -1,8 +1,12 @@
<template>
<div v-if="_avatars?.length" class="me-1.5 flex cursor-pointer items-center">
<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="flex items-center gap-2 text-base line-clamp-1"
class="user-avatar flex min-w-0 items-center gap-2 text-base line-clamp-1"
:data-name="_avatars[0].name"
>
<Tooltip :text="_avatars[0].name">
<Avatar
@@ -11,24 +15,34 @@
:label="_avatars[0].label"
size="sm"
/>
<div class="truncate" v-if="!hideName">{{ _avatars[0].label }}</div>
<div class="min-w-0 truncate" v-if="!hideName">
{{ _avatars[0].label }}
</div>
</Tooltip>
</div>
<Tooltip
v-for="avatar in _avatars"
v-else
:key="avatar.name"
:text="avatar.name"
>
<Avatar
class="user-avatar -mr-1.5 ring-2 ring-[var(--surface-base)] transition hover:z-10 hover:scale-110"
shape="circle"
:image="avatar.image"
:label="avatar.label"
:size="size"
:data-name="avatar.name"
/>
</Tooltip>
<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>
</div>
</template>
<script setup lang="ts">
@@ -37,8 +51,10 @@ 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,
type: [String, Array],
},
size: {
type: String,
@@ -48,6 +64,12 @@ const props = defineProps({
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(() => {
@@ -71,4 +93,25 @@ 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

@@ -1,60 +0,0 @@
<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

@@ -19,7 +19,7 @@
{{ avatar.label }}
</p>
<Tooltip v-if="badge" :text="badge.tooltip ?? ''">
<Badge :label="badge.label" :theme="badge.theme ?? 'orange'" />
<Badge :label="badge.label" :theme="badge.theme ?? 'amber'" />
</Tooltip>
</div>
<div v-if="!isMobileView" class="flex items-center gap-x-1.5">
@@ -69,7 +69,7 @@ interface DocInfoItem {
interface BadgeInfo {
label: string;
theme?: "gray" | "blue" | "green" | "orange" | "red";
theme?: "gray" | "blue" | "green" | "amber" | "red";
tooltip?: string;
}

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-lg-medium text-ink-gray-9">
<div v-if="title" class="text-md-medium text-ink-gray-9">
{{ title }}
</div>
</slot>

View File

@@ -10,10 +10,10 @@
<slot name="prefix" />
</template>
<template #suffix>
<Tooltip>
<template #body>
<Tooltip bare>
<template #content>
<div
class="rounded bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
class="rounded-4 bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
>
<span class="flex items-center gap-1">
{{ show ? "Hide Password" : "Show Password" }}

View File

@@ -38,6 +38,6 @@ const handleClick = (label) => {
};
defineExpose({
rootRef: computed(() => buttonRef.value?.rootRef),
rootRef: computed(() => buttonRef.value?.$el),
});
</script>

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,279 @@
<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

@@ -1,17 +1,10 @@
<template>
<Dialog
v-model:open="show"
size="4xl"
bare
@vue:unmounted="resetFilter"
@after-leave="onAfterLeave"
>
<Dialog v-model:open="show" size="4xl" bare @after-leave="onAfterLeave">
<template #default>
<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>
<Button
variant="solid"
icon-left="lucide-plus"
:label="__('New')"
@click="onNewSavedReplyClick"
@@ -42,20 +35,9 @@
class="absolute end-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="filters" placement="right">
<Button
:label="activeFilter"
icon-left="lucide-filter"
class="p-4"
>
<template #suffix>
<p
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-base pt-px text-xs-medium text-ink-gray-8 shadow-sm"
v-if="savedReplyListResource?.data?.length"
>
{{ savedReplyListResource?.data?.length }}
</p>
</template>
<Dropdown :options="filters" align="end">
<Button :label="activeFilterLabel">
<template #prefix><FilterIcon class="h-4" /></template>
</Button>
</Dropdown>
</div>
@@ -72,40 +54,67 @@
!savedReplyListResource?.list?.loading &&
savedReplyListResource?.data?.length
"
class="grid grid-cols-1 md:grid-cols-3 gap-2 pb-36"
class="grid grid-cols-1 md:grid-cols-3 gap-3 pb-36"
>
<div
v-for="template in savedReplyListResource?.data"
:key="template.name"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-surface-gray-2 relative"
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"
@click="onTemplateSelect(template)"
>
<div class="text-base-semibold truncate border-b pb-2">
{{ template.title }}
</div>
<Editor
v-if="template.message"
:model-value="template.message"
:editable="false"
:extensions="extensions"
>
<template #default="{ editor }">
<EditorContent
:editor="editor"
class="flex-1 overflow-hidden pointer-events-none !prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none"
<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])"
/>
</template>
</Editor>
</Tooltip>
</div>
<div
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>
<div
v-if="
selectedTemplate.name === template.name &&
selectedTemplate.isLoading
"
class="flex items-center justify-center absolute top-0 start-0 w-full h-full bg-surface-gray-10/20 rounded-lg"
class="flex items-center justify-center absolute top-0 start-0 w-full h-full rounded-6"
>
<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="
@@ -127,12 +136,16 @@
</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 { SavedReply } from "@/types";
import { RenderedSavedReply, SavedReply } from "@/types";
import { useStorage } from "@vueuse/core";
import {
Badge,
Button,
createListResource,
createResource,
@@ -140,14 +153,16 @@ import {
Dropdown,
LoadingIndicator,
TextInput,
Tooltip,
} from "frappe-ui";
import { Editor, EditorContent, RichTextKit } from "frappe-ui/editor";
import { Editor, EditorContent } from "frappe-ui/editor";
import { storeToRefs } from "pinia";
import { computed, nextTick, ref, watch } from "vue";
import { computed, h, nextTick, ref, watch } from "vue";
import {
setActiveSettingsTab,
showSettingsModal,
} from "./Settings/settingsModal";
const extensions = buildEditorExtensions();
const props = defineProps({
doctype: {
@@ -160,42 +175,48 @@ const props = defineProps({
},
});
const extensions = [RichTextKit];
const show = defineModel();
const activeFilter = useStorage("saved-replies-filter", "Personal");
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
storeToRefs(useConfigStore());
const filters = computed(() => {
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"),
},
const scopes = [
{ label: __("All"), value: "All" },
{ label: __("Personal"), value: "Personal" },
{ label: __("My Team(s)"), value: "Team" },
{ label: __("Global"), value: "Global" },
];
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
options.pop();
scopes.pop();
}
return options;
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)
),
},
}));
});
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 &&
@@ -213,7 +234,7 @@ const selectedTemplate = ref({
name: "",
isLoading: false,
});
const pendingTemplate = ref<string | null>(null);
const pendingTemplate = ref<RenderedSavedReply | null>(null);
function onAfterLeave() {
if (pendingTemplate.value !== null) {
@@ -228,7 +249,7 @@ const scope = computed(() => {
const savedReplyListResource = createListResource({
doctype: "HD Saved Reply",
fields: ["name", "title", "owner", "scope", "message"],
fields: ["name", "title", "owner", "scope", "message", "actions"],
filters: {
scope: scope.value == "All" ? undefined : ["=", scope.value],
},
@@ -236,9 +257,46 @@ const savedReplyListResource = createListResource({
auto: true,
orderBy: "modified desc",
start: 0,
pageLength: 999,
pageLength: 20,
});
// 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;
},
});
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 = {
@@ -251,7 +309,7 @@ const onTemplateSelect = (template: SavedReply) => {
saved_reply_id: template.name,
ticket_id: props.ticketId,
},
onSuccess: (data: string) => {
onSuccess: (data: RenderedSavedReply) => {
selectedTemplate.value = {
name: "",
isLoading: false,
@@ -260,7 +318,9 @@ const onTemplateSelect = (template: SavedReply) => {
if (!show.value) return;
pendingTemplate.value = data;
show.value = false;
capture("saved_reply_applied");
// Shared with the palette: modal picks count toward its most-used rows.
recordSavedReplyUse(template.name, template.title);
capture("saved_reply_applied", { data: { source: "composer" } });
},
});
renderResponse.submit().catch(() => {
@@ -277,18 +337,12 @@ const onNewSavedReplyClick = () => {
setActiveSettingsTab("Saved Replies");
};
const resetFilter = () => {
savedReplyListResource.filters = {
...savedReplyListResource.filters,
title: undefined,
};
};
watch(search, (newValue) => {
savedReplyListResource.filters = {
...savedReplyListResource.filters,
title: ["like", `%${newValue}%`],
};
// 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;
savedReplyListResource.list.reload();
});
@@ -297,17 +351,22 @@ watch(activeFilter, () => {
...savedReplyListResource?.filters,
scope: scope.value == "All" ? undefined : ["=", scope.value],
};
savedReplyListResource.start = 0;
savedReplyListResource.list.reload();
});
watch(
show,
(newValue) => {
if (newValue) {
nextTick(() => {
searchInput.value?.el?.focus();
});
if (!newValue) return;
if (search.value) {
search.value = "";
} else {
savedReplyListResource.list.reload();
}
// Counts don't move with the selected scope, so this is the only refetch
scopeCounts.reload();
nextTick(() => searchInput.value?.focus());
},
{ immediate: true }
);

View File

@@ -0,0 +1,132 @@
<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

@@ -0,0 +1,80 @@
<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

@@ -0,0 +1,264 @@
<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,7 +1,7 @@
<template>
<div
v-if="Boolean(articles.data?.length) && query.length > 2"
class="rounded border p-4 text-base"
class="rounded-4 border p-4 text-base"
>
<div class="mb-2 font-medium ps-2" v-if="!hideViewAll">
These articles may already cover what you are looking for
@@ -22,7 +22,7 @@
<div
v-for="a in articles.data"
:key="a.id"
class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2"
class="rounded-5 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"
@@ -50,7 +50,7 @@
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 border"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
>
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
@@ -62,7 +62,7 @@
</div>
<div
v-else-if="articles.loading"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
>
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
@@ -99,7 +99,9 @@ watch(
},
});
articles.reload();
}
},
// Mounted after the third character is typed.
{ immediate: true }
);
function handleSearchArticleClick(article) {

View File

@@ -1,101 +0,0 @@
<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

@@ -50,7 +50,7 @@
<div
v-if="isOpen"
class="absolute z-50 mt-2 w-64 divide-y divide-outline-elevation-2 rounded-lg 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-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"
>
<!-- Header -->
<div class="py-1.5 px-1.5">
@@ -85,13 +85,13 @@
<button
v-for="option in filteredIndividualOptions"
:key="`individual-${option.value}`"
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
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="{ 'bg-surface-gray-2': isHighlighted(option) }"
@click="toggleOption(option)"
>
<Checkbox
:modelValue="props.modelValue.includes(option.value)"
class="me-2 flex-shrink-0"
class="me-4 flex-shrink-0"
/>
<component v-if="option.icon" :is="renderIcon(option.icon)" />
@@ -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 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
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="{ 'bg-surface-gray-2': isHighlighted(option) }"
@click="toggleOption(option)"
>
<Checkbox
:modelValue="props.modelValue.includes(option.value)"
class="me-2 flex-shrink-0"
class="me-4 flex-shrink-0"
/>
<component v-if="option.icon" :is="renderIcon(option.icon)" />

View File

@@ -1,35 +1,41 @@
<template>
<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();
<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();
}
}
}
"
>
<template #prefix>
<LucideSearch class="size-4 text-ink-gray-4" />
</template>
</FormControl>
"
>
<template #prefix>
<LucideSearch class="size-4 text-ink-gray-4" />
</template>
</FormControl>
</div>
</div>
</template>
<template #body-main>
<template #default>
<!-- Searched Articles -->
<div class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col">
<div
class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col"
:class="popoverClass"
>
<SearchArticles
:query="query"
:hideViewAll="true"

View File

@@ -1,36 +1,41 @@
<template>
<div>
<!-- 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">
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
<div
v-if="!hide"
class="section-header flex items-center justify-between"
class="section-header sticky top-0 z-10 flex cursor-pointer select-none items-center gap-1 bg-surface-base py-3.5"
:class="headerClass"
@click="collapsible && toggle()"
>
<div
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
:class="labelClass"
@click="collapsible && toggle()"
>
<!-- Figma: text/base/medium, ink-gray-8 -->
<Tooltip v-if="tooltip" :text="__(tooltip)">
<span
v-if="collapsible && collapseIconPosition === 'left'"
class="lucide-chevron-right h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
<span>
{{ label || "Untitled" }}
class="text-base font-medium text-ink-gray-8"
:class="labelClass"
>
{{ __(label) || "Untitled" }}
</span>
<span
v-if="collapsible && collapseIconPosition === 'right'"
class="lucide-chevron-right h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
</div>
</Tooltip>
<span
v-else
class="text-base font-medium text-ink-gray-8"
:class="labelClass"
>
{{ __(label) || "Untitled" }}
</span>
<LucideChevronRight
v-if="collapsible"
class="size-3.5 text-ink-gray-5 transition-transform"
:class="{ 'rotate-90': opened }"
/>
<slot name="actions"></slot>
</div>
</slot>
<transition
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
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"
@@ -43,13 +48,20 @@
</div>
</template>
<script setup>
import { __ } from "@/translation";
import { Tooltip } from "frappe-ui";
import { ref, watch } from "vue";
import LucideChevronRight from "~icons/lucide/chevron-right";
const props = defineProps({
label: {
type: String,
default: "",
},
tooltip: {
type: String,
default: "",
},
hideLabel: {
type: Boolean,
default: false,
@@ -62,10 +74,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
collapseIconPosition: {
type: String,
default: "left",
},
labelClass: {
type: [String, Object, Array],
default: "",

View File

@@ -1,9 +1,12 @@
<template>
<Popover :placement="props.placement">
<template #target="{ togglePopover }">
<Popover
bare
:side="splitPlacement(props.placement).side"
:align="splitPlacement(props.placement).align"
>
<template #trigger>
<Button
class="flex items-center justify-between min-w-36"
@click="togglePopover()"
:class="targetClass"
icon-right="lucide-chevron-down"
>
@@ -14,30 +17,27 @@
</div>
</Button>
</template>
<template #body="{ togglePopover }">
<template #default="{ close: closePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-surface-base shadow-2xl rounded"
class="mt-1 p-1 text-ink-gray-6 w-[--reka-popper-anchor-width] bg-surface-base shadow-2xl rounded-4"
: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"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded-4"
@click="
() => {
onChange(option.value);
togglePopover();
closePopover();
}
"
>
<div class="w-full truncate">
{{ option.label }}
</div>
<span
v-if="model == option.value"
class="lucide-check size-4 ms-2"
/>
<LucideCheck v-if="model == option.value" class="size-4 ms-2" />
</div>
</div>
<hr class="my-1" />
@@ -46,7 +46,7 @@
:label="__('Reset')"
icon-left="lucide-refresh-ccw"
class="w-full focus-visible:ring-0"
@click="onReset(togglePopover)"
@click="onReset(closePopover)"
/>
</div>
</template>
@@ -54,6 +54,8 @@
</template>
<script setup lang="ts">
import { splitPlacement } from "@/utils";
import LucideCheck from "~icons/lucide/check";
import { Button, Popover } from "frappe-ui";
const model = defineModel();
@@ -71,9 +73,9 @@ const props = withDefaults(defineProps<Props>(), {
defaultValue: undefined,
});
const onReset = (togglePopover: () => void) => {
const onReset = (closePopover: () => void) => {
model.value = props.defaultValue !== undefined ? props.defaultValue : null;
togglePopover();
closePopover();
};
const onChange = (value: string) => {

View File

@@ -38,36 +38,29 @@
class="absolute end-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="dropdownOptions" placement="right">
<Dropdown :options="dropdownOptions" align="end">
<template #default="{ open }">
<Button
:label="activeFilter"
class="flex items-center justify-between w-fit p-4"
>
<Button :label="activeFilter">
<template #suffix>
<span
:class="[
'h-4',
open ? 'lucide-chevron-up' : 'lucide-chevron-down',
]"
aria-hidden="true"
<component
:is="open ? LucideChevronUp : LucideChevronDown"
class="h-4"
/>
</template>
</Button>
</template>
<template #item-label="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded text-base"
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded-4 text-base"
@click="item.onClick"
>
<div class="flex items-center justify-between flex-1">
<span class="whitespace-nowrap">
{{ item.label }}
</span>
<span
<LucideCheck
v-if="activeFilter === item.label"
class="lucide-check size-4 text-ink-gray-7"
aria-hidden="true"
class="size-4 text-ink-gray-7"
/>
</div>
</button>
@@ -86,7 +79,7 @@
:loading="agents.loading"
variant="ghost"
class="w-full"
size="2xl"
size="lg"
/>
</div>
<!-- Empty State -->
@@ -113,7 +106,7 @@
</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">
<div class="flex items-center justify-between h-14 group rounded-4">
<div class="flex items-center gap-x-3 grow">
<Avatar
:image="agent.user_image"
@@ -153,14 +146,9 @@
? 'lucide-briefcase'
: null,
}"
placement="right"
align="end"
/>
<Dropdown
:options="getOptions(agent)"
:key="agent"
class="ms-2"
placement="right"
>
<Dropdown :options="getOptions(agent)" :key="agent" align="end">
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
</div>
@@ -185,17 +173,19 @@
</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 { h, onUnmounted } from "vue";
import LucideCheck from "~icons/lucide/check";
import { activeFilter, useAgents } from "./agents";
import AgentIcon from "../icons/AgentIcon.vue";
import { activeFilter, useAgents } from "./agents";
import { setActiveSettingsTab } from "./settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { __ } from "@/translation";
import { renderOptionIcon } from "@/utils";
const { getUserRole, updateUserRoleCache } = useUserStore();
const { isManager } = useAuthStore();
@@ -206,65 +196,20 @@ const agents = agentStore.agents;
function getRoles(agent: string) {
const agentRole = getUserRole(agent);
const roles = [
{
label: "Agent",
component: (props) =>
RoleOption({
role: "Agent",
active: props.active,
selected: agentRole === "Agent",
icon: "lucide-user",
onClick: () => {
updateRole(agent, "Agent");
},
}),
},
];
const role = (label: string, icon: string) => ({
label,
icon,
selected: agentRole === label,
onClick: () => updateRole(agent, label),
});
const roles = [role("Agent", "lucide-user")];
if (isManager) {
roles.unshift({
label: "Manager",
component: (props) =>
RoleOption({
role: "Manager",
active: props.active,
selected: agentRole === "Manager",
icon: "lucide-briefcase",
onClick: () => {
updateRole(agent, "Manager");
},
}),
});
roles.unshift(role("Manager", "lucide-briefcase"));
}
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" }, [
renderOptionIcon(icon),
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) {

View File

@@ -24,11 +24,10 @@
</div>
</div>
<div>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<Popover bare side="bottom" align="end">
<template #trigger>
<div
class="flex items-center justify-between text-base rounded 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"
@click="togglePopover()"
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"
>
<div>
{{
@@ -37,30 +36,30 @@
)?.label
}}
</div>
<span class="lucide-chevron-down size-4" />
<LucideChevronDown class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<template #default="{ close: closePopover }">
<div
class="p-1 text-ink-gray-7 mt-1 bg-surface-base shadow-xl rounded w-[--reka-popper-anchor-width]"
class="p-1 text-ink-gray-7 mt-1 bg-surface-base shadow-xl rounded-4 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"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded-4"
@click="
() => {
assignmentRuleData.rule = option.value;
togglePopover();
closePopover();
}
"
>
<span>
{{ option.label }}
</span>
<span
<LucideCheck
v-if="assignmentRuleData.rule == option.value"
class="lucide-check size-4"
class="size-4"
/>
</div>
</div>
@@ -80,48 +79,56 @@
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<div
<Button
v-for="user in users"
:key="user.name"
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
variant="outline"
size="sm"
:label="user.full_name"
>
<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-surface-blue-3 text-ink-base p-0.5 px-2"
<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"
>
{{ __("Last") }}
</div>
</Tooltip>
<Button
variant="ghost"
icon="lucide-x"
@click="removeAssignedUser(user)"
/>
</div>
<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>
</div>
<ErrorMessage :message="assignmentRulesErrors.users" />
</div>
</template>
<script setup lang="ts">
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from "frappe-ui";
import LucideCheck from "~icons/lucide/check";
import LucideChevronDown from "~icons/lucide/chevron-down";
import {
assignmentRuleData,
assignmentRulesErrors,
validateAssignmentRule,
} from "@/stores/assignmentRules";
import AssigneeSearch from "./AssigneeSearch.vue";
import { computed } from "vue";
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";
const { getUser } = useUserStore();

View File

@@ -1,18 +1,17 @@
<template>
<Combobox :multiple="true">
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<Popover bare side="bottom" align="end">
<template #trigger>
<Button
variant="subtle"
icon-left="lucide-plus"
@click="togglePopover()"
:label="__('Add Assignee')"
class="rtl:flex-row-reverse"
/>
</template>
<template #body="{ togglePopover }">
<template #default>
<div
class="mt-1 rounded-lg bg-surface-base py-1 text-base shadow-2xl w-60"
class="mt-1 rounded-6 bg-surface-base py-1 text-base shadow-2xl w-60"
>
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
@@ -32,7 +31,7 @@
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="query = ''"
>
<span class="lucide-x w-4" />
<LucideX class="w-4" />
</button>
</div>
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
@@ -51,7 +50,7 @@
"
>
<li
class="flex items-center rounded p-1.5 w-full text-base"
class="flex items-center rounded-4 p-1.5 w-full text-base"
:class="{ 'bg-surface-gray-2': active }"
>
<div class="flex gap-2 items-center w-full select-none">
@@ -74,7 +73,7 @@
</ComboboxOption>
<li
v-if="users.length == 0"
class="mt-1.5 rounded-md p-1.5 text-base text-ink-gray-5"
class="mt-1.5 rounded-5 p-1.5 text-base text-ink-gray-5"
>
{{ __("No results found") }}
</li>
@@ -101,6 +100,7 @@
</template>
<script setup lang="ts">
import LucideX from "~icons/lucide/x";
import { assignmentRuleData } from "@/stores/assignmentRules";
import {
Combobox,

View File

@@ -1,6 +1,6 @@
<template>
<div
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded"
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
>
<div
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
@@ -16,7 +16,7 @@
</div>
<div class="col-span-3">
<select
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md 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-5 p-0 ps-2 pe-5 bg-transparent -ms-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
v-model="data.priority"
@update:modelValue="onPriorityChange"
@change="onPriorityChange"
@@ -39,12 +39,12 @@
/>
</div>
<div>
<Dropdown placement="right" :options="dropdownOptions">
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
<Dropdown
align="end"
:options="dropdownOptions"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>

View File

@@ -57,11 +57,10 @@
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Priority')" />
<Popover>
<template #target="{ togglePopover }">
<Popover bare>
<template #trigger>
<div
class="flex items-center justify-between text-base rounded 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"
@click="togglePopover()"
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"
>
<div>
{{
@@ -70,26 +69,26 @@
)?.label
}}
</div>
<span class="lucide-chevron-down size-4" />
<LucideChevronDown class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<template #default="{ close: closePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-base shadow-2xl rounded w-[--reka-popper-anchor-width]"
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-base shadow-2xl rounded-4 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"
class="p-2 cursor-pointer hover:bg-surface-sidebar text-base flex items-center justify-between rounded-4"
@click="
assignmentRuleData.priority = option.value;
togglePopover();
closePopover();
"
>
{{ option.label }}
<span
<LucideCheck
v-if="assignmentRuleData.priority == option.value"
class="lucide-check size-4"
class="size-4"
/>
</div>
</div>
@@ -137,29 +136,29 @@
<div
v-if="isAssignConditionOld && assignmentRulesActiveScreen.data"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<HoverCard :hoverDelay="250" side="top" align="end">
<template #trigger>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span>{{ __("Old Condition") }}</span>
<span class="lucide-info size-4" />
<LucideInfo class="size-4" />
</div>
</template>
<template #body-main>
<template #default>
<div
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
</template>
</Popover>
</HoverCard>
</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-md p-3 py-4"
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"
v-if="
!useNewUIForAssignCondition &&
assignmentRuleData.assignCondition
@@ -218,29 +217,29 @@
assignmentRuleData.unassignCondition
"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<HoverCard :hoverDelay="250" side="top" align="end">
<template #trigger>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span> {{ __("Old Condition") }} </span>
<span class="lucide-info size-4" />
<LucideInfo class="size-4" />
</div>
</template>
<template #body-main>
<template #default>
<div
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
</template>
</Popover>
</HoverCard>
</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-md p-3 py-4"
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"
v-if="
!useNewUIForUnassignCondition &&
assignmentRuleData.unassignCondition
@@ -310,6 +309,9 @@
</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,
@@ -319,6 +321,7 @@ import {
ErrorMessage,
FormControl,
FormLabel,
HoverCard,
LoadingIndicator,
Popover,
Switch,

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-md"
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"
@click="
props.conditions.push(['', '', '']);
validateAssignmentRule(props.name);
"
>
<span class="lucide-plus h-4" />
<LucidePlus class="size-4" />
{{ __("Add a condition") }}
</div>
<div class="flex items-center justify-between mt-2">
@@ -38,6 +38,7 @@
</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";

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div class="rounded-5 border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center"
style="grid-template-columns: 3fr 1fr"

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex justify-between items-center border-outline-elevation-2 p-2 cursor-pointer hover:bg-surface-sidebar rounded h-14"
class="flex justify-between items-center border-outline-elevation-2 p-2 cursor-pointer hover:bg-surface-sidebar rounded-4 h-14"
>
<!-- avatar and name -->
<div class="flex justify-between items-center gap-2">

View File

@@ -38,14 +38,15 @@
<hr
class="mx-2"
v-if="
emailAccount !== emailAccounts.data[emailAccounts.data.length - 1]
emailAccount !==
emailAccounts?.data[emailAccounts?.data?.length - 1]
"
/>
</div>
</div>
<!-- fallback if no email accounts -->
<EmptyState
v-else
v-else-if="!emailAccounts.loading"
variant="badge"
:icon="EmailIcon"
:title="__('No email account found')"
@@ -56,6 +57,7 @@
</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";

View File

@@ -27,7 +27,7 @@
<!-- email service provider info -->
<div>
<div
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-outline-elevation-2"
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
@@ -167,7 +167,7 @@ import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { EmailAccount, EmailService, EmailStep } from "@/types";
import { call, createResource, toast } from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { useOnboarding } from "@framework/ui";
import { computed, reactive, Ref, ref, watch } from "vue";
import CircleAlert from "~icons/lucide/circle-alert";
import {
@@ -222,7 +222,7 @@ interface E {
const emit = defineEmits<E>();
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const state = reactive<EmailAccountProviderAuthState>({
service: "",
@@ -308,7 +308,7 @@ const addEmailRes = createResource({
onSuccess: () => {
toast.success(__("Email account created"));
emit("update:step", "email-list");
updateOnboardingStep("setup_email_account");
updateOnboardingStep?.("setup_email_account");
capture("email_account_created", { data: { service: state.service } });
},
onError: () => {

View File

@@ -13,7 +13,7 @@
/>
</div>
<div
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-outline-elevation-2"
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
@@ -139,6 +139,7 @@
/>
<div class="flex gap-2">
<Button
v-if="hasChanges"
:label="__('Update Account')"
variant="solid"
@click="updateAccount"
@@ -229,7 +230,7 @@ const props = withDefaults(defineProps<P>(), {
const emit = defineEmits<E>();
const state = reactive<EmailAccountProviderAuthState>({
const getInitialState = (): EmailAccountProviderAuthState => ({
email_account_name: props.accountData.email_account_name || "",
service: props.accountData.service || "",
email_id: props.accountData.email_id || "",
@@ -243,6 +244,8 @@ const state = reactive<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 || "",
@@ -328,26 +331,8 @@ async function updateAccount() {
: currentServiceName.value;
error.value = validateInputs(validationState, validationService, true);
if (error.value) return;
const old = { ...props.accountData };
const updatedEmailAccount = { ...state };
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 = buildUpdatePayload();
if (!nameChanged && !otherFieldsChanged) {
toast.create({
message: __("No changes made"),
icon: h(CircleAlert, { class: "text-ink-blue-5" }),
});
return;
}
if (nameChanged) {
if (nameChanged.value) {
try {
loading.value = true;
await callRenameDoc();
@@ -356,10 +341,10 @@ async function updateAccount() {
errorHandler();
}
}
if (otherFieldsChanged) {
if (isDirty.value) {
try {
loading.value = true;
await callSetValue(values);
await callSetValue(buildUpdatePayload());
succesHandler();
} catch (err) {
errorHandler();
@@ -403,9 +388,8 @@ function buildUpdatePayload() {
function pullEmails() {
loadingPull.value = true;
toast.create({
message: __("Pulling emails, this may take a few minutes."),
icon: h(CircleAlert, { class: "text-ink-blue-5" }),
toast(__("Pulling emails, this may take a few minutes."), {
icon: () => h(CircleAlert, { class: "text-ink-blue-5" }),
});
call("frappe.email.doctype.email_account.email_account.pull_emails", {
@@ -423,32 +407,32 @@ 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 customDirty = isCustomProvider.value
? customState.domain !== props.accountData.domain ||
customState.email_server !== props.accountData.email_server ||
customState.smtp_server !== props.accountData.smtp_server ||
customState.incoming_port !== props.accountData.incoming_port ||
customState.smtp_port !== props.accountData.smtp_port ||
customState.use_ssl !== props.accountData.use_ssl ||
customState.use_starttls !== props.accountData.use_starttls
: false;
return (
customDirty ||
state.service !== props.accountData.service ||
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 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]);
});
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", {
doctype: "Email Account",

View File

@@ -62,7 +62,9 @@
:required="true"
:rows="10"
v-model="content"
:oninput="() => setUnsavedChanges()"
@update:model-value="
(value) => setUnsavedChanges(undefined, value)
"
/>
<div class="flex gap-x-1 items-start justify-between">
<p class="text-sm text-ink-gray-7 leading-5">
@@ -151,6 +153,7 @@ 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);
@@ -169,15 +172,25 @@ function setUnsavedChanges(
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;
unsavedChanges.value = true;
}
function resetUnsavedChanges() {
notificationDataResource.data = {
...notificationDataResource.data,
enabled: enabled.value,
content: content.value,
};
slotFieldChanged.value = false;
unsavedChanges.value = false;
}
function resetContent() {
@@ -209,6 +222,7 @@ watch(
defineExpose({
setUnsavedChanges,
markUnsavedChanges,
resetUnsavedChanges,
});
</script>

View File

@@ -13,7 +13,7 @@
v-for="(notification, index) in notifications"
:key="notification.name"
>
<li 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"
@@ -26,9 +26,8 @@
{{ __(notification.description) }}
</p>
</div>
<span
class="lucide-chevron-right text-ink-gray-7 size-4 relative z-10 pointer-events-none rtl:rotate-180"
aria-hidden="true"
<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]"
@@ -51,6 +50,7 @@
</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";

View File

@@ -15,16 +15,17 @@
>
<template #formFields>
<FormControl
type="autocomplete"
type="combobox"
size="sm"
:label="__('On ticket status')"
:options="statusOptions"
:required="true"
:model-value="ticketStatus"
:model-value="ticketStatus?.value ?? null"
@update:model-value="
(val) => {
ticketStatus = val;
compRef.setUnsavedChanges();
(value) => {
ticketStatus =
statusOptions.find((option) => option.value === value) ?? null;
compRef.markUnsavedChanges();
}
"
/>

View File

@@ -8,7 +8,11 @@
<div class="flex gap-4">
<!-- Switch -->
<div class="flex gap-2 items-center">
<Switch v-model="state.enabled" class="!w-fit" />
<Switch
class="!w-fit"
:model-value="Boolean(state.enabled)"
@update:model-value="(value) => (state.enabled = value)"
/>
<span class="text-p-base text-ink-gray-6">
{{ __("Enabled") }}
</span>

View File

@@ -20,15 +20,14 @@
/>
</div>
</div>
<MultiSelectCombobox
:disabled="!fieldCriteriaState.display.enabled"
<MultiSelect
class="min-w-[120px] max-w-[120px]"
side="top"
:disabled="!fieldCriteriaState.display.enabled"
:options="fieldCriteriaOptions"
:model-value="fieldCriteriaState.display.value"
:model-value="selectedValues('display')"
:placeholder="__('Select Child Field values')"
@update:model-value="handleCriteriaSelection($event, 'display')"
:multiple="true"
placeholder="Select Child Field values"
placement="top"
/>
</div>
<!-- Mandatory Criteria -->
@@ -46,15 +45,14 @@
/>
</div>
</div>
<MultiSelectCombobox
:disabled="!fieldCriteriaState.mandatory.enabled"
<MultiSelect
class="min-w-[120px] max-w-[120px]"
side="top"
:disabled="!fieldCriteriaState.mandatory.enabled"
:options="fieldCriteriaOptions"
:model-value="fieldCriteriaState.mandatory.value"
:model-value="selectedValues('mandatory')"
:placeholder="__('Select Child Field values')"
@update:model-value="handleCriteriaSelection($event, 'mandatory')"
:multiple="true"
placeholder="Select Child Field values"
placement="top"
/>
</div>
</div>
@@ -63,9 +61,9 @@
<script setup lang="ts">
import DocumentationButton from "@/components/DocumentationButton.vue";
import MultiSelectCombobox from "@/components/frappe-ui/MultiSelectCombobox.vue";
import { getMeta } from "@/stores/meta";
import { Switch } from "frappe-ui";
import { __ } from "@/translation";
import { MultiSelect, Switch } from "frappe-ui";
import { computed } from "vue";
const props = defineProps<{
@@ -98,21 +96,25 @@ const fieldCriteriaOptions = computed(() => {
return _options;
});
function selectedValues(stateKey: "display" | "mandatory") {
return fieldCriteriaState.value[stateKey].value.map((v) => v.value);
}
function handleCriteriaSelection(
values: { label: string; value: string }[],
values: string[],
stateKey: "display" | "mandatory"
) {
const _values = values.map((v) => v.value);
fieldCriteriaState.value[stateKey].value = values;
if (_values.length === 0) {
fieldCriteriaState.value[stateKey].value = [{ label: "Any", value: "Any" }];
} else if (_values[0] === "Any" && _values.length > 1) {
fieldCriteriaState.value[stateKey].value = _values
.filter((value) => value !== "Any")
.map((value) => ({ label: value, value }));
} else if (_values.at(-1) === "Any" && _values.length > 1) {
fieldCriteriaState.value[stateKey].value = [{ label: "Any", value: "Any" }];
}
// "Any" is exclusive: picking it drops the rest, picking anything else drops it
const wasAnySelected = selectedValues(stateKey).includes("Any");
let selected =
values.includes("Any") && !wasAnySelected
? ["Any"]
: values.filter((value) => value !== "Any");
if (!selected.length) selected = ["Any"];
fieldCriteriaState.value[stateKey].value = selected.map((value) => ({
label: value,
value,
}));
}
</script>

View File

@@ -39,7 +39,7 @@
v-if="fieldDependenciesList.loading"
class="flex items-center justify-center py-4"
>
<LoadingIndicator :scale="5" />
<LoadingIndicator class="size-5" />
</div>
<!-- Empty State -->
@@ -75,7 +75,7 @@
:key="row.name"
>
<div
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded h-12.5"
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4 h-12.5"
>
<div
@click.stop="$emit('update:step', 'fd', row.name)"
@@ -100,7 +100,13 @@
/>
</div>
<div>
<Dropdown placement="right" :options="getOptions(row.name)">
<Dropdown
align="end"
:options="getOptions(row.name)"
@update:open="
(open) => open && (isConfirmingDelete = false)
"
>
<Button
variant="ghost"
@click.stop="
@@ -138,6 +144,7 @@ import {
Switch,
toast,
} from "frappe-ui";
import EmptyState from "@/components/EmptyState.vue";
import { getFieldDependencyLabel, ConfirmDelete } from "@/utils";
import { onMounted, ref } from "vue";
import { fieldDependenciesList } from "./fieldDependency";

View File

@@ -7,7 +7,9 @@
<span class="block text-xs text-ink-gray-5">
Select parent field value
</span>
<div class="border flex-1 border-e-0 rounded-s p-2 flex flex-col gap-2">
<div
class="border border-outline-elevation-2 flex-1 border-e-0 rounded-s-4 p-2 flex flex-col gap-2"
>
<template v-if="state.selectedParentField">
<FormControl
v-model="state.parentSearch"
@@ -24,7 +26,7 @@
<li
v-for="value in filteredParentFieldValues"
:key="value"
class="py-2 mb-1 px-2.5 cursor-pointer rounded flex justify-between items-center hover:bg-surface-gray-1 overflow-hidden max-w-full"
class="py-2 mb-1 px-2.5 cursor-pointer rounded-4 flex justify-between items-center hover:bg-surface-gray-1 overflow-hidden max-w-full"
:class="{
'bg-surface-gray-2 hover:bg-surface-gray-3':
state.currentParentSelection === value,
@@ -66,7 +68,9 @@
<span class="block text-xs text-ink-gray-5 ps-1.5">
Select child field value
</span>
<div class="border flex-1 rounded-e p-2 flex flex-col gap-2">
<div
class="border border-outline-elevation-2 flex-1 rounded-e-4 p-2 flex flex-col gap-2"
>
<template
v-if="state.selectedChildField && state.currentParentSelection"
>
@@ -83,13 +87,13 @@
<div class="flex-1 overflow-y-auto hide-scrollbar basis-0">
<!-- Master Check box -->
<li
class="py-2 mb-1 px-2.5 cursor-pointer rounded flex items-center bg-surface-gray-1 hover:bg-surface-gray-2"
class="py-2 mb-1 px-2.5 cursor-pointer rounded-4 flex items-center bg-surface-gray-1 hover:bg-surface-gray-2"
@click="handleSelectAllChildValues(!toggleAllChildValues)"
>
<FormControl
type="checkbox"
:model-value="toggleAllChildValues"
class="me-2"
class="me-4"
/>
<span class="text-base-medium text-ink-gray-8">
{{ toggleCheckboxLabel }}
@@ -99,13 +103,13 @@
<li
v-for="value in filteredChildFieldValues"
:key="value"
class="py-2 mb-1 px-2.5 cursor-pointer rounded flex items-center hover:bg-surface-gray-1 max-w-full truncate"
class="py-2 mb-1 px-2.5 cursor-pointer rounded-4 flex items-center hover:bg-surface-gray-1 max-w-full truncate"
@click="handleChildValueClick(value)"
>
<FormControl
type="checkbox"
:model-value="isChildValueSelected(value)"
class="me-2"
class="me-4"
/>
<span class="text-base text-ink-gray-6">{{ value }}</span>
</li>

View File

@@ -254,14 +254,61 @@ const saveSettings = async () => {
});
};
const toggleFields = [
"isFeedbackMandatory",
"enableCommentReactions",
"disableSavedRepliesGlobalScope",
"allowAnyoneToCreateTickets",
"preferKnowledgeBase",
"skipEmailWorkflow",
] as const;
const toggleFieldnames = {
isFeedbackMandatory: "is_feedback_mandatory",
enableCommentReactions: "enable_comment_reactions",
disableSavedRepliesGlobalScope: "disable_saved_replies_global_scope",
allowAnyoneToCreateTickets: "allow_anyone_to_create_tickets",
preferKnowledgeBase: "prefer_knowledge_base",
skipEmailWorkflow: "skip_email_workflow",
} as const;
const toggleFields = Object.keys(toggleFieldnames) as Array<
keyof typeof toggleFieldnames
>;
// Toggles save on their own, so they never carry unsaved text fields along.
const saveTogglesResource = createResource({
url: "frappe.client.set_value",
makeParams: (values: Record<string, unknown>) => ({
doctype: "HD Settings",
name: "HD Settings",
fieldname: Object.fromEntries(
toggleFields.map((f) => [toggleFieldnames[f], values[f]])
),
}),
});
let pendingToggleSave = Promise.resolve();
function currentToggles() {
return Object.fromEntries(
toggleFields.map((f) => [f, settingsData.value[f]])
);
}
async function saveToggles(sent: Record<string, unknown>) {
try {
await saveTogglesResource.submit(sent);
markTogglesSaved(sent);
toast.success(__("Settings updated"));
} catch {
revertToggles(sent);
}
}
function markTogglesSaved(sent: Record<string, unknown>) {
const initial = JSON.parse(initialData.value!);
toggleFields.forEach((f) => (initial[f] = sent[f]));
initialData.value = JSON.stringify(initial);
configStore.configResource.reload();
}
// Undo only what the failed request sent; a toggle changed since keeps its queued save.
function revertToggles(sent: Record<string, unknown>) {
const initial = JSON.parse(initialData.value!);
toggleFields
.filter((f) => settingsData.value[f] === sent[f])
.forEach((f) => (settingsData.value[f] = initial[f]));
}
// Track dirty state for non-toggle fields only
watch(
@@ -271,7 +318,7 @@ watch(
const initial = JSON.parse(initialData.value);
isDirty.value = Object.keys(data).some(
(key) =>
!(toggleFields as readonly string[]).includes(key) &&
!(toggleFields as string[]).includes(key) &&
JSON.stringify(data[key as keyof typeof data]) !==
JSON.stringify(initial[key])
);
@@ -280,16 +327,16 @@ watch(
{ deep: true }
);
// auto save when any toggle field changes
// Queue toggle saves: parallel writes to the single doc fail with a 417.
watch(
() => toggleFields.map((f) => settingsData.value[f]),
async (newVals) => {
(newVals) => {
if (!initialData.value) return;
const initial = JSON.parse(initialData.value);
if (newVals.some((v, i) => v !== initial[toggleFields[i]])) {
await saveSettingsResource.submit();
toast.success(__("Settings updated"));
}
if (!newVals.some((v, i) => v !== initial[toggleFields[i]])) return;
pendingToggleSave = pendingToggleSave.then(() =>
saveToggles(currentToggles())
);
}
);

View File

@@ -4,7 +4,7 @@
>
<div class="flex flex-col sm:flex-row items-center gap-3.5">
<div
class="flex items-center justify-center min-w-16 min-h-16 rounded-lg overflow-hidden border border-outline-gray-1"
class="flex items-center justify-center min-w-16 min-h-16 rounded-6 overflow-hidden border border-outline-gray-1"
>
<Avatar
v-if="props.image"
@@ -12,11 +12,7 @@
:image="props.image"
:label="props.title"
/>
<span
v-else
class="lucide-image size-6 text-ink-gray-4"
aria-hidden="true"
/>
<LucideImage v-else class="size-6 text-ink-gray-4" />
</div>
<div class="flex flex-col gap-1 max-w-sm items-start">
<span class="text-base-medium text-ink-gray-8">{{ title }}</span>
@@ -61,6 +57,7 @@
</template>
<script setup lang="ts">
import LucideImage from "~icons/lucide/image";
import { Avatar, Button, FileUploader } from "frappe-ui";
import ImageUpIcon from "~icons/lucide/image-up";

View File

@@ -133,7 +133,7 @@
}}</span>
</div>
<SelectDropdown
:options="ticketTypeList.data"
:options="ticketTypeList.data || []"
v-model="settingsData.defaultTicketType"
target-class="max-w-40"
placement="bottom-start"
@@ -294,7 +294,8 @@ function resetBannerContent() {
}
function handleShowBannerToggle(value: boolean) {
if (!bannerMsg.data.current) {
// the switch is live before the message resource lands
if (!bannerMsg.data?.current) {
if (value) {
settingsData.value.outsideWorkingHoursBannerMessage =
bannerMsg.data?.default;

View File

@@ -3,7 +3,7 @@
<div class="text-base-medium mb-2 text-ink-gray-8 ms-2.5">
{{ formattedMonth }}
</div>
<div class="rounded-md text-sm">
<div class="rounded-5 text-sm">
<div class="flex items-center text-xs uppercase">
<div
class="flex size-7.5 items-center justify-center text-center text-ink-gray-5"
@@ -16,37 +16,51 @@
<div class="flex items-center" v-for="(week, i) in datesAsWeeks" :key="i">
<div v-for="date in week" :key="getFormattedDate(date)">
<Popover v-if="isHoliday(date)">
<template #target="{ open, close }">
<div
class="flex size-7 cursor-pointer text-ink-orange-6 bg-surface-yellow-2 items-center justify-center rounded hover:bg-surface-yellow-2 select-none m-[1px]"
:class="{
'!text-ink-gray-4 !bg-surface-gray-2': isWeekOff(date),
}"
@mouseover="handleMouseEnter(getFormattedDate(date), open)"
@mouseleave="handleMouseLeave(getFormattedDate(date), close)"
@click="
() => {
if (isWeekOff(date)) return;
close();
editHoliday(date);
}
"
>
{{ date.getDate() }}
<template #trigger="{ close, setOpen }">
<!--
Hover opens this one. reka turns the whole trigger subtree into
the toggle, so the cell stops its click before it gets there.
-->
<div>
<div
class="flex size-7 cursor-pointer text-ink-orange-6 bg-surface-yellow-2 items-center justify-center rounded-4 hover:bg-surface-yellow-2 select-none m-[1px]"
:class="{
'!text-ink-gray-4 !bg-surface-gray-2': isWeekOff(date),
}"
@mouseover="
handleMouseEnter(getFormattedDate(date), () =>
setOpen(true)
)
"
@mouseleave="handleMouseLeave(getFormattedDate(date), close)"
@click.stop="
() => {
if (isWeekOff(date)) return;
close();
editHoliday(date);
}
"
>
{{ date.getDate() }}
</div>
</div>
</template>
<template #body-main="{ close: closePopover, open: openPopover }">
<template
#default="{ close: closePopover, setOpen: setPopoverOpen }"
>
<div
class="p-3 flex gap-2.5 text-ink-gray-9 w-80 border border-outline-gray-1 rounded-md"
class="p-3 flex gap-2.5 text-ink-gray-9 w-80 border border-outline-gray-1 rounded-5"
@mouseover="
handleMouseEnter(getFormattedDate(date), openPopover)
handleMouseEnter(getFormattedDate(date), () =>
setPopoverOpen(true)
)
"
@mouseleave="
handleMouseLeave(getFormattedDate(date), closePopover)
"
>
<div class="w-[5%]">
<div class="size-3.5 bg-surface-orange-5 rounded-sm mt-1" />
<div class="size-3.5 bg-surface-orange-5 rounded-1 mt-1" />
</div>
<div class="grow">
<div class="text-sm-semibold">
@@ -60,11 +74,10 @@
v-if="!isWeekOff(date)"
@close="isConfirmingDelete = false"
>
<template #target="{ open, close }">
<template #trigger="{ close }">
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="open"
@mouseleave="
handleMouseLeave(
getFormattedDate(date) + 'dropdown',
@@ -74,15 +87,20 @@
/>
</template>
<template
#body-main="{ close: closeDropdown, open: openDropdown }"
#default="{
close: closeDropdown,
setOpen: setDropdownOpen,
}"
>
<div
class="p-2 flex flex-col gap-1 w-40 text-ink-gray-9 border border-outline-gray-1 rounded-md"
class="p-2 flex flex-col gap-1 w-40 text-ink-gray-9 border border-outline-gray-1 rounded-5"
@mouseover="
handleMouseEnter(getFormattedDate(date), openPopover);
handleMouseEnter(getFormattedDate(date), () =>
setPopoverOpen(true)
);
handleMouseEnter(
getFormattedDate(date) + 'dropdown',
openDropdown
() => setDropdownOpen(true)
);
"
@mouseleave="
@@ -131,7 +149,7 @@
</Popover>
<div
v-else
class="flex size-7 items-center justify-center rounded m-[1px] select-none"
class="flex size-7 items-center justify-center rounded-4 m-[1px] select-none"
:class="{
'cursor-pointer hover:bg-surface-gray-2': isDateInRange(date),
'text-ink-gray-3':
@@ -155,7 +173,8 @@
<script setup lang="ts">
import { holidayData } from "@/stores/holidayList";
import { getFormattedDate, htmlToText } from "@/utils";
import { dayjs, Popover, useDatePicker } from "frappe-ui";
import { dayjs, Popover } from "frappe-ui";
import { useDatePicker } from "@/composables/useDatePicker";
import { ref, watch } from "vue";
import AddHolidayModal from "./Modals/AddHolidayModal.vue";

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center cursor-pointer hover:bg-surface-sidebar rounded"
class="flex items-center cursor-pointer hover:bg-surface-sidebar rounded-4"
>
<div
class="w-full ps-2 flex flex-col justify-center h-14"
@@ -16,12 +16,12 @@
</div>
<div class="flex justify-between items-center pe-2">
<div>
<Dropdown placement="right" :options="dropdownOptions">
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
<Dropdown
align="end"
:options="dropdownOptions"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>

View File

@@ -140,13 +140,15 @@
</div>
</div>
<TabButtons
:buttons="[
:options="[
{
value: 'calendar',
label: __('Calendar'),
icon: 'lucide-calendar',
},
{
value: 'list',
label: __('List'),
icon: 'lucide-list',
},
]"
@@ -167,13 +169,13 @@
<!-- Indicators -->
<div class="flex gap-4" v-if="holidayListView === 'calendar'">
<div class="gap-1 flex items-center">
<span class="bg-surface-yellow-2 size-4 rounded-sm" />
<span class="bg-surface-yellow-2 size-4 rounded-1" />
<span class="text-sm text-ink-gray-6">{{
__("Holidays")
}}</span>
</div>
<div class="gap-1 flex items-center">
<span class="bg-surface-gray-2 size-4 rounded-sm" />
<span class="bg-surface-gray-2 size-4 rounded-1" />
<span class="text-sm text-ink-gray-6">{{
__("Recurring holidays")
}}</span>

View File

@@ -74,10 +74,8 @@ watch(holidaySearchRef, (newValue) => {
holidayList.filters = {
name: ["like", `%${newValue}%`],
};
if (!newValue) {
holidayList.start = 0;
holidayList.pageLength = 10;
}
// no pager on this list, so a shrunken pageLength would hide the rest for good
if (!newValue) holidayList.start = 0;
holidayList.reload();
});
</script>

View File

@@ -1,18 +1,17 @@
<template>
<div class="p-6.5 px-5 rounded-xl border border-outline-gray-2">
<div class="p-6.5 px-5 rounded-7 border border-outline-gray-2">
<div class="mb-6.5 flex justify-between items-center">
<div class="ms-1">
<Popover v-if="startYear !== endYear">
<template #target="{ togglePopover }">
<template #trigger>
<Button
class="flex items-center gap-2 text-xl-semibold cursor-pointer select-none"
variant="ghost"
@click="togglePopover"
:label="currentYear + ''"
icon-right="lucide-chevron-down"
/>
</template>
<template #body-main="{ togglePopover }">
<template #default="{ close: closePopover }">
<div class="w-24">
<div ref="yearsContainer" class="max-h-60 overflow-y-auto py-1">
<div
@@ -20,14 +19,10 @@
:key="year"
ref="yearItems"
class="cursor-pointer px-3 py-1.5 text-sm hover:bg-surface-gray-2 flex items-center justify-between"
@click="onYearChange(togglePopover, year)"
@click="onYearChange(closePopover, year)"
>
{{ year }}
<span
class="lucide-check size-4"
v-if="year === currentYear"
aria-hidden="true"
/>
<LucideCheck class="size-4" v-if="year === currentYear" />
</div>
</div>
</div>
@@ -105,7 +100,8 @@
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import LucideCheck from "~icons/lucide/check";
import { computed, ref, watch } from "vue";
import HLCalender from "./HLCalender.vue";
import { holidayData } from "@/stores/holidayList";
import { Button, dayjs, Popover } from "frappe-ui";
@@ -125,7 +121,7 @@ const yearsList = computed(() => {
return yearList;
});
const onYearChange = (togglePopover: () => void, year: number) => {
const onYearChange = (closePopover: () => void, year: number) => {
currentYear.value = year;
if (year === dayjs(holidayData.value.from_date).year()) {
if (dayjs(holidayData.value.from_date).month() >= 6) {
@@ -138,7 +134,7 @@ const onYearChange = (togglePopover: () => void, year: number) => {
} else {
visibleMonths.value = "first-half";
}
togglePopover();
closePopover();
};
const goToToday = () => {
@@ -150,17 +146,14 @@ const goToToday = () => {
watch(
() => [holidayData.value.from_date, holidayData.value.to_date],
([fromDate, toDate]) => {
fromDate = dayjs(fromDate);
toDate = dayjs(toDate);
fromDate = dayjs(fromDate || dayjs());
toDate = dayjs(toDate || dayjs());
startYear.value = fromDate.year();
endYear.value = toDate.year();
currentYear.value = fromDate.year();
visibleMonths.value = fromDate.month() >= 6 ? "second-half" : "first-half";
}
},
// The list is loaded before this view mounts, so a change-only watch never fires.
{ immediate: true }
);
onMounted(() => {
const fromDate = dayjs(holidayData.value.from_date || dayjs());
visibleMonths.value = fromDate.month() >= 6 ? "second-half" : "first-half";
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div class="rounded-5 border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center gap-2"
:style="{
@@ -37,12 +37,12 @@
</div>
</div>
<div class="flex justify-end">
<Dropdown placement="right" :options="dropdownOptions(holiday)">
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
<Dropdown
align="end"
:options="dropdownOptions(holiday)"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-md border p-1 border-outline-gray-2 text-sm">
<div class="rounded-5 border p-1 border-outline-gray-2 text-sm">
<div
class="grid p-2 items-center"
:style="{
@@ -32,12 +32,12 @@
<div v-else>{{ holiday[column.key] }}</div>
</div>
<div class="flex justify-end">
<Dropdown placement="right" :options="dropdownOptions(holiday)">
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
<Dropdown
align="end"
:options="dropdownOptions(holiday)"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<Button icon="lucide-more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>

View File

@@ -15,7 +15,7 @@
:description="__('Comma separated emails to invite.')"
/>
<div class="space-y-1.5">
<label class="block text-xs text-ink-gray-5">
<label class="block text-base text-ink-gray-5">
{{ __("Role") }}
<span class="text-ink-red-6 select-none" aria-hidden="true">*</span>
</label>
@@ -45,7 +45,7 @@
<li
v-for="invite in pendingInvitesResource.data"
:key="invite.name"
class="flex items-center justify-between px-3 py-1 rounded-lg bg-surface-gray-2"
class="flex items-center justify-between px-3 py-1 rounded-6 bg-surface-gray-2"
>
<div class="text-base">
<span class="text-ink-gray-8">
@@ -116,7 +116,7 @@ import {
createResource,
toast,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { useOnboarding } from "@framework/ui";
import { computed, ref } from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";
@@ -124,7 +124,7 @@ const authStore = useAuthStore();
const { isAdmin, isManager } = authStore;
// @ts-expect-error
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
const emails = ref("");
@@ -215,7 +215,7 @@ const inviteByEmailResource = createResource({
resetInputValues();
handleInviteUserSuccess(data);
pendingInvitesResource.reload();
updateOnboardingStep("invite_your_team");
updateOnboardingStep?.("invite_agents");
capture("agents_invited", {
data: {
role: role.value,

View File

@@ -12,8 +12,11 @@
<Link
:model-value="user.doc?.language"
doctype="Language"
:placeholder="__('Select Language')"
class="w-40"
side="top"
align="end"
:placeholder="__('Select language')"
hide-clear-button
@update:model-value="updateLanguage"
/>
</div>
@@ -36,9 +39,9 @@
</template>
<script setup lang="ts">
import Link from "@/components/frappe-ui/Link.vue";
import TimezoneControl from "@/components/TimezoneControl.vue";
import { __ } from "@/translation";
import { Link } from "frappe-ui/frappe";
const props = defineProps<{ user: any }>();

View File

@@ -2,9 +2,7 @@
<div class="flex flex-col gap-4 mt-6">
<div class="flex flex-col gap-1">
<slot name="title">
<span class="text-base font-medium text-ink-gray-8">{{
__("Theme")
}}</span>
<span class="text-base-medium text-ink-gray-8">{{ __("Theme") }}</span>
</slot>
<slot name="description">
<span class="text-p-sm text-ink-gray-6">
@@ -18,113 +16,64 @@
<div
v-for="option in themeOptions"
:key="option.value"
class="flex-1 max-w-[227px] min-h-[42px] overflow-hidden rounded-lg border cursor-pointer outline-none transition-colors focus-visible:ring-2 focus-visible:ring-outline-gray-3"
class="flex-1 rounded-6 border cursor-pointer min-h-[42px]"
:class="
theme === option.value
? 'border-outline-gray-5'
: 'border-outline-gray-modals'
? 'border-outline-gray-7'
: 'border-outline-elevation-2'
"
role="radio"
:aria-checked="theme === option.value"
tabindex="0"
@click="theme = option.value"
@keydown.enter.prevent="theme = option.value"
@keydown.space.prevent="theme = option.value"
>
<!-- Theme-independent window mock: each frame is a fixed-tone SVG so
the Light/Dark/System previews always render the same regardless
of the app's active theme. -->
<div :class="{ flex: option.frames.length > 1 }">
<div :class="{ flex: option.panes.length > 1 }">
<div
v-for="(frame, index) in option.frames"
v-for="(pane, index) in option.panes"
:key="index"
:class="frame.frameClass"
:class="pane.containerClass"
>
<div
class="relative"
:class="frame.fill ? 'w-full' : 'w-fit shrink-0'"
>
<svg
width="207"
height="68"
viewBox="0 0 207 68"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="block h-auto rounded-tl-sm"
:class="{ 'w-full': frame.fill }"
>
<path
d="M0 4C0 1.79086 1.79086 0 4 0H207V83H0V4Z"
:fill="tones[frame.tone].background"
/>
<template v-if="option.bars">
<rect
x="104"
y="28"
width="103"
height="6"
:fill="tones[frame.tone].bar"
/>
<rect
x="104"
y="39"
width="103"
height="6"
:fill="tones[frame.tone].bar"
/>
<rect
x="104"
y="50"
width="103"
height="6"
:fill="tones[frame.tone].bar"
/>
</template>
<rect
x="4"
y="3"
width="6"
height="6"
rx="3"
fill="#FF5F57"
/>
<rect
x="13"
y="3"
width="6"
height="6"
rx="3"
fill="#FEBC2D"
/>
<rect
x="22"
y="3"
width="6"
height="6"
rx="3"
fill="#28C840"
/>
<line
y1="13.5"
x2="207"
y2="13.5"
:stroke="tones[frame.tone].line"
/>
</svg>
<div :class="pane.screenClass">
<div
class="absolute flex items-center gap-1 text-xs text-ink-gray-5 font-semibold top-[24px] left-[10px]"
class="flex gap-[3px] py-[3px] px-1 border-b"
:class="
pane.tone === 'light'
? 'border-gray-100'
: 'border-gray-800'
"
>
<img
v-if="logoIsImage"
:src="logo as string"
class="size-5 object-cover"
/>
<component
:is="logo"
v-else-if="logo"
class="size-5 shrink-0 rounded-[5px]"
/>
<div v-if="name">{{ __(name) }}</div>
<div class="size-1.5 bg-[#FF5F57] rounded-full" />
<div class="size-1.5 bg-[#FEBC2D] rounded-full" />
<div class="size-1.5 bg-[#28C840] rounded-full" />
</div>
<div
class="flex items-start justify-between gap-2 p-2.5 pr-0 pb-1 min-h-[41px]"
>
<div
class="flex items-center flex-1 gap-1 text-xs-semibold text-ink-gray-5"
>
<img
v-if="logoIsImage"
:src="logo as string"
class="size-5 object-cover"
/>
<component
:is="logo"
v-else-if="logo"
class="size-5 shrink-0 rounded-4"
/>
<div>{{ __(name) }}</div>
</div>
<div
v-if="option.bars"
class="flex flex-col flex-1 gap-[5px]"
>
<div
v-for="bar in 3"
:key="bar"
class="w-full h-1.5"
:class="
pane.tone === 'light' ? 'bg-gray-100' : 'bg-gray-800'
"
/>
</div>
</div>
</div>
</div>
@@ -137,7 +86,7 @@
class="rounded-full size-3.5"
:class="
theme === option.value
? 'border-4 border-outline-gray-5'
? 'border-4 border-outline-gray-7'
: 'border border-outline-gray-4'
"
/>
@@ -151,22 +100,19 @@
<script setup lang="ts">
import { __ } from "@/translation";
import { useTheme, type Theme } from "frappe-ui";
import { useColorScheme, type ColorScheme } from "frappe-ui";
import { computed, type Component } from "vue";
type Tone = "light" | "dark";
type Frame = {
tone: Tone;
/** Stretch to fill the frame (light/dark) or natural width (system). */
fill: boolean;
frameClass: string;
type Pane = {
tone: "light" | "dark";
containerClass: string;
screenClass: string;
};
const props = withDefaults(
defineProps<{
/** Controlled value. When omitted the global frappe-ui theme is used. */
modelValue?: Theme;
modelValue?: ColorScheme;
/** Brand logo shown in the previews: image URL or component. */
logo?: string | Component;
/** Brand name shown in the previews. */
@@ -175,31 +121,25 @@ const props = withDefaults(
{ logo: "", name: "" }
);
const emit = defineEmits<{ "update:modelValue": [theme: Theme] }>();
const emit = defineEmits<{ "update:modelValue": [theme: ColorScheme] }>();
const { currentTheme, setTheme } = useTheme();
// Fixed window colors per tone — independent of the active app theme.
const tones: Record<Tone, { background: string; bar: string; line: string }> = {
light: { background: "#FFFFFF", bar: "#F3F3F3", line: "#EDEDED" },
dark: { background: "#171717", bar: "#383838", line: "#383838" },
};
const { colorScheme, setColorScheme } = useColorScheme();
const themeOptions: {
value: Theme;
value: ColorScheme;
label: string;
panes: Pane[];
bars: boolean;
frames: Frame[];
}[] = [
{
value: "light",
label: "Light",
bars: true,
frames: [
panes: [
{
tone: "light",
fill: true,
frameClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
containerClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
screenClass: "bg-white rounded-tl-1",
},
],
},
@@ -207,11 +147,11 @@ const themeOptions: {
value: "dark",
label: "Dark",
bars: true,
frames: [
panes: [
{
tone: "dark",
fill: true,
frameClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
containerClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
screenClass: "bg-gray-900 rounded-tl-1",
},
],
},
@@ -219,18 +159,18 @@ const themeOptions: {
value: "system",
label: "System",
bars: false,
frames: [
panes: [
{
tone: "light",
fill: false,
frameClass:
"flex flex-1 overflow-hidden pl-5 pt-3.5 bg-surface-gray-2 rounded-tl-[10.5px] max-h-[78px]",
containerClass:
"flex flex-1 pl-5 pt-3.5 bg-surface-gray-2 rounded-tl-[10.5px]",
screenClass: "bg-white rounded-tl-1 w-full",
},
{
tone: "dark",
fill: false,
frameClass:
"flex flex-1 overflow-hidden pl-5 pt-3.5 bg-surface-gray-3 rounded-tr-[10.5px] max-h-[78px]",
containerClass:
"flex flex-1 pl-5 pt-3.5 bg-surface-gray-3 rounded-tr-[10.5px]",
screenClass: "bg-gray-900 rounded-tl-1 w-full",
},
],
},
@@ -240,15 +180,15 @@ const logoIsImage = computed(
() => typeof props.logo === "string" && props.logo.length > 0
);
const theme = computed<Theme>({
const theme = computed<ColorScheme>({
get() {
const value = props.modelValue ?? currentTheme.value;
const value = props.modelValue ?? colorScheme.value;
if (value === "light") return "light";
if (value === "dark") return "dark";
return "system";
},
set(value) {
setTheme(value);
setColorScheme(value);
emit("update:modelValue", value);
},
});

View File

@@ -23,31 +23,33 @@
/>
<div
v-if="agentStatusStore.myStatus"
class="absolute bottom-0.5 right-0.5 size-3.5 rounded-full outline outline-white outline-4"
:class="
agentStatusStore.statusColor(agentStatusStore.myStatus)
"
/>
<Tooltip
:hoverDelay="0"
placement="bottom"
:text="profileTooltipText"
class="absolute -bottom-0.5 end-0 rounded-full bg-surface-elevation-2 p-1"
>
<div
class="size-3.5 rounded-full"
:class="
agentStatusStore.statusColor(agentStatusStore.myStatus)
"
/>
</div>
<!-- one Tooltip each: it wires only its first child -->
<Tooltip :hoverDelay="0" side="bottom" :text="uploadTooltip">
<div
class="z-1 absolute top-0 left-0 flex h-9 cursor-pointer items-center justify-center rounded-full !size-16"
@click.stop="openFileSelector"
/>
</Tooltip>
<Tooltip
v-if="user.doc?.user_image"
:hoverDelay="0"
side="bottom"
:text="__('Remove Photo')"
>
<div
v-if="user.doc?.user_image"
class="z-1 size-4 absolute -top-1 -right-1 flex cursor-pointer items-center justify-center rounded-full bg-surface-base opacity-0 duration-300 ease-in-out group-hover:opacity-100 hover:bg-surface-gray-2 outline outline-black-overlay-50"
class="z-1 size-4 absolute -top-1 -right-1 flex cursor-pointer items-center justify-center rounded-full bg-surface-base opacity-0 duration-300 ease-in-out group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-surface-gray-2 outline outline-black-overlay-50"
@click.stop="updateImage()"
@mouseenter="isHoveringRemove = true"
@mouseleave="isHoveringRemove = false"
>
<span
class="lucide-x size-3.5 cursor-pointer text-ink-gray-4"
aria-hidden="true"
/>
<LucideX class="size-3.5 cursor-pointer text-ink-gray-4" />
</div>
</Tooltip>
<div
@@ -164,6 +166,7 @@
</template>
<script setup lang="ts">
import LucideX from "~icons/lucide/x";
import {
Avatar,
Button,
@@ -190,13 +193,11 @@ const showChangePasswordModal = ref(false);
const { userId, hasAgentRecord } = useAuthStore();
const user = createDocumentResource({ doctype: "User", name: userId });
const isHoveringRemove = ref(false);
const editName = ref(false);
const profileTooltipText = computed(() => {
if (isHoveringRemove.value) return __("Remove Photo");
return user.doc?.user_image ? __("Change Photo") : __("Upload Photo");
});
const uploadTooltip = computed(() =>
user.doc?.user_image ? __("Change Photo") : __("Upload Photo")
);
const fullNameRef = useTemplateRef("fullNameRef");
const fullName = computed({
@@ -211,7 +212,7 @@ const fullName = computed({
function editFullName() {
editName.value = true;
nextTick(() => fullNameRef.value?.el?.focus());
nextTick(() => fullNameRef.value?.focus());
}
const isNameDirty = computed(() => {
@@ -234,7 +235,6 @@ function save() {
}
function updateImage(fileUrl = "") {
isHoveringRemove.value = false;
user.doc.user_image = fileUrl;
save();
}

View File

@@ -10,7 +10,7 @@
<Button
variant="solid"
:label="__('Update')"
:loading="user?.save?.loading"
:loading="user.save.loading || saveEmailAccountsResource.loading"
@click="update"
/></div
></Transition>
@@ -25,31 +25,16 @@
{{ __("Manage your email signature.") }}
</span>
</div>
<Editor
:model-value="user?.doc?.email_signature"
:placeholder="__('Write your email signature here.')"
:extensions="extensions"
:upload-function="(file) => uploadFunction(file)"
@change="(val) => (user.doc.email_signature = val)"
>
<template #default="{ editor }">
<div class="flex flex-col">
<div
class="overflow-x-auto hide-scrollbar rounded-t border border-b-0 border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1"
>
<EditorFixedMenu
:editor="editor"
:items="textEditorMenuItems"
/>
</div>
<EditorBubbleMenu :editor="editor" :items="articleToolbar" />
<EditorContent
:editor="editor"
class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded-b border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:shadow-sm 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 -mt-0.5 rtl:text-end"
/>
</div>
</template>
</Editor>
<div>
<CompactEditor
v-model="user.doc.email_signature"
:upload-fn="
(file: any, options: any) =>
uploadFunction(file, 'User', userId, false, options)
"
:placeholder="__('Write your email signature here.')"
/>
</div>
</div>
<div class="flex flex-col gap-4 mt-6">
<div class="flex flex-col gap-1">
@@ -66,8 +51,8 @@
</div>
<div>
<div
v-if="user.doc.user_emails?.length"
class="w-full border rounded-md mb-2 border-outline-elevation-2"
v-if="linkedEmails.length"
class="w-full border rounded-5 mb-2 border-outline-elevation-2"
>
<div
class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 px-4 py-3 text-sm-medium text-ink-gray-5 border-b border-outline-elevation-2"
@@ -77,48 +62,50 @@
<span></span>
</div>
<div
v-for="e in user.doc.user_emails"
:key="e.name"
v-for="e in linkedEmails"
:key="e.email_account"
class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 group items-center px-4 py-2.5 text-base border-b border-outline-elevation-2 last:border-b-0"
>
<span class="text-ink-gray-8 font-medium truncate">
{{ e.email_account }}
</span>
<span class="text-ink-gray-6 truncate">{{ e.email_id }}</span>
<div class="group-hover:opacity-100 opacity-0 transition-opacity">
<div
class="group-hover:opacity-100 opacity-0 transition-opacity [@media(hover:none)]:opacity-100"
>
<Button
class="w-10"
variant="ghost"
:tooltip="__('Remove')"
:aria-label="__('Remove')"
icon="lucide-x"
@click.prevent="removeEmail(e)"
/>
</div>
</div>
</div>
<Autocomplete
value=""
<Combobox
:model-value="null"
:options="filteredEmails"
@change="(e) => addEmail(e)"
@update:selected-option="(option) => option && addEmail(option)"
>
<template #target="{ togglePopover }">
<template #trigger>
<Button
class="!bg-surface-elevation-2"
variant="outline"
:label="__('Add Email')"
iconLeft="lucide-plus"
@click="togglePopover()"
/>
</template>
<template #item-label="{ option }">
<template #item-label="{ item }">
<div class="flex flex-col gap-1 text-ink-gray-9">
<div>{{ option.label }}</div>
<div>{{ item.label }}</div>
<div class="text-ink-gray-4 text-sm">
{{ option.email }}
{{ item.email }}
</div>
</div>
</template>
</Autocomplete>
</Combobox>
</div>
</div>
</template>
@@ -140,48 +127,64 @@
/>
</template>
<script setup>
import CompactEditor from "@/components/CompactEditor.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import Autocomplete from "@/components/frappe-ui/Autocomplete.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
import { useAuthStore } from "@/stores/auth";
import { __ } from "@/translation";
import { textEditorMenuItems } from "@/editor-menu";
import {
CleanStyles,
ComponentUtils,
HandleExcelPaste,
} from "@/tiptap-extensions";
import { normalize, uploadFunction } from "@/utils";
import { Button, createDocumentResource, toast } from "frappe-ui";
import {
Editor,
EditorBubbleMenu,
EditorContent,
EditorFixedMenu,
RichTextKit,
articleToolbar,
} from "frappe-ui/editor";
Button,
Combobox,
createDocumentResource,
createResource,
toast,
} from "frappe-ui";
import { computed, ref, watch } from "vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
const extensions = [RichTextKit, ComponentUtils, HandleExcelPaste, CleanStyles];
const { userId } = useAuthStore();
const user = createDocumentResource({ doctype: "User", name: userId });
const emit = defineEmits(["updateStep"]);
const currentUserEmailInfo = getUserEmailInfo();
// user_emails is System Manager-only, so the User doc never carries it for
// agents; the list comes from the email info API and is saved through it
const linkedEmails = ref([]);
watch(
() => currentUserEmailInfo.data?.outgoing_emails,
(emails, previousEmails) => {
// the info is shared with the composer; a reload from there must not
// wipe unsaved edits here
if (
sortedAccountNames(linkedEmails.value) !==
sortedAccountNames(previousEmails)
)
return;
linkedEmails.value = [...(emails || [])];
},
{ immediate: true }
);
const saveEmailAccountsResource = createResource({
url: "helpdesk.api.auth.set_user_email_accounts",
makeParams: () => ({
email_accounts: linkedAccountNames(linkedEmails.value),
}),
});
const filteredEmails = computed(() => {
if (!currentUserEmailInfo.data?.available_emails) return [];
const linkedEmails = user.doc.user_emails?.map((e) => e.email_id) || [];
const linkedAccounts = linkedAccountNames(linkedEmails.value);
return currentUserEmailInfo.data.available_emails
.map((doc) => ({
label: doc.name,
value: doc.name,
email: doc.email_id,
}))
.filter((e) => !linkedEmails.includes(e.email));
.filter((e) => !linkedAccounts.includes(e.value));
});
const isSignatureDirty = computed(() => {
@@ -192,10 +195,9 @@ const isSignatureDirty = computed(() => {
});
const isUserEmailListDirty = computed(() => {
const emailIds = (list = []) => list.map((e) => e.email_id).sort();
return (
JSON.stringify(emailIds(currentUserEmailInfo.data?.outgoing_emails)) !==
JSON.stringify(emailIds(user.doc.user_emails))
sortedAccountNames(currentUserEmailInfo.data?.outgoing_emails) !==
sortedAccountNames(linkedEmails.value)
);
});
@@ -209,28 +211,39 @@ if (isDirty.value) {
disableSettingModalOutsideClick.value = false;
}
function linkedAccountNames(list = []) {
return list.map((e) => e.email_account);
}
function sortedAccountNames(list) {
return linkedAccountNames(list).sort().join();
}
function addEmail(email) {
if (!user.doc.user_emails) user.doc.user_emails = [];
user.doc.user_emails.push({
email_account: email.label,
linkedEmails.value.push({
email_account: email.value,
email_id: email.email,
});
}
function removeEmail(email) {
user.doc.user_emails = user.doc.user_emails.filter(
(e) => e.email_id !== email.email_id
linkedEmails.value = linkedEmails.value.filter(
(e) => e.email_account !== email.email_account
);
}
function update() {
user.save.submit(null, {
onSuccess: () => {
toast.success(__("Email settings updated successfully."));
currentUserEmailInfo.reload();
user.reload();
},
});
async function update() {
// one after the other, since both save the same User doc
try {
if (isSignatureDirty.value) await user.save.submit();
if (isUserEmailListDirty.value) await saveEmailAccountsResource.submit();
} catch {
return; // the fallback error handler already showed a toast
}
toast.success(__("Email settings updated successfully."));
user.reload();
await currentUserEmailInfo.reload();
linkedEmails.value = [...currentUserEmailInfo.data.outgoing_emails];
}
watch(isDirty, (val) => {

View File

@@ -60,7 +60,7 @@
</template>
<script setup lang="ts">
import LockKeyhole from "~icons/lucide/lock-keyhole";
import { Dialog, toast, createResource } from "frappe-ui";
import { createResource, Dialog, Password, toast } from "frappe-ui";
import { ref, watch } from "vue";
import { __ } from "@/translation";
import { useAuthStore } from "@/stores/auth";

View File

@@ -25,7 +25,6 @@
@update:model-value="savedRepliesSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-surface-base hover:bg-surface-base focus:ring-0 border-outline-gray-2"
:debounce="300"
>
<template #prefix>
@@ -40,40 +39,21 @@
class="absolute end-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="filterOptions" placement="right">
<Dropdown
:options="filterOptions"
align="end"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<template #default="{ open }">
<Button
:label="activeFilter"
class="flex items-center justify-between w-fit p-4"
>
<Button :label="activeFilterLabel">
<template #suffix>
<span
:class="[
'h-4',
open ? 'lucide-chevron-up' : 'lucide-chevron-down',
]"
aria-hidden="true"
<component
:is="open ? LucideChevronUp : LucideChevronDown"
class="h-4"
/>
</template>
</Button>
</template>
<template #item-label="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded text-base"
@click="item.onSelect"
>
<div class="flex items-center justify-between flex-1">
<span class="whitespace-nowrap">
{{ item.label }}
</span>
<span
v-if="activeFilter === item.value"
class="lucide-check size-4 text-ink-gray-7"
aria-hidden="true"
/>
</div>
</button>
</template>
</Dropdown>
</div>
</template>
@@ -90,7 +70,7 @@
!savedRepliesListResource?.data?.length
"
variant="badge"
:icon="SavedReplyIcon"
:icon="ZapIcon"
:title="__('No saved replies found')"
:description="__('Add one to get started.')"
/>
@@ -114,7 +94,7 @@
:key="savedReply.name"
>
<div
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded"
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
>
<div
@click="
@@ -154,14 +134,15 @@
/>
{{ savedReply.scope }}
</div>
<!-- the confirm state is shared, so each menu clears it on open -->
<Dropdown
placement="right"
align="end"
:options="dropdownOptions(savedReply)"
@update:open="(open) => open && (isConfirmingDelete = false)"
>
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
class="me-2"
/>
</Dropdown>
@@ -202,6 +183,12 @@
</template>
<script setup lang="ts">
import LucideChevronUp from "~icons/lucide/chevron-up";
import LucideChevronDown from "~icons/lucide/chevron-down";
import EmptyState from "@/components/EmptyState.vue";
import { useConfigStore } from "@/stores/config";
import { __ } from "@/translation";
import { ConfirmDelete } from "@/utils";
import {
Avatar,
Button,
@@ -211,19 +198,16 @@ import {
TextInput,
toast,
} from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, inject, ref, Ref, watch } from "vue";
import { __ } from "@/translation";
import { ConfirmDelete } from "@/utils";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import { activeFilter } from "./savedReplies";
import { useUserStore } from "../../../stores/user";
import GlobeIcon from "~icons/lucide/globe";
import UserIcon from "~icons/lucide/user";
import UsersIcon from "~icons/lucide/users";
import GlobeIcon from "~icons/lucide/globe";
import { useUserStore } from "../../../stores/user";
import { SavedReply, SavedReplyListResourceSymbol } from "../../../types";
import SavedReplyIcon from "../../icons/SavedReplyIcon.vue";
import { storeToRefs } from "pinia";
import { useConfigStore } from "@/stores/config";
import ZapIcon from "~icons/lucide/zap";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import { activeFilter } from "./savedReplies";
const { getUser } = useUserStore();
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
@@ -318,40 +302,27 @@ const duplicate = async () => {
};
const filterOptions = computed(() => {
const options = [
{
label: __("All"),
value: "All",
onSelect: () => {
applyFilter("All");
},
},
{
label: __("Personal"),
value: "Personal",
onSelect: () => {
applyFilter("Personal");
},
},
{
label: __("My Team"),
value: "Team",
onSelect: () => {
applyFilter("Team");
},
},
{
label: __("Global"),
value: "Global",
onSelect: () => {
applyFilter("Global");
},
},
const scopes = [
{ label: __("All"), value: "All" },
{ label: __("Personal"), value: "Personal" },
{ label: __("My Team(s)"), value: "Team" },
{ label: __("Global"), value: "Global" },
];
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
options.pop();
scopes.pop();
}
return options;
return scopes.map((scope) => ({
...scope,
selected: activeFilter.value === scope.value,
onClick: () => applyFilter(scope.value),
}));
});
const activeFilterLabel = computed(() => {
return (
filterOptions.value.find((option) => option.value === activeFilter.value)
?.label ?? activeFilter.value
);
});
const applyFilter = (scope: string) => {

View File

@@ -54,8 +54,8 @@
<ErrorMessage class="text-p-sm" :message="errors.title" />
</div>
<div class="space-y-1.5">
<FormLabel :label="__('Scope')" />
<Select
:label="__('Visibility')"
v-model="savedReplyData.scope"
:options="scopeDropdownOptions"
required
@@ -95,6 +95,16 @@
<CompactEditor
ref="content"
v-model="savedReplyData.message"
:upload-fn="
(file: any, options: any) =>
uploadFunction(
file,
'HD Saved Reply',
savedReplyData.name,
false,
options
)
"
:extensions="[FieldAutocomplete]"
:placeholder="
__(
@@ -105,6 +115,10 @@
/>
<ErrorMessage class="text-p-sm" :message="errors.message" />
</div>
<ActionsEditor
v-model="savedReplyData.actions"
:error="errors.actions"
/>
</div>
</template>
</SettingsLayoutBase>
@@ -118,8 +132,14 @@
</template>
<script setup lang="ts">
import CompactEditor from "@/components/CompactEditor.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import DocumentationButton from "@/components/DocumentationButton.vue";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { __ } from "@/translation";
import { uploadFunction } from "@/utils";
import {
Badge,
Button,
createListResource,
createResource,
@@ -131,22 +151,18 @@ import {
Select,
toast,
} from "frappe-ui";
import { computed, inject, onUnmounted, ref, watch } from "vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation";
import PreviewDialog from "./components/PreviewDialog.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import CompactEditor from "@/components/CompactEditor.vue";
import DocumentationButton from "@/components/DocumentationButton.vue";
import { storeToRefs } from "pinia";
import { useConfigStore } from "@/stores/config";
import { useAuthStore } from "@/stores/auth";
import { FieldAutocomplete } from "../../../tiptap-extensions";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import { computed, inject, onUnmounted, ref, watch } from "vue";
import GlobeIcon from "~icons/lucide/globe";
import UserIcon from "~icons/lucide/user";
import UsersIcon from "~icons/lucide/users";
import GlobeIcon from "~icons/lucide/globe";
import { FieldAutocomplete } from "../../../tiptap-extensions";
import { SavedReply, SavedReplyListResourceSymbol, Team } from "../../../types";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import ActionsEditor from "./components/ActionsEditor.vue";
import { actionNeedsValue, isActionValueEmpty } from "./components/actionTypes";
import PreviewDialog from "./components/PreviewDialog.vue";
const showConfirmDialog = ref({
show: false,
@@ -176,12 +192,14 @@ const savedReplyData = ref({
scope: savedRepliesActiveScreen.value.data?.scope || "Personal",
message: "",
teams: [],
actions: [],
});
const initialData = ref("");
const errors = ref({
title: "",
message: "",
teams: "",
actions: "",
});
const scopeDropdownOptions = computed(() => {
@@ -226,6 +244,7 @@ const getSavedReplyData = createResource({
scope: data.scope,
message: data.message,
teams: data.teams?.map((team) => team.team) || [],
actions: JSON.parse(data.actions || "[]"),
};
initialData.value = JSON.stringify(savedReplyData.value);
},
@@ -299,10 +318,8 @@ const goBack = () => {
const onSave = () => {
validateData();
if (Object.values(errors.value).some((e) => e)) {
toast.error(__("Please fill all the required fields"));
return;
}
// Each field shows its own inline error; no toast needed
if (Object.values(errors.value).some((e) => e)) return;
if (savedRepliesActiveScreen.value.data?.name) {
updateSavedReply();
@@ -320,6 +337,7 @@ const createSavedReply = () => {
teams: savedReplyData.value.teams.map((team) => ({
team: team,
})),
actions: savedReplyData.value.actions,
},
{
onSuccess: (data) => {
@@ -359,6 +377,7 @@ const updateSavedReply = async () => {
teams: savedReplyData.value.teams.map((team) => ({
team: team,
})),
actions: savedReplyData.value.actions,
});
if (savedReplyData.value.name !== savedReplyData.value.title) {
@@ -421,6 +440,16 @@ const validateData = (key?: string) => {
}
break;
case "actions": {
const hasEmpty = savedReplyData.value.actions.some(
(action) =>
actionNeedsValue(action.action_type) &&
isActionValueEmpty(action.action_type, action.value)
);
errors.value.actions = hasEmpty ? __("Values can't be empty") : "";
break;
}
default:
break;
}
@@ -437,6 +466,7 @@ const validateData = (key?: string) => {
watch(
savedReplyData,
(newVal) => {
if (errors.value.actions) validateData("actions");
if (!initialData.value) return;
isDirty.value = JSON.stringify(newVal) != initialData.value;
if (isDirty.value) {

View File

@@ -0,0 +1,126 @@
<template>
<Select
v-if="config.control === 'select'"
class="w-full"
variant="ghost"
:model-value="(modelValue as string)"
:options="options"
:placeholder="placeholder"
@update:model-value="emit('update:modelValue', $event as string)"
/>
<Link
v-else-if="config.control === 'link'"
class="w-full"
variant="ghost"
:doctype="config.doctype!"
:filters="linkFilters(type)"
:model-value="(modelValue as string)"
:placeholder="placeholder"
:title="(modelValue as string)"
@update:model-value="emit('update:modelValue', ($event ?? '') as string)"
/>
<Combobox
v-else-if="config.control === 'combobox'"
class="w-full"
variant="ghost"
trigger="button"
:model-value="(modelValue as string)"
:options="options"
:placeholder="placeholder"
@update:model-value="emit('update:modelValue', $event as string)"
>
<template v-if="type === 'Assign Agent'" #item-prefix="{ item }">
<Avatar size="xs" :image="item.image" :label="item.label" />
</template>
</Combobox>
<MultiSelect
v-else-if="config.control === 'multiselect'"
class="w-full"
variant="ghost"
:model-value="(modelValue as string[])"
:options="options"
:placeholder="__('Select tags')"
@update:model-value="emit('update:modelValue', $event)"
>
<template #item-prefix="{ item }">
<span
class="size-2.5 shrink-0 rounded-full"
:class="colorToken(item.color)"
/>
</template>
<template #summary="{ selectedOptions }">
<span
v-if="selectedOptions.length"
class="flex items-center gap-1.5 truncate"
>
<TagChip
v-for="option in (selectedOptions as ActionOption[]).slice(0, 3)"
:key="option.value"
:tag="option.label"
:color="colorToken(option.color)"
/>
<span v-if="selectedOptions.length > 3" class="text-ink-gray-5">
+{{ selectedOptions.length - 3 }}
</span>
</span>
<span v-else>{{ __("Select tags") }}</span>
</template>
<!-- Custom summary renders its own dots; hide the default prefix so it adds no gap -->
<template #prefix>
<span class="hidden" />
</template>
</MultiSelect>
<Editor
v-else-if="config.control === 'editor'"
class="w-full"
:model-value="(modelValue as string)"
:extensions="editorExtensions"
:placeholder="__('Internal note. Type / for commands')"
@update:model-value="emit('update:modelValue', $event ?? '')"
>
<template #default>
<EditorContent
class="prose prose-v3 [--prose-font-size:14px] min-h-8 max-h-30 w-full max-w-full overflow-auto rounded-4 bg-surface-gray-1 px-2 py-1.5 text-ink-gray-7 focus:ring-0"
/>
</template>
</Editor>
<span
v-else-if="config.hint"
class="flex items-center px-2 text-base text-ink-gray-7 cursor-default"
>
{{ config.hint }}
</span>
</template>
<script setup lang="ts">
import { buildEditorExtensions } from "@/components/editor/config";
import TagChip from "@/components/tag/TagChip.vue";
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
import { colorToken } from "@/composables/useTags";
import { __ } from "@/translation";
import { SavedReplyActionType } from "@/types";
import { Link } from "@framework/ui";
import { Avatar, Combobox, MultiSelect, Select } from "frappe-ui";
import { Editor, EditorContent } from "frappe-ui/editor";
import { computed } from "vue";
import { ACTION_TYPES } from "./actionTypes";
const props = defineProps<{
type: SavedReplyActionType;
modelValue: string | string[];
options: ActionOption[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: string | string[]];
}>();
const { linkFilters } = useSavedReplyActionOptions();
const config = computed(() => ACTION_TYPES[props.type]);
const placeholder = computed(() =>
__("Select {0}", (config.value.fieldname || __("a value")).toLowerCase())
);
// Slash commands ship with RichTextKit, so "/" works without a toolbar.
const editorExtensions = buildEditorExtensions();
</script>

View File

@@ -0,0 +1,238 @@
<template>
<div class="space-y-3">
<div class="flex flex-col gap-1">
<span class="text-md-medium text-ink-gray-8">{{ __("Actions") }}</span>
<span class="text-p-base text-ink-gray-6">
{{ __("Applied to the ticket when this reply is sent") }}
</span>
</div>
<div class="rounded-5 border border-outline-gray-2 px-1 text-sm">
<template v-if="rows.length">
<div
class="grid items-center gap-6 p-2"
:style="{ gridTemplateColumns }"
>
<div
v-for="column in columns"
:key="column.key"
class="ms-2 overflow-hidden text-ellipsis whitespace-nowrap text-ink-gray-5"
>
{{ column.label }}
</div>
</div>
<hr />
<template v-for="(row, index) in rows" :key="index">
<!-- Cells centre inside one line's height, so a row centres while it
fits on one line and pins to the first line once it grows -->
<div
class="grid items-start gap-6 p-2"
:style="{ gridTemplateColumns }"
>
<div class="flex min-h-8 items-center">
<Combobox
class="w-full"
variant="ghost"
trigger="button"
:model-value="row.action_type"
:options="typeOptions(row)"
:placeholder="__('Select action')"
@update:model-value="
setActionType(row, $event as SavedReplyActionType)
"
/>
</div>
<div class="flex min-h-8 items-center">
<ActionValue
:type="row.action_type"
:model-value="row.value"
:options="valueOptions(row.action_type)"
@update:model-value="row.value = $event"
/>
</div>
<div class="flex min-h-8 items-center justify-end">
<Dropdown
align="end"
:options="[
{
label: __('Delete'),
icon: 'lucide-trash-2',
onClick: () => rows.splice(index, 1),
},
]"
>
<Button
variant="ghost"
icon="lucide-more-horizontal"
:label="__('Row actions')"
/>
</Dropdown>
</div>
</div>
<hr v-if="index !== rows.length - 1" />
</template>
</template>
<div v-else class="p-4 text-center text-ink-gray-5">
{{
__(
"No actions yet. Add one to update the ticket when this reply is sent"
)
}}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between">
<Dropdown align="start" :options="addOptions">
<Button
variant="subtle"
icon-left="lucide-plus"
:label="__('Add action')"
:disabled="!addOptions.length"
/>
</Dropdown>
<ErrorMessage :message="error" />
</div>
</div>
</template>
<script setup lang="ts">
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
import { __ } from "@/translation";
import { SavedReplyAction, SavedReplyActionType } from "@/types";
import { getGridTemplateColumnsForTable } from "@/utils";
import { Button, Combobox, Dropdown } from "frappe-ui";
import { computed, ref, watch } from "vue";
import ActionValue from "./ActionValue.vue";
import {
ACTION_MENU_GROUPS,
ACTION_TYPES,
ASSIGNMENT_ACTIONS,
isTagAction,
type ActionOption,
} from "./actionTypes";
/** One grid row. Tag rows hold every selected tag; the model stores one per tag. */
type ActionRow = {
action_type: SavedReplyActionType;
value: string | string[];
};
defineProps<{
error?: string;
}>();
const actions = defineModel<SavedReplyAction[]>({ required: true });
const { valueOptions: sharedValueOptions, tagOptions } =
useSavedReplyActionOptions();
const columns = [
{ key: "action_type", label: __("Action"), width: "190px" },
{ key: "value", label: __("Value") },
];
// Trailing 22px column holds the remove button
const gridTemplateColumns = computed(() =>
getGridTemplateColumnsForTable(columns)
);
// The table edits rows in place, so it needs a real ref; the flat model is derived.
const rows = ref<ActionRow[]>(toRows(actions.value));
/** Types still free, ignoring the row being changed: no duplicates, one assignment. */
function typeOptions(row?: ActionRow) {
const used = new Set(
rows.value
.filter((other) => other !== row)
.map((other) => other.action_type)
);
const hasAssignment = ASSIGNMENT_ACTIONS.some((type) => used.has(type));
return ACTION_MENU_GROUPS.map((group) => ({
group: group.label,
options: group.types
.filter(
(type) =>
!used.has(type) &&
!(hasAssignment && ASSIGNMENT_ACTIONS.includes(type))
)
.map((type) => ({
label: ACTION_TYPES[type].label,
value: type,
icon: ACTION_TYPES[type].icon,
})),
})).filter((group) => group.options.length);
}
/** The add button picks the type up front, so a row is never typeless. */
const addOptions = computed(() =>
typeOptions().map((group) => ({
group: group.group,
options: group.options.map((option) => ({
label: option.label,
icon: option.icon,
onClick: () => addAction(option.value),
})),
}))
);
function addAction(type: SavedReplyActionType) {
rows.value.push({ action_type: type, value: initialValue(type) });
}
/** A value picked for the old type means nothing to the new one. */
function setActionType(row: ActionRow, type: SavedReplyActionType) {
row.action_type = type;
row.value = initialValue(type);
}
/** Tag rows hold a list of tags, every other row a single value. */
function initialValue(type: SavedReplyActionType): string | string[] {
return isTagAction(type) ? [] : "";
}
/** Shared lists, minus the tags the opposite row already claimed. */
function valueOptions(type: SavedReplyActionType): ActionOption[] {
if (!isTagAction(type)) return sharedValueOptions(type);
// A tag can't be both added and removed by the same reply
const sibling = type === "Add Tag" ? "Remove Tag" : "Add Tag";
const taken = new Set(
rows.value
.filter((row) => row.action_type === sibling)
.flatMap((row) => row.value as string[])
);
return tagOptions().filter((tag) => !taken.has(String(tag.value)));
}
function toRows(list: SavedReplyAction[]): ActionRow[] {
const grouped: ActionRow[] = [];
for (const action of list) {
const tagRow = isTagAction(action.action_type)
? grouped.find((row) => row.action_type === action.action_type)
: undefined;
if (tagRow) (tagRow.value as string[]).push(action.value);
else
grouped.push({
action_type: action.action_type,
value: isTagAction(action.action_type) ? [action.value] : action.value,
});
}
return grouped;
}
/** A tag row with nothing picked simply doesn't persist. */
function toActions(list: ActionRow[]): SavedReplyAction[] {
return list.flatMap((row) => {
const type = row.action_type;
if (Array.isArray(row.value))
return row.value.map((value) => ({ action_type: type, value }));
return [{ action_type: type, value: row.value }];
});
}
watch(rows, () => (actions.value = toActions(rows.value)), { deep: true });
// Reseed only on a genuine outside change (the saved reply loading), never on
// our own write-back above.
watch(actions, (list) => {
if (JSON.stringify(list) !== JSON.stringify(toActions(rows.value)))
rows.value = toRows(list);
});
</script>

View File

@@ -1,7 +1,8 @@
<template>
<div
v-if="props.items.length > 0"
class="max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-base p-1 text-base shadow-lg pointer-events-auto hide-scrollbar"
data-suggestion-popup
class="max-h-[300px] min-w-40 overflow-y-auto rounded-6 bg-surface-base p-1 text-base shadow-lg pointer-events-auto hide-scrollbar"
>
<button
v-for="(item, index) in props.items"
@@ -12,7 +13,7 @@
}
"
:class="[
'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
'flex w-full items-center whitespace-nowrap rounded-5 px-2 py-1.5 text-sm text-ink-gray-9',
index === selectedIndex ? 'bg-surface-gray-2' : '',
]"
@click.stop.prevent="selectItem(index)"

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