Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/_config/contenthub.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
Name: contenthub
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
properties:
rules:
'news//$Action/$ID/$Name':
Controller: App\ContentHub\NewsHolderController
Comment on lines +4 to +9
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Routing rules are configured under SilverStripe\Core\Injector\Injector here, but elsewhere in the codebase routing is declared directly under SilverStripe\Control\Director: rules: (e.g., app/_config/routes.yml and app/_config/about_routes.yml). This config block likely won’t be picked up as a route definition. Move the rule under SilverStripe\Control\Director: at the top level to match existing routing config.

Suggested change
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
properties:
rules:
'news//$Action/$ID/$Name':
Controller: App\ContentHub\NewsHolderController
SilverStripe\Control\Director:
rules:
'news//$Action/$ID/$Name':
Controller: App\ContentHub\NewsHolderController

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route maps to App\ContentHub\NewsHolderController, which extends PageController (a ContentController subclass that normally expects a backing SiteTree record). In this codebase, routes that point at PageTypes map to the Page class (e.g., contact: ContactPage in app/_config/routes.yml), not the controller. Consider mapping the route to the NewsHolder PageType (or to a standalone Controller if this is meant to be recordless).

Suggested change
Controller: App\ContentHub\NewsHolderController
Controller: App\ContentHub\NewsHolder

Copilot uses AI. Check for mistakes.

SilverStripe\Security\Permission:
permissions:
- CONTENT_HUB_MANAGE: 'Manage Content Hub articles and press releases'
151 changes: 151 additions & 0 deletions app/src/ContentHub/ArticlePage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

namespace App\ContentHub;

use Page;
use PageController;
use SilverStripe\Assets\Image;
use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\DateField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\HTMLEditor\HTMLEditorField;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\TextareaField;
use SilverStripe\Forms\TreeMultiselectField;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DB;

