Skip to content

feat: [Content Hub] PageTypes: NewsHolder, ArticlePage, PressReleasePage, CaseStudyPage, TagPage#234

Open
jsirish wants to merge 1 commit intomainfrom
autopipe/issue-89
Open

feat: [Content Hub] PageTypes: NewsHolder, ArticlePage, PressReleasePage, CaseStudyPage, TagPage#234
jsirish wants to merge 1 commit intomainfrom
autopipe/issue-89

Conversation

@jsirish
Copy link
Copy Markdown
Member

@jsirish jsirish commented Apr 2, 2026

Fixes #89

NewsHolder (Parent Page)

  • allowed_children: ArticlePage, PressReleasePage, CaseStudyPage
  • LatestArticles(), LatestPressReleases(), LatestCaseStudies() methods
  • CMS GridField management for each child type

ArticlePage

  • Date, Subtitle, AuthorName, AuthorRole, AuthorBio
  • FeaturedImage (has_one), Tags (many_many → Tag)
  • getExcerpt($length) for listing cards
  • RelatedArticles($limit) via shared tag overlap query
  • IsFeatured flag for homepage promotion

PressReleasePage

  • Date, Location (city/state origin)
  • EmbargoDate field with getIsEmbargoed() helper
  • MediaContact, MediaEmail, Boilerplate fields
  • Tags (many_many → Tag)

CaseStudyPage

  • ClientName, ClientIndustry, ClientWebsite fields
  • Challenge, Solution, Results (HTMLText) for structured storytelling
  • Quote and QuoteAuthor for client testimonials
  • CaseStudyImage + Logo (client logo)
  • Tags (many_many → Tag)

Tag

  • Shared taxonomy across ArticlePage, PressReleasePage, CaseStudyPage
  • Auto-generated Slug from Title
  • belongs_many_many reverse relations

TagPage

  • Lists content filtered by TagSlug
  • TaggedArticles($limit) queries by tag

Config

  • app/_config/contenthub.yml with ContentHub permission definition

Relates to #89 (Content Hub epic)

…age, CaseStudyPage, TagPage

- NewsHolder: parent page for all content types, CMS grid fields, unified feed
- ArticlePage: date, subtitle, author metadata, tags, related articles query
- PressReleasePage: embargo, media contact, boilerplate, origin location
- CaseStudyPage: Client name, Challenge/Solution/Results, quote, client logo
- Tag: shared taxonomy with auto-slug, belongs_many_many to all content types
- TagPage: tag-based content archive page

Relates to #89
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a “Content Hub” feature set to the SilverStripe site by introducing new PageTypes (NewsHolder + content detail pages) and a shared Tag taxonomy, plus related routing/config.

Changes:

  • Introduces new Content Hub PageTypes: NewsHolder, ArticlePage, PressReleasePage, CaseStudyPage, and TagPage.
  • Adds a Tag DataObject for shared tagging across content types.
  • Adds app/_config/contenthub.yml for routing and a custom permission code.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
app/src/ContentHub/NewsHolder.php New hub/parent page for managing and listing content types
app/src/ContentHub/ArticlePage.php New Article PageType with metadata, tags, excerpt, and related-articles query
app/src/ContentHub/PressReleasePage.php New Press Release PageType with embargo/contact metadata and tags
app/src/ContentHub/CaseStudyPage.php New Case Study PageType with client/story fields and (intended) metrics/tags
app/src/ContentHub/Tag.php New taxonomy DataObject with auto-generated slug
app/src/ContentHub/TagPage.php New Tag archive PageType that lists articles by tag slug
app/_config/contenthub.yml Adds a /news route rule and a new permission definition

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +54 to +60
return ArticlePage::get()->limit($limit);
}

$tag = Tag::get()->filter(['Slug' => $this->TagSlug])->first();

if (!$tag) {
return ArticlePage::get()->limit($limit);
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.

TaggedArticles() returns all ArticlePage records when TagSlug is empty or doesn’t resolve to a Tag. That seems inconsistent with this PageType’s purpose (a tag archive) and can unintentionally expose unrelated content and create heavy queries. Consider returning an empty list (or a clear 404/validation error) when no valid tag is configured.

Suggested change
return ArticlePage::get()->limit($limit);
}
$tag = Tag::get()->filter(['Slug' => $this->TagSlug])->first();
if (!$tag) {
return ArticlePage::get()->limit($limit);
// No tag configured for this page: return an empty list instead of all articles
return ArticlePage::get()->where('1 = 0');
}
$tag = Tag::get()->filter(['Slug' => $this->TagSlug])->first();
if (!$tag) {
// Tag slug does not resolve to an existing tag: return an empty list
return ArticlePage::get()->where('1 = 0');

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +41
if (!$this->Slug && $this->Title) {
$this->Slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $this->Title));
}
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.

