AgentSkillsCN

Nova Damage Assessment Field Development

Nova损伤评估现场开发

SKILL.md

Nova Damage Assessment Field Development

Trigger Phrases

  • "nova custom field"
  • "damage assessment editor"
  • "nova field component"
  • "inertia vue field"
  • "canvas field nova"
  • "custom nova field"
  • "field serialization"
  • "DetailField", "FormField", "IndexField"

Expertise

Expert in developing custom Laravel Nova 5.x fields with Vue 3 Composition API and Inertia 2.x, specifically for the PCR Card damage assessment bounding box editor system.

Project Context

PCR Card uses a sophisticated custom Nova field (DamageAssessmentEditor) that provides an interactive HTML5 Canvas interface for visually marking damage areas on trading card images.

Key Technologies:

  • Laravel Nova 5.7.6 (Silver Surfer)
  • Inertia.js 2.x
  • Vue 3.5.18 (Composition API)
  • HTML5 Canvas API
  • Laravel Mix 6.0.49

Core Components

PHP Field Class

Location: app/Nova/Fields/DamageAssessmentEditor.php

Key Responsibilities:

  1. Define field component name (public $component = 'damage-assessment-editor')
  2. Accept configuration methods (image, damageAssessments, readonly, etc.)
  3. Serialize field data to JSON via jsonSerialize() method
  4. Resolve callables when passing data to Vue components
  5. Pass damage type and severity options from constants

Pattern:

php
class DamageAssessmentEditor extends Field
{
    public $component = 'damage-assessment-editor';

    protected $imageUrlCallback = null;
    protected $staticImageUrl = null;
    protected $imageUrls = null;

    // Fluent configuration methods
    public function image($url)
    {
        if (is_callable($url)) {
            $this->imageUrlCallback = $url;
        } else {
            $this->staticImageUrl = $url;
        }
        return $this;
    }

    public function withImages($images)
    {
        $this->imageUrls = $images;
        return $this;
    }

    // Serialize for Vue
    public function jsonSerialize(): array
    {
        return array_merge(parent::jsonSerialize(), [
            'imageUrl' => $this->resolveImageUrl(),
            'imageUrls' => value($this->imageUrls),
            'damageTypes' => $this->getDamageTypeOptions(),
            'severityLevels' => $this->getSeverityLevelOptions(),
        ]);
    }

    protected function getDamageTypeOptions(): array
    {
        $options = [];
        foreach (\App\Constants\DamageType::all() as $type) {
            $options[] = [
                'value' => $type,
                'label' => \App\Constants\DamageType::label($type),
                'color' => \App\Constants\DamageType::color($type),
            ];
        }
        return $options;
    }
}

Vue Components

Location: resources/js/nova/fields/damage-assessment-editor/

Three Required Files:

  1. IndexField.vue - Display in resource index table
  2. DetailField.vue - Display on resource detail page
  3. FormField.vue - Display on create/edit forms

Component Registration:

javascript
// resources/js/nova-components.js
import DamageAssessmentEditorIndexField from './nova/fields/damage-assessment-editor/IndexField.vue';
import DamageAssessmentEditorDetailField from './nova/fields/damage-assessment-editor/DetailField.vue';
import DamageAssessmentEditorFormField from './nova/fields/damage-assessment-editor/FormField.vue';

Nova.booting((app) => {
    Nova.component('index-damage-assessment-editor', DamageAssessmentEditorIndexField);
    Nova.component('detail-damage-assessment-editor', DamageAssessmentEditorDetailField);
    Nova.component('form-damage-assessment-editor', DamageAssessmentEditorFormField);
});

Nova 5.x + Inertia 2.x Patterns

Inertia 2 Migration (CRITICAL)

Old (Inertia 1.x):

javascript
import { usePage } from '@inertiajs/inertia-vue3'
import { Inertia } from '@inertiajs/inertia'

New (Inertia 2.x):

javascript
import { router as Inertia, usePage } from '@inertiajs/vue3'

Error Handling

Old:

javascript
import { Errors } from 'form-backend-validation'

New:

javascript
import { Errors } from 'laravel-nova'

Vue 3 Composition API Pattern

vue
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePage } from '@inertiajs/vue3';

const props = defineProps({
    field: Object,
    resourceName: String,
    resourceId: [String, Number],
});

const emit = defineEmits(['field-changed']);

// Reactive state
const selectedBox = ref(null);
const damageBoxes = ref([]);

// Computed properties
const damageTypes = computed(() => props.field.damageTypes || []);
const severityLevels = computed(() => props.field.severityLevels || []);

// Methods
const addDamageBox = (coordinates) => {
    damageBoxes.value.push({
        x: coordinates.x,
        y: coordinates.y,
        width: coordinates.width,
        height: coordinates.height,
        damage_type: null,
        severity: null,
    });
};

// Lifecycle
onMounted(() => {
    initializeCanvas();
});
</script>

Field Data Flow

1. Nova Resource → PHP Field

php
// app/Nova/DamageAssessment.php
use App\Nova\Fields\DamageAssessmentEditor;

Panel::make('Visual Damage Editor', [
    DamageAssessmentEditor::make('Visual Editor', 'damage_editor_demo')
        ->image(function () {
            return $this->submissionImage?->file_url ?? asset('images/placeholder.svg');
        })
        ->damageAssessments(function () {
            return $this->submissionTradingCard?->damageAssessments ?? collect([]);
        })
        ->readonly()
        ->onlyOnDetail(),
]),

2. PHP Field → Vue Component (via jsonSerialize)

php
public function jsonSerialize(): array
{
    return array_merge(parent::jsonSerialize(), [
        'imageUrl' => $this->resolveImageUrl(),  // Resolved callable
        'damageTypes' => $this->getDamageTypeOptions(),
        'severityLevels' => $this->getSeverityLevelOptions(),
    ]);
}

3. Vue Component Receives Props

vue
<script setup>
const props = defineProps({
    field: Object,  // Contains imageUrl, damageTypes, severityLevels, etc.
    resourceName: String,
    resourceId: [String, Number],
});

// Access data
const imageUrl = computed(() => props.field.imageUrl);
const damageTypes = computed(() => props.field.damageTypes || []);
</script>

Canvas Implementation

Canvas Setup

vue
<template>
  <div class="damage-assessment-editor">
    <canvas
      ref="canvasRef"
      @mousedown="startDrawing"
      @mousemove="draw"
      @mouseup="stopDrawing"
      @click="selectBox"
    />

    <div class="damage-boxes">
      <div v-for="(box, index) in damageBoxes" :key="index">
        <select v-model="box.damage_type">
          <option v-for="type in damageTypes" :key="type.value" :value="type.value">
            {{ type.label }}
          </option>
        </select>
        <select v-model="box.severity">
          <option v-for="level in severityLevels" :key="level.value" :value="level.value">
            {{ level.label }}
          </option>
        </select>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue';

const props = defineProps({
    field: Object,
});

const canvasRef = ref(null);
const damageBoxes = ref([]);
const isDrawing = ref(false);

const damageTypes = computed(() => props.field.damageTypes || []);
const severityLevels = computed(() => props.field.severityLevels || []);

const initializeCanvas = () => {
    const canvas = canvasRef.value;
    const ctx = canvas.getContext('2d');

    // Load image
    const img = new Image();
    img.onload = () => {
        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0);

        // Draw existing boxes
        drawAllBoxes();
    };
    img.src = props.field.imageUrl;
};

const drawAllBoxes = () => {
    const canvas = canvasRef.value;
    const ctx = canvas.getContext('2d');

    damageBoxes.value.forEach((box, index) => {
        ctx.strokeStyle = index === selectedBox.value ? '#ff0000' : '#00ff00';
        ctx.lineWidth = 2;
        ctx.strokeRect(box.x, box.y, box.width, box.height);
    });
};

onMounted(() => {
    initializeCanvas();
});
</script>

Saving Data

Form Data Submission

vue
<script setup>
import { watch } from 'vue';

const props = defineProps({
    field: Object,
});

