Apartment controller update

User
Assistant
Great—let’s switch your flow to use a specific **ApartmentID** created in step 1, and pass it through the next steps (via query string). This avoids “first apartment of member” problems and cleanly supports multiple apartments.

Here’s the minimal, safe set of changes:

# 1) Add a helper to resolve the apartment by `id` and verify ownership

// Inside ApartmentController
private function getApartmentFromRequest(HTTPRequest $request): ?Apartment
{
    $member = Security::getCurrentUser();
    if (!$member) {
        return null;
    }

    $id = (int) $request->getVar('id');
    if (!$id) {
        return null;
    }

    /** @var Apartment|null $apt */
    $apt = Apartment::get()->byID($id);
    if ($apt && (int)$apt->MemberID === (int)$member->ID) {
        return $apt;
    }

    return null;
}

> You can now remove the controller property `$this->apartment` and that lookup in `init()` (not required anymore).

# 2) Step 1: create/update and **redirect with `?id=…`**

public function doStep1(HTTPRequest $request)
{
    if (!$member = Security::getCurrentUser()) {
        return $this->redirect('/login');
    }

    $data = $request->postVars();

    // If id is present, we’re editing that apartment; otherwise create new
    $apartment = $this->getApartmentFromRequest($request);
    if (!$apartment) {
        $apartment = Apartment::create();
        $apartment->MemberID = $member->ID;
    }

    $apartment->ObjectNumber = $data['ObjectNumber'] ?? $apartment->ObjectNumber;
    $apartment->GroupNumber  = $data['GroupNumber']  ?? $apartment->GroupNumber;
    $apartment->WohnungType  = $data['WohnungType']  ?? $apartment->WohnungType;
    $apartment->write();

    // IMPORTANT: pass the id forward
    return $this->redirect('/apartment/step2?id=' . $apartment->ID);
}

Also set the form action in `step1Form()` to **preserve id** when editing:

public function step1(HTTPRequest $request)
{
    $form = $this->step1Form();
    if ($apt = $this->getApartmentFromRequest($request)) {
        $form->loadDataFrom($apt);
        $form->setFormAction('/apartment/doStep1?id=' . $apt->ID);
    } else {
        $form->setFormAction('/apartment/doStep1'); // creating new
    }

    // ... your customise/renderWith as is (you can keep using $form->Fields()->dataFieldByName(...))
    // Just return the view:
    return $this->customise([
        'Form' => $form,
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'ObjectNumber'=>$form->Fields()->dataFieldByName('ObjectNumber'),
        'GroupNumber'=>$form->Fields()->dataFieldByName('GroupNumber'),
        'WohnungType'=>$form->Fields()->dataFieldByName('WohnungType'),
    ])->renderWith(['Layout/Apartment/step1', 'Page']);
}

# 3) Step 2: always load/save via the `id` param and **carry it forward**

public function step2(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $form = $this->step2Form();
    $form->setFormAction('/apartment/doStep2?id=' . $apt->ID);

    if ($apt->AddressID) {
        if ($addr = ApartmentAddress::get()->byID($apt->AddressID)) {
            $form->loadDataFrom($addr);
        }
    }

    return $this->customise([
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'Form'=>$form,
        'Street'=>$form->Fields()->dataFieldByName('Street'),
        'Nr'=>$form->Fields()->dataFieldByName('Nr'),
        'Plz'=>$form->Fields()->dataFieldByName('Plz'),
        'Ort'=>$form->Fields()->dataFieldByName('Ort'),
        'Stadt'=>$form->Fields()->dataFieldByName('Stadt'),
        'Stadtteil'=>$form->Fields()->dataFieldByName('Stadtteil'),
        'StrasseundHausnummer'=>$form->Fields()->dataFieldByName('StrasseundHausnummer'),
    ])->renderWith(['Layout/Apartment/step2', 'Page']);
}

public function doStep2(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $d = $request->postVars();

    $addr = $apt->AddressID ? ApartmentAddress::get()->byID($apt->AddressID) : null;
    if (!$addr) $addr = ApartmentAddress::create();

    $addr->Street = $d['Street'] ?? '';
    $addr->Nr = $d['Nr'] ?? '';
    $addr->Plz = $d['Plz'] ?? '';
    $addr->Ort = $d['Ort'] ?? '';
    $addr->Stadt = $d['Stadt'] ?? '';
    $addr->Stadtteil = $d['Stadtteil'] ?? '';
    $addr->StrasseundHausnummer = $d['StrasseundHausnummer'] ?? '';
    $addr->write();

    $apt->AddressID = $addr->ID;
    $apt->write();

    return $this->redirect('/apartment/step3?id=' . $apt->ID);
}

> Note: in `step2Form()` set no hard-coded action; we’re overriding it in `step2()` based on id.

# 4) Step 3: same pattern (load via `id`, set action with `id`, redirect with `id`)

public function step3(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $form = $this->step3Form();
    $form->setFormAction('/apartment/doStep3?id=' . $apt->ID);

    if ($apt->DetailsID) {
        if ($det = ApartmentDetail::get()->byID($apt->DetailsID)) {
            $form->loadDataFrom($det);
        }
    }

    return $this->customise([
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'Form'=>$form,
        'Wohnflache'=>$form->Fields()->dataFieldByName('Wohnflache'),
        'Zimmer'=>$form->Fields()->dataFieldByName('Zimmer'),
        'Kaltmiete'=>$form->Fields()->dataFieldByName('Kaltmiete'),
        'FloorVerfugbarAb'=>$form->Fields()->dataFieldByName('FloorVerfugbarAb'),
        'Nebenkosten'=>$form->Fields()->dataFieldByName('Nebenkosten'),
        'Kaution'=>$form->Fields()->dataFieldByName('Kaution'),
        'Gesamtmiete'=>$form->Fields()->dataFieldByName('Gesamtmiete'),
    ])->renderWith(['Layout/Apartment/step3', 'Page']);
}

public function doStep3(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $d = $request->postVars();

    $det = $apt->DetailsID ? ApartmentDetail::get()->byID($apt->DetailsID) : null;
    if (!$det) $det = ApartmentDetail::create();

    $det->Wohnflache = $d['Wohnflache'] ?? '';
    $det->Zimmer = $d['Zimmer'] ?? '';
    $det->Kaltmiete = $d['Kaltmiete'] ?? '';
    $det->FloorVerfugbarAb = $d['FloorVerfugbarAb'] ?? '';
    $det->Nebenkosten = $d['Nebenkosten'] ?? '';
    $det->Kaution = $d['Kaution'] ?? '';
    $det->Gesamtmiete = $d['Gesamtmiete'] ?? '';
    $det->write();

    $apt->DetailsID = $det->ID;
    $apt->write();

    return $this->redirect('/apartment/step4?id=' . $apt->ID);
}

# 5) Step 4 & Step 5: same pattern (always carry `id`)

public function step4(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $form = $this->step4Form();
    $form->setFormAction('/apartment/doStep4?id=' . $apt->ID);

    if ($apt->DetailsID && ($det = ApartmentDetail::get()->byID($apt->DetailsID))) {
        $form->loadDataFrom($det);
    }

    return $this->customise([
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'Form'=>$form,
        'DetailsOptions'=>$form->Fields()->dataFieldByName('DetailsOptions'),
        'QualitatDerWohnung'=>$form->Fields()->dataFieldByName('QualitatDerWohnung'),
    ])->renderWith(['Layout/Apartment/step4', 'Page']);
}

