Apartment controller update
August 20, 2025 · AI Conversations
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.
—
# 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.
Bilder
GIF, JPEG, PNG
Triger file upload to this
FileField::create('BilderID', 'Bilder*')->setTitle('Bilder*')->setAllowedExtensions(['gif', 'jpeg', 'png']),
'BilderID'=>$form->Fields()->dataFieldByName('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?
give error mp4 not alliwed
👉 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?
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?
delete ajax
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).
—
### 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)?
use jqueet
—
### 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?
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.
OptionsetField::create('ContactId', 'Kontaktperson',['Ja'=>'Ja','Nein'=>'Nein'])->setTitle('Kontaktperson'),
and then fetch Id in key and email in label
$Title:
$Title
### 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.
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)?
E-mail: Loremipsum@mail.de
Tel: 06-123456789
I want to display like this
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?
E-mail: $Email
Tel: $Mobile
email and phone not showjng
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).
$Title
explode $Title ::
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.
$Title
Apply in above
—
## ✅ 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.
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 “)?
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`.