Concrete CMS form update
August 23, 2025 · AI Conversations
– Uses the built‑in CSRF security token (`Token`)
– Keeps your route with `$view->action('submit')`
– Avoids re‑generating the slug on validation errors
– Uses Concrete 9 dashboard styles (`ccm-ui`, cards, etc.)
### View: `/single_pages/…/add.php` (or your package dashboard view)
“`php
<script>
function deleteFeed(id) {
ConcreteAlert.confirm(
'<?= t("Are you sure you want to delete this feed? This will delete the feed and all its data.") ?>',
function() {
$.ajax({
url: '/shopping_feed/api/delete/' + id,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
success: function (data) {
if (data.success) {
ConcreteAlert.notify({
message: '<?= t("Feed deleted successfully!") ?>',
type: 'success'
});
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
},
error: function () {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
});
}
);
}
function cloneFeed(id) {
ConcreteAlert.confirm(
'<?= t("Duplicate feed?") ?>',
function() {
$.ajax({
url: '/shopping_feed/api/clone/' + id,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
success: function (data) {
if (data.success) {
ConcreteAlert.notify({
message: '<?= t("Feed cloned successfully!") ?>',
type: 'success'
});
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
},
error: function () {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
});
}
);
}
function copyFeed(feedurl) {
navigator.clipboard.writeText(feedurl).then(function() {
ConcreteAlert.notify({
message: '<?= t("Feed URL copied to clipboard!") ?>',
type: 'success'
});
}).catch(function() {
// Fallback for older browsers
var $tempInput = $('<input>');
$('body').append($tempInput);
$tempInput.val(feedurl).select();
try {
var successful = document.execCommand('copy');
$tempInput.remove();
if (successful) {
ConcreteAlert.notify({
message: '<?= t("Feed URL copied to clipboard!") ?>',
type: 'success'
});
} else {
ConcreteAlert.notify({
message: '<?= t("Failed to copy URL") ?>',
type: 'error'
});
}
} catch (err) {
$tempInput.remove();
ConcreteAlert.notify({
message: '<?= t("Failed to copy URL") ?>',
type: 'error'
});
}
});
}
</script>
I want to use ccm-dashboard-header-search div to add my search form so that it will come right side of ccm-dashboard-breadcrumb as all default cms pages
### 1) View: put this **at the very top** of your page (above `.ccm-dashboard-content`)
<?php
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCoreViewView;
use ConcreteCoreSupportFacadeApplication;
$view = View::getInstance();
$app = Application::getFacadeApplication();
/** Preserve current filters (GET) */
$searchQuery = isset($searchQuery) ? $searchQuery : (string)($this->request->query->get('q') ?? '');
$automation = isset($automation) ? $automation : (string)($this->request->query->get('automation') ?? '');
$limit = isset($limit) ? (int)$limit : (int)($this->request->query->get('limit') ?? 10);
?>
<div class="ccm-dashboard-header-search">
<form method="get" action="<?= $view->action('view') ?>"
class="row row-cols-auto g-0 align-items-center">
<div class="ccm-header-search-form input-group">
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button"
id="feedFilters" data-bs-toggle="dropdown" aria-expanded="false">
<?= t('Filters') ?>
</button>
<div class="dropdown-menu p-3" aria-labelledby="feedFilters" style="min-width:320px">
<div class="mb-2">
<label class="form-label"><?= t('Automation') ?></label>
<select class="form-select" name="automation">
<option value="" <?= $automation==='' ? 'selected' : '' ?>><?= t('All') ?></option>
<option value="1" <?= $automation==='1' ? 'selected' : '' ?>><?= t('Enabled') ?></option>
<option value="0" <?= $automation==='0' ? 'selected' : '' ?>><?= t('Disabled') ?></option>
</select>
</div>
<div class="mb-2">
<label class="form-label"><?= t('Items per page') ?></label>
<select class="form-select" name="limit">
<?php foreach ([10,25,50,100] as $opt) { ?>
<option value="<?= $opt ?>" <?= ($limit==$opt)?'selected':''; ?>><?= $opt ?></option>
<?php } ?>
</select>
</div>
<div class="text-end">
<button type="submit" class="btn btn-secondary"><?= t('Apply') ?></button>
</div>
</div>
</div>
<input class="form-control" type="text" name="q"
value="<?= h($searchQuery) ?>" placeholder="<?= t('Search feeds…') ?>" autocomplete="off">
<button class="btn btn-secondary" type="submit" aria-label="<?= t('Search') ?>">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
> Keep your existing `.ccm-dashboard-content` block exactly as is below this. The dashboard theme will float this search bar to the right of the breadcrumb automatically.
—
### 2) Controller: read filters from GET and pass to the view
public function view($limitArg = 10, $page = 1)
{
$req = $this->request;
$q = trim((string)$req->query->get('q', ''));
$automation = $req->query->get('automation'); // '', '1', '0'
$limit = (int)($req->query->get('limit', $limitArg ?: 10));
// Do your filtered query here (name/slug LIKE :q, automation flag, limit & page)
[$feeds, $totalPages] = $this->feedService->searchFeeds($q, $automation, $limit, (int)$page);
$this->set('feeds', $feeds);
$this->set('totalPages', $totalPages);
$this->set('currentPage', (int)$page);
$this->set('limit', $limit);
$this->set('searchQuery', $q);
$this->set('automation', $automation);
}
—
### 3) Keep filters in pagination links
<?php
$query = http_build_query([
'q' => $searchQuery,
'automation' => $automation,
'limit' => $limit,
]);
?>
<nav class="mt-4">
<ul class="pagination justify-content-center">
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<li class="page-item <?= ($i == $currentPage) ? 'active' : '' ?>">
<a class="page-link" href="<?= $this->action('view', $limit, $i) . ($query ? ('?' . $query) : '') ?>">
<?= $i ?>
</a>
</li>
<?php endfor; ?>
</ul>
</nav>
—
### 4) (Optional) Remove your old “per page” dropdown
Since the **Items per page** moved into the Filters dropdown, you can delete the old per-page button group from your header buttons to keep the header clean.
That’s it — this mirrors Concrete CMS’s dashboard header search pattern and will sit exactly where you want it.
<script>
function deleteFeed(id) {
ConcreteAlert.confirm(
'<?= t("Are you sure you want to delete this feed? This will delete the feed and all its data.") ?>',
function() {
$.ajax({
url: '/shopping_feed/api/delete/' + id,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
success: function (data) {
if (data.success) {
ConcreteAlert.notify({
message: '<?= t("Feed deleted successfully!") ?>',
type: 'success'
});
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
},
error: function () {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
});
}
);
}
function cloneFeed(id) {
ConcreteAlert.confirm(
'<?= t("Duplicate feed?") ?>',
function() {
$.ajax({
url: '/shopping_feed/api/clone/' + id,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
success: function (data) {
if (data.success) {
ConcreteAlert.notify({
message: '<?= t("Feed cloned successfully!") ?>',
type: 'success'
});
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
},
error: function () {
ConcreteAlert.notify({
message: '<?= t("An error occurred while processing your request.") ?>',
type: 'error'
});
}
});
}
);
}
function copyFeed(feedurl) {
navigator.clipboard.writeText(feedurl).then(function() {
ConcreteAlert.notify({
message: '<?= t("Feed URL copied to clipboard!") ?>',
type: 'success'
});
}).catch(function() {
// Fallback for older browsers
var $tempInput = $('<input>');
$('body').append($tempInput);
$tempInput.val(feedurl).select();
try {
var successful = document.execCommand('copy');
$tempInput.remove();
if (successful) {
ConcreteAlert.notify({
message: '<?= t("Feed URL copied to clipboard!") ?>',
type: 'success'
});
} else {
ConcreteAlert.notify({
message: '<?= t("Failed to copy URL") ?>',
type: 'error'
});
}
} catch (err) {
$tempInput.remove();
ConcreteAlert.notify({
message: '<?= t("Failed to copy URL") ?>',
type: 'error'
});
}
});
}
</script>
BUt How we place search in cms page header next to breadcrumb in
Drop this **at the very top of your view**, before `…`:
<?php
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCoreViewView;
$view = View::getInstance();
// read current query if you want it sticky
$searchQuery = (string)($this->request->query->get('q') ?? '');
?>
<!-- HEADER SEARCH (must be before .ccm-dashboard-content) -->
<div class="ccm-dashboard-header-search">
<div class="ccm-header-search-form ccm-ui" data-header="shopping-feed">
<form method="get" action="<?= $view->action('view') ?>" class="row row-cols-auto g-0 align-items-center">
<div class="ccm-header-search-form-input input-group">
<input class="form-control" type="text" name="q"
value="<?= h($searchQuery) ?>" placeholder="<?= t('Search feeds…') ?>" autocomplete="off">
<button class="btn btn-secondary" type="submit" aria-label="<?= t('Search') ?>">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
</div>
Then keep the rest of your page (including your existing `.ccm-dashboard-header-buttons`) as-is below:
<div class="ccm-dashboard-content">
<!-- your cards/table/etc -->
</div>
### Why yours didn’t show up
– If you put `ccm-dashboard-header-search` **inside** `.ccm-dashboard-content`, the theme won’t hoist it to the breadcrumb area.
– It also expects the inner wrapper classes:
– `ccm-header-search-form ccm-ui`
– `ccm-header-search-form-input` around your input group
### If you absolutely must keep it in the content (fallback)
You can move it up with a tiny script:
<div id="sfmHeaderSearch" class="d-none">
<div class="ccm-header-search-form ccm-ui" data-header="shopping-feed">
<form method="get" action="<?= $view->action('view') ?>" class="row row-cols-auto g-0 align-items-center">
<div class="ccm-header-search-form-input input-group">
<input class="form-control" type="text" name="q" placeholder="<?= t('Search feeds…') ?>">
<button class="btn btn-secondary" type="submit"><i class="fas fa-search"></i></button>
</div>
</form>
</div>
</div>
<script>
$(function () {
var html = $('#sfmHeaderSearch').html();
$('.ccm-dashboard-header .ccm-dashboard-header-search').html(html);
});
</script>
But the **preferred** (and cleanest) fix is the first one: place the search block **above** `.ccm-dashboard-content` with the exact class structure.