public function doStep4(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $d = $request->postVars();

    if (!$apt->DetailsID) return $this->redirect('/apartment/step3?id=' . $apt->ID);

    if ($det = ApartmentDetail::get()->byID($apt->DetailsID)) {
        $det->DetailsOptions      = $d['DetailsOptions'] ?? '';
        $det->QualitatDerWohnung  = $d['QualitatDerWohnung'] ?? '';
        $det->write();
    } else {
        return $this->redirect('/apartment/step3?id=' . $apt->ID);
    }

    return $this->redirect('/apartment/step5?id=' . $apt->ID);
}
public function step5(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $form = $this->step5Form();
    $form->setFormAction('/apartment/doStep5?id=' . $apt->ID);

    if ($apt->DetailsID && ($det = ApartmentDetail::get()->byID($apt->DetailsID))) {
        $form->loadDataFrom($det);
    }

    return $this->customise([
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'Form'=>$form,
        'Schlafzimmer'=>$form->Fields()->dataFieldByName('Schlafzimmer'),
        'Badezimmer'=>$form->Fields()->dataFieldByName('Badezimmer'),
        'Etage'=>$form->Fields()->dataFieldByName('Etage'),
        'Von'=>$form->Fields()->dataFieldByName('Von'),
        'Nutzflache'=>$form->Fields()->dataFieldByName('Nutzflache'),
        'Wohnberechtigungsschein'=>$form->Fields()->dataFieldByName('Wohnberechtigungsschein'),
        'Heizungsart'=>$form->Fields()->dataFieldByName('Heizungsart'),
    ])->renderWith(['Layout/Apartment/step5', 'Page']);
}

public function doStep5(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $d = $request->postVars();

    if (!$apt->DetailsID) return $this->redirect('/apartment/step3?id=' . $apt->ID);

    if ($det = ApartmentDetail::get()->byID($apt->DetailsID)) {
        $det->Schlafzimmer = $d['Schlafzimmer'] ?? '';
        $det->Badezimmer   = $d['Badezimmer'] ?? '';
        $det->Etage        = $d['Etage'] ?? '';
        $det->Von          = $d['Von'] ?? '';
        $det->Nutzflache   = $d['Nutzflache'] ?? '';
        $det->Wohnberechtigungsschein = $d['Wohnberechtigungsschein'] ?? '';
        $det->Heizungsart  = $d['Heizungsart'] ?? '';
        $det->write();
    } else {
        return $this->redirect('/apartment/step4?id=' . $apt->ID);
    }

    // Next step (or summary) — keep passing id
    return $this->redirect('/apartment/step6?id=' . $apt->ID);
}

# 6) Template links/buttons

Wherever you have “Weiter” links or back links in templates, keep the `?id=$Apartment.ID`.
For example:

<a href="/apartment/step3?id={$Request.get.id}" class="btn">Weiter</a>

(Or better, expose `$ApartmentID` from the controller to the template and use it.)

# 7) Summary of what changed

– Removed global `$this->apartment` reliance.
– Created `getApartmentFromRequest()` to resolve `?id=` and ensure the apartment belongs to the current user.
– Each step:
– Loads data using that apartment.
– Sets form action with `?id=…`.
– Redirects to the next step with `?id=…`.

This keeps each flow strictly tied to the chosen apartment and lets a member manage as many apartments as they want—clean and safe.

User
Now for editiing if we found apartmentID in get parameter then handle edit data like exisitng based on member
Assistant
Perfect — let’s treat `?id=` as “edit mode,” but only if that apartment belongs to the logged-in member. Below is a tight pattern you can drop in.

# 1) Single guard to fetch apartment by `id` and verify ownership

private function getApartmentFromRequest(HTTPRequest $request): ?Apartment
{
    $member = Security::getCurrentUser();
    if (!$member) return null;

    $id = (int) $request->getVar('id');
    if (!$id) return null;

    $apt = Apartment::get()->byID($id);
    return ($apt && (int)$apt->MemberID === (int)$member->ID) ? $apt : null;
}

# 2) Step 1 = Add (no id) or Edit (has id)

public function step1(HTTPRequest $request)
{
    $form = $this->step1Form();

    if ($apt = $this->getApartmentFromRequest($request)) {
        // EDIT mode – preload, keep id on action
        $form->loadDataFrom($apt);
        $form->setFormAction('/apartment/doStep1?id=' . $apt->ID);
    } else {
        // ADD mode
        $form->setFormAction('/apartment/doStep1');
    }

    return $this->customise([
        'Form' => $form,
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'ObjectNumber'=>$form->Fields()->dataFieldByName('ObjectNumber'),
        'GroupNumber'=>$form->Fields()->dataFieldByName('GroupNumber'),
        'WohnungType'=>$form->Fields()->dataFieldByName('WohnungType'),
    ])->renderWith(['Layout/Apartment/step1', 'Page']);
}

public function doStep1(HTTPRequest $request)
{
    if (!$member = Security::getCurrentUser()) {
        return $this->redirect('/login');
    }

    $data = $request->postVars();
    // If id present & owned => EDIT, else => ADD
    $apartment = $this->getApartmentFromRequest($request) ?: Apartment::create();
    if (!$apartment->ID) {
        $apartment->MemberID = $member->ID; // only on create
    }

    $apartment->ObjectNumber = $data['ObjectNumber'] ?? $apartment->ObjectNumber;
    $apartment->GroupNumber  = $data['GroupNumber']  ?? $apartment->GroupNumber;
    $apartment->WohnungType  = $data['WohnungType']  ?? $apartment->WohnungType;
    $apartment->write();

    return $this->redirect('/apartment/step2?id=' . $apartment->ID);
}

# 3) Steps 2–5 follow the same “edit if id present & owned” rule

– In each `stepX(HTTPRequest $request)`:
– `$apt = $this->getApartmentFromRequest($request);`
– If not found ⇒ redirect to step1 (or show 404).
– `$form = $this->stepXForm();`
– `$form->setFormAction('/apartment/doStepX?id=' . $apt->ID);`
– If related record exists (Address/Details), `loadDataFrom(…)`.

– In each `doStepX(…)`:
– `$apt = $this->getApartmentFromRequest($request);` (guard)
– Load/create the related record (Address/Details) **based on IDs on `$apt`**.
– Save fields from `$request->postVars()`.
– Update the foreign key on `$apt` if created.
– Redirect to next step with `?id=`.

Example for step2 (Address):

public function step2(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $form = $this->step2Form();
    $form->setFormAction('/apartment/doStep2?id=' . $apt->ID);

    if ($apt->AddressID && ($addr = ApartmentAddress::get()->byID($apt->AddressID))) {
        $form->loadDataFrom($addr);
    }

    return $this->customise([
        'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
        'Form'=>$form,
        // expose fields as you already do …
    ])->renderWith(['Layout/Apartment/step2', 'Page']);
}

