diff --git a/ghost/admin/app/components/editor/modals/publish-flow/confirm.hbs b/ghost/admin/app/components/editor/modals/publish-flow/confirm.hbs
index e216ebc21d2..537905ecb1c 100644
--- a/ghost/admin/app/components/editor/modals/publish-flow/confirm.hbs
+++ b/ghost/admin/app/components/editor/modals/publish-flow/confirm.hbs
@@ -20,7 +20,9 @@
{{@publishOptions.post.displayName}}
{{#if this.willPublish}}
- will be published on your site{{#if this.willEmail}}, and delivered to{{else}}.{{/if}}
+ will be published on your site{{#if this.willEmail}}, and delivered to{{else if @publishOptions.desiredNavigationPlacement}}
+ and listed in your {{@publishOptions.desiredNavigationPlacement}} navigation .
+ {{else}}.{{/if}}
{{/if}}
{{#if this.willEmail}}
diff --git a/ghost/admin/app/components/editor/modals/publish-flow/options.hbs b/ghost/admin/app/components/editor/modals/publish-flow/options.hbs
index 1fd0e122b2b..5405cf6c06a 100644
--- a/ghost/admin/app/components/editor/modals/publish-flow/options.hbs
+++ b/ghost/admin/app/components/editor/modals/publish-flow/options.hbs
@@ -134,6 +134,27 @@
{{/if}}
+ {{#if @publishOptions.showNavigationOption}}
+
+
+ {{svg-jar "navigation"}}
+
+ {{@publishOptions.selectedNavigationOption.display}}
+
+
+ {{svg-jar "arrow-down" class="icon-expand"}}
+
+
+ {{#liquid-if (eq this.openSection "navigation")}}
+
+
+
+ {{/liquid-if}}
+
+ {{/if}}
+
{{svg-jar "clock"}}
diff --git a/ghost/admin/app/components/editor/publish-management.js b/ghost/admin/app/components/editor/publish-management.js
index 848a71e3979..088c1cce570 100644
--- a/ghost/admin/app/components/editor/publish-management.js
+++ b/ghost/admin/app/components/editor/publish-management.js
@@ -60,6 +60,7 @@ export default class PublishManagement extends Component {
if (isValid && (!this.publishFlowModal || this.publishFlowModal?.isClosing)) {
this.publishOptions.resetPastScheduledAt();
+ this.publishOptions.resetNavigationPlacement();
this.publishFlowModal = this.modals.open(PublishFlowModal, {
publishOptions: this.publishOptions,
@@ -209,6 +210,15 @@ export default class PublishManagement extends Component {
// save with the required query params for emailing
const result = yield this.publishOptions[taskName].perform();
+ // the page published fine but its navigation placement couldn't be
+ // saved - surface that quietly rather than failing the publish
+ if (taskName === 'saveTask' && this.publishOptions.navigationSaveFailed) {
+ this.notifications.showNotification(
+ 'Page published, but its navigation couldn\'t be updated. You can change it in Settings → Navigation.',
+ {type: 'error', delayed: true}
+ );
+ }
+
// perform any post-save cleanup for the editor
yield this.args.afterPublish(result);
diff --git a/ghost/admin/app/components/editor/publish-options/navigation.hbs b/ghost/admin/app/components/editor/publish-options/navigation.hbs
new file mode 100644
index 00000000000..0fb7b93b921
--- /dev/null
+++ b/ghost/admin/app/components/editor/publish-options/navigation.hbs
@@ -0,0 +1,17 @@
+
+ {{#each @publishOptions.navigationOptions as |option|}}
+
+
+ {{option.label}}
+
+ {{/each}}
+
diff --git a/ghost/admin/app/components/editor/publish-options/navigation.js b/ghost/admin/app/components/editor/publish-options/navigation.js
new file mode 100644
index 00000000000..56e3d8bb330
--- /dev/null
+++ b/ghost/admin/app/components/editor/publish-options/navigation.js
@@ -0,0 +1,10 @@
+import Component from '@glimmer/component';
+import {action} from '@ember/object';
+
+export default class NavigationOption extends Component {
+ @action
+ onChange(event) {
+ event.preventDefault();
+ this.args.publishOptions.setNavigationPlacement(event.target.value);
+ }
+}
diff --git a/ghost/admin/app/components/posts-list/context-menu.hbs b/ghost/admin/app/components/posts-list/context-menu.hbs
index aaf381b853a..b4811777b5a 100644
--- a/ghost/admin/app/components/posts-list/context-menu.hbs
+++ b/ghost/admin/app/components/posts-list/context-menu.hbs
@@ -59,6 +59,30 @@
{{/if}}
+ {{#if this.canManageNavigation}}
+ {{#if this.canAddToPrimaryNavigation}}
+
+
+ {{svg-jar "navigation-primary"}}{{this.primaryNavigationActionLabel}}
+
+
+ {{/if}}
+ {{#if this.canAddToSecondaryNavigation}}
+
+
+ {{svg-jar "navigation-secondary"}}{{this.secondaryNavigationActionLabel}}
+
+
+ {{/if}}
+ {{#if this.canRemoveFromNavigation}}
+
+
+ {{svg-jar "navigation-remove"}}Remove from navigation
+
+
+ {{/if}}
+ {{/if}}
+
{{#if this.canCopySelection}}
diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js
index 8d00d50f973..961c252da15 100644
--- a/ghost/admin/app/components/posts-list/context-menu.js
+++ b/ghost/admin/app/components/posts-list/context-menu.js
@@ -8,6 +8,8 @@ import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import nql from '@tryghost/nql';
import {action} from '@ember/object';
import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter';
+import {getPagePlacement, pagePathForSlug, setPagesNavigationPlacement} from 'ghost-admin/utils/site-navigation';
+import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {task, timeout} from 'ember-concurrency';
@@ -66,6 +68,9 @@ export default class PostsContextMenu extends Component {
@service store;
@service notifications;
@service membersUtils;
+ @service settings;
+
+ @inject config;
get menu() {
return this.args.menu;
@@ -494,4 +499,98 @@ export default class PostsContextMenu extends Component {
get canCopySelection() {
return this.selectionList.availableModels.length === 1;
}
+
+ // site navigation --------------------------------------------------------
+
+ // a nav link points at the page's published URL, so only published pages can
+ // be placed in navigation - linking a draft/scheduled page would 404. Draft
+ // pages set their placement at publish time via the publish flow instead.
+ get canManageNavigation() {
+ return this.type === 'page'
+ && this.session.user.isAdmin
+ && this.selectionList.availableModels.every(model => model.status === 'published');
+ }
+
+ get selectedPagePlacements() {
+ return this.selectionList.availableModels
+ .map(model => getPagePlacement(this.settings, pagePathForSlug(model.slug, this.config.blogUrl), this.config.blogUrl));
+ }
+
+ get isSingleSelection() {
+ return this.selectionList.availableModels.length === 1;
+ }
+
+ // current placement when exactly one page is selected, otherwise null
+ get singleNavigationPlacement() {
+ return this.isSingleSelection ? this.selectedPagePlacements[0] : null;
+ }
+
+ // only offer a destination the selection isn't already entirely in
+ get canAddToPrimaryNavigation() {
+ return this.selectedPagePlacements.some(placement => placement !== 'primary');
+ }
+
+ get canAddToSecondaryNavigation() {
+ return this.selectedPagePlacements.some(placement => placement !== 'secondary');
+ }
+
+ get canRemoveFromNavigation() {
+ return this.selectedPagePlacements.some(placement => placement !== null);
+ }
+
+ // a single already-linked page is moved between menus rather than added
+ get primaryNavigationActionLabel() {
+ return this.singleNavigationPlacement === 'secondary' ? 'Move to primary navigation' : 'Add to primary navigation';
+ }
+
+ get secondaryNavigationActionLabel() {
+ return this.singleNavigationPlacement === 'primary' ? 'Move to secondary navigation' : 'Add to secondary navigation';
+ }
+
+ @action
+ addToPrimaryNavigation() {
+ this.menu.performTask({perform: () => this.updateNavigationPlacementTask.perform('primary')});
+ }
+
+ @action
+ addToSecondaryNavigation() {
+ this.menu.performTask({perform: () => this.updateNavigationPlacementTask.perform('secondary')});
+ }
+
+ @action
+ removeFromNavigation() {
+ this.menu.performTask({perform: () => this.updateNavigationPlacementTask.perform('none')});
+ }
+
+ @task
+ *updateNavigationPlacementTask(placement) {
+ const pages = this.selectionList.availableModels
+ .map(model => ({label: model.title, path: pagePathForSlug(model.slug, this.config.blogUrl)}));
+ const count = pages.length;
+ // a single already-linked page is moved rather than added
+ const isMove = count === 1 && this.singleNavigationPlacement && this.singleNavigationPlacement !== placement;
+
+ try {
+ yield setPagesNavigationPlacement(this.settings, {
+ pages,
+ placement: placement === 'none' ? null : placement,
+ blogUrl: this.config.blogUrl
+ });
+
+ let message;
+ if (placement === 'none') {
+ message = count > 1 ? `${count} pages removed from navigation` : 'Page removed from navigation';
+ } else if (isMove) {
+ message = `Page moved to ${placement} navigation`;
+ } else {
+ message = count > 1 ? `${count} pages added to ${placement} navigation` : `Page added to ${placement} navigation`;
+ }
+
+ this.notifications.showNotification(message, {type: 'success'});
+ } catch (error) {
+ this.notifications.showAPIError(error, {key: 'navigation.save'});
+ }
+
+ return true;
+ }
}
diff --git a/ghost/admin/app/components/posts-list/list-item-analytics.hbs b/ghost/admin/app/components/posts-list/list-item-analytics.hbs
index e5245dc0d89..bdcf7c389f4 100644
--- a/ghost/admin/app/components/posts-list/list-item-analytics.hbs
+++ b/ghost/admin/app/components/posts-list/list-item-analytics.hbs
@@ -159,6 +159,12 @@
{{/if}}
{{/if}}
+
+ {{#if this.navigationPlacement}}
+
+ {{svg-jar "navigation"}} In {{this.navigationPlacement}} navigation
+
+ {{/if}}
{{/unless}}
diff --git a/ghost/admin/app/components/posts-list/list-item-analytics.js b/ghost/admin/app/components/posts-list/list-item-analytics.js
index 7163ce21bfc..b8542270fce 100644
--- a/ghost/admin/app/components/posts-list/list-item-analytics.js
+++ b/ghost/admin/app/components/posts-list/list-item-analytics.js
@@ -1,6 +1,7 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {formatPostTime} from 'ghost-admin/helpers/gh-format-post-time';
+import {getPagePlacement, pagePathForSlug} from 'ghost-admin/utils/site-navigation';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
@@ -28,6 +29,18 @@ export default class PostsListItemClicks extends Component {
return '';
}
+ // 'primary' / 'secondary' when this page is linked in the site navigation,
+ // otherwise null. A nav link points at the published URL, so placement is
+ // only surfaced for live pages - a draft's link would 404 and shouldn't
+ // read as "in navigation".
+ get navigationPlacement() {
+ if (!this.post.isPage || !this.post.isPublished) {
+ return null;
+ }
+
+ return getPagePlacement(this.settings, pagePathForSlug(this.post.slug, this.config.blogUrl), this.config.blogUrl);
+ }
+
get scheduledText() {
let text = [];
diff --git a/ghost/admin/app/styles/components/dropdowns.css b/ghost/admin/app/styles/components/dropdowns.css
index 2b8c0bb4696..7075c031573 100644
--- a/ghost/admin/app/styles/components/dropdowns.css
+++ b/ghost/admin/app/styles/components/dropdowns.css
@@ -351,7 +351,9 @@ Post context menu
}
.gh-posts-context-menu {
- max-width: 160px !important;
+ width: max-content !important;
+ min-width: 160px !important;
+ max-width: 260px !important;
border-radius: var(--border-radius);
box-shadow:
0 0 2.3px rgba(0, 0, 0, 0.028),
@@ -375,6 +377,7 @@ Post context menu
.gh-posts-context-menu li > button span {
display: flex;
align-items: center;
+ white-space: nowrap;
}
.gh-posts-context-menu li > button span svg,
diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css
index 100aac14933..7c7fbac0163 100644
--- a/ghost/admin/app/styles/layouts/content.css
+++ b/ghost/admin/app/styles/layouts/content.css
@@ -1019,6 +1019,27 @@
font-weight: 500;
}
+.gh-content-entry-nav {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 8px;
+ padding-left: 8px;
+ border-left: 1px solid var(--whitegrey);
+ color: var(--midgrey);
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.gh-content-entry-nav svg {
+ width: 11px;
+ height: 11px;
+}
+
+.gh-content-entry-nav svg path {
+ stroke: currentcolor;
+}
+
.schedule-details {
margin-left: 3px;
color: var(--midlightgrey-d1);
diff --git a/ghost/admin/app/utils/publish-options.js b/ghost/admin/app/utils/publish-options.js
index 69a6c3bf313..be3501c60c1 100644
--- a/ghost/admin/app/utils/publish-options.js
+++ b/ghost/admin/app/utils/publish-options.js
@@ -1,5 +1,6 @@
import moment from 'moment-timezone';
import {action} from '@ember/object';
+import {getPagePlacement, pagePathForSlug, setPageNavigationPlacement} from 'ghost-admin/utils/site-navigation';
import {htmlSafe} from '@ember/template';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
@@ -94,6 +95,87 @@ export default class PublishOptions {
}
}
+ // navigation ----------------------------------------------------------
+ // pages are not linked from anywhere on a site by default so when
+ // publishing a page we let the user place it in the site navigation. The
+ // picker reflects the page's current placement so it can be added, moved
+ // between menus, or removed - not just added.
+
+ // the user's explicit picker selection (none/primary/secondary), or
+ // undefined when they haven't touched it - in which case the picker
+ // reflects the page's live current placement so it always matches the
+ // settings menu / pages list, even if those changed it
+ @tracked navigationPlacementOverride = undefined;
+ // true when the most recent saveTask attempted a navigation change that
+ // failed to save - reset at the start of every saveTask so it never leaks
+ // a stale failure into a later publish
+ @tracked navigationSaveFailed = false;
+
+ get pageNavigationPath() {
+ return pagePathForSlug(this.post.slug, this.config.blogUrl);
+ }
+
+ get currentNavigationPlacement() {
+ if (!this.post.isPage || !this.pageNavigationPath) {
+ return null;
+ }
+
+ return getPagePlacement(this.settings, this.pageNavigationPath, this.config.blogUrl);
+ }
+
+ get navigationPlacement() {
+ if (this.navigationPlacementOverride !== undefined) {
+ return this.navigationPlacementOverride;
+ }
+
+ return this.currentNavigationPlacement ?? 'none';
+ }
+
+ // only admins/owners can edit the navigation settings, and a link to a
+ // not-yet-published page would 404 so scheduling hides the option
+ get showNavigationOption() {
+ return this.post.isPage &&
+ !!this.user.isAdmin &&
+ !this.isScheduled &&
+ !!this.pageNavigationPath;
+ }
+
+ get desiredNavigationPlacement() {
+ return this.navigationPlacement === 'none' ? null : this.navigationPlacement;
+ }
+
+ get navigationOptions() {
+ return [{
+ value: 'none',
+ label: 'None', // shown in expanded options (pill)
+ display: 'Not in site navigation' // shown in collapsed option title
+ }, {
+ value: 'primary',
+ label: 'Primary',
+ display: 'Primary navigation'
+ }, {
+ value: 'secondary',
+ label: 'Secondary',
+ display: 'Secondary navigation'
+ }];
+ }
+
+ get selectedNavigationOption() {
+ return this.navigationOptions.find(o => o.value === this.navigationPlacement);
+ }
+
+ @action
+ setNavigationPlacement(placement) {
+ this.navigationPlacementOverride = placement;
+ }
+
+ // discards an unsaved picker selection so the flow always reopens showing
+ // the page's real current placement, not a change that was never published
+ @action
+ resetNavigationPlacement() {
+ this.navigationPlacementOverride = undefined;
+ }
+
// publish type ------------------------------------------------------------
@tracked publishType = 'publish+send';
@@ -313,6 +395,14 @@ export default class PublishOptions {
// willEmail can change after model changes are applied because the post
// can leave draft status - grab it now before that happens
const willEmail = this.willEmail;
+ // capture before model changes flip the post to published. We only
+ // touch settings when the chosen placement actually differs, so a plain
+ // republish never re-saves navigation
+ const navigationPlacementChanged = this.showNavigationOption
+ && this.desiredNavigationPlacement !== this.currentNavigationPlacement;
+
+ // clear any failure from a previous save so it can't leak into this one
+ this.navigationSaveFailed = false;
this._applyModelChanges();
@@ -323,12 +413,30 @@ export default class PublishOptions {
adapterOptions.emailSegment = this.recipientFilter;
}
+ let result;
try {
- return yield this.post.save({adapterOptions});
+ result = yield this.post.save({adapterOptions});
} catch (e) {
this._revertModelChanges();
throw e;
}
+
+ // the page is published at this point so a navigation failure should
+ // never fail the publish - the failure is surfaced separately
+ if (navigationPlacementChanged && this.post.isPublished) {
+ try {
+ yield setPageNavigationPlacement(this.settings, {
+ label: this.post.title,
+ path: this.pageNavigationPath,
+ placement: this.desiredNavigationPlacement,
+ blogUrl: this.config.blogUrl
+ });
+ } catch (e) {
+ this.navigationSaveFailed = true;
+ }
+ }
+
+ return result;
}
@task({drop: true})
diff --git a/ghost/admin/app/utils/site-navigation.js b/ghost/admin/app/utils/site-navigation.js
new file mode 100644
index 00000000000..aa7be49cfe6
--- /dev/null
+++ b/ghost/admin/app/utils/site-navigation.js
@@ -0,0 +1,185 @@
+import NavigationItem from 'ghost-admin/models/navigation-item';
+import {A as emberA} from '@ember/array';
+
+const RELATIVE_URL_BASE = 'http://__ghost-relative__.invalid';
+const RELATIVE_URL_ORIGIN = new URL(RELATIVE_URL_BASE).origin;
+
+// the configured site's origin, used to recognise absolute nav links pointing
+// at this site. Returns null when blogUrl is missing/unparseable, in which
+// case only relative links are matched.
+function siteOriginFor(blogUrl) {
+ try {
+ return new URL(blogUrl).origin;
+ } catch (e) {
+ return null;
+ }
+}
+
+// the configured site's subdirectory (the pathname of blogUrl), without a
+// trailing slash - '' for a root install. Pages and their nav links live
+// under this, so e.g. on a site at example.com/blog the page /about/ is
+// served (and linked) at /blog/about/.
+function siteSubdirFor(blogUrl) {
+ try {
+ return new URL(blogUrl).pathname.replace(/\/+$/, '');
+ } catch (e) {
+ return '';
+ }
+}
+
+// normalizes absolute and relative urls down to a comparable pathname,
+// e.g. "https://site.com/about/" -> "/about" and "about/" -> "/about".
+// urls pointing at other sites (external nav links) return null so they
+// never match a local page
+function comparablePathname(url, siteOrigin) {
+ if (!url) {
+ return null;
+ }
+
+ let parsed;
+ try {
+ parsed = new URL(url, RELATIVE_URL_BASE);
+ } catch (e) {
+ return null;
+ }
+
+ // an absolute url only counts as local when we have a site origin to
+ // confirm it against - without one (missing/unparseable blogUrl) it can't
+ // be verified, so treat it as external rather than matching by pathname
+ const isRelative = parsed.origin === RELATIVE_URL_ORIGIN;
+ if (!isRelative && (!siteOrigin || parsed.origin !== siteOrigin)) {
+ return null;
+ }
+
+ const pathname = parsed.pathname.replace(/\/+$/, '');
+
+ return (pathname || '/').toLowerCase();
+}
+
+// pages are served at /:slug/ (subdir is empty on a root install),
+// matching the verbatim url stored for a nav item that points at the page
+export function pagePathForSlug(slug, blogUrl) {
+ if (!slug) {
+ return null;
+ }
+
+ return `${siteSubdirFor(blogUrl)}/${slug}/`;
+}
+
+function itemsFor(settings, key) {
+ return settings[key]?.toArray() ?? [];
+}
+
+function placementFor(settings, path, siteOrigin) {
+ const pathToMatch = comparablePathname(path, siteOrigin);
+
+ if (!pathToMatch) {
+ return null;
+ }
+
+ const matches = items => items.some(item => comparablePathname(item.url, siteOrigin) === pathToMatch);
+
+ if (matches(itemsFor(settings, 'navigation'))) {
+ return 'primary';
+ }
+
+ if (matches(itemsFor(settings, 'secondaryNavigation'))) {
+ return 'secondary';
+ }
+
+ return null;
+}
+
+// returns 'primary', 'secondary', or null depending on where (if anywhere)
+// the page at `path` is linked in the site navigation
+export function getPagePlacement(settings, path, blogUrl) {
+ return placementFor(settings, path, siteOriginFor(blogUrl));
+}
+
+function normalizePlacement(placement) {
+ return (placement === 'primary' || placement === 'secondary') ? placement : null;
+}
+
+// applies the desired placement for one or more pages against the already
+// loaded settings, saving once. Existing label/url are preserved so any
+// customization made in the navigation editor isn't lost. On failure reverts
+// only the navigation attributes before rethrowing.
+async function applyNavigationPlacement(settings, {pages, placement, siteOrigin}) {
+ const desired = normalizePlacement(placement);
+
+ const previousPrimary = settings.navigation;
+ const previousSecondary = settings.secondaryNavigation;
+
+ let primary = itemsFor(settings, 'navigation');
+ let secondary = itemsFor(settings, 'secondaryNavigation');
+
+ for (const page of pages) {
+ const pathToMatch = comparablePathname(page.path, siteOrigin);
+
+ if (!pathToMatch) {
+ continue;
+ }
+
+ const existing = [...primary, ...secondary]
+ .find(item => comparablePathname(item.url, siteOrigin) === pathToMatch);
+ const itemLabel = existing?.label || page.label || 'Untitled';
+ const itemUrl = existing?.url || page.path;
+
+ const without = items => items.filter(item => comparablePathname(item.url, siteOrigin) !== pathToMatch);
+ primary = without(primary);
+ secondary = without(secondary);
+
+ if (desired === 'primary') {
+ primary = [...primary, NavigationItem.create({label: itemLabel, url: itemUrl, isSecondary: false})];
+ } else if (desired === 'secondary') {
+ secondary = [...secondary, NavigationItem.create({label: itemLabel, url: itemUrl, isSecondary: true})];
+ }
+ }
+
+ try {
+ settings.navigation = emberA(primary);
+ settings.secondaryNavigation = emberA(secondary);
+
+ await settings.save();
+
+ return desired;
+ } catch (error) {
+ if (settings.settingsModel) {
+ settings.navigation = previousPrimary;
+ settings.secondaryNavigation = previousSecondary;
+ }
+
+ throw error;
+ }
+}
+
+// moves a page's navigation link to match the desired placement
+// ('primary' | 'secondary' | null/none), handling add, move between menus,
+// and removal in one operation. No-ops (without saving) when already in the
+// desired state. Reloads settings first to avoid clobbering a concurrent
+// change. Returns the resulting placement.
+export async function setPageNavigationPlacement(settings, {label, path, placement, blogUrl}) {
+ await settings.reload();
+
+ const siteOrigin = siteOriginFor(blogUrl);
+
+ if (!comparablePathname(path, siteOrigin)) {
+ return null;
+ }
+
+ const desired = normalizePlacement(placement);
+
+ if (placementFor(settings, path, siteOrigin) === desired) {
+ return desired;
+ }
+
+ return applyNavigationPlacement(settings, {pages: [{label, path}], placement: desired, siteOrigin});
+}
+
+// bulk variant - places every given page ({label, path}) into the same
+// destination ('primary' | 'secondary' | null/none) in a single save
+export async function setPagesNavigationPlacement(settings, {pages, placement, blogUrl}) {
+ await settings.reload();
+
+ return applyNavigationPlacement(settings, {pages, placement, siteOrigin: siteOriginFor(blogUrl)});
+}
diff --git a/ghost/admin/public/assets/icons/navigation-primary.svg b/ghost/admin/public/assets/icons/navigation-primary.svg
new file mode 100644
index 00000000000..b94bc602a7b
--- /dev/null
+++ b/ghost/admin/public/assets/icons/navigation-primary.svg
@@ -0,0 +1 @@
+navigation-primary
diff --git a/ghost/admin/public/assets/icons/navigation-remove.svg b/ghost/admin/public/assets/icons/navigation-remove.svg
new file mode 100644
index 00000000000..ffaf3f5bf50
--- /dev/null
+++ b/ghost/admin/public/assets/icons/navigation-remove.svg
@@ -0,0 +1 @@
+navigation-remove
diff --git a/ghost/admin/public/assets/icons/navigation-secondary.svg b/ghost/admin/public/assets/icons/navigation-secondary.svg
new file mode 100644
index 00000000000..49c45dc6a24
--- /dev/null
+++ b/ghost/admin/public/assets/icons/navigation-secondary.svg
@@ -0,0 +1 @@
+navigation-secondary
diff --git a/ghost/admin/public/assets/icons/navigation.svg b/ghost/admin/public/assets/icons/navigation.svg
new file mode 100644
index 00000000000..17332e68bfd
--- /dev/null
+++ b/ghost/admin/public/assets/icons/navigation.svg
@@ -0,0 +1,7 @@
+
+ navigation
+
+
+
+
+
diff --git a/ghost/admin/tests/acceptance/content-test.js b/ghost/admin/tests/acceptance/content-test.js
index 87d2f54ee16..7a237f6d5ed 100644
--- a/ghost/admin/tests/acceptance/content-test.js
+++ b/ghost/admin/tests/acceptance/content-test.js
@@ -1208,6 +1208,95 @@ describe('Acceptance: Posts / Pages', function () {
const filter = find('[data-test-tag-select]');
expect(filter.textContent.trim(), 'filter text').to.contain('B - Second');
});
+
+ describe('site navigation', function () {
+ it('shows an in-menu indicator for linked pages only', async function () {
+ // the default navigation fixture links /about
+ const linkedPage = this.server.create('page', {authors: [admin], status: 'published', title: 'About', slug: 'about'});
+ const unlinkedPage = this.server.create('page', {authors: [admin], status: 'published', title: 'Partners', slug: 'partners'});
+
+ await visit('/pages');
+
+ const linkedRow = find(`[data-test-post-id="${linkedPage.id}"]`);
+ expect(linkedRow.querySelector('[data-test-nav-indicator="primary"]'), 'linked indicator').to.exist;
+
+ const unlinkedRow = find(`[data-test-post-id="${unlinkedPage.id}"]`);
+ expect(unlinkedRow.querySelector('[data-test-nav-indicator]'), 'unlinked indicator').to.not.exist;
+ });
+
+ it('does not show an indicator for a draft page even when linked', async function () {
+ // the default nav fixture links /about; a draft page at that
+ // slug isn't live, so it must not read as "in navigation"
+ const draftLinked = this.server.create('page', {authors: [admin], status: 'draft', title: 'About', slug: 'about'});
+
+ await visit('/pages');
+
+ const row = find(`[data-test-post-id="${draftLinked.id}"]`);
+ expect(row.querySelector('[data-test-nav-indicator]'), 'draft indicator').to.not.exist;
+ });
+
+ it('can add a page to primary navigation from the context menu', async function () {
+ const page = this.server.create('page', {authors: [admin], status: 'published', title: 'Partners', slug: 'partners'});
+
+ await visit('/pages');
+
+ const row = find(`[data-test-post-id="${page.id}"]`);
+ await triggerEvent(row, 'contextmenu');
+
+ expect(find('[data-test-button="add-to-primary-navigation"]'), 'add to primary option').to.exist;
+ await click('[data-test-button="add-to-primary-navigation"]');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation).to.deep.include({label: 'Partners', url: '/partners/'});
+ });
+
+ it('offers contextual actions for an already-linked page', async function () {
+ // default nav fixture links /about in primary
+ const page = this.server.create('page', {authors: [admin], status: 'published', title: 'About', slug: 'about'});
+
+ await visit('/pages');
+
+ const row = find(`[data-test-post-id="${page.id}"]`);
+ await triggerEvent(row, 'contextmenu');
+
+ // redundant "add to primary" is hidden; move + remove are offered
+ expect(find('[data-test-button="add-to-primary-navigation"]'), 'add to primary option').to.not.exist;
+ expect(find('[data-test-button="add-to-secondary-navigation"]'), 'move to secondary option').to.exist;
+ expect(find('[data-test-button="add-to-secondary-navigation"]').textContent.trim(), 'move label')
+ .to.contain('Move to secondary navigation');
+ expect(find('[data-test-button="remove-from-navigation"]'), 'remove option').to.exist;
+ });
+
+ it('can remove a linked page from navigation via the context menu', async function () {
+ const page = this.server.create('page', {authors: [admin], status: 'published', title: 'About', slug: 'about'});
+
+ await visit('/pages');
+
+ const row = find(`[data-test-post-id="${page.id}"]`);
+ await triggerEvent(row, 'contextmenu');
+
+ expect(find('[data-test-button="remove-from-navigation"]'), 'remove option').to.exist;
+ await click('[data-test-button="remove-from-navigation"]');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.not.include('About');
+ });
+
+ it('does not offer navigation actions for a draft page', async function () {
+ // a nav link points at the published URL, so an unpublished
+ // page would 404 - those actions are hidden until it's live
+ const page = this.server.create('page', {authors: [admin], status: 'draft', title: 'Partners', slug: 'partners'});
+
+ await visit('/pages');
+
+ const row = find(`[data-test-post-id="${page.id}"]`);
+ await triggerEvent(row, 'contextmenu');
+
+ expect(find('[data-test-button="add-to-primary-navigation"]'), 'add to primary option').to.not.exist;
+ expect(find('[data-test-button="add-to-secondary-navigation"]'), 'add to secondary option').to.not.exist;
+ expect(find('[data-test-button="remove-from-navigation"]'), 'remove option').to.not.exist;
+ });
+ });
});
});
});
diff --git a/ghost/admin/tests/acceptance/editor/publish-flow-test.js b/ghost/admin/tests/acceptance/editor/publish-flow-test.js
index c7be5682885..538ab99043c 100644
--- a/ghost/admin/tests/acceptance/editor/publish-flow-test.js
+++ b/ghost/admin/tests/acceptance/editor/publish-flow-test.js
@@ -1,5 +1,6 @@
import loginAsRole from '../../helpers/login-as-role';
import moment from 'moment-timezone';
+import {Response} from 'miragejs';
import {blur, click, fillIn, find, findAll, waitFor} from '@ember/test-helpers';
import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../../helpers/mock-analytics-apps';
import {clickTrigger, removeMultipleOption, selectChoose} from 'ember-power-select/test-support/helpers';
@@ -666,4 +667,223 @@ describe('Acceptance: Publish flow', function () {
).to.match(/\d+\s*subscriber/);
});
});
+
+ describe('pages', function () {
+ async function openPublishFlow(context, pageAttrs = {}) {
+ const attrs = {status: 'draft', ...pageAttrs};
+
+ // a draft page always has a slug by the time the publish flow can be
+ // opened (it's been auto-saved) - the factory doesn't set one
+ if (!attrs.slug && attrs.title) {
+ attrs.slug = attrs.title.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '');
+ }
+
+ const page = context.server.create('page', attrs);
+
+ await visit(`/editor/page/${page.id}`);
+ await click('[data-test-button="publish-flow"]');
+
+ return page;
+ }
+
+ it('offers a navigation placement option when publishing an unlinked page', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ await openPublishFlow(this, {title: 'Partners'});
+
+ expect(find('[data-test-setting="navigation"]'), 'navigation setting').to.exist;
+ expect(
+ find('[data-test-setting="navigation"] [data-test-setting-title]'), 'navigation title'
+ ).to.contain.trimmed.text('Not in site navigation');
+
+ // defaults to not-in-menu - publishing makes no navigation changes
+ await click('[data-test-button="continue"]');
+ expect(find('[data-test-text="confirm-details"]').textContent).to.not.contain('navigation');
+ await click('[data-test-button="confirm-publish"]');
+
+ expect(find('[data-test-publish-flow="complete"]'), 'complete step').to.exist;
+ expect(find('[data-test-publish-flow-navigation]'), 'navigation status').to.not.exist;
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.not.include('Partners');
+ });
+
+ it('adds the page to primary navigation when selected', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ await openPublishFlow(this, {title: 'Partners'});
+
+ await click('[data-test-setting="navigation"] [data-test-setting-title]');
+ await click('[data-test-navigation-placement="primary"] + label');
+
+ expect(
+ find('[data-test-setting="navigation"] [data-test-setting-title]'), 'navigation title'
+ ).to.contain.trimmed.text('Primary navigation');
+
+ await click('[data-test-button="continue"]');
+
+ expect(find('[data-test-text="confirm-details"]').textContent)
+ .to.contain('listed in your primary navigation');
+
+ await click('[data-test-button="confirm-publish"]');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation).to.deep.include({label: 'Partners', url: '/partners/'});
+ });
+
+ it('adds the page to secondary navigation when selected', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ await openPublishFlow(this, {title: 'Partners'});
+
+ await click('[data-test-setting="navigation"] [data-test-setting-title]');
+ await click('[data-test-navigation-placement="secondary"] + label');
+ await click('[data-test-button="continue"]');
+ await click('[data-test-button="confirm-publish"]');
+
+ const secondaryNavigation = JSON.parse(this.server.db.settings.findBy({key: 'secondary_navigation'}).value);
+ expect(secondaryNavigation).to.deep.include({label: 'Partners', url: '/partners/'});
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.not.include('Partners');
+ });
+
+ it('pre-selects the current placement for an already-linked page', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ // default navigation fixture includes {label: 'About', url: '/about'}
+ await openPublishFlow(this, {title: 'About'});
+
+ // the picker reflects where the page already lives, editable
+ expect(
+ find('[data-test-setting="navigation"] [data-test-setting-title]'), 'navigation title'
+ ).to.contain.trimmed.text('Primary navigation');
+
+ // confirm states where the page will live (declarative end-state),
+ // even though republishing without a change leaves the nav untouched
+ await click('[data-test-button="continue"]');
+ expect(find('[data-test-text="confirm-details"]').textContent)
+ .to.contain('listed in your primary navigation');
+ await click('[data-test-button="confirm-publish"]');
+
+ // nav fixture is unchanged
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.include('About');
+ });
+
+ it('can move an already-linked page to a different menu when republishing', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ await openPublishFlow(this, {title: 'About'});
+
+ await click('[data-test-setting="navigation"] [data-test-setting-title]');
+ await click('[data-test-navigation-placement="secondary"] + label');
+ await click('[data-test-button="continue"]');
+
+ expect(find('[data-test-text="confirm-details"]').textContent)
+ .to.contain('listed in your secondary navigation');
+
+ await click('[data-test-button="confirm-publish"]');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label), 'primary nav').to.not.include('About');
+
+ const secondaryNavigation = JSON.parse(this.server.db.settings.findBy({key: 'secondary_navigation'}).value);
+ expect(secondaryNavigation.map(item => item.label), 'secondary nav').to.include('About');
+ });
+
+ it('discards an unsaved navigation change when the modal is reopened', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ await openPublishFlow(this, {title: 'Partners'});
+
+ // pick a placement but close the modal without publishing
+ await click('[data-test-setting="navigation"] [data-test-setting-title]');
+ await click('[data-test-navigation-placement="primary"] + label');
+ expect(
+ find('[data-test-setting="navigation"] [data-test-setting-title]'), 'picked placement'
+ ).to.contain.trimmed.text('Primary navigation');
+
+ await click('[data-test-button="publish-flow-publish"]'); // header "Close"
+
+ // reopening shows the real current placement, not the discarded change
+ await click('[data-test-button="publish-flow"]');
+ expect(
+ find('[data-test-setting="navigation"] [data-test-setting-title]'), 'reset placement'
+ ).to.contain.trimmed.text('Not in site navigation');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.not.include('Partners');
+ });
+
+ it('can remove an already-linked page from navigation when republishing', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ await openPublishFlow(this, {title: 'About'});
+
+ await click('[data-test-setting="navigation"] [data-test-setting-title]');
+ await click('[data-test-navigation-placement="none"] + label');
+ await click('[data-test-button="continue"]');
+
+ // removal lands the page in "None", so the confirm step stays silent
+ // on navigation - the removal still applies on publish
+ expect(find('[data-test-text="confirm-details"]').textContent).to.not.contain('navigation');
+
+ await click('[data-test-button="confirm-publish"]');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.not.include('About');
+ });
+
+ it('does not show the navigation option to editors', async function () {
+ await loginAsRole('Editor', this.server);
+
+ await openPublishFlow(this, {title: 'Partners'});
+
+ expect(find('[data-test-setting="navigation"]'), 'navigation setting').to.not.exist;
+
+ await click('[data-test-button="continue"]');
+ await click('[data-test-button="confirm-publish"]');
+
+ expect(find('[data-test-publish-flow="complete"]'), 'complete step').to.exist;
+ expect(find('[data-test-publish-flow-navigation]'), 'navigation status').to.not.exist;
+ });
+
+ it('does not show the navigation option for posts', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ const post = this.server.create('post', {status: 'draft', title: 'A post'});
+
+ await visit(`/editor/post/${post.id}`);
+ await click('[data-test-button="publish-flow"]');
+
+ expect(find('[data-test-setting="navigation"]'), 'navigation setting').to.not.exist;
+ });
+
+ it('publishes the page and notifies when the navigation save fails', async function () {
+ await loginAsRole('Administrator', this.server);
+
+ const page = await openPublishFlow(this, {title: 'Partners'});
+
+ this.server.put('/settings/', function () {
+ return new Response(500, {}, {errors: [{
+ message: 'Could not save settings',
+ type: 'InternalServerError'
+ }]});
+ });
+
+ await click('[data-test-setting="navigation"] [data-test-setting-title]');
+ await click('[data-test-navigation-placement="primary"] + label');
+ await click('[data-test-button="continue"]');
+ await click('[data-test-button="confirm-publish"]');
+
+ // the page still publishes; the nav failure is surfaced as a toast
+ expect(page.status, 'page status after publish').to.equal('published');
+ expect(find('[data-test-text="notification-content"]'), 'failure notification')
+ .to.contain.text('navigation couldn\'t be updated');
+
+ const navigation = JSON.parse(this.server.db.settings.findBy({key: 'navigation'}).value);
+ expect(navigation.map(item => item.label)).to.not.include('Partners');
+ });
+ });
});
diff --git a/ghost/admin/tests/unit/utils/site-navigation-test.js b/ghost/admin/tests/unit/utils/site-navigation-test.js
new file mode 100644
index 00000000000..cc13a81cebf
--- /dev/null
+++ b/ghost/admin/tests/unit/utils/site-navigation-test.js
@@ -0,0 +1,65 @@
+import {describe, it} from 'mocha';
+import {expect} from 'chai';
+import {getPagePlacement, pagePathForSlug} from 'ghost-admin/utils/site-navigation';
+
+// getPagePlacement reads settings[key].toArray(); stub the minimum it needs
+function settingsWith({navigation = [], secondaryNavigation = []} = {}) {
+ return {
+ navigation: {toArray: () => navigation},
+ secondaryNavigation: {toArray: () => secondaryNavigation}
+ };
+}
+
+describe('Unit: Util: site-navigation', function () {
+ describe('pagePathForSlug', function () {
+ it('returns /:slug/ on a root install', function () {
+ expect(pagePathForSlug('about', 'https://example.com/')).to.equal('/about/');
+ expect(pagePathForSlug('about', 'https://example.com')).to.equal('/about/');
+ });
+
+ it('includes the subdirectory on a subdir install', function () {
+ expect(pagePathForSlug('about', 'https://example.com/blog/')).to.equal('/blog/about/');
+ expect(pagePathForSlug('about', 'https://example.com/blog')).to.equal('/blog/about/');
+ });
+
+ it('treats a missing/unparseable blogUrl as a root install', function () {
+ expect(pagePathForSlug('about')).to.equal('/about/');
+ expect(pagePathForSlug('about', 'not a url')).to.equal('/about/');
+ });
+
+ it('returns null for an empty slug', function () {
+ expect(pagePathForSlug('', 'https://example.com/')).to.be.null;
+ });
+ });
+
+ describe('getPagePlacement on a subdirectory install', function () {
+ const blogUrl = 'https://example.com/blog/';
+
+ it('matches a nav item stored with the subdirectory', function () {
+ const settings = settingsWith({navigation: [{url: '/blog/about/'}]});
+ expect(getPagePlacement(settings, pagePathForSlug('about', blogUrl), blogUrl)).to.equal('primary');
+ });
+
+ it('matches an absolute nav item pointing at the subdir page', function () {
+ const settings = settingsWith({secondaryNavigation: [{url: 'https://example.com/blog/about/'}]});
+ expect(getPagePlacement(settings, pagePathForSlug('about', blogUrl), blogUrl)).to.equal('secondary');
+ });
+
+ it('does not match a bare /about/ that would 404 under the subdirectory', function () {
+ const settings = settingsWith({navigation: [{url: '/about/'}]});
+ expect(getPagePlacement(settings, pagePathForSlug('about', blogUrl), blogUrl)).to.be.null;
+ });
+ });
+
+ describe('with a missing/unparseable site origin', function () {
+ it('treats an absolute url as external rather than matching by pathname', function () {
+ const settings = settingsWith({navigation: [{url: 'https://other.example/about/'}]});
+ expect(getPagePlacement(settings, pagePathForSlug('about'), undefined)).to.be.null;
+ });
+
+ it('still matches a relative nav url', function () {
+ const settings = settingsWith({navigation: [{url: '/about/'}]});
+ expect(getPagePlacement(settings, pagePathForSlug('about'), undefined)).to.equal('primary');
+ });
+ });
+});