keystonejs / keystone-classic

Node.js CMS and web app framework
http://v4.keystonejs.com
MIT License
14.63k stars 2.2k forks source link

Pages, Page Tree and Navigation #428

Closed MartinArendt closed 8 years ago

MartinArendt commented 10 years ago

I did some research for Content Management Systems based on node,js. My favourite would have been nodize but the apparent lack of continuous development was a show-stopper.

So Keystone got on the shortlist. No full-grown CMS compared to what I'm used to in the PHP world though a lightweight design and rather flexible.

Just one downer: it does not support tree structures for pages probably owing to nosql mongodb. I may be antiquated but I consider this as real disadvantage because trees are quite usefull for some purposes like navigation structures, breadcrumbs etc. Is there anyone here who has tried to extend keysone to manage a page tree? Is it a good idea, anyhow?

JedWatson commented 10 years ago

@MartinArendt there are a few people who have created page trees and template systems using Keystone Lists, who will hopefully share their experience here...

I can say that it's a big new feature we'd like to do properly and there's some prototype code in the system at the moment to support it, it needs quite a bit of work to become as mature as the rest of the system though.

So it's not a limitation of MongoDB, just a feature that we're taking our time to get right :)

MartinArendt commented 10 years ago

@JedWatson many thanks for your reply. Can you provide a rough estimation when this might become available?

I also would be very interested in the experiences of other developers.

P.S. not a limitation of MongoDB, indeed. I was just guessing that there are some issues to be addressed which might have restrained people from doing it to date.

danielmahon commented 10 years ago

I can share a simple integration I've been working on. I only built this yesterday so it really hasn't had ample testing or thought ;) but maybe it will spark something for your own project.

The basic premise is that the last route is a catch-all for /:parent/:slug and that triggers a DB lookup for pages or parents matching the path. If nothing is found, it continues, else it populates a common page view with the html from the DB result. I also changed the default way the navigation is handling just a bit to support pages being added on the fly.

Here are the main changes:

keystone.js I start with a defined set of top-level nav items, inserted after keystone.init()

keystone.set('navigation', [{
    label: 'Home',
    key: 'home',
    href: '/'
}, {
    label: 'About',
    key: 'about',
    href: '/about'
}, {
    label: 'Blog',
    key: 'blog',
    href: '/blog'
}, {
    label: 'Gallery',
    key: 'gallery',
    href: '/gallery'
}, {
    label: 'Contact',
    key: 'contact',
    href: '/contact'
}]);

/models/pages.js Create the Page model, and a function to update the navigation upon init and modifying a page, that way the nav will show the changes on refresh

'use strict';

var keystone = require('keystone'),
  _ = require('underscore'),
  Types = keystone.Field.Types;

var Page = new keystone.List('Page', {
  map: { name: 'title' },
  autokey: { path: 'slug', from: 'title', unique: true }
});

function updateNavigation() {
  Page.model.find({
    state: 'published'
  }, function(err, pages) {
    console.log(pages.length);
    pages.forEach(function(page, i) {
      var navLink = _.findWhere(keystone.get('navigation'), {
        key: page.parent
      });
      if (i === 0) navLink.children = [];
      navLink.children.push({
        label: page.title,
        key: page.slug,
        href: '/' + page.parent + '/' + page.slug
      });
    });
  });
}

Page.add({
  title: { type: String, required: true },
  slug: { type: String, index: true },
  state: { type: Types.Select, options: 'draft, published, archived', default: 'draft', index: true },
  author: { type: Types.Relationship, ref: 'User', index: true },
  publishedDate: { type: Types.Date, index: true },
  parent: { type: Types.Select, options: _.pluck(keystone.get('navigation'), 'key').join(','), required: true, default: keystone.get('navigation')[0].key},
  content: {
    html: { type: Types.Html, wysiwyg: true, height: 600 }
  },
  categories: { type: Types.Relationship, ref: 'PageCategory', many: true }
});

Page.defaultColumns = 'title, parent|10%, state|10%, author|10%, publishedDate|20%';

// Update navigation on page save
Page.schema.post('save', function () {
  console.log('Save Post');
  updateNavigation();
});