public function doStep2(HTTPRequest $request)
{
    $apt = $this->getApartmentFromRequest($request);
    if (!$apt) return $this->redirect('/apartment/step1');

    $d = $request->postVars();

    $addr = $apt->AddressID ? ApartmentAddress::get()->byID($apt->AddressID) : ApartmentAddress::create();
    $addr->Street = $d['Street'] ?? '';
    $addr->Nr = $d['Nr'] ?? '';
    $addr->Plz = $d['Plz'] ?? '';
    $addr->Ort = $d['Ort'] ?? '';
    $addr->Stadt = $d['Stadt'] ?? '';
    $addr->Stadtteil = $d['Stadtteil'] ?? '';
    $addr->StrasseundHausnummer = $d['StrasseundHausnummer'] ?? '';
    $addr->write();

    if ((int)$apt->AddressID !== (int)$addr->ID) {
        $apt->AddressID = $addr->ID;
        $apt->write();
    }

    return $this->redirect('/apartment/step3?id=' . $apt->ID);
}

# 4) Templates: always carry the `id`

Buttons/links in your `.ss` files should include the current id:

<a href="$Link(apartment/step3)?id={$Request.get.id}" class="btn">Weiter</a>

(Or pass `$ApartmentID` from the controller and use `?id=$ApartmentID`.)

That’s it: if `?id` exists **and** belongs to the member, you’re in edit mode (pre-filled + update); otherwise you’re creating a new apartment.

User
I want onclick on below

Bilder

GIF, JPEG, PNG

Triger file upload to this
FileField::create('BilderID', 'Bilder*')->setTitle('Bilder*')->setAllowedExtensions(['gif', 'jpeg', 'png']),
'BilderID'=>$form->Fields()->dataFieldByName('BilderID'),

Assistant
Got it 👍 — you want the styled `…` to behave like a button and trigger your `FileField` input (`BilderID`).

Here’s how to do it in SilverStripe:

### 1. Give your `FileField` an HTML `id`

FileField::create('BilderID', 'Bilder*')
    ->setTitle('Bilder*')
    ->setAllowedExtensions(['gif','jpeg','png'])
    ->setAttribute('id', 'BilderIDInput')

### 2. In your template, render the field hidden and add your clickable card

<!-- Hidden file input -->
$BilderID

<!-- Styled upload card -->
<div class="upload-card col align-items-center" id="uploadCard">
    <i class="fa-regular fa-image"></i>
    <div class="title">Bilder</div>
    <small>GIF, JPEG, PNG</small>
</div>

If you want the input completely invisible, wrap it with CSS:

#BilderIDInput {
    display: none;
}

### 3. Add JS to trigger the hidden input

<script>
document.addEventListener("DOMContentLoaded", function () {
    const card = document.getElementById("uploadCard");
    const fileInput = document.getElementById("BilderIDInput");

    if (card && fileInput) {
        card.addEventListener("click", function () {
            fileInput.click();
        });
    }
});
</script>

✅ Now when the user clicks the **upload card**, it will open the native file picker of the hidden `FileField`.
The uploaded file will still bind to `BilderID` in your SilverStripe form.

Do you also want me to show how to **preview the selected image(s)** inside that card after choosing a file?

User
FileField::create('VideoID', 'Video*')->setTitle('Video*')->setAllowedExtensions(['mps','avi','mov','mpg','mp4','mkv'])->setAttribute('id', 'VideoIDInput')->setAttribute('class', 'inputFileHidden'),
give error mp4 not alliwed
Assistant
Ah — that happens because in SilverStripe (and PHP’s `File::allowedExtensions` under the hood), the extension list must be **valid lowercase extensions** and correctly spelled.

👉 Two problems in your code:
1. You have a **typo**: `'mps'` (probably meant `'mps'` → `'mpg'` or `'mpe'`).
2. `'mp4'` is valid, but SilverStripe compares strictly against the extension list. If the list has a wrong entry, it may block the upload.

### ✅ Corrected field:

FileField::create('VideoID', 'Video*')
    ->setTitle('Video*')
    ->setAllowedExtensions(['mp4', 'avi', 'mov', 'mpg', 'mkv'])
    ->setAttribute('id', 'VideoIDInput')
    ->setAttribute('class', 'inputFileHidden');

### Notes
– **Order doesn’t matter**; just ensure correct spellings.
– SilverStripe always expects lowercase file extensions.
– If you also want to allow **mpeg**, add `'mpeg'` to the list.

So the final list could be:

['mp4', 'avi', 'mov', 'mpg', 'mpeg', 'mkv']

Do you want me to also show you how to **whitelist these globally** (so the AssetAdmin also allows uploading these video formats), not just in your form field?

User
Assistant
Thanks for pasting the full context 👌 — I see exactly why you’re stuck:

Right now you’re using **`Image::create()`** in `attachImageFromFiles()`.

👉 `Image` in SilverStripe is a subclass of `File` **restricted to raster image formats** (`jpg`, `jpeg`, `png`, `gif`, `webp` etc). That’s why when you try to upload `pdf` or `mp4`, it gets rejected as “not allowed” — they can never become `Image` objects.

## ✅ Fix

Use the correct class depending on the type of file:

– `Image` → for images only
– `File` → for everything (pdf, video, docs, etc.)

So update your helper like this:

use SilverStripeAssetsFile;
use SilverStripeAssetsImage;
use SilverStripeAssetsUpload;

private function attachFileFromFiles(string $field, string $folder = 'profiles'): ?File
{
    if (empty($_FILES[$field]) || empty($_FILES[$field]['tmp_name'])) {
        return null;
    }

    $upload = Upload::create();
    $upload->getValidator()->setAllowedExtensions([
        'jpg','jpeg','png','gif',
        'pdf',
        'avi','mov','mpg','mp4','mkv'
    ]);

    // Use generic File instead of Image
    $file = File::create();

    if ($upload->loadIntoFile($_FILES[$field], $file, $folder)) {
        return $file; // published later if you want
    }

    // Debug: $upload->getErrors()
    return null;
}

## 2. Use the helper correctly in `doStep9`

if ($file = $this->attachFileFromFiles('BilderID', 'profiles')) {
    if ($file instanceof Image) {
        $file->publishSingle();
    } else {
        $file->publishFile();
    }
    $apartment->BilderID = $file->ID;
}

if ($file = $this->attachFileFromFiles('DocumentID', 'profiles')) {
    $file->publishFile();
    $apartment->DocumentID = $file->ID;
}

if ($file = $this->attachFileFromFiles('VideoID', 'profiles')) {
    $file->publishFile();
    $apartment->VideoID = $file->ID;
}

if ($file = $this->attachFileFromFiles('GrundID', 'profiles')) {
    if ($file instanceof Image) {
        $file->publishSingle();
    } else {
        $file->publishFile();
    }
    $apartment->GrundID = $file->ID;
}

## 3. Difference between `publishSingle()` and `publishFile()`

– `Image` has `publishSingle()` (for Versioned images).
– Generic `File` uses `publishFile()` in SS4+ (since 4.4).

So it’s safest to check with `instanceof`.

## 🔑 Takeaway
– Use `Image` **only** for images.
– For video, pdf, docs, always use `File`.
– Your `attachImageFromFiles` should be renamed to `attachFileFromFiles` and return `File`.

