# PRD: Custom Properties — Bug Fixes
**Module:** Custom Properties (`manages/custom-properties`)
**Date:** 2026-05-08
**Source:** `custom-properties.csv` — 48 failing QA test cases (TC_002, TC_005–TC_050)

---

## Overview

48 test cases fail across seven functional areas of the Custom Properties module:
breadcrumb display (TC_002), search/filter (TC_005–TC_008), property name validation and
submit-button state (TC_010, TC_011, TC_014–TC_021), Add/Edit modal behavior (TC_022–TC_024),
custom-field sub-modal (TC_025–TC_034), save-and-count verification (TC_035–TC_037),
and table action buttons / UX behaviors (TC_038–TC_050).

---

## Fix 1 — Breadcrumb: "Site Management > Custom Properties"
**Covers:** TC_002

### Root Cause
`index()` sets `$this->_data['breadcrumb'] = []` — an empty array produces no breadcrumb.

### Files to Change

#### `app/Http/Controllers/Manages/CustomPropertyController.php` — `index()`

Replace the empty assignment:
```php
$this->_data['breadcrumb'] = [];
```
with:
```php
$this->_data['breadcrumb'] = [
    '#' => 'Site Management',
    route($this->_routePrefix . '.index') => 'Custom Properties',
];
```

---

## Fix 2 — Search: Connect Input to Filter Query
**Covers:** TC_005, TC_006, TC_007, TC_008

### Root Cause
The search `<input>` rendered inside `$headingOptions[0]` has no `name` attribute and no
form-submission handler. `getListing()` looks for `$srch_params['name']` (passed via
`$request->all()`), but the query parameter never arrives because the input is disconnected.

### Files to Change

#### `resources/views/admin/manages/custom-properties/index.blade.php`

Replace the raw string in `$headingOptions`:
```php
'<div class="search-field"><input type="text" class="form-control pr-45" placeholder="Search"><i class="'. \Config::get('settings.icon_search') . '"></i></div>',
```
with:
```php
'<div class="search-field"><input type="text" id="cp-search" name="name" class="form-control pr-45" placeholder="Search" value="' . e(request('name')) . '"><i class="'. \Config::get('settings.icon_search') . '"></i></div>',
```

Then add JavaScript inside the existing `@push('page_script')` block (after the drag-drop
init script):
```js
// Search: submit on Enter key or 400ms debounce after typing
(function(){
    var input = document.getElementById('cp-search');
    if (!input) return;
    var timer;
    function doSearch(){
        var url = new URL(window.location.href);
        var val = input.value.trim();
        if (val) { url.searchParams.set('name', val); }
        else { url.searchParams.delete('name'); }
        window.location.href = url.toString();
    }
    input.addEventListener('keydown', function(e){ if(e.key === 'Enter'){ clearTimeout(timer); doSearch(); } });
    input.addEventListener('input', function(){ clearTimeout(timer); timer = setTimeout(doSearch, 400); });
})();
```

#### `app/Models/Masters/MasterCustomProperty.php` — `getListing()`

The `name` LIKE filter already exists. Confirm the condition uses a case-insensitive LIKE
(MySQL default collation is case-insensitive for varchar, so no change needed):
```php
->when(isset($srch_params['name']), function ($q) use ($srch_params) {
    return $q->where($this->table . ".name", "LIKE", "%{$srch_params['name']}%");
})
```
No change required here — the filter logic is correct.

---

## Fix 3 — Property Name Validation: Required, Disabled Button, Duplicate, Max Length, Trim, XSS
**Covers:** TC_010, TC_011, TC_014, TC_015, TC_016, TC_017, TC_018, TC_019, TC_020, TC_021

### Root Cause
- Server-side validation is entirely commented out in `__formPost()`.
- No Save-button disabled state implemented for the Add/Edit form.
- No duplicate property name guard per company.
- `store()` does not trim the name before saving.

### Files to Change

#### `app/Http/Controllers/Manages/CustomPropertyController.php` — `__formPost()`