Slug generation can produce leading/trailing hyphens and multiple consecutive hyphens (e.g., titles starting/ending with punctuation). Consider trimming hyphens and collapsing repeats so the saved Slug is consistently URL-safe. Also consider whether the slug should update when the Title changes (while still allowing manual overrides).

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +77
$fields->addFieldToTab('Root.Distribution', [
DateField::create('EmbargoDate', 'Embargo Date/Time (optional)'),
TextField::create('MediaContact', 'Media Contact Name'),
TextField::create('MediaEmail', 'Media Email'),
HTMLEditorField::create('Boilerplate', 'Company Boilerplate')->setRows(4),
]);
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.

EmbargoDate is defined as a Datetime but is scaffolded with DateField, which drops the time component. Use a datetime-capable CMS field (and ensure the value saved/compared includes the intended time) so embargo behavior matches the schema and UI label (“Date/Time”).

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +80
$fields->addFieldToTab('Root.Main', TreeMultiselectField::create('Tags', 'Tags', Tag::class));

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 intended for hierarchical/tree data (e.g., SiteTree/Hierarchy). Tag is a plain DataObject, so this field type is unlikely to render/select tags correctly in the CMS. Consider using a relation-appropriate field (e.g., CheckboxSetField, GridField relation editor, or a tag UI like silverstripe/tagfield) so editors can actually manage the many_many Tags.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +41
private static $has_one = [
'PressImage' => Image::class,
];
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.

PressImage is declared as a has_one, but no CMS field is added for it in getCMSFields(), so authors won’t be able to upload/select an image. The codebase already uses UploadField for Image relations (e.g., SEOExtension), so consider adding an UploadField for PressImage (and any required ownership config) to make the relation usable.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +42
private static $has_one = [
'FeaturedImage' => Image::class,
];
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.
Comment on lines +132 to +143
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"')
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.
Comment on lines +4 to +9
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
properties:
rules:
'news//$Action/$ID/$Name':
Controller: App\ContentHub\NewsHolderController
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.
properties:
rules:
'news//$Action/$ID/$Name':
Controller: App\ContentHub\NewsHolderController
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.
Comment on lines +88 to +92
]);

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

return $fields;
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.
jsirish pushed a commit that referenced this pull request Apr 2, 2026
…Calendar

- ArticleCategory: hierarchical categorization with tree structure
  (ParentID, many_many < Articles), URL slugs
- AuthorProfile: author bios with social links and profile images
- EditorialCalendar: content scheduling with status tracking
  (Idea → Planned → In Progress → In Review → Scheduled → Published),
  assignee, priority, due/publish dates, many_many to Categories + Authors

Note: Tag model is handled separately in PR #234 and can be
added as a many_many relation once merged.

Relates to #86
jsirish pushed a commit that referenced this pull request Apr 2, 2026
…Calendar, ContentBrief

- ArticleCategory: hierarchical categorization with tree structure
  (ParentID, many_many relation to articles), URL slugs
- AuthorProfile: author bios with social links and profile images
- EditorialCalendar: content scheduling with status tracking
  (Idea → Planned → In Progress → In Review → Scheduled → Published → Archived),
  assignee, priority, due/publish dates, many_many to Categories + Authors,
  getIsOverdue()/getIsPublished() helpers
- ContentBrief: pre-production content planning with objectives,
  audience, key points, relates to EditorialCalendar via has_one

Note: Tag model is handled separately in PR #234 and can be
added as a many_many relation once merged.

Relates to #86
jsirish added a commit that referenced this pull request Apr 4, 2026
…Calendar (#236)

* feat: [Content Hub] Models: ArticleCategory, AuthorProfile, EditorialCalendar, ContentBrief

- ArticleCategory: hierarchical categorization with tree structure
  (ParentID, many_many relation to articles), URL slugs
- AuthorProfile: author bios with social links and profile images
- EditorialCalendar: content scheduling with status tracking
  (Idea → Planned → In Progress → In Review → Scheduled → Published → Archived),
  assignee, priority, due/publish dates, many_many to Categories + Authors,
  getIsOverdue()/getIsPublished() helpers
- ContentBrief: pre-production content planning with objectives,
  audience, key points, relates to EditorialCalendar via has_one

Note: Tag model is handled separately in PR #234 and can be
added as a many_many relation once merged.

Relates to #86

* feat: [Content Hub] Add Tag model and Color field to ArticleCategory (#86)

- Add Tag model with Title, Slug, UsageCount
- Auto-generate slug from title
- Auto-calculate usage count from related articles
- Add Color field to ArticleCategory for UI tagging
- Update ArticleCategory CMS fields with Color picker

---------

Co-authored-by: AutoPipe Builder <builder@autopipe.test>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Content Hub] PageTypes: NewsHolder, ArticlePage, PressReleasePage, CaseStudyPage, TagPage

2 participants