Do you want me to rewrite `doStep9()` in a **clean reusable loop** so you don’t repeat the same block 4 times?

User
Now implement
delete ajax
Assistant
Sweet — let’s add an AJAX “delete file” button that clears a specific file field (BilderID / DocumentID / VideoID / GrundID) for the current apartment, with proper auth + CSRF.

Below is a drop‑in, minimal setup.

# 1) Controller: endpoint to delete a file

Add imports (top of controller):

use SilverStripeAssetsFile;
use SilverStripeSecuritySecurityToken;
use SilverStripeControlHTTPResponse;
use SilverStripeVersionedVersioned;

Add the action to `$allowed_actions`:

private static $allowed_actions = [
  // ...existing
  'deleteFile'
];

Expose a SecurityID helper (so you can print the CSRF token in your template):

public function SecurityID(): string
{
    return SecurityToken::inst()->getValue();
}

Add the delete action:

public function deleteFile(HTTPRequest $request): HTTPResponse
{
    // Enforce POST + CSRF
    if (!$request->isPOST() || !SecurityToken::inst()->checkRequest($request)) {
        return $this->jsonError('Invalid request', 400);
    }

    // Must be logged in
    $member = Security::getCurrentUser();
    if (!$member) {
        return $this->jsonError('Unauthorized', 401);
    }

    $apartment = $this->getApartmentFromRequest($request);
    if (!$apartment) {
        return $this->jsonError('Apartment not found or not yours', 404);
    }

    // Only allow these fields to be cleared
    $field = $request->postVar('field');
    $allowedFields = ['BilderID', 'DocumentID', 'VideoID', 'GrundID'];
    if (!in_array($field, $allowedFields, true)) {
        return $this->jsonError('Invalid field', 422);
    }

    // Get current file id for this field
    $fileID = (int) $apartment->{$field};
    if (!$fileID) {
        return $this->jsonOk(['message' => 'Nothing to delete', 'field' => $field]);
    }

    /** @var File|null $file */
    $file = File::get()->byID($fileID);

    // Clear relation first
    $apartment->{$field} = 0;
    $apartment->write();

    // If you also want to remove the underlying asset and DB record:
    if ($file) {
        // Unpublish if versioned
        if ($file->hasExtension(Versioned::class)) {
            try { $file->doUnpublish(); } catch (Throwable $e) { /* ignore */ }
        }
        // Delete DB record and physical file
        try {
            $file->deleteFile(); // remove from filesystem
        } catch (Throwable $e) { /* ignore */ }
        try {
            $file->delete(); // remove DB record
        } catch (Throwable $e) { /* ignore */ }
    }

    return $this->jsonOk([
        'message' => 'Deleted',
        'field'   => $field,
        'apartmentID' => $apartment->ID
    ]);
}

Add two tiny helpers at the bottom of the controller (optional but tidy):

private function jsonOk(array $payload = []): HTTPResponse
{
    $resp = HTTPResponse::create(json_encode(['ok' => true] + $payload));
    $resp->addHeader('Content-Type', 'application/json');
    return $resp;
}

private function jsonError(string $msg, int $code = 400): HTTPResponse
{
    $resp = HTTPResponse::create(json_encode(['ok' => false, 'error' => $msg]));
    $resp->addHeader('Content-Type', 'application/json');
    $resp->setStatusCode($code);
    return $resp;
}

# 2) Template markup

Print a CSRF token somewhere globally in your layout (e.g. `Page.ss`), so JS can read it:

<meta name="security-id" content="$SecurityID">

For each file slot, render a delete button with data attrs:

<button class="delete-btn"
        data-field="BilderID"
        data-apartment="{$apartment.ID}"
        aria-label="Delete Bild">
  <i class="fa-solid fa-trash"></i>
</button>

<button class="delete-btn"
        data-field="DocumentID"
        data-apartment="{$apartment.ID}"
        aria-label="Delete Dokument">
  <i class="fa-solid fa-trash"></i>
</button>

<!-- repeat for VideoID / GrundID -->

(You can conditionally show the button only if the field has a file, e.g. ` … `.)

# 3) JavaScript (plain fetch)

Attach once (e.g., in your step9 template or a global JS bundle):

<script>
document.addEventListener('DOMContentLoaded', function () {
  const token = document.querySelector('meta[name="security-id"]')?.getAttribute('content') || '';
  document.body.addEventListener('click', async function (e) {
    const btn = e.target.closest('.delete-btn');
    if (!btn) return;

    e.preventDefault();

    if (!confirm('Datei wirklich löschen?')) return;

    const field = btn.getAttribute('data-field');
    const apartmentID = btn.getAttribute('data-apartment');

    try {
      const res = await fetch('/apartment/deleteFile?apartmentID=' + encodeURIComponent(apartmentID), {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
        },
        body: new URLSearchParams({
          field,
          SecurityID: token
        })
      });

      const data = await res.json();

      if (!data.ok) {
        alert(data.error || 'Fehler beim Löschen');
        return;
      }

      // UI update idea:
      // - hide preview
      // - clear filename label
      // - disable the delete button
      btn.closest('.file-slot')?.classList.add('is-empty');
      btn.disabled = true;

    } catch (err) {
      console.error(err);
      alert('Netzwerkfehler');
    }
  });
});
</script>

> If your delete button sits next to a preview wrapper, give that wrapper a class like `.file-slot` so you can toggle UI state easily.

## Notes / options

– **Only unlink, don’t delete file?**
If you want to keep the file in the library, remove the `deleteFile()` / `delete()` calls, and just clear the relation.
– **Custom permissions:**
We already verify ownership via `getApartmentFromRequest()`, so others can’t delete your files.
– **URL path:**
Action is `/apartment/deleteFile` (matches the controller’s URL segment).
– **HTTP method:**
We accept only `POST` and validate `SecurityToken` to prevent CSRF.

That’s it — click the trash icon, confirm, and the field is cleared (and the asset removed if you keep that part).

User
all data in button
Assistant
Got it 👍 — you don’t want to spread `data-*` across multiple attributes, you want **all the info (field + apartment id)** inside the button itself. That’s easy:

### 1. Markup
Just pack everything into `data-*` on the button:

<button class="delete-btn"
        data-field="BilderID"
        data-apartment="{$apartment.ID}">
  <i class="fa-solid fa-trash"></i>
</button>

If you need multiple values combined, you can also use a JSON string:

<button class="delete-btn"
        data-info='{"field":"BilderID","apartment":"{$apartment.ID}"}'>
  <i class="fa-solid fa-trash"></i>
</button>

### 2. JS — Option A (separate attrs)

document.addEventListener("click", async e => {
  const btn = e.target.closest(".delete-btn");
  if (!btn) return;

  const field = btn.dataset.field;
  const apartmentID = btn.dataset.apartment;

  console.log("Delete", field, "for apartment", apartmentID);
  // … call fetch('/apartment/deleteFile?apartmentID=' + apartmentID, { body: "field=" + field + … })
});

### 2. JS — Option B (single `data-info` JSON)