Replace the commented-out validation block with active validation:
```php
protected function __formPost(Request $request, $id = 0)
{
    try {
        $request->validate([
            'group_name' => 'required|string|max:255',
        ]);

        $input = $request->all();

        // Trim the name; reject if only whitespace
        $trimmedName = trim($input['group_name'] ?? '');
        if ($trimmedName === '') {
            return redirect()->back()->withInput()
                ->withErrors(['group_name' => 'The name field is required.']);
        }
        $input['group_name'] = $trimmedName;

        // Strip any HTML/script tags to prevent XSS persisted in the DB
        $input['group_name'] = strip_tags($input['group_name']);

        $companyId = auth()->user()->company_id;

        if ($id == 0) {
            $countData = $this->_model->getListing(['company_id' => $companyId])->count();
            if ($countData >= 5) {
                return redirect()->back()->withInput()
                    ->with('error', 'You have reached the maximum limit of custom properties.');
            }

            // Duplicate name check (create only)
            $exists = \App\Models\Masters\MasterCustomProperty::where('company_id', $companyId)
                ->where('name', $input['group_name'])
                ->whereNull('deleted_at')
                ->exists();
            if ($exists) {
                return redirect()->back()->withInput()
                    ->withErrors(['group_name' => 'A custom property with this name already exists.']);
            }
        }

        $response = $this->_model->store($input, $id, $request);

        if (in_array($response['status'], [200, 201])) {
            return redirect()->route($this->_routePrefix . '.index')
                ->with('success', $response['message']);
        }
        return redirect()->back()->with('error', $response['message']);
    } catch (\Illuminate\Validation\ValidationException $e) {
        return redirect()->back()->withInput()->withErrors($e->errors());
    } catch (\Exception $e) {
        \App\Models\ErrorLog::Log($e);
        return Helper::rj($e->getMessage(), 500);
    }
}
```

#### `resources/views/admin/manages/custom-properties/form.blade.php`

Add an inline script to disable the Save button when the Name field is blank (mirrors the
HTS-TT-019 fix pattern):
```blade
<div class="body">
    @include('admin.components.admin-form-wrapper')
</div>
@include('admin.components.date-time-picker')
@push('page_script')
    <script src="{{ asset('admin-form-plugins/select2-init.js')}}"></script>
    <script src="{{ asset('assets/js/custom-field.js') }}"></script>
    <script>
        <?php if(isset($id) && $id != '') { ?>
            getAjaxCustomFieldData('{{ $id }}');
        <?php } ?>
    </script>
    <script>
    // Disable Save when Name is blank (TC_011)
    (function(){
        var form = document.querySelector('.custom-property-form');
        if (!form) return;
        var nameInput = form.querySelector('input[name="group_name"]');
        var submitBtn  = form.querySelector('[type="submit"]');
        if (!nameInput || !submitBtn) return;
        function syncBtn(){ submitBtn.disabled = nameInput.value.trim() === ''; }
        syncBtn();
        nameInput.addEventListener('input',  syncBtn);
        nameInput.addEventListener('change', syncBtn);
    })();
    </script>
@endpush
```

---

## Fix 4 — Add/Edit Form: Cancel and Close Icon Behavior
**Covers:** TC_022, TC_023, TC_024

### Root Cause
The Add Custom Property form is rendered as a full-page view (not a modal) when accessed
via the "Add Custom Property" link. The Cancel button uses `back_route`, which navigates
back — this is correct. The close icon (X) is part of the admin-form-wrapper component and
works as-is. The modal backdrop (TC_024) is standard Bootstrap behavior.

**No code changes required** for TC_022, TC_023, TC_024 — these pass once the form page
is correctly served (fixes in G3 ensure proper form state). If the form is being loaded
inside a modal via AJAX, verify the `back_route` and the `data-bs-dismiss="modal"` close
button in `admin-form-wrapper`.

---

## Fix 5 — Custom Field Sub-Modal: Validation, Auto-Update Field, Duplicate Check, Checkbox Behavior
**Covers:** TC_025, TC_026, TC_027, TC_028, TC_029, TC_030, TC_031, TC_032, TC_033, TC_034

### Root Cause
- TC_026: The `#save-all-btn` is `type="button"` — HTML5 `required` on `#fieldName` does not
  fire. No JS validation exists before the save handler runs.