/**
* ArticlePage
*
* Standard news/blog article with date, author, tags.
*/
class ArticlePage extends Page
{
private static $table_name = 'SiteTree_ArticlePage';

private static $singular_name = 'Article';
private static $plural_name = 'Articles';
private static $description = 'A news or blog article with metadata, author info, and tags';

private static $db = [
'Date' => 'Date',
'Subtitle' => 'Varchar(255)',
'AuthorName' => 'Varchar(255)',
'AuthorRole' => 'Varchar(100)',
'AuthorBio' => 'Text',
'IsFeatured' => 'Boolean',
];

private static $has_one = [
'FeaturedImage' => Image::class,
];
Comment on lines +40 to +42
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FeaturedImage is declared as a has_one, but no CMS field is added for it in getCMSFields(), so editors won’t be able to upload/select an image. The project already uses UploadField for Image relations (see SEOExtension), so consider adding an UploadField for FeaturedImage (and any required ownership config) to make this usable.

Copilot uses AI. Check for mistakes.

private static $many_many = [
'Tags' => Tag::class,
];

private static $casting = [
'Excerpt' => 'Text',
'Date' => 'Text',
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casting the Date field to Text will override the normal DBDate/DBDatetime behavior in templates (e.g., $Date.Nice, $Date.Format(...)), potentially breaking date formatting throughout the frontend. Consider removing this cast (or adding a separate computed getter like getFormattedDate() if you need a string).

Suggested change
'Date' => 'Text',

Copilot uses AI. Check for mistakes.
];

private static $summary_fields = [
'Title' => 'Title',
'Date' => 'Date',
'AuthorName' => 'Author',
'IsFeatured' => 'Featured',
];

private static $searchable_fields = [
'Title',
'AuthorName',
'Date',
];

private static $default_sort = '"Date" DESC';

public function getCMSFields(): FieldList
{
$fields = parent::getCMSFields();

// Date
$fields->addFieldToTab(
'Root.Main',
DateField::create('Date', 'Publication Date')
->setDateFormat('yyyy-MM-dd'),
'Content'
);

// Subtitle
$fields->addFieldToTab(
'Root.Main',
TextField::create('Subtitle', 'Subtitle / Deck'),
'Content'
);

// Author
$fields->addFieldsToTab('Root.Author', [
TextField::create('AuthorName', 'Author Name'),
TextField::create('AuthorRole', 'Role / Title'),
TextareaField::create('AuthorBio', 'Author Bio')->setRows(4),
]);

// Tags
$fields->addFieldToTab(
'Root.Taxonomy',
TreeMultiselectField::create('Tags', 'Tags', Tag::class)
);
Comment on lines +94 to +98
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TreeMultiselectField is a tree UI intended for hierarchical data; Tag is a plain DataObject. This is likely to break tag selection in the CMS (and doesn’t allow creating tags). Consider using a relation-appropriate field (CheckboxSetField/GridField/tagfield) for the many_many Tags relation.

Copilot uses AI. Check for mistakes.

// Featured flag
$fields->addFieldToTab(
'Root.Main',
CheckboxField::create('IsFeatured', 'Feature this article?'),
);

return $fields;
}

/**
* Excerpt for listing cards
*/
public function getExcerpt(int $length = 150): string
{
$text = $this->Subtitle ?: strip_tags($this->Content);
if (mb_strlen($text) > $length) {
return mb_substr($text, 0, $length) . '…';
}
return $text;
}

/**
* Related articles by shared tags
*/
public function RelatedArticles(int $limit = 5): DataList
{
$tagIds = $this->Tags()->column('ID');

if (empty($tagIds)) {
return $this->Siblings()->limit($limit);
}

return ArticlePage::get()
->filter(['ID:not' => $this->ID])
->innerJoin(
'ArticlePage_Tags',
'"ArticlePage_Tags"."ArticlePageID" = "SiteTree"."ID"'
)
->where(sprintf(
'"ArticlePage_Tags"."TagID" IN (%s)',
implode(',', array_map('intval', $tagIds))
))
->sort('"Date" DESC')
->groupBy('"SiteTree"."ID"')
Comment on lines +132 to +143
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RelatedArticles() hardcodes the many_many join table name (ArticlePage_Tags) and builds a manual SQL join/where. This is brittle (naming can differ with namespacing/config) and bypasses ORM conveniences. Prefer expressing this via ORM relation filters (e.g., filtering on Tags.ID) and using distinct()/deduping via ORM to avoid the manual join.

Suggested change
return ArticlePage::get()
->filter(['ID:not' => $this->ID])
->innerJoin(
'ArticlePage_Tags',
'"ArticlePage_Tags"."ArticlePageID" = "SiteTree"."ID"'
)
->where(sprintf(
'"ArticlePage_Tags"."TagID" IN (%s)',
implode(',', array_map('intval', $tagIds))
))
->sort('"Date" DESC')
->groupBy('"SiteTree"."ID"')
return static::get()
->exclude('ID', $this->ID)
->filter(['Tags.ID' => $tagIds])
->distinct()
->sort('"Date" DESC')

Copilot uses AI. Check for mistakes.
->limit($limit);
}
}

class ArticlePageController extends PageController
{
private static $allowed_actions = [];
}
99 changes: 99 additions & 0 deletions app/src/ContentHub/CaseStudyPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace App\ContentHub;

use Page;
use PageController;
use SilverStripe\Assets\Image;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\TextareaField;
use SilverStripe\Forms\TreeMultiselectField;
use SilverStripe\ORM\DataList;