document.addEventListener("click", async e => {
  const btn = e.target.closest(".delete-btn");
  if (!btn) return;

  const info = JSON.parse(btn.dataset.info); 
  const field = info.field;
  const apartmentID = info.apartment;

  console.log("Delete", field, "for apartment", apartmentID);
});

✅ This way *all* your needed values ride along on the “ itself.

👉 Do you prefer **Option A** (two `data-*` attributes, easier to read) or **Option B** (single JSON string, tidier markup)?

User
delete-btn
use jqueet
Assistant
Perfect 👍 — here’s how to wire up your `.delete-btn` with **jQuery** so it reads the `data-*` attributes and calls your `/apartment/deleteFile` endpoint via AJAX.

### 1. HTML Button
Keep your info in the button itself:

<button class="delete-btn" 
        data-field="BilderID" 
        data-apartment="123">
  <i class="fa-solid fa-trash"></i>
</button>

### 2. jQuery Script

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).on('click', '.delete-btn', function(e) {
    e.preventDefault();

    if (!confirm("Datei wirklich löschen?")) return;

    var btn = $(this);
    var field = btn.data('field');
    var apartmentID = btn.data('apartment');
    var securityID = $('meta[name="security-id"]').attr('content'); // CSRF token

    $.ajax({
        url: '/apartment/deleteFile?apartmentID=' + apartmentID,
        method: 'POST',
        data: {
            field: field,
            SecurityID: securityID
        },
        success: function(res) {
            if (res.ok) {
                alert("Datei gelöscht: " + field);
                // update UI: remove preview, disable button
                btn.closest('.file-slot').fadeOut();
            } else {
                alert(res.error || "Fehler beim Löschen");
            }
        },
        error: function(xhr) {
            alert("Serverfehler: " + xhr.status);
        }
    });
});
</script>

### 3. Notes
– `data-field` and `data-apartment` are automatically parsed by jQuery → `btn.data('field')`.
– We’re reading `SecurityID` from “.
– `btn.closest('.file-slot').fadeOut();` is just an example — adjust to your DOM structure.

Do you also want me to make the **same button remove its file preview thumbnail instantly** (before the AJAX response comes back), or only after the server confirms deletion?

User
also delete image
Assistant
gotcha — you want the delete button to also remove the previewed image (and clear the file input) on the page.

Here’s a clean, jQuery-based setup that:
– sends the AJAX delete
– optimistically hides the preview
– clears the corresponding “
– gracefully restores the UI if the request fails

### Example HTML structure (per file slot)

<!-- Example: Bilder -->
<div class="file-slot" data-field="BilderID">
  <div class="preview">
    <img class="file-thumb" src="/path/to/preview.jpg" alt="Bilder preview">
    <span class="file-name">bild.jpg</span>
  </div>

  <!-- Your hidden FileField input (already on page) -->
  <!-- Make sure the IDs match your form code: e.g. BilderIDInput, DocumentIDInput, etc. -->
  <input type="file" id="BilderIDInput" class="inputFileHidden" />

  <button class="delete-btn" data-field="BilderID" data-apartment="{$apartment.ID}">
    <i class="fa-solid fa-trash"></i>
  </button>
</div>

> Repeat similar blocks for `DocumentID`, `VideoID`, `GrundID` (change `data-field` and input `id` accordingly).

Also include the CSRF token once in your layout:

<meta name="security-id" content="$SecurityID">

### jQuery: delete + remove preview + clear input

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).on('click', '.delete-btn', function (e) {
  e.preventDefault();

  if (!confirm('Datei wirklich löschen?')) return;

  var $btn         = $(this);
  var field        = $btn.data('field');         // e.g. "BilderID"
  var apartmentID  = $btn.data('apartment');
  var securityID   = $('meta[name="security-id"]').attr('content');
  var $slot        = $btn.closest('.file-slot'); // wrapper of this file field
  var $preview     = $slot.find('.preview');     // contains img + filename
  var $img         = $preview.find('.file-thumb');
  var $name        = $preview.find('.file-name');

  // Build the input id conventionally: BilderID -> BilderIDInput
  var inputId      = field + 'Input';
  var $fileInput   = $('#' + inputId);

  // Keep old HTML in case we need to restore on error
  var oldHTML = $preview.html();

  // Optimistic UI: hide and mark loading
  $slot.addClass('is-deleting');
  $preview.fadeTo(120, 0.2);

  $.ajax({
    url: '/apartment/deleteFile?apartmentID=' + encodeURIComponent(apartmentID),
    method: 'POST',
    dataType: 'json',
    data: {
      field: field,
      SecurityID: securityID
    },
    success: function (res) {
      if (res && res.ok) {
        // Remove preview content and show a placeholder
        $preview.slideUp(150, function () {
          $preview.html('<em class="placeholder">Keine Datei</em>').show();
        });

        // Clear the file input value so re-upload is possible
        if ($fileInput.length) {
          $fileInput.val('');
        }

        // Disable the button (or hide it) since there’s no file now
        $btn.prop('disabled', true).addClass('is-disabled');

        $slot.removeClass('is-deleting').addClass('is-empty');
      } else {
        // Restore UI on server-declared error
        alert((res && res.error) ? res.error : 'Fehler beim Löschen.');
        $preview.html(oldHTML).css('opacity', 1);
        $slot.removeClass('is-deleting');
      }
    },
    error: function (xhr) {
      // Restore UI on network/server error
      alert('Serverfehler: ' + xhr.status);
      $preview.html(oldHTML).css('opacity', 1);
      $slot.removeClass('is-deleting');
    }
  });
});
</script>

### Optional CSS (nice UX)

