Task #444: Add Send button to note composer

Problem: the new-note composer in the Notes page only exposed Save —
to send a freshly-typed note the user had to save it, hover the
resulting card, and click the small Send icon. The user asked for an
explicit Send action right inside the composer and confirmed they're
fine with auto-saving as a side effect of pressing Send.

Implementation (artifacts/tx-os/src/pages/notes.tsx):
- Added `onSend: (id: number) => void` prop to <Composer> and wired
  the NotesPage call site to forward to the existing
  `setSendForNoteId(id)` flow that already mounts <SendNoteDialog>.
- Extracted a `buildPayload()` helper from save() so save() and the
  new sendNew() use the exact same payload shape (title, content,
  color, labels, kind, items, folderId).
- Added `sendNew()` that runs the same `create.mutate` save and, on
  success, resets the composer and calls `onSend(created.id)` so the
  send dialog opens for the freshly created note. On failure the
  composer state is left intact (same UX as save() failures).
- Added a Send icon button next to Save inside the composer footer,
  aria-labeled with the existing `notes.send` translation and tagged
  `data-testid="notes-composer-send"`. The button is disabled while
  the composer is empty (no title and no content/checklist text) and
  while a create request is in flight to prevent duplicate notes
  from rapid clicks.

The button lives inside the composer's `ref` so it doesn't trip the
existing click-outside-to-save handler, and <SendNoteDialog> renders
in a Radix portal which is already excluded from that handler.

No changes to send dialog logic, the per-card hover Send button, or
any other surface. tsc --noEmit passes for tx-os. The pre-existing
api-server `test` workflow failures (executive-meetings, service-
orders) are unrelated and out of scope.
This commit is contained in:
riyadhafraa
2026-05-09 10:42:11 +00:00
parent efe349ce8f
commit 5ef0250195
+67 -11
View File
@@ -479,7 +479,11 @@ export default function NotesPage() {
</span>
</button>
)}
<Composer labels={labels} folderSelection={folderSelection} />
<Composer
labels={labels}
folderSelection={folderSelection}
onSend={(id) => setSendForNoteId(id)}
/>
{loadingNotes ? (
<Loading />
) : filteredNotes.length === 0 ? (
@@ -2451,9 +2455,11 @@ function LabelMenu({
function Composer({
labels,
folderSelection,
onSend,
}: {
labels: NoteLabel[];
folderSelection: FolderSelection;
onSend: (id: number) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -2508,7 +2514,12 @@ function Composer({
setOpen(false);
};
const save = () => {
// Build the create-note payload from current composer state and a
// boolean indicating whether the composer is empty (no title and no
// body / checklist items). Shared by save() and sendNew() so the
// "save before send" path uses the exact same data the bare Save
// button would have submitted.
const buildPayload = () => {
const cleanItems =
kind === "checklist"
? items
@@ -2518,12 +2529,9 @@ function Composer({
const isEmpty =
!title.trim() &&
(kind === "checklist" ? cleanItems.length === 0 : !content.trim());
if (isEmpty) {
reset();
return;
}
create.mutate(
{
return {
isEmpty,
payload: {
title: title.trim(),
content: kind === "checklist" ? "" : content.trim(),
color,
@@ -2532,10 +2540,42 @@ function Composer({
items: kind === "checklist" ? cleanItems : null,
folderId: targetFolderId,
},
{ onSuccess: reset },
);
};
};
const save = () => {
const { isEmpty, payload } = buildPayload();
if (isEmpty) {
reset();
return;
}
create.mutate(payload, { onSuccess: reset });
};
// Task #444: Send button inside the composer. The plan agreed with
// the user is "save first, then open the send dialog for the new
// note". On success we reset the composer (mirroring save()) and
// hand the freshly created note's id up to the page so it can mount
// <SendNoteDialog>. On failure we leave the composer state intact
// so the user can retry — same as the existing save error behavior.
const sendNew = () => {
const { isEmpty, payload } = buildPayload();
if (isEmpty || create.isPending) return;
create.mutate(payload, {
onSuccess: (created) => {
reset();
onSend(created.id);
},
});
};
const sendDisabled =
create.isPending ||
(!title.trim() &&
(kind === "checklist"
? items.every((it) => !it.text.trim())
: !content.trim()));
return (
<div
ref={ref}
@@ -2583,10 +2623,26 @@ function Composer({
selected={labelIds}
onChange={setLabelIds}
/>
<div className="ms-auto">
<div className="ms-auto flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
{t("notes.save", "Save")}
</Button>
{/* Task #444: Send action inside the composer. Saves first
(auto-save on send was explicitly OK'd by the user),
then opens <SendNoteDialog> for the new note. Disabled
while empty or while the create request is in flight to
prevent duplicate notes from rapid clicks. */}
<Button
size="sm"
variant="ghost"
onClick={sendNew}
disabled={sendDisabled}
aria-label={t("notes.send", "Send")}
data-testid="notes-composer-send"
className="px-2"
>
<Send size={16} />
</Button>
</div>
</div>
</>