feat(17-03): the other four Journal menu item types
- journal-category (category tree references, nesting returns the subtree), all-journal-categories (by name), all-journal-posts (published, by title) and category-journal-posts (category and descendants, published, by title) - list types take the record type's DefaultPattern and set PatternFrom, and build their items from the layered pattern - menuitem labels for all five types in en and pl - README section Menu item types and API reference rows - TestMenuItemTypesSmoke covers host and item overrides, nesting, ordering, the published filter and x-default
This commit is contained in:
@@ -19,52 +19,100 @@ import (
|
||||
// The Journal menu item type keys, the PHP keys of Plugin.php
|
||||
// extendWinterPagesPlugin (D-04).
|
||||
const (
|
||||
MenuTypePost = "journal-post"
|
||||
MenuTypeCategory = "journal-category"
|
||||
MenuTypeAllCategories = "all-journal-categories"
|
||||
MenuTypePost = "journal-post"
|
||||
MenuTypeAllPosts = "all-journal-posts"
|
||||
MenuTypeCategoryPosts = "category-journal-posts"
|
||||
)
|
||||
|
||||
// The phrase keys of the menu item type labels.
|
||||
const (
|
||||
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
|
||||
menuLabelCategory = "golem15.journal::lang.menuitem.journal_category"
|
||||
menuLabelAllCategories = "golem15.journal::lang.menuitem.all_journal_categories"
|
||||
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
|
||||
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
|
||||
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
|
||||
)
|
||||
|
||||
// The default URL patterns of the menu item types. A host overrides them
|
||||
// through the registry's menu_items.patterns config, an item through its own
|
||||
// URL pattern.
|
||||
// URL pattern. The list types take the pattern of the record type they list
|
||||
// and name it in PatternFrom, so a host that overrides journal-post and
|
||||
// journal-category changes the list types' children too.
|
||||
const (
|
||||
DefaultPostPattern = "/blog/:slug"
|
||||
DefaultPostPattern = "/blog/:slug"
|
||||
DefaultCategoryPattern = "/blog/category/:slug"
|
||||
)
|
||||
|
||||
// xDefaultLocale is the single alternate's locale when translate has no
|
||||
// enabled locale (PHP buildAlternateLinks).
|
||||
const xDefaultLocale = "x-default"
|
||||
|
||||
// postPlaceholders are the placeholders a post URL pattern understands, in
|
||||
// the order PHP getMenuTypeInfo lists them.
|
||||
var postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
|
||||
// The placeholders a URL pattern understands, in the order PHP
|
||||
// getMenuTypeInfo lists them.
|
||||
var (
|
||||
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
|
||||
categoryPlaceholders = []string{":slug", ":id"}
|
||||
)
|
||||
|
||||
// MenuItemTypes returns the Journal menu item types, the typed successors
|
||||
// of the PHP pages.menuitem handlers of Post and Category (headless branch):
|
||||
//
|
||||
// - journal-category: one category picked from the category tree; it
|
||||
// resolves to the category's URL and, with nesting, to its subtree as
|
||||
// sub-items, each with its URL in the first locale only.
|
||||
// - all-journal-categories: a list without a URL of its own whose items are
|
||||
// every category ordered by name.
|
||||
// - journal-post: one post picked by id (References lists every post by
|
||||
// title); it resolves to the post's URL without a published filter.
|
||||
// - all-journal-posts: a list whose items are the published posts ordered
|
||||
// by title.
|
||||
// - category-journal-posts: a list whose items are the published posts of
|
||||
// the picked category and its descendants, ordered by title.
|
||||
//
|
||||
// URLs are built from the pattern the registry layered (MenuItemResolve
|
||||
// Pattern) by ordered literal replacement of the placeholders, as PHP
|
||||
// buildUrlFromPattern did: posts fill :id, :slug, :category (the slug of the
|
||||
// post's first category by name) and, for a published post with a publish
|
||||
// date, :year, :month and :day (UTC). Alternates hold one URL per enabled
|
||||
// translate locale in sort_order (ties by id): the unprefixed locale
|
||||
// (MenuItemResolve.UnprefixedLocale, else translate's default locale) has no
|
||||
// prefix, every other locale the prefix /code, and slugs are translated per
|
||||
// locale with the default value as fallback. Without an enabled locale the
|
||||
// only alternate is x-default. The item URL is the first alternate's URL, as
|
||||
// PHP took reset($alternateLinks).
|
||||
// A list type builds its items from the pattern it receives, which the
|
||||
// registry layers from the item's override, the host override of the list
|
||||
// key, the host override of its PatternFrom record type and the default.
|
||||
// Published means published, with a publish date in the past. Ties in every
|
||||
// order break by id.
|
||||
//
|
||||
// URLs are built from the layered pattern (MenuItemResolve Pattern) by
|
||||
// ordered literal replacement of the placeholders, as PHP
|
||||
// buildUrlFromPattern did: categories fill :id and :slug; posts fill :id,
|
||||
// :slug, :category (the slug of the post's first category by name) and, for
|
||||
// a published post with a publish date, :year, :month and :day (UTC).
|
||||
// Alternates hold one URL per enabled translate locale in sort_order (ties by
|
||||
// id): the unprefixed locale (MenuItemResolve.UnprefixedLocale, else
|
||||
// translate's default locale) has no prefix, every other locale the prefix
|
||||
// /code, and slugs are translated per locale with the default value as
|
||||
// fallback. Without an enabled locale the only alternate is x-default. An
|
||||
// item's URL is its first alternate's URL, as PHP took reset($alternateLinks).
|
||||
//
|
||||
// app is asked for the booted application at request time; its *gorm.DB is
|
||||
// the database the types read.
|
||||
func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
m := menuTypes{app: app}
|
||||
return []pact.MenuItemType{
|
||||
{
|
||||
Key: MenuTypeCategory,
|
||||
Label: menuLabelCategory,
|
||||
DefaultPattern: DefaultCategoryPattern,
|
||||
Placeholders: append([]string(nil), categoryPlaceholders...),
|
||||
Nesting: true,
|
||||
DynamicItems: true,
|
||||
References: m.categoryReferences,
|
||||
Resolve: m.resolveCategory,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeAllCategories,
|
||||
Label: menuLabelAllCategories,
|
||||
DefaultPattern: DefaultCategoryPattern,
|
||||
PatternFrom: MenuTypeCategory,
|
||||
Placeholders: append([]string(nil), categoryPlaceholders...),
|
||||
DynamicItems: true,
|
||||
Resolve: m.resolveAllCategories,
|
||||
},
|
||||
{
|
||||
Key: MenuTypePost,
|
||||
Label: menuLabelPost,
|
||||
@@ -73,6 +121,25 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
References: m.postReferences,
|
||||
Resolve: m.resolvePost,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeAllPosts,
|
||||
Label: menuLabelAllPosts,
|
||||
DefaultPattern: DefaultPostPattern,
|
||||
PatternFrom: MenuTypePost,
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
DynamicItems: true,
|
||||
Resolve: m.resolveAllPosts,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeCategoryPosts,
|
||||
Label: menuLabelCategoryPosts,
|
||||
DefaultPattern: DefaultPostPattern,
|
||||
PatternFrom: MenuTypePost,
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
DynamicItems: true,
|
||||
References: m.categoryReferences,
|
||||
Resolve: m.resolveCategoryPosts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +264,260 @@ func resolvePostItems(ctx context.Context, db *gorm.DB, posts []models.Post, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveAllPosts resolves all-journal-posts: no URL, the published posts
|
||||
// ordered by title as items.
|
||||
func (m menuTypes) resolveAllPosts(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
if err := publishedPosts(db).Order("title ASC, id ASC").Find(&posts).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item posts: %w", err)
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
}
|
||||
|
||||
// resolveCategoryPosts resolves category-journal-posts: no URL, the
|
||||
// published posts of the referenced category and its descendants ordered by
|
||||
// title as items. An unknown category resolves to nothing.
|
||||
func (m menuTypes) resolveCategoryPosts(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cats []models.Category
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item category %d: %w", id, err)
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids, err := categoryAndDescendantIDs(db, cats[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
err = publishedPosts(db).
|
||||
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN ?)", ids).
|
||||
Order("title ASC, id ASC").
|
||||
Find(&posts).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item category posts: %w", err)
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
}
|
||||
|
||||
// publishedPosts restricts db to published posts: published, with a publish
|
||||
// date in the past (PHP scopeIsPublished, the public API's rule).
|
||||
func publishedPosts(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now())
|
||||
}
|
||||
|
||||
// categoryAndDescendantIDs is the category's id and the ids of every
|
||||
// category inside its nested-set range (PHP getAllChildrenAndSelf).
|
||||
func categoryAndDescendantIDs(db *gorm.DB, c models.Category) ([]uint, error) {
|
||||
ids := []uint{c.ID}
|
||||
if c.NestLeft == nil || c.NestRight == nil {
|
||||
return ids, nil
|
||||
}
|
||||
var kids []uint
|
||||
if err := db.Model(&models.Category{}).
|
||||
Where("nest_left > ? AND nest_right < ?", *c.NestLeft, *c.NestRight).
|
||||
Order("nest_left ASC, id ASC").
|
||||
Pluck("id", &kids).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item category descendants: %w", err)
|
||||
}
|
||||
return append(ids, kids...), nil
|
||||
}
|
||||
|
||||
// categoryTree is the categories nested by parent_id in nested-set order
|
||||
// (PHP getNested): a category whose parent is not among them is a root.
|
||||
type categoryTree struct {
|
||||
cats []models.Category
|
||||
index map[uint]int
|
||||
roots []int
|
||||
children map[uint][]int
|
||||
}
|
||||
|
||||
// loadCategoryTree reads every category ordered by nest_left, then name and
|
||||
// id.
|
||||
func loadCategoryTree(ctx context.Context, db *gorm.DB) (categoryTree, error) {
|
||||
var cats []models.Category
|
||||
if err := db.WithContext(ctx).Order("nest_left ASC NULLS LAST, name ASC, id ASC").Find(&cats).Error; err != nil {
|
||||
return categoryTree{}, fmt.Errorf("golem15.journal: menu item categories: %w", err)
|
||||
}
|
||||
t := categoryTree{cats: cats, index: map[uint]int{}, children: map[uint][]int{}}
|
||||
for i, c := range cats {
|
||||
t.index[c.ID] = i
|
||||
}
|
||||
for i, c := range cats {
|
||||
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
|
||||
if _, ok := t.index[*c.ParentID]; ok {
|
||||
t.children[*c.ParentID] = append(t.children[*c.ParentID], i)
|
||||
continue
|
||||
}
|
||||
}
|
||||
t.roots = append(t.roots, i)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t categoryTree) ids() []uint {
|
||||
out := make([]uint, 0, len(t.cats))
|
||||
for _, c := range t.cats {
|
||||
out = append(out, c.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// references is the tree as menu item references: value id, label name.
|
||||
func (t categoryTree) references(nodes []int) []pact.MenuItemReference {
|
||||
out := make([]pact.MenuItemReference, 0, len(nodes))
|
||||
for _, i := range nodes {
|
||||
c := t.cats[i]
|
||||
out = append(out, pact.MenuItemReference{
|
||||
Value: strconv.FormatUint(uint64(c.ID), 10),
|
||||
Label: c.Name,
|
||||
Items: t.references(t.children[c.ID]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// categoryReferences lists the category tree (PHP listSubCategoryOptions).
|
||||
func (m menuTypes) categoryReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := loadCategoryTree(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.references(t.roots), nil
|
||||
}
|
||||
|
||||
// categoryItems resolves categories with their pattern URLs and
|
||||
// alternates.
|
||||
type categoryItems struct {
|
||||
pattern string
|
||||
locales menuLocales
|
||||
tr menuTranslations
|
||||
}
|
||||
|
||||
func newCategoryItems(ctx context.Context, db *gorm.DB, in pact.MenuItemResolve, ids []uint) (categoryItems, error) {
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return categoryItems{}, err
|
||||
}
|
||||
tr, err := loadMenuTranslations(ctx, db, models.Category{}.MorphName(), ids, locales)
|
||||
if err != nil {
|
||||
return categoryItems{}, err
|
||||
}
|
||||
return categoryItems{pattern: in.Pattern, locales: locales, tr: tr}, nil
|
||||
}
|
||||
|
||||
func (ci categoryItems) item(c models.Category) pact.ResolvedMenuItem {
|
||||
params := func(locale string) [][2]string {
|
||||
slug := c.Slug
|
||||
if locale != "" {
|
||||
slug = ci.tr.value(locale, c.ID, "slug", slug)
|
||||
}
|
||||
return [][2]string{{"id", strconv.FormatUint(uint64(c.ID), 10)}, {"slug", slug}}
|
||||
}
|
||||
title := func(locale string) string {
|
||||
if locale == "" {
|
||||
return c.Name
|
||||
}
|
||||
return ci.tr.value(locale, c.ID, "name", c.Name)
|
||||
}
|
||||
return resolvedMenuItem(ci.pattern, ci.locales, c.Name, c.UpdatedAt, params, title)
|
||||
}
|
||||
|
||||
// subtree resolves the children of id, each with its own children.
|
||||
func (ci categoryItems) subtree(t categoryTree, id uint) []pact.ResolvedMenuItem {
|
||||
nodes := t.children[id]
|
||||
out := make([]pact.ResolvedMenuItem, 0, len(nodes))
|
||||
for _, i := range nodes {
|
||||
c := t.cats[i]
|
||||
item := ci.item(c)
|
||||
if kids := ci.subtree(t, c.ID); len(kids) > 0 {
|
||||
item.Items = kids
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveCategory resolves journal-category: the category's URL, and with
|
||||
// nesting its subtree as items. An unknown category resolves to nothing.
|
||||
func (m menuTypes) resolveCategory(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := loadCategoryTree(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, ok := t.index[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
ci, err := newCategoryItems(ctx, db, in, t.ids())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ci.item(t.cats[i])
|
||||
if in.Nesting {
|
||||
item.Items = ci.subtree(t, id)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// resolveAllCategories resolves all-journal-categories: no URL, every
|
||||
// category ordered by name as items.
|
||||
func (m menuTypes) resolveAllCategories(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cats []models.Category
|
||||
if err := db.Order("name ASC, id ASC").Find(&cats).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item categories: %w", err)
|
||||
}
|
||||
ids := make([]uint, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
ci, err := newCategoryItems(ctx, db, in, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]pact.ResolvedMenuItem, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
items = append(items, ci.item(c))
|
||||
}
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
}
|
||||
|
||||
// postURLParams are a post's placeholder values in PHP
|
||||
// getHeadlessUrlParams order: id, slug, category, then year, month and day
|
||||
// for a published post with a publish date.
|
||||
|
||||
Reference in New Issue
Block a user