- TC_028: The "Auto Update Lead Field" dropdown is missing from `custom-field-type.blade.php`.
- TC_030: No duplicate-field-name check before appending to the listing.
- TC_031/TC_032: The `#is_create_another_field` checkbox exists in the modal footer but the
  JS save handler does not read it to decide whether to keep the modal open or close it.

### Files to Change

#### `resources/views/admin/components/custom-field-type.blade.php`

Add the "Auto Update Lead Field" dropdown (TC_028) after the Field Type select block:
```blade
@php
    $fieldTypes = \App\Helpers\Helper::getCustomFieldTypes();
    $leadFields = \App\Models\Masters\MasterLeadField::getLeadFieldsForAutoUpdate(); // see model note below
@endphp

<div class="row mb-3">
    <div class="col-md-12 project-form form-line">
        <label for="fieldType">Field Type <span class="text-danger">*</span></label>
        <select id="fieldType" name="fieldType" class="form-control form-control-lg select2" required>
            <option value="">Select Field Type</option>
            @foreach($fieldTypes as $type)
            <option value="{{ $type['id'] }}" data-type="{{ $type['type'] }}">{{ $type['name'] }}</option>
            @endforeach
        </select>
    </div>
</div>

{{-- Auto Update Lead Field (TC_028) --}}
<div class="row mb-3">
    <div class="col-md-12 project-form form-line">
        <label for="autoUpdateLeadField">Auto Update Lead Field</label>
        <select id="autoUpdateLeadField" name="autoUpdateLeadField" class="form-control form-control-lg select2">
            <option value="">None</option>
            @foreach($leadFields as $lf)
            <option value="{{ $lf['id'] }}">{{ $lf['label'] }}</option>
            @endforeach
        </select>
    </div>
</div>

<div id="dynamic-inputs"></div>
<button type="button" class="green-btn-inner waves-effect mb-3" id="add-value-btn" style="display:none;"><i class="fa fa-plus"></i> Add Value</button>
<div id="field-values-listing" style="display:none;">
    <label>Values</label>
    <ul class="list-group sortable-list"></ul>
</div>
```

**Note:** Add a static helper method `getLeadFieldsForAutoUpdate()` on `MasterLeadField`
(or use an existing equivalent) that returns an array of `[id, label]` pairs for standard
lead fields (e.g., DBA, Email, Phone, etc.). If the model already exposes lead fields via
`$leadFieldOptions`, reuse that array.

#### `public/assets/js/custom-field.js` — `save-all-btn` click handler

Add the following improvements to the save handler (search for `$('#save-all-btn').on('click',...`
or add it if missing):

```js
$('#save-all-btn').on('click', function () {
    // TC_026: Validate required fields before saving
    var fieldName = $.trim($('#fieldName').val());
    if (!fieldName) {
        $('#fieldName').addClass('is-invalid');
        if (!$('#fieldName').next('.invalid-feedback').length) {
            $('#fieldName').after('<div class="invalid-feedback">The name field is required.</div>');
        }
        return;
    }
    $('#fieldName').removeClass('is-invalid').next('.invalid-feedback').remove();

    var fieldType = $('#fieldType').val();
    if (!fieldType) {
        alert('Please select a Field Type.');
        return;
    }

    // TC_030: Duplicate field name check within the current property
    var existingNames = [];
    $('#custom-field-listing-body tr').each(function(){
        existingNames.push($.trim($(this).find('.field-name-cell').text()).toLowerCase());
    });
    if (existingNames.indexOf(fieldName.toLowerCase()) !== -1) {
        $('#fieldName').addClass('is-invalid');
        if (!$('#fieldName').next('.invalid-feedback').length) {
            $('#fieldName').after('<div class="invalid-feedback">A field with this name already exists.</div>');
        }
        return;
    }

    var autoUpdateLeadField = $('#autoUpdateLeadField').val() || '';

    // Build field object and append to hidden input / listing
    var fieldData = {
        id: $('#id').val() || '',
        name: fieldName,
        type: fieldType,
        autoUpdateLeadField: autoUpdateLeadField,
        options: savedValues,
    };

    appendFieldToListing(fieldData);

    // TC_031/TC_032: "Create field and make another" checkbox
    var makeAnother = $('#is_create_another_field').is(':checked');
    if (makeAnother) {
        // Reset form for next entry; keep modal open
        resetCustomFieldForm();
    } else {
        // Close modal
        $('#custom-field-modal').modal('hide'); // adjust selector to match actual modal ID
    }
});

function resetCustomFieldForm() {
    $('#fieldName').val('').removeClass('is-invalid');
    $('#fieldType').val('').trigger('change');
    $('#autoUpdateLeadField').val('');
    $('#id').val('');
    values = [];
    savedValues = [];
    defaultIdx = null;
    $('#dynamic-inputs').html('');
    renderListing();
}
```

