Base inbox view
A support queue as an inbox: tickets newest first on the left, the picked one on the right to read and edit. Tickets a teammate changed in the database show unread, bold with a dot, until opened; j and k step through them and Mark all read clears the rest. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Base). See the SvGrid documentation for the full API.
About this example
An inbox view in an SvGrid Base, in Svelte 5. Records on the left, newest first by a date field, the picked one on the right to read and edit. A record a teammate changed in the database is unread, bold with a dot, until it is picked; Mark all read clears the rest, and j and k step through the list.
A support queue as an inbox: the tickets on the left, newest first by Received (inboxDateField), the picked one on the right with its fields, to read and edit in place. The view's filter applies: Solved tickets are left out.
The tickets live in a small in-page database read through baseSource. Teammates change it from elsewhere: a few seconds after the page opens Cleo, Dev and Elena move some tickets and a new one arrives, and "A teammate changes a ticket" does it again. The base reads the change and marks the record unread: bold, with a dot, and what changed listed on top of the record, credited from the Last changed by field. Picking a ticket marks it read; "Mark all read" marks the rest.
Down or j picks the next ticket, Up or k the previous, Enter opens the full record with its comments. Narrower than 640px the inbox shows the list, then the ticket with a way back.
Imports, features and API used
Imports: svelte, @svgrid/grid, @svgrid/enterprise/base, ../shared/base-people
Columns: subject (Subject), customer (Customer), status (Status), priority (Priority), assignee (Assignee), received (Received), message (Message), modified_by (Last changed by)
Frequently asked questions
What makes a record unread?
A change made elsewhere and read from the database: another user, a script or an agent. The base marks the record until someone here opens it, in the inbox or anywhere else, and names who made the change from the record's last modified by field.
How is the inbox ordered?
Newest first by the date field the view names (inboxDateField), records without a date last. Without one it keeps the view's sort. The view's filter applies either way.
Can I edit a record from the inbox?
Yes. The fields on the right open the same editors as the grid's cells, and a change goes through the grid's edit path, so undo and validation work. Expand opens the full record with its comments.
What happens on a narrow screen?
Below 640px of its own width the inbox shows the list, and picking a record shows it with a Back button.
Which license does it need?
SvGrid Base is part of the Suite edition of @svgrid/enterprise. Without a key it runs with a watermark, so it can be evaluated as is.
Related documentation
Related articles
- A Svelte Data Grid with SvelteKit and Supabase - Wire SvGrid to a Supabase Postgres backend with server-side pagination, sorting, and filtering - keeping credentials on the server and queries fast with proper indexing.
- A Live Svelte Data Grid with Firebase Firestore - Wire Firestore onSnapshot to SvGrid in Svelte 5 - reactive listeners, query-driven sort and filter, cursor pagination, and the subtle cleanup detail most implementations get wrong.
- A Svelte Data Grid with PocketBase - Wire SvGrid to PocketBase for server-side paging, sorting, and filtering via getList, plus live row updates through PocketBase realtime subscriptions.
Source code (720-base-inbox-view.svelte)
<script lang="ts">
/**
* 720. SvGrid Base: inbox view
* ----------------------------
* A support queue as an inbox: the tickets on the left, newest first by
* Received (`inboxDateField`), the picked one on the right with its fields,
* to read and edit in place. The view's filter applies: Solved tickets are
* left out.
*
* The tickets live in a small in-page database read through `baseSource`.
* Teammates change it from elsewhere: a few seconds after the page opens
* Cleo, Dev and Elena move some tickets and a new one arrives, and "A
* teammate changes a ticket" does it again. The base reads the change and
* marks the record unread: bold, with a dot, and what changed listed on top
* of the record, credited from the Last changed by field. Picking a ticket
* marks it read; "Mark all read" marks the rest.
*
* Down or j picks the next ticket, Up or k the previous, Enter opens the
* full record with its comments. Narrower than 640px the inbox shows the
* list, then the ticket with a way back.
*/
import { onMount } from 'svelte'
import { SvToaster, type ServerDataSource } from '@svgrid/grid'
import { SvBase, baseSource, enableBase, type BaseTable } from '@svgrid/enterprise/base'
import { usePeople } from '../shared/base-people'
enableBase()
usePeople()
type Row = Record<string, unknown>
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString()
const STATUS = [
{ value: 'New', color: '#2d7ff9' },
{ value: 'Open', color: '#fcb400' },
{ value: 'Waiting on customer', color: '#8b46ff' },
{ value: 'Solved', color: '#20c933' },
]
const PRIORITY = [
{ value: 'Urgent', color: '#f82b60' },
{ value: 'High', color: '#ff6f2c' },
{ value: 'Normal', color: '#94a3b8' },
]
const TICKETS: Array<[string, string, string, string, string, number, string]> = [
['Invoice shows the wrong company address', '[email protected]', 'Open', 'Normal', 'u2', 35, 'Our new address has been in the account settings since March, but the October invoice still has the old one.'],
['Cannot sign in after the password reset', '[email protected]', 'New', 'Urgent', '', 50, 'The reset link works, then the sign-in page says the password is wrong. Three people on the team have the same problem.'],
['CSV export cuts off long notes', '[email protected]', 'Open', 'High', 'u4', 95, 'Notes longer than about 250 characters end in the middle of a word in the exported file.'],
['How do I add a teammate as read-only?', '[email protected]', 'Waiting on customer', 'Normal', 'u5', 160, 'We want our accountant to see the invoices but not change anything.'],
['Charged twice for November', '[email protected]', 'Open', 'High', 'u2', 240, 'Two identical charges on the card statement, a minute apart.'],
['Report builder is slow with a year of data', '[email protected]', 'Open', 'Normal', 'u4', 380, 'Opening the yearly report takes about forty seconds; a quarter is fine.'],
['Please add a Monday digest email', '[email protected]', 'New', 'Normal', '', 600, 'A short summary of last week would save us a meeting.'],
['Webhook retries flood our endpoint', '[email protected]', 'Waiting on customer', 'High', 'u3', 900, 'After a timeout we got the same event 40 times in a minute.'],
['Thanks for the quick fix yesterday', '[email protected]', 'Solved', 'Normal', 'u5', 1500, 'The import works again. Closing this from our side.'],
]
// ---- The database: tickets kept in this page, read and written like a real one --------
const store = new Map<string, Row>(
TICKETS.map(([subject, customer, status, priority, assignee, ago, message], i) => [
`t${i + 1}`,
{ id: `t${i + 1}`, subject, customer, status, priority, assignee: assignee || null, received: minutesAgo(ago), message, modified_by: 'u1' },
]),
)
const ds: ServerDataSource<Row> = {
async getRows() {
return { rows: [...store.values()].map((r) => ({ ...r })), rowCount: store.size }
},
async createRow(input) {
const row = { ...input }
store.set(String(row.id), row)
return { ...row }
},
async updateRow(id, patch) {
store.set(String(id), { ...store.get(String(id)), ...patch })
return { ...store.get(String(id))! }
},
async deleteRow(id) {
store.delete(String(id))
},
}
// What the base listens to: a database's realtime channel would call these.
const listeners = new Set<() => void>()
const realtime = (onChange: () => void) => {
listeners.add(onChange)
return () => listeners.delete(onChange)
}
const notSolved = { kind: 'fieldFilter' as const, conjunction: 'and' as const, conditions: [{ column: 'status', kind: 'select' as const, op: 'isNot' as const, value: 'Solved' }] }
let tables = $state.raw<BaseTable[]>([
{
id: 'inbox-tickets',
name: 'Tickets',
primaryField: 'subject',
data: [],
columns: [
{ field: 'subject', header: 'Subject', width: 300, fieldType: 'text' },
{ field: 'customer', header: 'Customer', width: 210, fieldType: 'email' },
{ field: 'status', header: 'Status', width: 170, fieldType: 'singleSelect', fieldProps: { options: STATUS } },
{ field: 'priority', header: 'Priority', width: 110, fieldType: 'singleSelect', fieldProps: { options: PRIORITY } },
{ field: 'assignee', header: 'Assignee', width: 150, fieldType: 'user' },
{ field: 'received', header: 'Received', width: 170, fieldType: 'date', fieldProps: { dateFormat: 'friendly', time: true } },
{ field: 'message', header: 'Message', width: 320, fieldType: 'longText' },
{ field: 'modified_by', header: 'Last changed by', width: 150, fieldType: 'lastModifiedBy' },
],
views: [
{
id: 'inbox',
name: 'Inbox',
type: 'inbox',
inboxDateField: 'received',
listFields: ['customer', 'status', 'priority'],
state: { sorting: [], grouping: [], hiddenColumns: [], advancedFilter: notSolved },
},
{ id: 'all', name: 'All tickets', type: 'grid', state: { sorting: [{ id: 'received', desc: true }], grouping: [], hiddenColumns: [], advancedFilter: null } },
],
source: baseSource(ds, { realtime }),
props: { rowAdder: { label: 'Add ticket', defaults: () => ({ status: 'New', priority: 'Normal', received: new Date().toISOString() }) } },
},
])
// ---- Teammates, changing tickets from elsewhere -------------------------------------
const CHANGES: Array<(n: number) => void> = [
() => patch('t2', { status: 'Open', assignee: 'u3', modified_by: 'u3' }),
() => patch('t5', { status: 'Waiting on customer', priority: 'Normal', modified_by: 'u2' }),
(n) =>
store.set(`n${n}`, {
id: `n${n}`,
subject: 'Mobile app crashes when opening a photo',
customer: '[email protected]',
status: 'New',
priority: 'Urgent',
assignee: null,
received: new Date().toISOString(),
message: 'Since this morning the app closes as soon as a photo attachment is tapped.',
modified_by: 'u4',
}),
() => patch('t3', { priority: 'Urgent', assignee: 'u5', modified_by: 'u5' }),
() => patch('t6', { status: 'Waiting on customer', modified_by: 'u4' }),
() => patch('t4', { status: 'Open', message: 'They replied: read-only for one person, the accountant.', modified_by: 'u5' }),
]
let next = 0
function patch(id: string, values: Row) {
const row = store.get(id)
if (row) store.set(id, { ...row, ...values })
}
/** One teammate's change, then the database tells its listeners. */
function teammate() {
const change = CHANGES[next % CHANGES.length]!
next += 1
change(next)
for (const on of listeners) on()
}
onMount(() => {
// The first changes land once the base has read the tickets.
const timers = [1500, 1800, 2100].map((ms) => setTimeout(teammate, ms))
return () => timers.forEach(clearTimeout)
})
let phone = $state(false)
</script>
<section class="base-demo">
<div class="base-demo-bar">
<button type="button" class="base-demo-btn" onclick={teammate}>A teammate changes a ticket</button>
<div class="base-demo-seg" role="radiogroup" aria-label="Width">
<button type="button" role="radio" aria-checked={!phone} class:is-on={!phone} onclick={() => (phone = false)}>Desktop</button>
<button type="button" role="radio" aria-checked={phone} class:is-on={phone} onclick={() => (phone = true)}>Phone (390px)</button>
</div>
<span class="base-demo-tip">j and k move through the tickets; Enter opens one in full.</span>
</div>
<div class="base-demo-stage" class:is-device={phone}>
<div class="base-demo-device" style:width={phone ? '390px' : '100%'}>
<SvBase bind:tables editable sortable expandRows showRowNumbers rowHeight={40} containerHeight="100%" fieldMenu />
</div>
</div>
<SvToaster />
</section>
<style>
.base-demo { display: flex; flex-direction: column; flex: 1; min-height: 620px; height: 100%; gap: 10px; }
.base-demo-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.base-demo-btn {
height: 32px; padding: 0 12px; border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 6px);
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 13px; cursor: pointer;
}
.base-demo-btn:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 2px; }
.base-demo-seg { display: inline-flex; padding: 2px; border-radius: var(--sg-radius, 6px); background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, transparent); }
.base-demo-seg button {
height: 28px; padding: 0 10px; border: 0; border-radius: calc(var(--sg-radius, 6px) - 2px); background: none;
color: var(--sg-muted, #64748b); font: inherit; font-size: 13px; cursor: pointer;
}
.base-demo-seg button.is-on { background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); box-shadow: 0 0 0 1px var(--sg-border, #e2e8f0); }
.base-demo-tip { font-size: 13px; color: var(--sg-muted, #64748b); }
.base-demo-stage { flex: 1; min-height: 0; display: flex; justify-content: center; }
.base-demo-stage.is-device { padding: 8px 0; background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, transparent); border-radius: var(--sg-radius-lg, 10px); }
.base-demo-device { max-width: 100%; height: 100%; min-height: 560px; }
.is-device .base-demo-device { max-height: 720px; box-shadow: 0 0 0 8px var(--sg-fg, #0f172a); border-radius: 18px; overflow: hidden; }
</style>More Base examples
- Project tracker - A project tracker, ready to use: Tasks and Milestones as two linked tables. Tasks have status, tags, assignee, dates, estimate, progress, budget and files; each milestone lists its tasks with their progress and budget. Views for all tasks, the board by status, due dates on a calendar, a gallery and a request form. Automations close finished tasks, start work from a button and flag overdue ones.
- Content calendar - An editorial content calendar, ready to use: Posts and Campaigns as two linked tables. Posts have stage, channels, author, publish date, cover and brief. Views for the plan, the pipeline as a Kanban, the publishing calendar, a wall of covers and a pitch form.
- Sales CRM - A sales CRM, ready to use: Deals, Accounts and Contacts as three linked tables. Link a deal to an account and the account lists it. Views for all deals, the pipeline as a Kanban, the closing calendar, open deals by owner and a new-lead form.
- Product roadmap - A product roadmap, ready to use: Features and customer Feedback as two linked tables. Features have quarter, status, teams, lead, impact, effort and votes. Views for the roadmap grouped by quarter, a status board, a timeline, the work in progress now and a request form.
- Hiring tracker - A hiring tracker, ready to use: Candidates and Roles as two linked tables. Candidates have photo, role, stage, recruiter, rating, interview date and resume; each role lists its candidates. Views for the pipeline as a Kanban, all candidates, a gallery of faces, interviews and an application form.