Page.register();

// Init navigation
updateNavigation();

/routes/index.js add route to handle pages after defaults

// Views
    app.get('/', routes.views.index);
    app.get('/blog/:category?', routes.views.blog);
    app.get('/blog/post/:post', routes.views.post);
    app.get('/gallery', routes.views.gallery);
    app.all('/contact', routes.views.contact);

    app.get('/:parent/:slug?', routes.views.pages);

/routes/middleware.js update middleware initLocals function to set navigation

exports.initLocals = function(req, res, next) {

    var locals = res.locals;

    // Set locals
    locals.user = req.user;
    locals.navigation = keystone.get('navigation');
    locals.settings = keystone.get('settings');

    next();

};

/routes/views/pages.js query DB for docs matching req.pathand render match to page view, this is still a bit sloppy

'use strict';

var keystone = require('keystone'),
  async = require('async');

exports = module.exports = function(req, res, next) {

  var view = new keystone.View(req, res),
    locals = res.locals;

  // Set locals
  locals.filters = {
    parent: req.params.parent || '',
    slug: req.params.slug || ''
  };

  // Load the current post
  view.on('init', function(done) {

    var q = keystone.list('Page').model.findOne({
      state: 'published',
      parent: locals.filters.parent,
      slug: locals.filters.slug
    });

    q.exec(function(err, result) {
      if (result) {
        locals.section = locals.filters.parent;
        locals.title = result.title;
        locals.page = result;
        done();
      } else {
        done(404);
      }
    });

  });

  // Render the view
  view.render(function (err, req, res) {
    if (!locals.filters.slug) {
      locals.section = locals.filters.parent;
      locals.title = locals.filters.parent;
      return res.render(locals.filters.parent);
    }
    if (err === 404)  {
      return next();
    }
    res.render('page');
  });

};

/templates/views/page.html rendered view, im using nunjucks

{% block content %}
// ...
          <div>
            {{page.content.html}}
          </div>
//...
{% endblock %}

/templates/includes/header.html here is the include for the navigation to create the main nav links with dropdowns

          <!-- Navigation links -->
          <div class="collapse navbar-collapse navbar-ex1-collapse">
            <ul class="nav navbar-right navbar-nav">
              {% for link in navigation %}
              <li class="dropdown">
                <a href="{{link.href}}" class="dropdown-toggle">{{link.label}}</a>
                {% if link.children.length %}
                <ul class="dropdown-menu">
                  {% for child in link.children %}
                  <li><a href="{{child.href}}">{{child.label}}</a></li>
                  {% endfor %}
                </ul>
                {% endif %}
              </li>
              {% endfor %}
            </ul>
          </div>

I think that's it, hope it helps! (expect bugs) Also, this only support 2 level navigation, but you get the idea.

danielmahon commented 10 years ago

While this way doesnt actually extend keystone, I do think that once @JedWatson can solidify a plugin schema, a keystone-pages plugin would be the next step.

alsoicode commented 10 years ago

@danielmahon Would you mind sharing how you configured/added Nunjucks to your Keystone project? I'm assuming you passed in keystone as the app instance for Nunjucks?

Did you have to make any other changes in keystone.init()?

I can't quite seem to find the right combination. I've tried:

keystone.init({
    ...
});

keystone.set('view engine', 'nunjucks');
keystone.set('custom engine', nunjucks.render);

nunjucks.configure('templates', {
    autoescape: true,
    express: keystone
});

but I'm getting an error from Keystone stating that it failed to lookup view 'index'

danielmahon commented 10 years ago

@alsoicode Here is what I'm currently doing, with the help of TJs consolidate library. I suspect there is a simpler way to do this, without consolidate, but I also wanted to manipulate the templates on the fly to mimic the old "layouts" express style. Anywho, it seems to work fine for now...

I am also passing the nunjucks env to the ./lib/filters.js module that follows the nunjucks convention of adding filters to an environment.

/keystone.js

// Require keystone
var keystone = require('keystone'),
  pkg = require('./package.json'),
  filters = require('./lib/filters.js'),
  cons = require('consolidate'),
  nunjucks = require('nunjucks');

