I have watched lesson 7 - lesson 10 videos on the silverstripe.org website, have read all the articles on this page: http://docs.silverstripe.org/en/3.1/developer_guides/model/, been all up in the API and have googled silverstripe/stackoverflow forums for hours. I am really stuck trying to apply what I learned though. Here is what I'm trying to do. I want to expand on the Article Holder & Article Page concept. I am trying to make another page that is a Master Article Holder and my site tree will be organized as such:
HomePage
|
| _ MasterArticleHolderOne
| |
| | _ ArticleHolderA
| | |
| | | _ ArticlePageA1
| | |
| | | _ ArticlePageA2
| | |
| | | _ ArticlePageA3
| |
| |
| | _ ArticleHolderB
| | |
| | | _ ArticlePageB1
| | |
| | | _ ArticlePageB2
| | |
| | | _ ArticlePageB3
| |
| |
| | _ ArticleHolderB
| |
| | _ ArticlePageB1
| |
| | _ ArticlePageB2
| |
| | _ ArticlePageB3
|
| _ MasterArticleHolderTwo
I would like the MasterArticleHolder page to do is this: There will be an option in the CMS for the MasterArticleHolder page to select any existing ArticleHolder pages (meaning that even MasterArticleHolderTwo from above could display articles from ArticleHolderA or B or C) and the MasterArticleHolder page will then display all the articles that are children of the selected ArticleHolder pages. These articles need to be sorted by PublicationDate; not grouped by what holder they belong to.
Here is the code that I've written so far. The ArticlePage and the ArticleHolder work perfectly. It's the MasterArticleHolder that I'm struggling with (though I did manage to at least create checkboxes appear in the CMS for all existing ArticleHolder page, don't know if it will actually function once everything else is written):
ArticlePage
class ArticlePage extends Page {
private static $db = array(
'PublicationDate' => 'Date',
);
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->addFieldToTab('Root.Main', DateField::create('PublicationDate','Publication Date')->setConfig('showcalendar', true));
return $fields;
}
}
ArticleHolder
class ArticleHolder_Controller extends Page_Controller {
public function LatestArticles() {
return ArticlePage::get()
->filter('PublicationDate:LessThanOrEqual', SS_Datetime::now())
->sort('PublicationDate', 'DESC');
}
}
MasterArticleHolder
class MasterArticleHolder extends Page {
private static $many_many = array (
'Categories' => 'ArticleHolder'
);
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->addFieldToTab('Root.Main', CheckboxSetField::create(
'Categories',
'Articles to Display',
ArticleHolder::get()->map('ID','MenuTitle')
));
return $fields;
}
}
class MasterArticleHolder_Controller extends Page_Controller {
public function LatestArticles() {
return ArticlePage::get()
->filter(array('PublicationDate:LessThanOrEqual' => SS_Datetime::now()))
->sort('PublicationDate', 'DESC')
}
}
Seems to me that there could be the following relations but I just don't know which one is needed, if any
ArticlePage is a has_one with ArticleHolder
ArticleHolder is a has_many with ArticlePage
ArticleHolder is a many_many with MasterArticleHolder (Don't really know which one is the belongs_many_many but I'd guess Article Holder)
The following relation may also apply but they seem less likely to me
ArticlePage is a many_many with MasterArticleHolder
Now I know normally the the has_one, has_many, and many_many apply to objects that extend DataObject but I figured that since page in a round-about way extends DataObject that these relationships may still apply, but I could be completely wrong and really overthinking things.
Hopefully I explained myself well enough for me to get help, but not too much to overwhelm everyone. I appreciate the feedback!
You should to go further into the tutorials, this (using Pages) is ... not really the best solution. It can work, but is extremely complex and just not really a good idea.
If you're doing this as a learning exercise then that's cool, but if not then just use the Blog Module http://addons.silverstripe.org/add-ons/silverstripe/blog - it does basically exactly this.
It would be better to use DataObjects instead of Pages for this exercise. See video tutorial 9 (http://www.silverstripe.org/learn/lessons/working-with-data-relationships-has-many)
A simple solution to what you've got now though is:
ArticlePage::get()
->filter([
'ParentID'=>$this->Categories()->getIDList(),
'PublicationDate:LessThanOrEqual'=>'now'
])
->sort('PublicationDate','desc');
Basically: get every Article that's parent is one of my chosen categories (then your existing filters, sort, etc).
Using DataObjects instead of Pages is basically the same thing (Pages are DataObjects), but you get less pollution of the site tree (in the CMS) and hierarchical issues to do with children, etc.
But then on the other hand you loose the automatic routing, and various other things that Page gives you. This is why the Blog module is a much better solution if you're not just trying to learn.
Sadly I don't have the time to test these but following should work.. as a guide at least:
Remove the
private static $many_many = array (
'Categories' => 'ArticleHolder'
);
and add categories as a db field
private static $db = array(
'Categories' => 'Varchar'
);
No need to introduce a new relation as they are already Children of that holder. The checkbox field set stores the data as comma separated list. This is a loose relation so note that.
Then use the comma separated list in the filter with something like:
public function LatestArticles() {
$ids = explode(",", $this->Categories)
return ArticlePage::get()
->filter(array('ParentID' => $ids))
->sort('PublicationDate', 'DESC')
}
I just cant remember is the database field parent_id or ParentID or what so that you might want to check out from the database tables.
I am trying to find an easy way to use an entity form type, which uses the property from a joined table. I found a work around, but I hope this can be solved better than this.
In my situation, I want to show the translated text for my options in the selectbox. Unfortunately, the database is already there with it's translation tables, so I can't use the symfony country form type. So, for example, I have these tables:
Country
+----+-------------+
| id | name |
+----+-------------+
| 1 | Netherlands |
| 2 | Germany |
+----+-------------+
CountryTranslation
+----+-------------+------------+-----------+
| id | language_id | country_id | text |
+----+-------------+------------+-----------+
| 1 | 1 | 1 | Nederland |
| 2 | 1 | 2 | Duitsland |
| 3 | 2 | 1 | ... |
+----+-------------+------------+-----------+
Flags
+----+------------+-----------------------------+
| id | country_id | img_url |
+----+------------+-----------------------------+
| 1 | 1 | http://flags.cdn.com/nl.gif |
+----+------------+-----------------------------+
Now in my CMT, I want the user to be able to manage the flags table. The CMT is multi-lingual, so it should show the countries in his own language. So in my formbuilder I would like to do something like this:
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$flag = $event->getData();
$form = $event->getForm();
if (!$flag) {
$form
->add('language', 'entity', array(
'class' => 'MbDbLookupBundle:Country',
'query_builder' => function(EntityRepository $er) {
return $er->createQueryBuilder('c')
->innerJoin('c.countryTranslations', 'ct', 'WITH', 'ct.languageId = :languageId')
->leftJoin('MyCmtBundle:Flag', 'f', 'WITH', 'f.country = c')
->where('f.country IS NULL')
->setParameter('languageId', $this->lang)
},
'property' => 'ct.text', // <--------- This will not work
'label' => 'Language',
'position' => array('before' => 'title'),
));
}
}
Things i've tried
Use a 'choice' field with a Data Transformer as explained here. This seemed ok at first, but I couldn't use this in my dynamic form as explained here. This was because inside the addEventListener, I can use $form->add, but I cannot use $form->add(form->create(...)) since the create function doesn't exist on that object.
Create a custom form type. In my application I wanted the selectbox to be filled only with countries that weren't selected already. So I needed to join my "Flags" table as well, which is why I use the dynamic form. Therefore I cannot use the custom form type, because I need the query to be dynamic, based on the underlying data.
My solution
In my Country entity I added the function getNameTranslated($langId=null). When there is only one record in the array, it would return this. This way I could query the object with an inner join, making sure I already have the right translation inside the Country object. To make this function less filthy, one also has the option to fetch by the locale manually by using the parameter. So I came up with something like this:
public function getNameTranslated($langId=null) {
if (!isset($langId) && count($this->nameTranslations) > 1)
throw new \Exception('No locale given '.count($this->nameTranslations));
foreach($this->nameTranslations as $nameTranslation) {
if (!isset($langId) || ($nameTranslation->getLanguageId() == $langId))
return $nameTranslation;
}
return false;
}
Now I can use 'property' => 'nameTranslated' in my form builder, also inside the form event listener.
Somehow I still don't really like this solution. I would rather solve the problem inside my form, instead of inside the object.
What complicates things, I think, is that I am using this form event, and that I am joining my "Flags" table to rule out the languages already chosen. Otherwise I think the best options would be to go with the 'choice' field together with the Data Transformer. But I hope I am missing something and there is a better solution to use a foreign property.
I am creating a web-site on wordpress platform where I want to be able to post my own book texts. So what I want is to have a some kind of hierarchy where I would add a post and then add children to it (chapters). I found this:
register_post_type( 'post', array(
'labels' => array(
'name_admin_bar' => _x( 'Post', 'add new on admin bar' ),
),
'public' => true,
'_builtin' => true, /* internal use only. don't use this when registering your own post type. */
'_edit_link' => 'post.php?post=%d', /* internal use only. don't use this when registering your own post type. */
'capability_type' => 'post',
'map_meta_cap' => true,
'hierarchical' => false,
'rewrite' => false,
'query_var' => false,
'supports' => array( 'title', 'editor', 'author', 'thumbnail', 'excerpt', 'trackbacks', 'custom-fields', 'comments', 'revisions', 'post-formats' ),
) );
and tried to make the 'hierarchical"=>true, but there was no effect. Can anyone help?
Here is my workaround. This achieves exactly what you want, to be able to set post parents for the builtin post type post. You can achieve this by adding an action to the registred_post_type action hook. Just add this to your theme's functions.php.
add_action('registered_post_type', 'igy2411_make_posts_hierarchical', 10, 2 );
// Runs after each post type is registered
function igy2411_make_posts_hierarchical($post_type, $pto){
// Return, if not post type posts
if ($post_type != 'post') return;
// access $wp_post_types global variable
global $wp_post_types;
// Set post type "post" to be hierarchical
$wp_post_types['post']->hierarchical = 1;
// Add page attributes to post backend
// This adds the box to set up parent and menu order on edit posts.
add_post_type_support( 'post', 'page-attributes' );
}
There can be dozens of reasons why making posts hierarchical can be helpful. My use case is that the client wanted to structure their (already existing) posts into issues, where child posts are articles of one issue (parent posts).
This is easily achieved by limiting the query to only show posts that have no parents, using.
'post_parent' => 0,
in your query $args.
WP 4.9.*
Workaround above makes it crazy with Friendly URLs.
My solution to add hierarchy to any existent post type:
add_filter( 'register_post_type_args', 'add_hierarchy_support', 10, 2 );
function add_hierarchy_support( $args, $post_type ){
if ($post_type === 'post') { // <-- enter desired post type here
$args['hierarchical'] = true;
$args['supports'] = array_merge($args['supports'], array ('page-attributes') );
}
return $args;
}
Resave wp settings at /wp-admin/options-permalink.php
Update
Due to comments provoking new use-cases and issues, I rewrote this code and I am using it on my own sites [tested in 5.8.2]. I have provided a gist for it. You can include it in your functions.php, or make it into a plugin.
https://gist.github.com/amurrell/00d29a86fc1a773274bf049ef545b29f
🎉 This new update is leveraging SQL (fast!) to resolve the slug and post id to determine the permalink & routing. It produces the exact matching post id, even if you are using the same post_name for different post descendants. It's really fast & reliable!
In the gist, the most interesting function is get_post_from_uri($uri)
I built a custom query that will determine the permalink for us and find the exact matching post id.
Why a query? There are no wordpress functions to help determine a post's full lineage, without spinning yourself into loops.
But the relationships exist in the data!!
Therefore, the perfect way to ensure we get good friendly permalink urls is to leverage the power of the database and query language.
👇 Let's see how the query works. This may not be a perfect 1-1 of the code, because I made it dynamic, but the concept is there:
Example:
I have the following posts:
climate [547]
alliance-for-innovation [1395]
climate [1808]
procurement [518]
city-sales-cycle [1345]
climate [1811]
See it in SQL:
mysql> select id, post_name, post_parent from wp_posts where post_type = 'post' and id in (1811, 1808, 1345, 1395, 547, 518);
+------+-------------------------+-------------+
| id | post_name | post_parent |
+------+-------------------------+-------------+
| 518 | procurement | 0 |
| 547 | climate | 0 |
| 1345 | city-sales-cycle | 518 |
| 1395 | alliance-for-innovation | 0 |
| 1808 | climate | 1395 |
| 1811 | climate | 1345 |
+------+-------------------------+-------------+
Ex URL: alliance-for-innovation/climate
The full query...
mysql> select * from
-> (select TRIM(BOTH '/' FROM concat(
-> IFNULL(p3_slug,''),
-> '/',
-> IFNULL(p2_slug,''),
-> '/',
-> p1_slug
-> )
-> ) as slug,
-> id
-> from (
-> select d2.*, p3.post_name as p3_slug, p3.post_parent as p3_parent from (
-> select d1.*, p2.post_name as p2_slug, p2.post_parent as p2_parent from (
-> select id, post_name as p1_slug, post_parent as p1_parent from wp_posts where post_type = 'post' and post_name = 'climate'
-> ) as d1
-> left join wp_posts p2 on p2.id = d1.p1_parent
-> ) as d2
-> left join wp_posts p3 on p3.id = d2.p2_parent) as d3
->
-> ) as all_slugs
-> where slug = 'alliance-for-innovation/climate';
+---------------------------------+------+
| slug | id |
+---------------------------------+------+
| alliance-for-innovation/climate | 1808 |
+---------------------------------+------+
1 row in set (0.01 sec)
I now have both the post ID and the slug, or permalink, I should be using!
It is worth noting I went to the level of p3, which is one extra level than the URL would require (being two parts). This is to prevent something like alliance-for-innovation/climate/something from matching.
How does it work? Break down the query
There's an inside query that looks for the last part of the URL, aka basename. In this case it would be climate.
mysql> select id, post_name as p1_slug, post_parent as p1_parent from wp_posts where post_type = 'post' and post_name = 'climate';
+------+---------+-----------+
| id | p1_slug | p1_parent |
+------+---------+-----------+
| 547 | climate | 0 |
| 1808 | climate | 1395 |
| 1811 | climate | 1345 |
+------+---------+-----------+
Programmatically, we keep adding abstractions around the query that's directly related to the number of / in the url, so that we can find more information about the post_parent's slug.
mysql> select d1.*, p2.post_name as p2_slug, p2.post_parent as p2_parent from (
-> select id, post_name as p1_slug, post_parent as p1_parent from wp_posts where post_type = 'post' and post_name = 'climate'
-> ) as d1
-> left join wp_posts p2 on p2.id = d1.p1_parent;
+------+---------+-----------+-------------------------+-----------+
| id | p1_slug | p1_parent | p2_slug | p2_parent |
+------+---------+-----------+-------------------------+-----------+
| 547 | climate | 0 | NULL | NULL |
| 1808 | climate | 1395 | alliance-for-innovation | 0 |
| 1811 | climate | 1345 | city-sales-cycle | 518 |
+------+---------+-----------+-------------------------+-----------+
After we abstracted enough times, we can then select concats as slug like: p1_slug + '/' + p2_slug
mysql> select TRIM(BOTH '/' FROM concat(
-> IFNULL(p3_slug,''),
-> '/',
-> IFNULL(p2_slug,''),
-> '/',
-> p1_slug
-> )
-> ) as slug,
-> id
-> from (
-> select d2.*, p3.post_name as p3_slug, p3.post_parent as p3_parent from (
-> select d1.*, p2.post_name as p2_slug, p2.post_parent as p2_parent from (
-> select id, post_name as p1_slug, post_parent as p1_parent from wp_posts where post_type = 'post' and post_name = 'climate'
-> ) as d1
-> left join wp_posts p2 on p2.id = d1.p1_parent
-> ) as d2
-> left join wp_posts p3 on p3.id = d2.p2_parent) as d3
->
-> ;
+--------------------------------------+------+
| slug | id |
+--------------------------------------+------+
| climate | 547 |
| alliance-for-innovation/climate | 1808 |
| procurement/city-sales-cycle/climate | 1811 |
+--------------------------------------+------+
The last step is to add a where for the original url: alliance-for-innovation/climate. And that's what you see in the full query example we first examined!
Let's see how the others go:
# climate
+---------+-----+
| slug | id |
+---------+-----+
| climate | 547 |
+---------+-----+
# procurement/city-sales-cycle/climate
+--------------------------------------+------+
| slug | id |
+--------------------------------------+------+
| procurement/city-sales-cycle/climate | 1811 |
+--------------------------------------+------+
Another thing that I like about this update is that I remembered to:
Escape the climate, or basename of the URL that we use in the query, because this is technically user-inputted (via url)
$wpdb->_real_escape($basename));
So how is this query-building function dynamic?
We use PHP arrays, loops, etc to build a string that will be the query so that we do not have to use PHP for logic about the data itself.
This is a snippet showing the dynamic abstractions - eg. how many p1_slug, p2_slug, p3_slug to grab.
// We will do 1 more depth level than we need to confirm the slug would not lazy match
// This for loop builds inside out.
for ($c = 1; $c < $depth + 2; $c++) {
$d = $c;
$p = $c + 1;
$pre = "select d${d}.*, p${p}.post_name as p${p}_slug, p${p}.post_parent as p${p}_parent from (";
$suf = ") as d${d} left join $wpdb->posts p${p} on p${p}.id = d${d}.p${c}_parent";
$sql = $pre . $sql . $suf;
$concats[] = sprintf("IFNULL(p${p}_slug,'')");
}
Previous Answer:
I came here looking to achieve:
Adding page attributes to post_type posts to add parent posts
Being able to add a page template to post_type posts
Being able to get hierarchical permalink structure on post_type posts
I was able to use the accepted answer to accomplish 1 & 2, but not 3.
Note: to fully get 2 to work, you need to specify the post_type in the template comments of your page template like this:
<?php
/*
Template Name: Your Post Template Name
Template Post Type: post
*/
For 3, I found a plugin that ruined my post_type pages, and it was a lot of pretty awful, unmaintained code.
So I wrote a solution to accomplish all this, borrowing from this answer:
(Tested with 4.9.8)
<?php
add_action('registered_post_type', 'make_posts_hierarchical', 10, 2 );
// Runs after each post type is registered
function make_posts_hierarchical($post_type, $pto){
// Return, if not post type posts
if ($post_type != 'post') return;
// access $wp_post_types global variable
global $wp_post_types;
// Set post type "post" to be hierarchical
$wp_post_types['post']->hierarchical = 1;
// Add page attributes to post backend
// This adds the box to set up parent and menu order on edit posts.
add_post_type_support( 'post', 'page-attributes' );
}
/**
* Get parent post slug
*
* Helpful function to get the post name of a posts parent
*/
function get_parent_post_slug($post) {
if (!is_object($post) || !$post->post_parent) {
return false;
}
return get_post($post->post_parent)->post_name;
}
/**
*
* Edit View of Permalink
*
* This affects editing permalinks, and $permalink is an array [template, replacement]
* where replacement is the post_name and template has %postname% in it.
*
**/
add_filter('get_sample_permalink', function($permalink, $post_id, $title, $name, $post) {
if ($post->post_type != 'post' || !$post->post_parent) {
return $permalink;
}
// Deconstruct the permalink parts
$template_permalink = current($permalink);
$replacement_permalink = next($permalink);
// Find string
$postname_string = '/%postname%/';
// Get parent post
$parent_slug = get_parent_post_slug($post);
$altered_template_with_parent_slug = '/' . $parent_slug . $postname_string;
$new_template = str_replace($postname_string, $altered_template_with_parent_slug, $template_permalink);
$new_permalink = [$new_template, $replacement_permalink];
return $new_permalink;
}, 99, 5);
/**
* Alter the link to the post
*
* This affects get_permalink, the_permalink etc.
* This will be the target of the edit permalink link too.
*
* Note: only fires on "post" post types.
*/
add_filter('post_link', function($post_link, $post, $leavename){
if ($post->post_type != 'post' || !$post->post_parent) {
return $post_link;
}
$parent_slug = get_parent_post_slug($post);
$new_post_link = str_replace($post->post_name, $parent_slug . '/' . $post->post_name, $post_link);
return $new_post_link;
}, 99, 3);
/**
* Before getting posts
*
* Has to do with routing... adjusts the main query settings
*
*/
add_action('pre_get_posts', function($query){
global $wpdb, $wp_query;
$original_query = $query;
$uri = $_SERVER['REQUEST_URI'];
// Do not do this post check all the time
if ( $query->is_main_query() && !is_admin()) {
// get the post_name
$basename = basename($uri);
// find out if we have a post that matches this post_name
$test_query = sprintf("select * from $wpdb->posts where post_type = '%s' and post_name = '%s';", 'post', $basename);
$result = $wpdb->get_results($test_query);
// if no match, return default query, or if there's no parent post, this is not necessary
if (!($post = current($result)) || !$post->post_parent) {
return $original_query;
}
// get the parent slug
$parent_slug = get_parent_post_slug($post);
// concat the parent slug with the post_name to get most of the url
$hierarchal_slug = $parent_slug . '/' . $post->post_name;
// if the concat of parent-slug/post-name is not in the uri, this is not the right post.
if (!stristr($uri, $hierarchal_slug)) {
return $original_query;
}
// pretty high confidence that we need to override the query.
$query->query_vars['post_type'] = ['post'];
$query->is_home = false;
$query->is_page = true;
$query->is_single = true;
$query->queried_object_id = $post->ID;
$query->set('page_id', $post->ID);
return $query;
}
}, 1);
You can save this to a file custom-posts-hierarchy.php and include it in your functions.php file in your theme, or you can add to the top:
/*
Plugin Name: Custom Posts Hierarchy
Plugin URI:
Description: Add page attributes to posts and support hiearchichal
Author: Angela Murrell
Version:
Author URI:
*/
And drop it into your plugins folder. Good luck!
Posts in Wordpress are supposed to be typical chronological Blog Posts. Pages are made for static content, they can be organised in a hierarchical structure out of the box.
For any Page, you can select a parent page. This way, you can create nested hierarchies with multiple children. Sounds like what you need.
Check the Wordpress Documentation for details.
If you have a deep, complicated tree structure, a plugin might help you manage it, like Wordpress Page Tree. It provides a better interface than the default Wordpress Page listing.
Using a plugins like CPT UI, you can create a custom post type and set it to have hierarchical tree.
Then just check that the post type page-attribute is set for this custom post type and voile, your posts now have hierarchical states.
https://wordpress.org/plugins/custom-post-type-ui/
best solution is to create custom taxonomy [1]: http://codex.wordpress.org/Function_Reference/register_taxonomy and create main slug - books or something else.
There exists plugin, which creates hierarchy for post:
https://wordpress.org/plugins/add-hierarchy-parent-to-post/
Isn't this a better option?
register_post_type( 'MYPOSTTYPE',
array(
'labels' => array(
'name' => __( 'MYPOSTTYPE' ),
'singular_name' => __( 'MYPOSTTYPE' )
),
'supports' => array('title', 'editor', 'page-attributes'),
'public' => true,
'has_archive' => true,
'hierarchical' => true,
'rewrite' => array('slug' => 'MYPOSTTYPE'),
)
);
I have added:
'hierarchical' => true,
and it works.
I was also considering to add hierarchy to posts and I read through this topic. My conclusion is that (in my opinion) it is better to avoid adding hierarchy to posts. Post and page hierarchy is such a core concept of Wordpress, and all kinds of issues with SEO and compatibility with plugins (and Wordpress core) can come up when touching this.
Instead of implementing a complicated technical solution, and having to hope that it remains compatible with future Wordpress and plugin updates, it seems better to use categories and subcategories for hierarchy of posts. And if that doesn't provide sufficient flexibility, use pages instead of posts because pages also come with support for hierarchy out of the box (as previously mentioned by others).
There may be situations where it looks like post categories or pages both can't solve your hierarchy problem but I'd still argue that you should consider whether you want to complicate things for yourself by implementing some form of customized post hierarchy solution, and all the future problems that can come from it.
While it is tempting to come up with a technical solution for a problem, sometimes the best answer is to not do something at all and use a different approach altogether. I strongly believe that is the case here.