Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions frontend/src/app/(dashboard)/whatsapp/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
Expand Down Expand Up @@ -49,6 +49,7 @@ import {
renderWhatsAppTemplate,
buildSampleWhatsAppContext,
extractTriggerRule,
insertPlaceholderAtSelection,
type WhatsAppAutomationConfig,
} from '../../../../../shared/whatsapp-automation';

Expand Down Expand Up @@ -322,6 +323,8 @@ export default function WhatsAppPage() {
const [automationLoaded, setAutomationLoaded] = useState(false);
const [automationLoadError, setAutomationLoadError] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('receipt');
const templateBodyRef = useRef<HTMLTextAreaElement | null>(null);
const templateSelectionEstablishedRef = useRef(false);
const [savingAutomation, setSavingAutomation] = useState(false);
const [runningSweep, setRunningSweep] = useState(false);
const [broadcasting, setBroadcasting] = useState(false);
Expand Down Expand Up @@ -478,6 +481,14 @@ export default function WhatsAppPage() {
void refreshAutomation();
}, [refreshAutomation]);

useEffect(() => {
templateSelectionEstablishedRef.current = false;
const el = templateBodyRef.current;
if (!el) return;
const end = el.value.length;
el.setSelectionRange(end, end);
}, [selectedTemplateId, effectiveTab]);

const saveAutomation = async () => {
if (!automationLoaded || automationLoadError) {
toast.error(tCommon('somethingWrong'));
Expand Down Expand Up @@ -1442,15 +1453,49 @@ export default function WhatsAppPage() {
}));
};

const markTemplateSelectionEstablished = () => {
templateSelectionEstablishedRef.current = true;
};

const resetTemplateToDefault = () => {
const def = DEFAULT_WHATSAPP_TEMPLATES.find((d) => d.id === activeTpl.id);
if (!def) return;
templateSelectionEstablishedRef.current = false;
setAutomationConfig((prev) => ({
...prev,
templates: prev.templates.map((t) =>
t.id === activeTpl.id ? { ...def } : t,
),
}));
requestAnimationFrame(() => {
const el = templateBodyRef.current;
if (!el) return;
el.setSelectionRange(def.body.length, def.body.length);
});
};

const insertTemplatePlaceholder = (token: string) => {
const el = templateBodyRef.current;
const selectionEstablished =
templateSelectionEstablishedRef.current ||
(typeof document !== 'undefined' && document.activeElement === el) ||
(typeof el?.selectionStart === 'number' &&
(el.selectionStart !== 0 || (el.selectionEnd ?? 0) !== 0));
const { body: nextBody, cursor: nextCursor } = insertPlaceholderAtSelection(
activeTpl.body,
token,
el?.selectionStart,
el?.selectionEnd,
selectionEstablished,
);
templateSelectionEstablishedRef.current = true;
updateTemplateField('body', nextBody);
requestAnimationFrame(() => {
const target = templateBodyRef.current;
if (!target) return;
target.focus();
target.setSelectionRange(nextCursor, nextCursor);
});
};

