Build an administrator dashboard that shows contacts missing email or postal addresses. Clicking Refresh review list calls a server action, reads your app database, and displays the totals. The browser receives counts rather than contact records.
Use the same unpublished app as React to record changes, with Database access enabled. Run that handler for a designated test contact first to populate the queue. You can also start with an empty database and verify the empty state. No organization API credential belongs in this widget.
What you’ll have at the end
An administrator dashboard that reads the app’s review queue and displays missing-detail counts with refresh and error states.
- Refresh button
- Named server action
- Read app database
- Display review totals
Before you start
- An unpublished Widget group with Admin Scope
- App Database access enabled
- Optional review entries from the record-change guide
On this page
Open Edit → Add group → Widget, enter Widget verification, and select Admin as the Scope. Keep this internal dashboard restricted before publishing.
- index.ts directly under the group contains server actions.
- Widget → index.tsx contains the React interface.
- Widget → index.css initially imports Tailwind and can remain unchanged.
Replace the group's server index.ts with:
Example
export async function getReviewSummary() {
let contacts = 0
let missingEmail = 0
let missingAddress = 0
const entries = storage.list<{ issues: string[] }>({ prefix: ['contact-review'] }, { limit: 501, batchSize: 100 })
for await (const entry of entries) {
contacts += 1
if (contacts > 500) throw new Error('Review list exceeds the 500-contact dashboard limit')
if (entry.value.issues.includes('Add an email address')) missingEmail += 1
if (entry.value.issues.includes('Add a postal address')) missingAddress += 1
}
return { contacts, missingEmail, missingAddress, checkedAt: new Date().toISOString() }
}storage.list yields database entries. Read entry.value to access the stored issue list. The limit provides a useful bound for a small app; the extra 501st entry makes an oversized queue fail explicitly rather than returning an incomplete total. For larger lists, use a paginated or precomputed report such as the Schedule example.
The action returns only counts and the refresh time. It does not return contact names, emails, app credentials, or the entire stored object. The Admin Scope is still necessary: reducing a response's fields is not an access policy.
Replace Widget → index.tsx with this complete client:
Example
import { useState } from 'react'
type Summary = { contacts: number; missingEmail: number; missingAddress: number; checkedAt: string }
export default function Widget() {
const [summary, setSummary] = useState<Summary | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function refresh() {
setBusy(true)
setError('')
try {
setSummary(await action('getReviewSummary'))
} catch {
setError('Could not load the review list. Try again or check the app logs.')
} finally {
setBusy(false)
}
}
return <main className="mx-auto max-w-xl space-y-6 p-6">
<h1 className="text-2xl font-semibold">Contact data review</h1>
<p>Contacts flagged by this app after an update.</p>
<button className="rounded bg-black px-4 py-2 text-white disabled:opacity-50" disabled={busy} onClick={refresh}>
{busy ? 'Loading…' : 'Refresh review list'}
</button>
{error && <p role="alert">{error}</p>}
{summary && <section className="space-y-3 rounded-xl border p-5" aria-live="polite">
<h2 className="text-xl font-semibold">{summary.contacts} {summary.contacts === 1 ? 'contact needs' : 'contacts need'} attention</h2>
<p>{summary.missingEmail} missing an email address</p>
<p>{summary.missingAddress} missing a postal address</p>
<p className="text-sm">Last successful refresh: {new Date(summary.checkedAt).toLocaleTimeString()}</p>
{summary.contacts === 0 && <p>The review list is empty. Run the record-change example to add your first check.</p>}
</section>}
</main>
}action('getReviewSummary') calls the matching named server export. It returns a promise. The server uses storage; the client uses React state to display its result. No /getReviewSummary REST route or API key is needed.
The button is disabled while the request runs. On failure, the widget shows an error and keeps the last successful totals with their timestamp. It never turns a failed request into a misleading zero.
Save both projects, choose Widget verification: Simulate, wait for validation and build, and click Refresh review list.
| Test | What you should see |
|---|---|
| Empty app queue | Zero contacts and a hint to run the record-change example. |
| One contact missing both details | One contact, one missing email, one missing address. |
| Same event processed twice | The contact count remains one. |
| Corrected contact processed again | Refresh shows zero for that contact. |
| Database unavailable | A clear error; no invented successful result. |
Rendering the widget alone does not execute the action. Refresh after another group updates the database; this example does not subscribe to live changes.
Add new counters on the server and render them in the returned summary. Keep the checks consistent with the record handler. An empty queue means there are no saved review items; it does not prove every organization contact has been inspected.
For a more detailed list, return only the fields administrators need, add pagination, and apply the intended access policy. Never accept a contact ID from an arbitrary visitor and assume that visitor may read it.
If an action accepts inputs later, validate them on the server. TypeScript and disabled controls do not prevent a modified browser client from calling an action. See client and server projects and server access.
Saving changes the draft. Publish the intended version, then test the shared widget with an authorized administrator and a signed-out visitor. The latter must not reach this Admin-scoped dashboard. Owner simulation alone does not establish the published access policy.
If the published action fails, check the version, export name, Scope, and Database access. Continue with logs and file history to trace the failure.
Check your result
- Empty queue
- Zero counts with a next-step hint.
- One contact missing both details
- One contact and one of each issue.
- Action failure
- Show an error and retain the last successful timestamp.