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}} +
+ + {{#liquid-if (eq this.openSection "navigation")}} +
+ +
+ {{/liquid-if}} +
+ {{/if}} +
+ + {{/if}} + {{#if this.canAddToSecondaryNavigation}} +
  • + +
  • + {{/if}} + {{#if this.canRemoveFromNavigation}} +
  • + +
  • + {{/if}} + {{/if}} + {{#if this.canCopySelection}}
  • 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'); + }); + }); +});