use-bound-cell-write.ts6.6 KBView on GitHub 'use client';
/**
* The write half of a bound cell: a cell string in, a CRM mutation out.
*
* The grid never talks to `crm_conversations` in its own words. It calls the SAME two
* mutations the deal header and the conversation row call — `crm.updateConversation` and
* `crm.upsertWorkingMemory` — so the ownership check, the timeline row, the external-CRM push
* and the bound-cell invalidation all happen exactly as they do everywhere else. What is new
* here is only the coercion: every cell is a string, and a `dealValue` is a number.
*
* Failure is a REVERT, not a toast alone. The caller writes the new value into the Y.Doc
* before this runs (the paint the user is waiting on), so a rejected mutation has to put the
* old one back — otherwise the grid keeps showing a value the deal never took.
*/
import { useCallback, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { inferRouterInputs } from '@trpc/server';
import type { AppRouter } from '@zero/server/trpc';
import { toast } from 'sonner';
import { useTRPC } from '@/providers/query-provider';
import { isCheckedValue, parseCellDate } from './cell-values';
import type { BoundWriteTarget, ConversationWriteKey } from './bound-writes';
/**
* How a bound DATE cell is STORED — the server's own `formatBindingValue`, which slices an
* ISO string rather than honouring `column.format`. The editor has to read and write that
* form, not the column's, or the first recompute would rewrite what the user just picked.
*/
export const BOUND_DATE_FORMAT = 'yyyy-MM-dd';
type ConversationPatch = Omit<inferRouterInputs<AppRouter>['crm']['updateConversation'], 'id'>;
/**
* One cell string → the conversation patch it means, or why it is not one.
*
* Coercion and field mapping in a single exhaustive switch, deliberately: they are the same
* decision seen twice, and splitting them is how a date picker ends up writing into a field
* the route reads as text. The compiler checks each arm against the real mutation input, so a
* renamed field fails to build rather than failing in front of a customer.
*/
function conversationPatch(
key=[redacted],
raw: string,
): { patch: ConversationPatch } | { error: string } {
const trimmed = raw.trim();
// Emptying a cell CLEARS the field — each of these is nullable on the conversation, so
// "I deleted the text" has somewhere honest to land.
switch (key) {
case 'name':
// `crm_conversations.name` is NOT NULL, and the update route reads a null name as
// "leave it alone" — so an emptied name cell would silently do nothing at all.
if (!trimmed) return { error: 'A deal needs a name' };
return { patch: { name: trimmed } };
case 'status':
return { patch: { status: trimmed || null } };
case 'priority':
return { patch: { priority: trimmed || null } };
case 'risk':
return { patch: { risk: trimmed || null } };
case 'nextSteps':
return { patch: { nextSteps: trimmed || null } };
case 'statusOverview':
return { patch: { statusOverview: trimmed || null } };
case 'important':
return { patch: { important: isCheckedValue(trimmed) } };
case 'dealValue': {
if (!trimmed) return { patch: { dealValue: null } };
// A currency symbol and thousands separators are what a person types into a deal value.
const parsed = Number(trimmed.replace(/[$,\s]/g, ''));
if (!Number.isFinite(parsed)) return { error: `'${raw}' is not a number` };
return { patch: { dealValue: parsed } };
}
case 'nextStepDate': {
if (!trimmed) return { patch: { nextStepDate: null } };
const parsed = parseCellDate(trimmed, BOUND_DATE_FORMAT);
if (!parsed) return { error: `'${raw}' is not a date` };
return { patch: { nextStepDate: parsed } };
}
}
}
/**
* Sends one bound cell's value to the CRM. Resolves when the write has SETTLED — refused,
* failed or applied — and never rejects: a failure is already a toast and a revert here.
*
* Awaitable because a caller writing several cells of one gesture has to be able to order
* them: two writes to the same deal must not race the other's optimistic lock. See
* `sendGroupedWrites` in `use-grid-commands.ts`.
*/
export type BoundCellWriter = (
target: BoundWriteTarget,
value: string,
/** Put the previous cell value back. Called on a refused value and on a failed write. */
revert: () => void,
) => Promise<void>;
export function useBoundCellWrite(): BoundCellWriter {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { mutateAsync: updateConversation } = useMutation(
trpc.crm.updateConversation.mutationOptions(),
);
const { mutateAsync: upsertField } = useMutation(trpc.crm.upsertWorkingMemory.mutationOptions());
// Read at call time. The returned writer must be STABLE — it is threaded through the
// memoized row components, and a fresh identity each render would re-render every visible
// row whenever anything else in the grid changed.
const handlers = useRef({ updateConversation, upsertField, queryClient, trpc });
handlers.current = { updateConversation, upsertField, queryClient, trpc };
return useCallback(async (target, value, revert) => {
const { updateConversation: update, upsertField: upsert, queryClient: qc, trpc: t } =
handlers.current;
let write: Promise<unknown>;
if (target.write.type === 'customField') {
// A user-defined CRM field is working memory: its value is a string, and clearing it
// is the empty one.
write = upsert({
conversationId: target.conversationId,
name: target.write.name,
value: value.trim(),
});
} else {
const result = conversationPatch(target.write.key, value);
if ('error' in result) {
toast.error(`${result.error} — the cell was left as it was.`);
revert();
return;
}
write = update({ id: target.conversationId, ...result.patch });
}
try {
await write;
// The bound cell itself is refreshed server-side — the CRM write invalidates it. This
// is for the OTHER surfaces on screen: a deal open in a side panel, the pipeline
// behind the file, both of which read the conversation through these queries.
void qc.invalidateQueries({
queryKey=[redacted] id: target.conversationId }),
});
void qc.invalidateQueries({ queryKey=[redacted] });
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Could not update the deal');
revert();
}
}, []);
}