// Initialise Keystone with your project's configuration.
// See http://keystonejs.com/guide/config for available options
// and documentation.

// // Override render function to auto apply layout
cons.nunjucks.render = function(str, options, fn) {
  // Define new nunjucks env, set template location and path
  var env = nunjucks.configure('templates/views'),
    layout = options.layout || 'default',
    layoutPath = 'layouts/' + layout + '.html';
  // add default layout to every template
  str = '{% extends "' + layoutPath + '" %}\n' + str;
  // Add filters to nunjucks environment
  filters.init(env);
  // Render page
  env.renderString(str, options, fn);
};

keystone.init({
...
  'views': 'templates/views',
  'view engine': 'html',
  'custom engine': cons.nunjucks,
...
});
alsoicode commented 10 years ago

Wow. I would never have arrived at that solution on my own. I'll give it a try later this evening. Does providing this override of the template engine cause the admin pages to stop working?

danielmahon commented 10 years ago

@alsoicode No, the admin templating is completely internal to keystone, for now... I am going to create a post on the groups page if you want to discuss nunjucks further, that way we stop hijacking this issue ;)

https://groups.google.com/forum/#!topic/keystonejs/D7JQmM2MY1Y

alsoicode commented 10 years ago

Gotcha. Thank you so much for the code sample.

On Mon, Jul 7, 2014 at 1:40 PM, Daniel Mahon notifications@github.com wrote:

@alsoicode https://github.com/alsoicode No, the admin templating is completely internal to keystone, for now... I am going to create a post on the groups page if you want to discuss nunjucks further, that way we stop hijacking this issue ;)

https://groups.google.com/forum/#!topic/keystonejs/D7JQmM2MY1Y

— Reply to this email directly or view it on GitHub https://github.com/JedWatson/keystone/issues/428#issuecomment-48211241.

hatzipanis commented 9 years ago

@danielmahon Interested to hear if this is still what you're doing or whether you have refined your process even further. I'm about to implement some kind of Page model and I haven't seen anything more detailed floating around than the above.

@JedWatson Any update on when we can expect something natively?

radiovisual commented 8 years ago

@danielmahon I would also be curious to learn if your approach has been battle-tested. I am about to venture into creating custom Pages in keystone for an upcoming CMS project, and I need something similar to what you have implemented.

@JedWatson , I know that a "Pages" feature is not built-in to keystone yet, but can you offer some guidance on an approach you would take to implement this using the latest version of Keystone? Many of the clients I build for are not technically-inclined, so they need a simple way to create new content on their websites. I am currently evaluating keystone for a massive project, and this is an important feature.

I have found this article: http://rob.codes/creating-a-page-router-in-keystonejs/, but it doesn't touch on dynamic building of navigation, and I will refer to danielhahon's advice above to see if I can get a working implementation from there, but it would be nice to get an official position on such an important feature.

Also, thanks for all the great work you have put into this project! :+1:

LorbusChris commented 8 years ago

+1 to this. I'd really like to use react-router for this. I've been mingling with react-intl (the v2 preprelease) and the keystone-test-project and was actually hoping to build smth like a navbar component with a select language dropdown. Still wrapping my head around server side parsing of localization files and all that, though. Also noticed there was no predefined pages model. I guess thats probably because a page might now just be a component with more components in it. Because I'm pretty new to this I'd like to keep my dev stack a small ap. Thats why I want to avoid templating engines, and I dont think they are needed anymore when using react. This is of course probably means building the public UI also entirely in react. Whats the plan at thinkmill for that?

Tl;dr I'd really like to see a sample implementation of a react-router powered navbar for react Pages/Components (whatever you want to call them)

JedWatson commented 8 years ago

Dynamic building of navigation actually isn't too challenging in Express; you just need to replace the build-in router for the dynamic routes with your own middleware, and have it look up the path in the database (can be cached for speed if necessary but that's unlikely)

i.e if you wanted to load a page from a Pages collection, where each Page stores a custom URL path and a template file to use to render, it would look something like this:

In your routes/index.js:

// note: this goes after other routes with dynamic paths, like the blog
app.get(':page', function(req, res, next) {
  Page.findOne().where('path', req.params.page).exec(function(err, page) {
    if (err || !page) return next(err);
    res.render('pages/' + page.template, { page: page });
  });
});

Anything you put on the Page model would then be available under the page variable in the template. The middleware above is template engine agnostic, and could be a smarter if you wanted (e.g. you could predefine datasets to load in checkboxes in the Page model, and conditionally run the queries before rendering the template)

You'd probably also store some navigation properties in the Page model, like showInPrimaryNavigation: boolean that you could use in the templates to look through the items & dynamically generate HTML.

@LorbusChris we're experimenting heavily with Keystone + React + react-router powered sites at Thinkmill at the moment. Generally this means generating whatever page-init data is required into a <script> tag when the base template is loaded, and using that to generate whatever we need in React; other data is loaded by the API. We're not really seeing a lot of downside to site front-ends being generated entirely in React these days (especially with options to pre-render HTML server-side available if you need them)

I'm really interested in seeing something developed in this area around pre-build React components that work with a pre-built Keystone model (or convention for configuring Page models)

If you're up for contributing some patterns to the test site (or keystone-react generator) please feel free to propose them - would be good to get something more self-documenting out there.

Right now developments in this area (new react-router about to be released, redux, relay + graphql, etc) mean things are rapidly evolving and we basically have infinite options on how to solve problems... and don't even get me started on style preprocessors vs. styles in components ...

Gotta jump back into code right now - really pushing to get 0.4 released - but I hope this is useful, and feel free to ask me any more specific questions about the ideas above!

LorbusChris commented 8 years ago

Thanks @JedWatson, my learning curve is really gaining some momentum now! :)

I actually converted the react-router sidebar example to smth of a navbar. Gonna mash it up with the react-intl translations example. And I will also think of a keystone model for pages, properties might be: showInPrimaryNav: boolean ownsSecondaryNav: boolean showInFooterNav: boolean or should the Secondary Nav (being a sub nav/sidebar on one page) not be in here?

React Intl lets you declare default formatted messages in the code, and then extracts those messages with a babel plugin. Components can be exported with a HOC like this

import {intlShape, injectIntl, defineMessages} from 'react-intl';

class LocalesMenu extends Component {
    render() {
        const {formatMessage} = this.props.intl;

        return (
           <menu>                 
               <li>
                    <a
                        href="/?locale=en-US"
                        title={formatMessage(messages.enUSDescription)}
                    >
                        en-US
                    </a>
               </li>
           </menu>
        );
    }
}

LocalesMenu.propTypes = {
    intl: intlShape.isRequired,
};

export default injectIntl(LocalesMenu);

and then rendered like this (client):

import {IntlProvider} from 'react-intl';
import App from './components/app';

const {locale, messages} = window.App;

ReactDOM.render(
    <IntlProvider locale={locale} messages={messages}>
        <App />
    </IntlProvider>,
    document.getElementById('container')
);

In the provided example Messages are aggregated in one file for translation, and on runtime parsed and injected as needed. Express also sets (lets) default values for locale and messages here: https://github.com/yahoo/react-intl/blob/master/examples/translations/src/server/index.js

Overall, react-intl works with thes types: https://github.com/yahoo/react-intl/blob/master/src/types.js

I would love to see these types implemented with keystone. Also formatting of numbers, dates, plurals and relative numbers can be done with it..not sure how much work that would be though. I'll focus on my intl-navbar and get back to you guys once I have something I can confidently show off (in a few days hopefully :)

shanepisko commented 8 years ago

I am working on implementing the steps outlined by @danielmahon above and am receiving a few errors, the current one i'm dealing with is 55| each link in navigation 56| li.dropdown(class=(section == link.key ? 'active' : null)): a(href=link.href)= link.label

57| if link.children.length 58| ul.dropdown-menu 59| each child in link.children 60| li: a(href=child.href)= child.label

Cannot read property 'length' of undefined

it is referring to the .children reference of the link, also i am using jade templating

danielmahon commented 8 years ago

