Imported from yurabh/admin-panel-ui (
.claude/skills/add-crud-resource/SKILL.md). Install upstream withnpx skills add yurabh/admin-panel-ui --skill add-crud-resource. Copyright stays with the author.
Add CRUD Resource
Purpose
Scaffold a new admin CRUD resource matching the existing patterns. The project already
uses the universal useResource composable — everything below assumes it exists at
src/composables/useResource.ts. Do NOT reimplement CRUD logic.
Step 1 — Gather info
If the user did not specify, ask these (batch them together, don't ask one by one):
- Resource singular (e.g.
tag) and plural (e.g.tags). - Fields — for each: name, type (string/number/boolean/textarea/select/relation), required?, default value.
- Auto-slug from another field? (e.g. slug from name) — yes/no.
- Related resources to load into a dropdown? (e.g. comments need a posts dropdown). If yes, which resource.
- Table columns — which fields to display (defaults to first 3 + created_at).
- Backend endpoint — default
/api/admin/<plural>, override if different.
Skip questions the user already answered in the request.
Step 2 — Read reference implementations BEFORE writing anything
Read at minimum:
src/composables/useResource.ts— the universal composablesrc/composables/useCategories.ts— simplest wrappersrc/composables/usePosts.ts— wrapper with a related dropdownsrc/components/categories/CategoriesTable.vuesrc/components/categories/CategoriesTableRow.vuesrc/components/categories/CategoryFormModal.vuesrc/pages/categories/CategoriesPage.vuesrc/application/api/resources/CategoryClient.tssrc/application/types/api/resources/Category.tssrc/components/layout/AppSidebar.vuesrc/router/index.ts
If the resource has related dropdowns, also read usePosts.ts for the pattern.
Step 3 — Generate these files
Naming: <Resource> = PascalCase singular, <Resources> = PascalCase plural,
<resource> = camelCase singular, <resources> = camelCase plural.
-
src/application/types/api/resources/<Resource>.tsinterface <Resource>withid, all fields, optionalcreated_at/updated_atenum BackendEndpointwith<Resources>and<Resource>entriesinterface Store<Resource>Requestwith writable fields onlyexport type Update<Resource>Request = Store<Resource>Request
-
src/application/api/resources/<Resource>Client.ts- Class with
index,store,update(id, data),destroy(id) - Mirror
CategoryClient.tsexactly
- Class with
-
src/composables/use<Resources>.ts- Thin wrapper around
useResource<Item, StoreRequest>() - Provide
client,resourceLabel,resourceLabelPlural,initialForm,mapItemToForm,getItemLabel - Alias
itemsto plural name:<resources>: resource.items - Return
{ ...resource, <resources>: resource.items } - If auto-slug: add
generateSlug()usingslugifyfrom@/utils/slugify(create it if missing — see step 4) - If related dropdown:
const relatedItems = ref<Related[]>([])andonMounted(fetchRelated)— mirrorusePosts.ts
- Thin wrapper around
-
src/components/<resources>/<Resources>Table.vue- Header row per column
<v-for>over<Resources>TableRowwithedit/deleteemits
-
src/components/<resources>/<Resources>TableRow.vue- Cells for each column
formattedDatecomputed for created_at- Two
AppButtons (edit/delete) with the same styling asPostsTableRow.vue
-
src/components/<resources>/<Resource>FormModal.vue- Wraps
AppModal <FormRow>per field with the correct input componentAppSelectfor enums/relations (convertnull <-> ''via acomputed— seePostFormModal.vuecategoryIdAsStringfor the pattern)AppCheckboxfor booleans (writable via computed — seeisPublishedComputed)- Cancel button + submit
AppButton - Emits:
close,submit, and anyblurevents for auto-slug
- Wraps
-
src/pages/<resources>/<Resources>Page.vuePageHeaderwith title, subtitle, action buttonEmptyStatefor loading/empty<Resources>Tablewhen data exists<Resource>FormModalwhenisModalOpen- Destructure everything from
use<Resources>()
Step 4 — Ensure utilities exist
Check src/utils/slugify.ts:
export function slugify(str: string): string {
return str.toLowerCase().trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
}
If missing and the resource needs slug, create it.
Step 5 — Wire it up
Update BOTH files, do not forget:
src/router/index.ts— add a child route inside the dashboard layout block:
{
path: '',
name: '',
component: () => import('@/pages//Page.vue'),
}
src/components/layout/AppSidebar.vue— add tonavItems:
{ name: '', label: '' }
Step 6 — Verify before finishing
Run through this checklist and fix anything that fails:
- Composable uses
useResource, not raw refs and try/catch. - Composable returns
<resources>: resource.itemsalias. - No
try/catchin components or pages. - No new base UI primitive created — only existing ones reused.
-
AppSelectfor relations uses acomputedfor null↔string conversion. -
AppCheckboxuses acomputedwith getter/setter for boolean binding. -
formattedDateis acomputedin the row component. - Route name and sidebar nav item name match.
- Delete button has
delete-btnclass for red hover state. - Types file exports
interface,enum BackendEndpoint,Store<Resource>Request,Update<Resource>Request. -
slugifyused from@/utils/slugify(do not inline it). - If related dropdown:
onMounted(fetchRelated)in composable, related array passed as prop to modal.
Rules and gotchas
- No slug field for the resource? Do not add
generateSlugor a slug input. Users are the example — no slug. - Field is nullable in DB but the form uses AppSelect? Convert
nullto''in the getter and'' -> nullin the setter, same ascategoryIdAsString. - Confirmation message on delete comes from
getItemLabel— pick the most human-readable field. Fall back to#${id}if nothing readable exists (like Comments). - Related items list goes in the composable (not the page). Pass to the
form modal via a prop, mirror how
usePosts.tspassescategories. - StatusBadge vs new badges — reuse
StatusBadgefor published/draft. Create a new badge only if the semantics are different (likeRoleBadgefor admin/user).
Reference patterns cheat sheet
| Scenario | Look at |
|---|---|
| Simplest (name + slug) | useCategories.ts, CategoryFormModal.vue |
| With auto-slug | useCategories.ts, usePosts.ts |
| With a related dropdown | usePosts.ts, PostFormModal.vue |
| Without slug | useUsers.ts |
| With truncated text column | CommentsTableRow.vue |
| With password field | UserFormModal.vue |
| With a status/role badge | PostsTableRow.vue (StatusBadge), UsersTableRow.vue (RoleBadge) |