.file-slot.is-deleting { pointer-events: none; }
.file-slot.is-empty .delete-btn { display: none; } /* hide delete when empty */
.placeholder { color: #999; font-style: italic; }

#### Notes
– The code assumes your file input IDs follow the pattern `BilderIDInput`, `DocumentIDInput`, `VideoIDInput`, `GrundIDInput`. If not, adjust the `inputId` logic.
– For non-image files (pdf/video), you can still remove a generic preview node (icon + filename) with the same code.
– If you **only** want to unlink the DB field and keep the asset in library, remove the physical deletion in your controller. The front-end logic above remains the same.

User
pass member object to
OptionsetField::create('ContactId', 'Kontaktperson',['Ja'=>'Ja','Nein'=>'Nein'])->setTitle('Kontaktperson'),
and then fetch Id in key and email in label

$Title:

$Title

Assistant
You got it—build the options dynamically from members/contacts so the **key = ID** and **label = email**, then feed that into your `OptionsetField`.

### 1) Build options (ID ⇒ Email) from the logged-in member’s contacts
Adjust the fetching to match where your contacts live:

**Case A: contacts are other `Member` records you choose (example filter shown)**

use SilverStripeSecuritySecurity;
use SilverStripeSecurityMember;
use SilverStripeFormsOptionsetField;
use SilverStripeFormsLiteralField;

// inside stepXForm(...)
$member = Security::getCurrentUser();

// Example query — replace this with your real logic:
$contactsList = Member::get()
    ->filter('CompanyID', $member->CompanyID ?? 0); // OR your own condition

$options = $contactsList->map('ID', 'Email')->toArray();

// Optionally include the logged-in member as a contact option:
if ($member && $member->ID && $member->Email) {
    $options = [$member->ID => $member->Email] + $options; // put current member first
}

$fields->push(
    !empty($options)
        ? OptionsetField::create('ContactId', 'Kontaktperson', $options)
            ->setTitle('Kontaktperson')
            ->addExtraClass('radio_group')               // keeps your custom radio styles
            ->setTemplate('OptionsetFieldLeft')          // if you’re using custom template
        : LiteralField::create('NoContactsNote', '<p class="text-muted">Keine Kontakte gefunden.</p>')
);

**Case B: contacts are a custom DataObject (e.g., `ContactPerson` with `Email`)**

use AppModelContactPerson;

$contactsList = ContactPerson::get()->filter('MemberID', $member->ID);
$options = $contactsList->map('ID', 'Email')->toArray();

$fields->push(
    !empty($options)
        ? OptionsetField::create('ContactId', 'Kontaktperson', $options)
            ->setTitle('Kontaktperson')
            ->addExtraClass('radio_group')
            ->setTemplate('OptionsetFieldLeft')
        : LiteralField::create('NoContactsNote', '<p class="text-muted">Keine Kontakte gefunden.</p>')
);

> Make sure your target table (e.g., `Apartment` or `ApartmentDetail`) has a column `ContactId` (Int). On submit, SilverStripe will post the selected `ID` value—store it directly.

### 2) Saving (example in your `doStepX`)

public function doStepX(HTTPRequest $request) {
    $data = $request->postVars();
    $apartment = $this->getApartmentFromRequest($request);
    if (!$apartment) return $this->redirect('/apartment/step1');

    // Save selected contact id
    if (isset($data['ContactId']) && is_numeric($data['ContactId'])) {
        $apartment->ContactId = (int)$data['ContactId'];
    }
    $apartment->write();

    return $this->redirect('/apartment/stepY?apartmentID=' . $apartment->ID);
}

### 3) Your template snippet works as-is
Your custom template that loops `$Options` is correct; it will render each option as a radio (value = contact ID, label = email):

<div class="area_label col-md-6">$Title:</div>
<div class="radio_group col-md-6">
  <div class="d-flex gap-5">
    <% loop $Options %>
      <label class="radio-wrapper inline-wrapper">
        <input type="radio" name="$Name" value="$Value" id="$ID" required="required" $AttributesHTML <% if $isChecked %>checked="checked"<% end_if %> />
        <span class="custom-radio"></span> $Title
      </label>
    <% end_loop %>
  </div>
</div>

### Quick tips
– If you need a default selection, call `$field->setValue($apartment->ContactId)` after `loadDataFrom`, or set it explicitly with `$field->setValue($member->ID)`.
– If the list could be long, consider a `DropdownField` instead—or keep `OptionsetField` and split by groups.

User
also want to sow telephone in lablel
Assistant
Perfect 👍 — you just need to build the **options array** with whatever string you want for the label.
Instead of only `$Email`, concatenate **Email + Telephone**.

### Example with `Member` records

$member = Security::getCurrentUser();

// get possible contacts (adjust filter as needed)
$contacts = Member::get()->filter('CompanyID', $member->CompanyID ?? 0);

$options = [];
foreach ($contacts as $c) {
    $label = $c->Email;
    if (!empty($c->Phone)) {          // SilverStripe's default Member has "Phone"
        $label .= ' (' . $c->Phone . ')';
    }
    $options[$c->ID] = $label;
}

// optionally add current user as a contact
if ($member && $member->ID && $member->Email) {
    $label = $member->Email;
    if (!empty($member->Phone)) {
        $label .= ' (' . $member->Phone . ')';
    }
    $options = [$member->ID => $label] + $options;
}

// create the field
$fields->push(
    OptionsetField::create('ContactId', 'Kontaktperson', $options)
        ->setTitle('Kontaktperson')
        ->addExtraClass('radio_group')
        ->setTemplate('OptionsetFieldLeft')
);

### Example with custom `ContactPerson` DataObject
If you store phone numbers on another object:

$contacts = ContactPerson::get()->filter('MemberID', $member->ID);

$options = [];
foreach ($contacts as $c) {
    $options[$c->ID] = $c->Email . ' (' . $c->Telephone . ')';
}

### Output in your custom template
Your template will now render radios like:

<label class="radio-wrapper inline-wrapper">
  <input type="radio" name="ContactId" value="15" />
  <span class="custom-radio"></span>
  john@example.com (01234 567890)
</label>

✅ This way the **value = contact ID** (what you save to `ContactId`),
and the **label = email + telephone**.

Do you want me to also show how to display the **selected contact’s email + phone** later (e.g. in step summary)?

User
Broker
E-mail: Loremipsum@mail.de
Tel: 06-123456789

I want to display like this

Assistant
👌 got it — you don’t want the email+telephone shown inline in the radio label anymore, you want a **formatted block** like your `…` per option.

That means you should still keep the **radio input** as the “value holder,” but render the contact info next to it in your custom template.

### 1. Build options (ID only)
In PHP keep the options simple (`ID => ID`) so you have the key available in the template:

$options = [];
foreach ($contacts as $c) {
    $options[$c->ID] = $c->ID; // value is just the ID
}
$field = OptionsetField::create('ContactId', 'Kontaktperson', $options)
    ->addExtraClass('radio_group')
    ->setTemplate('OptionsetFieldLeft'); // your custom template

### 2. Pass full contact info to the template
Expose a `Contacts` list to the template:

public function getContactsData()
{
    $member = Security::getCurrentUser();
    return Member::get()->filter('CompanyID', $member->CompanyID ?? 0);
}

Now in your template you can match each `$Option.Value` against a `$Contact.ID`.

### 3. Custom template (OptionsetField.ss override)
Inside your `themes/…/templates/SilverStripe/Forms/OptionsetFieldLeft.ss` put something like:

<div class="radio_group">
  <% loop $Options %>
    <% with $Top.ContactsData.Filter(ID, $Value).First %>
      <label class="radio-wrapper inline-wrapper">
        <input type="radio" name="$Up.Name" value="$ID" id="$Up.ID"
          <% if $Up.isChecked %>checked="checked"<% end_if %> $Up.AttributesHTML />
        <span class="custom-radio"></span>
        <div class="content">
          <h4>Broker</h4>
          <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Email</div>
          <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Phone</div>
        </div>
      </label>
    <% end_with %>
  <% end_loop %>
</div>

### 4. Rendered HTML per option
This will output:

<label class="radio-wrapper inline-wrapper">
  <input type="radio" name="ContactId" value="15">
  <span class="custom-radio"></span>
  <div class="content">
    <h4>Broker</h4>
    <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: john@example.com</div>
    <div class="phone"><i class="fa-solid fa-phone"></i> Tel: 06-123456789</div>
  </div>
</label>

✅ That way you have a nice **radio selector** with the contact info displayed like your `…`.

Do you want me to also adjust this so if the member has **no phone** it hides the “ block automatically?

User
Broker
E-mail: $Email
Tel: $Mobile

email and phone not showjng

Assistant
You’re super close. Two common gotchas are causing the labels to be empty:

1) **Your option values must be the contact IDs.**
If your `OptionsetField` uses anything else (like email) as the value, then
`Filter(ID, $Value)` won’t find a match.

