feat(water-log): add Smartsheet setup instructions modal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m54s
Deploy to route.crispygoat.com / deploy (push) Successful in 3m54s
Adds a 'Setup instructions' button to the Connection card that opens a modal with full onboarding guidance: - Prerequisites (Smartsheet account, admin access) - Step 1: Generate an API token (with link to Smartsheet docs) - Step 2: Sheet structure — full schema table mapping our 7 fields to Smartsheet column types (TEXT_NUMBER / DATETIME / DATE), with required vs optional flags and example values - Collapsible 'starter header row' CSV snippet users can paste into row 1 of a new sheet - Step 3: Connect flow (paste URL + token, test, map columns, save) - Common issues troubleshooting (401, 404 slug URLs, DATE column rejecting ISO timestamps) Modal UX: - role='dialog', aria-modal, aria-labelledby - Closes on X button, 'Got it' footer button, click-outside backdrop, or Escape key - Sticky header + footer so scrollable content stays framed - Locked to existing cream + forest palette (Field Almanac style) Implementation: - Added 'setupModalOpen' to state, OPEN_SETUP_MODAL/CLOSE_SETUP_MODAL actions to reducer - Extended Card subcomponent with optional 'action' slot (used here for the trigger button, reusable for future per-card actions) - Added useEffect that registers a window keydown listener only while the modal is open; cleans up on close or unmount - FIELD_SCHEMA const declared alongside the modal so future edits to the schema live next to their documentation
This commit is contained in:
@@ -95,6 +95,8 @@ type State = {
|
||||
// Recent activity
|
||||
recentLog: SmartsheetSyncLogEntry[];
|
||||
loadingLog: boolean;
|
||||
/** Whether the setup-instructions modal is open. */
|
||||
setupModalOpen: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
@@ -114,7 +116,9 @@ type Action =
|
||||
| { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] }
|
||||
| { type: "TEST_FAIL"; message: string }
|
||||
| { type: "CLEAR_MESSAGE" }
|
||||
| { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] };
|
||||
| { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] }
|
||||
| { type: "OPEN_SETUP_MODAL" }
|
||||
| { type: "CLOSE_SETUP_MODAL" };
|
||||
|
||||
const EMPTY_MAPPING: SmartsheetColumnMapping = {
|
||||
entry_id: "",
|
||||
@@ -141,6 +145,7 @@ const initialState: State = {
|
||||
test: { state: "idle" },
|
||||
recentLog: [],
|
||||
loadingLog: false,
|
||||
setupModalOpen: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
@@ -218,6 +223,10 @@ function reducer(state: State, action: Action): State {
|
||||
return { ...state, message: null };
|
||||
case "REFRESH_LOG":
|
||||
return { ...state, recentLog: action.log };
|
||||
case "OPEN_SETUP_MODAL":
|
||||
return { ...state, setupModalOpen: true };
|
||||
case "CLOSE_SETUP_MODAL":
|
||||
return { ...state, setupModalOpen: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -243,6 +252,18 @@ export default function SmartsheetIntegrationCard({
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
// Close the setup modal on Escape. Listens only while the modal is open.
|
||||
useEffect(() => {
|
||||
if (!state.setupModalOpen) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
dispatch({ type: "CLOSE_SETUP_MODAL" });
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [state.setupModalOpen]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
dispatch({ type: "SAVE_START" });
|
||||
@@ -331,6 +352,15 @@ export default function SmartsheetIntegrationCard({
|
||||
<Card
|
||||
title="Connection"
|
||||
subtitle="Smartsheet API token + target sheet."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "OPEN_SETUP_MODAL" })}
|
||||
className="rounded-md border border-[#d4d9d3] bg-white px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-[#5a5d5a] transition hover:border-[#1a4d2e] hover:text-[#1a4d2e]"
|
||||
>
|
||||
Setup instructions
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* Sheet */}
|
||||
<div className="space-y-1">
|
||||
@@ -603,6 +633,12 @@ export default function SmartsheetIntegrationCard({
|
||||
Paste an API token above to enable Save.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.setupModalOpen && (
|
||||
<SetupInstructionsModal
|
||||
onClose={() => dispatch({ type: "CLOSE_SETUP_MODAL" })}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -612,17 +648,22 @@ export default function SmartsheetIntegrationCard({
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
|
||||
<header className="mb-4">
|
||||
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
||||
<header className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -687,4 +728,340 @@ function TestResultPanel({ test }: { test: TestState }) {
|
||||
<p className="font-semibold text-[#a4452b]">✗ {test.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Setup instructions modal ───────────────────────────────────────────────
|
||||
|
||||
const FIELD_SCHEMA: Array<{
|
||||
key: keyof SmartsheetColumnMapping;
|
||||
label: string;
|
||||
required: boolean;
|
||||
type: string;
|
||||
example: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
key: "entry_id",
|
||||
label: "Entry ID",
|
||||
required: true,
|
||||
type: "TEXT_NUMBER",
|
||||
example: "550e8400-e29b-41d4-a716-446655440000",
|
||||
description:
|
||||
"Unique UUID for each water log row. We use this for dedup — if the same Entry ID is already in your sheet, we skip the insert instead of creating a duplicate row.",
|
||||
},
|
||||
{
|
||||
key: "logged_at",
|
||||
label: "Logged At",
|
||||
required: true,
|
||||
type: "DATETIME (or DATE)",
|
||||
example: "2026-07-03 14:50",
|
||||
description:
|
||||
"When the reading was taken. Use a DATETIME column for full timestamp, or DATE for day-only. We format automatically based on the column type.",
|
||||
},
|
||||
{
|
||||
key: "headgate",
|
||||
label: "Headgate",
|
||||
required: false,
|
||||
type: "TEXT_NUMBER",
|
||||
example: "Section 4 — North Field",
|
||||
description: "Display name of the headgate the reading came from.",
|
||||
},
|
||||
{
|
||||
key: "measurement",
|
||||
label: "Measurement",
|
||||
required: false,
|
||||
type: "TEXT_NUMBER",
|
||||
example: "123.45",
|
||||
description:
|
||||
"The numeric reading. Sent as a string (e.g. \"123.45\"); Smartsheet's TEXT_NUMBER columns accept this and display it as a number.",
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "Unit",
|
||||
required: false,
|
||||
type: "TEXT_NUMBER",
|
||||
example: "CFS",
|
||||
description: "Display unit, e.g. CFS, GPM, AF/Day, inches.",
|
||||
},
|
||||
{
|
||||
key: "irrigator",
|
||||
label: "Irrigator",
|
||||
required: false,
|
||||
type: "TEXT_NUMBER",
|
||||
example: "Jane Doe",
|
||||
description: "Name of the irrigator who logged the reading.",
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
label: "Notes",
|
||||
required: false,
|
||||
type: "TEXT_NUMBER",
|
||||
example: "Replaced meter seal",
|
||||
description: "Free-text notes attached to the entry (may be empty).",
|
||||
},
|
||||
];
|
||||
|
||||
function SetupInstructionsModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="smartsheet-setup-title"
|
||||
onClick={(e) => {
|
||||
// Close when clicking the backdrop (but not the panel itself).
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-xl">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] bg-[#fdfaf2] px-6 py-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
||||
§ 05.1 — Setup
|
||||
</p>
|
||||
<h2
|
||||
id="smartsheet-setup-title"
|
||||
className="mt-1 text-xl font-medium text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
Connect a Smartsheet
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="-mr-1 -mt-1 rounded-md p-2 text-[#5a5d5a] hover:bg-[#f4f1e8] hover:text-[#1d1d1f]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6 px-6 py-5">
|
||||
{/* Prerequisites */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-[#1d1d1f]">
|
||||
Before you start
|
||||
</h3>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-[#5a5d5a]">
|
||||
<li>You need a Smartsheet account.</li>
|
||||
<li>
|
||||
The target sheet must exist in your account. We can add
|
||||
columns to it for you (see Step 2), but we cannot create
|
||||
the sheet itself.
|
||||
</li>
|
||||
<li>
|
||||
You need admin access to the Water Log settings page
|
||||
(you're already here).
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Step 1 */}
|
||||
<section>
|
||||
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
|
||||
1
|
||||
</span>
|
||||
Generate an API token
|
||||
</h3>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-sm text-[#5a5d5a]">
|
||||
<li>
|
||||
Go to{" "}
|
||||
<a
|
||||
href="https://app.smartsheet.com/b/home"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#1a4d2e] underline underline-offset-2"
|
||||
>
|
||||
app.smartsheet.com
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Click your avatar (top right) →{" "}
|
||||
<span className="font-mono text-xs">Account</span> →{" "}
|
||||
<span className="font-mono text-xs">Personal Settings</span>{" "}
|
||||
→ <span className="font-mono text-xs">API Access</span>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate new access token</strong>. Give it a
|
||||
name like <span className="font-mono text-xs">RouteCommerce Water Log</span>.
|
||||
</li>
|
||||
<li>
|
||||
Copy the token <em>immediately</em> — Smartsheet only shows
|
||||
it once.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Step 2 */}
|
||||
<section>
|
||||
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
|
||||
2
|
||||
</span>
|
||||
Set up your sheet
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||
Your sheet needs at least the two <strong>required</strong>{" "}
|
||||
columns below. The other five are optional but recommended —
|
||||
you decide which of your sheet's existing columns
|
||||
receives each field.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-[#d4d9d3]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[#f4f1e8] text-[#5a5d5a]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
Our field
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
Smartsheet column type
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
Example
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#d4d9d3] bg-white">
|
||||
{FIELD_SCHEMA.map((f) => (
|
||||
<tr key={f.key}>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="font-semibold text-[#1d1d1f]">
|
||||
{f.label}
|
||||
{f.required && (
|
||||
<span className="ml-1 text-[#a4452b]">*</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-[#8a8b88]">
|
||||
{f.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-[11px] text-[#5a5d5a]">
|
||||
{f.type}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-[11px] text-[#5a5d5a]">
|
||||
{f.example}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#8a8b88]">
|
||||
<span className="text-[#a4452b]">*</span> Required. The
|
||||
Entry ID column is what we use for dedup — without it, a
|
||||
re-sync would create duplicate rows.
|
||||
</p>
|
||||
|
||||
<details className="mt-3 rounded-lg border border-[#d4d9d3] bg-white">
|
||||
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#f4f1e8]">
|
||||
Copy a starter header row
|
||||
</summary>
|
||||
<pre className="overflow-x-auto border-t border-[#d4d9d3] bg-[#fdfaf2] px-4 py-3 text-xs text-[#1d1d1f]">
|
||||
<code>
|
||||
Entry ID,Logged At,Headgate,Measurement,Unit,Irrigator,Notes
|
||||
</code>
|
||||
</pre>
|
||||
<p className="border-t border-[#d4d9d3] px-4 py-2 text-xs text-[#5a5d5a]">
|
||||
Paste this into row 1 of a new sheet, then click the
|
||||
<span className="font-mono"> ▼ </span>
|
||||
on the Entry ID cell and choose{" "}
|
||||
<span className="font-mono">Column Type → Text/Number</span>.
|
||||
Repeat for the others.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{/* Step 3 */}
|
||||
<section>
|
||||
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
|
||||
3
|
||||
</span>
|
||||
Connect
|
||||
</h3>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-sm text-[#5a5d5a]">
|
||||
<li>
|
||||
Close this dialog and paste your <strong>sheet URL</strong>{" "}
|
||||
or numeric sheet ID into the Sheet URL field. (File →
|
||||
Properties in Smartsheet shows the numeric ID.)
|
||||
</li>
|
||||
<li>
|
||||
Paste your API token into the API Access Token field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Test Connection</strong> — we'll load
|
||||
your sheet's columns into the dropdowns.
|
||||
</li>
|
||||
<li>
|
||||
Map each of our fields to one of your sheet's
|
||||
columns. Unmapped fields are simply skipped.
|
||||
</li>
|
||||
<li>Click <strong>Save Settings</strong>.</li>
|
||||
</ol>
|
||||
<p className="mt-3 rounded-lg border border-[#1a4d2e]/30 bg-[#1a4d2e]/5 px-3 py-2 text-xs text-[#1a4d2e]">
|
||||
<strong>Tip:</strong> the first sync runs immediately if you
|
||||
set frequency to <em>Real-time</em>, or on the next cron
|
||||
tick for 15-minute / hourly. Submit a test entry from{" "}
|
||||
<code className="font-mono">/water</code> to see it land in
|
||||
your sheet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<section className="border-t border-[#d4d9d3] pt-4">
|
||||
<h3 className="text-sm font-semibold text-[#1d1d1f]">
|
||||
Common issues
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-2 text-xs text-[#5a5d5a]">
|
||||
<li>
|
||||
<strong>401 Unauthorized</strong> — the token is wrong or
|
||||
expired. Re-generate it in Smartsheet.
|
||||
</li>
|
||||
<li>
|
||||
<strong>404 Sheet not found</strong> — if you pasted a
|
||||
share URL with a slug (e.g. <code className="font-mono">/sheets/abc123</code>),
|
||||
Smartsheet sometimes 404s on those. Use the numeric sheet
|
||||
ID instead, found in{" "}
|
||||
<span className="font-mono">File → Properties</span>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Test Connection succeeds but rows don't
|
||||
appear</strong> — the column type probably rejects our
|
||||
value (most common: a DATE column getting an ISO
|
||||
timestamp). Check the Recent Activity panel for the exact
|
||||
error.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="sticky bottom-0 flex justify-end border-t border-[#d4d9d3] bg-[#fdfaf2] px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-[#1a4d2e] px-5 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24]"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user