return (
Expand Down Expand Up @@ -1489,9 +1534,17 @@ export default function WhatsAppPage() {
</label>
<textarea
id="wa-template-body"
ref={templateBodyRef}
rows={8}
value={activeTpl.body}
onChange={(e) => updateTemplateField('body', e.target.value)}
onFocus={markTemplateSelectionEstablished}
onClick={markTemplateSelectionEstablished}
onKeyUp={markTemplateSelectionEstablished}
onSelect={markTemplateSelectionEstablished}
onChange={(e) => {
markTemplateSelectionEstablished();
updateTemplateField('body', e.target.value);
}}
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
Expand All @@ -1505,9 +1558,8 @@ export default function WhatsAppPage() {
<button
key={ph.token}
type="button"
onClick={() =>
updateTemplateField('body', `${activeTpl.body}${ph.token}`)
}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertTemplatePlaceholder(ph.token)}
className="rounded border bg-muted/60 hover:bg-muted px-2 py-0.5 text-xs font-mono transition-colors cursor-pointer"
title={`${ph.label}: ${ph.sample}`}
>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@
"test:phone-validation": "node tests/run-electron-node-test.cjs tests/phone-validation.test.ts && node tests/run-electron-node-test.cjs tests/issue-263-phone-normalization.test.ts",
"test:phone-migration": "node tests/run-electron-node-test.cjs tests/phone-migration.test.ts",
"test:whatsapp-schema": "node tests/run-electron-node-test.cjs tests/whatsapp-schema.test.ts",
"test:whatsapp-service": "ts-node --transpile-only -P tests/tsconfig.json tests/whatsapp-service.test.ts && node tests/run-electron-node-test.cjs tests/whatsapp-shutdown-timeout.test.ts",
"test:whatsapp-service": "ts-node --transpile-only -P tests/tsconfig.json tests/whatsapp-template-placeholder-cursor.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/whatsapp-service.test.ts && node tests/run-electron-node-test.cjs tests/whatsapp-shutdown-timeout.test.ts",
"test:whatsapp-middleware": "node tests/run-electron-node-test.cjs tests/whatsapp-middleware.test.ts",
"test:printer-width-refresh": "node tests/run-electron-node-test.cjs tests/printer-width-refresh.test.ts",
"test:printer-migrations": "node tests/run-electron-node-test.cjs tests/printer-usb-device-path-migration.test.ts",
Expand Down
23 changes: 23 additions & 0 deletions shared/whatsapp-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,29 @@ export function buildSampleWhatsAppContext(
return merged;
}

export function insertPlaceholderAtSelection(
body: string,
token: string,
selectionStart?: number | null,
selectionEnd?: number | null,
selectionEstablished = true,
): { body: string; cursor: number } {
const rawStart =
selectionEstablished && typeof selectionStart === 'number' && Number.isFinite(selectionStart)
? Math.trunc(selectionStart)
: body.length;
const start = Math.max(0, Math.min(body.length, rawStart));
const rawEnd =
selectionEstablished && typeof selectionEnd === 'number' && Number.isFinite(selectionEnd)
? Math.trunc(selectionEnd)
: start;
const end = Math.max(start, Math.min(body.length, rawEnd));
return {
body: `${body.slice(0, start)}${token}${body.slice(end)}`,
cursor: start + token.length,
};
}

const TIME_HH_MM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;

function clampInt(value: unknown, fallback: number, min: number, max: number): number {
Expand Down
154 changes: 154 additions & 0 deletions tests/whatsapp-template-placeholder-cursor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Regression test for WhatsApp template placeholder insertion at cursor/selection (#1432).
*
* Run: npx ts-node --transpile-only -P tests/tsconfig.json tests/whatsapp-template-placeholder-cursor.test.ts
*/

import { strict as assert } from 'node:assert';
import * as whatsappAutomation from '../shared/whatsapp-automation';

const insertPlaceholderAtSelection = (
whatsappAutomation as {
insertPlaceholderAtSelection?: (
body: string,
token: string,
selectionStart?: number | null,
selectionEnd?: number | null,
selectionEstablished?: boolean,
) => { body: string; cursor: number };
}
).insertPlaceholderAtSelection;

assert.equal(
typeof insertPlaceholderAtSelection,
'function',
'shared/whatsapp-automation exports insertPlaceholderAtSelection',
);

// 1. Insert at beginning (cursor = 0)
assert.deepEqual(
insertPlaceholderAtSelection!('Hello world', '{customer_name} ', 0, 0),
{
body: '{customer_name} Hello world',
cursor: '{customer_name} '.length,
},
'inserts placeholder at index 0 and advances cursor after token',
);

// 2. Insert in the middle of the message at the active caret
assert.deepEqual(
insertPlaceholderAtSelection!(
'Dear ,\nThank you for visiting {store_name}!',
'{customer_name}',
5,
5,
),
{
body: 'Dear {customer_name},\nThank you for visiting {store_name}!',
cursor: 5 + '{customer_name}'.length,
},
'inserts placeholder at mid-string caret position instead of appending to end',
);

// 3. Replace selected text range [selectionStart, selectionEnd]
assert.deepEqual(
insertPlaceholderAtSelection!('Hi NAME, your bill is ready.', '{customer_name}', 3, 7),
{
body: 'Hi {customer_name}, your bill is ready.',
cursor: 3 + '{customer_name}'.length,
},
'replaces active selection range with placeholder and places cursor after token',
);

// 4. Sequential insertions at the returned cursor stay at the typing point
{
const initial = 'Order : ';
const first = insertPlaceholderAtSelection!(initial, '{bill_no}', 6, 6);
assert.deepEqual(first, {
body: 'Order {bill_no}: ',
cursor: 6 + '{bill_no}'.length,
});
const second = insertPlaceholderAtSelection!(
first.body,
' ({order_date})',
first.cursor,
first.cursor,
);
assert.deepEqual(
second,
{
body: 'Order {bill_no} ({order_date}): ',
cursor: 6 + '{bill_no}'.length + ' ({order_date})'.length,
},
'consecutive insertions advance cursor sequentially at insertion point',
);
}

// 5. Fallback to appending at end when selectionStart/selectionEnd are null or undefined
assert.deepEqual(
insertPlaceholderAtSelection!('Total: ', '{total}', null, null),
{
body: 'Total: {total}',
cursor: 'Total: {total}'.length,
},
'appends to end when selectionStart/selectionEnd are null',
);

assert.deepEqual(
insertPlaceholderAtSelection!('Total: ', '{total}'),
{
body: 'Total: {total}',
cursor: 'Total: {total}'.length,
},
'appends to end when selectionStart/selectionEnd are omitted',
);

// 6. Clamps out-of-bounds or inverted selection indices safely
assert.deepEqual(
insertPlaceholderAtSelection!('Hello', '{store_name}', -5, -2),
{
body: '{store_name}Hello',
cursor: '{store_name}'.length,
},
'clamps negative selection indices to 0',
);

assert.deepEqual(
insertPlaceholderAtSelection!('Hello', '{store_name}', 999, 999),
{
body: 'Hello{store_name}',
cursor: 'Hello{store_name}'.length,
},
'clamps selection indices beyond body.length to body.length',
);

assert.deepEqual(
insertPlaceholderAtSelection!('Hello world', '{store_name}', 6, 3),
{
body: 'Hello {store_name}world',
cursor: 6 + '{store_name}'.length,
},
'clamps selectionEnd below selectionStart to selectionStart without corrupting text',
);

// 7. Untouched textarea (selectionEstablished = false) appends to end even when DOM defaults to (0, 0),
// whereas an explicitly focused caret at index 0 (selectionEstablished = true) inserts at index 0
assert.deepEqual(
insertPlaceholderAtSelection!('Reply STOP to opt out.', ' {feedback_link}', 0, 0, false),
{
body: 'Reply STOP to opt out. {feedback_link}',
cursor: 'Reply STOP to opt out. {feedback_link}'.length,
},
'appends to end when selection is not yet established even if DOM selectionStart/End report 0',
);

assert.deepEqual(
insertPlaceholderAtSelection!('Reply STOP to opt out.', '{store_name}: ', 0, 0, true),
{
body: '{store_name}: Reply STOP to opt out.',
cursor: '{store_name}: '.length,
},
'inserts at index 0 when selection at 0 is explicitly established by the user',
);

console.log('✓ WhatsApp template placeholder cursor insertion tests passed');
Loading