diff --git a/CLAUDE.md b/CLAUDE.md index 5214edcc..65282325 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,8 @@ Frontend: - Formatting: `#lib/v2/format.js` (`money(n, currency)`, which defaults to USD, so pass `data.org.currency`; `shortDate`, `longDate`, `relativeTime`). The org's "today": `todayIn(tz)` from `#lib/v2/dates.js` with `data.org.timezone`. - Admin: `isOrgAdmin` from `#lib/admin.js`. Labels and tones: `#lib/v2/enums.js`. Classes: `cn` from `#lib/utils.js`. DRF errors to a sentence: `readableError` from `#lib/server/v2/form-errors.js`. -- Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`. -- Components: `#lib/v2/components/.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...). Many shadcn components in `components/ui/` and everything in `components/layout`, `components/dashboard` and `components/users` have no importers, and neither does `#lib/api.js`. Find a live caller before building on any of them. +- Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`. @mentions: `MentionTextarea` and `MentionBody` in `#lib/v2/components/`, fed by `mentionPeople()` from `#lib/v2/mentions.js`. +- Components: `#lib/v2/components/.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...). Many shadcn components in `components/ui/` and everything in `components/layout`, `components/dashboard` and `components/users` have no importers. Find a live caller before building on any of them. ## Commands @@ -94,7 +94,7 @@ Frontend: - **Data loads on the server.** A route's `+page.server.js` calls `#lib/server/v2/.js`, which calls `apiRequest` from `#lib/api-helpers.js` with the cookies; writes are form actions with `use:enhance` and `fail()`. There are no `+page.js` files, because the JWT is an httpOnly cookie the browser cannot read. Browser code talks to `src/routes/api/*/+server.js` proxies or `?/action`, never to Django, and cannot import `#lib/server/*`. - **A load's returned names are the page's contract.** `server/v2` returns `{ results, totals }` and each load renames them; a mismatch is an SSR 500 while the API answers 200, and `pnpm check` does not catch it. Load the page. - **Forms drop values silently.** A ` - {#each Object.entries(extraFields) as [extraKey, extraValue] (extraKey)} - - {/each} - -
- {#each activeDefs as defn (defn.id)} -
- - - {#if defn.field_type === 'text'} - - {:else if defn.field_type === 'textarea'} - - {:else if defn.field_type === 'number'} - - {:else if defn.field_type === 'date'} - - {:else if defn.field_type === 'checkbox'} - - {:else if defn.field_type === 'dropdown'} - - {:else} - - {/if} -
- {/each} -
- -
- -
- - {/if} - diff --git a/frontend/src/lib/components/custom-fields/index.js b/frontend/src/lib/components/custom-fields/index.js deleted file mode 100644 index e589c7dc..00000000 --- a/frontend/src/lib/components/custom-fields/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as CustomFieldsPanel } from './CustomFieldsPanel.svelte'; diff --git a/frontend/src/lib/components/layout/AppShell.svelte b/frontend/src/lib/components/layout/AppShell.svelte deleted file mode 100644 index 5aeb060b..00000000 --- a/frontend/src/lib/components/layout/AppShell.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - {@render children?.()} - - diff --git a/frontend/src/lib/components/layout/AppSidebar.svelte b/frontend/src/lib/components/layout/AppSidebar.svelte deleted file mode 100644 index a212a973..00000000 --- a/frontend/src/lib/components/layout/AppSidebar.svelte +++ /dev/null @@ -1,1501 +0,0 @@ - - - - - - - - - - - - - Workspace - - - - {#each workspaceItems as item (item.key ?? item.href)} - {#if item.type === 'dropdown' && item.children} - { - if (item.key) openDropdowns[item.key] = open; - }} - class="group/collapsible" - > - - - {#snippet child({ props })} - - {#snippet child({ props: btnProps })} - - {/snippet} - - {/snippet} - - - - {#each item.children as navChild (navChild.href)} - - - {#snippet child({ props })} - - {#if currentPath === navChild.href} - - {/if} - {navChild.label} - {#if navChild.count !== undefined && navChild.count !== null} - {navChild.count} - {/if} - - {/snippet} - - - {/each} - - - - - {:else} - - - {#snippet child({ props })} - - {#if currentPath === item.href} - - {/if} - - {item.label} - {#if item.count !== undefined && item.count !== null} - {item.count} - {/if} - - {/snippet} - - - {/if} - {/each} - - - - - - - - Records - - - - {#each recordsItems as item (item.key ?? item.href)} - {#if item.type === 'dropdown' && item.children} - { - if (item.key) openDropdowns[item.key] = open; - }} - class="group/collapsible" - > - - - {#snippet child({ props })} - - {#snippet child({ props: btnProps })} - - {/snippet} - - {/snippet} - - - - {#each item.children as navChild (navChild.href)} - - - {#snippet child({ props })} - - {#if currentPath === navChild.href} - - {/if} - {navChild.label} - {#if navChild.count !== undefined && navChild.count !== null} - {navChild.count} - {/if} - - {/snippet} - - - {/each} - - - - - {:else} - - - {#snippet child({ props })} - - {#if currentPath === item.href} - - {/if} - - {item.label} - {#if item.count !== undefined && item.count !== null} - {item.count} - {/if} - - {/snippet} - - - {/if} - {/each} - - - - - - - - Work - - - - {#each workItems as item (item.key ?? item.href)} - {#if item.type === 'dropdown' && item.children} - { - if (item.key) openDropdowns[item.key] = open; - }} - class="group/collapsible" - > - - - {#snippet child({ props })} - - {#snippet child({ props: btnProps })} - - {/snippet} - - {/snippet} - - - - {#each item.children as navChild (navChild.href)} - - - {#snippet child({ props })} - - {#if currentPath === navChild.href} - - {/if} - {navChild.label} - {#if navChild.count !== undefined && navChild.count !== null} - {navChild.count} - {/if} - - {/snippet} - - - {/each} - - - - - {:else} - - - {#snippet child({ props })} - - {#if currentPath === item.href} - - {/if} - - {item.label} - {#if item.count !== undefined && item.count !== null} - {item.count} - {/if} - - {/snippet} - - - {/if} - {/each} - - - - - - - - Revenue - - - - {#each revenueItems as item (item.key ?? item.href)} - {#if item.type === 'dropdown' && item.children} - { - if (item.key) openDropdowns[item.key] = open; - }} - class="group/collapsible" - > - - - {#snippet child({ props })} - - {#snippet child({ props: btnProps })} - - {/snippet} - - {/snippet} - - - - {#each item.children as navChild (navChild.href)} - - - {#snippet child({ props })} - - {#if currentPath === navChild.href} - - {/if} - {navChild.label} - {#if navChild.count !== undefined && navChild.count !== null} - {navChild.count} - {/if} - - {/snippet} - - - {/each} - - - - - {:else} - - - {#snippet child({ props })} - - {#if currentPath === item.href} - - {/if} - - {item.label} - {#if item.count !== undefined && item.count !== null} - {item.count} - {/if} - - {/snippet} - - - {/if} - {/each} - - - - - - - - Support - - - - {#each supportItems as item (item.key ?? item.href)} - {#if item.type === 'dropdown' && item.children} - { - if (item.key) openDropdowns[item.key] = open; - }} - class="group/collapsible" - > - - - {#snippet child({ props })} - - {#snippet child({ props: btnProps })} - - {/snippet} - - {/snippet} - - - - {#each item.children as navChild (navChild.href)} - - - {#snippet child({ props })} - - {#if currentPath === navChild.href} - - {/if} - {navChild.label} - {#if navChild.count !== undefined && navChild.count !== null} - {navChild.count} - {/if} - - {/snippet} - - - {/each} - - - - - {:else} - - - {#snippet child({ props })} - - {#if currentPath === item.href} - - {/if} - - {item.label} - {#if item.count !== undefined && item.count !== null} - {item.count} - {/if} - - {/snippet} - - - {/if} - {/each} - - - - - - - - - - - {#snippet child({ props })} - - - - - Download app - - - {/snippet} - - - - - - sidebar.toggle()} - tooltipContent={sidebar.state === 'collapsed' ? 'Expand sidebar' : 'Collapse sidebar'} - class="h-[30px] rounded-md pr-[10px] pl-[18px] text-[color:var(--sidebar-muted)] transition-colors duration-150 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-md - group-data-[collapsible=icon]:px-0 hover:bg-[color:var(--sidebar-accent)] hover:text-[color:var(--sidebar-foreground)]" - > - {#snippet child({ props })} - - {/snippet} - - - - - - - - {#snippet child({ props })} - - {#if user.profilePhoto && !profileImageError} - User avatar (profileImageError = true)} - /> - {:else} - - - {getInitials(user.name)} - - - {/if} -
- {user.name} - {user.email} -
- -
- {/snippet} -
- - - My Account - - - - navigateTo('/profile')} - class="gap-2.5" - > - - Profile - - navigateTo('/users')} class="gap-2.5"> - - Users & Teams - - navigateTo('/org')} class="gap-2.5"> - - Organizations - - navigateTo('/settings/organization')} - class="gap-2.5" - > - - Settings - - navigateTo('/settings/tags')} - class="gap-2.5" - > - - Tags - - navigateTo('/settings/reopen')} - class="gap-2.5" - > - - Reopen Policy - - navigateTo('/settings/custom-fields')} - class="gap-2.5" - > - - Custom Fields - - navigateTo('/settings/routing')} - class="gap-2.5" - > - - Auto-Routing - - navigateTo('/settings/escalation')} - class="gap-2.5" - > - - Escalation - - navigateTo('/settings/ticket-approvals')} - class="gap-2.5" - > - - Approval Rules - - navigateTo('/settings/inbound-email')} - class="gap-2.5" - > - - Inbound Email - - navigateTo('/settings/business-hours')} - class="gap-2.5" - > - - Business Hours - - navigateTo('/settings/macros')} - class="gap-2.5" - > - - Macros - - navigateTo('/settings/api-tokens')} - class="gap-2.5" - > - - API Tokens - - - - - Theme - - - - - - - - navigateTo('/logout')} - > - - Sign out - - -
-
-
-
- - -
- - diff --git a/frontend/src/lib/components/layout/index.js b/frontend/src/lib/components/layout/index.js index af7ce54a..d336987e 100644 --- a/frontend/src/lib/components/layout/index.js +++ b/frontend/src/lib/components/layout/index.js @@ -1,5 +1,3 @@ -export { default as AppShell } from './AppShell.svelte'; -export { default as AppSidebar } from './AppSidebar.svelte'; export { default as FilterPopover } from './FilterPopover.svelte'; export { default as PageHeader } from './PageHeader.svelte'; export { default as FilterPill } from './FilterPill.svelte'; diff --git a/frontend/src/lib/components/notifications/Bell.svelte b/frontend/src/lib/components/notifications/Bell.svelte deleted file mode 100644 index 8e6d7590..00000000 --- a/frontend/src/lib/components/notifications/Bell.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - {#snippet child({ props })} - - {/snippet} - - - (open = false)} /> - - diff --git a/frontend/src/lib/components/notifications/NotificationPanel.svelte b/frontend/src/lib/components/notifications/NotificationPanel.svelte deleted file mode 100644 index 64702489..00000000 --- a/frontend/src/lib/components/notifications/NotificationPanel.svelte +++ /dev/null @@ -1,183 +0,0 @@ - - - diff --git a/frontend/src/lib/components/notifications/index.js b/frontend/src/lib/components/notifications/index.js deleted file mode 100644 index 6c3c20b7..00000000 --- a/frontend/src/lib/components/notifications/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Bell } from './Bell.svelte'; -export { default as NotificationPanel } from './NotificationPanel.svelte'; diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte deleted file mode 100644 index dd42e915..00000000 --- a/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte deleted file mode 100644 index 800613d9..00000000 --- a/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/collapsible/collapsible.svelte b/frontend/src/lib/components/ui/collapsible/collapsible.svelte deleted file mode 100644 index 1f79ad64..00000000 --- a/frontend/src/lib/components/ui/collapsible/collapsible.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/collapsible/index.js b/frontend/src/lib/components/ui/collapsible/index.js deleted file mode 100644 index 0ecd6270..00000000 --- a/frontend/src/lib/components/ui/collapsible/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import Root from './collapsible.svelte'; -import Trigger from './collapsible-trigger.svelte'; -import Content from './collapsible-content.svelte'; - -export { - Root, - Content, - Trigger, - // - Root as Collapsible, - Content as CollapsibleContent, - Trigger as CollapsibleTrigger -}; diff --git a/frontend/src/lib/components/ui/comment-section/CommentSection.svelte b/frontend/src/lib/components/ui/comment-section/CommentSection.svelte deleted file mode 100644 index 812c27aa..00000000 --- a/frontend/src/lib/components/ui/comment-section/CommentSection.svelte +++ /dev/null @@ -1,475 +0,0 @@ - - -
- -
-
- - Comments - {#if comments.length > 0} - - {comments.length} - - {/if} -
-
- - - {#if showAddComment} -
-
- + {#if open} +
    + {#each matches as person, i (person.username)} +
  • { + // Before the textarea blurs, so the pick still knows where the handle is. + event.preventDefault(); + pick(person); + }} + > + {person.name} + @{person.username} +
  • + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/v2/mentions.js b/frontend/src/lib/v2/mentions.js new file mode 100644 index 00000000..84d3cad6 --- /dev/null +++ b/frontend/src/lib/v2/mentions.js @@ -0,0 +1,99 @@ +/** + * @mentions in ticket comments, held to the backend's rules: `cases/notifications.py` + * reads `@` with MENTION_RE and resolves it against the email local part + * of an active member. Mirroring both means the composer only offers handles that + * resolve, and a chip appears only where a notification went out. + */ + +// MENTION_RE in cases/notifications.py. +const MENTION = /(? + p.username.startsWith(q) || + p.name + .toLowerCase() + .split(/\s+/) + .some((word) => word.startsWith(q)) + ) + : people; + return hits.slice(0, LIMIT); +} + +/** + * @param {string} body + * @param {MentionPerson[]} people + * @returns {({ type: 'text', value: string } | { type: 'mention', value: string, person: MentionPerson })[]} + */ +export function mentionTokens(body, people) { + const byName = new Map(people.map((p) => [p.username, p])); + /** @type {any[]} */ + const out = []; + let last = 0; + for (const match of body.matchAll(MENTION)) { + const at = match.index ?? 0; + if (at > last) out.push({ type: 'text', value: body.slice(last, at) }); + const person = byName.get(match[1].toLowerCase()); + out.push( + person ? { type: 'mention', value: match[0], person } : { type: 'text', value: match[0] } + ); + last = at + match[0].length; + } + if (last < body.length) out.push({ type: 'text', value: body.slice(last) }); + return out; +} diff --git a/frontend/src/lib/v2/mentions.test.js b/frontend/src/lib/v2/mentions.test.js new file mode 100644 index 00000000..e0c26cd5 --- /dev/null +++ b/frontend/src/lib/v2/mentions.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { mentionPeople, mentionAt, insertMention, matchPeople, mentionTokens } from './mentions.js'; + +const liz = { username: 'liz.rao', name: 'Liz Rao' }; +const sam = { username: 'sam', name: 'Sam Okafor' }; + +describe('mentionPeople', () => { + it('names each person by their email local part, lower-cased, as the backend resolves them', () => { + expect(mentionPeople([{ id: '1', name: 'Liz Rao', email: 'Liz.Rao@acme.com' }])).toEqual([liz]); + }); + + it('keeps one entry when two people share a local part', () => { + const people = [ + { id: '1', name: 'Sam Okafor', email: 'sam@acme.com' }, + { id: '2', name: 'Sam Lee', email: 'sam@other.com' } + ]; + expect(mentionPeople(people)).toEqual([sam]); + }); + + it('skips a person with no email', () => { + expect(mentionPeople([{ id: '1', name: 'Nobody', email: '' }])).toEqual([]); + }); +}); + +describe('mentionAt', () => { + it('finds the handle being typed after @', () => { + expect(mentionAt('Hi @li', 6)).toEqual({ start: 3, query: 'li' }); + }); + + it('opens on a bare @', () => { + expect(mentionAt('@', 1)).toEqual({ start: 0, query: '' }); + }); + + it('ignores an email address', () => { + expect(mentionAt('mail liz@acme', 13)).toBeNull(); + }); + + it('reads only up to the caret', () => { + expect(mentionAt('Hi @liz there', 5)).toEqual({ start: 3, query: 'l' }); + }); + + it('closes once a space follows the handle', () => { + expect(mentionAt('Hi @liz ', 8)).toBeNull(); + }); + + it('treats a missing caret as the end of the text', () => { + expect(mentionAt('@li', null)).toEqual({ start: 0, query: 'li' }); + }); +}); + +describe('insertMention', () => { + it('replaces the typed handle and adds a space', () => { + expect(insertMention('Hi @li', 3, 6, 'liz.rao')).toEqual({ text: 'Hi @liz.rao ', caret: 12 }); + }); + + it('keeps the text after the caret', () => { + expect(insertMention('Hi @li there', 3, 6, 'liz.rao')).toEqual({ + text: 'Hi @liz.rao there', + caret: 11 + }); + }); +}); + +describe('matchPeople', () => { + it('matches the start of the handle or of a name, ignoring case', () => { + expect(matchPeople([liz, sam], 'LI')).toEqual([liz]); + expect(matchPeople([liz, sam], 'oka')).toEqual([sam]); + }); + + it('offers everyone, up to the cap, before anything is typed', () => { + const many = Array.from({ length: 9 }, (_, i) => ({ username: `u${i}`, name: `U ${i}` })); + expect(matchPeople(many, '')).toHaveLength(6); + }); +}); + +describe('mentionTokens', () => { + it('marks a handle that names a member', () => { + expect(mentionTokens('Thanks @liz.rao, done', [liz, sam])).toEqual([ + { type: 'text', value: 'Thanks ' }, + { type: 'mention', value: '@liz.rao', person: liz }, + { type: 'text', value: ', done' } + ]); + }); + + it('matches a handle whatever its case', () => { + expect(mentionTokens('@LIZ.RAO', [liz])).toEqual([ + { type: 'mention', value: '@LIZ.RAO', person: liz } + ]); + }); + + it('leaves an unknown handle as text', () => { + expect(mentionTokens('cc @bob', [liz])).toEqual([ + { type: 'text', value: 'cc ' }, + { type: 'text', value: '@bob' } + ]); + }); + + it('leaves an email address alone', () => { + expect(mentionTokens('mail liz.rao@acme.com', [liz])).toEqual([ + { type: 'text', value: 'mail liz.rao@acme.com' } + ]); + }); + + it('reads a trailing full stop as part of the handle, as the backend does', () => { + // `cases/notifications.py` captures "liz.rao." and resolves nobody, so no + // notification went out and no chip may claim one did. + expect(mentionTokens('Thanks @liz.rao.', [liz])).toEqual([ + { type: 'text', value: 'Thanks ' }, + { type: 'text', value: '@liz.rao.' } + ]); + }); +}); diff --git a/frontend/src/routes/(app)/tickets/[id]/+page.server.js b/frontend/src/routes/(app)/tickets/[id]/+page.server.js index 4e1a2baa..e5a88857 100644 --- a/frontend/src/routes/(app)/tickets/[id]/+page.server.js +++ b/frontend/src/routes/(app)/tickets/[id]/+page.server.js @@ -23,6 +23,8 @@ import { applySummary } from '#lib/server/v2/macros.js'; import { getOrgSettings } from '#lib/server/v2/organization.js'; +import { getOrgPeopleAndTeams } from '#lib/server/v2/org-people.js'; +import { mentionPeople } from '#lib/v2/mentions.js'; import { listTicketTime, startTicketTimer, @@ -86,11 +88,12 @@ import { treeRows, subtreeIds, parentCandidates, linkRefusal } from './tree.js'; * @type {import('./$types').PageServerLoad} */ export async function load({ cookies, params, locals, url }) { - const [data, timeEntries, approvals, watchers] = await Promise.all([ + const [data, timeEntries, approvals, watchers, orgPeople] = await Promise.all([ getTicket({ cookies }, params.id), listTicketTime({ cookies }, params.id).catch(() => null), listTicketApprovals({ cookies }, params.id).catch(() => null), - getTicketWatchers({ cookies }, params.id).catch(() => null) + getTicketWatchers({ cookies }, params.id).catch(() => null), + getOrgPeopleAndTeams(cookies) ]); const merge = { open: false, q: '', targets: /** @type {any[]} */ ([]), error: '' }; @@ -162,7 +165,15 @@ export async function load({ cookies, params, locals, url }) { ? { rows: treeRows(tree.root, params.id), rootId: tree.root.id } : null; - const extras = { time, approvals, watchers, macros, articlePicker }; + // Who an @handle can name, for the composer and the chips in the thread. + const extras = { + time, + approvals, + watchers, + macros, + articlePicker, + mentionPeople: mentionPeople(orgPeople.people) + }; if (!hasChildren) return { ...data, ...extras, merge, link, tree: treeView }; return { diff --git a/frontend/src/routes/(app)/tickets/[id]/+page.svelte b/frontend/src/routes/(app)/tickets/[id]/+page.svelte index 6d1080d8..3de30f82 100644 --- a/frontend/src/routes/(app)/tickets/[id]/+page.svelte +++ b/frontend/src/routes/(app)/tickets/[id]/+page.svelte @@ -6,6 +6,8 @@ import NextAction from '#lib/v2/components/NextAction.svelte'; import Pill from '#lib/v2/components/Pill.svelte'; import Avatar from '#lib/v2/components/Avatar.svelte'; + import MentionTextarea from '#lib/v2/components/MentionTextarea.svelte'; + import MentionBody from '#lib/v2/components/MentionBody.svelte'; import { relativeDays, shortAge, @@ -1357,7 +1359,9 @@ {m.author} · internal note · {shortAge(m.at)} ago
-
{m.body}
+
+ +
{:else}
{m.subject}
{/if} -
{m.body}
+ +
+ {#if m.kind === 'email'}{m.body}{:else}{/if} +
{/if} @@ -1455,14 +1465,15 @@ {/each} {/if} - + />
diff --git a/mobile/CLAUDE.md b/mobile/CLAUDE.md index 3c38d9e3..054bb8f6 100644 --- a/mobile/CLAUDE.md +++ b/mobile/CLAUDE.md @@ -17,7 +17,7 @@ Flutter client for the BottleCRM API: Riverpod 3 (hand-written `AsyncNotifier` a - "Today" is the org's, not the device's: `lib/services/org_date.dart` (`orgToday`, `todayIn`, `orgDaysUntil`, `orgTimeZoneOf`). - Currency: `Currency.symbolFor(code)` (`lib/data/models/deal.dart`), which never falls back to `$`. Invoice amounts: `money()` and `compactMoney()` in `lib/screens/invoices/invoice_format.dart`. - Attachments: `lib/services/attachment_upload.dart` (`AttachmentTarget`, `downloadAttachment`) and nowhere else. Each record type posts its own multipart field (`task_attachment`, `lead_attachment`, `opportunity_attachment`, `case_attachment`). A wrong name is answered 200 with nothing stored, so `test/unit/attachment_upload_test.dart` pins all four. -- Forms: `lib/widgets/forms/custom_fields_form.dart`, `unsaved_changes.dart` (`UnsavedChangesGuard`), `multi_select_sheet.dart`. Shared widgets: the `lib/widgets/common/common.dart` barrel and `badge.dart` (`StatusBadge`, `PriorityBadge`, `LabelPill`). +- Forms: `lib/widgets/forms/custom_fields_form.dart`, `unsaved_changes.dart` (`UnsavedChangesGuard`), `multi_select_sheet.dart`. Shared widgets: the `lib/widgets/common/common.dart` barrel and `badge.dart` (`StatusBadge`, `PriorityBadge`, `LabelPill`). @mention chips: `MentionText` in `lib/widgets/common/mention_text.dart`. ## Commands diff --git a/mobile/docs/PARITY.md b/mobile/docs/PARITY.md index 47ad4418..45b9b1a6 100644 --- a/mobile/docs/PARITY.md +++ b/mobile/docs/PARITY.md @@ -45,3 +45,4 @@ These are not gaps. Do not build them on mobile without a decision. | B9 | Web Delete for leads, contacts, accounts, deals and solutions | 2026-09-26 | Found in the 1.12.0 pass (never had a row). Both clients now gate Delete on the server's `can_delete`; mobile's client-side admin-or-creator check (`core/permissions.dart`) is deleted. | | B7 | Deal board drag gated on the card's `can_move` | 2026-09-27 | The mobile deal board reads `GET /api/opportunities/kanban/` (one call, with the list's pipeline and filters) instead of loading every page of the deals list, and a card drags or offers "Change stage" only when `can_move` is `true` (missing counts as false), like the ticket and lead boards. A column over the 100-card cap shows its real `item_count`. The board endpoint now accepts every deal list filter and its cards carry `tags`, `line_items`, `stage_changed_at` and `updated_at`. Tests: `test/unit/deal_board_provider_test.dart`, `test/screens/deals/deals_list_screen_test.dart`, `opportunity/tests/test_kanban_card_mobile_fields.py`. | | B11 | Done step after an import | 2026-10-20 | After a successful contacts or leads commit the phone sheet stays open on a done step, as the web's `ImportFlow` does: "Imported N contacts." with a check icon, one line per field the commit left blank (`csvDroppedLines`, by label), and "N rows skipped." with Save skipped rows, which saves the commit's own skipped rows (`csvSkippedRowsCsv`, formula-guarded, `-skipped-rows.csv`). `CsvImportState.result` keeps the commit's `created`, `skipped` and `dropped`. Done, Close, back and a tap outside all close it and run `onImported`, so the list refreshes and the welcome screen still lands on the imported list; the snackbar is gone for contacts and leads, and tickets keep theirs. Tests: `test/unit/csv_import_provider_test.dart`, `test/widgets/csv_import_sheet_test.dart`, `test/widgets/import_welcome_screen_test.dart`. | +| B12 | @mention someone in a ticket comment, and show mentions as chips | 2026-10-21 | Web `/tickets/[id]`: after `@` the composer offers active org members (`MentionTextarea.svelte`: keyboard or tap, a listbox announced through `aria-activedescendant`) and inserts `@`, as the phone's picker does. Comments and notes draw a handle that names a member as a chip on both clients (`MentionBody.svelte`, `mobile/lib/widgets/common/mention_text.dart`); emails stay plain. Both mirror `MENTION_RE`, so a chip means the backend notified someone. Tests: `frontend/src/lib/v2/mentions.test.js`, `mobile/test/widgets/mention_text_test.dart`. | diff --git a/mobile/lib/screens/tickets/ticket_detail_screen.dart b/mobile/lib/screens/tickets/ticket_detail_screen.dart index fa209537..358390de 100644 --- a/mobile/lib/screens/tickets/ticket_detail_screen.dart +++ b/mobile/lib/screens/tickets/ticket_detail_screen.dart @@ -22,6 +22,7 @@ import '../../services/api_service.dart' show ApiResponse; import '../../services/attachment_upload.dart'; import '../../data/models/lookup_models.dart'; import '../../widgets/common/common.dart'; +import '../../widgets/common/mention_text.dart'; import '../../widgets/forms/multi_select_sheet.dart'; import '../../widgets/tickets/ticket_approval_panel.dart'; import '../../widgets/tickets/ticket_properties_card.dart'; @@ -676,6 +677,7 @@ class _TicketDetailScreenState extends ConsumerState : 'Add the first comment to get started.', ); } + final handles = mentionHandles(ref.watch(usersProvider)); return ListView.builder( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), itemCount: comments.length, @@ -684,6 +686,7 @@ class _TicketDetailScreenState extends ConsumerState return _CommentTile( comment: comment, isInternal: internalIds.contains(comment.id), + handles: handles, ); }, ); @@ -2278,8 +2281,13 @@ class _InfoRow extends StatelessWidget { class _CommentTile extends StatelessWidget { final Comment comment; final bool isInternal; + final Set handles; - const _CommentTile({required this.comment, required this.isInternal}); + const _CommentTile({ + required this.comment, + required this.isInternal, + required this.handles, + }); @override Widget build(BuildContext context) { @@ -2321,8 +2329,9 @@ class _CommentTile extends StatelessWidget { ), const SizedBox(height: 6), ], - Text( + MentionText( comment.comment, + handles: handles, style: AppTypography.body.copyWith( color: AppColors.textPrimary, height: 1.5, diff --git a/mobile/lib/widgets/common/mention_text.dart b/mobile/lib/widgets/common/mention_text.dart new file mode 100644 index 00000000..7b05ce94 --- /dev/null +++ b/mobile/lib/widgets/common/mention_text.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/theme.dart'; +import '../../data/models/lookup_models.dart'; + +/// MENTION_RE in cases/notifications.py. The backend resolves the handle +/// against the email local part of an active member, so a chip appears only +/// where a notification went out. The web mirrors this in `lib/v2/mentions.js`. +final _mention = RegExp(r'(? mentionHandles(Iterable users) => { + for (final u in users) + if (u.isActive && u.email.contains('@')) + u.email.split('@').first.toLowerCase(), +}; + +List<({String text, bool isMention})> mentionTokens( + String body, + Set handles, +) { + final out = <({String text, bool isMention})>[]; + var last = 0; + for (final match in _mention.allMatches(body)) { + if (match.start > last) { + out.add((text: body.substring(last, match.start), isMention: false)); + } + out.add(( + text: match.group(0)!, + isMention: handles.contains(match.group(1)!.toLowerCase()), + )); + last = match.end; + } + if (last < body.length) { + out.add((text: body.substring(last), isMention: false)); + } + return out; +} + +/// A comment body with each `@handle` that names a member drawn as a chip. +class MentionText extends StatelessWidget { + final String text; + final Set handles; + final TextStyle? style; + + const MentionText(this.text, {super.key, required this.handles, this.style}); + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan( + style: style, + children: [ + for (final token in mentionTokens(text, handles)) + TextSpan( + text: token.text, + style: token.isMention + ? const TextStyle( + fontWeight: FontWeight.w600, + backgroundColor: AppColors.gray200, + ) + : null, + ), + ], + ), + ); + } +} diff --git a/mobile/test/widgets/mention_text_test.dart b/mobile/test/widgets/mention_text_test.dart new file mode 100644 index 00000000..91975570 --- /dev/null +++ b/mobile/test/widgets/mention_text_test.dart @@ -0,0 +1,84 @@ +import 'package:bottle_crm/data/models/lookup_models.dart'; +import 'package:bottle_crm/widgets/common/mention_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('mentionTokens', () { + const handles = {'liz.rao', 'sam'}; + List<(String, bool)> read(String body) => [ + for (final t in mentionTokens(body, handles)) (t.text, t.isMention), + ]; + + test('marks a handle that names a member', () { + expect(read('Thanks @liz.rao, done'), [ + ('Thanks ', false), + ('@liz.rao', true), + (', done', false), + ]); + }); + + test('matches a handle whatever its case', () { + expect(read('@LIZ.RAO'), [('@LIZ.RAO', true)]); + }); + + test('leaves an unknown handle as text', () { + expect(read('cc @bob'), [('cc ', false), ('@bob', false)]); + }); + + test('leaves an email address alone', () { + expect(read('mail liz.rao@acme.com'), [('mail liz.rao@acme.com', false)]); + }); + + test( + 'reads a trailing full stop as part of the handle, as the backend does', + () { + // cases/notifications.py captures "liz.rao." and resolves nobody. + expect(read('Thanks @liz.rao.'), [ + ('Thanks ', false), + ('@liz.rao.', false), + ]); + }, + ); + }); + + test( + "mentionHandles uses each active member's email local part, lower-cased", + () { + const users = [ + UserLookup( + id: '1', + email: 'Liz.Rao@acme.com', + role: 'USER', + isActive: true, + ), + UserLookup( + id: '2', + email: 'gone@acme.com', + role: 'USER', + isActive: false, + ), + ]; + expect(mentionHandles(users), {'liz.rao'}); + }, + ); + + testWidgets('draws a known handle as a chip and leaves the rest plain', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: MentionText('Ask @sam or @bob', handles: {'sam'})), + ), + ); + + final root = tester.widget(find.byType(Text)).textSpan! as TextSpan; + final spans = root.children!.cast(); + final sam = spans.firstWhere((s) => s.text == '@sam'); + final bob = spans.firstWhere((s) => s.text == '@bob'); + expect(sam.style?.fontWeight, FontWeight.w600); + expect(sam.style?.backgroundColor, isNotNull); + expect(bob.style, isNull); + expect(find.text('Ask @sam or @bob', findRichText: true), findsOneWidget); + }); +}