**Note:** The exact modal selector and `appendFieldToListing` implementation depend on the
existing JS structure in `custom-field.js`. Align the save-and-append logic with how
`custom_property_hidden` serializes field data for form submission.

---

## Fix 6 — Save with Custom Fields, Field Count, Values Count Display
**Covers:** TC_035, TC_036, TC_037

### Root Cause
- TC_035/TC_036: The `custom_property_hidden` mechanism in `store()` works only when the
  hidden input is non-null. Confirm the JS correctly serializes field data into the hidden
  field before form submission. Field count in the listing is computed via
  `count($parentProperty->fields)` — this is correct, but requires the `fields` relationship
  to be eager-loaded.
- TC_037: Value count is already computed in `index()` and attached via `$property->value_count`.
  The listing shows `{{ $parentProperty->value_count ?? 0 }}` — correct.

### Files to Change

#### `app/Http/Controllers/Manages/CustomPropertyController.php` — `index()`

Eager-load the `fields` relationship to ensure field count is not triggering N+1 queries
and is actually available on each property object:
```php
$customPropertiesData = $this->_model->getListing(array_merge($srch_params, ['with' => ['fields']]));
```

#### `public/assets/js/custom-field.js`

Ensure that before the Add/Edit form is submitted, the custom-field listing data is
serialized into `input[name="custom_property_hidden"]`. Add a form `submit` listener:

```js
$(document).on('submit', '.custom-property-form', function(){
    var fieldRows = [];
    // Collect each field row from the listing table
    $('#custom-field-listing-body tr').each(function(){
        fieldRows.push({
            id:    $(this).data('field-id') || '',
            name:  $(this).find('.field-name-cell').text().trim(),
            type:  $(this).data('field-type') || '',
            auto:  $(this).data('auto-update') || '',
        });
    });
    $('input[name="custom_property_hidden"]').val(JSON.stringify(fieldRows));
});
```

---

## Fix 7 — Table Actions: Add/Remove Values Button, Edit as Modal, System Property Protection, Responsive Layout
**Covers:** TC_038, TC_039, TC_040, TC_041, TC_042, TC_043, TC_044, TC_045, TC_046, TC_047, TC_048, TC_050

### Root Cause
- **TC_038:** Both `index.blade.php` and `child-rows.blade.php` render the "Add/Remove Values"
  button with a hardcoded `disabled` attribute — the button is never clickable.
- **TC_039:** The Edit pencil anchor has class `action-btn` only — no modal trigger class.
  This navigates to a full page instead of opening the form as a modal. The controller's
  `__formUiGeneration()` already renders a modal layout when `$request->ajax()` is true;
  the link just needs the correct class.
- **TC_044:** No concept of system/protected properties exists. The "Processor" property
  (and any seeded system properties) can be deleted. Requires an `is_system` column and a
  guard in both the view and `remove()`.
- TC_042/TC_043, TC_045, TC_046, TC_047, TC_048: These depend on the above fixes and the
  general form working correctly. No separate code changes needed.
- **TC_050:** Bootstrap grid is already used. Ensure the table container uses
  `table-responsive` (already present) and that no fixed-pixel widths break small viewports.

### Files to Change

#### `resources/views/admin/manages/custom-properties/index.blade.php`

1. Remove `disabled` from the Add/Remove Values button (TC_038):
```blade
{{-- Before --}}
'<a href="' . route('manages.custom-properties.values', $parentProperty->id) . '" target="_blank">
    <button disabled type="button" class="btn add-btn">Add/Remove Values</button>
</a>'

{{-- After --}}
'<a href="' . route('manages.custom-properties.values', $parentProperty->id) . '" target="_blank">
    <button type="button" class="btn add-btn">Add/Remove Values</button>
</a>'
```

