Data Model and Migrations
Add a category field to Note from migration through API, form and list.
Data Model and Migrations
Add an optional category to the Note created in Create a Resource. Existing Notes keep null; users can enter up to 80 characters. All file paths are App-relative. Apply migrations only to a practice tenant until the upgrade has been reviewed.
Add the column
Create database/migrations/tenant/2026_09_07_120000_add_category_to_wsp_notes_table.php with a unique timestamp later than the create-table migration:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wsp_notes', function (Blueprint $table): void {
$table->string('category', 80)->nullable();
});
}
public function down(): void
{
Schema::table('wsp_notes', function (Blueprint $table): void {
$table->dropColumn('category');
});
}
};Do not edit an already deployed create-table migration. This forward migration preserves existing rows; its down() drops category data. The generated Manifest already returns this App's tenant migration directory using __DIR__, so no host registration is needed.
Accept and return the value
In src/Models/Note.php, append 'category' to $fillable. No cast is needed for a nullable string. In both store() and update() validation arrays in src/Http/Controllers/NoteController.php, add:
'category' => ['sometimes', 'nullable', 'string', 'max:80'],The generated serialize() starts with $record->toArray(), so this ordinary attribute is already returned by list, show and write responses. Check $hidden/$visible if you have customized serialization; never make a protected field public by following this recipe.
In resources/js/resources/notes/note-resource-contract.ts, add category: string | null; to NoteRecord. The following JSON is the new part of an authorized request and response, not a complete create request:
{ "category": "Planning" }A create request still needs name and the explicit legal_entity_public_id. Omitting category preserves its value during update; sending null clears it.
Connect form state and display
In section/NoteFormSection.tsx under resources/js/resources/notes/, extend the onSubmit payload type to { name: string; category: string | null }. Add the following state beside the generated name state, replacing the existing useMarkTabDirty(name !== initialName) call:
const initialCategory = initialItem?.category ?? '';
const [category, setCategory] = useState(initialCategory);
useEffect(() => {
setCategory(initialCategory);
}, [initialCategory]);
useMarkTabDirty(name !== initialName || category !== initialCategory);Change the submit call to onSubmit({ name, category: category.trim() || null }); and add category, setCategory to the NxResourceInformationForm context. In surface/NoteFormSurface.tsx, extend mutationFn's payload type to the same { name: string; category: string | null }. The existing api.put(url, payload) and spread into api.post() forward it; keep target selection, authorization, error parsing and completion handling.
In note-information-schema.tsx, add category: string; and setCategory: (category: string) => void; to NoteInformationEditContext, then insert this field after name:
{
key: 'category',
labelKey: 'workshop.note.category.label',
view: ({ item }) => resourceInformationValue(item.category),
edit: {
kind: 'control',
error: ({ context }) => firstFieldError(context.fieldErrors, 'category'),
render: ({ context }, slot) => (
<NxTextInput
id={slot.id}
aria-describedby={slot.describedBy}
invalid={slot.invalid}
value={context.category}
maxLength={80}
onChange={(event) => {
context.setCategory(event.target.value);
context.onFieldChange?.('category');
}}
/>
),
},
},The schema's imports already supply these primitives. Its view projection feeds the generated Show section and Inspector; its edit projection feeds the form. The slot attributes preserve label and error associations.
Display it in the list
Add this column to the array in section/NoteListSection.tsx. Its existing imports provide ResourceTextCell:
{
key: 'category',
header: t('workshop.note.category.label'),
size: 'meta',
cell: (row) => (
<ResourceTextCell primary={row.category ?? t('common.cell.empty')} />
),
},To make category searchable and sortable as well, add ->field('category', searchable: true, sortable: true) to Note::resourceListFields(). The backend remains the list-query allowlist; do not create a second frontend allowlist.
Add workshop.note.category.label to each catalog in resources/lang/: Category in en.json, 분류 in ko.json, and 分类 in zh.json. See Localize an App for cache refresh and validation.
Publishing category to external consumers or the Dashboard builder is a separate choice. If needed, declare its nullable string field in NoteModule's descriptor and review that public contract version. A table column alone is not a public descriptor declaration.
Verify the field
From the Core root, set the practice tenant ID and run:
nexia_tenant=REPLACE_WITH_TENANT_ID
task db:tenant:migrate TENANT="$nexia_tenant"Create a Note with category, edit it, clear the value, and reopen list/detail/Inspector. Verify an existing Note still opens. Send a category over 80 characters directly to the API and expect HTTP 422 with a category field error; the input’s maxLength prevents that case through ordinary typing. In Test an App, cover persistence and the rejected input through the API.
The baseline table contains a UUID, name, status, timestamps, soft deletion, ownership columns and indexes. It does not automatically supply your business uniqueness, audit evidence or concurrency protocol. Add constraints for actual invariants and use a transaction or explicit concurrency check for conflicting writes. For another App's record use Resource References, not a foreign key into its tables.