Concrete CMS CRUD Package
August 7, 2025 · AI Conversations
1. **Package setup**
2. **Database schema creation**
3. **Single page controller for CRUD**
4. **Basic view for listing and form**
—
## ✅ 1. Package Directory & Controller
Create your package folder in `packages/custom_crud`.
### `packages/custom_crud/controller.php`
<?php
namespace ConcretePackageCustomCrud;
use ConcreteCorePackagePackage;
use ConcreteCorePageSingle;
class Controller extends Package
{
protected $pkgHandle = 'custom_crud';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
public function getPackageName()
{
return t('Custom CRUD Package');
}
public function getPackageDescription()
{
return t('A package to perform basic CRUD operations.');
}
public function install()
{
$pkg = parent::install();
// Install single pages
Single::add('/dashboard/crud', $pkg);
Single::add('/dashboard/crud/add', $pkg);
Single::add('/dashboard/crud/edit', $pkg);
// Run database schema
$this->installDatabase();
}
}
—
## ✅ 2. Database XML
### `packages/custom_crud/db.xml`
<?xml version="1.0"?>
<schema>
<table name="Items">
<field name="id" type="I" size="11">
<key />
<autoincrement />
</field>
<field name="name" type="C" size="255" />
<field name="description" type="X2" />
</table>
</schema>
—
## ✅ 3. Single Page Controller
Create `packages/custom_crud/controllers/single_page/dashboard/crud.php`:
<?php
namespace ConcretePackageCustomCrudControllerSinglePageDashboard;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreSupportFacadeApplication;
use ConcreteCoreDatabaseConnectionConnection;
class Crud extends DashboardPageController
{
public function view()
{
$db = $this->app->make(Connection::class);
$items = $db->fetchAll("SELECT * FROM Items");
$this->set('items', $items);
}
}
For `add.php` and `edit.php` create similar controllers in:
– `dashboard/crud/add.php`
– `dashboard/crud/edit.php`
—
## ✅ 4. Views
Create `packages/custom_crud/single_pages/dashboard/crud/view.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Item List</h2>
<a href="<?= URL::to('/dashboard/crud/add') ?>" class="btn btn-primary mb-3">Add New Item</a>
<table class="table">
<thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Action</th></tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td><?= $item['id'] ?></td>
<td><?= h($item['name']) ?></td>
<td><?= h($item['description']) ?></td>
<td>
<a href="<?= URL::to('/dashboard/crud/edit', $item['id']) ?>" class="btn btn-sm btn-secondary">Edit</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
Then create `add.php` and `edit.php` views similarly with a form and handling POST logic in respective controller actions.
—
## ✅ 5. Install the Package
Run in your terminal:
cd /path/to/concrete
./vendor/bin/concrete5 c5:package-install custom_crud
—
Would you like me to also provide full code for `add.php`, `edit.php`, and their controllers with save logic?
Here’s a complete setup for saving data when submitting a form in `/dashboard/crud/add`.
—
## ✅ 1. Controller: `add.php`
### Path: `packages/custom_crud/controllers/single_page/dashboard/crud/add.php`
<?php
namespace ConcretePackageCustomCrudControllerSinglePageDashboardCrud;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreHttpRequest;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreRoutingRedirect;
class Add extends DashboardPageController
{
public function view()
{
// Just show the form
}
public function submit()
{
$request = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$name = $request->request->get('name');
$description = $request->request->get('description');
if ($name && $description) {
$db->insert('Items', [
'name' => $name,
'description' => $description,
]);
$this->flash('success', 'Item added successfully.');
return Redirect::to('/dashboard/crud');
} else {
$this->set('error', 'All fields are required.');
}
}
}
—
## ✅ 2. View: `add.php`
### Path: `packages/custom_crud/single_pages/dashboard/crud/add.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Add New Item</h2>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endif; ?>
<form method="post" action="<?= $view->action('submit') ?>">
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea name="description" class="form-control" rows="5" required></textarea>
</div>
<button type="submit" class="btn btn-success mt-2">Save</button>
</form>
—
## ✅ 3. Table Structure Reference (from `db.xml`)
<table name="Items">
<field name="id" type="I" size="11">
<key />
<autoincrement />
</field>
<field name="name" type="C" size="255" />
<field name="description" type="X2" />
</table>
—
Let me know if you want edit/delete logic as well.
– Package installation
– DB schema
– Add, Edit, View
– Full controller + views
—
## 📁 Folder Structure
packages/
└── custom_crud/
├── controller.php
├── db.xml
├── controllers/
│ └── single_page/
│ └── dashboard/
│ └── crud/
│ ├── add.php
│ └── edit.php
└── single_pages/
└── dashboard/
└── crud/
├── view.php
├── add.php
└── edit.php
—
## 📄 `controller.php`
<?php
namespace ConcretePackageCustomCrud;
use ConcreteCorePackagePackage;
use ConcreteCorePageSingle;
class Controller extends Package
{
protected $pkgHandle = 'custom_crud';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
public function getPackageName()
{
return t('Custom CRUD Package');
}
public function getPackageDescription()
{
return t('Basic CRUD operations in dashboard.');
}
public function install()
{
$pkg = parent::install();
Single::add('/dashboard/crud', $pkg);
Single::add('/dashboard/crud/add', $pkg);
Single::add('/dashboard/crud/edit', $pkg);
$this->installDatabase();
}
}
—
## 📄 `db.xml`
<?xml version="1.0"?>
<schema>
<table name="Items">
<field name="id" type="I">
<key />
<autoincrement />
</field>
<field name="name" type="C" size="255" />
<field name="description" type="X2" />
</table>
</schema>
—
## 📄 `controllers/single_page/dashboard/crud/add.php`
<?php
namespace ConcretePackageCustomCrudControllerSinglePageDashboardCrud;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreHttpRequest;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreRoutingRedirect;
class Add extends DashboardPageController
{
public function view() {}
public function submit()
{
$req = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$name = $req->request->get('name');
$desc = $req->request->get('description');
if ($name && $desc) {
$db->insert('Items', ['name' => $name, 'description' => $desc]);
$this->flash('success', 'Item added successfully.');
return Redirect::to('/dashboard/crud');
}
$this->set('error', 'Both fields are required.');
}
}
—
## 📄 `controllers/single_page/dashboard/crud/edit.php`
<?php
namespace ConcretePackageCustomCrudControllerSinglePageDashboardCrud;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreHttpRequest;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreRoutingRedirect;
class Edit extends DashboardPageController
{
public function view($id = null)
{
$db = $this->app->make(Connection::class);
$item = $db->fetchAssociative('SELECT * FROM Items WHERE id = ?', [$id]);
if (!$item) {
$this->flash('error', 'Item not found.');
return $this->redirect('/dashboard/crud');
}
$this->set('item', $item);
}
public function submit($id)
{
$req = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$name = $req->request->get('name');
$desc = $req->request->get('description');
if ($name && $desc) {
$db->update('Items', ['name' => $name, 'description' => $desc], ['id' => $id]);
$this->flash('success', 'Item updated.');
return Redirect::to('/dashboard/crud');
}
$this->set('error', 'Both fields are required.');
$this->view($id);
}
}
—
## 📄 `single_pages/dashboard/crud/view.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Items</h2>
<a class="btn btn-primary mb-3" href="<?= URL::to('/dashboard/crud/add') ?>">Add New</a>
<?php if (isset($success)) : ?>
<div class="alert alert-success"><?= $success ?></div>
<?php endif; ?>
<table class="table table-striped">
<thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Actions</th></tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td><?= $item['id'] ?></td>
<td><?= h($item['name']) ?></td>
<td><?= h($item['description']) ?></td>
<td>
<a href="<?= URL::to('/dashboard/crud/edit', $item['id']) ?>" class="btn btn-sm btn-secondary">Edit</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
—
## 📄 `single_pages/dashboard/crud/add.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Add Item</h2>
<?php if (isset($error)) : ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endif; ?>
<form method="post" action="<?= $view->action('submit') ?>">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" class="form-control" rows="5" required></textarea>
</div>
<button class="btn btn-success mt-2" type="submit">Save</button>
</form>
—
## 📄 `single_pages/dashboard/crud/edit.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Edit Item</h2>
<?php if (isset($error)) : ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endif; ?>
<form method="post" action="<?= $view->action('submit', $item['id']) ?>">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="<?= h($item['name']) ?>" class="form-control" required>
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" class="form-control" rows="5" required><?= h($item['description']) ?></textarea>
</div>
<button class="btn btn-primary mt-2" type="submit">Update</button>
</form>
—
## ✅ Final Step: Install Package
Run from project root:
./vendor/bin/concrete5 c5:package-install custom_crud
—
Let me know if you want:
– Delete functionality
– Pagination or search
– Permissions or validation helpers
– listing items (`view`)
– showing a form (`add`)
– submitting a form (`submit`)
– editing items (`edit`, `update`)
### ✅ Benefits:
– Keeps things centralized
– Less duplication
– Easier routing
—
## ✅ Step-by-Step: One Controller, Multiple Actions
### 📁 Structure
/packages/custom_crud/
├── controller.php
├── db.xml
├── controllers/single_page/dashboard/crud.php
└── single_pages/dashboard/crud/
├── view.php
└── form.php
—
## 📄 `controllers/single_page/dashboard/crud.php`
<?php
namespace ConcretePackageCustomCrudControllerSinglePageDashboard;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpRequest;
use ConcreteCoreRoutingRedirect;
class Crud extends DashboardPageController
{
public function view()
{
$db = $this->app->make(Connection::class);
$items = $db->fetchAll('SELECT * FROM Items ORDER BY id DESC');
$this->set('items', $items);
}
public function add()
{
$this->set('action', 'add');
$this->set('item', ['name' => '', 'description' => '']);
}
public function submit()
{
$req = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$name = $req->request->get('name');
$desc = $req->request->get('description');
$id = $req->request->get('id'); // optional
if ($name && $desc) {
if ($id) {
$db->update('Items', ['name' => $name, 'description' => $desc], ['id' => $id]);
$this->flash('success', 'Item updated.');
} else {
$db->insert('Items', ['name' => $name, 'description' => $desc]);
$this->flash('success', 'Item added.');
}
return Redirect::to('/dashboard/crud');
}
$this->set('error', 'Both fields are required.');
$this->set('action', $id ? 'edit' : 'add');
$this->set('item', ['id' => $id, 'name' => $name, 'description' => $desc]);
$this->render('/dashboard/crud/form');
}
public function edit($id = null)
{
$db = $this->app->make(Connection::class);
$item = $db->fetchAssociative('SELECT * FROM Items WHERE id = ?', [$id]);
if (!$item) {
$this->flash('error', 'Item not found.');
return $this->redirect('/dashboard/crud');
}
$this->set('action', 'edit');
$this->set('item', $item);
$this->render('/dashboard/crud/form');
}
}
—
## 📄 `single_pages/dashboard/crud/view.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Items</h2>
<a class="btn btn-primary mb-3" href="<?= URL::to('/dashboard/crud/add') ?>">Add New</a>
<table class="table table-striped">
<thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Actions</th></tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td><?= $item['id'] ?></td>
<td><?= h($item['name']) ?></td>
<td><?= h($item['description']) ?></td>
<td>
<a href="<?= URL::to('/dashboard/crud/edit', $item['id']) ?>" class="btn btn-sm btn-secondary">Edit</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
—
## 📄 `single_pages/dashboard/crud/form.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2><?= $action === 'edit' ? 'Edit' : 'Add' ?> Item</h2>
<?php if (isset($error)) : ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endif; ?>
<form method="post" action="<?= $view->action('submit') ?>">
<?php if (!empty($item['id'])): ?>
<input type="hidden" name="id" value="<?= $item['id'] ?>" />
<?php endif; ?>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="<?= h($item['name']) ?>" class="form-control" required>
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" class="form-control" rows="5" required><?= h($item['description']) ?></textarea>
</div>
<button type="submit" class="btn btn-success mt-2"><?= $action === 'edit' ? 'Update' : 'Save' ?></button>
</form>
—
## ✅ Summary
| URL | Purpose |
|————————–|—————|
| `/dashboard/crud` | View all items |
| `/dashboard/crud/add` | Show add form |
| `/dashboard/crud/edit/1` | Show edit form |
| `/dashboard/crud/submit` | Handle both add & update |
—
Want to add **delete functionality** or **AJAX support** next?
– ✅ **Single controller for all CRUD actions**
– ✅ **Route-based handling (create/edit/update/delete)**
– ✅ **Dashboard page setup for views like feed list, add/edit**
– ✅ **DB table creation for `shoppingfeedconfiguration` and `shoppingfeedlog`**
—
### ✅ Adjusted Full Code for `controller.php`
<?php
namespace ConcretePackageShoppingFeedManager;
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCorePackagePackage;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCorePagePage;
use ConcreteCorePageSingle;
use Route;
class Controller extends Package
{
protected $pkgHandle = 'shopping_feed_manager';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0';
public function getPackageDescription()
{
return t("Google Merchant Shopping Feed package for ConcreteCMS");
}
public function getPackageName()
{
return t("Shopping Feed Manager");
}
public function install()
{
$pkg = parent::install();
// Create dashboard pages (only those needed as "views")
$pages = [
'/dashboard/shopping_feed' => ['Shopping Feed', 'Shopping Feed Tools'],
'/dashboard/shopping_feed/feeds' => ['Manage Feeds', 'List and manage feeds'],
'/dashboard/shopping_feed/add' => ['Add Feed', 'Add a feed config'],
'/dashboard/shopping_feed/form' => ['Feed Form', 'Used for both create and edit'],
];
foreach ($pages as $path => [$name, $desc]) {
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $pkg);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
}
}
$this->createTables();
}
public function on_start()
{
// Controller for unified CRUD handling
$ctrl = 'ConcretePackageShoppingFeedManagerControllerDashboardShoppingFeedFeed';
Route::register('/dashboard/shopping_feed/create', "{$ctrl}::create");
Route::register('/dashboard/shopping_feed/edit/{id}', "{$ctrl}::edit");
Route::register('/dashboard/shopping_feed/update/{id}', "{$ctrl}::update");
Route::register('/dashboard/shopping_feed/delete/{id}', "{$ctrl}::delete");
// Public feed endpoint
Route::register('/google-merchant-feed/{url}', 'ConcretePackageShoppingFeedManagerControllerMerchantFeed::generateXml');
}
protected function createTables()
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
$db->executeQuery("
CREATE TABLE IF NOT EXISTS shoppingfeedconfiguration (
id INT AUTO_INCREMENT PRIMARY KEY,
url VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(355) NOT NULL,
settings TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
)
");
$db->executeQuery("
CREATE TABLE IF NOT EXISTS shoppingfeedlog (
id INT AUTO_INCREMENT PRIMARY KEY,
feed_id INT NOT NULL,
updated_at DATETIME NOT NULL,
status VARCHAR(50),
error_message TEXT,
INDEX idx_feed (feed_id),
INDEX idx_status (status),
INDEX idx_updated_at (updated_at),
FOREIGN KEY (feed_id) REFERENCES shoppingfeedconfiguration(id) ON DELETE CASCADE
)
");
}
}
—
### ✅ Next Steps
– Create a single controller `Feed.php` that handles all the logic for:
– `create()`: handles insert
– `edit($id)`: fetches record to edit
– `update($id)`: processes update
– `delete($id)`: optional, deletes the feed
– Use a single `form.php` view with conditional logic to show for both add/edit
Let me know if you want the **controller** and **view** code as well for these endpoints (create/edit/update/delete) in this structure.
—
## 📁 Folder Structure for `shopping_feed_manager`
packages/
└── shopping_feed_manager/
├── controller.php <-- Main package controller
├── db.xml <-- Database schema
├── src/
│ └── Controller/
│ └── MerchantFeed.php <-- For public XML endpoint (/google-merchant-feed/{url})
├── controllers/
│ └── dashboard/
│ └── shopping_feed/
│ └── feed.php <-- Single controller handling all CRUD logic
└── single_pages/
└── dashboard/
└── shopping_feed/
├── index.php <-- Optional homepage
├── feeds.php <-- List view
├── add.php <-- Add feed form (or just redirect to form)
└── form.php <-- Shared form for add/edit
—
## 🧩 Breakdown of Purpose
| Path | Purpose |
|——|———|
| `controller.php` | Registers package, installs DB and pages, sets up routes |
| `db.xml` | Declares `shoppingfeedconfiguration` and `shoppingfeedlog` tables |
| `controllers/dashboard/shopping_feed/feed.php` | Unified controller for: create, edit, update, delete |
| `src/Controller/MerchantFeed.php` | Serves XML feeds from `/google-merchant-feed/{url}` |
| `single_pages/dashboard/shopping_feed/feeds.php` | Lists all feed records |
| `single_pages/dashboard/shopping_feed/form.php` | Reusable form for create/edit |
| `single_pages/dashboard/shopping_feed/add.php` | Optionally redirect to `/form` or reuse form directly |
—
## ✅ Sample Usage Flow
| Action | URL | Controller Method |
|——–|—–|——————–|
| View Feeds | `/dashboard/shopping_feed/feeds` | `feeds.php` shows list |
| Add Feed | `/dashboard/shopping_feed/add` | View renders `form.php` |
| Submit New Feed | `/dashboard/shopping_feed/create` | `Feed::create()` via POST |
| Edit Feed | `/dashboard/shopping_feed/edit/5` | `Feed::edit($id)` loads form |
| Update Feed | `/dashboard/shopping_feed/update/5` | `Feed::update($id)` via POST |
| Public XML Feed | `/google-merchant-feed/sample-url` | `MerchantFeed::generateXml()` |
—
Would you like me to generate the code for:
– `feed.php` (controller)
– `form.php` (view)
– `feeds.php` (view)
Let me know and I’ll drop it in clean, full examples.
packages/shopping_feed_manager/single_pages/dashboard/shopping_feed/form.php
This form is designed to **work for both Add and Edit modes** by checking if an `$item` with an `id` exists. It posts to the appropriate route automatically.
—
## ✅ `form.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<?php
$formAction = isset($item['id']) ? $view->url('/dashboard/shopping_feed/update/' . $item['id']) : $view->url('/dashboard/shopping_feed/create');
$formTitle = isset($item['id']) ? 'Edit Feed' : 'Add New Feed';
?>
<h2><?= $formTitle ?></h2>
<?php if (isset($error)) : ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endif; ?>
<form method="post" action="<?= $formAction ?>">
<div class="form-group">
<label for="url">Feed URL <span class="text-danger">*</span></label>
<input type="text" name="url" id="url" value="<?= h($item['url'] ?? '') ?>" class="form-control" required>
</div>
<div class="form-group">
<label for="name">Feed Name <span class="text-danger">*</span></label>
<input type="text" name="name" id="name" value="<?= h($item['name'] ?? '') ?>" class="form-control" required>
</div>
<div class="form-group">
<label for="settings">Feed Settings (JSON or XML)</label>
<textarea name="settings" id="settings" rows="6" class="form-control"><?= h($item['settings'] ?? '') ?></textarea>
</div>
<button type="submit" class="btn btn-success mt-3">
<?= isset($item['id']) ? 'Update Feed' : 'Create Feed' ?>
</button>
<a href="<?= URL::to('/dashboard/shopping_feed/feeds') ?>" class="btn btn-secondary mt-3">Cancel</a>
</form>
—
### 🔄 How it Works
– **If `$item['id']` is set** → shows edit form and posts to `/dashboard/shopping_feed/update/{id}`
– **If not set** → shows add form and posts to `/dashboard/shopping_feed/create`
—
Let me know if you'd like the full controller `feed.php` next that passes `$item` to this form for both `add` and `edit`.
"name": "Shopping Feed Manager Package",
"type": "code/php",
"content": "// File: packages/shopping_feed_manager/controller.phpnnamespace Concrete\Package\ShoppingFeedManager;nnuse Concrete\Core\Package\Package;nuse Concrete\Core\Page\Page;nuse Concrete\Core\Page\Single;nuse Concrete\Core\Database\Connection\Connection;nuse Route;nndefined('C5_EXECUTE') or die('Access Denied.');nnclass Controller extends Packagen{n protected $pkgHandle = 'shopping_feed_manager';n protected $appVersionRequired = '9.0.0';n protected $pkgVersion = '1.0';nn public function getPackageDescription() { return t("Google Merchant Shopping Feed package for ConcreteCMS"); }n public function getPackageName() { return t("Shopping Feed Manager"); }nn public function install()n {n $pkg = parent::install();nn $pages = [n '/dashboard/shopping_feed' => ['Shopping Feed', 'Shopping Feed Tools'],n '/dashboard/shopping_feed/feeds' => ['Manage Feeds', 'List and manage feeds'],n '/dashboard/shopping_feed/add' => ['Add Feed', 'Add a feed config'],n '/dashboard/shopping_feed/form' => ['Feed Form', 'Used for both create and edit']n ];nn foreach ($pages as $path => [$name, $desc]) {n $page = Page::getByPath($path);n if ($page->isError()) {n $page = Single::add($path, $pkg);n }n if (is_object($page) && !$page->isError()) {n $page->update(['cName' => $name, 'cDescription' => $desc]);n }n }nn $this->createTables();n }nn public function on_start()n {n $ctrl = 'Concrete\\Package\\ShoppingFeedManager\\Controller\\Dashboard\\ShoppingFeed\\Feed';nn Route::register('/dashboard/shopping_feed/create', "$ctrl::create");n Route::register('/dashboard/shopping_feed/edit/{id}', "$ctrl::edit");n Route::register('/dashboard/shopping_feed/update/{id}', "$ctrl::update");n Route::register('/dashboard/shopping_feed/delete/{id}', "$ctrl::delete");n Route::register('/google-merchant-feed/{url}', 'Concrete\\Package\\ShoppingFeedManager\\Controller\\MerchantFeed::generateXml');n }nn protected function createTables()n {n $db = $this->app->make(Connection::class);nn $db->executeQuery("CREATE TABLE IF NOT EXISTS shoppingfeedconfiguration (n id INT AUTO_INCREMENT PRIMARY KEY,n url VARCHAR(255) UNIQUE NOT NULL,n name VARCHAR(355) NOT NULL,n settings TEXT NULL,n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,n INDEX idx_name (name)n )");nn $db->executeQuery("CREATE TABLE IF NOT EXISTS shoppingfeedlog (n id INT AUTO_INCREMENT PRIMARY KEY,n feed_id INT NOT NULL,n updated_at DATETIME NOT NULL,n status VARCHAR(50),n error_message TEXT,n INDEX idx_feed (feed_id),n INDEX idx_status (status),n INDEX idx_updated_at (updated_at),n FOREIGN KEY (feed_id) REFERENCES shoppingfeedconfiguration(id) ON DELETE CASCADEn )");n }n}nnn// File: packages/shopping_feed_manager/db.xmlnnn n n n n n n n n n n n n n n nnnn// File: packages/shopping_feed_manager/controllers/dashboard/shopping_feed/feed.phpnnnnnnn nnn
It includes:
– 📦 `controller.php`: package logic and routing
– 🧩 `db.xml`: defines `shoppingfeedconfiguration` and `shoppingfeedlog`
– 🧠 `Feed.php`: unified controller for create, edit, update
– 📄 `form.php`: a shared dashboard view for both add/edit actions
If you now need:
– The **`feeds.php`** list view to show all feeds
– The **public XML controller** (`MerchantFeed.php`)
– Or a **delete button** on list view
Just say the word and I’ll generate those instantly.
ConcreteCoreNavigationItemItem::__construct(): Argument #2 ($name) must be of type string, null given, called in /var/www/html/concrete/src/Navigation/Item/PageItem.php on line 31
ConcreteCoreNavigationItemItem::__construct(): Argument #2 ($name) must be of type string, null given
means that Concrete CMS is trying to create a navigation item for a dashboard page, but the **page’s name (`cName`) is missing or null**.
This usually happens when you add a dashboard page using `Single::add()` but don’t immediately set its name and description.
—
## ✅ Solution
Make sure you're setting `cName` (name) and `cDescription` (description) **after** creating the page and checking it's valid.
You're already doing this in your `controller.php`:
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $pkg);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
}
But this only runs on `install()`. So, if you:
– Added new single pages manually,
– Renamed files or added new controllers later,
Then **those pages might not have a proper name set** in the database.
—
## 🛠️ Fix it manually
Go into your database and run this query (for example):
UPDATE Pages
JOIN PagePaths ON Pages.cID = PagePaths.cID
SET Pages.cName = 'Feed Form', Pages.cDescription = 'Used for both create and edit'
WHERE PagePaths.cPath = '/dashboard/shopping_feed/form';
Repeat this for any other `/dashboard/shopping_feed/…` paths you're using.
—
## 🔁 OR: Re-run install logic manually
If you want to fix it through code without reinstalling the package, add a `c5:exec` script to your package that updates all missing names for those paths.
Or temporarily add this to `on_start()` to ensure page names are restored:
public function on_start()
{
$pages = [
'/dashboard/shopping_feed' => ['Shopping Feed', 'Shopping Feed Tools'],
'/dashboard/shopping_feed/feeds' => ['Manage Feeds', 'List and manage feeds'],
'/dashboard/shopping_feed/add' => ['Add Feed', 'Add a feed config'],
'/dashboard/shopping_feed/form' => ['Feed Form', 'Used for both create and edit'],
];
foreach ($pages as $path => [$name, $desc]) {
$page = Page::getByPath($path);
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
}
}
// ... your Route::register lines
}
> ⚠️ Remove this logic after one request, or wrap it in a check to prevent repeating on every page load.
—
Let me know if you want a one-time CLI command you can run instead.
—
## ✅ 1. CLI Command (Concrete v9+)
Run this in your project root:
./vendor/bin/concrete5 c5:router:list
### 🔍 Example output:
+---------------------------------------------+--------------------------+-----------------------------+
| Route | Controller | Action |
+---------------------------------------------+--------------------------+-----------------------------+
| /dashboard/shopping_feed/create | ShoppingFeedFeed | create |
| /dashboard/shopping_feed/edit/{id} | ShoppingFeedFeed | edit |
| /google-merchant-feed/{url} | MerchantFeed | generateXml |
| ... | ... | ... |
+---------------------------------------------+--------------------------+-----------------------------+
—
## ✅ 2. Programmatically List Routes (optional)
If you want to dump routes in code:
use ConcreteCoreRoutingRouteList;
$app = ConcreteCoreSupportFacadeApplication::getFacadeApplication();
/** @var RouteList $router */
$router = $app->make(RouteList::class);
$routes = $router->getRoutes();
foreach ($routes as $route) {
echo $route->getPath() . " → " . $route->getAction()['controller'] . "n";
}
You can drop this into a single page for debugging.
—
Let me know if you want to **filter routes by package** or check if your custom routes were registered correctly.
—
## ✅ Command to List Routes in Concrete CMS 9
From your project root, run:
./vendor/bin/concrete c5:router:list
> This will display a table of all routes, including paths, HTTP methods, and their associated controller callbacks.
—
### 🧪 Example output:
+-------------------------------+----------+---------------------------------------------+
| Path | Method | Callback |
+-------------------------------+----------+---------------------------------------------+
| /dashboard/shopping_feed | GET | ConcreteControllerDashboardShoppingFeed |
| /dashboard/shopping_feed/add | GET | Custom Package Controller |
| /google-merchant-feed/{url} | GET | MerchantFeed::generateXml |
+-------------------------------+----------+---------------------------------------------+
—
## 🛠️ If You Get “Command Not Found”
Make sure:
– You're inside the Concrete CMS root directory
– You have dependencies installed with Composer (`vendor` folder exists)
If `concrete` isn't recognized, you can also try:
php vendor/bin/concrete c5:router:list
—
Let me know if you want to **export this list**, or filter for **custom package routes** only (e.g. starting with `/dashboard/shopping_feed`).
bash: c5:router:list: command not found
admin1@concrete-cms-web:/var/www/html/concrete/vendor/bin$
You should run it using the **Concrete CLI tool** like this:
—
## ✅ Correct Command:
./vendor/bin/concrete c5:router:list
> 🔁 This runs the `concrete` CLI tool located at `vendor/bin/concrete`, and then calls the `c5:router:list` subcommand.
—
### 🧪 Example:
From your project root (`/var/www/html`):
cd /var/www/html
./vendor/bin/concrete c5:router:list
—
Let me know if you're inside a DDEV/Docker container and need help making this global inside the container.
bash: ./vendor/bin/concrete: No such file or directory
admin1@concrete-cms-web:/var/www/html/concrete/vendor/bin$
You're in:
/var/www/html/concrete/vendor/bin
But the correct path to run the Concrete CLI is from the **project root**, which is likely:
/var/www/html
—
## ✅ Here's what to do:
### 🥾 Step-by-step:
cd /var/www/html
./vendor/bin/concrete c5:router:list
—
### 🧠 Explanation
– `vendor/bin/concrete` is a **script** that exists in your **project root** (`/var/www/html`)
– It's **not inside** `/var/www/html/concrete/vendor/bin`, which is a different subfolder
—
### ✅ Check if the file exists:
Just to confirm:
ls -l /var/www/html/vendor/bin/concrete
If it exists, you're good to run the CLI command from `/var/www/html`.
—
Let me know if you'd like a one-liner to **grep/filter your custom package routes** from the full list.
https://concrete-cms.ddev.site/index.php/dashboard/shopping_feed/create
ConcreteCoreNavigationItemItem::__construct(): Argument #2 ($name) must be of type string, null given
is caused when **Concrete tries to build the dashboard sidebar navigation**, and the page at `/dashboard/shopping_feed/create` doesn't have a `cName` (name) set in the `Pages` table.
—
### 🚫 Why this is happening:
You're registering `/dashboard/shopping_feed/create` via `Route::register()`:
Route::register('/dashboard/shopping_feed/create', Feed::class . '::create');
But you're **not creating a single page** for `/dashboard/shopping_feed/create`.
Concrete CMS assumes every `/dashboard/…` route is also a single page when building the dashboard UI navigation — **and will try to get its name**.
Because `/dashboard/shopping_feed/create` exists as a route but not as a page, it causes this error.
—
## ✅ Fix Options
### ✅ Option 1: Change route path to something not in the dashboard
Change the route path from:
/dashboard/shopping_feed/create
to something like:
/shopping_feed/create
So in `controller.php`:
Route::register('/shopping_feed/create', Feed::class . '::create');
Use non-dashboard URLs for actions like `create`, `update`, `delete`.
> Use `/dashboard/…` only for actual **view pages** that need sidebar navigation.
—
### ✅ Option 2: Add a single page at that path (not recommended for POST routes)
If you must keep `/dashboard/shopping_feed/create`, add it as a single page so Concrete can assign it a name:
Single::add('/dashboard/shopping_feed/create', $pkg);
Then also ensure you set the name:
$page = Page::getByPath('/dashboard/shopping_feed/create');
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => 'Create Feed']);
}
🛑 But this is **not recommended** for `POST-only` or logic routes like `create`, `update`, `delete`. Those should be routes, not pages.
—
### ✅ Best Practice Summary
| Type | URL | Define as |
|——|—–|———–|
| View/List/Edit pages | `/dashboard/…` | Single Page |
| Submit/update/delete logic | `/shopping_feed/create` | `Route::register(…)` only |
—
Let me know if you want me to refactor all your registered routes into best practice versions.
Your form posts to a **custom route** like:
<form method="post" action="<?= $view->url('/dashboard/shopping_feed/create') ?>">
And that route is handled by your controller:
class Feed extends DashboardPageController {
public function create() { ... }
}
### 🚨 The problem:
You're **posting to a route that looks like a dashboard single page**, but `/dashboard/shopping_feed/create` is **not a registered dashboard page**, only a route.
Concrete CMS tries to **render it in the dashboard sidebar menu**, but since it doesn't have a page name (`cName`), it throws:
> `ConcreteCoreNavigationItemItem::__construct(): Argument #2 ($name) must be of type string, null given`
—
## ✅ Correct Solution (Best Practice)
### 🔄 Change your `form.php`:
Instead of:
<form method="post" action="<?= $view->url('/dashboard/shopping_feed/create') ?>">
Use:
<form method="post" action="<?= URL::to('/shopping_feed/create') ?>">
### ✅ Then change your `Route::register()` in `controller.php`:
From:
Route::register('/dashboard/shopping_feed/create', '...Feed::create');
To:
Route::register('/shopping_feed/create', '...Feed::create');
Same for `update`, `delete`:
Route::register('/shopping_feed/update/{id}', '...Feed::update');
Route::register('/shopping_feed/delete/{id}', '...Feed::delete');
—
### ✅ Summary
| URL Type | Action | Route path | Explanation |
|———-|——–|————|————-|
| View | `/dashboard/shopping_feed/form` | Registered as Single Page | Displays form |
| Logic (POST) | `/shopping_feed/create` | Registered with `Route::register` | Avoids dashboard errors |
| Logic (POST) | `/shopping_feed/update/{id}` | Registered with `Route::register` | Handles updates |
—
Let me know if you'd like me to refactor your entire `controller.php` and `form.php` files for this best-practice route structure.
ExceptionStack Trace
TypeError
in /var/www/html/concrete/src/Navigation/Item/Item.php (line 38)
* Item constructor. * @param string $url * @param string $name * @param bool $isActive */ public function __construct(string $url, string $name, bool $isActive = false, $isActiveParent = false, $children = []) { $this->url = $url; $this->name = $name; $this->isActive = $isActive; $this->isActiveParent = $isActiveParent;
> **Concrete CMS is trying to create a sidebar navigation item for a route that is not backed by a proper dashboard page.**
—
### 🔍 Why This Is Happening
You're using a URL like:
/dashboard/shopping_feed/create
But this **URL is not a real page** (i.e., not added via `Single::add()`), it's only a registered **route** via:
Route::register('/dashboard/shopping_feed/create', ...);
Concrete CMS sees `/dashboard/shopping_feed/create` as part of the dashboard. So when it tries to build the sidebar, it looks for a page with a `cName` (title) to display — but none exists → **throws `TypeError: $name must be string, null given`**.
—
## ✅ Solution Summary (Best Practice)
### ✅ Change route paths **outside** the dashboard:
| ❌ Wrong | ✅ Correct |
|——–|———|
| `/dashboard/shopping_feed/create` | `/shopping_feed/create` |
| `/dashboard/shopping_feed/update/{id}` | `/shopping_feed/update/{id}` |
| `/dashboard/shopping_feed/delete/{id}` | `/shopping_feed/delete/{id}` |
Update your `Route::register()`:
Route::register('/shopping_feed/create', Feed::class . '::create');
Route::register('/shopping_feed/update/{id}', Feed::class . '::update');
Route::register('/shopping_feed/delete/{id}', Feed::class . '::delete');
### ✅ Update your form action:
<form method="post" action="<?= URL::to('/shopping_feed/create') ?>">
—
## ✅ Final Tip (Optional Fallback)
If you **must** keep the `/dashboard/…` path for any reason, you must also create a page for it:
$page = Page::getByPath('/dashboard/shopping_feed/create');
if ($page->isError()) {
$page = Single::add('/dashboard/shopping_feed/create', $pkg);
}
$page->update(['cName' => 'Create Feed']);
> ⚠️ But do **not** do this for POST/logic-only routes. It's against best practice.
—
Would you like me to **rewrite your `controller.php` and `form.php`** to follow this corrected routing structure fully?
1. ✅ Registers **logic routes** like `create`, `update`, `delete` **outside** the `/dashboard/…` path to avoid the sidebar `null name` error.
2. ✅ Keeps actual **view pages** under `/dashboard/shopping_feed/…` as they are supposed to be shown in the dashboard UI.
—
## ✅ Updated `controller.php`
<?php
namespace ConcretePackageShoppingFeedManager;
use ConcreteCorePackagePackage;
use ConcreteCorePagePage;
use ConcreteCorePageSingle;
use ConcreteCoreDatabaseConnectionConnection;
use Route;
defined('C5_EXECUTE') or die('Access Denied.');
class Controller extends Package
{
protected $pkgHandle = 'shopping_feed_manager';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0';
public function getPackageDescription()
{
return t("Google Merchant Shopping Feed package for ConcreteCMS");
}
public function getPackageName()
{
return t("Shopping Feed Manager");
}
public function install()
{
$pkg = parent::install();
// Only add pages that should appear in the dashboard UI
$pages = [
'/dashboard/shopping_feed' => ['Shopping Feed', 'Shopping Feed Tools'],
'/dashboard/shopping_feed/feeds' => ['Manage Feeds', 'List and manage feeds'],
'/dashboard/shopping_feed/add' => ['Add Feed', 'Add a feed config'],
'/dashboard/shopping_feed/form' => ['Feed Form', 'Used for both create and edit'],
];
foreach ($pages as $path => [$name, $desc]) {
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $pkg);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
}
}
$this->createTables();
}
public function on_start()
{
$ctrl = 'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\Feed';
// ✅ These are logic routes and should NOT be inside /dashboard
Route::register('/shopping_feed/create', "$ctrl::create");
Route::register('/shopping_feed/update/{id}', "$ctrl::update");
Route::register('/shopping_feed/delete/{id}', "$ctrl::delete");
// ✅ This is okay because edit is rendered as a view in dashboard
Route::register('/dashboard/shopping_feed/edit/{id}', "$ctrl::edit");
// Public route for merchant feed
Route::register('/google-merchant-feed/{url}', 'Concrete\Package\ShoppingFeedManager\Controller\MerchantFeed::generateXml');
}
protected function createTables()
{
$db = $this->app->make(Connection::class);
$db->executeQuery("CREATE TABLE IF NOT EXISTS shoppingfeedconfiguration (
id INT AUTO_INCREMENT PRIMARY KEY,
url VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(355) NOT NULL,
settings TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
)");
$db->executeQuery("CREATE TABLE IF NOT EXISTS shoppingfeedlog (
id INT AUTO_INCREMENT PRIMARY KEY,
feed_id INT NOT NULL,
updated_at DATETIME NOT NULL,
status VARCHAR(50),
error_message TEXT,
INDEX idx_feed (feed_id),
INDEX idx_status (status),
INDEX idx_updated_at (updated_at),
FOREIGN KEY (feed_id) REFERENCES shoppingfeedconfiguration(id) ON DELETE CASCADE
)");
}
}
—
## ✅ Also Update Your Form Action
In your `form.php`, use the **non-dashboard logic route** for POST:
<form method="post" action="<?= URL::to('/shopping_feed/create') ?>">
Same goes for `update`:
<form method="post" action="<?= URL::to('/shopping_feed/update/' . $item['id']) ?>">
—
With this setup, Concrete CMS will:
– Properly show all the dashboard pages in the sidebar without errors
– Run form actions safely via backend logic routes without triggering `Item::__construct()` errors
Let me know if you'd like me to verify `feed.php` or adjust your view paths next.
ConcreteCoreNavigationItemItem::__construct(): Argument #2 ($name) must be of type string, null given
…it strongly indicates that Concrete CMS is still trying to **render a page inside the dashboard** whose `cName` is `null`.
—
## ✅ Step-by-Step Debug & Fix
### ✅ 1. **Check for missing names in your dashboard pages**
Run this SQL in your database (e.g., via phpMyAdmin or CLI):
SELECT pp.cPath, p.cID, p.cName
FROM PagePaths pp
JOIN Pages p ON pp.cID = p.cID
WHERE pp.cPath LIKE '/dashboard/shopping_feed%' AND (p.cName IS NULL OR p.cName = '');
This will list all dashboard pages under `/dashboard/shopping_feed` that are missing names — these are what's breaking the sidebar.
—
### ✅ 2. **Fix it manually via SQL (quick patch)**
If you find any results, update them like this:
UPDATE Pages
SET cName = 'Create Feed'
WHERE cID = [PUT_THE_CID_HERE];
Do this for every page with a missing name.
—
### ✅ 3. **Clean orphaned routes (optional but recommended)**
Sometimes `Single::add()` gets skipped or replaced incorrectly. To clean things:
#### A. Run this in a CLI terminal:
./vendor/bin/concrete c5:clear-cache
#### B. Then re-check with:
./vendor/bin/concrete c5:router:list | grep shopping_feed
You can also list **all pages missing names**:
SELECT cID, cPath FROM PagePaths WHERE cPath LIKE '/dashboard/shopping_feed%' AND cID IN (
SELECT cID FROM Pages WHERE cName IS NULL
);
—
### ✅ 4. **Prevent it next time (important)**
Ensure every page created with `Single::add()` has a name:
$page = Page::getByPath('/dashboard/shopping_feed/create');
if ($page->isError()) {
$page = Single::add('/dashboard/shopping_feed/create', $pkg);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => 'Create Feed', 'cDescription' => 'Creates feed entries']);
}
But again — avoid creating dashboard pages for routes that are meant to **only process logic (POST)**.
—
## 🧪 Bonus: Dump Route Access
You can even dump from within a controller or tool:
use ConcreteCoreRoutingRouter;
$router = Core::make(Router::class);
foreach ($router->getRoutes() as $route) {
echo $route->getPath() . ' => ' . ($route->getAction()['controller'] ?? 'closure') . PHP_EOL;
}
—
If you'd like, I can:
– Create a CLI command or one-time PHP script to auto-fix `cName` for all `/dashboard/shopping_feed/*` pages.
– Help you fully decouple all logic routes into `/shopping_feed/*` namespace.
Would you like that?
—
### 🎯 Here's what we're going to do:
We will:
1. ✅ **Ensure all dashboard pages have valid `cName`s**
2. ✅ **Ensure logic routes (`create`, `update`, etc.) are not mistakenly added as pages**
3. ✅ **Pinpoint and remove any ghost dashboard routes still in the database**
—
### ✅ Step-by-step fix (no guesswork)
#### 🧩 Step 1: Run this SQL — identify all problem pages
SELECT pp.cPath, p.cID, p.cName
FROM PagePaths pp
JOIN Pages p ON pp.cID = p.cID
WHERE pp.cPath LIKE '/dashboard/shopping_feed%' AND (p.cName IS NULL OR p.cName = '');
If any rows return, **those are the broken dashboard pages** causing the error.
—
#### 🧼 Step 2: Fix missing names
Update them:
UPDATE Pages
SET cName = 'Create Feed', cDescription = 'Creates feed entries'
WHERE cID = [PUT_THE_cID_HERE];
Repeat for each bad entry. This alone **will stop the sidebar crash**.
—
#### 🚫 Step 3: Delete accidentally added logic routes as pages (optional)
If `/dashboard/shopping_feed/create` or `/dashboard/shopping_feed/update` was wrongly added as a page:
DELETE FROM PagePaths WHERE cPath = '/dashboard/shopping_feed/create';
DELETE FROM Pages WHERE cID = [the same cID];
—
#### 🔁 Step 4: Clear cache and check
./vendor/bin/concrete c5:clear-cache
Now reload your dashboard.
—
### ✅ Final Advice
– **Only register `create`, `update`, `delete` as `Route::register('/shopping_feed/…')`**
– Only use `/dashboard/…` for pages that should show in sidebar (like `feeds`, `edit`, `form`)
—
If you'd like, I’ll give you a one-time PHP script you can drop into a tool page to **automatically repair all broken `cName`s** under `/dashboard/shopping_feed`.
Would you like that?
edit /dashboard/shopping_feed/add 310 313 1 1
edit /dashboard/shopping_feed/feeds 309 312 1 1
edit /dashboard/shopping_feed 308 311 1 1
✅ These are the **correct dashboard pages**:
– `/dashboard/shopping_feed`
– `/dashboard/shopping_feed/feeds`
– `/dashboard/shopping_feed/add`
– `/dashboard/shopping_feed/form`
These are pages you explicitly added using `Single::add()` — and should **definitely** have `cName` and `cDescription` set.
—
## 🛠️ Final Fix: Set the missing `cName` values directly
Let's fix those pages **manually in the DB** using this SQL:
UPDATE Pages SET cName = 'Shopping Feed', cDescription = 'Shopping Feed Tools' WHERE cID = 308;
UPDATE Pages SET cName = 'Manage Feeds', cDescription = 'List and manage feeds' WHERE cID = 309;
UPDATE Pages SET cName = 'Add Feed', cDescription = 'Add a feed config' WHERE cID = 310;
UPDATE Pages SET cName = 'Feed Form', cDescription = 'Used for both create and edit' WHERE cID = 311;
—
## ✅ After That
Clear the cache to reflect the changes:
./vendor/bin/concrete c5:clear-cache
Then go to:
👉 `/dashboard/shopping_feed/feeds`
and confirm: **no more fatal error**!
—
## 🧠 Root Cause Recap
Even though you were using `Single::add()` correctly in `install()`, **Concrete CMS may skip setting `cName` if a page already exists** from a previous install attempt.
That’s why the best fix here was to **manually update the `Pages` table**.
—
Let me know if you'd like a script that auto-checks & repairs this in the future.
—
## ✅ Goal: Make sure the form submits correctly to `create`
You have this method:
// In Feed.php
public function create() {
// insert feed logic
}
And a registered route:
Route::register('/shopping_feed/create', Feed::class . '::create');
But if your form posts to `/dashboard/shopping_feed/create`, that route doesn't exist anymore — **hence the form appears broken or fails silently.**
—
## ✅ Fix it: Update your form action
### 🔧 Change `form.php` like this:
<form method="post" action="<?= URL::to('/shopping_feed/create') ?>">
Not:
<form method="post" action="<?= $view->url('/dashboard/shopping_feed/create') ?>"> <!-- 🚫 WRONG -->
> `URL::to()` directly matches the registered route, **not a dashboard page**.
—
## ✅ Confirm the flow
– ✅ `Route::register('/shopping_feed/create', …)` exists
– ✅ You are submitting via POST
– ✅ Form action is: `/shopping_feed/create`
– ✅ `create()` method handles form data and redirects
—
## 🧪 Bonus: Add a debug line in `create()`
To confirm it’s being hit:
public function create() {
file_put_contents(DIR_APPLICATION . '/logs/create_debug.log', 'Form hit: ' . json_encode($_POST));
// your logic
}
Then:
tail -f application/logs/create_debug.log
This proves whether the method is executed.
—
## ✅ Final Checklist
| Item | Value |
|——|——-|
| Route registered | `/shopping_feed/create` |
| Controller method | `Feed::create()` |
| Form action | “ |
| Form method | `POST` |
| Sidebar error fixed | Yes |
| Sidebar route (create) removed | Yes |
—
If it's still not working after this, send:
– The full “ block
– Any network error / redirect status from browser Dev Tools
I'll guide you immediately from there.
—
### ✅ Fix Summary
1. **Update form action in** `form.php`:
– Change from `'/dashboard/shopping_feed/create'` to `'/shopping_feed/create'` (non-dashboard route)
2. **Ensure controller route in `controller.php` is registered correctly** for `create`.
—
Applying the fix now…
The form in `form.php` now correctly submits to the route:
<?= URL::to('/shopping_feed/create') ?>
This matches your registered route and avoids the sidebar error.
Would you like me to repackage the corrected ZIP for download?
Download it here: [shopping_feed_manager_fixed.zip](sandbox:/mnt/data/shopping_feed_manager_fixed.zip)
Let me know if you'd like help reinstalling or testing this in your Concrete CMS dashboard.
– ✅ View feed list (`/dashboard/feeds`)
– ✅ Add new feed (`/dashboard/feeds/add`)
– ✅ Edit existing feed (`/dashboard/feeds/edit/{id}`)
We'll call the package: **`simple_feed_crud`**
It manages a single table: **`feeds`** with columns: `id`, `name`, `url`, `created_at`
—
## ✅ Folder Structure
packages/simple_feed_crud/
├── controller.php
├── db.xml
├── controllers/
│ └── dashboard/
│ └── feeds.php
├── single_pages/
│ └── dashboard/
│ └── feeds/
│ ├── view.php
│ ├── add.php
│ └── edit.php
—
## ✅ 1. `controller.php`
<?php
namespace ConcretePackageSimpleFeedCrud;
use ConcreteCorePackagePackage;
use ConcreteCorePageSingle;
use ConcreteCoreDatabaseConnectionConnection;
use Page;
class Controller extends Package
{
protected $pkgHandle = 'simple_feed_crud';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0';
public function getPackageName() { return t("Simple Feed CRUD"); }
public function getPackageDescription() { return t("A basic CRUD interface for feeds."); }
public function install()
{
$pkg = parent::install();
// Add dashboard pages
foreach (['/dashboard/feeds', '/dashboard/feeds/add', '/dashboard/feeds/edit'] as $path) {
if (Page::getByPath($path)->isError()) {
Single::add($path, $pkg);
}
}
$this->installDatabase();
}
}
—
## ✅ 2. `db.xml`
<?xml version="1.0"?>
<schema>
<table name="feeds">
<field name="id" type="I"><key /><autoincrement /></field>
<field name="name" type="C" size="255" />
<field name="url" type="C" size="255" />
<field name="created_at" type="T" />
</table>
</schema>
—
## ✅ 3. Controller: `controllers/dashboard/feeds.php`
<?php
namespace ConcretePackageSimpleFeedCrudControllerDashboard;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpRequest;
use ConcreteCoreRoutingRedirect;
class Feeds extends DashboardPageController
{
public function view()
{
$db = $this->app->make(Connection::class);
$this->set('feeds', $db->fetchAll("SELECT * FROM feeds ORDER BY id DESC"));
}
public function add()
{
$this->render('/dashboard/feeds/add');
}
public function save()
{
$req = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$name = $req->request->get('name');
$url = $req->request->get('url');
if ($name && $url) {
$db->insert('feeds', ['name' => $name, 'url' => $url, 'created_at' => date('Y-m-d H:i:s')]);
$this->flash('success', 'Feed added.');
return Redirect::to('/dashboard/feeds');
}
$this->set('error', 'All fields are required.');
$this->render('/dashboard/feeds/add');
}
public function edit($id = null)
{
$db = $this->app->make(Connection::class);
$feed = $db->fetchAssociative("SELECT * FROM feeds WHERE id = ?", [$id]);
if (!$feed) {
return Redirect::to('/dashboard/feeds');
}
$this->set('feed', $feed);
$this->render('/dashboard/feeds/edit');
}
public function update($id)
{
$req = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$name = $req->request->get('name');
$url = $req->request->get('url');
if ($name && $url) {
$db->update('feeds', ['name' => $name, 'url' => $url], ['id' => $id]);
$this->flash('success', 'Feed updated.');
return Redirect::to('/dashboard/feeds');
}
$this->set('error', 'All fields are required.');
$this->edit($id);
}
}
—
## ✅ 4. View: `view.php`
<h2>All Feeds</h2>
<a href="<?= URL::to('/dashboard/feeds/add') ?>" class="btn btn-primary mb-3">Add Feed</a>
<table class="table">
<thead><tr><th>ID</th><th>Name</th><th>URL</th><th>Created</th><th></th></tr></thead>
<tbody>
<?php foreach ($feeds as $f): ?>
<tr>
<td><?= $f['id'] ?></td>
<td><?= h($f['name']) ?></td>
<td><?= h($f['url']) ?></td>
<td><?= $f['created_at'] ?></td>
<td><a class="btn btn-sm btn-secondary" href="<?= URL::to('/dashboard/feeds/edit', $f['id']) ?>">Edit</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
—
## ✅ 5. Add Form: `add.php`
<h2>Add Feed</h2>
<?php if (isset($error)) echo "<div class='alert alert-danger'>$error</div>"; ?>
<form method="post" action="<?= $view->action('save') ?>">
<div class="form-group">
<label>Name</label>
<input class="form-control" name="name" required>
</div>
<div class="form-group">
<label>URL</label>
<input class="form-control" name="url" required>
</div>
<button class="btn btn-success mt-2">Save</button>
</form>
—
## ✅ 6. Edit Form: `edit.php`
<h2>Edit Feed</h2>
<?php if (isset($error)) echo "<div class='alert alert-danger'>$error</div>"; ?>
<form method="post" action="<?= $view->action('update', $feed['id']) ?>">
<div class="form-group">
<label>Name</label>
<input class="form-control" name="name" value="<?= h($feed['name']) ?>" required>
</div>
<div class="form-group">
<label>URL</label>
<input class="form-control" name="url" value="<?= h($feed['url']) ?>" required>
</div>
<button class="btn btn-primary mt-2">Update</button>
</form>
—
Would you like me to bundle this into a ready-to-install ZIP package for you?
change menu name
Single::add('/dashboard/shopping_feed/view', $pkg);
You should immediately update the page like this:
—
## ✅ Correct Code
$page = Page::getByPath('/dashboard/shopping_feed/view');
if ($page->isError()) {
$page = Single::add('/dashboard/shopping_feed/view', $pkg);
}
if (is_object($page) && !$page->isError()) {
$page->update([
'cName' => 'Feed List',
'cDescription' => 'List all shopping feeds',
]);
}
—
## 🧠 Explanation
– `cName`: This is the title used in the sidebar menu (`Feed List`)
– `cDescription`: Optional, but good for clarity (`List all shopping feeds`)
You can use this pattern for all dashboard pages like:
/dashboard/shopping_feed/add
/dashboard/shopping_feed/form
/dashboard/shopping_feed/feeds
Let me know if you'd like me to loop over all dashboard paths and apply custom names/descriptions in your `install()` method.
Single::add('/dashboard/shopping_feed/tracklog', $pkg);
—
## ✅ Full Code Snippet
Place this inside your `install()` method after the `Single::add(…)` lines:
$page1 = Page::getByPath('/dashboard/shopping_feed/view');
if ($page1->isError()) {
$page1 = Single::add('/dashboard/shopping_feed/view', $pkg);
}
if (is_object($page1) && !$page1->isError()) {
$page1->update([
'cName' => 'Feed List',
'cDescription' => 'View all feed configurations',
]);
}
$page2 = Page::getByPath('/dashboard/shopping_feed/tracklog');
if ($page2->isError()) {
$page2 = Single::add('/dashboard/shopping_feed/tracklog', $pkg);
}
if (is_object($page2) && !$page2->isError()) {
$page2->update([
'cName' => 'Track Log',
'cDescription' => 'Log history for feed updates',
]);
}
—
## ✅ Result in Dashboard Sidebar
– **Feed List** → `/dashboard/shopping_feed/view`
– **Track Log** → `/dashboard/shopping_feed/tracklog`
These titles will now appear in the Concrete CMS dashboard sidebar correctly.
Let me know if you want this dynamic for multiple paths with a cleaner loop.
—
## ✅ Solution: Use `upgrade()` for adding new dashboard pages
Concrete CMS calls `upgrade()` when you update the version in `controller.php` and run:
./vendor/bin/concrete c5:package-update shopping_feed_manager
—
### 🛠 Add `upgrade()` method to your `controller.php`:
public function upgrade()
{
parent::upgrade();
$this->addDashboardPages();
}
—
### 🛠 Extract `addDashboardPages()` logic:
This makes your `install()` and `upgrade()` both clean and reusable.
protected function addDashboardPages()
{
$pages = [
'/dashboard/shopping_feed' => ['Shopping Feed', 'Shopping Feed Tools'],
'/dashboard/shopping_feed/view' => ['Manage Feeds', 'List and manage feeds'],
'/dashboard/shopping_feed/tracklog' => ['Tracking Log', 'View tracking log'],
];
foreach ($pages as $path => [$name, $desc]) {
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $this);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
}
}
}
—
### 🔄 Update your `install()` to reuse it:
public function install()
{
$pkg = parent::install();
$this->addDashboardPages();
$this->createTables();
}
—
## ✅ How to Add New Pages Later Without Reinstalling
1. Add your new path to the `addDashboardPages()` array
2. Bump your version:
protected $pkgVersion = '1.1'; // e.g. from 1.0 to 1.1
3. Run:
./vendor/bin/concrete c5:package-update shopping_feed_manager
—
This approach avoids reinstallation and **adds/updates only the missing pages**, preserving existing data and tables.
Would you like me to apply this structure to your current zip file as well?
I wan to add this but don;t want to menu show
Single::add('/dashboard/shopping_feed/add', $pkg);
…but **do not want it to appear in the left-hand dashboard menu**, here’s how to do it:
—
## ✅ Solution: Set `exclude_nav` attribute
Immediately after adding the page, update its attributes like this:
$page = Page::getByPath('/dashboard/shopping_feed/add');
if ($page->isError()) {
$page = Single::add('/dashboard/shopping_feed/add', $pkg);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => 'Add Feed', 'cDescription' => 'Hidden form for adding feed']);
$page->setAttribute('exclude_nav', true); // 🚫 hides from sidebar
}
—
## ✅ Explanation
– `cName` and `cDescription` are still needed for system stability.
– `exclude_nav = true` makes the page **accessible by URL** but **hidden from the menu**.
—
## ✅ Where to Put This
If you're using the `addDashboardPages()` method I suggested earlier, you can add a second array to track hidden pages like this:
$visiblePages = [
'/dashboard/shopping_feed' => ['Shopping Feed', 'Main page'],
'/dashboard/shopping_feed/view' => ['Manage Feeds', 'List feeds'],
];
$hiddenPages = [
'/dashboard/shopping_feed/add' => ['Add Feed', 'Hidden form'],
];
foreach ($visiblePages as $path => [$name, $desc]) {
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $this);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
}
}
foreach ($hiddenPages as $path => [$name, $desc]) {
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $this);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
$page->setAttribute('exclude_nav', true); // hide from menu
}
}
Let me know if you'd like me to modify your current controller to reflect this exact logic.
– Loads a feed record from the database
– Displays it in a form
– Submits to update the record
– Is added as a dashboard page (`/dashboard/shopping_feed/edit`)
– Can reuse your `Feed` controller logic if structured cleanly
—
## ✅ 1. Controller Logic
### File: `packages/shopping_feed_manager/controllers/dashboard/shopping_feed/edit.php`
<?php
namespace ConcretePackageShoppingFeedManagerControllerDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpRequest;
use ConcreteCoreRoutingRedirect;
class Edit extends DashboardPageController
{
public function view($id = null)
{
$db = $this->app->make(Connection::class);
$feed = $db->fetchAssociative("SELECT * FROM shoppingfeedconfiguration WHERE id = ?", [$id]);
if (!$feed) {
$this->flash('error', 'Feed not found.');
return $this->redirect('/dashboard/shopping_feed/view');
}
$this->set('feed', $feed);
}
public function submit($id)
{
$req = $this->app->make(Request::class);
$db = $this->app->make(Connection::class);
$url = $req->request->get('url');
$name = $req->request->get('name');
$settings = $req->request->get('settings');
if ($url && $name) {
$db->update('shoppingfeedconfiguration', compact('url', 'name', 'settings'), ['id' => $id]);
$this->flash('success', 'Feed updated successfully.');
return Redirect::to('/dashboard/shopping_feed/view');
}
$this->set('error', 'All fields are required.');
$this->view($id); // reload form with values
}
}
—
## ✅ 2. View Template
### File: `packages/shopping_feed_manager/single_pages/dashboard/shopping_feed/edit.php`
<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<h2>Edit Feed</h2>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?= $error ?></div>
<?php endif; ?>
<form method="post" action="<?= $view->action('submit', $feed['id']) ?>">
<div class="form-group">
<label for="url">Feed URL</label>
<input type="text" name="url" value="<?= h($feed['url']) ?>" class="form-control" required>
</div>
<div class="form-group">
<label for="name">Feed Name</label>
<input type="text" name="name" value="<?= h($feed['name']) ?>" class="form-control" required>
</div>
<div class="form-group">
<label for="settings">Feed Settings</label>
<textarea name="settings" class="form-control" rows="6"><?= h($feed['settings']) ?></textarea>
</div>
<button type="submit" class="btn btn-primary mt-2">Update Feed</button>
<a href="<?= URL::to('/dashboard/shopping_feed/view') ?>" class="btn btn-secondary mt-2">Cancel</a>
</form>
—
## ✅ 3. Add Page in `install()` or `upgrade()`
Inside your `addDashboardPages()` method:
'/dashboard/shopping_feed/edit' => ['Edit Feed', 'Edit an existing feed'],
> You can optionally mark it `exclude_nav = true` if you want it hidden from the sidebar.
—
## ✅ Result
– Visiting `/dashboard/shopping_feed/edit/1` will show the edit form for feed ID 1
– Submitting will update the database and redirect back to `/dashboard/shopping_feed/view`
Let me know if you'd like this added to your ZIP or if you'd like to make the edit view use the same `form.php` for both add/edit.
its sating page not found
/dashboard/shopping_feed/edit/3
…and getting a **“page not found”**, that means Concrete CMS is looking for a **single page at that exact path** — not a route with a variable like `{id}`.
—
### 🔍 Why this happens
Concrete CMS dashboard pages like `/dashboard/…` **must exist in the Pages database** (i.e. created via `Single::add()`).
But you can't `Single::add('/dashboard/shopping_feed/edit/3')` — because `/3` is dynamic. So:
> Concrete doesn't know how to match a URL like `/dashboard/shopping_feed/edit/3` to a controller.
—
## ✅ Fix: Use a Route Instead of a Page
### 🧠 Option 1 (Recommended): Register it as a route
In your package `controller.php` under `on_start()`:
Route::register('/dashboard/shopping_feed/edit/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\Edit::view');
Route::register('/dashboard/shopping_feed/edit/submit/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\Edit::submit');
> This tells Concrete CMS to handle the dynamic part (`{id}`) correctly.
### Then in your form:
<form method="post" action="<?= URL::to('/dashboard/shopping_feed/edit/submit/' . $feed['id']) ?>">
—
### ✅ Option 2: Hardcode form post in non-dynamic page
If you still want to keep a real dashboard page for edit, you'd do something like:
– Visit `/dashboard/shopping_feed/edit` (single page)
– Select or pass the ID via query string
– Load based on `?id=3` (but that's clunky)
—
## ✅ Final Recommendation
Stick with **Option 1: Register dynamic routes**, and don’t try to `Single::add()` dynamic paths.
Let me know if you want me to patch your package with those route lines added.
Now I want to pass encryted id
—
## ✅ Step-by-Step: Encrypt & Decrypt ID in Concrete CMS
Concrete CMS provides a built-in helper: `ConcreteCoreEncryptionEncryptionService`.
—
### ✅ 1. Inject Encryption Service
In your controller (`Edit.php` or wherever you’re generating the link):
use ConcreteCoreEncryptionEncryptionService;
Encrypting the ID when generating a link:
$encrypter = $this->app->make(EncryptionService::class);
$encryptedID = $encrypter->encrypt($feed['id']);
$url = URL::to('/dashboard/shopping_feed/edit/' . urlencode($encryptedID));
—
### ✅ 2. Decrypt in `edit` Controller
In your controller method:
public function view($encryptedId = null)
{
$encrypter = $this->app->make(EncryptionService::class);
try {
$id = $encrypter->decrypt(urldecode($encryptedId));
} catch (Exception $e) {
$this->flash('error', 'Invalid feed ID.');
return $this->redirect('/dashboard/shopping_feed/view');
}
$db = $this->app->make(Connection::class);
$feed = $db->fetchAssociative("SELECT * FROM shoppingfeedconfiguration WHERE id = ?", [$id]);
if (!$feed) {
$this->flash('error', 'Feed not found.');
return $this->redirect('/dashboard/shopping_feed/view');
}
$this->set('feed', $feed);
}
—
### ✅ 3. Update View Link in Feed List
In your `view.php` list page, update the Edit button:
<?php
$encrypter = Core::make(EncryptionService::class);
$encId = urlencode($encrypter->encrypt($f['id']));
?>
<a class="btn btn-sm btn-secondary" href="<?= URL::to('/dashboard/shopping_feed/edit/' . $encId) ?>">Edit</a>
—
## 🔐 Summary
| Step | Use |
|——|—–|
| Encrypt | `$encId = $encryption->encrypt($id)` |
| Decrypt | `$id = $encryption->decrypt($encId)` |
| Output URL | `urlencode($encId)` to avoid breaking URLs |
—
Let me know if you'd like me to apply this to your package code and return an updated ZIP.
Call to a member function encrypt() on null
It means that `$encrypter = Core::make(EncryptionService::class);` is returning `null`, usually because:
– You're calling `make()` **inside a view file** like `view.php`
– And you didn't use the correct global helper context
—
## ✅ Fix: Use `app()` instead of `Core::make()`
Inside a **view file** like `view.php`, use this instead:
$encrypter = app(ConcreteCoreEncryptionEncryptionService::class);
Then you can safely do:
<?php
$encId = urlencode($encrypter->encrypt($f['id']));
?>
<a class="btn btn-sm btn-secondary" href="<?= URL::to('/dashboard/shopping_feed/edit/' . $encId) ?>">Edit</a>
—
## ✅ Why This Works
– `app()` is a global helper available in views and Blade templates.
– `Core::make(…)` is fine in controllers but can fail in certain template scopes.
—
Let me know if you want this patched automatically across your files.
You're currently linking to:
$this->url('/dashboard/shopping_feed/edit', $feed['id'])
But you want to **pass an encrypted ID**, and you're likely seeing a `null` error if you tried `Core::make()` in the view.
—
## ✅ Apply Encrypted ID Fix in Your View
### 🔧 Modify the view as follows:
At the top of the view, after “, add:
<?php
$encrypter = app(ConcreteCoreEncryptionEncryptionService::class);
?>
Now update the "Edit" and "Delete" buttons like this:
<?php foreach ($feeds as $feed):
$encId = urlencode($encrypter->encrypt($feed['id']));
?>
<tr>
<td><?= $feed['id'] ?></td>
<td><?= h($feed['url']) ?></td>
<td><?= h($feed['name']) ?></td>
<td><?= h($feed['created_at']) ?></td>
<td>
<a href="<?= $this->url('/dashboard/shopping_feed/edit', $encId) ?>" class="btn btn-sm btn-secondary">Edit</a>
<a href="<?= $this->url('/dashboard/shopping_feed/delete', $encId) ?>" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure?')">Delete</a>
</td>
</tr>
<?php endforeach; ?>
—
## ✅ Update Your Edit Controller to Decrypt
In your controller where `edit($id)` is handled (e.g., `edit.php` or `Feed::edit()`):
use ConcreteCoreEncryptionEncryptionService;
public function view($encryptedId = null)
{
$encrypter = $this->app->make(EncryptionService::class);
try {
$id = $encrypter->decrypt(urldecode($encryptedId));
} catch (Exception $e) {
$this->flash('error', 'Invalid or tampered link.');
return $this->redirect('/dashboard/shopping_feed/view');
}
// continue with fetching $id
}
—
## 🧪 Confirm it works
– Your list page (`/dashboard/shopping_feed/view`) now generates encrypted links.
– Clicking "Edit" or "Delete" uses encrypted IDs.
– Controllers decrypt and load the feed securely.
Let me know if you want the delete controller logic updated similarly.
> 🧨 **Error:**
Target class [ConcreteCoreEncryptionEncryptionService] does not exist.
This means you're using an incorrect or outdated class name. In **Concrete CMS 9+**, the correct encryption service is accessed via the **facade**:
—
## ✅ Correct Way to Access the Encryption Service
Instead of:
use ConcreteCoreEncryptionEncryptionService;
$encrypter = app(EncryptionService::class);
You should use the **facade**:
use ConcreteCoreSupportFacadeEncryption;
$encrypter = Encryption::getFacadeRoot();
✅ This works reliably in both **controllers** and **view files**.
—
### ✅ In your view (`view.php`), fix it like this:
<?php
use ConcreteCoreSupportFacadeEncryption;
$encrypter = Encryption::getFacadeRoot();
?>
Then continue as before:
<?php foreach ($feeds as $feed):
$encId = urlencode($encrypter->encrypt($feed['id']));
?>
<tr>
<td><?= $feed['id'] ?></td>
<td><?= h($feed['url']) ?></td>
<td><?= h($feed['name']) ?></td>
<td><?= h($feed['created_at']) ?></td>
<td>
<a href="<?= $this->url('/dashboard/shopping_feed/edit', $encId) ?>" class="btn btn-sm btn-secondary">Edit</a>
<a href="<?= $this->url('/dashboard/shopping_feed/delete', $encId) ?>" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure?')">Delete</a>
</td>
</tr>
<?php endforeach; ?>
—
### ✅ In your controller:
use ConcreteCoreSupportFacadeEncryption;
$id = Encryption::decrypt(urldecode($encryptedId));
—
Let me know if you want me to edit your files directly and return an updated ZIP.
Did you forget a "use" statement for another namespace?
—
## ✅ Final & Correct Answer for Concrete CMS 9+
In Concrete CMS, to use encryption in **controllers** and **views**, you should use:
use ConcreteCoreUtilityServiceEncryption;
### ✅ In a **controller** (like `Edit.php`):
use ConcreteCoreUtilityServiceEncryption;
$encrypter = $this->app->make(Encryption::class);
$id = $encrypter->decrypt(urldecode($encryptedId));
### ✅ In a **view file** (`view.php`):
Since you're not in a class context, do this:
<?php
use ConcreteCoreUtilityServiceEncryption;
$encrypter = Core::make(Encryption::class);
?>
Then use as expected:
$encId = urlencode($encrypter->encrypt($feed['id']));
—
## ✅ Summary
| Where | Class | Correct usage |
|————-|————————————-|—————————————-|
| Controller | `ConcreteCoreUtilityServiceEncryption` | `$this->app->make(…)` |
| View file | `ConcreteCoreUtilityServiceEncryption` | `Core::make(…)` or `app(…)` |
—
Let me know if you want me to apply this fix directly in your package code.
—
## ✅ 1. Add Delete Route in `controller.php` → `on_start()`
If you're using a dynamic delete (via encrypted ID):
use Route;
Route::register(
'/dashboard/shopping_feed/delete_json/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\Delete::delete'
);
—
## ✅ 2. Create the Controller: `Delete.php`
### File: `controllers/dashboard/shopping_feed/delete.php`
<?php
namespace ConcretePackageShoppingFeedManagerControllerDashboardShoppingFeed;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreHttpResponseFactoryInterface;
use ConcreteCoreUtilityServiceEncryption;
use ConcreteCoreDatabaseConnectionConnection;
class Delete extends AbstractController
{
public function delete($encId)
{
try {
$encryption = $this->app->make(Encryption::class);
$id = $encryption->decrypt(urldecode($encId));
$db = $this->app->make(Connection::class);
$db->delete('shoppingfeedconfiguration', ['id' => $id]);
return $this->app->make(ResponseFactoryInterface::class)
->json(['success' => true, 'message' => 'Feed deleted']);
} catch (Exception $e) {
return $this->app->make(ResponseFactoryInterface::class)
->json(['success' => false, 'message' => 'Invalid ID'], 400);
}
}
}
—
## ✅ 3. Update the View to Trigger AJAX
In `view.php`:
### A. Add this in the “ section or inline JS:
<script>
function deleteFeed(id, button) {
if (!confirm("Are you sure you want to delete this feed?")) return;
fetch('/dashboard/shopping_feed/delete_json/' + id, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
// Remove row
const row = button.closest('tr');
row.parentNode.removeChild(row);
} else {
alert('Error: ' + data.message);
}
})
.catch(err => alert('Request failed.'));
}
</script>
—
### B. In the feed list table, replace delete link with:
<?php
use ConcreteCoreUtilityServiceEncryption;
$encrypter = Core::make(Encryption::class);
$encId = urlencode($encrypter->encrypt($feed['id']));
?>
<a href="javascript:void(0);" class="btn btn-sm btn-danger"
onclick="deleteFeed('<?= $encId ?>', this)">
Delete
</a>
—
## ✅ Done
You now have AJAX-based deletion:
– Click “Delete”
– Confirmation prompt
– Sends POST to `/dashboard/shopping_feed/delete_json/{encryptedId}`
– Deletes the record and removes the row
—
Let me know if you want this logic applied in a ZIP file or if you'd like a loading indicator too.
—
## ✅ 1. Create Asset Files in Your Package
### Folder structure inside `shopping_feed_manager`:
shopping_feed_manager/
├── css/
│ └── admin.css
├── js/
│ └── admin.js
—
### Example: `css/admin.css`
table.table td {
vertical-align: middle;
}
.btn-sm {
padding: 0.3rem 0.6rem;
}
### Example: `js/admin.js`
document.addEventListener('DOMContentLoaded', function () {
console.log('Shopping Feed Manager JS loaded.');
});
—
## ✅ 2. Register Assets in `controller.php`
Add this to your package controller class:
use ConcreteCoreAssetAssetList;
use ConcreteCoreAssetAsset;
Then, inside your `on_start()` method:
public function on_start()
{
// Routes...
Route::register('/dashboard/shopping_feed/delete_json/{id}', '...');
// Register assets
$al = AssetList::getInstance();
$al->register('javascript', 'sfm-admin-js', 'js/admin.js', [], $this);
$al->register('css', 'sfm-admin-css', 'css/admin.css', [], $this);
}
—
## ✅ 3. Include Assets in Your View (`view.php`, `form.php`, etc.)
At the top of any view file where you want them loaded:
<?php
View::getInstance()->requireAsset('javascript', 'sfm-admin-js');
View::getInstance()->requireAsset('css', 'sfm-admin-css');
?>
Place this **above your HTML content**.
—
## ✅ Optional: Use `enqueueAssets()` in Controller
In your controller (e.g., `DashboardPageController` subclass):
public function on_start()
{
$this->requireAsset('javascript', 'sfm-admin-js');
$this->requireAsset('css', 'sfm-admin-css');
}
This way, all views under the controller get the assets automatically.
—
## ✅ Summary
| Step | Action |
|——|——–|
| 1 | Create `css/admin.css` and `js/admin.js` in your package |
| 2 | Register them in `on_start()` using `AssetList` |
| 3 | Load them using `View::getInstance()->requireAsset(…)` or via controller |
Let me know if you want these added to your ZIP and rebuilt.
—
## ✅ Step-by-Step: Load JS/CSS Globally via `controller.php`
### 📁 File structure (in your package):
shopping_feed_manager/
├── css/
│ └── admin.css
├── js/
│ └── admin.js
—
### ✅ 1. Register Assets in `on_start()` of your `controller.php`
Inside your `controller.php`:
use ConcreteCoreAssetAssetList;
public function on_start()
{
$al = AssetList::getInstance();
// Register assets globally with a unique handle
$al->register('javascript', 'sfm-admin-js', 'js/admin.js', [], $this);
$al->register('css', 'sfm-admin-css', 'css/admin.css', [], $this);
// Auto-load these assets on all dashboard pages
$al->requireAsset('javascript', 'sfm-admin-js');
$al->requireAsset('css', 'sfm-admin-css');
}
✅ The `requireAsset(…)` calls inside `on_start()` ensure your assets are globally loaded on every Concrete CMS page load (including dashboard pages from your package).
—
### ✅ 2. (Optional) Restrict to Your Package Pages Only
If you want the CSS/JS to only load on your package's dashboard paths (like `/dashboard/shopping_feed/*`), you can do:
$request = Request::getInstance();
$path = $request->getPathInfo();
if (strpos($path, '/dashboard/shopping_feed') === 0) {
$al = AssetList::getInstance();
$al->register('javascript', 'sfm-admin-js', 'js/admin.js', [], $this);
$al->register('css', 'sfm-admin-css', 'css/admin.css', [], $this);
$al->requireAsset('javascript', 'sfm-admin-js');
$al->requireAsset('css', 'sfm-admin-css');
}
This will limit global inclusion to your package's dashboard pages.
—
## ✅ Summary
| Goal | Solution |
|——|———-|
| Include JS/CSS on all dashboard pages | Use `AssetList::getInstance()->requireAsset(…)` in `on_start()` |
| Limit to package pages only | Check `$request->getPathInfo()` for path prefix |
Let me know if you want me to patch this into your existing `controller.php`.
> **"Attempted to call an undefined method named `requireAsset`…"**
means you're mistakenly calling `$this->requireAsset(…)` in your **package controller** (`ConcretePackageShoppingFeedManagerController`) — but that method only exists in **page controllers**, like `DashboardPageController`.
—
## ✅ Correct Way in `controller.php`
In your `controller.php`, use the **AssetList directly**, like this:
use ConcreteCoreAssetAssetList;
public function on_start()
{
$al = AssetList::getInstance();
// Register assets
$al->register('javascript', 'sfm-admin-js', 'js/admin.js', [], $this);
$al->register('css', 'sfm-admin-css', 'css/admin.css', [], $this);
}
This **registers** the assets globally — but it doesn't enqueue them yet.
—
## ✅ Enqueue the assets in your dashboard controller
In any controller that extends `DashboardPageController` (like `ShoppingFeed.php`), use this:
public function on_start()
{
$this->requireAsset('javascript', 'sfm-admin-js');
$this->requireAsset('css', 'sfm-admin-css');
}
✅ This **does work**, because `requireAsset()` is defined in `DashboardPageController`.
—
## 🔁 If you want auto-load for all dashboard pages
You can do this from your package controller with a path check:
use ConcreteCoreAssetAssetList;
use ConcreteCoreHttpRequest;
public function on_start()
{
$al = AssetList::getInstance();
$al->register('javascript', 'sfm-admin-js', 'js/admin.js', [], $this);
$al->register('css', 'sfm-admin-css', 'css/admin.css', [], $this);
$req = $this->app->make(Request::class);
$path = $req->getPath();
if (str_starts_with($path, '/dashboard/shopping_feed')) {
// manually output in header from view/controller
View::getInstance()->requireAsset('javascript', 'sfm-admin-js');
View::getInstance()->requireAsset('css', 'sfm-admin-css');
}
}
> Note: `View::getInstance()->requireAsset()` works globally when called in `on_start()`.
—
## ✅ Summary
| Context | Correct way |
|——–|————–|
| `controller.php` (package) | Use `AssetList::register()` and optionally `View::getInstance()->requireAsset(…)` |
| `DashboardPageController` | Use `$this->requireAsset(…)` |
—
Would you like me to patch your package and re-zip it with this applied?
> `Target class [ConcretePackageShoppingFeedManagerRequest] does not exist.`
means you're trying to `make()` or `use` the class `Request` without the correct namespace.
—
## ✅ Correct Class Name
The correct `Request` class is:
ConcreteCoreHttpRequest
So at the top of your `controller.php`, make sure you have:
use ConcreteCoreHttpRequest;
Then use it like this inside `on_start()`:
$request = $this->app->make(Request::class);
$path = $request->getPathInfo(); // or ->getPath() if you prefer
—
### ✅ Full Correct Usage Example
use ConcreteCoreAssetAssetList;
use ConcreteCoreHttpRequest;
public function on_start()
{
$al = AssetList::getInstance();
$al->register('javascript', 'sfm-admin-js', 'js/admin.js', [], $this);
$al->register('css', 'sfm-admin-css', 'css/admin.css', [], $this);
$request = $this->app->make(Request::class);
$path = $request->getPathInfo();
if (strpos($path, '/dashboard/shopping_feed') === 0) {
View::getInstance()->requireAsset('javascript', 'sfm-admin-js');
View::getInstance()->requireAsset('css', 'sfm-admin-css');
}
}
—
Let me know if you'd like me to inject this into your `controller.php` and repackage the ZIP.
—
## ✅ Goal
– Include **React** and **ReactDOM** from a CDN or locally
– Register them properly in your Concrete CMS package
– Use them in your dashboard view
—
## ✅ Option 1: Register and load from CDN (recommended for React)
### Step 1: Add in `controller.php → on_start()`
use ConcreteCoreAssetAssetList;
public function on_start()
{
$al = AssetList::getInstance();
// Register external React + ReactDOM from CDN
$al->register(
'javascript',
'react',
'https://unpkg.com/react@18/umd/react.development.js',
['local' => false]
);
$al->register(
'javascript',
'react-dom',
'https://unpkg.com/react-dom@18/umd/react-dom.development.js',
['local' => false]
);
// Optionally register your own JS that depends on React
$al->register(
'javascript',
'my-react-ui',
'js/react-ui.js',
['position' => 'footer', 'depends' => ['react', 'react-dom']],
$this
);
}
—
### Step 2: Require the assets in your dashboard controller
Inside your controller (e.g. `DashboardPageController` subclass):
public function on_start()
{
$this->requireAsset('javascript', 'react');
$this->requireAsset('javascript', 'react-dom');
$this->requireAsset('javascript', 'my-react-ui'); // your component file
}
—
### Step 3: Create `react-ui.js` in your package
📁 Path: `packages/shopping_feed_manager/js/react-ui.js`
document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById("react-root");
if (container) {
const element = React.createElement('h2', null, 'Hello from React!');
ReactDOM.render(element, container);
}
});
—
### Step 4: Use it in your view (e.g. `view.php`)
<div id="react-root"></div>
—
## ✅ Option 2: Use Local React Files (not preferred unless offline)
Download UMD builds of React + ReactDOM and place in `/js/` in your package, then register and load them exactly like above with `local => true`.
—
## ✅ Result
You now have:
– ✅ React & ReactDOM loaded from CDN
– ✅ Your own script loaded after them
– ✅ Rendering inside Concrete CMS dashboard page
—
Let me know if you'd like me to bundle this into your ZIP and make a `react-ui.js` file with sample component rendering.
—
## ✅ Assumptions
– React and ReactDOM are already registered and loading
– You have a working AJAX route:
/dashboard/shopping_feed/delete_json/{encryptedId}
—
## ✅ 1. Update HTML view: `view.php`
Replace your existing “ action buttons with:
<td>
<div id="react-delete-<?= $feed['id'] ?>"
data-id="<?= urlencode($encrypter->encrypt($feed['id'])) ?>">
</div>
</td>
Do this inside your loop:
<?php foreach ($feeds as $feed):
$encId = urlencode($encrypter->encrypt($feed['id']));
?>
<tr>
...
<td>
<div id="react-delete-<?= $feed['id'] ?>"
data-id="<?= $encId ?>">
</div>
</td>
</tr>
<?php endforeach; ?>
—
## ✅ 2. Create `react-ui.js` in your package
📁 File: `packages/shopping_feed_manager/js/react-ui.js`
document.addEventListener("DOMContentLoaded", function () {
class DeleteButton extends React.Component {
constructor(props) {
super(props);
this.state = { loading: false };
}
handleDelete = () => {
if (!confirm("Are you sure?")) return;
this.setState({ loading: true });
fetch(`/dashboard/shopping_feed/delete_json/${this.props.id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
// Remove table row
const row = this.container.closest('tr');
row?.remove();
} else {
alert('Error: ' + data.message);
}
})
.catch(() => alert('Request failed.'))
.finally(() => this.setState({ loading: false }));
}
render() {
return (
<button className="btn btn-sm btn-danger" onClick={this.handleDelete} disabled={this.state.loading}>
{this.state.loading ? 'Deleting...' : 'Delete'}
</button>
);
}
componentDidMount() {
this.container = document.getElementById(`react-delete-${this.props.rawId}`);
}
}
// Mount delete buttons
document.querySelectorAll('[id^="react-delete-"]').forEach(div => {
const id = div.dataset.id;
const rawId = div.id.replace("react-delete-", "");
ReactDOM.render(<DeleteButton id={id} rawId={rawId} />, div);
});
});
—
## ✅ 3. Make Sure Assets Are Registered
In your `controller.php → on_start()`:
$al->register(
'javascript',
'my-react-ui',
'js/react-ui.js',
['position' => 'footer', 'depends' => ['react', 'react-dom']],
$this
);
And your dashboard controller (e.g. `ShoppingFeed.php`) includes:
$this->requireAsset('javascript', 'react');
$this->requireAsset('javascript', 'react-dom');
$this->requireAsset('javascript', 'my-react-ui');
—
## ✅ Result
– Each feed row now has a **React-powered Delete** button
– Deleting sends AJAX to your encrypted `/delete_json/{id}` route
– On success, removes the row from the DOM
—
Would you like me to build this into your package zip and return a ready-to-use version?
<script>
function Hello() {
return <h1>Hello World!</h1>;
}
const container = document.getElementById('react-root');
const root = ReactDOM.createRoot(container);
root.render(<Hello />)
</script>
not working
> **Concrete CMS does not automatically include Babel.**
So “ won't be processed unless **you manually include Babel** in the page.
—
## ✅ Solution: Include Babel + React + ReactDOM
Here’s how to make it work:
—
### ✅ 1. Add this to your `view.php` (above the “)
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
—
### ✅ 2. Then your Babel JSX block will work:
<script type="text/babel">
function Hello() {
return <h1>Hello from React with Babel!</h1>;
}
const container = document.getElementById('react-root');
const root = ReactDOM.createRoot(container);
root.render(<Hello />);
</script>
—
### ⚠️ Important Notes
– This works **only for development**. Babel in the browser is **slow** and not production-ready.
– For production, you should compile JSX with a build tool (e.g. Vite/Webpack) and include plain JS.
—
## ✅ Your Final Working Code in `view.php`
<div id="react-root">
<div class="ccm-dashboard-header-buttons">
<a href="<?= $this->url('/dashboard/shopping_feed/add') ?>" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Feed
</a>
</div>
<h2>Shopping Feed Manager</h2>
<?php if (!empty($feeds)) : ?>
<!-- your existing table here -->
<?php else: ?>
<div class="alert alert-info">No feeds found yet.</div>
<?php endif; ?>
</div>
<!-- Include React + Babel -->
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Your Babel JSX code -->
<script type="text/babel">
function Hello() {
return <h1>Hello from React with Babel!</h1>;
}
const container = document.getElementById('react-root');
const root = ReactDOM.createRoot(container);
root.render(<Hello />);
</script>
—
Let me know if you want to switch to using a **compiled React app** inside your package with `npm build` and no Babel at runtime.
You’ll:
– ✅ Use React to fetch the feed list via AJAX
– ✅ Render the table entirely inside a React component
– ✅ Use a Concrete CMS route to serve the JSON feed list
—
## ✅ Step 1: Create JSON API Endpoint in Your Package
### 📄 Controller: `FeedApi.php`
Create in:
`packages/shopping_feed_manager/controllers/dashboard/shopping_feed/feed_api.php`
<?php
namespace ConcretePackageShoppingFeedManagerControllerDashboardShoppingFeed;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpResponseFactoryInterface;
class FeedApi extends AbstractController
{
public function view()
{
$db = $this->app->make(Connection::class);
$feeds = $db->fetchAll("SELECT id, url, name, created_at FROM shoppingfeedconfiguration");
return $this->app->make(ResponseFactoryInterface::class)
->json($feeds);
}
}
—
## ✅ Step 2: Register Route in `controller.php`
In your package’s `controller.php → on_start()`:
use Route;
public function on_start()
{
Route::register(
'/dashboard/shopping_feed/api/feeds',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\FeedApi::view'
);
}
Now `/dashboard/shopping_feed/api/feeds` will return feed data in JSON.
—
## ✅ Step 3: Add React Component in `view.php`
In your `view.php`:
### ✅ A. Add `div` for React
<div id="react-feed-table"></div>
### ✅ B. Include React + Babel
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
### ✅ C. JSX Script to Render Table
<script type="text/babel">
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
React.useEffect(() => {
fetch('/dashboard/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => setFeeds(data));
}, []);
if (feeds.length === 0) {
return <div class="alert alert-info">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
))}
</tbody>
</table>
);
}
const root = ReactDOM.createRoot(document.getElementById('react-feed-table'));
root.render(<FeedTable />);
</script>
—
## ✅ Done!
Your feed list is now rendered 100% by React, pulling live data via `/dashboard/shopping_feed/api/feeds`.
—
Let me know if you want:
– Add edit/delete buttons to each row
– Pagination or search
– React bundled via `npm` for production use (no Babel)
—
## ✅ Overview of What We’ll Do:
– Add a “Delete” button to each row in the React table
– When clicked:
– Confirm deletion
– Send POST request to `/dashboard/shopping_feed/delete_json/{encryptedId}`
– On success: remove the row from UI
– Use Concrete's encryption for ID protection
—
## ✅ Step 1: Modify JSON API to Include Encrypted ID
In your `FeedApi.php`:
use ConcreteCoreUtilityServiceEncryption;
public function view()
{
$db = $this->app->make(Connection::class);
$feeds = $db->fetchAll("SELECT id, url, name, created_at FROM shoppingfeedconfiguration");
$encrypter = $this->app->make(Encryption::class);
foreach ($feeds as &$feed) {
$feed['encrypted_id'] = urlencode($encrypter->encrypt($feed['id']));
}
return $this->app->make(ResponseFactoryInterface::class)->json($feeds);
}
Now every row in the JSON response includes a safe `encrypted_id`.
—
## ✅ Step 2: Update React Component in `view.php`
Replace the “ section with:
<script type="text/babel">
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
React.useEffect(() => {
fetch('/dashboard/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => setFeeds(data));
}, []);
function deleteFeed(encId) {
if (!confirm('Are you sure you want to delete this feed?')) return;
fetch(`/dashboard/shopping_feed/delete_json/${encId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
setFeeds(prev => prev.filter(f => f.encrypted_id !== encId));
} else {
alert('Error: ' + data.message);
}
})
.catch(() => alert('Request failed.'));
}
if (feeds.length === 0) {
return <div className="alert alert-info">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
<button className="btn btn-sm btn-danger"
onClick={() => deleteFeed(feed.encrypted_id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
const root = ReactDOM.createRoot(document.getElementById('react-feed-table'));
root.render(<FeedTable />);
</script>
—
## ✅ Step 3: Confirm Delete Route Exists
In your `controller.php → on_start()`:
Route::register(
'/dashboard/shopping_feed/delete_json/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\Delete::delete'
);
And your controller `Delete::delete($encId)` should decrypt and remove the row.
—
## ✅ Done
Now each row has a **Delete** button that:
– Uses the encrypted ID
– Sends an AJAX POST request
– Removes the row on success
Let me know if you want the **Edit** button moved into React too.
<script>
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
React.useEffect(() => {
fetch('/dashboard/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => setFeeds(data));
}, []);
if (feeds.length === 0) {
return <div class="alert alert-info">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
))}
</tbody>
</table>
);
}
const root = ReactDOM.createRoot(document.getElementById('react-feed-table'));
root.render(<FeedTable />);
</script>
can we make it more standard
—
## ✅ Refactored Version: Clean & Modular
We'll use:
– ✅ Proper function and file layout
– ✅ Destructured props
– ✅ Arrow functions
– ✅ Separate `FeedRow` component
– ✅ Deletion support with inline callback (optional)
—
### 🔁 Replace your entire “ with this:
<script type="text/babel">
const FeedRow = ({ feed }) => {
return (
<tr>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
{/* Placeholder for actions */}
<button className="btn btn-sm btn-secondary">Edit</button>
<button className="btn btn-sm btn-danger">Delete</button>
</td>
</tr>
);
};
const FeedTable = () => {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch('/dashboard/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
}, []);
if (loading) {
return <div className="alert alert-info">Loading feeds...</div>;
}
if (feeds.length === 0) {
return <div className="alert alert-warning">No feeds found.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<FeedRow key={feed.id} feed={feed} />
))}
</tbody>
</table>
);
};
ReactDOM.createRoot(document.getElementById('react-feed-table')).render(<FeedTable />);
</script>
—
### ✅ Why This is Better
– ✅ **Modular**: `FeedRow` component separates concerns
– ✅ **Cleaner logic**: `loading` and `empty` states
– ✅ **Reusable**: Easy to add `onDelete()` or `onEdit()` props
– ✅ **Maintains JSX readability** for team or future expansion
—
### 🧪 Optional Improvements
– Add `Edit` and `Delete` logic using props or context
– Style with `Bootstrap` or Tailwind
– Extract into a compiled `react-ui.js` instead of using `type="text/babel"` (for production)
Let me know if you'd like me to generate a **compiled version using Vite/Webpack** instead of in-browser Babel.
– A controlled form inside React
– POST request to your existing backend (`/dashboard/shopping_feed/create`)
– Live reload of the feed list after adding
—
## ✅ Step-by-Step: Add Feed in React
—
### ✅ 1. Make sure backend `/dashboard/shopping_feed/create` route works with JSON POST
**Controller (`Feed::create`) must:**
if ($this->request->isXmlHttpRequest()) {
return $this->app->make(ResponseFactoryInterface::class)
->json(['success' => true]);
}
—
### ✅ 2. Update `FeedTable` React Component
### 📦 Replace entire “ with this:
<script type="text/babel">
const FeedRow = ({ feed }) => (
<tr>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
);
const AddFeedForm = ({ onAdd }) => {
const [name, setName] = React.useState('');
const [url, setUrl] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const handleSubmit = (e) => {
e.preventDefault();
setLoading(true);
setError(null);
fetch('/shopping_feed/create', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({ name, url })
})
.then(res => res.json())
.then(res => {
if (res.success) {
onAdd();
setName('');
setUrl('');
} else {
setError(res.message || 'Failed to add feed');
}
})
.catch(() => setError('Request failed'))
.finally(() => setLoading(false));
};
return (
<form onSubmit={handleSubmit} className="mb-4">
<div className="row g-2 align-items-end">
<div className="col">
<label>Name</label>
<input className="form-control" value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="col">
<label>URL</label>
<input className="form-control" value={url} onChange={e => setUrl(e.target.value)} required />
</div>
<div className="col-auto">
<button className="btn btn-success" type="submit" disabled={loading}>
{loading ? 'Adding...' : 'Add Feed'}
</button>
</div>
</div>
{error && <div className="text-danger mt-2">{error}</div>}
</form>
);
};
const FeedTable = () => {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const fetchFeeds = () => {
setLoading(true);
fetch('/dashboard/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
};
React.useEffect(fetchFeeds, []);
return (
<div>
<AddFeedForm onAdd={fetchFeeds} />
{loading ? (
<div className="alert alert-info">Loading feeds...</div>
) : feeds.length === 0 ? (
<div className="alert alert-warning">No feeds found.</div>
) : (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => <FeedRow key={feed.id} feed={feed} />)}
</tbody>
</table>
)}
</div>
);
};
ReactDOM.createRoot(document.getElementById('react-feed-table')).render(<FeedTable />);
</script>
—
### ✅ 3. Remove PHP “ and keep only:
<div id="react-feed-table"></div>
—
### ✅ 4. Make sure the form works
If your `/shopping_feed/create` route does not return JSON, make it return this when `isXmlHttpRequest()`:
return $this->app->make(ResponseFactoryInterface::class)->json([
'success' => true,
'message' => 'Feed created successfully.'
]);
—
## ✅ Done!
You now have:
– A React-powered "Add Feed" form
– Live AJAX insert
– Auto-refresh of the feed list
Let me know if you'd like to add field validation or display a toast message on success.
– ✅ Show only the **feed list**
– ✅ Include a **Delete** button with AJAX
– ✅ Add **pagination** (client-side or server-side)
—
## ✅ Let’s Use Client-Side Pagination (simpler)
This keeps everything in one component and doesn’t require backend paging logic.
—
## ✅ Full React Component: Feed List + Delete + Pagination
Replace your “ in `view.php` with:
<script type="text/babel">
const FeedRow = ({ feed, onDelete }) => (
<tr>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
<button className="btn btn-sm btn-danger" onClick={() => onDelete(feed.encrypted_id)}>
Delete
</button>
</td>
</tr>
);
const FeedTable = () => {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [currentPage, setCurrentPage] = React.useState(1);
const feedsPerPage = 5;
const fetchFeeds = () => {
setLoading(true);
fetch('/dashboard/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
};
const deleteFeed = (encId) => {
if (!confirm("Are you sure?")) return;
fetch(`/dashboard/shopping_feed/delete_json/${encId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
setFeeds(prev => prev.filter(f => f.encrypted_id !== encId));
} else {
alert('Error: ' + data.message);
}
})
.catch(() => alert('Delete request failed.'));
};
React.useEffect(fetchFeeds, []);
const totalPages = Math.ceil(feeds.length / feedsPerPage);
const paginatedFeeds = feeds.slice((currentPage - 1) * feedsPerPage, currentPage * feedsPerPage);
return (
<div>
{loading ? (
<div className="alert alert-info">Loading feeds...</div>
) : feeds.length === 0 ? (
<div className="alert alert-warning">No feeds found.</div>
) : (
<>
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{paginatedFeeds.map(feed => (
<FeedRow key={feed.id} feed={feed} onDelete={deleteFeed} />
))}
</tbody>
</table>
<nav>
<ul className="pagination">
{Array.from({ length: totalPages }, (_, i) => (
<li key={i} className={`page-item ${i + 1 === currentPage ? 'active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(i + 1)}>
{i + 1}
</button>
</li>
))}
</ul>
</nav>
</>
)}
</div>
);
};
ReactDOM.createRoot(document.getElementById('react-feed-table')).render(<FeedTable />);
</script>
—
## ✅ Confirm JSON Response Has `encrypted_id`
Your `/dashboard/shopping_feed/api/feeds` must include this:
foreach ($feeds as &$feed) {
$feed['encrypted_id'] = urlencode($encrypter->encrypt($feed['id']));
}
—
## ✅ Final View Markup
Your `view.php` should only include:
<div id="react-feed-table"></div>
And the React script block above.
—
✅ You now have:
– Feed list with **client-side pagination**
– AJAX-powered **Delete** per row
– **No Add form**
Let me know if you'd like server-side pagination or search added next.
—
## ✅ What Changes
– Remove `encrypted_id` from JSON API
– Use `id` directly in frontend for AJAX delete
– Update delete route to accept plain ID
—
## ✅ Step 1: Update `FeedApi.php` (JSON feed)
Replace:
$feed['encrypted_id'] = urlencode($encrypter->encrypt($feed['id']));
with:
✅ Just return the `id` as-is:
return $this->app->make(ResponseFactoryInterface::class)->json($feeds);
—
## ✅ Step 2: Update Delete Route (in `controller.php`)
Update this:
Route::register(
'/dashboard/shopping_feed/delete_json/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\Delete::delete'
);
Your controller `Delete::delete($id)` should now directly use the ID:
public function delete($id)
{
$db = $this->app->make(Connection::class);
$db->delete('shoppingfeedconfiguration', ['id' => $id]);
return $this->app->make(ResponseFactoryInterface::class)->json([
'success' => true,
'message' => 'Feed deleted.'
]);
}
—
## ✅ Step 3: Update React Component
Replace the `deleteFeed` function and feed mapping section like this:
const deleteFeed = (id) => {
if (!confirm("Are you sure?")) return;
fetch(`/dashboard/shopping_feed/delete_json/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
setFeeds(prev => prev.filter(f => f.id !== id));
} else {
alert('Error: ' + data.message);
}
})
.catch(() => alert('Delete request failed.'));
};
// ...
{paginatedFeeds.map(feed => (
<FeedRow key={feed.id} feed={feed} onDelete={deleteFeed} />
))}
In `FeedRow`:
<button className="btn btn-sm btn-danger" onClick={() => onDelete(feed.id)}>
Delete
</button>
—
## ✅ Done
Your app now:
– Uses plain `id` for all actions
– Avoids encryption logic
– Works cleanly with React pagination + delete
Let me know if you'd like to add real-time success messages or server-side paging next.
GET https://concrete-cms.ddev.site/dashboard/shopping_feed/api/feeds 500 (Internal Server Error)
(anonymous) @ Inline Babel script:6
commitHookEffectListMount @ react-dom.development.js:23199
commitPassiveMountOnFiber @ react-dom.development.js:24980
commitPassiveMountEffects_complete @ react-dom.development.js:24940
commitPassiveMountEffects_begin @ react-dom.development.js:24927
commitPassiveMountEffects @ react-dom.development.js:24915
flushPassiveEffectsImpl @ react-dom.development.js:27088
flushPassiveEffects @ react-dom.development.js:27033
(anonymous) @ react-dom.development.js:26818
workLoop @ react.development.js:2653
flushWork @ react.development.js:2626
performWorkUntilDeadline @ react.development.js:2920Understand this errorAI
VM1980:1 Uncaught (in promise) SyntaxError: Unexpected token '
GET https://concrete-cms.ddev.site/dashboard/shopping_feed/api/feeds 500 (Internal Server Error)
...
Uncaught (in promise) SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
—
### 🔍 What this means:
– React tried to fetch JSON from `/dashboard/shopping_feed/api/feeds`
– But instead of JSON, it received an **HTML error page**
– Most likely: a PHP **fatal error** or missing controller
—
## ✅ Step-by-step fix
—
### ✅ 1. Check the actual response from the server
Open your browser Dev Tools > Network tab, and inspect the **response** body for `/api/feeds`. You will likely see an HTML error like:
Call to undefined class ...
Class FeedApi not found...
Missing required parameter...
—
### ✅ 2. Verify `FeedApi.php` exists
✅ It should be here:
packages/shopping_feed_manager/controllers/dashboard/shopping_feed/feed_api.php
And look like:
<?php
namespace ConcretePackageShoppingFeedManagerControllerDashboardShoppingFeed;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpResponseFactoryInterface;
class FeedApi extends AbstractController
{
public function view()
{
$db = $this->app->make(Connection::class);
$feeds = $db->fetchAll("SELECT id, url, name, created_at FROM shoppingfeedconfiguration");
return $this->app->make(ResponseFactoryInterface::class)->json($feeds);
}
}
—
### ✅ 3. Confirm route is registered in `controller.php`
In `on_start()` of your package controller:
use Route;
public function on_start()
{
Route::register(
'/dashboard/shopping_feed/api/feeds',
'Concrete\Package\ShoppingFeedManager\Controller\Dashboard\ShoppingFeed\FeedApi::view'
);
}
—
### ✅ 4. Clear Concrete cache (important!)
./vendor/bin/concrete c5:clear-cache
Or in dashboard:
**System & Settings → Optimization → Clear Cache**
—
### ✅ 5. Reload the page
Once the route is registered and the class is properly loaded, React will get a valid JSON response like:
[
{
"id": 1,
"name": "Test Feed",
"url": "https://example.com",
"created_at": "2025-05-15 12:34:56"
}
]
And the table will render.
—
Let me know if you'd like to generate a working ZIP with the JSON API controller and route preconfigured.
'/dashboard/shopping_feed/api/feeds',
'Concrete\Package\ShoppingFeedManager\Controller\SinglePage\Dashboard\ShoppingFeed::apiView'
);
We are usingg shopping_feed.php
—
## ✅ Summary of Your Setup
– Controller: `ShoppingFeed` in
`ConcretePackageShoppingFeedManagerControllerSinglePageDashboard`
– Functions:
– `view()` → renders dashboard UI
– `apiView()` → returns JSON list
– `deleteFeed($id)` → deletes by ID (returns JSON)
—
## ✅ 1. Confirm Route Registration
In your package’s `controller.php`, the route must look like this:
Route::register(
'/dashboard/shopping_feed/api/feeds',
'Concrete\Package\ShoppingFeedManager\Controller\SinglePage\Dashboard\ShoppingFeed::apiView'
);
Route::register(
'/dashboard/shopping_feed/delete_json/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\SinglePage\Dashboard\ShoppingFeed::deleteFeed'
);
✅ This is correct now.
—
## ✅ 2. Fix for 500 Error in React
**Issue:** When Concrete CMS calls your controller directly via route (`ShoppingFeed::apiView()`), it does not go through the `DashboardPageController` lifecycle.
So, this line:
$this->app->make(...)
Can fail if `$this->app` isn’t initialized.
—
### ✅ Fix: Always use `Core::make()` or `app()` inside API routes
Change `apiView()` to:
public function apiView()
{
$db = Core::make(Connection::class);
$feeds = $db->fetchAll("SELECT id, name, url, created_at FROM shoppingfeedconfiguration");
return Core::make(ResponseFactoryInterface::class)->json($feeds);
}
Same for `deleteFeed()`:
public function deleteFeed($id)
{
$db = Core::make(Connection::class);
try {
$db->executeQuery("DELETE FROM shoppingfeedconfiguration WHERE id = ?", [$id]);
return Core::make(ResponseFactoryInterface::class)
->json(['success' => true, 'message' => 'Feed deleted']);
} catch (Exception $e) {
return Core::make(ResponseFactoryInterface::class)
->json(['success' => false, 'message' => 'Invalid ID'], 400);
}
}
✅ This ensures `apiView()` and `deleteFeed()` never rely on `$this->app`, which may not be initialized when calling the controller via direct route.
—
## ✅ 3. Clear Cache
After registering routes and fixing the controller:
./vendor/bin/concrete c5:clear-cache
Or use the dashboard:
**System & Settings → Optimization → Clear Cache**
—
## ✅ 4. Test URLs
– Open `/dashboard/shopping_feed/api/feeds`
Should return raw JSON array
– Open `/dashboard/shopping_feed/delete_json/1`
Should return `{"success": true, …}`
—
Let me know if you want to now combine **React + server-side pagination**, or repackage all this into a ready-to-install ZIP.
Creating a **separate controller for API actions** keeps your logic clean, avoids lifecycle issues with `DashboardPageController`, and clearly separates views from backend actions.
—
## ✅ Why Create a Separate Controller?
| Concern | `DashboardPageController` | `AbstractController` (API) |
|————————|—————————|—————————-|
| View Rendering | ✅ Yes | ❌ No |
| Route-only logic (AJAX/API) | ❌ Messy | ✅ Clean |
| Automatic loading via Single Page | ✅ Yes | ❌ No |
| Use for JSON, React, Vue | 🚫 Avoid | ✅ Recommended |
—
## ✅ Step-by-Step: Create API Controller
—
### ✅ 1. File: `controllers/api/feed_api.php`
<?php
namespace ConcretePackageShoppingFeedManagerControllerApi;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpResponseFactoryInterface;
class FeedApi extends AbstractController
{
public function view()
{
$db = Core::make(Connection::class);
$feeds = $db->fetchAll("SELECT id, name, url, created_at FROM shoppingfeedconfiguration");
return Core::make(ResponseFactoryInterface::class)->json($feeds);
}
public function delete($id)
{
$db = Core::make(Connection::class);
try {
$db->executeQuery("DELETE FROM shoppingfeedconfiguration WHERE id = ?", [$id]);
return Core::make(ResponseFactoryInterface::class)
->json(['success' => true]);
} catch (Exception $e) {
return Core::make(ResponseFactoryInterface::class)
->json(['success' => false, 'message' => 'Invalid ID'], 400);
}
}
}
—
### ✅ 2. Register Routes in `controller.php`
In your package's `controller.php → on_start()`:
use Route;
public function on_start()
{
Route::register(
'/shopping_feed/api/feeds',
'Concrete\Package\ShoppingFeedManager\Controller\Api\FeedApi::view'
);
Route::register(
'/shopping_feed/api/delete/{id}',
'Concrete\Package\ShoppingFeedManager\Controller\Api\FeedApi::delete'
);
}
✅ Notice: these are **not dashboard routes** — they're clean `/shopping_feed/api/…` endpoints.
—
### ✅ 3. Update React Fetch Calls
In your “:
fetch('/shopping_feed/api/feeds') // instead of /dashboard/...
And for delete:
fetch(`/shopping_feed/api/delete/${id}`, { method: 'POST', ... })
—
## ✅ Done — Clean Separation
Now:
– Dashboard page: `shopping_feed.php` → only view logic
– API controller: `FeedApi.php` → all React/JS/backend logic
– No more confusion over route lifecycle or `$this->app`
—
Would you like me to zip and share a working version of this clean API setup?
<script>
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
React.useEffect(() => {
fetch('/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => setFeeds(data));
}, []);
if (feeds.length === 0) {
return <div className="alert alert-info">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
))}
</tbody>
</table>
);
}
const container = document.getElementById('react-root');
const root = ReactDOM.createRoot(container);
root.render(<FeedTable />)
</script>
Dont show no feeed show item loading untill data return then if no return then show no feed found
1. ✅ A **loading** state while data is being fetched
2. ✅ Show **"No feeds found"** **only after** the data has returned and is empty
3. ✅ Keep UI clean and user-friendly
—
## ✅ Updated Script with Loading and Empty State Handling
Replace your “ with:
<script type="text/babel">
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch('/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
}, []);
if (loading) {
return <div className="alert alert-secondary">Loading feeds...</div>;
}
if (feeds.length === 0) {
return <div className="alert alert-warning">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
))}
</tbody>
</table>
);
}
const container = document.getElementById('react-root');
const root = ReactDOM.createRoot(container);
root.render(<FeedTable />);
</script>
—
## ✅ Behavior
– Shows **"Loading feeds…"** while fetching
– If the result is empty → shows **"No feeds found yet"**
– If feeds exist → renders the full table
Let me know if you want to add search, pagination, or delete to this.
—
## ✅ Final Script with Safety Checks
This version includes:
– ✅ Check if `React` and `ReactDOM` are loaded
– ✅ Check if the container element (`#react-root`) exists
– ✅ Gracefully handle failures
<script type="text/babel">
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch('/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
}, []);
if (loading) {
return <div className="alert alert-secondary">Loading feeds...</div>;
}
if (feeds.length === 0) {
return <div className="alert alert-warning">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
))}
</tbody>
</table>
);
}
// ✅ Safe mount
document.addEventListener("DOMContentLoaded", function () {
if (typeof React === "undefined" || typeof ReactDOM === "undefined") {
console.error("React or ReactDOM not loaded.");
return;
}
const container = document.getElementById("react-root");
if (!container) {
console.error("Target #react-root not found.");
return;
}
const root = ReactDOM.createRoot(container);
root.render(<FeedTable />);
});
</script>
—
## ✅ Bonus: Add Fallback in HTML
In case JS fails, you can add this inside `#react-root`:
<div id="react-root">
<noscript>Please enable JavaScript to view the feed list.</noscript>
</div>
—
Let me know if you'd like to move this JSX to a `.js` file and compile via Vite/Webpack for production.
1. ✅ Place a loader in the HTML inside `#react-root`
2. ✅ Have React replace that loader once it mounts
3. ✅ Optionally fade it out for smoother UX
—
## ✅ Step-by-Step Implementation
—
### ✅ 1. In your HTML (`view.php` or equivalent):
<div id="react-root">
<div class="react-loader alert alert-secondary">Loading feeds, please wait...</div>
</div>
> You can customize `.react-loader` with a spinner or animated text using Bootstrap or CSS.
—
### ✅ 2. Add CSS for smoother UX (optional)
<style>
.react-loader {
text-align: center;
padding: 20px;
}
</style>
—
### ✅ 3. Update your “ to remove loader before mounting React
<script type="text/babel">
function FeedTable() {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch('/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
}, []);
if (loading) {
return <div className="alert alert-secondary">Loading feeds...</div>;
}
if (feeds.length === 0) {
return <div className="alert alert-warning">No feeds found yet.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
</tr>
))}
</tbody>
</table>
);
}
document.addEventListener("DOMContentLoaded", function () {
if (typeof React === "undefined" || typeof ReactDOM === "undefined") {
console.error("React or ReactDOM not loaded.");
return;
}
const container = document.getElementById("react-root");
if (!container) {
console.error("Target #react-root not found.");
return;
}
// 🧼 Remove loader before mounting
const loader = container.querySelector('.react-loader');
if (loader) loader.remove();
const root = ReactDOM.createRoot(container);
root.render(<FeedTable />);
});
</script>
—
### ✅ Result
– Static loader is shown instantly (before React loads)
– When React initializes, it removes the loader and renders the real UI
– If something fails, loader is not stuck forever
Let me know if you want a Bootstrap spinner or animated loader added to the layout.
—
## ✅ Final Result Will Include:
– 📋 **Feed table with Edit/Delete buttons**
– 🧠 React state management for deleting
– 🛠️ Edit button with a link (e.g. to a separate edit page)
– 🧼 Full UI and fallback loader
—
## ✅ 1. Update Your “
Replace your current React script with this:
<script type="text/babel">
const FeedRow = ({ feed, onDelete }) => (
<tr>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
<a className="btn btn-sm btn-secondary me-2" href={`/dashboard/shopping_feed/edit/${feed.id}`}>
Edit
</a>
<button className="btn btn-sm btn-danger" onClick={() => onDelete(feed.id)}>
Delete
</button>
</td>
</tr>
);
const FeedTable = () => {
const [feeds, setFeeds] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const fetchFeeds = () => {
fetch('/shopping_feed/api/feeds')
.then(res => res.json())
.then(data => {
setFeeds(data);
setLoading(false);
})
.catch(() => {
setFeeds([]);
setLoading(false);
});
};
const deleteFeed = (id) => {
if (!confirm("Are you sure you want to delete this feed?")) return;
fetch(`/shopping_feed/api/delete/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
setFeeds(prev => prev.filter(f => f.id !== id));
} else {
alert('Error: ' + data.message);
}
})
.catch(() => alert('Delete failed.'));
};
React.useEffect(fetchFeeds, []);
if (loading) {
return <div className="alert alert-secondary">Loading feeds...</div>;
}
if (feeds.length === 0) {
return <div className="alert alert-warning">No feeds found.</div>;
}
return (
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{feeds.map(feed => (
<FeedRow key={feed.id} feed={feed} onDelete={deleteFeed} />
))}
</tbody>
</table>
);
};
document.addEventListener("DOMContentLoaded", function () {
if (typeof React === "undefined" || typeof ReactDOM === "undefined") {
console.error("React or ReactDOM not loaded.");
return;
}
const container = document.getElementById("react-root");
if (!container) {
console.error("Target #react-root not found.");
return;
}
const loader = container.querySelector('.react-loader');
if (loader) loader.remove();
const root = ReactDOM.createRoot(container);
root.render(<FeedTable />);
});
</script>
—
## ✅ 2. HTML Placeholder
Make sure your `view.php` has:
<div id="react-root">
<div class="react-loader alert alert-info">Loading feeds...</div>
</div>
—
## ✅ 3. Backend URLs
– `/dashboard/shopping_feed/edit/{id}` → edit page (already existing)
– `/shopping_feed/api/delete/{id}` → delete route (registered in `controller.php`)
– `/shopping_feed/api/feeds` → JSON list (separate controller)
—
You now have:
– 🧠 Smart React-powered table
– 🧹 Clean loader logic
– 🖱️ Edit button that links to PHP page
– 🗑️ Delete via AJAX + instant row removal
Let me know if you want a modal edit form in React next!
{feed.id}
{feed.url}
{feed.name}
{feed.created_at}
Edit
deleteFeed(feed.id)}>Delete
))}
apply feed lenght condition here
Here’s the updated JSX with the check inline:
—
## ✅ Updated Feed List Rendering With `feeds.length` Check
Replace:
{feeds.map(feed => (
<tr key={feed.id}>
...
</tr>
))}
With:
{feeds.length > 0 && feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
<a href={`/dashboard/shopping_feed/edit/${feed.id}`} className="btn btn-sm btn-secondary me-2">Edit</a>
<button className="btn btn-sm btn-danger" onClick={() => deleteFeed(feed.id)}>Delete</button>
</td>
</tr>
))}
—
### ✅ Why this works:
– React will skip rendering `.map()` if `feeds.length === 0`
– You already have a fallback like:
if (feeds.length === 0) return <div>No feeds found</div>
So this prevents any accidental rendering
—
Let me know if you want to include a "No items" “ inside the “ instead of a separate message outside the table.
—
## ✅ Final React Table with "No Feeds" Message Inside “
Replace your feed list rendering block with this:
<tbody>
{feeds.length > 0 ? (
feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
<a href={`/dashboard/shopping_feed/edit/${feed.id}`} className="btn btn-sm btn-secondary me-2">Edit</a>
<button className="btn btn-sm btn-danger" onClick={() => deleteFeed(feed.id)}>Delete</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan="5" className="text-center text-muted">No feeds found.</td>
</tr>
)}
</tbody>
—
## ✅ Full Table Example:
<table className="table table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Feed URL</th>
<th>Name</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{feeds.length > 0 ? (
feeds.map(feed => (
<tr key={feed.id}>
<td>{feed.id}</td>
<td>{feed.url}</td>
<td>{feed.name}</td>
<td>{feed.created_at}</td>
<td>
<a href={`/dashboard/shopping_feed/edit/${feed.id}`} className="btn btn-sm btn-secondary me-2">Edit</a>
<button className="btn btn-sm btn-danger" onClick={() => deleteFeed(feed.id)}>Delete</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan="5" className="text-center text-muted">No feeds found.</td>
</tr>
)}
</tbody>
</table>
—
Now your table will:
– Always render “ and column layout
– Show a meaningful "No feeds found." message inside the table if empty
Let me know if you want to animate the row removal or highlight newly added rows too!
—
## ✅ Step-by-Step: Create Google Merchant XML
—
### 📄 File: `controllers/merchant_feed.php`
<?php
namespace ConcretePackageShoppingFeedManagerController;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreDatabaseConnectionConnection;
use SymfonyComponentHttpFoundationResponse;
class MerchantFeed extends AbstractController
{
public function generateXml($feedSlug = null)
{
$db = Core::make(Connection::class);
// OPTIONAL: if using a custom slug to load feed config
if ($feedSlug) {
$config = $db->fetchAssociative("SELECT * FROM shoppingfeedconfiguration WHERE url = ?", [$feedSlug]);
if (!$config) {
return new Response("Feed not found", 404);
}
}
// Fetch your feed items
$products = $db->fetchAll("SELECT * FROM products ORDER BY id DESC");
// Build XML
$xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"/>');
$xml->addAttribute('xmlns:g', 'http://base.google.com/ns/1.0');
$channel = $xml->addChild('channel');
$channel->addChild('title', 'Your Store Feed');
$channel->addChild('link', 'https://yourstore.com');
$channel->addChild('description', 'Product feed for Google Merchant Center');
foreach ($products as $product) {
$item = $channel->addChild('item');
$item->addChild('g:id', $product['id'], 'http://base.google.com/ns/1.0');
$item->addChild('title', htmlspecialchars($product['name']));
$item->addChild('description', htmlspecialchars($product['description']));
$item->addChild('link', htmlspecialchars($product['product_url']));
$item->addChild('g:image_link', htmlspecialchars($product['image_url']), 'http://base.google.com/ns/1.0');
$item->addChild('g:availability', 'in stock', 'http://base.google.com/ns/1.0');
$item->addChild('g:price', $product['price'] . ' INR', 'http://base.google.com/ns/1.0');
$item->addChild('g:brand', htmlspecialchars($product['brand']), 'http://base.google.com/ns/1.0');
$item->addChild('g:condition', 'new', 'http://base.google.com/ns/1.0');
}
$xmlContent = $xml->asXML();
return new Response(
$xmlContent,
200,
['Content-Type' => 'application/xml']
);
}
}
—
## ✅ Sample Table Structure for `products`
Assuming this minimal structure in your DB:
| Column | Type |
|—————|————|
| id | INT |
| name | VARCHAR |
| description | TEXT |
| product_url | VARCHAR |
| image_url | VARCHAR |
| price | DECIMAL |
| brand | VARCHAR |
You can adjust the column names to your schema.
—
## ✅ Register Route in `controller.php`
Route::register(
'/google-merchant-feed/{slug}',
'Concrete\Package\ShoppingFeedManager\Controller\MerchantFeed::generateXml'
);
Visit:
https://yourdomain.com/google-merchant-feed/default
Or skip `{slug}` if you don't filter by config.
—
## ✅ Google Merchant Required Fields Included:
| Field | XML Tag |
|——————|———————|
| Product ID | “ |
| Name | “ |
| Description | “ |
| Product link | “ |
| Image | “ |
| Availability | “ |
| Price | “ |
| Brand | “ |
| Condition | “ |
Let me know if you want to make this XML dynamic based on feed settings or restrict by price range, category, etc.
);
}
}
above product grou store in CommunityStoreProductGroups table with pID and gID
how to get that particular product
1. Join `CommunityStoreProducts` with `CommunityStoreProductGroups`
2. (Optional) Join with a `Groups` table if you want group names
3. Ensure products are not duplicated if they belong to multiple groups
—
## ✅ Goal:
For each product from `CommunityStoreProducts`, also get the related `gID`s from `CommunityStoreProductGroups` (via `pID`).
—
## ✅ Step 1: Use a `LEFT JOIN` to pull group IDs
### 🔧 Updated Product Query
Replace your `$products = $db->fetchAll(…)` line with this:
$sql = "
SELECT p.*, GROUP_CONCAT(pg.gID) AS group_ids
FROM CommunityStoreProducts p
LEFT JOIN CommunityStoreProductGroups pg ON pg.pID = p.id
WHERE $condition
GROUP BY p.id
ORDER BY p.id DESC
";
$products = $db->fetchAll($sql);
—
## ✅ Step 2: Use `group_ids` inside XML generation
Update your XML loop like this:
foreach ($products as $product) {
$item = $channel->addChild('item');
$item->addChild('g:id', $product['id'], 'http://base.google.com/ns/1.0');
$item->addChild('title', htmlspecialchars($product['name']));
$item->addChild('description', htmlspecialchars($product['description']));
$item->addChild('link', htmlspecialchars($product['product_url']));
$item->addChild('g:image_link', htmlspecialchars($product['image_url']), 'http://base.google.com/ns/1.0');
$item->addChild('g:availability', 'in stock', 'http://base.google.com/ns/1.0');
$item->addChild('g:price', $product['price'] . ' INR', 'http://base.google.com/ns/1.0');
$item->addChild('g:brand', htmlspecialchars($product['brand']), 'http://base.google.com/ns/1.0');
$item->addChild('g:condition', 'new', 'http://base.google.com/ns/1.0');
// Optional: Add group IDs as custom tag
if (!empty($product['group_ids'])) {
$item->addChild('g:product_group_ids', $product['group_ids'], 'http://base.google.com/ns/1.0');
}
}
—
## ✅ Optional: Join group names (if available)
If you have a `Groups` or `ProductGroups` table like:
CommunityStoreGroup
- gID
- groupName
You could expand the query with another join to pull readable group names.
—
Let me know if you'd like to filter products by group name instead of ID or return group names inside the feed.
✅ Use an **`INNER JOIN`** between `CommunityStoreProducts` and `CommunityStoreProductGroups`
✅ Filter using `gID IN (…)`
✅ Group by product ID to avoid duplicates
—
## ✅ Final Query When Group Filter is Applied
Assuming:
– `$product_groups_selected = [1, 3, 5];` (from feed settings)
### 🔧 Build the condition and query like this:
$groupIds = implode(',', array_map('intval', $product_groups_selected));
$sql = "
SELECT p.*
FROM CommunityStoreProducts p
INNER JOIN CommunityStoreProductGroups pg ON pg.pID = p.id
WHERE pg.gID IN ($groupIds)
GROUP BY p.id
ORDER BY p.id DESC
";
$products = $db->fetchAll($sql);
—
## 🧠 Key Notes
– `INNER JOIN`: ensures we only get products that are linked to selected group IDs
– `pg.gID IN (…)`: filters products to only those groups
– `GROUP BY p.id`: removes duplicates when a product is in multiple selected groups
—
## ✅ Optional: Combine With Other Filters
If you already have other conditions (price range, product types), you can add them:
$extraConditions = "pg.gID IN ($groupIds)";
if ($selectedMin && $selectedMax) {
$extraConditions .= " AND p.price BETWEEN $selectedMin AND $selectedMax";
}
$sql = "
SELECT p.*
FROM CommunityStoreProducts p
INNER JOIN CommunityStoreProductGroups pg ON pg.pID = p.id
WHERE $extraConditions
GROUP BY p.id
ORDER BY p.id DESC
";
—
Let me know if you want to support multiple group *AND* type filters simultaneously.
– ✅ Apply an `INNER JOIN` with `CommunityStoreProductGroups` **only if** the feed settings contain product group IDs
– ✅ Otherwise, skip the join and fetch all products with a `WHERE 1=1` fallback
—
## ✅ Final PHP Logic
Below is the complete logic that dynamically adjusts the query based on whether product groups are selected in `$settings['product_groups']`.
$baseSelect = "SELECT p.*";
$baseFrom = "FROM CommunityStoreProducts p";
$joins = [];
$conditions = ["1=1"];
$params = [];
// Price range condition
if (!empty($settings['price_range']['min']) && !empty($settings['price_range']['max'])) {
$conditions[] = "p.price BETWEEN ? AND ?";
$params[] = (float) $settings['price_range']['min'];
$params[] = (float) $settings['price_range']['max'];
}
// Product group filter
if (!empty($settings['product_groups'])) {
$groupIds = array_map('intval', $settings['product_groups']);
$joins[] = "INNER JOIN CommunityStoreProductGroups pg ON pg.pID = p.id";
$conditions[] = "pg.gID IN (" . implode(',', $groupIds) . ")";
}
// Product type filter
if (!empty($settings['product_types'])) {
$productTypes = array_map('intval', $settings['product_types']);
$conditions[] = "p.pType IN (" . implode(',', $productTypes) . ")";
}
// Build final query
$sql = "
$baseSelect
$baseFrom
" . implode(" ", $joins) . "
WHERE " . implode(" AND ", $conditions) . "
GROUP BY p.id
ORDER BY p.id DESC
";
$products = $db->fetchAll($sql, $params);
—
## ✅ Explanation
| Part | Role |
|———————|——————————————|
| `INNER JOIN` | Only added if `product_groups` exist |
| `params[]` | Safely passes price values |
| `GROUP BY p.id` | Avoids duplicate products if in multiple groups |
| `fetchAll($sql, $params)` | Prevents SQL injection for numeric values |
—
Let me know if you'd like to add debugging output or expand this to include group names too.
—
## ✅ If You Already Have a Product Entity
Assuming you're looping through products from the database using Community Store's model:
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
$product = Product::getByID($productID);
if ($product) {
$url = $product->getProductLink();
echo $url;
}
—
## ✅ In a Loop (e.g., for feed export)
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
$products = Product::getAll();
foreach ($products as $product) {
$url = $product->getProductLink();
echo $url;
}
This returns the **public-facing product detail URL**, like:
/store/product/sample-product
—
## ✅ To Make It Absolute URL
echo URL::to($product->getProductLink());
Or:
echo Core::make('url')->to($product->getProductLink());
—
## 🔁 If You’re Working with Raw DB Rows
If you're fetching products directly from the DB (not via the Product model), you must convert them manually:
$product = Product::getByID($row['pID']);
$url = $product ? $product->getProductLink() : '';
—
Let me know if you want this integrated into your Google Merchant XML feed generator.
Did you mean to call "getProductPage"?
$product->getProductPage();
But that returns a **Page object**, so to get the actual URL, do this:
—
## ✅ Get the Product Page URL
$page = $product->getProductPage();
if ($page) {
$url = $page->getCollectionLink(); // returns full front-end URL
echo $url;
}
—
## ✅ One-Liner Version
$url = $product->getProductPage() ? $product->getProductPage()->getCollectionLink() : '';
—
## ✅ Full Example in a Loop
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
$products = Product::getAll();
foreach ($products as $product) {
$url = $product->getProductPage()?->getCollectionLink();
echo '<a href="' . h($url) . '">' . h($product->getName()) . '</a>';
}
—
Let me know if you want to integrate this into your merchant feed XML or apply fallback if the product page is missing.
Also How I can fetch store currenncy
To **fetch the store currency** in **Community Store**, use the `CommunityStoreUtilitiesCalculator` or `Config` facade depending on your need.
—
## ✅ 1. Get Store Currency Code (e.g., `INR`, `USD`, etc.)
use ConcretePackageCommunityStoreSrcCommunityStoreUtilitiesCalculator;
$currencyCode = Calculator::getCurrencyCode(); // returns 'USD', 'INR', etc.
—
## ✅ 2. Get Store Currency Symbol (e.g., `₹`, `$`, `€`)
$currencySymbol = Calculator::getCurrencySymbol(); // returns '₹', '$', etc.
—
## ✅ 3. Get Currency Settings from Config (if needed)
$symbol = Config::get('community_store.symbol');
$code = Config::get('community_store.currency');
—
## ✅ Output Example
echo 'Currency: ' . Calculator::getCurrencyCode(); // e.g., INR
echo 'Symbol: ' . Calculator::getCurrencySymbol(); // e.g., ₹
—
Let me know if you'd like to format product prices automatically with the currency, or support multi-currency setup.
—
## ✅ Option 1: Get the Main Product Image URL
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
$product = Product::getByID($productID);
if ($product && $product->getImage()) {
$image = $product->getImage();
$imageUrl = $image->getURL();
echo $imageUrl;
}
—
## ✅ Option 2: Get a Resized Thumbnail (optional size)
$imageUrl = $product->getImageThumb(); // uses default thumbnail
Or specify dimensions:
$imageUrl = $product->getImageThumb(600, 600); // width, height
—
## ✅ Full Safe One-Liner (for feeds or templates)
$imageUrl = $product->getImage() ? $product->getImage()->getURL() : '';
—
## ✅ Output Example for Feed
<g:image_link><?= h($product->getImage()?->getURL()) ?></g:image_link>
—
Let me know if you want to fetch **multiple images per product** (e.g., gallery images).
> **Call to a member function getURL() on string**
means `$product->getImage()` is returning a **string** (probably a file ID or path), not a File object.
Let’s fix it properly 👇
—
## ✅ Correct Way to Get Product Image URL in Community Store
### Step 1: `getImage()` returns the **File ID**, not the File object.
You need to convert it to a `File` object:
use ConcreteCoreFileFile;
$imageUrl = '';
$imageFID = $product->getImage(); // returns file ID
if ($imageFID) {
$file = File::getByID($imageFID);
if ($file && is_object($file)) {
$imageUrl = $file->getRelativePath(); // or getURL()
}
}
### ✅ Or as a one-liner with null safe:
use ConcreteCoreFileFile;
$imageUrl = ($fid = $product->getImage()) ? File::getByID($fid)?->getRelativePath() : '';
You can also use `getURL()` if you want the full absolute URL (with domain):
$imageUrl = File::getByID($fid)?->getURL();
—
## ✅ Final Example (for feed):
use ConcreteCoreFileFile;
$productImage = '';
if ($product->getImage()) {
$f = File::getByID($product->getImage());
$productImage = $f ? $f->getURL() : '';
}
echo '<g:image_link>' . h($productImage) . '</g:image_link>';
—
Let me know if you want to include **multiple gallery images** in your XML feed too!
—
## ✅ 1. Understand What `getImage()` Returns
In Community Store, `getImage()` returns the **`File` object**, not just an ID (as of latest versions).
So the correct way is:
$image = $product->getImage();
if ($image && is_object($image)) {
$imageUrl = $image->getURL(); // or getRelativePath()
echo $imageUrl;
}
No need to use `File::getByID()` unless you're dealing with raw data (like from the database directly).
—
## ✅ 2. Full Safe Example
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
$product = Product::getByID($productID);
$imageUrl = '';
$image = $product->getImage();
if ($image && method_exists($image, 'getURL')) {
$imageUrl = $image->getURL();
}
echo $imageUrl;
—
## ✅ 3. Check for Errors
If you’re still getting:
– `getURL() on string` → means `$image` is a string, likely you loaded the product incorrectly or directly from the database.
### ✅ Fix That by Loading with Product Model:
$product = Product::getByID($row['pID']); // not raw db row
Don't use `$row = $db->fetchAll(…)` and treat it as a `Product`. Always use:
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
$product = Product::getByID($id);
—
## ✅ Final: Print in XML
if ($product && ($img = $product->getImage()) && is_object($img)) {
echo '<g:image_link>' . h($img->getURL()) . '</g:image_link>';
}
—
Let me know how you're loading the products (e.g., `Product::getAll()` or from raw SQL), and I’ll adapt this exactly to your flow.
Let's fix it based on **your exact scenario**.
—
## ✅ SOLUTION: Get image URL when using raw DB rows
When you're querying directly like this:
$products = $db->fetchAll("SELECT * FROM CommunityStoreProducts");
The `pImage` column contains a **File ID (integer)** — not a File object.
### ✅ Fix: Convert `pImage` (File ID) to a File object
use ConcreteCoreFileFile;
foreach ($products as $row) {
$imageUrl = '';
if (!empty($row['pImage'])) {
$file = File::getByID($row['pImage']);
if ($file && is_object($file)) {
$imageUrl = $file->getURL();
}
}
echo '<g:image_link>' . h($imageUrl) . '</g:image_link>';
}
—
## ✅ Tip: Check your DB column name
In most Community Store setups, the image column is:
pImage
Make sure you're accessing the right column:
$row['pImage']
—
## ✅ Final Example in XML Feed
foreach ($products as $row) {
$file = ConcreteCoreFileFile::getByID($row['pImage']);
$imageUrl = $file ? $file->getURL() : '';
echo '<item>';
echo '<title>' . h($row['pName']) . '</title>';
echo '<g:image_link>' . h($imageUrl) . '</g:image_link>';
echo '</item>';
}
—
Let me know if you're mixing raw DB and `Product::getByID()` — I can refactor it to fully use Eloquent for cleaner output.
—
## ✅ 1. Get Full Site URL (with domain)
use ConcreteCoreSupportFacadeUrl;
$baseUrl = Url::to('/');
echo $baseUrl; // Outputs: https://yourdomain.com/
> This uses the current site context and domain (supports multi-site).
—
## ✅ 2. If you need it as a raw string for feed:
$baseUrl = (string) Url::to('/');
Or for consistency in CLI/feed use:
$baseUrl = rtrim(Core::make('url/canonical')->getUrl(), '/') . '/';
—
## ✅ 3. Combine with image or product path
$imageUrl = $baseUrl . ltrim($file->getRelativePath(), '/');
Or directly with full URL from file:
$imageUrl = $file->getURL(); // Already full absolute URL
—
## ✅ Example in Google Merchant Feed XML
$productLink = $productPage->getCollectionLink(); // full URL
$imageUrl = $file->getURL();
echo '<link>' . h($productLink) . '</link>';
echo '<g:image_link>' . h($imageUrl) . '</g:image_link>';
—
Let me know if you want this to support multilingual domains or sub-sites too.
—
## ✅ 1. Enable Pretty URLs in Dashboard
1. Go to:
**Dashboard → System & Settings → SEO & Statistics → Pretty URLs**
2. ✅ Check **"Enable Pretty URLs"**
3. 📄 It will show you an `.htaccess` snippet to use (next step)
—
## ✅ 2. Set Up `.htaccess`
In your site's **root directory**, create or edit a `.htaccess` file with this content:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L]
</IfModule>
—
### ✅ If Concrete is in a subfolder (e.g. `/site/`):
Change this line:
RewriteBase /
To:
RewriteBase /site/
—
## ✅ 3. Ensure mod_rewrite is Enabled
For Apache, run this:
sudo a2enmod rewrite
sudo systemctl restart apache2
Also ensure that `AllowOverride All` is set for your site directory in Apache config.
—
## ✅ 4. Clear Concrete CMS Cache
./vendor/bin/concrete c5:clear-cache
Or use the dashboard:
**System & Settings → Optimization → Clear Cache**
—
Once done:
– URLs like `/index.php/store/product/foo` become
`/store/product/foo`
– Feed URLs like `/index.php/google-merchant-feed/slug` become
`/google-merchant-feed/slug`
—
Let me know if you're using **nginx** or **DDEV**, and I can give you the rewrite rules for that too.
);
}
}
Call to a member function getCollectionID() on null
> **Call to a member function getCollectionID() on null**
means that:
$productu->getProductPage();
is returning `null` — i.e., the product is missing its associated **product page**.
—
## ✅ Why This Happens
Community Store links each product to a product page (`cID`). If that page has been deleted, not created, or not properly assigned, `getProductPage()` returns `null`.
—
## ✅ Safe Fix (Skip Product if Page Missing)
Wrap it in a check **before adding to XML**:
$page = $productu->getProductPage();
if (!$page) {
continue; // Skip product with no page
}
—
### ✅ Updated Code Inside Your Loop
foreach ($products as $product) {
$productu = Product::getByID($product['pID']);
$page = $productu->getProductPage();
// Skip products with no page assigned
if (!$page) {
continue;
}
$url = $page->getCollectionLink();
// Image
$imageUrl = '';
$image = $productu->getImageObj();
if (is_object($image)) {
$imageUrl = $image->getURL(); // full URL, no need to prefix with $baseUrl
}
$item = $channel->addChild('item');
$item->addChild('g:id', $product['pID'], 'http://base.google.com/ns/1.0');
$item->addChild('title', htmlspecialchars($product['pName']));
$item->addChild('description', htmlspecialchars($product['pDesc']));
$item->addChild('link', htmlspecialchars($url));
$item->addChild('g:image_link', htmlspecialchars($imageUrl), 'http://base.google.com/ns/1.0');
$item->addChild('g:availability', 'in stock', 'http://base.google.com/ns/1.0');
$item->addChild('g:price', $product['pPrice'] . ' USD', 'http://base.google.com/ns/1.0');
$item->addChild('g:brand', htmlspecialchars($product['pManufacturer']), 'http://base.google.com/ns/1.0');
$item->addChild('g:condition', 'new', 'http://base.google.com/ns/1.0');
}
—
## ✅ Optional: Log Missing Pages
If you'd like to log skipped products:
if (!$page) {
Log::addInfo("Product {$product['pID']} skipped - no product page assigned.");
continue;
}
—
Let me know if you’d like to auto-create missing product pages or list skipped product IDs in the response.
$page = $productu->getProductPage();
if ($page) {
$url = $page->getCollectionLink(); // returns full front-end URL
}
above working fine in view.php but in merchanht controller it give serror getCollectionID()
—
## ✅ Root Cause
In **Concrete CMS page controllers like `view.php`**, you're in a full page context:
👉 `Site`, `Page`, `Application` are fully initialized.
But in a **custom controller like `MerchantFeed` (extends `AbstractController`)**, it's a **minimal request context** — not all services are bootstrapped the same way.
So calling:
$page = $productu->getProductPage();
Internally calls:
Page::getByID($product->getPageID());
That expects a **valid site context**, and can fail if the product has a `null` or invalid `cID`.
—
## ✅ Reliable Fix: Manually fetch Page
Replace this:
$page = $productu->getProductPage();
With a **safe direct lookup**:
use ConcreteCorePagePage;
$page = Page::getByID($productu->getPageID());
if (!$page || $page->isError()) {
continue; // skip broken/missing page
}
Then safely get the link:
$url = $page->getCollectionLink(); // this is safe now
—
## ✅ Updated Code Snippet
use ConcreteCorePagePage;
$productu = Product::getByID($product['pID']);
$page = Page::getByID($productu->getPageID());
if (!$page || $page->isError()) {
continue;
}
$url = $page->getCollectionLink();
—
This works consistently in **any controller**, including CLI, REST, or feed generators.
Let me know if you want to fallback to a default URL when the page is missing instead of skipping.