/**
* CaseStudyPage
*
* Customer success story with problem/solution/results structure.
*/
class CaseStudyPage extends Page
{
private static $table_name = 'SiteTree_CaseStudyPage';

private static $singular_name = 'Case Study';
private static $plural_name = 'Case Studies';
private static $description = 'A customer success story with Problem → Solution → Results structure';

private static $db = [
'ClientName' => 'Varchar(255)',
'ClientIndustry'=> 'Varchar(100)',
'ClientWebsite' => 'Varchar(255)',
'Challenge' => 'HTMLText', // The problem
'Solution' => 'HTMLText', // What we did
'Results' => 'HTMLText', // Outcomes
'Quote' => 'Text', // Client testimonial quote
'QuoteAuthor' => 'Varchar(255)', // Who said the quote
];

private static $has_one = [
'CaseStudyImage' => Image::class,
'Logo' => Image::class, // Client logo
];

private static $many_many = [
'Results' => 'App\ContentHub\CaseStudyMetric',
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Results is defined twice: as an HTMLText db field and as a many_many relation. This will break schema generation and runtime access (only one can exist). Rename one of these (e.g., keep Results as HTMLText and rename the metrics relation to something like Metrics, or vice versa).

Suggested change
'Results' => 'App\ContentHub\CaseStudyMetric',
'Metrics' => 'App\ContentHub\CaseStudyMetric',

Copilot uses AI. Check for mistakes.
];
Comment on lines +45 to +47
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCMSFields() adds a Tags field, but this PageType doesn’t declare a many_many Tags relation. This will trigger a “relation does not exist” error when building the field. Add the missing many_many definition (consistent with ArticlePage/PressReleasePage) or remove the field.

Copilot uses AI. Check for mistakes.

Comment on lines +45 to +48
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CaseStudyMetric is referenced as a relation target, but there is no CaseStudyMetric class in the repository (searching for class CaseStudyMetric returns no matches). This will cause a fatal error when the ORM builds the relation. Add the missing DataObject class (and any required config), or remove/replace this relation.

Suggested change
private static $many_many = [
'Results' => 'App\ContentHub\CaseStudyMetric',
];

Copilot uses AI. Check for mistakes.
private static $default_sort = '"Created" DESC';

private static $summary_fields = [
'Title' => 'Title',
'ClientName' => 'Client',
'ClientIndustry'=> 'Industry',
];

private static $searchable_fields = [
'Title',
'ClientName',
'ClientIndustry',
];

public function getCMSFields(): FieldList
{
$fields = parent::getCMSFields();

$fields->addFieldToTab('Root.Client', [
TextField::create('ClientName', 'Client / Organization Name'),
TextField::create('ClientIndustry', 'Industry Sector'),
TextField::create('ClientWebsite', 'Client Website URL'),
]);

$fields->addFieldToTab('Root.Challenge',
TextareaField::create('Challenge', 'The Challenge / Problem')->setRows(6)
);

$fields->addFieldToTab('Root.Solution',
TextareaField::create('Solution', 'Our Solution / Approach')->setRows(6)
);

$fields->addFieldToTab('Root.Results',
TextareaField::create('Results', 'Results / Outcomes')->setRows(6)
);

$fields->addFieldToTab('Root.Quote', [
TextField::create('Quote', 'Client Quote / Testimonial'),
TextField::create('QuoteAuthor', 'Quote Author Name & Title'),
]);

$fields->addFieldToTab('Root.Main', TreeMultiselectField::create('Tags', 'Tags', Tag::class));

return $fields;
Comment on lines +88 to +92
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TreeMultiselectField is a tree UI intended for hierarchical data; Tag is a plain DataObject. Even once the Tags relation exists, this field type is unlikely to work correctly for selecting tags. Consider switching to a relation-appropriate selector (CheckboxSetField/GridField/tagfield) so tags can be managed reliably in the CMS.

Copilot uses AI. Check for mistakes.
}
}

class CaseStudyPageController extends PageController
{
private static $allowed_actions = [];
}
90 changes: 90 additions & 0 deletions app/src/ContentHub/NewsHolder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\ContentHub;

use Page;
use SilverStripe\Assets\Image;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Forms\DateField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordPaginator;
use SilverStripe\Forms\HTMLEditor\HTMLEditorField;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\TreeMultiselectField;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList;
use PageController;

