Fix form submit error
August 29, 2025 · AI Conversations
at HTMLInputElement. (brokercontacts:686:42)
Hallo $Name
Zur Übersicht
Selbstauskunft hinzufügen
$FormError
$FormSuccess
$FirstName $LastName
Mietinteressent:in
$FirstName $LastName
Standaard contact
$PositionUnterneehmen
E-mail: $Email
Zusätizlche E-mail: $ZusaEmail
Tel: $CountryCode $Telefon
Mobiel: $Mobile
Fax: $Fax
$Description
um zu sehen
hinzufügen
<script>
const accordions = document.querySelectorAll('.accordion');
accordions.forEach(acc => {
acc.querySelector('.accordion-header').addEventListener('click', () => {
// Optional: Close others before opening
accordions.forEach(item => {
if (item !== acc) item.classList.remove('active');
});
acc.classList.toggle('active');
});
});
</script>
Miet arbeiter Angaben
$Anrede
$Titel
$Vorname
$Nachname
$CountryCode
$Telefon
$ZusaEmail
$PositionUnterneehmen
$Mobile
$Fax
$StandaardContact
$WorkerID
Prev
Firma omschijving
Foto
JPEG, PNG
$Image
$Description
$MemberID
$Form.Actions
<script>
// PREV buttons
document.querySelectorAll(".prevstep").forEach(btn => {
btn.addEventListener("click", function () {
if (current > 0) {
current--;
showStep(current);
}
});
});
document.addEventListener("DOMContentLoaded", function () {
const steps = document.querySelectorAll(".step");
let current = 0;
function showStep(index) {
steps.forEach((s, i) => s.classList.toggle("active", i === index));
}
// attach click to all .nextandsubmit buttons
document.querySelectorAll(".nextstep").forEach(btn => {
btn.addEventListener("click", function () {
if (current < steps.length - 1) {
current++;
showStep(current);
} else {
// last step → submit form
document.getElementById("msForm").submit();
}
});
});
// PREV buttons
document.querySelectorAll(".prevstep").forEach(btn => {
btn.addEventListener("click", function () {
if (current > 0) {
current--;
showStep(current);
}
});
});
// show only first step on load
showStep(current);
});
</script>
<script>
function editWorkerModal(id){
document.getElementById('ModalImage').style.display = "block";
const fields = [
'FirstName',
'LastName',
'Email',
'Telefon',
'Fax',
'PositionUnterneehmen',
'Description',
'StandaardContact',
'ZusaEmail',
'Mobile',
'CountryCode',
'Titel',
'Anrede',
'StandaardContact',
'WorkerID',
];
fields.forEach(field => {
const hiddenInput = document.getElementById(`Model-${field}-${id}`);
const modalInput = document.querySelector(`#multiStepModal [name="${field}"]`);
if (hiddenInput) {
if(field == 'Anrede' || field == 'StandaardContact'){
const radios = document.querySelectorAll(`#multiStepModal [name="${field}"]`);
radios.forEach(radio => {
radio.checked = (radio.value === hiddenInput.value);
});
}else{
if (modalInput) {
modalInput.value = hiddenInput.value;
}
}
}
});
if(document.getElementById(`Model-ImageIDdata-${id}`).value!=0){
document.getElementById('ModalImage').src = document.getElementById(`Model-Image-${id}`).src;
document.getElementById('deleteoption').style.display = "block";
document.getElementById('deleteoption').innerHTML = `<button class="delete-btn" data-field="ImageID" data-objectId="${id}" data-action='/dashboard/deleteFileImg'><i class="fa-solid fa-trash"></i></button>`;
}
// reset step navigation to first step
const modalEl = document.getElementById('multiStepModal');
const steps = modalEl.querySelectorAll(".step");
let current = 0;
steps.forEach((s, i) => s.classList.toggle("active", i === current));
// show modal
var modal = new bootstrap.Modal(modalEl);
modal.show();
}
function addWorkerModal()
{
document.getElementById('deleteoption').style.display = "none";
document.getElementById('deleteoption').innerHTML = "";
document.getElementById('ModalImage').style.display = "none";
const form = document.getElementById('Form_AddWorkerForm');
form.reset();
form.querySelectorAll('input[type="file"]').forEach(input => {
input.value = "";
});
// reset step navigation to first step
const modalEl = document.getElementById('multiStepModal');
const steps = modalEl.querySelectorAll(".step");
let current = 0;
steps.forEach((s, i) => s.classList.toggle("active", i === current));
// show modal
var modal = new bootstrap.Modal(modalEl);
modal.show();
}
</script>
document.getElementById("msForm").submit();
There is no element with id `msForm`, so it returns `null` → `.submit` throws.
### Fix (minimal changes)
1) **Submit the actual modal form** (use the button’s closest form, or fallback to the form inside the modal):
// replace the whole "else" block in the .nextstep click handler with:
} else {
const form = this.closest('form') || document.querySelector('#multiStepModal form');
if (form) {
if (typeof form.requestSubmit === 'function') form.requestSubmit();
else form.submit();
} else {
console.warn('Form not found for submission');
}
}
2) **Remove the extra PREV script block before DOMContentLoaded** (it references `current` before it’s defined). Keep only the one inside `DOMContentLoaded`.
Delete this block entirely:
<script> // PREV buttons
document.querySelectorAll(".prevstep").forEach(btn => {
btn.addEventListener("click", function () {
if (current > 0) {
current--;
showStep(current);
}
});
});
</script>
(You already re-add the prev handler correctly inside the `DOMContentLoaded` block.)
3) *(Optional but recommended)* Make sure your CSRF/`SecurityID` input is **inside** the “ (right now it’s above the form). Move:
<input type="hidden" name="SecurityID" value="$SecurityID">
into the “ so it’s submitted.
That’s it—after (1) the error disappears; after (2) you avoid any future `current` reference issues; and (3) ensures your submission has the token.
—
### ✅ Single Checkbox (true/false)
use SilverStripeFormsCheckboxField;
$fields->addFieldToTab(
'Root.Main',
CheckboxField::create('IsActive', 'Is Active?')
);
– This will render a single checkbox “.
– By default, if checked it saves `1`, if unchecked it saves `0`.
—
### ✅ Multiple Checkboxes (list of options)
use SilverStripeFormsCheckboxSetField;
$fields->addFieldToTab(
'Root.Main',
CheckboxSetField::create(
'Services',
'Select Services',
[
'design' => 'Design',
'frontend' => 'Frontend Development',
'backend' => 'Backend Development'
]
)
);
– This will render multiple checkboxes.
– In the DB, you’d usually store this as a `MultiValueField` or `Text` (comma-separated) unless you set up a `has_many` relation.
—
### ✅ Example with `DataObject`
private static $db = [
'IsActive' => 'Boolean',
'Services' => 'MultiValueField'
];
public function getCMSFields()
{
$fields = parent::getCMSFields();
$fields->addFieldToTab(
'Root.Main',
CheckboxField::create('IsActive', 'Is Active?')
);
$fields->addFieldToTab(
'Root.Main',
CheckboxSetField::create(
'Services',
'Select Services',
[
'seo' => 'SEO',
'ads' => 'Google Ads',
'smm' => 'Social Media'
]
)
);
return $fields;
}
—
👉 Question for you: do you need **just a simple true/false checkbox** or a **set of checkboxes for multiple choices**?
# 1) Single checkbox → `Boolean`
**Model (DataObject):**
use SilverStripeORMDataObject;
class Employee extends DataObject
{
private static $db = [
'IsActive' => 'Boolean',
];
}
**Form field:**
use SilverStripeFormsCheckboxField;
$fields->addFieldToTab('Root.Main',
CheckboxField::create('IsActive', 'Is Active?')
);
**Save (frontend form handler or controller):**
public function doSave($data, Form $form)
{
$record = Employee::create();
$form->saveInto($record); // handles Boolean correctly (checked=1, unchecked=0)
$record->write();
return $this->redirectBack();
}
> If you’re **editing** an existing record:
$form->loadDataFrom($record); // prefill
// ...
$form->saveInto($record);
$record->write();
—
# 2) Checkbox list (multiple options)
## Best practice: use a relation
### Option A: `many_many` to an `Option` DataObject
**Option model:**
class Service extends DataObject
{
private static $db = ['Title' => 'Varchar(100)'];
private static $summary_fields = ['Title'];
private static $table_name = 'Service';
}
**Main model:**
class Employee extends DataObject
{
private static $db = [
'IsActive' => 'Boolean',
];
private static $many_many = [
'Services' => Service::class,
];
}
**Form field:**
use SilverStripeFormsCheckboxSetField;
$serviceMap = Service::get()->map('ID', 'Title')->toArray();
$fields->addFieldToTab('Root.Main',
CheckboxSetField::create('Services', 'Select Services', $serviceMap)
);
**Save (frontend form handler or controller):**
public function doSave($data, Form $form)
{
$employee = Employee::create();
$form->saveInto($employee); // saves non-relational fields
$employee->write(); // must write before setting relations
// Save many_many selections
$selectedIDs = $data['Services'] ?? []; // array of Service IDs (strings)
$employee->Services()->setByIDList($selectedIDs); // overwrites existing
// If none selected, this clears the relation
if (empty($selectedIDs)) {
$employee->Services()->removeAll();
}
return $this->redirectBack();
}
**Edit (prefill):**
$form->loadDataFrom($employee);
// For relations, SilverStripe auto-fills CheckboxSetField if name matches ('Services')
—
## Alternative (not recommended unless you really need it)
– Store as JSON in a `Text` field and `json_encode`/`json_decode` in accessors, or
– Use the community module `silverstripe/multi-valuefield` to store arrays.
But `many_many` is cleaner (queryable, relational, no custom parsing).
—
# Common gotchas & tips
– **Unchecked single checkbox**: SilverStripe posts it as `0`, and `saveInto()` writes `0` properly to `Boolean`.
– **Order of writes**: For relations, call `$object->write()` **before** `setByIDList()` so there’s an ID for the join table.
– **Clearing selections**: If nothing was selected, ensure you clear with `removeAll()` or pass an empty array to `setByIDList([])`.
– **Prefill edit forms**: Use `$form->loadDataFrom($record)`. For `CheckboxSetField` on a `many_many`, it auto-ticks based on the relation.
If you show me your exact models (names/fields) or whether this is CMS or a frontend form, I’ll tailor the snippet to your codebase.
Pick one of these fixes:
—
## A) Quick emergency fix (store as CSV in the same column)
No schema change needed. Convert the array to a string before `saveInto()`.
public function doSave($data, Form $form)
{
$obj = $this->getRecord() ?: MyObject::create();
// Handle DetailsOptions as CSV
if (isset($data['DetailsOptions']) && is_array($data['DetailsOptions'])) {
// optional: normalise values
$values = array_map('strval', $data['DetailsOptions']);
$obj->DetailsOptions = implode(',', $values);
// prevent saveInto from trying to set an array
unset($data['DetailsOptions']);
}
// Now save the rest
$form->saveInto($obj);
$obj->write();
return $this->redirectBack();
}
– Your DB field can be `Text`/`Varchar`.
– When reading, split with `explode(',', $obj->DetailsOptions)`.
—
## B) Proper relational model (recommended)
If `DetailsOptions` represents **multiple choices**, model it as a relation.
**1) DataObjects**
class DetailOption extends DataObject
{
private static $db = ['Title' => 'Varchar(200)'];
private static $summary_fields = ['Title'];
}
class MyObject extends DataObject
{
private static $many_many = [
'DetailsOptions' => DetailOption::class,
];
}
**2) Form field**
$map = DetailOption::get()->map('ID', 'Title')->toArray();
$fields->addFieldToTab(
'Root.Main',
CheckboxSetField::create('DetailsOptions', 'Details', $map)
);
**3) Save handler**
public function doSave($data, Form $form)
{
$obj = $this->getRecord() ?: MyObject::create();
// Save non-relational fields first
$form->saveInto($obj);
$obj->write(); // must have an ID before setting many_many
// Save many_many selections
$ids = $data['DetailsOptions'] ?? [];
$obj->DetailsOptions()->setByIDList($ids);
return $this->redirectBack();
}
This avoids the scalar issue entirely and keeps the data queryable.
—
## C) JSON column (if you prefer one column but structured)
Change schema to `Text` and store JSON:
// save
if (isset($data['DetailsOptions']) && is_array($data['DetailsOptions'])) {
$obj->DetailsOptions = json_encode(array_values($data['DetailsOptions']));
unset($data['DetailsOptions']);
}
$form->saveInto($obj);
$obj->write();
// read
$opts = json_decode($obj->DetailsOptions ?? '[]', true) ?: [];
—
### Why it happened (so you can avoid it next time)
– `CheckboxSetField` always posts an **array** (even if only one ticked).
– `saveInto()` maps by field name. If the DB says `Varchar/Text/Boolean/Int` but the form sends an array, `DataObject::setFieldValue()` throws: “only accepts scalars”.
—
If you tell me how you want to use `DetailsOptions` (search/filter vs just display), I’ll lock in the best option and adjust your exact model/form code.
if ($img = $this->attachImageFromFiles('Bilder', 'profiles')) {
$img->publishSingle();
// set the correct relation column you have:
if ($companyData->hasField('ImageID')) $companyData->ImageID = $img->ID;
}
to file upload
if ($img = $this->attachImageFromFiles('Image', 'profiles')) {
$img->publishSingle();
// set the correct relation column you have:
if ($worker->hasField('ImageID')) $worker->ImageID = $img->ID;
}
now I want to use ajax and show progress bar
—
# 1) Minimal HTML (input + progress bar + preview)
<div class="upload-area">
<input type="file" id="workerImage" accept="image/*">
<div class="progress mt-2" style="height: 10px; display:none;">
<div class="progress-bar" role="progressbar" style="width:0%;">0%</div>
</div>
<img id="previewImg" src="" alt="" style="max-width:180px; display:none; margin-top:10px;">
<input type="hidden" name="SecurityID" id="SecurityID" value="$SecurityID">
</div>
> If you already have a file field rendered by `$Image`, you can add an `id="workerImage"` to it instead of creating a new input.
—
# 2) Frontend JS (XHR with upload progress)
“`html
(function(){
const fileInput = document.getElementById('workerImage');
const progressWrap = document.querySelector('.progress');
const progressBar = document.querySelector('.progress-bar');
const previewImg = document.getElementById('previewImg');
const securityID = document.getElementById('SecurityID')?.value; // SilverStripe CSRF token
// Optional: image preview immediately on choose
fileInput.addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Show preview right away (client-side)
const reader = new FileReader();
reader.onload = ev => {
previewImg.src = ev.target.result;
previewImg.style.display = 'block';
};
reader.readAsDataURL(file);
// Start upload
uploadFile(file);
});
function uploadFile(file) {
const url = '/dashboard/uploadWorkerImage'; // = 200 && xhr.status < 300) {
try {
const res = JSON.parse(xhr.responseText);
if (res.ok) {
// Server-processed image; use canonical URL + keep preview
if (res.url) {
previewImg.src = res.url; // ensures published/processed URL
previewImg.style.display = 'block';
}
// If you need to put ImageID into a hidden field for later form submit:
// document.querySelector('[name="ImageID"]').value = res.id;
} else {
alert(res.message || 'Upload failed.');
}
Bilder
JPEG, PNG
document.addEventListener("DOMContentLoaded", function () {
const cards = document.querySelectorAll(".uploadtrigger");
const fileInputs = document.querySelectorAll(".inputFileHidden");
if (cards && fileInputs) {
cards.forEach(card => {
card.addEventListener("click", function () {
const target = this.getAttribute("data-target");
const fileInput = document.getElementById(target);
fileInput.click();
});
});
}
});
Below is a small, *reusable* pattern that works for multiple upload widgets on the same page.
—
## 1) HTML (your wrapper + hidden input + progress + preview)
Add a hidden file input (id must match `data-target`) next to your label, plus tiny progress/preview holders:
<!-- ONE UPLOAD WIDGET -->
<label class="upload-box uploadtrigger" for="fileUpload" data-target="BilderIDInput" data-endpoint="/dashboard/uploadCompanyImage" data-fieldname="Bilder">
<div class="upload-left">
<img src="https://img.icons8.com/ios-filled/50/image--v1.png" alt="icon">
<span class="file-name" style="display:block;">Bilder</span>
</div>
<span class="file-types">JPEG, PNG</span>
</label>
<input type="file" id="BilderIDInput" class="inputFileHidden" name="Bilder" accept="image/*" style="display:none;">
<!-- Progress + preview (matched via data-for) -->
<div class="upload-ui" data-for="BilderIDInput" style="display:none;">
<div class="progress" style="height:10px;">
<div class="progress-bar" role="progressbar" style="width:0%;">0%</div>
</div>
<img class="upload-preview" src="" alt="" style="max-width:180px; display:none; margin-top:10px;">
<input type="hidden" name="BilderID" class="upload-image-id" value="">
</div>
<!-- CSRF once on page (or keep it inside your form) -->
<input type="hidden" id="SecurityID" value="$SecurityID">
> For **worker** uploads, just duplicate the block and change `data-endpoint="/dashboard/uploadWorkerImage"` and `data-fieldname="Image"`, with matching `id`/`data-for`.
—
## 2) JavaScript (click → file pick → AJAX with progress)
<script>
document.addEventListener("DOMContentLoaded", function () {
const securityID = document.getElementById('SecurityID')?.value || '';
// open file picker when clicking the label card
document.querySelectorAll(".uploadtrigger").forEach(card => {
card.addEventListener("click", function () {
const inputId = this.getAttribute("data-target");
const fileInput = document.getElementById(inputId);
if (fileInput) fileInput.click();
});
});
// when file selected, start upload with progress
document.querySelectorAll(".inputFileHidden").forEach(input => {
input.addEventListener("change", function (e) {
const file = this.files?.[0];
if (!file) return;
const inputId = this.id;
const uiWrap = document.querySelector(`.upload-ui[data-for="${inputId}"]`);
const progress = uiWrap?.querySelector('.progress');
const bar = uiWrap?.querySelector('.progress-bar');
const preview = uiWrap?.querySelector('.upload-preview');
const label = document.querySelector(`.uploadtrigger[data-target="${inputId}"] .file-name`);
const endpoint = document.querySelector(`.uploadtrigger[data-target="${inputId}"]`)?.getAttribute('data-endpoint');
const fieldName = document.querySelector(`.uploadtrigger[data-target="${inputId}"]`)?.getAttribute('data-fieldname') || this.name;
const hiddenId = uiWrap?.querySelector('.upload-image-id');
if (!endpoint) {
console.warn('No data-endpoint on uploadtrigger for', inputId);
return;
}
// show chosen filename
if (label) label.textContent = file.name;
// show basic preview immediately
if (preview) {
const reader = new FileReader();
reader.onload = ev => {
preview.src = ev.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
// show UI
if (uiWrap) uiWrap.style.display = 'block';
if (bar) { bar.style.width = '0%'; bar.textContent = '0%'; }
// build request
const formData = new FormData();
formData.append(fieldName, file); // e.g. "Bilder" or "Image"
if (securityID) formData.append('SecurityID', securityID);
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// progress
xhr.upload.onprogress = function (ev) {
if (ev.lengthComputable && bar) {
const pct = Math.round((ev.loaded / ev.total) * 100);
bar.style.width = pct + '%';
bar.textContent = pct + '%';
}
};
xhr.onload = function () {
try {
const res = JSON.parse(xhr.responseText || '{}');
if (xhr.status >= 200 && xhr.status < 300 && res.ok) {
// use canonical URL from server (published asset)
if (res.url && preview) preview.src = res.url;
if (res.id && hiddenId) hiddenId.value = res.id; // store ImageID for later submit
} else {
alert(res.message || 'Upload failed');
}
} catch (e) {
alert('Upload failed');
}
};
xhr.onerror = function () {
alert('Network error during upload');
};
xhr.send(formData);
});
});
});
</script>
—
## 3) SilverStripe controller actions (AJAX endpoints)
These use your existing helper `attachImageFromFiles($fieldName, $folder)` and return JSON.
Create two actions (routes are examples; wire them via `allowed_actions` + template routes).
use SilverStripeControlController;
use SilverStripeControlHTTPRequest;
use SilverStripeControlHTTPResponse;
use SilverStripeAssetsImage;
use SilverStripeSecuritySecurityToken;
class DashboardController extends Controller
{
private static $allowed_actions = [
'uploadCompanyImage' => '->canUpload',
'uploadWorkerImage' => '->canUpload',
];
protected function json($arr, $code = 200): HTTPResponse
{
$res = HTTPResponse::create(json_encode($arr), $code);
$res->addHeader('Content-Type', 'application/json; charset=utf-8');
return $res;
}
public function canUpload()
{
// add your own permission checks; example:
return true;
}
public function uploadCompanyImage(HTTPRequest $req): HTTPResponse
{
return $this->doUpload($req, 'Bilder', 'profiles', 'company'); // field name must match JS
}
public function uploadWorkerImage(HTTPRequest $req): HTTPResponse
{
return $this->doUpload($req, 'Image', 'profiles', 'worker'); // field name must match JS
}
protected function doUpload(HTTPRequest $req, string $fieldName, string $folder, string $type): HTTPResponse
{
// CSRF
$token = SecurityToken::inst();
if (!$token->checkRequest($req)) {
return $this->json(['ok' => false, 'message' => 'Invalid SecurityID'], 400);
}
// ensure there is a file
if (empty($_FILES[$fieldName]['tmp_name'])) {
return $this->json(['ok' => false, 'message' => 'No file uploaded'], 400);
}
try {
// your helper: stores the file as Image and returns Image object
if ($img = $this->attachImageFromFiles($fieldName, $folder)) {
if ($img instanceof Image) {
$img->publishSingle();
return $this->json([
'ok' => true,
'id' => (int)$img->ID,
'url' => $img->getURL(), // published URL
'type'=> $type,
]);
}
}
return $this->json(['ok' => false, 'message' => 'Upload failed'], 500);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'message' => $e->getMessage()], 500);
}
}
}
> If you still need to set `$companyData->ImageID` or `$worker->ImageID`, do that later when the full form is submitted — we’re already returning the `id` so you can drop it into a hidden “ (`.upload-image-id`) and save normally.
—
## 4) Notes / gotchas
– **`data-fieldname` must match** what your helper expects (`Bilder` or `Image`).
– **Multiple widgets**: duplicate the HTML block; the JS is generic and uses `data-target` / `data-for`.
– **SecurityID**: make sure the token you append is valid for this controller; keeping the hidden input *inside the same form render* is best.
– **Validation**: Add size/type checks in `doUpload()` if needed.
– **Error UI**: right now it’s an `alert()`. You can replace with a toast.
If you paste this in and give me your actual controller class name / routes, I can align the `data-endpoint` URLs and `allowed_actions` exactly.
1) **Name mismatch** — the key in `FormData.append(, file)` must match what the PHP side reads (and what your helper expects).
2) **Reading files wrong** — using `$_POST`/`$req->postVar()` instead of **`$_FILES` / `$req->getFiles()`**.
3) **Wrong Content-Type** — if you manually set `Content-Type: multipart/form-data` on XHR, the browser won’t add the boundary → PHP sees no file. (Don’t set it; let XHR do it.)
4) **PHP limits** — `upload_max_filesize` / `post_max_size` too small → file discarded → looks like “null”.
Below is a robust, copy-paste safe way to implement the upload endpoint **and** the helper so it works with your existing JS.
—
## 1) JS (make sure names match, and don’t set Content-Type)
// GOOD: name matches the server-side $fieldName
formData.append('Bilder', file); // for company
// or
formData.append('Image', file); // for worker
// DO NOT do xhr.setRequestHeader('Content-Type', 'multipart/form-data');
—
## 2) Controller endpoint (use `$req->getFiles()`)
use SilverStripeControlController;
use SilverStripeControlHTTPRequest;
use SilverStripeControlHTTPResponse;
use SilverStripeSecuritySecurityToken;
use SilverStripeAssetsImage;
class DashboardController extends Controller
{
private static $allowed_actions = [
'uploadCompanyImage' => '->canUpload',
'uploadWorkerImage' => '->canUpload',
];
public function canUpload() { return true; }
public function uploadCompanyImage(HTTPRequest $req): HTTPResponse
{
return $this->handleUpload($req, 'Bilder', 'profiles');
}
public function uploadWorkerImage(HTTPRequest $req): HTTPResponse
{
return $this->handleUpload($req, 'Image', 'profiles');
}
protected function handleUpload(HTTPRequest $req, string $fieldName, string $folder): HTTPResponse
{
// CSRF
if (!SecurityToken::inst()->checkRequest($req)) {
return $this->json(['ok' => false, 'message' => 'Invalid SecurityID'], 400);
}
// Make sure PHP actually has the file
$files = $req->getFiles(); // ✅ correct for files
if (empty($files[$fieldName]['tmp_name'])) {
// Helpful debug info in dev:
// error_log('FILES keys: ' . implode(',', array_keys($files ?: [])));
return $this->json(['ok' => false, 'message' => "No file on key '{$fieldName}'"], 400);
}
// Try to attach
$img = $this->attachImageFromFiles($files[$fieldName], $folder); // pass the FILES entry
if ($img instanceof Image && $img->isInDB()) {
$img->publishSingle();
return $this->json(['ok' => true, 'id' => (int)$img->ID, 'url' => $img->getURL()]);
}
return $this->json(['ok' => false, 'message' => 'attachImageFromFiles() returned null'], 500);
}
protected function json(array $data, int $code = 200): HTTPResponse
{
return HTTPResponse::create(json_encode($data), $code)
->addHeader('Content-Type', 'application/json; charset=utf-8');
}
}
—
## 3) Fix `attachImageFromFiles()` itself
If your helper currently reads `$_FILES[$fieldName]` internally, you can keep that, **or** make it accept the specific `$_FILES[…]` subarray we passed above (safer & testable):
use SilverStripeAssetsImage;
use SilverStripeAssetsUpload;
use SilverStripeAssetsUpload_Validator;
protected function attachImageFromFiles(array $fileArray, string $folder): ?Image
{
// Guard
if (empty($fileArray['tmp_name']) || !is_uploaded_file($fileArray['tmp_name'])) {
return null;
}
$image = Image::create();
$upload = Upload::create();
// Optional: tighten validation
/** @var Upload_Validator $validator */
$validator = $upload->getValidator();
$validator->setAllowedExtensions(['jpg','jpeg','png','gif']);
// $validator->setAllowedMaxFileSize(5 * 1024 * 1024); // 5MB
// Store under assets/{$folder}/
$result = $upload->loadIntoFile($fileArray, $image, $folder);
if (!$result) {
// In dev you can inspect $upload->getErrors()
// error_log('Upload errors: ' . implode('; ', $upload->getErrors()));
return null;
}
return $image; // caller will publish
}
> If you prefer the old signature `attachImageFromFiles(string $fieldName, string $folder)`, then inside it use:
>
> $files = Controller::curr()->getRequest()->getFiles();
> $fileArray = $files[$fieldName] ?? null;
>
> and continue as above.
—
## 4) Quick checklist if it still returns null
– **Key names line up**:
– JS: `formData.append('Bilder', file)` ⟷ PHP: `$files['Bilder']`
– or `Image` ⟷ `Image`
– **Don’t override `Content-Type`** on XHR.
– **Controller action is allowed** (`allowed_actions`) and reachable.
– **CSRF token valid** for that controller (same render cycle).
– **PHP limits**: set `upload_max_filesize` and `post_max_size` > your image size (e.g., `16M`).
– **Permissions / assets**: `assets/` writable; no filesystem errors.
– **Validation**: your `Upload_Validator` allows the file’s extension/size.
—
If you paste your current `attachImageFromFiles()` signature/body, I’ll align the exact version you’re using.
Bilder
(binary)
SecurityID
32b52d02fede8614f3b6a9350fc3fb2c35afb60e
– `Bilder`: the binary file
– `SecurityID`: CSRF token
So if `attachImageFromFiles()` still returns `null`, it’s almost always one of these on the **server**:
1) Reading the file from the wrong place (using `$_POST`/`$req->postVar()` instead of **`$req->getFiles()`**).
2) Name mismatch (helper expects `Image` but you send `Bilder`).
3) Upload validator rejects the file (extension/size), and `Upload::loadIntoFile()` returns `false`.
4) `assets/` not writable, or folder name incorrect.
5) CSRF fails (token not checked against this controller).
Below is a **known-good** pattern you can paste in to pinpoint the cause and fix it.
—
## 1) Controller endpoint (robust + debuggable)
use SilverStripeControlController;
use SilverStripeControlHTTPRequest;
use SilverStripeControlHTTPResponse;
use SilverStripeSecuritySecurityToken;
use SilverStripeAssetsImage;
use SilverStripeAssetsUpload;
class DashboardController extends Controller
{
private static $allowed_actions = [
'uploadCompanyImage' => '->canUpload',
];
public function canUpload() { return true; }
public function uploadCompanyImage(HTTPRequest $req): HTTPResponse
{
// 1) CSRF
if (!SecurityToken::inst()->checkRequest($req)) {
return $this->json(['ok' => false, 'message' => 'Invalid SecurityID'], 400);
}
// 2) Ensure PHP actually has the file on the expected key
$files = $req->getFiles(); // IMPORTANT
$key = 'Bilder'; // must match your FormData.append('Bilder', file)
if (empty($files[$key]['tmp_name'])) {
return $this->json([
'ok' => false,
'message' => "No file received on key '{$key}'",
'debug_keys' => array_keys($files ?: [])
], 400);
}
// 3) Try saving with a validator that logs errors
$image = Image::create();
$upload = Upload::create();
$validator = $upload->getValidator();
$validator->setAllowedExtensions(['jpg','jpeg','png','gif','webp']); // allow what you need
// $validator->setAllowedMaxFileSize(10 * 1024 * 1024); // 10MB if needed
$ok = $upload->loadIntoFile($files[$key], $image, 'profiles'); // folder w/o leading slash
if (!$ok) {
// If this returns false, validator or filesystem blocked it
return $this->json([
'ok' => false,
'message' => 'Upload failed',
'errors' => $upload->getErrors() // super helpful
], 400);
}
$image->publishSingle();
return $this->json([
'ok' => true,
'id' => (int) $image->ID,
'url' => $image->getURL()
]);
}
protected function json(array $data, int $code = 200): HTTPResponse
{
return HTTPResponse::create(json_encode($data), $code)
->addHeader('Content-Type', 'application/json; charset=utf-8');
}
}
### Why this helps
– Uses `$req->getFiles()` (not `postVars`).
– Checks the exact key (`Bilder`) you’re sending.
– Surfaces `Upload` validator errors (file too big, wrong type, etc.).
– Ensures folder name is correct (`'profiles'`, **no leading slash**).
—
## 2) If you want to keep your helper signature
If your current code is:
if ($img = $this->attachImageFromFiles('Bilder', 'profiles')) {
$img->publishSingle();
$companyData->ImageID = $img->ID;
}
Make sure the helper **reads from `$req->getFiles()` using that field name**, and logs upload errors:
use SilverStripeAssetsImage;
use SilverStripeAssetsUpload;
use SilverStripeControlController;
protected function attachImageFromFiles(string $fieldName, string $folder): ?Image
{
$req = Controller::curr()->getRequest();
$files = $req->getFiles();
if (empty($files[$fieldName]['tmp_name'])) {
return null; // nothing uploaded on that key
}
$image = Image::create();
$upload = Upload::create();
$upload->getValidator()->setAllowedExtensions(['jpg','jpeg','png','gif','webp']);
if (!$upload->loadIntoFile($files[$fieldName], $image, $folder)) {
// In dev, you can inspect these:
// error_log('Upload errors: ' . implode('; ', $upload->getErrors()));
return null;
}
return $image;
}
—
## 3) Quick client-side sanity checks
– In your XHR code, **do not** set `Content-Type` manually. Let the browser set `multipart/form-data; boundary=…`.
– Ensure the key matches: `formData.append('Bilder', file)`.
– Send the CSRF token that matches this controller render (you already are: `SecurityID`).
—
## 4) If it’s still null—flip on debug
Temporarily return the validator errors back to the UI (as above). The usual messages you’ll see:
– “File extension not allowed” → add to `setAllowedExtensions`.
– “File size exceeds limit” → bump `upload_max_filesize` and `post_max_size` in PHP **and** `setAllowedMaxFileSize`.
– “Cannot write to asset store” → filesystem/permissions for `assets/`.
– “No file received” → name mismatch; verify the key.
—
If you paste the JSON response from the endpoint with the `errors` array, I’ll tell you the exact next tweak (extension/size/folder/permissions).
private function attachImageFromFiles(string $field = 'Bilder', string $folder = 'profiles'): ?Image
{
if (empty($_FILES[$field]) || empty($_FILES[$field]['tmp_name'])) {
return null; // no file chosen
}
$upload = Upload::create();
$upload->getValidator()->setAllowedExtensions(['jpg','jpeg','png','webp']); // ✅
$image = Image::create();
if ($upload->loadIntoFile($_FILES[$field], $image, $folder)) {
return $image; // not published yet
}
// Optional: inspect $upload->getErrors()
return null;
}
Here’s the complete wiring that keeps your exact helper signature:
—
### 1) Keep your helper (with a small optional improvement)
use SilverStripeAssetsImage;
use SilverStripeAssetsUpload;
private function attachImageFromFiles(string $field = 'Bilder', string $folder = 'profiles'): ?Image
{
if (empty($_FILES[$field]) || empty($_FILES[$field]['tmp_name'])) {
return null; // no file chosen
}
$upload = Upload::create();
$upload->getValidator()
->setAllowedExtensions(['jpg','jpeg','png','webp']) // allow what you need
->setAllowedMaxFileSize(10 * 1024 * 1024); // e.g. 10 MB
$image = Image::create();
if ($upload->loadIntoFile($_FILES[$field], $image, $folder)) {
return $image; // not published yet
}
// Optional: log validator/fs errors while debugging
// error_log('Upload errors: ' . implode('; ', $upload->getErrors()));
return null;
}
—
### 2) Controller endpoint that uses your helper
use SilverStripeControlController;
use SilverStripeControlHTTPRequest;
use SilverStripeControlHTTPResponse;
use SilverStripeSecuritySecurityToken;
use SilverStripeAssetsImage;
class DashboardController extends Controller
{
private static $allowed_actions = [
'uploadCompanyImage' => '->canUpload',
'uploadWorkerImage' => '->canUpload',
];
public function canUpload() { return true; } // add your permission logic
protected function json(array $data, int $code = 200): HTTPResponse
{
return HTTPResponse::create(json_encode($data), $code)
->addHeader('Content-Type', 'application/json; charset=utf-8');
}
public function uploadCompanyImage(HTTPRequest $req): HTTPResponse
{
// CSRF
if (!SecurityToken::inst()->checkRequest($req)) {
return $this->json(['ok' => false, 'message' => 'Invalid SecurityID'], 400);
}
// IMPORTANT: make sure JS sends FormData key 'Bilder'
$img = $this->attachImageFromFiles('Bilder', 'profiles');
if (!$img instanceof Image) {
return $this->json(['ok' => false, 'message' => 'No/invalid file on key "Bilder"'], 400);
}
$img->publishSingle();
return $this->json([
'ok' => true,
'id' => (int)$img->ID,
'url' => $img->getURL(),
]);
}
public function uploadWorkerImage(HTTPRequest $req): HTTPResponse
{
if (!SecurityToken::inst()->checkRequest($req)) {
return $this->json(['ok' => false, 'message' => 'Invalid SecurityID'], 400);
}
// IMPORTANT: make sure JS sends FormData key 'Image'
$img = $this->attachImageFromFiles('Image', 'profiles');
if (!$img instanceof Image) {
return $this->json(['ok' => false, 'message' => 'No/invalid file on key "Image"'], 400);
}
$img->publishSingle();
return $this->json([
'ok' => true,
'id' => (int)$img->ID,
'url' => $img->getURL(),
]);
}
}
—
### 3) JS: send correct keys + progress bar (no manual Content-Type)
// For company image:
const fd = new FormData();
fd.append('Bilder', file); // <-- MUST match helper's $field
fd.append('SecurityID', document.getElementById('SecurityID').value);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/dashboard/uploadCompanyImage', true);
// DO NOT set Content-Type manually for FormData requests
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressBar.textContent = pct + '%';
}
};
xhr.onload = () => {
try {
const res = JSON.parse(xhr.responseText || '{}');
if (xhr.status >= 200 && xhr.status < 300 && res.ok) {
previewImg.src = res.url; // show published URL
hiddenImageIdInput.value = res.id; // store ID if needed for later save
} else {
alert(res.message || 'Upload failed');
}
} catch(e) {
alert('Upload failed');
}
};
xhr.send(fd);
> For the worker image, just change:
>
>
> fd.append('Image', file); // key must be "Image"
> xhr.open('POST', '/dashboard/uploadWorkerImage', true);
>
—
### 4) Common pitfalls to avoid
– **Key mismatch**: `FormData.append('Bilder', file)` ↔ helper called with `'Bilder'`. Same for `'Image'`.
– **Content-Type**: never set `multipart/form-data` manually; the browser must add the boundary.
– **PHP limits**: ensure `upload_max_filesize` and `post_max_size` > your file size (e.g., `16M`).
– **Assets permissions**: `assets/` must be writable; folder name in `loadIntoFile` should be `'profiles'` (no leading slash).
– **Extensions**: allow `webp` if you’ll upload it (already included above).
If you want, paste your XHR snippet, and I’ll align it to the exact endpoint + key so your helper returns the `Image` object every time.
Here are the main ways:
—
### 1. Using `setAttribute`
const el = document.getElementById('myElement');
el.setAttribute('data-status', 'active');
Resulting HTML:
<div id="myElement" data-status="active"></div>
—
### 2. Using the `dataset` property (recommended)
const el = document.getElementById('myElement');
// set
el.dataset.status = 'active'; // → data-status="active"
el.dataset.userId = '123'; // → data-user-id="123"
// read
console.log(el.dataset.status); // "active"
console.log(el.dataset.userId); // "123"
👉 Note: `dataset` converts dashed names to camelCase (`data-user-id` → `dataset.userId`).
—
### 3. Example with dynamic values
document.querySelectorAll('.uploadtrigger').forEach(trigger => {
trigger.dataset.endpoint = '/dashboard/uploadWorkerImage';
trigger.dataset.fieldname = 'Image';
});
Resulting HTML:
<label class="uploadtrigger" data-endpoint="/dashboard/uploadWorkerImage" data-fieldname="Image"></label>
—
✅ Use `element.dataset` when working in JS — it’s cleaner.
✅ Use `setAttribute` if you want to set the attribute name dynamically and don’t want to worry about camelCase conversion.
Do you want me to show you how to set a `data-*` attribute dynamically **based on a variable** (e.g., `data-target="…"` taken from JS code)?
### HTML (repeat this block for each image field)
<div class="upload-widget">
<!-- trigger -->
<label class="upload-box uploadtrigger"
data-endpoint="/dashboard/uploadCompanyImage"
data-fieldname="Bilder">
<div class="upload-left">
<img src="https://img.icons8.com/ios-filled/50/image--v1.png" alt="icon">
<span class="file-name">Bilder</span>
</div>
<span class="file-types">JPEG, PNG, WEBP</span>
</label>
<!-- hidden input -->
<input type="file" class="inputFileHidden" accept="image/*" style="display:none;">
<!-- per-widget UI -->
<div class="upload-ui" style="display:none;">
<div class="progress" style="height:10px;">
<div class="progress-bar" role="progressbar" style="width:0%">0%</div>
</div>
<img class="upload-preview" src="" alt="" style="max-width:180px; display:none; margin-top:10px;">
<input type="hidden" class="upload-image-id" name="BilderID" value="">
<button type="button" class="btn-cancel" style="display:none;">Cancel</button>
</div>
</div>
<!-- Put this once on the page (or inside your form render) -->
<input type="hidden" id="SecurityID" value="$SecurityID">
> For other fields (e.g. worker image), duplicate the block and change `data-endpoint` and `name="…ID"` as needed (e.g. `data-fieldname="Image"`, hidden name `ImageID`, etc.).
### JS (scoped per widget = separate progress bars)
<script>
document.addEventListener('DOMContentLoaded', function () {
const securityID = document.getElementById('SecurityID')?.value || '';
// Initialize all widgets
document.querySelectorAll('.upload-widget').forEach(initUploadWidget);
function initUploadWidget(widget) {
const trigger = widget.querySelector('.uploadtrigger');
const fileInput = widget.querySelector('.inputFileHidden');
const ui = widget.querySelector('.upload-ui');
const bar = widget.querySelector('.progress-bar');
const preview = widget.querySelector('.upload-preview');
const hiddenId = widget.querySelector('.upload-image-id');
const fileName = widget.querySelector('.file-name');
const cancelBtn = widget.querySelector('.btn-cancel');
let xhr = null; // each widget keeps its own XHR
// Open picker
trigger.addEventListener('click', () => fileInput.click());
// On choose -> start upload for THIS widget
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) return;
// Show filename + local preview
if (fileName) fileName.textContent = file.name;
if (preview) {
const reader = new FileReader();
reader.onload = ev => { preview.src = ev.target.result; preview.style.display = 'block'; };
reader.readAsDataURL(file);
}
// Show UI & reset bar
ui.style.display = 'block';
bar.style.width = '0%';
bar.textContent = '0%';
cancelBtn.style.display = 'inline-block';
// Build request (keys from data-attrs)
const endpoint = trigger.dataset.endpoint; // e.g. /dashboard/uploadCompanyImage
const fieldName = trigger.dataset.fieldname; // e.g. Bilder or Image
const fd = new FormData();
fd.append(fieldName, file);
if (securityID) fd.append('SecurityID', securityID);
xhr = new XMLHttpRequest();
xhr.open('POST', endpoint, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
bar.style.width = pct + '%';
bar.textContent = pct + '%';
};
xhr.onload = () => {
cancelBtn.style.display = 'none';
let res = {};
try { res = JSON.parse(xhr.responseText || '{}'); } catch {}
if (xhr.status >= 200 && xhr.status < 300 && res.ok) {
// swap to canonical URL from server (published asset)
if (res.url && preview) preview.src = res.url;
if (res.id && hiddenId) hiddenId.value = res.id;
} else {
alert(res.message || 'Upload failed');
// reset UI minimally
bar.style.width = '0%';
bar.textContent = '0%';
}
xhr = null;
};
xhr.onerror = () => {
cancelBtn.style.display = 'none';
alert('Network error');
xhr = null;
};
// Cancel just this widget's upload
cancelBtn.onclick = () => {
if (xhr) xhr.abort();
cancelBtn.style.display = 'none';
bar.style.width = '0%';
bar.textContent = '0%';
};
xhr.send(fd);
});
}
});
</script>
### Why this works
– Each `.upload-widget` has its **own** elements and keeps its **own `xhr`** variable.
– Progress events update the **bar inside that widget only**.
– You can have any number of upload widgets on the page; they won’t clash.
– `data-endpoint` + `data-fieldname` let you reuse the same JS for company/worker images.
**Gotchas**
– Don’t set the `Content-Type` header manually for `FormData`.
– Ensure your controller action whitelists the route and checks the `SecurityID`.
– Your PHP helper must allow the extension you upload (`jpg`, `jpeg`, `png`, `webp`), and `assets/` must be writable.
If you paste your exact endpoints (`uploadCompanyImage`, `uploadWorkerImage`) I can prefill the `data-endpoint` values for each widget block.
if (preview) {
const reader = new FileReader();
reader.onload = ev => {
preview.src = ev.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
file may be image or pdf or video show we are using same class and want replace src when type is image and for other file type replcase href
– If file is an **image** → show thumbnail (“ with `src`).
– If file is a **PDF/video/other** → show a clickable **link** (“ with `href`) instead of forcing into an “.
Here’s a safe pattern you can drop into your loop where you currently handle `FileReader`:
// Determine file type
const mime = file.type; // e.g. "image/png", "application/pdf", "video/mp4"
if (mime.startsWith('image/')) {
// Show as <img>
const reader = new FileReader();
reader.onload = ev => {
preview.tagName === 'IMG' ? preview.src = ev.target.result : null;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
// For non-images: use <a> link
const reader = new FileReader();
reader.onload = ev => {
// If you want to reuse same preview element, replace it with a link
if (preview.tagName === 'IMG') {
// swap element dynamically
const link = document.createElement('a');
link.className = preview.className; // keep styling
link.style.display = 'block';
link.target = '_blank';
preview.replaceWith(link);
preview = link; // update reference
}
preview.href = ev.target.result;
preview.textContent = file.name; // link label
preview.style.display = 'block';
};
reader.readAsDataURL(file); // for PDF/video, this creates a data: URL too
}
—
### 🔑 Tips
– `file.type` is the MIME type (`image/png`, `application/pdf`, `video/mp4` etc.).
– `startsWith('image/')` is a reliable way to test for images.
– For other types, creating an `filename` works: browser opens it in a new tab (PDFs/videos included).
– If you want to keep the DOM simpler, you can pre-place **both** an “ and an “ in your HTML, and show/hide them based on file type.
—
### Example HTML for dual preview
<img class="upload-preview preview-img" style="display:none; max-width:150px;">
<a class="upload-preview preview-link" style="display:none;" target="_blank"></a>
### Example JS
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = ev => {
widget.querySelector('.preview-img').src = ev.target.result;
widget.querySelector('.preview-img').style.display = 'block';
widget.querySelector('.preview-link').style.display = 'none';
};
reader.readAsDataURL(file);
} else {
const reader = new FileReader();
reader.onload = ev => {
widget.querySelector('.preview-link').href = ev.target.result;
widget.querySelector('.preview-link').textContent = file.name;
widget.querySelector('.preview-link').style.display = 'block';
widget.querySelector('.preview-img').style.display = 'none';
};
reader.readAsDataURL(file);
}
—
👉 Do you want me to adapt this so you **reuse the same preview element** (replace “ with “ dynamically), or would you rather have **two dedicated elements** (one for image, one for link) and just toggle visibility?
Instead, just **treat images as `href + background-image` (or inline “ inside “)**, and for other files just set `href` with the file name text.
Here’s a clean approach:
—
### HTML (your existing element)
<a class="upload-preview" href="#" target="_blank" style="display:none;"></a>
—
### JS
const preview = widget.querySelector('.upload-preview');
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = ev => {
preview.href = ev.target.result;
preview.innerHTML = `<img src="${ev.target.result}"
alt="${file.name}"
style="max-width:150px; display:block;">`;
preview.style.display = 'inline-block';
};
reader.readAsDataURL(file);
} else {
const reader = new FileReader();
reader.onload = ev => {
preview.href = ev.target.result;
preview.textContent = file.name; // just show filename text
preview.style.display = 'inline-block';
};
reader.readAsDataURL(file);
}
—
### 🔑 How it works
– You always keep one “.
– For **images**:
– `href` = full-size image (opens on click).
– `innerHTML` = inline “ thumbnail.
– For **other files** (PDF, video, doc, etc.):
– `href` = the file’s data URL (or later, the server URL).
– `textContent` = just show the filename.
—
👉 Do you want me to also update this so once the upload is complete, the “ points to the **server URL** (published asset), replacing the local `data:` preview?
That way, if the user selects the same file again, the `change` event will still fire.
Here’s how to add it into your `xhr.onload` (or `fetch().then(…)`) block:
xhr.onload = () => {
cancelBtn.style.display = 'none';
let res = {};
try { res = JSON.parse(xhr.responseText || '{}'); } catch {}
if (xhr.status >= 200 && xhr.status < 300 && res.ok) {
// ✅ Show server URL
preview.href = res.url;
if (res.url.match(/.(jpe?g|png|gif|webp)$/i)) {
preview.innerHTML = `<img src="${res.url}" alt="${file.name}" style="max-width:150px; display:block;">`;
} else {
preview.textContent = file.name;
}
preview.style.display = 'inline-block';
// ✅ Clear input so the same file can be re-selected later
fileInput.value = "";
} else {
alert(res.message || 'Upload failed');
}
xhr = null;
};
—
### 🔑 Key point
– `fileInput.value = ""` resets the “.
– This is safe, and doesn’t affect your hidden “ where you keep the server’s ImageID.
—
👉 Do you want me to give you the **final integrated snippet** with:
1. Progress bar
2. Preview as “ (image vs non-image)
3. Clearing the file input on success
…all together so you can drop it in?