crud-ui
Use when a data-backed app needs a working interface — a list people can add to, edit, and delete from, with the states that make it feel finished. Pairs with data-modeling, which defines the entities; this covers the React side: optimistic updates, forms, loading / empty / error states, and delete confirmation. Triggers: CRUD, add edit delete, list, form, table, manage, tracker, admin screen, "let me add items", "I want to edit them", inline edit, optimistic.
- License 许可
- MIT
- 所属仓库
- microsoft/rayfin
- 最近更新
- 2026-11-12
- Stars
- ★ 661
- Forks
- 77
01 前言 Front Matter
- name
- crud-ui
- description
- Use when a data-backed app needs a working interface — a list people can add to, edit, and delete from, with the states that make it feel finished. Pairs with data-modeling, which defines the entities; this covers the React side: optimistic updates, forms, loading / empty / error states, and delete confirmation. Triggers: CRUD, add edit delete, list, form, table, manage, tracker, admin screen, "let me add items", "I want to edit them", inline edit, optimistic.
02 资源文件 4
- 其他kit/components/states.tsx3.6 KB
- 脚本kit/components/useCrud.ts5.3 KB
- 参考文档references/evidence-and-tools.md4.2 KB
- 参考文档references/upstream-reporting.md4.0 KB
03 SKILL.md 正文
CRUD UI — a list people can actually use
data-modeling gives you entities and a typed client. This gives you the interface
over them, and the states that separate a demo from something usable.
Prerequisites: entities defined per data-modeling.
Keep the template's dynamic sign-in flow and app-wide auth gate intact, including outside the portal.
The client API is the package's, not this skill's. For the query chain, mutations, filtering,
findById, and relationship writes, readnode_modules/@microsoft/rayfin-guide/assets/docs/data/graphql.md. For paging, cursors and text-field nullability, readnode_modules/@microsoft/rayfin-data/assets/docs/index.md. Those move between releases; the React patterns below do not.
Copy the two files this skill ships into the app:
.agents/skills/crud-ui/kit/components/useCrud.ts -> packages/frontend/src/components/useCrud.ts
.agents/skills/crud-ui/kit/components/states.tsx -> packages/frontend/src/components/states.tsx
The shape
useCrud owns state and mutations; you own rendering. Give it four functions
bound to your entity and it returns items plus optimistic create / update /
remove. The four functions are ordinary client calls — see data/graphql.md
for their exact signatures.
import { useCrud } from '@/components/useCrud';
import { LoadingState, EmptyState, ErrorState } from '@/components/states';
import { getRayfinClient } from '@/lib/rayfin-client';
interface Task { id: string; title: string; done: boolean }
export function TaskList() {
const crud = useCrud<Task>({
list: async () => (await getRayfinClient()).data.Task.select(['id', 'title', 'done']).execute(),
create: async (input) => (await getRayfinClient()).data.Task.create(input),
update: async (id, patch) => (await getRayfinClient()).data.Task.update({ id }, patch),
remove: async (id) => { await (await getRayfinClient()).data.Task.delete({ id }); },
});
if (crud.status === 'loading') return <LoadingState />;
if (crud.status === 'error') return <ErrorState message={crud.error!} onRetry={crud.refresh} />;
return (
<div className="space-y-3">
{crud.error ? <ErrorState message={crud.error} onRetry={crud.clearError} /> : null}
<AddTaskForm
onAdd={(title) => { void crud.create({ title, done: false }); }}
disabled={crud.pending}
/>
{crud.items.length === 0 ? (
<EmptyState title="No tasks yet" hint="Add your first one above." />
) : (
<ul className="space-y-2">
{crud.items.map((task) => (
<li key={task.id} className="flex items-center gap-3 rounded-lg border border-black/10 px-4 py-3 dark:border-white/15">
<input
type="checkbox"
checked={task.done}
onChange={(e) => crud.update(task.id, { done: e.target.checked })}
/>
<span className={task.done ? 'flex-1 line-through opacity-60' : 'flex-1'}>{task.title}</span>
<button type="button" onClick={() => crud.remove(task.id)}>Remove</button>
</li>
))}
</ul>
)}
</div>
);
}
Why optimistic
A Fabric round-trip is slow enough that the naive version feels broken — you click
Add, nothing happens, then a row appears. useCrud applies the change locally first
and rolls it back if the server rejects it, surfacing the error. The UI tracks
intent without lying about the outcome.
Two details worth knowing: a created row gets a temporary pending-… id so React
keys stay stable until the real row arrives, and a failed delete restores the row in
its original position, not appended at the end.
Mutation result contract
The kit merges the row returned by create or update into local state. Treat that returned row as the service's authoritative representation, including normalized dates, rounded values, generated fields, and defaults. Do not refetch only to compare every field with the submitted input, and do not turn a successful mutation into a failure because the readback differs.
useCrud.create, useCrud.update, and useCrud.remove catch operation errors, store the message in crud.error, roll back optimistic state where needed, and return Promise<void>.
Fulfillment of that wrapper promise is not proof that the save succeeded.
Use crud.pending, crud.error, and the rendered item state for kit-driven UI feedback.
When code calls a lower-level mutation directly, follow that operation's documented success and error contract instead of assuming that resolution or rejection means the same thing across APIs.
Forms
Controlled inputs, validation before submit, and a disabled button while pending:
function AddTaskForm({ onAdd, disabled }: { onAdd(title: string): void; disabled?: boolean }) {
const [title, setTitle] = useState('');
const trimmed = title.trim();
return (
<form
onSubmit={(e) => { e.preventDefault(); if (!trimmed) return; onAdd(trimmed); }}
className="flex gap-2"
>
<label htmlFor="new-task" className="sr-only">Task</label>
<input
id="new-task"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a task"
maxLength={200}
className="flex-1 rounded-lg border border-black/15 px-3 py-2 dark:border-white/20"
/>
<button type="submit" disabled={disabled || !trimmed} className="rounded-lg px-4 py-2 font-medium disabled:opacity-50">
Add
</button>
</form>
);
}
- Match the entity's constraints.
@text({ max: 200 })in the schema meansmaxLength={200}here. Client validation that disagrees with the server produces errors the user can't act on. - Do not clear the field from an awaited
useCrudwrapper alone. ItsPromise<void>fulfills after either success or internally handled failure. Keep the input on error, and clear it only from explicit success state or an operation contract that reports success directly. - Every input needs a label —
sr-onlywhen the design has no room for one.
Deleting
Use ConfirmDelete for anything irreversible. It requires you to name the item,
because "Delete this item?" is a prompt people click through without reading.
{confirming === task.id ? (
<ConfirmDelete
label={task.title}
pending={crud.pending}
onCancel={() => setConfirming(null)}
onConfirm={() => { crud.remove(task.id); setConfirming(null); }}
/>
) : (
<button type="button" onClick={() => setConfirming(task.id)}>Delete</button>
)}
Skip confirmation only for genuinely cheap, reversible toggles.
Per-user data
If each person should see only their own rows, this is server-side work, not a
filter in the UI. Add the @role policy per data-modeling § per-user data — the
decorator and policy DSL are documented in
node_modules/@microsoft/rayfin-core/assets/docs/permissions.md — and stamp the
owner column from the session on create:
const session = client.auth.getSession();
if (!session.isAuthenticated || !session.user) throw new Error('Not signed in.');
create: (input) => client.data.Task.create({ ...input, owner_id: session.user.id }),
The column name has to match the one the policy compares against — the
data-modeling pack seeds owner_id. Stamping a differently-named field leaves
the real one unset, and the policy then refuses every row.
Never filter by owner only in the client — the rows would still be fetchable.
Seed data
Real-feeling content on first run makes an empty app legible. Seed once, on demand — never on every mount, which duplicates rows on refresh:
async function seedIfEmpty() {
const existing = await client.data.Task.select(['id']).execute();
if (existing.length > 0) return;
for (const title of ['Read the brief', 'Draft the plan']) {
await client.data.Task.create({
title,
done: false,
owner_id: session.user.id,
});
}
}
Prefer wiring this to an explicit "Add sample tasks" button in the empty state, so it
is the user's choice and obviously distinct from real content. Seeded rows are real
rows in their database — if they are invented figures rather than starter content,
label them visibly in the UI as sample data, per the Critical Rules in AGENTS.md.
Rendering a row safely
Treat every value crossing into JSX as untrusted display input.
A row written before a field existed may return null or undefined.
A date can arrive as a Date, and objects or arrays are never valid React text without an app-specific conversion.
Do not interpolate a raw Date, object, or array.
(Optional vs. required text nullability is a package behaviour — see
node_modules/@microsoft/rayfin-data/assets/docs/index.md. The render-time
hazard below is React's, and is this skill's to state.)
// Both can throw or render invalid React children.
if (task.description.startsWith('Starter task')) {
// ...
}
return <span>{task.metadata}</span>;
Guard optional reads and convert known values to strings.
Date-only values need an explicit UTC formatter so a value such as 2026-10-15 cannot display as the previous day west of UTC:
const dateOnlyFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeZone: 'UTC',
});
function formatDateOnly(value: unknown): string {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
return 'Not set';
}
const parsed = new Date(`${value}T00:00:00Z`);
return Number.isNaN(parsed.getTime())
? 'Not set'
: dateOnlyFormatter.format(parsed);
}
For arrays, map the intended fields to strings. For objects, render selected properties or an app-specific summary instead of relying on implicit conversion or dumping raw JSON. Format numbers explicitly for their units and provide a fallback for missing or invalid values.
Keep saved rows visible
A created or updated row should render from its own returned fields. Do not gate its title, date, status, or other saved values on unrelated live-source readiness.
Choose a default filter that includes a just-created row, or explain clearly why the active filter excludes it. For date-only filters, compare normalized date strings or use UTC fields. Do not parse a date-only value into local time before deciding whether it belongs in a month or day.
The optimistic row must be replaced by the returned row, which the kit already does. Do not add a field-by-field readback assertion that can reinterpret rounding, normalized dates, or service defaults as a failed save.
Checklist
- [ ] Loading, empty, and error states all render — not just the happy path
- [ ] Mutations are optimistic and roll back visibly on failure
- [ ] Submit disabled while pending; success UI follows the actual operation or hook state contract
- [ ] Input limits match the entity's
@text({ max }) - [ ] Every input has a label
- [ ] Destructive actions confirm, and name the thing
- [ ] Per-user data enforced by a
@rolepolicy, not a client filter - [ ] Seeding is idempotent and user-initiated
- [ ] Optional fields and non-primitive JSX values are normalized before rendering
- [ ] Date-only display and filtering cannot shift with the browser timezone
- [ ] Default filters include a newly created row
- [ ] Saved fields do not depend on unrelated live-source state
04 同仓库更多 Skills
rayfin-getting-started
Use when starting or creating a NEW Rayfin app, or when a Rayfin task comes up and you are not yet inside a Rayfin project. Gets you into a project with the Rayfin CLI, then hands off to the authoritative, version-locked in-project rayfin skill/docs that own all in-project work. Triggers: build a Rayfin app, start a Rayfin project, create a new Rayfin app, create-rayfin, npm create @microsoft/rayfin, rayfin init, scaffold rayfin, rayfin CLI, rayfin template, universal app, awesome-rayfin gallery, get started with Rayfin
visuals
Use when user wants to incorporate charts, graphs, data grid, or other visual representations of data into their project. Use VegaVisual and DataGrid components to create these visuals, utilizing the shared DataTable input, formatting, theme, and interactivity. Covers the onInteraction selection/click events host apps consume, named multi-table data input for layered overlays and reference lines, and Vega-Lite native selections. Triggers: chart, graph, plot, dashboard, KPI, metric, trend, scorecard, data grid, visualize.
schema-discovery
Explore Power BI semantic model schemas using DAX INFO functions. Progressive metadata discovery strategy, scope estimation, and narrowing techniques for tables, columns, measures, and relationships.
query-design
Separates DAX data-fetching from TypeScript presentation. Guides when to use DAX vs. TypeScript vs. Vega-Lite for aggregation, total rows, FORMAT(), SELECTCOLUMNS, BLANK handling, filtering, multi-grain queries, cross-filtering and cross-highlight subset/overlay queries, and format strings.
functions-capability
Apply and build the Universal App functions capability. Use for trusted server-side logic, secrets, external APIs, custom model endpoints, published Fabric data agents, webhooks, or privileged operations.
fabric-sdk
How to query Fabric data from a running app through the Rayfin connectors client. Use getRayfinClient().connectors.<name>.executeQuery to run DAX against a semantic model, and toQueryResult to normalize the response.