/**
* NewsHolder
*
* Parent PageType that holds Articles, Press Releases, and Case Studies.
* Provides unified feed, per-type listing methods, and CMS management.
*/
class NewsHolder extends Page
{
private static $table_name = 'NewsHolder';
private static $singular_name = 'News Hub';
private static $plural_name = 'News Hubs';
private static $description = 'Parent page holding Articles, Press Releases, and Case Studies';

private static $allowed_children = [
ArticlePage::class,
PressReleasePage::class,
CaseStudyPage::class,
];

public function getCMSFields(): FieldList
{
$fields = parent::getCMSFields();

$config = GridFieldConfig_RecordPaginator::create(20);

$fields->addFieldToTab('Root.Articles', GridField::create(
'Articles',
'Articles',
ArticlePage::get()->filter(['ParentID' => $this->ID]),
$config
));
Comment on lines +43 to +50
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GridFields are configured with GridFieldConfig_RecordPaginator only, which provides pagination but not record creation/editing/deletion. This doesn’t match the PR description (“CMS GridField management”). Consider switching to a config that includes editing/adding (or remove the GridField approach and rely on SiteTree child management).

Copilot uses AI. Check for mistakes.

$fields->addFieldToTab('Root.PressReleases', GridField::create(
'PressReleases',
'Press Releases',
PressReleasePage::get()->filter(['ParentID' => $this->ID]),
$config
));

Comment on lines +43 to +58
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$allowed_children includes CaseStudyPage, but getCMSFields() only adds GridFields for Articles and PressReleases—there’s no CaseStudies management tab. Add the missing CaseStudies GridField (or adjust $allowed_children/CMS UI) so the holder can manage all declared child types.

Suggested change
$config = GridFieldConfig_RecordPaginator::create(20);
$fields->addFieldToTab('Root.Articles', GridField::create(
'Articles',
'Articles',
ArticlePage::get()->filter(['ParentID' => $this->ID]),
$config
));
$fields->addFieldToTab('Root.PressReleases', GridField::create(
'PressReleases',
'Press Releases',
PressReleasePage::get()->filter(['ParentID' => $this->ID]),
$config
));
$articleConfig = GridFieldConfig_RecordPaginator::create(20);
$pressReleaseConfig = GridFieldConfig_RecordPaginator::create(20);
$caseStudyConfig = GridFieldConfig_RecordPaginator::create(20);
$fields->addFieldToTab('Root.Articles', GridField::create(
'Articles',
'Articles',
ArticlePage::get()->filter(['ParentID' => $this->ID]),
$articleConfig
));
$fields->addFieldToTab('Root.PressReleases', GridField::create(
'PressReleases',
'Press Releases',
PressReleasePage::get()->filter(['ParentID' => $this->ID]),
$pressReleaseConfig
));
$fields->addFieldToTab('Root.CaseStudies', GridField::create(
'CaseStudies',
'Case Studies',
CaseStudyPage::get()->filter(['ParentID' => $this->ID]),
$caseStudyConfig
));

Copilot uses AI. Check for mistakes.
return $fields;
}

/**
* Latest published articles (any parent, site-wide)
*/
public function LatestArticles(int $limit = 10): DataList
{
return ArticlePage::get()->sort('Date DESC, Created DESC')->limit($limit);
}

/**
* Latest press releases (site-wide)
*/
public function LatestPressReleases(int $limit = 5): DataList
{
return PressReleasePage::get()->sort('Date DESC, Created DESC')->limit($limit);
}

/**
* Latest case studies (site-wide)
*/
public function LatestCaseStudies(int $limit = 5): DataList
{
return CaseStudyPage::get()->sort('Created DESC')->limit($limit);
}
}

class NewsHolderController extends PageController
{
private static $allowed_actions = [];
}
Loading
Loading