const emit = defineEmits(['field-changed']);

// Watch for changes and emit to Nova
watch(damageBoxes, (newBoxes) => {
    emit('field-changed', {
        attribute: props.field.attribute,
        value: JSON.stringify(newBoxes),
    });
}, { deep: true });
</script>

PHP Field Hydration

php
protected function fillAttributeFromRequest(
    NovaRequest $request,
    $requestAttribute,
    $model,
    $attribute
) {
    if ($request->exists($requestAttribute)) {
        $value = $request[$requestAttribute];

        // Decode JSON if needed
        if (is_string($value)) {
            $value = json_decode($value, true);
        }

        // Validate bounding boxes
        $validated = $this->validateBoundingBoxes($value);

        // Store in model
        $model->{$attribute} = $validated;
    }
}

Debugging Nova Custom Fields

Check Component Registration

bash
# Verify component is in compiled JS
grep -o "detail-damage-assessment-editor" public/js/nova-components.js

# Check file size (should be ~4MB+)
ls -lh public/js/nova-components.js

Vue DevTools

bash
# Enable Vue DevTools for Nova
./vendor/bin/sail php vendor/bin/testbench nova:devtool enable-vue-devtool
./vendor/bin/sail npm run build

Console Debugging

vue
<script setup>
import { watch } from 'vue';

const props = defineProps({
    field: Object,
});

// Debug field data
watch(() => props.field, (newField) => {
    console.log('Field data:', newField);
    console.log('Damage types:', newField.damageTypes);
    console.log('Image URL:', newField.imageUrl);
}, { immediate: true, deep: true });
</script>

Common Issues

Issue 1: Component Not Rendering

  • Check public $component matches registration name
  • Verify component is registered in nova-components.js
  • Ensure assets are compiled (npm run dev)
  • Check browser console for errors

Issue 2: Data Not Passing to Vue

  • Verify jsonSerialize() returns correct structure
  • Check callables are resolved (use value() helper)
  • Ensure field is visible (onlyOnDetail, canSee, etc.)

Issue 3: Canvas Not Drawing

  • Verify image URL is valid and accessible
  • Check canvas context is initialized
  • Ensure image loads before drawing

Issue 4: Type Errors with Inertia

  • Update to @inertiajs/vue3 v2
  • Replace @inertiajs/inertia imports with router
  • Remove form-backend-validation dependency

Testing Custom Fields

Manual Testing Checklist

  1. ✅ Field renders on detail page
  2. ✅ Image loads correctly
  3. ✅ Canvas is interactive (click, drag)
  4. ✅ Dropdowns show options from constants
  5. ✅ Damage types display with correct labels
  6. ✅ Severity levels show all options
  7. ✅ Boxes can be drawn and selected
  8. ✅ Delete key removes selected box
  9. ✅ Data saves to database correctly
  10. ✅ Page refresh preserves data

Browser Test

javascript
// In browser console
Nova.$page.props.field
// Should show field data with damageTypes, severityLevels, imageUrl

Best Practices

  1. Always Resolve Callables - Use value() helper or custom resolve methods
  2. Type Safety - Define proper return types in PHP and prop types in Vue
  3. Reactive Data - Use Vue 3 refs and computed properties
  4. Error Boundaries - Wrap canvas operations in try-catch
  5. Asset Compilation - Always run npm run dev after Vue changes
  6. Cache Clearing - Clear Laravel caches after PHP changes
  7. Validation - Validate bounding box data server-side
  8. Accessibility - Provide keyboard navigation for canvas
  9. Performance - Debounce canvas drawing operations
  10. Documentation - Comment complex canvas math

File Structure

code
app/Nova/Fields/
└── DamageAssessmentEditor.php          # PHP field class

resources/js/nova/fields/damage-assessment-editor/
├── IndexField.vue                      # Table view
├── DetailField.vue                     # Detail page view (Canvas!)
└── FormField.vue                       # Create/Edit form

resources/js/
└── nova-components.js                  # Component registration

public/js/
└── nova-components.js                  # Compiled bundle

Related Documentation