2) **Field names must exist.**
`Member` has `Email` and usually `Phone` (not `Mobile`). If your model uses `Mobile`, keep it; otherwise use `$Phone`.

Here’s the working setup.

## A) Build the options as ID ⇒ ID
(In your form method)

$member  = Security::getCurrentUser();

// however you select contacts:
$contacts = Member::get()->filter('CompanyID', $member->CompanyID ?? 0);

// important: values must be IDs
$options = [];
foreach ($contacts as $c) {
    $options[$c->ID] = $c->ID; // value is the contact ID
}

$field = OptionsetField::create('ContactId', 'Kontaktperson', $options)
    ->addExtraClass('radio_group')
    ->setTemplate('OptionsetFieldLeft'); // your custom template

Also expose the list to templates (controller):

public function ContactsData() {
    $member = Security::getCurrentUser();
    return Member::get()->filter('CompanyID', $member->CompanyID ?? 0);
}

## B) Template override (OptionsetFieldLeft.ss)
Use the contact ID carried in `$Value` to fetch the full record, then print email + phone:

<div class="radio_group">
  <% loop $Options %>
    <% with $Top.ContactsData.Filter('ID', $Value).First %>
      <label class="radio-wrapper inline-wrapper">
        <input type="radio"
               name="$Up.Name"
               value="$ID"
               id="$Up.ID"
               $Up.AttributesHTML
               <% if $Up.isChecked %>checked="checked"<% end_if %> />
        <span class="custom-radio"></span>
        <div class="content">
          <h4>Broker</h4>
          <div class="email">
            <i class="fa-solid fa-envelope"></i>
            E-mail: $Email
          </div>
          <% if $Phone %>
            <div class="phone">
              <i class="fa-solid fa-phone"></i>
              Tel: $Phone
            </div>
          <% end_if %>
        </div>
      </label>
    <% end_with %>
  <% end_loop %>
</div>

> If your contact object uses `Mobile` instead of `Phone`, just swap `$Phone` → `$Mobile`.

## Quick sanity checks

– Temporarily print the raw `$Value` to ensure it’s an **ID**:
“ inside the loop.
– If nothing shows, try `Find` instead of `Filter`:

<% with $Top.ContactsData.Find('ID', $Value) %>
     ...same content...
  <% end_with %>

– Confirm your `ContactsData()` actually returns items (try `$Email` somewhere).

If you prefer, I can also show a version that **doesn’t query inside the loop** (maps the list into an array once and passes a lookup helper).

User
$Title:

$Title

explode $Title ::

Assistant
You can’t “explode” a string inside a SilverStripe template directly. Do it in PHP and expose the parts to the template.

Here are two clean options—pick one.

# Option A (recommended): Don’t use `$Title` at all—lookup by `$Value`
Make your `OptionsetField` source be `ID => ID`, then fetch the contact by ID in the template (so you can show email/phone nicely).

**Form (build options):**

$options = [];
foreach ($contacts as $c) {
    $options[$c->ID] = $c->ID; // value = contact id
}
$field = OptionsetField::create('ContactId', 'Kontaktperson', $options)
    ->addExtraClass('radio_group')
    ->setTemplate('OptionsetFieldLeft');

**Controller (expose data):**

public function ContactsData() {
    $member = Security::getCurrentUser();
    return Member::get()->filter('CompanyID', $member->CompanyID ?? 0);
}

**Template:**

<div class="radio_group form_control d-flex">
  <label class="form-label left_side">$Title:</label>
  <div class="d-flex">
    <% loop $Options %>
      <% with $Top.ContactsData.Find('ID', $Value) %>
        <label class="radio-wrapper">
          <input type="radio" name="$Up.Name" value="$ID" id="$Up.ID"
                 $Up.AttributesHTML <% if $Up.isChecked %>checked="checked"<% end_if %> />
          <span class="custom-radio"></span>
          <div class="content">
            <h4>Broker</h4>
            <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Email</div>
            <% if $Phone %>
              <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Phone</div>
            <% end_if %>
          </div>
        </label>
      <% end_with %>
    <% end_loop %>
  </div>
</div>

# Option B: If you must split `$Title` (e.g., it holds `"email::phone"`)
Create a small helper in your controller to “explode” and return parts:

**Controller:**

use SilverStripeViewArrayData;

public function SplitLabel($title) {
    $parts = explode('::', (string)$title, 2);
    return ArrayData::create([
        'Left'  => trim($parts[0] ?? ''), // e.g., email
        'Right' => trim($parts[1] ?? ''), // e.g., phone
    ]);
}

**Template:**

<div class="radio_group form_control d-flex">
  <label class="form-label left_side">$Title:</label>
  <div class="d-flex">
    <% loop $Options %>
      <label class="radio-wrapper">
        <input type="radio" name="$Name" value="$Value" id="$ID"
               $AttributesHTML <% if $isChecked %>checked="checked"<% end_if %> />
        <span class="custom-radio"></span>
        <% with $Top.SplitLabel($Title) %>
          <div class="content">
            <h4>Broker</h4>
            <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Left</div>
            <% if $Right %>
              <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Right</div>
            <% end_if %>
          </div>
        <% end_with %>
      </label>
    <% end_loop %>
  </div>
</div>

**Notes**
– Option A is cleaner and avoids packing data into `$Title`.
– If your phone field is actually `Mobile`, replace `$Phone` with `$Mobile`.
– If you want HTML inside labels, remember to use `$Something.RAW` only when you trust the content.

User
$Title:

$Title

Apply in above

Assistant
Here are two drop‑in ways you can “apply” it to your existing template.

## ✅ Option A (recommended): use the option **value as Contact ID** and fetch full record

### 1) PHP (form + controller)
Build options as `ID => ID`, and expose the contacts list:

// In your form builder
$options = [];
foreach ($contacts as $c) {
    $options[$c->ID] = $c->ID; // value is the contact ID
}
$field = OptionsetField::create('ContactId', 'Kontaktperson', $options)
    ->addExtraClass('radio_group')
    ->setTemplate('OptionsetFieldLeft'); // your custom template (below)

// In your controller
public function ContactsData() {
    $member = Security::getCurrentUser();
    return Member::get()->filter('CompanyID', $member->CompanyID ?? 0);
}

### 2) Template (apply to your snippet)
Replace the inside of your “ with a lookup by `$Value`:

<div class="radio_group form_control d-flex">
  <label class="form-label left_side">$Title:</label>
  <div class="d-flex">
    <% loop $Options %>
      <% with $Top.ContactsData.Find('ID', $Value) %>
        <label class="radio-wrapper">
          <input type="radio"
                 name="$Up.Name"
                 value="$ID"
                 id="$Up.ID"
                 required="required"
                 $Up.AttributesHTML
                 <% if $Up.isChecked %>checked="checked"<% end_if %> />
          <span class="custom-radio"></span>
          <div class="content">
            <h4>Broker</h4>
            <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Email</div>
            <% if $Phone %>
              <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Phone</div>
            <% end_if %>
          </div>
        </label>
      <% end_with %>
    <% end_loop %>
  </div>
</div>