@shanepisko while I havent touched my above code for a bit, sounds like a simple matter of link.children not being set. Add some console.log statments before line 57 and see if/what link.children is being assigned. You may need to append line 57 to be if link.children && link.children.length

shanepisko commented 8 years ago

Thanks for getting back! i know this is a bit of a dead issue. I will try that. I am now trying to implement this using relationships and referencing other pages as i was having an issue of other pages showing up using the plucking of the keystone.get('navigation')

mxstbr commented 8 years ago

The question seems to have been answered, so I'll close this issue for now. Let me know if you still need something here by commenting!

benjaminapetersen commented 6 years ago

New to keystone, I see this issue is closed of 2 years, but not sure it currently supports dynamic pages & menus via CMS/admin? Just getting into the docs, haven't downloaded to give it a go yet. Def hoping there is a solution, or to know what the current workaround is. Thx!

danielmahon commented 6 years ago

@benjaminapetersen there is definitely a way to handle dynamic page routing and menu with keystone. You still have to build the routing logic (see above) to tell it how to handle your paths. This part is not handled by the admin UI. You can think of the admin UI as a convenient database interface for your models. The basic premise of my example code is still the same for the latest version (that is just one way to handle it). Create a route that matches your dynamic path. Use the route params (parent/slug) to find the correct page in your database and then return that page’s content in a shared template so you don’t need to create a template for each result. This is a very common use case. It’s bascially the same as the blog/article example in the keystone boilerplate. Just instead of looking up an article’s record and displaying it you are looking up a page. I tend to break a page up into sections. So I would have a ‘page’ model that can hold many ‘section’ models. That way I can easily reuse sections (like calls to action) on different pages. Once the structure is setup it is easy to just create new pages and sections in the admin UI and you don’t need to mess with the routing logic anymore.

benjaminapetersen commented 6 years ago

Ok great, thx! I'll give that another look above. Sounds like a common enough use case it could be a handy customization tutorial!

jelordreygulle commented 5 years ago

What I did on implementing page tree and navigation is this , this is simple but i think it could be somehow a solution

Page Model

Pages.add({
    name: { type: String, required: true },
    key: { type: String, required: false },
    link_destination : { type: Types.Url, require: true},
    second_level_page: { type: Types.Relationship, ref: 'SecondLevelPage', many: true },
    publishedDate: { type: Types.Date, index: true, dependsOn: { state: 'published' } },
    state: { type: Types.Select, options: 'draft, published, archived', default: 'draft', index: true },

});

Second Level Page Model

SecondLevelPage.add({
    title: { type: String, required: true },
    link_destination : { type: Types.Url, require: true},
    state: { type: Types.Select, options: 'draft, published, archived', default: 'draft', index: true },
    author: { type: Types.Relationship, ref: 'User', index: true },
    publishedDate: { type: Types.Date, index: true },
    content: {
      html: { type: Types.Html, wysiwyg: true, height: 600 }
    },

  });

And then in the views


//Load the top level pages including the second level
    view.on('init', function (next) {

        var q = keystone.list('Pages').paginate({
            page: req.query.page || 1,
            perPage: 10,
            maxPages: 10,
            filters: {
                state: 'published',
            },
        })
            .sort('publishedDate')
            .populate('second_level_page')

        q.exec(function (err, results) {
            locals.data.toplevelpages = results;
            console.log("Response Data:", locals.data.toplevelpages)
            next(err);
        });
    });

And then on the template

                            {% for page in data.toplevelpages.results %}

                            <li class="{{ 'active' if page.key == section else '' }} dropdown ">
                                <a class="" data-href="/"
                                    href="/{{ page.link_destination }}" id="homePage" title="{{ page.key }}"
                                    aria-label="Home"
                                    onclick="TrackNavigationClick('topNav', this);">{{ page.name }} </a>

                                    <div class="dropdown-content">
                                        {%for second_level in page.second_level_page %}
                                        <a href="/{{ second_level.link_destination }}" style="color:white;font-size:12.5px;text-align: left; padding: 12px 16px;text-transform: capitalize ;">{{ second_level.title }}</a>
                                        {% endfor %}
                                    </div>
                            </li>
                            {% endfor %}

On the admin add your pages and then set the second level page to a page.