2. Change the Edit anchor to open as a modal (TC_039):
```blade
{{-- Before --}}
{!! $permission['edit'] ? '<a href="' . route('manages.custom-properties.edit', $parentProperty->id) . '" type="button" class="action-btn"><i class="bx bx-pencil"></i></a>' : '' !!}

{{-- After --}}
{!! $permission['edit'] ? '<a href="' . route('manages.custom-properties.edit', $parentProperty->id) . '" type="button" class="action-btn show-modal-lg" data-toggle="tooltip" title="Edit"><i class="bx bx-pencil"></i></a>' : '' !!}
```

3. Guard delete button for system properties (TC_044):
```blade
{{-- Before --}}
@if($permission['destroy'])
<a href="javascript:void(0);" class="action-btn"
    data-toggle="tooltip" title="Delete"
    data-confirm="Are You Sure?|..."
    data-confirm-yes="...delete-form-{{ $parentProperty->id }}..."><i class="..."></i></a>
...
@endif

{{-- After --}}
@if($permission['destroy'])
  @if($parentProperty->is_system)
    <span class="action-btn text-muted" data-toggle="tooltip" title="System properties cannot be deleted" style="cursor:not-allowed; opacity:0.4;"><i class="{{ \Config::get('settings.icon_delete') }}"></i></span>
  @else
    <a href="javascript:void(0);" class="action-btn"
        data-toggle="tooltip" title="Delete"
        data-confirm="Are You Sure?|This action can not be undone. Do you want to continue?"
        data-confirm-yes="event.preventDefault(); document.getElementById('delete-form-{{ $parentProperty->id }}').submit();"><i class="{{ \Config::get('settings.icon_delete') }}"></i></a>
    {!! html()->form('DELETE', route($routePrefix . '.destroy', $parentProperty->id))->attributes([
    'style' => 'display:inline',
    'id' => 'delete-form-' . $parentProperty->id,
    ])->open() !!}
    {!! html()->form()->close() !!}
  @endif
@endif
```

#### `resources/views/admin/manages/custom-properties/child-rows.blade.php`

Apply the same three changes (remove `disabled` from values button, add `show-modal-lg` to
edit link, guard delete for `is_system`):

```blade
{{-- Add/Remove Values: remove disabled --}}
'<a href="' . route('manages.custom-properties.values', $child->id) . '" target="_blank">
    <button type="button" class="btn add-btn">Add/Remove Values</button>
</a>'

{{-- Edit: add show-modal-lg --}}
{!! $permission['edit'] ? '<a href="' . route('manages.custom-properties.edit', $child->id) . '" type="button" class="action-btn show-modal-lg" data-toggle="tooltip" title="Edit"><i class="bx bx-pencil"></i></a>' : '' !!}

{{-- Delete: guard is_system --}}
@if($permission['destroy'])
  @if($child->is_system)
    <span class="action-btn text-muted" data-toggle="tooltip" title="System properties cannot be deleted" style="cursor:not-allowed; opacity:0.4;"><i class="{{ \Config::get('settings.icon_delete') }}"></i></span>
  @else
    <a href="javascript:void(0);" class="action-btn"
        data-toggle="tooltip" title="Delete"
        data-confirm="Are You Sure?|This action can not be undone. Do you want to continue?"
        data-confirm-yes="event.preventDefault(); document.getElementById('delete-form-{{ $child->id }}').submit();"><i class="{{ \Config::get('settings.icon_delete') }}"></i></a>
    {!! html()->form('DELETE', route($routePrefix . '.destroy', $child->id))->attributes([
    'style' => 'display:inline',
    'id' => 'delete-form-' . $child->id,
    ])->open() !!}
    {!! html()->form()->close() !!}
  @endif
@endif
```

#### `app/Models/Masters/MasterCustomProperty.php`

Add the `is_system` column to `$fillable` and guard deletion:
```php
protected $fillable = [
    'name',
    'parent_id',
    'display_order',
    'company_id',
    'status',
    'sort_order',
    'is_system',  // add this
];
```

Update `remove()` to block system property deletion:
```php
public function remove($id = null)
{
    try {
        $data = $this->getListing(['id' => $id]);
        if (!$data) {
            return Helper::resp('Not a valid data', 400);
        }
        if ($data->is_system) {
            return Helper::resp('System properties cannot be deleted.', 403);
        }
        // ... rest of existing delete logic
    }
}
```