> If your model uses `Mobile` instead of `Phone`, change `$Phone` → `$Mobile`.

## 🅱️ Option B: if your `$Title` already contains both values (e.g., `"email::phone"`)

### 1) Controller helper to split:

use SilverStripeViewArrayData;

public function SplitLabel($title) {
    $parts = explode('::', (string)$title, 2);
    return ArrayData::create([
        'Left'  => trim($parts[0] ?? ''), // email
        'Right' => trim($parts[1] ?? ''), // phone
    ]);
}

### 2) Template (apply to your snippet)
Use the splitter for each option:

<div class="radio_group form_control d-flex">
  <label class="form-label left_side">$Title:</label>
  <div class="d-flex">
    <% loop $Options %>
      <label class="radio-wrapper">
        <input type="radio"
               name="$Name"
               value="$Value"
               id="$ID"
               required="required"
               $AttributesHTML
               <% if $isChecked %>checked="checked"<% end_if %> />
        <span class="custom-radio"></span>
        <% with $Top.SplitLabel($Title) %>
          <div class="content">
            <h4>Broker</h4>
            <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Left</div>
            <% if $Right %>
              <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Right</div>
            <% end_if %>
          </div>
        <% end_with %>
      </label>
    <% end_loop %>
  </div>
</div>

### Which should you use?
– **Option A** is cleaner: store just the ID in options, fetch the real record, and print `$Email`/`$Phone`.
– **Option B** only if you’re already packing both values into `$Title`.

If you want, share how you’re building the `OptionsetField` source and I’ll tailor the exact code.

User
$Top.SplitLabel($Title) NOt working
Assistant
Right 👍 — the reason `$Top.SplitLabel($Title)` isn’t working is because in SilverStripe templates you can only call methods on the **current scope object**, not arbitrary helpers on the controller unless you’ve explicitly added them.

Let’s fix it properly.

## ✅ Cleanest solution: don’t stuff two values in `$Title`

Instead of “exploding” `$Title`, make your `OptionsetField` options just the **contact IDs**, then fetch the full `Member` (or Contact model) in the template by `$Value`.

### 1) In your form PHP

// Build options: ID => ID
$options = [];
foreach ($contacts as $c) {
    $options[$c->ID] = $c->ID;
}

$field = OptionsetField::create('ContactId', 'Kontaktperson', $options)
    ->addExtraClass('radio_group')
    ->setTemplate('OptionsetFieldLeft');

Expose the contacts list:

public function ContactsData() {
    return Member::get(); // or your custom filter
}

### 2) In your template (OptionsetFieldLeft.ss)

<div class="radio_group form_control d-flex">
  <label class="form-label left_side">$Title:</label>
  <div class="d-flex">
    <% loop $Options %>
      <% with $Top.ContactsData.Find('ID', $Value) %>
        <label class="radio-wrapper">
          <input type="radio"
                 name="$Up.Name"
                 value="$ID"
                 id="$Up.ID"
                 required="required"
                 $Up.AttributesHTML
                 <% if $Up.isChecked %>checked="checked"<% end_if %> />
          <span class="custom-radio"></span>
          <div class="content">
            <h4>Broker</h4>
            <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Email</div>
            <% if $Phone %>
              <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Phone</div>
            <% end_if %>
          </div>
        </label>
      <% end_with %>
    <% end_loop %>
  </div>
</div>

### Why this works
– `$Value` is the contact’s ID.
– `$Top.ContactsData.Find('ID', $Value)` finds that `Member` record.
– You now have `$Email` and `$Phone` directly — no need for “SplitLabel”.

👉 If your model uses `Mobile` instead of `Phone`, just replace `$Phone` with `$Mobile`.

Do you want me to also show how to **fallback gracefully** if no phone is set (hide the “)?

User
NOt wokrong
Assistant
No stress—we’ll make it work. The issue is **template scope**: inside an `OptionsetField` template, `$Top` is the **field**, not your controller. To reach your controller method (`ContactsData()`), you usually need to climb up with `$Top.Top` (form) and sometimes `$Top.Top.Top` (controller/page).

Also confirm your option **values are contact IDs**; otherwise `Find('ID', $Value)` won’t match.

### Do this step‑by‑step

#### 1) Sanity check the option values
In your template, temporarily print the value:

<!-- debug: value is `$Value` -->

If it’s **not** a numeric ID, fix your PHP to set `ID => ID` for the field source.

#### 2) Verify `ContactsData()` is visible
Somewhere on the page (outside the field), test:

<% loop $ContactsData %>$Email<br><% end_loop %>

If nothing prints, the method isn’t on the rendering controller or returns empty. Put it on the page controller that renders the step, and return the right list.

#### 3) Use the correct scope chain inside the field template
Try `$Top.Top.ContactsData` first; if still empty, use `$Top.Top.Top.ContactsData`.

**Working template (apply to your snippet):**

<div class="radio_group form_control d-flex">
  <label class="form-label left_side">$Title:</label>
  <div class="d-flex">
    <% loop $Options %>
      <% with $Top.Top.Top.ContactsData.Find('ID', $Value) %>
        <label class="radio-wrapper">
          <input type="radio"
                 name="$Up.Name"
                 value="$ID"
                 id="$Up.ID"
                 required="required"
                 $Up.AttributesHTML
                 <% if $Up.isChecked %>checked="checked"<% end_if %> />
          <span class="custom-radio"></span>
          <div class="content">
            <h4>Broker</h4>
            <div class="email"><i class="fa-solid fa-envelope"></i> E-mail: $Email</div>
            <% if $Phone %>
              <div class="phone"><i class="fa-solid fa-phone"></i> Tel: $Phone</div>
            <% end_if %>
          </div>
        </label>
      <% end_with %>
      <!-- Optional debug if no match -->
      <% if not $Top.Top.Top.ContactsData.Find('ID', $Value) %>
        <!-- debug: no contact found for value `$Value` -->
      <% end_if %>
    <% end_loop %>
  </div>
</div>

> If your model uses `Mobile` instead of `Phone`, change `$Phone` → `$Mobile`.

### If scope is still painful — fallback approach
Build the full label in PHP and render it as HTML:

$options = [];
foreach ($contacts as $c) {
    $label = sprintf(
        '<div class="content"><h4>Broker</h4><div class="email"><i class="fa-solid fa-envelope"></i> E-mail: %s</div>%s</div>',
        htmlspecialchars($c->Email ?? ''),
        $c->Phone ? '<div class="phone"><i class="fa-solid fa-phone"></i> Tel: '.htmlspecialchars($c->Phone).'</div>' : ''
    );
    $options[$c->ID] = $label;
}
$field = OptionsetField::create('ContactId', 'Kontaktperson', $options)
    ->setTemplate('OptionsetFieldLeft');

Then in your template, print `$Title.RAW`:

<label class="radio-wrapper">
  <input ... />
  <span class="custom-radio"></span>
  $Title.RAW
</label>

That bypasses the lookup and scope issues entirely.

**TL;DR:** Use `$Top.Top.Top.ContactsData` (scope up) and ensure option values are IDs. If you still hit walls, render the full HTML label server‑side and output `$Title.RAW`.

Leave a Comment

Your email address will not be published. Required fields are marked *