#### New Migration: `database/migrations/YYYY_MM_DD_000001_add_is_system_to_master_custom_properties.php`

```php
<?php

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('master_custom_properties', function (Blueprint $table) {
            $table->tinyInteger('is_system')->default(0)->after('status')
                  ->comment('1 = seeded system property; cannot be deleted');
        });

        // Mark the "Processor" property as a system property
        DB::table('master_custom_properties')
            ->where('name', 'Processor')
            ->update(['is_system' => 1]);
    }

    public function down(): void
    {
        Schema::table('master_custom_properties', function (Blueprint $table) {
            $table->dropColumn('is_system');
        });
    }
};
```

---

## Summary Table

| Group | Test Cases | Root Cause | Primary Fix Location |
|-------|-----------|------------|----------------------|
| G1 — Breadcrumb | TC_002 | `breadcrumb = []` | `CustomPropertyController::index()` |
| G2 — Search | TC_005–TC_008 | Input has no `name`; no submit handler | `index.blade.php` + inline JS |
| G3 — Name Validation | TC_010, TC_011, TC_014–TC_021 | Server-side validation commented out; no disabled-state JS; no duplicate guard | `__formPost()` + `form.blade.php` |
| G4 — Modal Behavior | TC_022–TC_024 | Framework behavior; no code change needed | — |
| G5 — Custom Field Modal | TC_025–TC_034 | Missing JS validation; missing Auto Update dropdown; no duplicate/checkbox logic | `custom-field-type.blade.php` + `custom-field.js` |
| G6 — Field/Value Counts | TC_035–TC_037 | Missing eager load for `fields`; JS serialization gap | `index()` eager-load + `custom-field.js` submit handler |
| G7 — Table Actions | TC_038–TC_050 | `disabled` on button; edit not modal; no `is_system` protection | Both `index.blade.php` + `child-rows.blade.php` + migration |

---

## Regression Checklist

After implementing all fixes, verify:
- [ ] Breadcrumb renders "Site Management > Custom Properties" on the index page
- [ ] Typing "proc" in Search shows only properties whose name contains "proc" (case-insensitive)
- [ ] Clearing Search shows all properties
- [ ] Submitting Add form with blank Name shows "The name field is required." and does not create a record
- [ ] Save button is disabled while Name input is empty; enables immediately on typing
- [ ] Attempting to create "Processor" (or any existing name) shows duplicate-name error
- [ ] Long name (256+ chars) is rejected with max-length validation error
- [ ] Name with leading/trailing spaces is trimmed before save
- [ ] `<script>alert(1)</script>` as a name is saved as plain text and rendered escaped
- [ ] "Add Custom Field" button opens the sub-modal
- [ ] Blank field name in sub-modal shows inline required error; Save is prevented
- [ ] "Auto Update Lead Field" dropdown is visible and selectable in sub-modal
- [ ] Attempting to add a field with the same name as an existing field shows duplicate warning
- [ ] "Create field and make another" checked: sub-modal resets and stays open after save
- [ ] "Create field and make another" unchecked: sub-modal closes after save
- [ ] Saving a property with one or more custom fields persists the fields; Field Count increments
- [ ] Values Count column correctly reflects number of values for each property
- [ ] "Add/Remove Values" button is clickable and navigates to the values page
- [ ] Edit pencil opens the property form as a modal (not a full page)
- [ ] Editing and saving a property name updates the listing immediately on redirect
- [ ] Delete confirmation dialog appears; confirming removes the property
- [ ] Cancelling delete confirmation leaves the property in the listing
- [ ] Delete button for the "Processor" (system) property is visually disabled and shows tooltip
- [ ] Attempting to delete a system property via direct HTTP request returns 403
- [ ] Permissions gear icon opens the permissions modal for the selected property
- [ ] Drag-and-drop reorders properties and persists on refresh
- [ ] Child properties appear indented under their parent with correct Parent Property name
- [ ] Page refresh after any add/edit/delete shows the latest state
- [ ] Layout remains usable at 1366×768 and tablet resolutions without overflow or broken buttons
