From 14cabf97dfa4a1d3768e04c19cb869f61a0227a4 Mon Sep 17 00:00:00 2001 From: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Mon, 29 Jul 2024 09:25:09 +0200 Subject: [PATCH 01/22] Updated tips and donations copy in editor button card (#20662) REF MOM-313 --- .../src/components/settings/growth/TipsAndDonations.tsx | 4 ++-- ghost/admin/app/components/koenig-lexical-editor.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/growth/TipsAndDonations.tsx b/apps/admin-x-settings/src/components/settings/growth/TipsAndDonations.tsx index 10cbf0ed67..8c9f06237a 100644 --- a/apps/admin-x-settings/src/components/settings/growth/TipsAndDonations.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/TipsAndDonations.tsx @@ -88,7 +88,7 @@ const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => { const inputFields = ( -
+
= ({keywords}) => { return ( { if (this.feature.tipsAndDonations && this.settings.donationsEnabled) { return [{ - label: 'Tip or donation', + label: 'Tips and donations', value: '#/portal/support' }]; } From 0193ad90bb6e75405412e99dcf764d945808dc0f Mon Sep 17 00:00:00 2001 From: Daniel Lockyer Date: Mon, 29 Jul 2024 09:32:12 +0200 Subject: [PATCH 02/22] Protected against quick escapes when loading post fix https://linear.app/tryghost/issue/SLO-180/typeerror-cannot-read-properties-of-null-reading-displayname - in the event you click on a post and then press Back really quickly, `this.post` will be null because the post is not loaded - the code here fails because it tries to read a property from `null` - we can protect against that by using optional chaining on the property --- ghost/admin/app/controllers/lexical-editor.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ghost/admin/app/controllers/lexical-editor.js b/ghost/admin/app/controllers/lexical-editor.js index ee15feab8f..71c6331437 100644 --- a/ghost/admin/app/controllers/lexical-editor.js +++ b/ghost/admin/app/controllers/lexical-editor.js @@ -871,11 +871,11 @@ export default class LexicalEditorController extends Controller { this.ui.updateDocumentTitle(); } - /* + /* // sync the post slug with the post title, except when: // - the user has already typed a custom slug, which should not be overwritten // - the post has been published, so that published URLs are not broken - */ + */ @enqueueTask *generateSlugTask() { const currentTitle = this.get('post.title'); @@ -916,7 +916,7 @@ export default class LexicalEditorController extends Controller { *backgroundLoaderTask() { yield this.store.query('snippet', {limit: 'all'}); - if (this.post.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) { + if (this.post?.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) { yield this.store.query('collection', {limit: 'all'}); } From dc3539ebca3a3096bd0138b1178d3e54f11da84d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:31:06 +0000 Subject: [PATCH 03/22] Update dependency html-validate to v8.21.0 --- ghost/email-service/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ghost/email-service/package.json b/ghost/email-service/package.json index 189f9be6ff..96842c6917 100644 --- a/ghost/email-service/package.json +++ b/ghost/email-service/package.json @@ -19,7 +19,7 @@ ], "devDependencies": { "c8": "8.0.1", - "html-validate": "8.20.1", + "html-validate": "8.21.0", "mocha": "10.2.0", "should": "13.2.3", "sinon": "15.2.0" diff --git a/yarn.lock b/yarn.lock index f3685fd55b..1f81ca7c03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19524,10 +19524,10 @@ html-to-text@8.2.1: minimist "^1.2.6" selderee "^0.6.0" -html-validate@8.20.1: - version "8.20.1" - resolved "https://registry.yarnpkg.com/html-validate/-/html-validate-8.20.1.tgz#8cdd1fc32f4578efa5a9dea596cdd9bf0e26f805" - integrity sha512-EawDiHzvZtnbBIfxE90lvKOWqNsmZGqRXTy+utxlGo525Vqjowg+RK42q1AeJ6zm1AyVTFIDSah1eBe9tc6YHg== +html-validate@8.21.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/html-validate/-/html-validate-8.21.0.tgz#fcb8aa4d05d95c9b806bebf3d1be6836a1d8a196" + integrity sha512-f6uyHdNeul4f/E6TDaUrH8agrVmnG5VbWwmIhbkg+Vrz+To/2xxbc+soBKXqani1QSaA+5I12Qr7dQt/HVFJtw== dependencies: "@babel/code-frame" "^7.10.0" "@html-validate/stylish" "^4.1.0" From 184ef6274ab961f4ec933cfd30acaf08d31dbb9b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:30:47 +0000 Subject: [PATCH 04/22] Update dependency tailwindcss to v3.4.6 --- apps/admin-x-design-system/package.json | 2 +- apps/comments-ui/package.json | 2 +- apps/signup-form/package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 4d1841c4fc..1524ae194f 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -71,7 +71,7 @@ "react-colorful": "5.6.1", "react-hot-toast": "2.4.1", "react-select": "5.8.0", - "tailwindcss": "3.4.5" + "tailwindcss": "3.4.6" }, "peerDependencies": { "react": "^18.2.0", diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 3f89a3258c..78a9ec7e07 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -75,7 +75,7 @@ "eslint-plugin-tailwindcss": "3.13.0", "jsdom": "24.1.1", "postcss": "8.4.39", - "tailwindcss": "3.4.5", + "tailwindcss": "3.4.6", "vite": "4.5.3", "vite-plugin-css-injected-by-js": "3.3.0", "vite-plugin-svgr": "3.3.0", diff --git a/apps/signup-form/package.json b/apps/signup-form/package.json index e1e608d751..fc3841fdec 100644 --- a/apps/signup-form/package.json +++ b/apps/signup-form/package.json @@ -65,7 +65,7 @@ "rollup-plugin-node-builtins": "2.1.2", "storybook": "7.6.20", "stylelint": "15.10.3", - "tailwindcss": "3.4.5", + "tailwindcss": "3.4.6", "vite": "4.5.3", "vite-plugin-commonjs": "0.10.1", "vite-plugin-svgr": "3.3.0", diff --git a/yarn.lock b/yarn.lock index 1f81ca7c03..e4cccdb81f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30049,10 +30049,10 @@ table@^6.0.9, table@^6.8.1: string-width "^4.2.3" strip-ansi "^6.0.1" -tailwindcss@3.4.5: - version "3.4.5" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.5.tgz#0de2e92ed4d00fb015feb962fa0781605761724d" - integrity sha512-DlTxttYcogpDfx3tf/8jfnma1nfAYi2cBUYV2YNoPPecwmO3YGiFlOX9D8tGAu+EDF38ryBzvrDKU/BLMsUwbw== +tailwindcss@3.4.6: + version "3.4.6" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.6.tgz#41faae16607e0916da1eaa4a3b44053457ba70dd" + integrity sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA== dependencies: "@alloc/quick-lru" "^5.2.0" arg "^5.0.2" From 103672ef57ad5d9ae1f3a1185b70d9d1eed36ca4 Mon Sep 17 00:00:00 2001 From: Daniel Lockyer Date: Mon, 29 Jul 2024 11:57:53 +0200 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20spurious=20errors?= =?UTF-8?q?=20when=20loading=20modal=20before=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix https://linear.app/tryghost/issue/SLO-190/error-no-dispatch-method-detected-did-you-embed-your-app-with - we've spuriously been seeing `No dispatch method detected, did you embed your app with NiceModal.Provider` when browsing to a URL that loads a modal in Safari - it looks like DesignSystemProvider (via DesignSystemApp) contains the NiceModal.Provider, but this is loaded within the RoutingProvider that could trigger a modal to load - I tried switching around RoutingProvider and DesignSystemApp but many other tests failed, so my fix here is to add a NiceModal.Provider to wrap the RoutingProvider - unfortunately, this bug is flaky to occur and I've only been able to reproduce it on Safari, so writing a test for this would be very tricky --- apps/admin-x-settings/src/App.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/admin-x-settings/src/App.tsx b/apps/admin-x-settings/src/App.tsx index c6590c9882..26292ceeb7 100644 --- a/apps/admin-x-settings/src/App.tsx +++ b/apps/admin-x-settings/src/App.tsx @@ -1,4 +1,5 @@ import MainContent from './MainContent'; +import NiceModal from '@ebay/nice-modal-react'; import SettingsAppProvider, {OfficialTheme, UpgradeStatusType} from './components/providers/SettingsAppProvider'; import SettingsRouter, {loadModals, modalPaths} from './components/providers/SettingsRouter'; import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system'; @@ -18,12 +19,17 @@ function App({framework, designSystem, officialThemes, zapierTemplates, upgradeS return ( - - - - - - + {/* NOTE: we need to have an extra NiceModal.Provider here because the one inside DesignSystemApp + is loaded too late for possible modals in RoutingProvider, and it's quite hard to change it at + this point */} + + + + + + + + ); From 7411724a0389d911db3d2904aa8a165556bdd7df Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jul 2024 11:39:22 +0000 Subject: [PATCH 06/22] Update dependency terser to v5.31.3 --- ghost/minifier/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ghost/minifier/package.json b/ghost/minifier/package.json index fd2783dd9c..6a6e1abfb9 100644 --- a/ghost/minifier/package.json +++ b/ghost/minifier/package.json @@ -28,7 +28,7 @@ "@tryghost/errors": "1.3.2", "@tryghost/tpl": "0.1.30", "csso": "5.0.5", - "terser": "5.31.1", + "terser": "5.31.3", "tiny-glob": "0.2.9" } } diff --git a/yarn.lock b/yarn.lock index e4cccdb81f..93278ba4f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30228,10 +30228,10 @@ terser-webpack-plugin@^5.3.10: serialize-javascript "^6.0.1" terser "^5.26.0" -terser@5.31.1, terser@^5.26.0, terser@^5.7.0: - version "5.31.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.1.tgz#735de3c987dd671e95190e6b98cfe2f07f3cf0d4" - integrity sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg== +terser@5.31.3, terser@^5.26.0, terser@^5.7.0: + version "5.31.3" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.3.tgz#b24b7beb46062f4653f049eea4f0cd165d0f0c38" + integrity sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" From f64820b1be41dcceb144f1d996f30c24c4b98f5e Mon Sep 17 00:00:00 2001 From: Sag Date: Mon, 29 Jul 2024 17:33:23 +0200 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=90=9B=20Disabled=20bulk=20deletion?= =?UTF-8?q?=20when=20multiple=20member=20filters=20are=20applied=20(#20681?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes https://linear.app/tryghost/issue/ONC-206 ref https://app.incident.io/ghost/incidents/90 - when multiple member filters are used in combination, NQL sometimes hit a limitation that results in the wrong members being returned - while we work on the NQL limitation, we are temporarily disabling bulk member deletion when more than one member filter has been applied --- ghost/admin/app/controllers/members.js | 4 ++ ghost/admin/app/templates/members.hbs | 14 +++--- ghost/admin/tests/acceptance/members-test.js | 49 ++++++++++++++++++- .../tests/acceptance/members/filter-test.js | 7 ++- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index 016987fda2..1270b5a06a 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -209,6 +209,10 @@ export default class MembersController extends Controller { return uniqueColumns.splice(0, 2); // Maximum 2 columns } + get isMultiFiltered() { + return this.isFiltered && this.filters.length >= 2; + } + includeTierQuery() { const availableFilters = this.filters.length ? this.filters : this.softFilters; return availableFilters.some((f) => { diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index c2671358d6..d72c91a904 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -104,12 +104,14 @@ {{/if}} -
  • -
  • - -
  • + {{#unless this.isMultiFiltered}} +
  • +
  • + +
  • + {{/unless}} {{/if}} diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js index 778d29a33b..b59f9514ca 100644 --- a/ghost/admin/tests/acceptance/members-test.js +++ b/ghost/admin/tests/acceptance/members-test.js @@ -143,6 +143,53 @@ describe('Acceptance: Members', function () { .to.equal('example@domain.com'); }); + /* NOTE: Bulk deletion is disabled temporarily when multiple filters are applied, due to a NQL limitation. + * Delete this test once we have fixed the root NQL limitation. + * See https://linear.app/tryghost/issue/ONC-203 + */ + it('cannot bulk delete members if more than 1 filter is selected', async function () { + // Members with label + const labelOne = this.server.create('label'); + const labelTwo = this.server.create('label'); + this.server.createList('member', 2, {labels: [labelOne]}); + this.server.createList('member', 2, {labels: [labelOne, labelTwo]}); + + await visit('/members'); + expect(findAll('[data-test-member]').length).to.equal(4); + + // The delete button should not be visible by default + await click('[data-test-button="members-actions"]'); + expect(find('[data-test-button="delete-selected"]')).to.not.exist; + + // Apply a single filter + await click('[data-test-button="members-filter-actions"]'); + await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'label'); + await click('.gh-member-label-input input'); + await click(`[data-test-label-filter="${labelOne.name}"]`); + await click(`[data-test-button="members-apply-filter"]`); + + expect(findAll('[data-test-member]').length).to.equal(4); + expect(currentURL()).to.equal(`/members?filter=label%3A%5B${labelOne.slug}%5D`); + + await click('[data-test-button="members-actions"]'); + expect(find('[data-test-button="delete-selected"]')).to.exist; + + // Apply a second filter + await click('[data-test-button="members-filter-actions"]'); + await click('[data-test-button="add-members-filter"]'); + + await fillIn('[data-test-members-filter="1"] [data-test-select="members-filter"]', 'label'); + await click('[data-test-members-filter="1"] .gh-member-label-input input'); + await click(`[data-test-members-filter="1"] [data-test-label-filter="${labelTwo.name}"]`); + await click(`[data-test-button="members-apply-filter"]`); + + expect(findAll('[data-test-member]').length).to.equal(2); + expect(currentURL()).to.equal(`/members?filter=label%3A%5B${labelOne.slug}%5D%2Blabel%3A%5B${labelTwo.slug}%5D`); + + await click('[data-test-button="members-actions"]'); + expect(find('[data-test-button="delete-selected"]')).to.not.exist; + }); + it('can bulk delete members', async function () { // members to be kept this.server.createList('member', 6); @@ -167,7 +214,7 @@ describe('Acceptance: Members', function () { await click(`[data-test-button="members-apply-filter"]`); expect(findAll('[data-test-member]').length).to.equal(5); - expect(currentURL()).to.equal('/members?filter=label%3A%5Blabel-0%5D'); + expect(currentURL()).to.equal(`/members?filter=label%3A%5B${label.slug}%5D`); await click('[data-test-button="members-actions"]'); diff --git a/ghost/admin/tests/acceptance/members/filter-test.js b/ghost/admin/tests/acceptance/members/filter-test.js index cf9f3a1e49..3068b9a60e 100644 --- a/ghost/admin/tests/acceptance/members/filter-test.js +++ b/ghost/admin/tests/acceptance/members/filter-test.js @@ -1328,7 +1328,12 @@ describe('Acceptance: Members filtering', function () { expect(find('[data-test-button="add-label-selected"]'), 'add label to selected button').to.exist; expect(find('[data-test-button="remove-label-selected"]'), 'remove label from selected button').to.exist; expect(find('[data-test-button="unsubscribe-selected"]'), 'unsubscribe selected button').to.exist; - expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.exist; + + /* NOTE: Bulk deletion is disabled temporarily when multiple filters are applied, due to a NQL limitation. + * Re-enable following line once we have fixed the root NQL limitation. + * See https://linear.app/tryghost/issue/ONC-203 + */ + // expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.exist; // filter is active and has # of filters expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.have.class('gh-btn-label-green'); From a109b255f0311a09151aab06cde8db06813abab6 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Mon, 29 Jul 2024 10:58:08 -0500 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20no=20redirect=20on?= =?UTF-8?q?=20Portal=20signin=20when=20trying=20to=20access=20newsletters?= =?UTF-8?q?=20(#20683)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/tryghost/issue/ENG-1464 - added redirect to sign in page when trying to access newsletter management If a user tries to access newsletter management when not logged in, Portal requires sign in via magic link. This magic link didn't previous redirect the user back to newsletter management, requiring some extra clicks. --- .../src/components/pages/AccountEmailPage.js | 5 +++- .../components/pages/AccountEmailPage.test.js | 2 +- .../src/tests/EmailSubscriptionsFlow.test.js | 29 ------------------- 3 files changed, 5 insertions(+), 31 deletions(-) diff --git a/apps/portal/src/components/pages/AccountEmailPage.js b/apps/portal/src/components/pages/AccountEmailPage.js index b971c09440..ed31a676f4 100644 --- a/apps/portal/src/components/pages/AccountEmailPage.js +++ b/apps/portal/src/components/pages/AccountEmailPage.js @@ -9,7 +9,10 @@ export default function AccountEmailPage() { useEffect(() => { if (!member) { onAction('switchPage', { - page: 'signin' + page: 'signin', + pageData: { + redirect: window.location.href // This includes the search/fragment of the URL (#/portal/account) which is missing from the default referer header + } }); } }, [member, onAction]); diff --git a/apps/portal/src/components/pages/AccountEmailPage.test.js b/apps/portal/src/components/pages/AccountEmailPage.test.js index 87c5f8ea57..8bd6a2f082 100644 --- a/apps/portal/src/components/pages/AccountEmailPage.test.js +++ b/apps/portal/src/components/pages/AccountEmailPage.test.js @@ -112,6 +112,6 @@ describe('Account Email Page', () => { newsletters: newsletterData }); const {mockOnActionFn} = setup({site: siteData, member: null}); - expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin'}); + expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin', pageData: {redirect: window.location.href}}); }); }); diff --git a/apps/portal/src/tests/EmailSubscriptionsFlow.test.js b/apps/portal/src/tests/EmailSubscriptionsFlow.test.js index 960a240858..e77fb33c27 100644 --- a/apps/portal/src/tests/EmailSubscriptionsFlow.test.js +++ b/apps/portal/src/tests/EmailSubscriptionsFlow.test.js @@ -254,33 +254,4 @@ describe('Newsletter Subscriptions', () => { expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked'); }); }); - - // describe('navigating straight to /portal/account/newsletters', () => { - // it('shows the newsletter management page when signed in', async () => { - // const {popupFrame, triggerButton, queryAllByText, popupIframeDocument} = await setup({ - // site: FixtureSite.singleTier.onlyFreePlanWithoutStripe, - // member: FixtureMember.subbedToNewsletter, - // newsletters: Newsletters - // }); - - // const manageSubscriptionsButton = within(popupIframeDocument).queryByRole('button', {name: 'Manage'}); - // await userEvent.click(manageSubscriptionsButton); - - // const newsletter1 = within(popupIframeDocument).queryAllByText('Newsletter 1'); - // expect(newsletter1).toBeInTheDocument(); - // }); - - // it('redirects to the sign in page when not signed in', async () => { - // const {popupFrame, queryByTitle, popupIframeDocument} = await setup({ - // site: FixtureSite.singleTier.onlyFreePlanWithoutStripe, - // member: FixtureMember.subbedToNewsletter, - // newsletters: Newsletters - // }, true); - - // // console.log(`popupFrame`, popupFrame); - // // console.log(`queryByTitle`, queryByTitle); - // // console.log(`popupIframeDocument`, popupIframeDocument); - - // }); - // }); }); From c61c42ce1dc64891854f5591a1796a2cd736452a Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Mon, 29 Jul 2024 11:19:28 -0500 Subject: [PATCH 09/22] =?UTF-8?q?=E2=9C=A8=20Improved=20performance=20load?= =?UTF-8?q?ing=20posts=20&=20pages=20in=20admin=20(#20646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref 8ea1dfb ref https://linear.app/tryghost/issue/ONC-111 * undid the reversion for the performance improvements * built upon new tests for the posts list functionality in admin, including right click actions * added tests for pages view in Admin This was reverted because it broke the Pages list view in Admin, which is a thin extension of the Posts functionality in admin (route & controller). That has been fixed and tests added. This was originally reverted because the changes to improve loading response times broke right click (bulk) actions in the posts list. This was not caught because it turned out we had near-zero test coverage of that part of the codebase. Test coverage has been expanded for the posts list, and while not comprehensive, is a much better place for us to be in. --- ghost/admin/app/components/gh-context-menu.js | 2 +- .../app/components/posts-list/context-menu.js | 39 +- .../admin/app/components/posts-list/list.hbs | 41 +- .../posts-list}/selection-list.js | 98 +- ghost/admin/app/controllers/pages.js | 2 +- ghost/admin/app/controllers/posts.js | 10 +- ghost/admin/app/routes/posts.js | 56 +- ghost/admin/app/templates/pages.hbs | 23 +- ghost/admin/app/templates/posts.hbs | 23 +- ghost/admin/mirage/config/pages.js | 1 - ghost/admin/mirage/config/posts.js | 25 +- ghost/admin/tests/acceptance/content-test.js | 1143 +++++++++-------- 12 files changed, 834 insertions(+), 629 deletions(-) rename ghost/admin/app/{utils => components/posts-list}/selection-list.js (67%) diff --git a/ghost/admin/app/components/gh-context-menu.js b/ghost/admin/app/components/gh-context-menu.js index 8b3de5a54b..205e488bb1 100644 --- a/ghost/admin/app/components/gh-context-menu.js +++ b/ghost/admin/app/components/gh-context-menu.js @@ -1,5 +1,5 @@ import Component from '@glimmer/component'; -import SelectionList from '../utils/selection-list'; +import SelectionList from './posts-list/selection-list'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index f945cfda40..3eabecb4a1 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -216,11 +216,14 @@ export default class PostsContextMenu extends Component { yield this.performBulkDestroy(); this.notifications.showNotification(this.#getToastMessage('deleted'), {type: 'success'}); - const remainingModels = this.selectionList.infinityModel.content.filter((model) => { - return !deletedModels.includes(model); - }); - // Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this - this.infinity.replace(this.selectionList.infinityModel, remainingModels); + for (const key in this.selectionList.infinityModel) { + const remainingModels = this.selectionList.infinityModel[key].content.filter((model) => { + return !deletedModels.includes(model); + }); + // Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this + this.infinity.replace(this.selectionList.infinityModel[key], remainingModels); + } + this.selectionList.clearSelection({force: true}); return true; } @@ -247,9 +250,7 @@ export default class PostsContextMenu extends Component { } } - // Remove posts that no longer match the filter this.updateFilteredPosts(); - return true; } @@ -282,14 +283,16 @@ export default class PostsContextMenu extends Component { ] }); - const remainingModels = this.selectionList.infinityModel.content.filter((model) => { - if (!updatedModels.find(u => u.id === model.id)) { - return true; - } - return filterNql.queryJSON(model.serialize({includeId: true})); - }); - // Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this - this.infinity.replace(this.selectionList.infinityModel, remainingModels); + for (const key in this.selectionList.infinityModel) { + const remainingModels = this.selectionList.infinityModel[key].content.filter((model) => { + if (!updatedModels.find(u => u.id === model.id)) { + return true; + } + return filterNql.queryJSON(model.serialize({includeId: true})); + }); + // Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this + this.infinity.replace(this.selectionList.infinityModel[key], remainingModels); + } this.selectionList.clearUnavailableItems(); } @@ -386,8 +389,10 @@ export default class PostsContextMenu extends Component { const data = result[this.type === 'post' ? 'posts' : 'pages'][0]; const model = this.store.peekRecord(this.type, data.id); - // Update infinity list - this.selectionList.infinityModel.content.unshiftObject(model); + // Update infinity draft posts content - copied posts are always drafts + if (this.selectionList.infinityModel.draftInfinityModel) { + this.selectionList.infinityModel.draftInfinityModel.content.unshiftObject(model); + } // Show notification this.notifications.showNotification(this.#getToastMessage('duplicated'), {type: 'success'}); diff --git a/ghost/admin/app/components/posts-list/list.hbs b/ghost/admin/app/components/posts-list/list.hbs index 4755c76d62..99e0dcf7d5 100644 --- a/ghost/admin/app/components/posts-list/list.hbs +++ b/ghost/admin/app/components/posts-list/list.hbs @@ -1,14 +1,39 @@ - {{#each @model as |post|}} - - - + {{!-- always order as scheduled, draft, remainder --}} + {{#if (or @model.scheduledInfinityModel (or @model.draftInfinityModel @model.publishedAndSentInfinityModel))}} + {{#if @model.scheduledInfinityModel}} + {{#each @model.scheduledInfinityModel as |post|}} + + + + {{/each}} + {{/if}} + {{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}} + {{#each @model.draftInfinityModel as |post|}} + + + + {{/each}} + {{/if}} + {{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}} + {{#each @model.publishedAndSentInfinityModel as |post|}} + + + + {{/each}} + {{/if}} {{else}} {{yield}} - {{/each}} + {{/if}} {{!-- The currently selected item or items are passed to the context menu --}} diff --git a/ghost/admin/app/utils/selection-list.js b/ghost/admin/app/components/posts-list/selection-list.js similarity index 67% rename from ghost/admin/app/utils/selection-list.js rename to ghost/admin/app/components/posts-list/selection-list.js index b409d6da4b..ec45475be0 100644 --- a/ghost/admin/app/utils/selection-list.js +++ b/ghost/admin/app/components/posts-list/selection-list.js @@ -18,7 +18,11 @@ export default class SelectionList { #clearOnNextUnfreeze = false; constructor(infinityModel) { - this.infinityModel = infinityModel ?? {content: []}; + this.infinityModel = infinityModel ?? { + draftInfinityModel: { + content: [] + } + }; } freeze() { @@ -41,7 +45,12 @@ export default class SelectionList { * Returns an NQL filter for all items, not the selection */ get allFilter() { - return this.infinityModel.extraParams?.filter ?? ''; + const models = this.infinityModel; + // grab filter from the first key in the infinityModel object (they should all be identical) + for (const key in models) { + return models[key].extraParams?.allFilter ?? ''; + } + return ''; } /** @@ -81,10 +90,13 @@ export default class SelectionList { * Keep in mind that when using CMD + A, we don't have all items in memory! */ get availableModels() { + const models = this.infinityModel; const arr = []; - for (const item of this.infinityModel.content) { - if (this.isSelected(item.id)) { - arr.push(item); + for (const key in models) { + for (const item of models[key].content) { + if (this.isSelected(item.id)) { + arr.push(item); + } } } return arr; @@ -102,7 +114,13 @@ export default class SelectionList { if (!this.inverted) { return this.selectedIds.size; } - return Math.max((this.infinityModel.meta?.pagination?.total ?? 0) - this.selectedIds.size, 1); + + const models = this.infinityModel; + let total; + for (const key in models) { + total += models[key].meta?.pagination?.total; + } + return Math.max((total ?? 0) - this.selectedIds.size, 1); } isSelected(id) { @@ -147,9 +165,12 @@ export default class SelectionList { clearUnavailableItems() { const newSelection = new Set(); - for (const item of this.infinityModel.content) { - if (this.selectedIds.has(item.id)) { - newSelection.add(item.id); + const models = this.infinityModel; + for (const key in models) { + for (const item of models[key].content) { + if (this.selectedIds.has(item.id)) { + newSelection.add(item.id); + } } } this.selectedIds = newSelection; @@ -181,37 +202,40 @@ export default class SelectionList { // todo let running = false; - for (const item of this.infinityModel.content) { - // Exlusing the last selected item - if (item.id === this.lastSelectedId || item.id === id) { - if (!running) { - running = true; + const models = this.infinityModel; + for (const key in models) { + for (const item of this.models[key].content) { + // Exlusing the last selected item + if (item.id === this.lastSelectedId || item.id === id) { + if (!running) { + running = true; - // Skip last selected on its own - if (item.id === this.lastSelectedId) { - continue; - } - } else { - // Still include id - if (item.id === id) { - this.lastShiftSelectionGroup.add(item.id); - - if (this.inverted) { - this.selectedIds.delete(item.id); - } else { - this.selectedIds.add(item.id); + // Skip last selected on its own + if (item.id === this.lastSelectedId) { + continue; } - } - break; - } - } + } else { + // Still include id + if (item.id === id) { + this.lastShiftSelectionGroup.add(item.id); - if (running) { - this.lastShiftSelectionGroup.add(item.id); - if (this.inverted) { - this.selectedIds.delete(item.id); - } else { - this.selectedIds.add(item.id); + if (this.inverted) { + this.selectedIds.delete(item.id); + } else { + this.selectedIds.add(item.id); + } + } + break; + } + } + + if (running) { + this.lastShiftSelectionGroup.add(item.id); + if (this.inverted) { + this.selectedIds.delete(item.id); + } else { + this.selectedIds.add(item.id); + } } } } diff --git a/ghost/admin/app/controllers/pages.js b/ghost/admin/app/controllers/pages.js index 6a011d1564..cd62cc80b5 100644 --- a/ghost/admin/app/controllers/pages.js +++ b/ghost/admin/app/controllers/pages.js @@ -40,4 +40,4 @@ export default class PagesController extends PostsController { openEditor(page) { this.router.transitionTo('lexical-editor.edit', 'page', page.get('id')); } -} +} \ No newline at end of file diff --git a/ghost/admin/app/controllers/posts.js b/ghost/admin/app/controllers/posts.js index 014cad0f47..8f48fca2db 100644 --- a/ghost/admin/app/controllers/posts.js +++ b/ghost/admin/app/controllers/posts.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import SelectionList from 'ghost-admin/utils/selection-list'; +import SelectionList from 'ghost-admin/components/posts-list/selection-list'; import {DEFAULT_QUERY_PARAMS} from 'ghost-admin/helpers/reset-query-params'; import {action} from '@ember/object'; import {inject} from 'ghost-admin/decorators/inject'; @@ -85,14 +85,6 @@ export default class PostsController extends Controller { Object.assign(this, DEFAULT_QUERY_PARAMS.posts); } - get postsInfinityModel() { - return this.model; - } - - get totalPosts() { - return this.model.meta?.pagination?.total ?? 0; - } - get showingAll() { const {type, author, tag, visibility} = this; diff --git a/ghost/admin/app/routes/posts.js b/ghost/admin/app/routes/posts.js index 93e7d5d4ab..a1c11aac27 100644 --- a/ghost/admin/app/routes/posts.js +++ b/ghost/admin/app/routes/posts.js @@ -1,4 +1,5 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import RSVP from 'rsvp'; import {action} from '@ember/object'; import {assign} from '@ember/polyfills'; import {isBlank} from '@ember/utils'; @@ -39,43 +40,54 @@ export default class PostsRoute extends AuthenticatedRoute { model(params) { const user = this.session.user; - let queryParams = {}; let filterParams = {tag: params.tag, visibility: params.visibility}; let paginationParams = { perPageParam: 'limit', totalPagesParam: 'meta.pagination.pages' }; - + + // type filters are actually mapping statuses assign(filterParams, this._getTypeFilters(params.type)); - + if (params.type === 'featured') { filterParams.featured = true; } - + + // authors and contributors can only view their own posts if (user.isAuthor) { - // authors can only view their own posts filterParams.authors = user.slug; } else if (user.isContributor) { - // Contributors can only view their own draft posts filterParams.authors = user.slug; - // filterParams.status = 'draft'; + // otherwise we need to filter by author if present } else if (params.author) { filterParams.authors = params.author; } - - let filter = this._filterString(filterParams); - if (!isBlank(filter)) { - queryParams.filter = filter; - } - - if (!isBlank(params.order)) { - queryParams.order = params.order; - } - + let perPage = this.perPage; - let paginationSettings = assign({perPage, startingPage: 1}, paginationParams, queryParams); + + const filterStatuses = filterParams.status; + let queryParams = {allFilter: this._filterString({...filterParams})}; // pass along the parent filter so it's easier to apply the params filter to each infinity model + let models = {}; - return this.infinity.model(this.modelName, paginationSettings); + if (filterStatuses.includes('scheduled')) { + let scheduledInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: 'scheduled'})}; + models.scheduledInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, scheduledInfinityModelParams)); + } + if (filterStatuses.includes('draft')) { + let draftInfinityModelParams = {...queryParams, order: params.order || 'updated_at desc', filter: this._filterString({...filterParams, status: 'draft'})}; + models.draftInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, draftInfinityModelParams)); + } + if (filterStatuses.includes('published') || filterStatuses.includes('sent')) { + let publishedAndSentInfinityModelParams; + if (filterStatuses.includes('published') && filterStatuses.includes('sent')) { + publishedAndSentInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: '[published,sent]'})}; + } else { + publishedAndSentInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: filterStatuses.includes('published') ? 'published' : 'sent'})}; + } + models.publishedAndSentInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, publishedAndSentInfinityModelParams)); + } + + return RSVP.hash(models); } // trigger a background load of all tags and authors for use in filter dropdowns @@ -120,6 +132,12 @@ export default class PostsRoute extends AuthenticatedRoute { }; } + /** + * Returns an object containing the status filter based on the given type. + * + * @param {string} type - The type of filter to generate (draft, published, scheduled, sent). + * @returns {Object} - An object containing the status filter. + */ _getTypeFilters(type) { let status = '[draft,scheduled,published,sent]'; diff --git a/ghost/admin/app/templates/pages.hbs b/ghost/admin/app/templates/pages.hbs index 723ebf8c17..7dba875970 100644 --- a/ghost/admin/app/templates/pages.hbs +++ b/ghost/admin/app/templates/pages.hbs @@ -28,7 +28,7 @@
  • @@ -41,7 +41,7 @@ {{else}}

    No pages match the current filter

    - + Show all pages {{/if}} @@ -49,11 +49,26 @@
  • + {{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}} + {{#if @model.scheduledInfinityModel}} -
    + {{/if}} + {{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}} + + {{/if}} + {{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}} + + {{/if}} + {{outlet}} diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index f0d0b6bbe8..3d99d9ee66 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -30,7 +30,7 @@
  • @@ -43,7 +43,7 @@ {{else}}

    No posts match the current filter

    - + Show all posts {{/if}} @@ -51,11 +51,26 @@
  • + {{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}} + {{#if @model.scheduledInfinityModel}} -
    + {{/if}} + {{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}} + + {{/if}} + {{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}} + + {{/if}} + {{outlet}} diff --git a/ghost/admin/mirage/config/pages.js b/ghost/admin/mirage/config/pages.js index 9ab0162c06..6faeb2b44a 100644 --- a/ghost/admin/mirage/config/pages.js +++ b/ghost/admin/mirage/config/pages.js @@ -37,7 +37,6 @@ export default function mockPages(server) { return pages.create(attrs); }); - // TODO: handle authors filter server.get('/pages/', function ({pages}, {queryParams}) { let {filter, page, limit} = queryParams; diff --git a/ghost/admin/mirage/config/posts.js b/ghost/admin/mirage/config/posts.js index a12863bfe7..2836e0613d 100644 --- a/ghost/admin/mirage/config/posts.js +++ b/ghost/admin/mirage/config/posts.js @@ -23,7 +23,6 @@ function extractTags(postAttrs, tags) { }); } -// TODO: handle authors filter export function getPosts({posts}, {queryParams}) { let {filter, page, limit} = queryParams; @@ -31,15 +30,27 @@ export function getPosts({posts}, {queryParams}) { limit = +limit || 15; let statusFilter = extractFilterParam('status', filter); + let authorsFilter = extractFilterParam('authors', filter); + let visibilityFilter = extractFilterParam('visibility', filter); let collection = posts.all().filter((post) => { let matchesStatus = true; + let matchesAuthors = true; + let matchesVisibility = true; if (!isEmpty(statusFilter)) { matchesStatus = statusFilter.includes(post.status); } - return matchesStatus; + if (!isEmpty(authorsFilter)) { + matchesAuthors = authorsFilter.includes(post.authors.models[0].slug); + } + + if (!isEmpty(visibilityFilter)) { + matchesVisibility = visibilityFilter.includes(post.visibility); + } + + return matchesStatus && matchesAuthors && matchesVisibility; }); return paginateModelCollection('posts', collection, page, limit); @@ -59,7 +70,6 @@ export default function mockPosts(server) { return posts.create(attrs); }); - // TODO: handle authors filter server.get('/posts/', getPosts); server.get('/posts/:id/', function ({posts}, {params}) { @@ -100,6 +110,13 @@ export default function mockPosts(server) { posts.find(ids).destroy(); }); + server.post('/posts/:id/copy/', function ({posts}, {params}) { + let post = posts.find(params.id); + let attrs = post.attrs; + + return posts.create(attrs); + }); + server.put('/posts/bulk/', function ({tags}, {requestBody}) { const bulk = JSON.parse(requestBody).bulk; const action = bulk.action; @@ -115,7 +132,7 @@ export default function mockPosts(server) { tags.create(tag); } }); - // TODO: update the actual posts in the mock db + // TODO: update the actual posts in the mock db if wanting to write tests where we navigate around (refresh model) // const postsToUpdate = posts.find(ids); // getting the posts is fine, but within this we CANNOT manipulate them (???) not even iterate with .forEach } diff --git a/ghost/admin/tests/acceptance/content-test.js b/ghost/admin/tests/acceptance/content-test.js index 5fbbf3f743..2690dfc655 100644 --- a/ghost/admin/tests/acceptance/content-test.js +++ b/ghost/admin/tests/acceptance/content-test.js @@ -17,7 +17,9 @@ const findButton = (text, buttons) => { return Array.from(buttons).find(button => button.innerText.trim() === text); }; -describe('Acceptance: Content', function () { +// NOTE: With accommodations for faster loading of posts in the UI, the requests to fetch the posts have been split into separate requests based +// on the status of the post. This means that the tests for filtering by status will have multiple requests to check against. +describe('Acceptance: Posts / Pages', function () { let hooks = setupApplicationTest(); setupMirage(hooks); @@ -25,550 +27,643 @@ describe('Acceptance: Content', function () { this.server.loadFixtures('configs'); }); - it('redirects to signin when not authenticated', async function () { - await invalidateSession(); - await visit('/posts'); - - expect(currentURL()).to.equal('/signin'); - }); - - describe('as admin', function () { - let admin, editor, publishedPost, scheduledPost, draftPost, authorPost; - - beforeEach(async function () { - let adminRole = this.server.create('role', {name: 'Administrator'}); - admin = this.server.create('user', {roles: [adminRole]}); - let editorRole = this.server.create('role', {name: 'Editor'}); - editor = this.server.create('user', {roles: [editorRole]}); - - publishedPost = this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post'}); - scheduledPost = this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Scheduled Post'}); - // draftPost = this.server.create('post', {authors: [admin], status: 'draft', title: 'Draft Post', visibility: 'paid'}); - draftPost = this.server.create('post', {authors: [admin], status: 'draft', title: 'Draft Post'}); - authorPost = this.server.create('post', {authors: [editor], status: 'published', title: 'Editor Published Post', visibiity: 'paid'}); - - // pages shouldn't appear in the list - this.server.create('page', {authors: [admin], status: 'published', title: 'Published Page'}); - - return await authenticateSession(); + describe('posts', function () { + it('redirects to signin when not authenticated', async function () { + await invalidateSession(); + + await visit('/posts'); + expect(currentURL()).to.equal('/signin'); }); - describe('displays and filter posts', function () { - it('displays posts', async function () { - await visit('/posts'); + describe('as contributor', function () { + beforeEach(async function () { + let contributorRole = this.server.create('role', {name: 'Contributor'}); + this.server.create('user', {roles: [contributorRole]}); - const posts = findAll('[data-test-post-id]'); - // displays all posts by default (all statuses) [no pages] - expect(posts.length, 'all posts count').to.equal(4); - - // note: atm the mirage backend doesn't support ordering of the results set + return await authenticateSession(); }); - it('can filter by status', async function () { + // NOTE: This test seems to fail if run AFTER the 'can change access' test in the 'as admin' section; router seems to fail, did not look into it further + it('shows posts list and allows post creation', async function () { await visit('/posts'); - // show draft posts - await selectChoose('[data-test-type-select]', 'Draft posts'); + // has an empty state + expect(findAll('[data-test-post-id]')).to.have.length(0); + expect(find('[data-test-no-posts-box]')).to.exist; + expect(find('[data-test-link="write-a-new-post"]')).to.exist; + + await click('[data-test-link="write-a-new-post"]'); + + expect(currentURL()).to.equal('/editor/post'); + + await fillIn('[data-test-editor-title-input]', 'First contributor post'); + await blur('[data-test-editor-title-input]'); + + expect(currentURL()).to.equal('/editor/post/1'); + + await click('[data-test-link="posts"]'); + + expect(findAll('[data-test-post-id]')).to.have.length(1); + expect(find('[data-test-no-posts-box]')).to.not.exist; + }); + }); + + describe('as author', function () { + let author, authorPost; + + beforeEach(async function () { + let authorRole = this.server.create('role', {name: 'Author'}); + author = this.server.create('user', {roles: [authorRole]}); + let adminRole = this.server.create('role', {name: 'Administrator'}); + let admin = this.server.create('user', {roles: [adminRole]}); + + // create posts + authorPost = this.server.create('post', {authors: [author], status: 'published', title: 'Author Post'}); + this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Admin Post'}); + + return await authenticateSession(); + }); + + it('only fetches the author\'s posts', async function () { + await visit('/posts'); + // trigger a filter request so we can grab the posts API request easily + await selectChoose('[data-test-type-select]', 'Published posts'); + + // API request includes author filter + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter).to.have.string(`authors:${author.slug}`); + + // only author's post is shown + expect(findAll('[data-test-post-id]').length, 'post count').to.equal(1); + expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist; + }); + }); + + describe('as admin', function () { + let admin, editor, publishedPost, scheduledPost, draftPost, authorPost; + + beforeEach(async function () { + let adminRole = this.server.create('role', {name: 'Administrator'}); + admin = this.server.create('user', {roles: [adminRole]}); + let editorRole = this.server.create('role', {name: 'Editor'}); + editor = this.server.create('user', {roles: [editorRole]}); + + publishedPost = this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post', visibility: 'paid'}); + scheduledPost = this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Scheduled Post'}); + draftPost = this.server.create('post', {authors: [admin], status: 'draft', title: 'Draft Post'}); + authorPost = this.server.create('post', {authors: [editor], status: 'published', title: 'Editor Published Post'}); + + // pages shouldn't appear in the list + this.server.create('page', {authors: [admin], status: 'published', title: 'Published Page'}); + + return await authenticateSession(); + }); + + describe('displays and filter posts', function () { + it('displays posts', async function () { + await visit('/posts'); + + const posts = findAll('[data-test-post-id]'); + // displays all posts by default (all statuses) [no pages] + expect(posts.length, 'all posts count').to.equal(4); + + // make sure display is scheduled > draft > published/sent + expect(posts[0].querySelector('.gh-content-entry-title').textContent, 'post 1 title').to.contain('Scheduled Post'); + expect(posts[1].querySelector('.gh-content-entry-title').textContent, 'post 2 title').to.contain('Draft Post'); + expect(posts[2].querySelector('.gh-content-entry-title').textContent, 'post 3 title').to.contain('Published Post'); + expect(posts[3].querySelector('.gh-content-entry-title').textContent, 'post 4 title').to.contain('Editor Published Post'); + + // check API requests + let lastRequests = this.server.pretender.handledRequests.filter(request => request.url.includes('/posts/')); + expect(lastRequests[0].queryParams.filter, 'scheduled request filter').to.have.string('status:scheduled'); + expect(lastRequests[1].queryParams.filter, 'drafts request filter').to.have.string('status:draft'); + expect(lastRequests[2].queryParams.filter, 'published request filter').to.have.string('status:[published,sent]'); + }); + + it('can filter by status', async function () { + await visit('/posts'); + + // show draft posts + await selectChoose('[data-test-type-select]', 'Draft posts'); + + // API request is correct + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, '"drafts" request status filter').to.have.string('status:draft'); + // Displays draft post + expect(findAll('[data-test-post-id]').length, 'drafts count').to.equal(1); + expect(find(`[data-test-post-id="${draftPost.id}"]`), 'draft post').to.exist; + + // show published posts + await selectChoose('[data-test-type-select]', 'Published posts'); + + // API request is correct + [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, '"published" request status filter').to.have.string('status:published'); + // Displays three published posts + pages + expect(findAll('[data-test-post-id]').length, 'published count').to.equal(2); + expect(find(`[data-test-post-id="${publishedPost.id}"]`), 'admin published post').to.exist; + expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author published post').to.exist; + + // show scheduled posts + await selectChoose('[data-test-type-select]', 'Scheduled posts'); + + // API request is correct + [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, '"scheduled" request status filter').to.have.string('status:scheduled'); + // Displays scheduled post + expect(findAll('[data-test-post-id]').length, 'scheduled count').to.equal(1); + expect(find(`[data-test-post-id="${scheduledPost.id}"]`), 'scheduled post').to.exist; + }); + + it('can filter by author', async function () { + await visit('/posts'); + + // show all posts by editor + await selectChoose('[data-test-author-select]', editor.name); + + // API request is correct + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.allFilter, '"editor" request status filter') + .to.have.string('status:[draft,scheduled,published,sent]'); + expect(lastRequest.queryParams.allFilter, '"editor" request filter param') + .to.have.string(`authors:${editor.slug}`); + + // Displays editor post + expect(findAll('[data-test-post-id]').length, 'editor count').to.equal(1); + }); + + it('can filter by visibility', async function () { + await visit('/posts'); + + await selectChoose('[data-test-visibility-select]', 'Paid members-only'); + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.allFilter, '"visibility" request filter param') + .to.have.string('visibility:[paid,tiers]'); + let posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(1); + + await selectChoose('[data-test-visibility-select]', 'Public'); + [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.allFilter, '"visibility" request filter param') + .to.have.string('visibility:public'); + posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(3); + }); + + it('can filter by tag', async function () { + this.server.create('tag', {name: 'B - Second', slug: 'second'}); + this.server.create('tag', {name: 'Z - Last', slug: 'last'}); + this.server.create('tag', {name: 'A - First', slug: 'first'}); + + await visit('/posts'); + await clickTrigger('[data-test-tag-select]'); + + let options = findAll('.ember-power-select-option'); + + // check that dropdown sorts alphabetically + expect(options[0].textContent.trim()).to.equal('All tags'); + expect(options[1].textContent.trim()).to.equal('A - First'); + expect(options[2].textContent.trim()).to.equal('B - Second'); + expect(options[3].textContent.trim()).to.equal('Z - Last'); + + // select one + await selectChoose('[data-test-tag-select]', 'B - Second'); + // affirm request + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.allFilter, '"tag" request filter param').to.have.string('tag:second'); + }); + }); + + describe('context menu actions', function () { + describe('single post', function () { + it('can duplicate a post', async function () { + await visit('/posts'); + + // get the post + const post = find(`[data-test-post-id="${publishedPost.id}"]`); + expect(post, 'post').to.exist; + + await triggerEvent(post, 'contextmenu'); + + let contextMenu = find('.gh-posts-context-menu'); // this is a
      element + + let buttons = contextMenu.querySelectorAll('button'); + + expect(contextMenu, 'context menu').to.exist; + expect(buttons.length, 'context menu buttons').to.equal(5); + expect(buttons[0].innerText.trim(), 'context menu button 1').to.contain('Unpublish'); + expect(buttons[1].innerText.trim(), 'context menu button 2').to.contain('Feature'); // or Unfeature + expect(buttons[2].innerText.trim(), 'context menu button 3').to.contain('Add a tag'); + expect(buttons[3].innerText.trim(), 'context menu button 4').to.contain('Duplicate'); + expect(buttons[4].innerText.trim(), 'context menu button 5').to.contain('Delete'); + + // duplicate the post + await click(buttons[3]); + + const posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(5); + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`)); + }); + }); + + describe('multiple posts', function () { + it('can feature and unfeature', async function () { + await visit('/posts'); + + // get all posts + const posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(4); + + const postThreeContainer = posts[2].parentElement; // draft post + const postFourContainer = posts[3].parentElement; // published post + + await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + + expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; + expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; + + // NOTE: right clicks don't seem to work in these tests + // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event + await triggerEvent(postFourContainer, 'contextmenu'); + + let contextMenu = find('.gh-posts-context-menu'); // this is a
        element + expect(contextMenu, 'context menu').to.exist; + + // feature the post + let buttons = contextMenu.querySelectorAll('button'); + let featureButton = findButton('Feature', buttons); + expect(featureButton, 'feature button').to.exist; + await click(featureButton); + + // API request is correct - note, we don't mock the actual model updates + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, 'feature request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); + expect(JSON.parse(lastRequest.requestBody).bulk.action, 'feature request action').to.equal('feature'); + + // ensure ui shows these are now featured + expect(postThreeContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist; + expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist; + + // unfeature the posts + await triggerEvent(postFourContainer, 'contextmenu'); + + contextMenu = find('.gh-posts-context-menu'); // this is a
          element + expect(contextMenu, 'context menu').to.exist; + + // unfeature the posts + buttons = contextMenu.querySelectorAll('button'); + featureButton = findButton('Unfeature', buttons); + expect(featureButton, 'unfeature button').to.exist; + await click(featureButton); + + // API request is correct - note, we don't mock the actual model updates + [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, 'unfeature request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); + expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unfeature request action').to.equal('unfeature'); + + // ensure ui shows these are now unfeatured + expect(postThreeContainer.querySelector('.gh-featured-post'), 'postFour featured').to.not.exist; + expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.not.exist; + }); + + it('can add a tag', async function () { + await visit('/posts'); + + // get all posts + const posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(4); + + const postThreeContainer = posts[2].parentElement; // draft post + const postFourContainer = posts[3].parentElement; // published post + + await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + + expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; + expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; + + // NOTE: right clicks don't seem to work in these tests + // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event + await triggerEvent(postFourContainer, 'contextmenu'); + + let contextMenu = find('.gh-posts-context-menu'); // this is a
            element + expect(contextMenu, 'context menu').to.exist; + + // add a tag to the posts + let buttons = contextMenu.querySelectorAll('button'); + let addTagButton = findButton('Add a tag', buttons); + expect(addTagButton, 'add tag button').to.exist; + await click(addTagButton); + + const addTagsModal = find('[data-test-modal="add-tags"]'); + expect(addTagsModal, 'tag settings modal').to.exist; + + const input = addTagsModal.querySelector('input'); + expect(input, 'tag input').to.exist; + await fillIn(input, 'test-tag'); + await triggerKeyEvent(input, 'keydown', 13); + await click('[data-test-button="confirm"]'); + + // API request is correct - note, we don't mock the actual model updates + let [lastRequest] = this.server.pretender.handledRequests.slice(-2); + expect(lastRequest.queryParams.filter, 'add tag request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); + expect(JSON.parse(lastRequest.requestBody).bulk.action, 'add tag request action').to.equal('addTag'); + }); + + // TODO: Skip for now. This causes the member creation test to fail ('New member' text doesn't show... ???). + it.skip('can change access', async function () { + await visit('/posts'); + + // get all posts + const posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(4); + + const postThreeContainer = posts[2].parentElement; // draft post + const postFourContainer = posts[3].parentElement; // published post + + await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + + expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; + expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; + + await triggerEvent(postFourContainer, 'contextmenu'); + let contextMenu = find('.gh-posts-context-menu'); // this is a
              element + expect(contextMenu, 'context menu').to.exist; + let buttons = contextMenu.querySelectorAll('button'); + let changeAccessButton = findButton('Change access', buttons); + + expect(changeAccessButton, 'change access button').not.to.exist; + + const settingsService = this.owner.lookup('service:settings'); + await settingsService.set('membersEnabled', true); + + await triggerEvent(postFourContainer, 'contextmenu'); + contextMenu = find('.gh-posts-context-menu'); // this is a
                element + expect(contextMenu, 'context menu').to.exist; + buttons = contextMenu.querySelectorAll('button'); + changeAccessButton = findButton('Change access', buttons); + + expect(changeAccessButton, 'change access button').to.exist; + await click(changeAccessButton); + + const changeAccessModal = find('[data-test-modal="edit-posts-access"]'); + const selectElement = changeAccessModal.querySelector('select'); + await fillIn(selectElement, 'members'); + await click('[data-test-button="confirm"]'); + + // check API request + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, 'change access request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); + expect(JSON.parse(lastRequest.requestBody).bulk.action, 'change access request action').to.equal('access'); + }); + + it('can unpublish', async function () { + await visit('/posts'); + + // get all posts + const posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(4); + + const postThreeContainer = posts[2].parentElement; // draft post + const postFourContainer = posts[3].parentElement; // published post + + await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + + expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; + expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; + + // NOTE: right clicks don't seem to work in these tests + // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event + await triggerEvent(postFourContainer, 'contextmenu'); + + let contextMenu = find('.gh-posts-context-menu'); // this is a
                  element + expect(contextMenu, 'context menu').to.exist; + + // unpublish the posts + let buttons = contextMenu.querySelectorAll('button'); + let unpublishButton = findButton('Unpublish', buttons); + expect(unpublishButton, 'unpublish button').to.exist; + await click(unpublishButton); + + // handle modal + const modal = find('[data-test-modal="unpublish-posts"]'); + expect(modal, 'unpublish modal').to.exist; + await click('[data-test-button="confirm"]'); + + // API request is correct - note, we don't mock the actual model updates + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, 'unpublish request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); + expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unpublish request action').to.equal('unpublish'); + + // ensure ui shows these are now unpublished + expect(postThreeContainer.querySelector('.gh-content-entry-status').textContent, 'postThree status').to.contain('Draft'); + expect(postFourContainer.querySelector('.gh-content-entry-status').textContent, 'postThree status').to.contain('Draft'); + }); + + it('can delete', async function () { + await visit('/posts'); + + // get all posts + const posts = findAll('[data-test-post-id]'); + expect(posts.length, 'all posts count').to.equal(4); + + const postThreeContainer = posts[2].parentElement; // draft post + const postFourContainer = posts[3].parentElement; // published post + + await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); + + expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; + expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; + + // NOTE: right clicks don't seem to work in these tests + // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event + await triggerEvent(postFourContainer, 'contextmenu'); + + let contextMenu = find('.gh-posts-context-menu'); // this is a
                    element + expect(contextMenu, 'context menu').to.exist; + + // delete the posts + let buttons = contextMenu.querySelectorAll('button'); + let deleteButton = findButton('Delete', buttons); + expect(deleteButton, 'delete button').to.exist; + await click(deleteButton); + + // handle modal + const modal = find('[data-test-modal="delete-posts"]'); + expect(modal, 'delete modal').to.exist; + await click('[data-test-button="confirm"]'); + + // API request is correct - note, we don't mock the actual model updates + let [lastRequest] = this.server.pretender.handledRequests.slice(-1); + expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['${publishedPost.id}','${authorPost.id}']`); + expect(lastRequest.method, 'delete request method').to.equal('DELETE'); + + // ensure ui shows these are now deleted + expect(findAll('[data-test-post-id]').length, 'all posts count').to.equal(2); + }); + }); + }); + + it('can add and edit custom views', async function () { + // actions are not visible when there's no filter + await visit('/posts'); + expect(find('[data-test-button="edit-view"]'), 'edit-view button (no filter)').to.not.exist; + expect(find('[data-test-button="add-view"]'), 'add-view button (no filter)').to.not.exist; + + // add action is visible after filtering to a non-default filter + await selectChoose('[data-test-author-select]', admin.name); + expect(find('[data-test-button="add-view"]'), 'add-view button (with filter)').to.exist; + + // adding view shows it in the sidebar + await click('[data-test-button="add-view"]'), 'add-view button'; + expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (on add)').to.exist; + expect(find('[data-test-modal="custom-view-form"] h1').textContent.trim()).to.equal('New view'); + await fillIn('[data-test-input="custom-view-name"]', 'Test view'); + await click('[data-test-button="save-custom-view"]'); + // modal closes on save + expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (after add save)').to.not.exist; + // UI updates + expect(find('[data-test-nav-custom="posts-Test view"]'), 'new view nav').to.exist; + expect(find('[data-test-nav-custom="posts-Test view"]').textContent.trim()).to.equal('Test view'); + expect(find('[data-test-button="add-view"]'), 'add-view button (on existing view)').to.not.exist; + expect(find('[data-test-button="edit-view"]'), 'edit-view button (on existing view)').to.exist; + + // editing view + await click('[data-test-button="edit-view"]'), 'edit-view button'; + expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (on edit)').to.exist; + expect(find('[data-test-modal="custom-view-form"] h1').textContent.trim()).to.equal('Edit view'); + await fillIn('[data-test-input="custom-view-name"]', 'Updated view'); + await click('[data-test-button="save-custom-view"]'); + // modal closes on save + expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (after edit save)').to.not.exist; + // UI updates + expect(find('[data-test-nav-custom="posts-Updated view"]')).to.exist; + expect(find('[data-test-nav-custom="posts-Updated view"]').textContent.trim()).to.equal('Updated view'); + expect(find('[data-test-button="add-view"]'), 'add-view button (after edit)').to.not.exist; + expect(find('[data-test-button="edit-view"]'), 'edit-view button (after edit)').to.exist; + }); + + it('can navigate to custom views', async function () { + this.server.create('setting', { + group: 'site', + key: 'shared_views', + value: JSON.stringify([{ + route: 'posts', + name: 'My posts', + filter: { + author: admin.slug + } + }]) + }); + + await visit('/posts'); + + // nav bar contains default + custom views + expect(find('[data-test-nav-custom="posts-Drafts"]')).to.exist; + expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.exist; + expect(find('[data-test-nav-custom="posts-Published"]')).to.exist; + expect(find('[data-test-nav-custom="posts-My posts"]')).to.exist; + + // screen has default title and sidebar is showing inactive custom view + expect(find('[data-test-screen-title]')).to.have.rendered.text('Posts'); + expect(find('[data-test-nav="posts"]')).to.have.class('active'); + + // clicking sidebar custom view link works + await click('[data-test-nav-custom="posts-Scheduled"]'); + expect(currentURL()).to.equal('/posts?type=scheduled'); + expect(find('[data-test-screen-title]').innerText).to.match(/Scheduled/); + expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.have.class('active'); + + // clicking the main posts link resets + await click('[data-test-nav="posts"]'); + expect(currentURL()).to.equal('/posts'); + expect(find('[data-test-screen-title]')).to.have.rendered.text('Posts'); + expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.not.have.class('active'); + + // changing a filter to match a custom view shows custom view + await selectChoose('[data-test-type-select]', 'Scheduled posts'); + expect(currentURL()).to.equal('/posts?type=scheduled'); + expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.have.class('active'); + expect(find('[data-test-screen-title]').innerText).to.match(/Scheduled/); + }); + }); + }); + + // NOTE: Because the pages list is (at this point in time) a thin extension of the posts list, we should not need to duplicate all of the tests. + // The main difference is that we fetch pages, not posts. + // IF we implement any kind of functionality that *is* specific to a post or page and differentiate these models further, we will need to add tests then. + describe('pages', function () { + describe('as admin', function () { + let admin, editor; + + beforeEach(async function () { + let adminRole = this.server.create('role', {name: 'Administrator'}); + admin = this.server.create('user', {roles: [adminRole]}); + let editorRole = this.server.create('role', {name: 'Editor'}); + editor = this.server.create('user', {roles: [editorRole]}); + + // posts shouldn't show in the pages list + // TODO: figure out why we need post counts to be >= page count for mirage to work right + this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post', visibility: 'paid'}); + this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post', visibility: 'paid'}); + this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post', visibility: 'paid'}); + this.server.create('post', {authors: [admin], status: 'published', title: 'Published Post', visibility: 'paid'}); + + this.server.create('page', {authors: [admin], status: 'published', title: 'Published Page'}); + this.server.create('page', {authors: [editor], status: 'published', title: 'Editor Published Page'}); + this.server.create('page', {authors: [admin], status: 'draft', title: 'Draft Page'}); + this.server.create('page', {authors: [admin], status: 'scheduled', title: 'Scheduled Page'}); + + return await authenticateSession(); + }); + + it('can view pages', async function () { + await visit('/pages'); + + const pages = findAll('[data-test-post-id]'); + // displays all pages by default (all statuses) + expect(pages.length, 'all pages count').to.equal(4); + }); + + it('can filter pages', async function () { + await visit('/pages'); + + // show draft pages + await selectChoose('[data-test-type-select]', 'Draft pages'); // API request is correct let [lastRequest] = this.server.pretender.handledRequests.slice(-1); expect(lastRequest.queryParams.filter, '"drafts" request status filter').to.have.string('status:draft'); - // Displays draft post + // Displays draft page expect(findAll('[data-test-post-id]').length, 'drafts count').to.equal(1); - expect(find(`[data-test-post-id="${draftPost.id}"]`), 'draft post').to.exist; - - // show published posts - await selectChoose('[data-test-type-select]', 'Published posts'); - + expect(find('[data-test-post-id="3"]'), 'draft page').to.exist; + + // show published pages + await selectChoose('[data-test-type-select]', 'Published pages'); + // API request is correct [lastRequest] = this.server.pretender.handledRequests.slice(-1); expect(lastRequest.queryParams.filter, '"published" request status filter').to.have.string('status:published'); - // Displays three published posts + pages + // Displays two published pages expect(findAll('[data-test-post-id]').length, 'published count').to.equal(2); - expect(find(`[data-test-post-id="${publishedPost.id}"]`), 'admin published post').to.exist; - expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author published post').to.exist; - - // show scheduled posts - await selectChoose('[data-test-type-select]', 'Scheduled posts'); - + expect(find('[data-test-post-id="1"]'), 'admin published page').to.exist; + expect(find('[data-test-post-id="2"]'), 'editor published page').to.exist; + + // show scheduled pages + await selectChoose('[data-test-type-select]', 'Scheduled pages'); + // API request is correct [lastRequest] = this.server.pretender.handledRequests.slice(-1); expect(lastRequest.queryParams.filter, '"scheduled" request status filter').to.have.string('status:scheduled'); - // Displays scheduled post + // Displays scheduled page expect(findAll('[data-test-post-id]').length, 'scheduled count').to.equal(1); - expect(find(`[data-test-post-id="${scheduledPost.id}"]`), 'scheduled post').to.exist; - - // show all posts - await selectChoose('[data-test-type-select]', 'All posts'); - - // API request is correct - [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[draft,scheduled,published,sent]'); + expect(find('[data-test-post-id="4"]'), 'scheduled page').to.exist; }); - - it('can filter by author', async function () { - await visit('/posts'); - - // show all posts by editor - await selectChoose('[data-test-author-select]', editor.name); - - // API request is correct - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, '"editor" request status filter') - .to.have.string('status:[draft,scheduled,published,sent]'); - expect(lastRequest.queryParams.filter, '"editor" request filter param') - .to.have.string(`authors:${editor.slug}`); - }); - - it('can filter by visibility', async function () { - await visit('/posts'); - - await selectChoose('[data-test-visibility-select]', 'Paid members-only'); - - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, '"visibility" request filter param') - .to.have.string('visibility:[paid,tiers]+status:[draft,scheduled,published,sent]'); - }); - - it('can filter by tag', async function () { - this.server.create('tag', {name: 'B - Second', slug: 'second'}); - this.server.create('tag', {name: 'Z - Last', slug: 'last'}); - this.server.create('tag', {name: 'A - First', slug: 'first'}); - - await visit('/posts'); - await clickTrigger('[data-test-tag-select]'); - - let options = findAll('.ember-power-select-option'); - - // check that dropdown sorts alphabetically - expect(options[0].textContent.trim()).to.equal('All tags'); - expect(options[1].textContent.trim()).to.equal('A - First'); - expect(options[2].textContent.trim()).to.equal('B - Second'); - expect(options[3].textContent.trim()).to.equal('Z - Last'); - - // select one - await selectChoose('[data-test-tag-select]', 'B - Second'); - // affirm request - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, 'request filter').to.have.string('tag:second'); - }); - }); - - describe('context menu actions', function () { - describe('single post', function () { - // has a duplicate option - it.skip('can duplicate a post', async function () { - await visit('/posts'); - - // get the post - const post = find(`[data-test-post-id="${publishedPost.id}"]`); - expect(post, 'post').to.exist; - - await triggerEvent(post, 'contextmenu'); - // await this.pauseTest(); - - let contextMenu = find('.gh-posts-context-menu'); // this is a
                      element - - let buttons = contextMenu.querySelectorAll('button'); - - // should have three options for a published post - expect(contextMenu, 'context menu').to.exist; - expect(buttons.length, 'context menu buttons').to.equal(5); - expect(buttons[0].innerText.trim(), 'context menu button 1').to.contain('Unpublish'); - expect(buttons[1].innerText.trim(), 'context menu button 2').to.contain('Feature'); // or Unfeature - expect(buttons[2].innerText.trim(), 'context menu button 3').to.contain('Add a tag'); - expect(buttons[3].innerText.trim(), 'context menu button 4').to.contain('Duplicate'); - expect(buttons[4].innerText.trim(), 'context menu button 5').to.contain('Delete'); - - // duplicate the post - await click(buttons[3]); - - // API request is correct - // POST /ghost/api/admin/posts/{id}/copy/?formats=mobiledoc,lexical - - // TODO: probably missing endpoint in mirage... - - // let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - // console.log(`lastRequest`, lastRequest); - // expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`)); - }); - }); - - describe('multiple posts', function () { - it('can feature and unfeature posts', async function () { - await visit('/posts'); - - // get all posts - const posts = findAll('[data-test-post-id]'); - expect(posts.length, 'all posts count').to.equal(4); - - const postThreeContainer = posts[2].parentElement; // draft post - const postFourContainer = posts[3].parentElement; // published post - - await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - - expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; - expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; - - // NOTE: right clicks don't seem to work in these tests - // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event - await triggerEvent(postFourContainer, 'contextmenu'); - - let contextMenu = find('.gh-posts-context-menu'); // this is a
                        element - expect(contextMenu, 'context menu').to.exist; - - // feature the post - let buttons = contextMenu.querySelectorAll('button'); - let featureButton = findButton('Feature', buttons); - expect(featureButton, 'feature button').to.exist; - await click(featureButton); - - // API request is correct - note, we don't mock the actual model updates - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, 'feature request id').to.equal(`id:['3','4']`); - expect(JSON.parse(lastRequest.requestBody).bulk.action, 'feature request action').to.equal('feature'); - - // ensure ui shows these are now featured - expect(postThreeContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist; - expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.exist; - - // unfeature the posts - await triggerEvent(postFourContainer, 'contextmenu'); - - contextMenu = find('.gh-posts-context-menu'); // this is a
                          element - expect(contextMenu, 'context menu').to.exist; - - // unfeature the posts - buttons = contextMenu.querySelectorAll('button'); - featureButton = findButton('Unfeature', buttons); - expect(featureButton, 'unfeature button').to.exist; - await click(featureButton); - - // API request is correct - note, we don't mock the actual model updates - [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, 'unfeature request id').to.equal(`id:['3','4']`); - expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unfeature request action').to.equal('unfeature'); - - // ensure ui shows these are now unfeatured - expect(postThreeContainer.querySelector('.gh-featured-post'), 'postFour featured').to.not.exist; - expect(postFourContainer.querySelector('.gh-featured-post'), 'postFour featured').to.not.exist; - }); - - it('can add a tag to multiple posts', async function () { - await visit('/posts'); - - // get all posts - const posts = findAll('[data-test-post-id]'); - expect(posts.length, 'all posts count').to.equal(4); - - const postThreeContainer = posts[2].parentElement; // draft post - const postFourContainer = posts[3].parentElement; // published post - - await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - - expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; - expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; - - // NOTE: right clicks don't seem to work in these tests - // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event - await triggerEvent(postFourContainer, 'contextmenu'); - - let contextMenu = find('.gh-posts-context-menu'); // this is a
                            element - expect(contextMenu, 'context menu').to.exist; - - // add a tag to the posts - let buttons = contextMenu.querySelectorAll('button'); - let addTagButton = findButton('Add a tag', buttons); - expect(addTagButton, 'add tag button').to.exist; - await click(addTagButton); - - const addTagsModal = find('[data-test-modal="add-tags"]'); - expect(addTagsModal, 'tag settings modal').to.exist; - - const input = addTagsModal.querySelector('input'); - expect(input, 'tag input').to.exist; - await fillIn(input, 'test-tag'); - await triggerKeyEvent(input, 'keydown', 13); - await click('[data-test-button="confirm"]'); - - // API request is correct - note, we don't mock the actual model updates - let [lastRequest] = this.server.pretender.handledRequests.slice(-2); - expect(lastRequest.queryParams.filter, 'add tag request id').to.equal(`id:['3','4']`); - expect(JSON.parse(lastRequest.requestBody).bulk.action, 'add tag request action').to.equal('addTag'); - }); - - // NOTE: we do not seem to be loading the settings properly into the membersutil service, such that the members - // service doesn't think members are enabled - it.skip('can change access to multiple posts', async function () { - await visit('/posts'); - - // get all posts - const posts = findAll('[data-test-post-id]'); - expect(posts.length, 'all posts count').to.equal(4); - - const postThreeContainer = posts[2].parentElement; // draft post - const postFourContainer = posts[3].parentElement; // published post - - await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - - expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; - expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; - - // NOTE: right clicks don't seem to work in these tests - // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event - await triggerEvent(postFourContainer, 'contextmenu'); - - let contextMenu = find('.gh-posts-context-menu'); // this is a
                              element - expect(contextMenu, 'context menu').to.exist; - - // TODO: the change access button is not showing; need to debug the UI to see what field it expects - // change access to the posts - let buttons = contextMenu.querySelectorAll('button'); - let changeAccessButton = findButton('Change access', buttons); - - expect(changeAccessButton, 'change access button').to.exist; - await click(changeAccessButton); - - const changeAccessModal = find('[data-test-modal="edit-posts-access"]'); - expect(changeAccessModal, 'change access modal').to.exist; - }); - - it('can unpublish posts', async function () { - await visit('/posts'); - - // get all posts - const posts = findAll('[data-test-post-id]'); - expect(posts.length, 'all posts count').to.equal(4); - - const postThreeContainer = posts[2].parentElement; // draft post - const postFourContainer = posts[3].parentElement; // published post - - await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - - expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; - expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; - - // NOTE: right clicks don't seem to work in these tests - // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event - await triggerEvent(postFourContainer, 'contextmenu'); - - let contextMenu = find('.gh-posts-context-menu'); // this is a
                                element - expect(contextMenu, 'context menu').to.exist; - - // unpublish the posts - let buttons = contextMenu.querySelectorAll('button'); - let unpublishButton = findButton('Unpublish', buttons); - expect(unpublishButton, 'unpublish button').to.exist; - await click(unpublishButton); - - // handle modal - const modal = find('[data-test-modal="unpublish-posts"]'); - expect(modal, 'unpublish modal').to.exist; - await click('[data-test-button="confirm"]'); - - // API request is correct - note, we don't mock the actual model updates - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, 'unpublish request id').to.equal(`id:['3','4']`); - expect(JSON.parse(lastRequest.requestBody).bulk.action, 'unpublish request action').to.equal('unpublish'); - - // ensure ui shows these are now unpublished - expect(postThreeContainer.querySelector('.gh-content-entry-status').textContent, 'postThree status').to.contain('Draft'); - expect(postFourContainer.querySelector('.gh-content-entry-status').textContent, 'postThree status').to.contain('Draft'); - }); - - it('can delete posts', async function () { - await visit('/posts'); - - // get all posts - const posts = findAll('[data-test-post-id]'); - expect(posts.length, 'all posts count').to.equal(4); - - const postThreeContainer = posts[2].parentElement; // draft post - const postFourContainer = posts[3].parentElement; // published post - - await click(postThreeContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - await click(postFourContainer, {metaKey: ctrlOrCmd === 'command', ctrlKey: ctrlOrCmd === 'ctrl'}); - - expect(postFourContainer.getAttribute('data-selected'), 'postFour selected').to.exist; - expect(postThreeContainer.getAttribute('data-selected'), 'postThree selected').to.exist; - - // NOTE: right clicks don't seem to work in these tests - // contextmenu is the event triggered - https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event - await triggerEvent(postFourContainer, 'contextmenu'); - - let contextMenu = find('.gh-posts-context-menu'); // this is a
                                  element - expect(contextMenu, 'context menu').to.exist; - - // delete the posts - let buttons = contextMenu.querySelectorAll('button'); - let deleteButton = findButton('Delete', buttons); - expect(deleteButton, 'delete button').to.exist; - await click(deleteButton); - - // handle modal - const modal = find('[data-test-modal="delete-posts"]'); - expect(modal, 'delete modal').to.exist; - await click('[data-test-button="confirm"]'); - - // API request is correct - note, we don't mock the actual model updates - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter, 'delete request id').to.equal(`id:['3','4']`); - expect(lastRequest.method, 'delete request method').to.equal('DELETE'); - - // ensure ui shows these are now deleted - expect(findAll('[data-test-post-id]').length, 'all posts count').to.equal(2); - }); - }); - }); - - it('can add and edit custom views', async function () { - // actions are not visible when there's no filter - await visit('/posts'); - expect(find('[data-test-button="edit-view"]'), 'edit-view button (no filter)').to.not.exist; - expect(find('[data-test-button="add-view"]'), 'add-view button (no filter)').to.not.exist; - - // add action is visible after filtering to a non-default filter - await selectChoose('[data-test-author-select]', admin.name); - expect(find('[data-test-button="add-view"]'), 'add-view button (with filter)').to.exist; - - // adding view shows it in the sidebar - await click('[data-test-button="add-view"]'), 'add-view button'; - expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (on add)').to.exist; - expect(find('[data-test-modal="custom-view-form"] h1').textContent.trim()).to.equal('New view'); - await fillIn('[data-test-input="custom-view-name"]', 'Test view'); - await click('[data-test-button="save-custom-view"]'); - // modal closes on save - expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (after add save)').to.not.exist; - // UI updates - expect(find('[data-test-nav-custom="posts-Test view"]'), 'new view nav').to.exist; - expect(find('[data-test-nav-custom="posts-Test view"]').textContent.trim()).to.equal('Test view'); - expect(find('[data-test-button="add-view"]'), 'add-view button (on existing view)').to.not.exist; - expect(find('[data-test-button="edit-view"]'), 'edit-view button (on existing view)').to.exist; - - // editing view - await click('[data-test-button="edit-view"]'), 'edit-view button'; - expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (on edit)').to.exist; - expect(find('[data-test-modal="custom-view-form"] h1').textContent.trim()).to.equal('Edit view'); - await fillIn('[data-test-input="custom-view-name"]', 'Updated view'); - await click('[data-test-button="save-custom-view"]'); - // modal closes on save - expect(find('[data-test-modal="custom-view-form"]'), 'custom view modal (after edit save)').to.not.exist; - // UI updates - expect(find('[data-test-nav-custom="posts-Updated view"]')).to.exist; - expect(find('[data-test-nav-custom="posts-Updated view"]').textContent.trim()).to.equal('Updated view'); - expect(find('[data-test-button="add-view"]'), 'add-view button (after edit)').to.not.exist; - expect(find('[data-test-button="edit-view"]'), 'edit-view button (after edit)').to.exist; - }); - - it('can navigate to custom views', async function () { - this.server.create('setting', { - group: 'site', - key: 'shared_views', - value: JSON.stringify([{ - route: 'posts', - name: 'My posts', - filter: { - author: admin.slug - } - }]) - }); - - await visit('/posts'); - - // nav bar contains default + custom views - expect(find('[data-test-nav-custom="posts-Drafts"]')).to.exist; - expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.exist; - expect(find('[data-test-nav-custom="posts-Published"]')).to.exist; - expect(find('[data-test-nav-custom="posts-My posts"]')).to.exist; - - // screen has default title and sidebar is showing inactive custom view - expect(find('[data-test-screen-title]')).to.have.rendered.text('Posts'); - expect(find('[data-test-nav="posts"]')).to.have.class('active'); - - // clicking sidebar custom view link works - await click('[data-test-nav-custom="posts-Scheduled"]'); - expect(currentURL()).to.equal('/posts?type=scheduled'); - expect(find('[data-test-screen-title]').innerText).to.match(/Scheduled/); - expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.have.class('active'); - - // clicking the main posts link resets - await click('[data-test-nav="posts"]'); - expect(currentURL()).to.equal('/posts'); - expect(find('[data-test-screen-title]')).to.have.rendered.text('Posts'); - expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.not.have.class('active'); - - // changing a filter to match a custom view shows custom view - await selectChoose('[data-test-type-select]', 'Scheduled posts'); - expect(currentURL()).to.equal('/posts?type=scheduled'); - expect(find('[data-test-nav-custom="posts-Scheduled"]')).to.have.class('active'); - expect(find('[data-test-screen-title]').innerText).to.match(/Scheduled/); - }); - }); - - describe('as author', function () { - let author, authorPost; - - beforeEach(async function () { - let authorRole = this.server.create('role', {name: 'Author'}); - author = this.server.create('user', {roles: [authorRole]}); - let adminRole = this.server.create('role', {name: 'Administrator'}); - let admin = this.server.create('user', {roles: [adminRole]}); - - // create posts - authorPost = this.server.create('post', {authors: [author], status: 'published', title: 'Author Post'}); - this.server.create('post', {authors: [admin], status: 'scheduled', title: 'Admin Post'}); - - return await authenticateSession(); - }); - - it('only fetches the author\'s posts', async function () { - await visit('/posts'); - // trigger a filter request so we can grab the posts API request easily - await selectChoose('[data-test-type-select]', 'Published posts'); - - // API request includes author filter - let [lastRequest] = this.server.pretender.handledRequests.slice(-1); - expect(lastRequest.queryParams.filter).to.have.string(`authors:${author.slug}`); - - // only author's post is shown - expect(findAll('[data-test-post-id]').length, 'post count').to.equal(1); - expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist; - }); - }); - - describe('as contributor', function () { - beforeEach(async function () { - let contributorRole = this.server.create('role', {name: 'Contributor'}); - this.server.create('user', {roles: [contributorRole]}); - - return await authenticateSession(); - }); - - it('shows posts list and allows post creation', async function () { - await visit('/posts'); - - // has an empty state - expect(findAll('[data-test-post-id]')).to.have.length(0); - expect(find('[data-test-no-posts-box]')).to.exist; - expect(find('[data-test-link="write-a-new-post"]')).to.exist; - - await click('[data-test-link="write-a-new-post"]'); - - expect(currentURL()).to.equal('/editor/post'); - - await fillIn('[data-test-editor-title-input]', 'First contributor post'); - await blur('[data-test-editor-title-input]'); - - expect(currentURL()).to.equal('/editor/post/1'); - - await click('[data-test-link="posts"]'); - - expect(findAll('[data-test-post-id]')).to.have.length(1); - expect(find('[data-test-no-posts-box]')).to.not.exist; }); }); }); \ No newline at end of file From 8afdc10ec9da671480a3a094101c5cf91ff12f1e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jul 2024 14:10:12 +0000 Subject: [PATCH 10/22] Update dependency tailwindcss to v3.4.7 --- apps/admin-x-design-system/package.json | 2 +- apps/comments-ui/package.json | 2 +- apps/signup-form/package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 1524ae194f..124ee2b4e6 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -71,7 +71,7 @@ "react-colorful": "5.6.1", "react-hot-toast": "2.4.1", "react-select": "5.8.0", - "tailwindcss": "3.4.6" + "tailwindcss": "3.4.7" }, "peerDependencies": { "react": "^18.2.0", diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 78a9ec7e07..3ad5cca6f3 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -75,7 +75,7 @@ "eslint-plugin-tailwindcss": "3.13.0", "jsdom": "24.1.1", "postcss": "8.4.39", - "tailwindcss": "3.4.6", + "tailwindcss": "3.4.7", "vite": "4.5.3", "vite-plugin-css-injected-by-js": "3.3.0", "vite-plugin-svgr": "3.3.0", diff --git a/apps/signup-form/package.json b/apps/signup-form/package.json index fc3841fdec..867922bdea 100644 --- a/apps/signup-form/package.json +++ b/apps/signup-form/package.json @@ -65,7 +65,7 @@ "rollup-plugin-node-builtins": "2.1.2", "storybook": "7.6.20", "stylelint": "15.10.3", - "tailwindcss": "3.4.6", + "tailwindcss": "3.4.7", "vite": "4.5.3", "vite-plugin-commonjs": "0.10.1", "vite-plugin-svgr": "3.3.0", diff --git a/yarn.lock b/yarn.lock index 93278ba4f9..8c9eb84e07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30049,10 +30049,10 @@ table@^6.0.9, table@^6.8.1: string-width "^4.2.3" strip-ansi "^6.0.1" -tailwindcss@3.4.6: - version "3.4.6" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.6.tgz#41faae16607e0916da1eaa4a3b44053457ba70dd" - integrity sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA== +tailwindcss@3.4.7: + version "3.4.7" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.7.tgz#6092f18767f5933f59375b9afe558e592fc77201" + integrity sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ== dependencies: "@alloc/quick-lru" "^5.2.0" arg "^5.0.2" From fd335d3f4cd4c0a2a41fd10a56171edc6e5f149f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 30 Jul 2024 07:37:55 +0000 Subject: [PATCH 11/22] Update dependency i18next to v23.12.2 --- ghost/i18n/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ghost/i18n/package.json b/ghost/i18n/package.json index f1ec201952..e55ed4b862 100644 --- a/ghost/i18n/package.json +++ b/ghost/i18n/package.json @@ -30,6 +30,6 @@ "mocha": "10.2.0" }, "dependencies": { - "i18next": "23.12.1" + "i18next": "23.12.2" } } diff --git a/yarn.lock b/yarn.lock index 8c9eb84e07..9023ab5a15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19802,10 +19802,10 @@ i18next-parser@8.13.0: vinyl-fs "^4.0.0" vue-template-compiler "^2.6.11" -i18next@23.12.1, i18next@^23.5.1: - version "23.12.1" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.12.1.tgz#1cdb4d6dde62404e128ae1212af586d14c70d389" - integrity sha512-l4y291ZGRgUhKuqVSiqyuU2DDzxKStlIWSaoNBR4grYmh0X+pRYbFpTMs3CnJ5ECKbOI8sQcJ3PbTUfLgPRaMA== +i18next@23.12.2, i18next@^23.5.1: + version "23.12.2" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.12.2.tgz#c5b44bb95e4d4a5908a51577fa06c63dc2f650a4" + integrity sha512-XIeh5V+bi8SJSWGL3jqbTEBW5oD6rbP5L+E7dVQh1MNTxxYef0x15rhJVcRb7oiuq4jLtgy2SD8eFlf6P2cmqg== dependencies: "@babel/runtime" "^7.23.2" From f7ec72488af496cb684ebe111706c9ae14436e7c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 30 Jul 2024 07:38:15 +0000 Subject: [PATCH 12/22] Update tiptap monorepo to v2.5.8 --- apps/comments-ui/package.json | 20 +++---- yarn.lock | 100 +++++++++++++++++----------------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 3ad5cca6f3..605af91ba1 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -44,16 +44,16 @@ }, "dependencies": { "@headlessui/react": "1.7.19", - "@tiptap/core": "2.5.7", - "@tiptap/extension-blockquote": "2.5.7", - "@tiptap/extension-document": "2.5.7", - "@tiptap/extension-hard-break": "2.5.7", - "@tiptap/extension-link": "2.5.7", - "@tiptap/extension-paragraph": "2.5.7", - "@tiptap/extension-placeholder": "2.5.7", - "@tiptap/extension-text": "2.5.7", - "@tiptap/pm": "2.5.7", - "@tiptap/react": "2.5.7", + "@tiptap/core": "2.5.8", + "@tiptap/extension-blockquote": "2.5.8", + "@tiptap/extension-document": "2.5.8", + "@tiptap/extension-hard-break": "2.5.8", + "@tiptap/extension-link": "2.5.8", + "@tiptap/extension-paragraph": "2.5.8", + "@tiptap/extension-placeholder": "2.5.8", + "@tiptap/extension-text": "2.5.8", + "@tiptap/pm": "2.5.8", + "@tiptap/react": "2.5.8", "react": "17.0.2", "react-dom": "17.0.2", "react-string-replace": "1.1.1" diff --git a/yarn.lock b/yarn.lock index 9023ab5a15..1b373200a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7563,66 +7563,66 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.1.tgz#27337d72046d5236b32fd977edee3f74c71d332f" integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg== -"@tiptap/core@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.5.7.tgz#681eefd198f9b7b8ad543ca29c56d46aab4919cf" - integrity sha512-8fBW+yBRSc2rEDOs6P+53kF0EAmSv17M4ruQBABo18Nt5qIyr/Uo4p+/E4NkV30bKgKI1zyq1dPeznDplSseqQ== +"@tiptap/core@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.5.8.tgz#58de366b0d2acb0a6e67a4780de64d619ebd90fa" + integrity sha512-lkWCKyoAoMTxM137MoEsorG7tZ5MZU6O3wMRuZ0P9fcTRY5vd1NWncWuPzuGSJIpL20gwBQOsS6PaQSfR3xjlA== -"@tiptap/extension-blockquote@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.5.7.tgz#378921028a25f39d6a1dcebc86efb73fc4f19cce" - integrity sha512-cSnk5ViQgG6SgKnvJ5qaW47jl5qTN0oADXdcfyaY5XrbCPBGCVq1yRZlUtPU/J0YocZpjNLRRSMPVQ3wya5vtQ== +"@tiptap/extension-blockquote@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.5.8.tgz#95880f0b687790dbff85a1c9e83f2afd0011be67" + integrity sha512-P8vDiagtRrUfIewfCKrJe0ddDSjPgOTKzqoM1UXKS+MenT8C/wT4bjiwopAoWP6zMoV0TfHWXah9emllmCfXFA== -"@tiptap/extension-bubble-menu@^2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.7.tgz#24c3f41d93022ed4bebe3610124bb114333c834d" - integrity sha512-gkuBuVGm5YPDRUG5Bscj6IYjDbzM7iJ2aXBGCM1rzuIiwT04twY51dKMIeseXa49uk/AQs/mqt3kGQjgSdSFAw== +"@tiptap/extension-bubble-menu@^2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.8.tgz#e39b176c574b9fd2f59c6457724f3f22a22fb1b8" + integrity sha512-COmd1Azudu7i281emZFIESECe7FnvWiRoBoQBVjjWSyq5PVzwJaA3PAlnU7GyNZKtVXMZ4xbrckdyNQfDeVQDA== dependencies: tippy.js "^6.3.7" -"@tiptap/extension-document@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.5.7.tgz#cfc608656ab9173334247baf8ddb93135b024260" - integrity sha512-tcK6aleya6pmC/ForF/y2PiwPhN5hK8JSm07pcWV9FmP2Qemx26GWS+1u1EzPDeTTbRBvk+9txHGcq9NYZem0Q== +"@tiptap/extension-document@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.5.8.tgz#644f042f1d4a8d3f74af057477cc627da7b54dc7" + integrity sha512-r3rP4ihCJAdp3VRIeqd80etHx7jttzZaKNFX8hkQShHK6eTHwrR92VL0jDE4K+NOE3bxjMsOlYizJYWV042BtA== -"@tiptap/extension-floating-menu@^2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.7.tgz#b97101d82629335f47663bb4ddbc9231985a2b80" - integrity sha512-tQjNNx0gPb7GxMiozcQ4R1Tl1znmlx/ZkbCF9rqxTzPTD4fnCliqBQAWjtHl98+D8+yEJBcB2DimtP7ztkv2mg== +"@tiptap/extension-floating-menu@^2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.8.tgz#6af3fa169bf293ab79a671a7b60b5199992a9154" + integrity sha512-qsM6tCyRlXnI/gADrkO/2p0Tldu5aY96CnsXpZMaflMgsO577qhcXD0ReGg17uLXBzJa5xmV8qOik0Ptq3WEWg== dependencies: tippy.js "^6.3.7" -"@tiptap/extension-hard-break@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.5.7.tgz#a832460a610f3ff6c3c4260562c8555e0d0734ec" - integrity sha512-Ki1JV2cz74wo4am8vIY6KWnfiFoE68RVQDIL0/29fNz1oZI46R4VV2Q5IvoVhetXcx7Qe9nTJVqy1vRS//Kcvg== +"@tiptap/extension-hard-break@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.5.8.tgz#95288faad3408b91284d925c3e4dbab66029dd98" + integrity sha512-samZEL0EXzHSmMQ7KyLnfSxdDv3qSjia0JzelfCnFZS6LLcbwjrIjV8ZPxEhJ7UlZqroQdFxPegllkLHZj/MdQ== -"@tiptap/extension-link@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.5.7.tgz#91326304a3f4f5fdeb23a8f630d5ae4a1ab64287" - integrity sha512-rxvcdV8H/TiRhR2SZfLHp7hUp5hwBAhkc6PsXEWj8lekG4/5lXGwPSPxLtHMBRtOyeJpXTv9DY6nsCGZuz9x6A== +"@tiptap/extension-link@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.5.8.tgz#f9264afed09bd25c37668303151ab80ba82ef044" + integrity sha512-qfeWR7sG2V7bn8z0f3HMyoR68pFlxYJmLs9cbW30diE9/zKClYEd3zTMPCgJ9yMSagCj4PWkqksIuktAhyRqOQ== dependencies: linkifyjs "^4.1.0" -"@tiptap/extension-paragraph@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.5.7.tgz#7ce35b365e8222fb8e93f5e7bcdc18ef73c32ac5" - integrity sha512-7zmDE43jv+GMTLuWrztA6oAnYLdUki5fUjYFn0h5FPRHQTuDoxsCD+hX0N/sGQVlc8zl1yn7EYbPNn9rHi7ECw== +"@tiptap/extension-paragraph@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.5.8.tgz#5be7e7c4e5c19bd4f512c72d3dfc4e1e6d6dd876" + integrity sha512-AMfD3lfGSiomfkSE2tUourUjVahLtIfWUQew13NTPuWoxAXaSyoCGO0ULkiou/lO3JVUUUmF9+KJrAHWGIARdA== -"@tiptap/extension-placeholder@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.5.7.tgz#b01f695d370d72ce2efd81a2b9b61868290b1f36" - integrity sha512-Xjl0sCUlNyVq8HDrf+6n62gPEM3ymPr5d5t0zXjE+NPzfOeOARfiMXW2VB5QYFOsxnCd2MbZAeZ4+RY2sSVaZg== +"@tiptap/extension-placeholder@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.5.8.tgz#80fdf02133d94f41363f6fe28f5fc3ef09ac73c6" + integrity sha512-mvRl73OM5jBXVtDRLSTvp8/4+0mS2J2+ZcuiAHjABwEsZRCfJsiqty5NisOxSuy/AQtm8TK2kyt6ZCXQ2VRGig== -"@tiptap/extension-text@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.5.7.tgz#bc436206ba16214383064672a6ebe3dd0464f368" - integrity sha512-vukhh2K/MsaIIs/UzIAwp44IVxTHPJcAhSsDnmJd4iPlkpjLt1eph77dfxv5awq78bj6mGvnCM0/0F6fW1C6/w== +"@tiptap/extension-text@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.5.8.tgz#a9c4de33eec749c8c01d8bd81fb589f581c30dfc" + integrity sha512-CNkD51jRMdcYCqFVOkrnebqBQ6pCD3ZD5z9kO5bOC5UPZKZBkLsWdlrHGAVwosxcGxdJACbqJ0Nj+fMgIw4tNA== -"@tiptap/pm@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.5.7.tgz#9661d508fe34f7616b1078becc049baeff75d677" - integrity sha512-4Eb4vA4e4vesBAUmZgx+n3xjgJ58uRKKtnhFDJ3Gg+dfpXvtF8FcEwSIjHJsTlNJ8mSrzX/I7S157qPc5wZXVw== +"@tiptap/pm@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.5.8.tgz#b18afa77fdf69527b13614a05cfefc8b63e82224" + integrity sha512-CVhHaTG4QNHSkvuh6HHsUR4hE+nbUnk7z+VMUedaqPU8tNqkTwWGCMbiyTc+PCsz0T9Mni7vvBR+EXgEQ3+w4g== dependencies: prosemirror-changeset "^2.2.1" prosemirror-collab "^1.3.1" @@ -7643,13 +7643,13 @@ prosemirror-transform "^1.9.0" prosemirror-view "^1.33.9" -"@tiptap/react@2.5.7": - version "2.5.7" - resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-2.5.7.tgz#0de723c468a37cf69bb1a9d7a38815137f156c71" - integrity sha512-QRMbo6eDtYHBwZ7ATFgKFWLlRZ/Q7NJrBS/Z6FW2lFhr1eM8UhOG6HMEMt/kibMJDJVi1FpXEavgaT75oe2BJg== +"@tiptap/react@2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-2.5.8.tgz#d6bc68710f084fe0f02855376cf869f8ca2cf6fd" + integrity sha512-twUMm8HV7scUgR/E1hYS9N6JDtKPl7cgDiPjxTynNHc5S5f5Ecv4ns/BZRq3TMZ/JDrp4rghLvgq+ImQsLvPOA== dependencies: - "@tiptap/extension-bubble-menu" "^2.5.7" - "@tiptap/extension-floating-menu" "^2.5.7" + "@tiptap/extension-bubble-menu" "^2.5.8" + "@tiptap/extension-floating-menu" "^2.5.8" "@types/use-sync-external-store" "^0.0.6" use-sync-external-store "^1.2.2" From bbec5c0ba696c1c818cd71d3d2f11592750e3111 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Tue, 30 Jul 2024 10:40:10 +0100 Subject: [PATCH 13/22] Added support for displaying different types of Mastodon notes (#20684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added logic for displaying different attachments for Mastodon Notes (images, audio, video) - Centered the feed on the screen for better focus and made the Feed layout the default one - Moved Following and Followers counters to the new “Profile” tab --------- Co-authored-by: Fabien O'Carroll --- .../src/components/ListIndex.tsx | 174 +++++++++++------- apps/admin-x-framework/src/api/activitypub.ts | 2 +- 2 files changed, 105 insertions(+), 71 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/ListIndex.tsx b/apps/admin-x-activitypub/src/components/ListIndex.tsx index 476c81ff46..4bd3519a27 100644 --- a/apps/admin-x-activitypub/src/components/ListIndex.tsx +++ b/apps/admin-x-activitypub/src/components/ListIndex.tsx @@ -1,6 +1,7 @@ import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png'; import React, {useEffect, useRef, useState} from 'react'; import articleBodyStyles from './articleBodyStyles'; +import getUsername from '../utils/get-username'; import {ActivityPubAPI} from '../api/activitypub'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Avatar, Button, ButtonGroup, Heading, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system'; @@ -84,7 +85,7 @@ const ActivityPubComponent: React.FC = () => { setArticleContent(null); }; - const [selectedOption, setSelectedOption] = useState({label: 'Inbox', value: 'inbox'}); + const [selectedOption, setSelectedOption] = useState({label: 'Feed', value: 'feed'}); const [selectedTab, setSelectedTab] = useState('inbox'); @@ -92,10 +93,10 @@ const ActivityPubComponent: React.FC = () => { { id: 'inbox', title: 'Inbox', - contents:
                                  -
                                    - {activities && activities.some(activity => activity.type === 'Create' && activity.object.type === 'Article') ? (activities.slice().reverse().map(activity => ( - activity.type === 'Create' && activity.object.type === 'Article' && + contents:
                                    +
                                      + {activities && activities.some(activity => activity.type === 'Create' && (activity.object.type === 'Article' || activity.object.type === 'Note')) ? (activities.slice().reverse().map(activity => ( + activity.type === 'Create' && (activity.object.type === 'Article' || activity.object.type === 'Note') &&
                                    • handleViewContent(activity.object, activity.actor)}>
                                    • @@ -109,7 +110,6 @@ const ActivityPubComponent: React.FC = () => {
                                  }
                                -
    }, { @@ -120,7 +120,6 @@ const ActivityPubComponent: React.FC = () => { activity.type === 'Like' && } id='list-item' title={
    {activity.actor.name} liked your post {activity.object.name}
    }>
    ))} -
    }, { @@ -135,8 +134,24 @@ const ActivityPubComponent: React.FC = () => { ))} - + }, + { + id: 'profile', + title: 'Profile', + contents:
    +
    +
    +
    updateRoute('/view-following')}> + {followingCount} + Following +
    +
    updateRoute('/view-followers')}> + {followersCount} + Followers +
    +
    +
    } ]; @@ -148,18 +163,18 @@ const ActivityPubComponent: React.FC = () => { { icon: 'listview', size: 'sm', - iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500', + iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500', onClick: () => { - setSelectedOption({label: 'Inbox', value: 'inbox'}); + setSelectedOption({label: 'Feed', value: 'feed'}); } }, { icon: 'cardview', size: 'sm', - iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500', + iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500', onClick: () => { - setSelectedOption({label: 'Feed', value: 'feed'}); + setSelectedOption({label: 'Inbox', value: 'inbox'}); } } ]} clearBg={true} link outlineOnMobile />]} @@ -189,35 +204,6 @@ const ActivityPubComponent: React.FC = () => { ); }; -const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => ( -
    -
    -
    -
    -
    updateRoute('/view-following')}> - {followingCount} - Following -
    -
    updateRoute('/view-followers')}> - {followersCount} - Followers -
    -
    -
    -
    -
    - Explore -
    - - {}} />} avatar={} detail='829 followers' hideActions={true} title='404 Media' /> - {}} />} avatar={} detail='791 followers' hideActions={true} title='The Browser' /> - {}} />} avatar={} detail='854 followers' hideActions={true} title='Welcome to Hell World' /> - -
    -
    -); - const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { const site = useBrowseSite(); const siteData = site.data?.site; @@ -274,6 +260,60 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp const doc = parser.parseFromString(object.content || '', 'text/html'); const plainTextContent = doc.body.textContent; + let previewContent = ''; + if (object.preview) { + const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html'); + previewContent = previewDoc.body.textContent || ''; + } else if (object.type === 'Note') { + previewContent = plainTextContent || ''; + } + + const renderAttachment = () => { + let attachment; + if (object.image) { + attachment = object.image; + } + + if (object.type === 'Note' && !attachment) { + attachment = object.attachment; + } + + // const attachment = object.attachment; + if (!attachment) { + return null; + } + + if (Array.isArray(attachment)) { + return ( +
    + {attachment.map((item, index) => ( + {`attachment-${index}`} + ))} +
    + ); + } + + switch (attachment.mediaType) { + case 'image/jpeg': + case 'image/png': + case 'image/gif': + return attachment; + case 'video/mp4': + case 'video/webm': + return
    +
    ; + + case 'audio/mpeg': + case 'audio/ogg': + return
    +
    ; + default: + return null; + } + }; + const timestamp = new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'}); @@ -291,34 +331,28 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp return ( <> {object && ( -
    - -
    - -
    -

    {actor.name}

    -
    - {/* {getUsername(actor)} */} - {timestamp} +
    + +
    +
    +

    {actor.name}

    + {getUsername(actor)} + {timestamp} +
    +
    +
    + {object.name && {object.name}} +

    {plainTextContent}

    + {/*

    {object.content}

    */} + {renderAttachment()} +
    +
    -
    -
    - - {object.image &&
    - -
    } - {object.name} -

    {plainTextContent}

    -
    -
    -
    -
    -
    - {/*
    */} +
    )} @@ -339,15 +373,15 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
    {object.name}
    -

    {object.preview?.content}

    +

    {previewContent}

    - {object.image &&
    - -
    } + {/* {image &&
    + +
    } */}
    {/*
    */} diff --git a/apps/admin-x-framework/src/api/activitypub.ts b/apps/admin-x-framework/src/api/activitypub.ts index 246292d556..0d419e6568 100644 --- a/apps/admin-x-framework/src/api/activitypub.ts +++ b/apps/admin-x-framework/src/api/activitypub.ts @@ -9,7 +9,7 @@ export type FollowItem = { export type ObjectProperties = { '@context': string | (string | object)[]; - type: 'Article' | 'Link'; + type: 'Article' | 'Link' | 'Note'; name: string; content: string; url?: string | undefined; From 178c98c17fbc14af6b002d082fc29b46121eed3b Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Tue, 30 Jul 2024 17:33:25 +0700 Subject: [PATCH 14/22] Fixed handling of single item collections (#20688) Fedify will not use an array for the `items` key of collections when there is only a single item, which wasn't being handled in our activitypub api module. Now we always return an array so that the components recieve consistent data. --- .../src/api/activitypub.test.ts | 123 +++++++++++++++++- .../src/api/activitypub.ts | 6 +- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 3e742b65f4..e194474e2c 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -95,7 +95,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns an the items array when the inbox is not empty', async function () { + test('Returns all the items array when the inbox is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -137,6 +137,49 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); + + test('Returns an array when the items key is a single object', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + response: + JSONResponse({ + type: 'Collection', + items: { + type: 'Create', + object: { + type: 'Note' + } + } + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getInbox(); + const expected: Activity[] = [ + { + type: 'Create', + object: { + type: 'Note' + } + } + ]; + + expect(actual).toEqual(expected); + }); }); describe('getFollowing', function () { @@ -199,7 +242,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns an the items array when the following is not empty', async function () { + test('Returns all the items array when the following is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -235,6 +278,43 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); + + test('Returns an array when the items key is a single object', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + response: + JSONResponse({ + type: 'Collection', + items: { + type: 'Person' + } + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowing(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); }); describe('getFollowers', function () { @@ -297,7 +377,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns an the items array when the followers is not empty', async function () { + test('Returns all the items array when the followers is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -333,6 +413,43 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); + + test('Returns an array when the items key is a single object', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + response: + JSONResponse({ + type: 'Collection', + items: { + type: 'Person' + } + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowers(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); }); describe('follow', function () { diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index fb7a185131..edb0da0ac1 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -45,7 +45,7 @@ export class ActivityPubAPI { return []; } if ('items' in json) { - return Array.isArray(json?.items) ? json.items : []; + return Array.isArray(json.items) ? json.items : [json.items]; } return []; } @@ -60,7 +60,7 @@ export class ActivityPubAPI { return []; } if ('items' in json) { - return Array.isArray(json?.items) ? json.items : []; + return Array.isArray(json.items) ? json.items : [json.items]; } return []; } @@ -86,7 +86,7 @@ export class ActivityPubAPI { return []; } if ('items' in json) { - return Array.isArray(json?.items) ? json.items : []; + return Array.isArray(json.items) ? json.items : [json.items]; } return []; } From f7a592e7612bcb61641f4c7d112b5842bd91aacf Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 30 Jul 2024 09:18:51 -0500 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20dark=20mode=20for?= =?UTF-8?q?=20standalone=20html=20editors=20in=20Admin=20X=20(#20689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/tryghost/issue/DES-591 - finished wiring up the darkMode prop through the context providers The main impact here was that the formatting toolbar was not respecting the dark mode settings. --- apps/admin-x-design-system/src/DesignSystemApp.tsx | 2 +- .../src/global/form/HtmlEditor.tsx | 10 ++++++---- .../src/providers/DesignSystemProvider.tsx | 9 ++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/admin-x-design-system/src/DesignSystemApp.tsx b/apps/admin-x-design-system/src/DesignSystemApp.tsx index 6606867af1..9ea7f4a730 100644 --- a/apps/admin-x-design-system/src/DesignSystemApp.tsx +++ b/apps/admin-x-design-system/src/DesignSystemApp.tsx @@ -17,7 +17,7 @@ const DesignSystemApp: React.FC = ({darkMode, fetchKoenigL return (
    - + {children}
    diff --git a/apps/admin-x-design-system/src/global/form/HtmlEditor.tsx b/apps/admin-x-design-system/src/global/form/HtmlEditor.tsx index 68d9f13b22..632cdd0990 100644 --- a/apps/admin-x-design-system/src/global/form/HtmlEditor.tsx +++ b/apps/admin-x-design-system/src/global/form/HtmlEditor.tsx @@ -13,6 +13,7 @@ export interface HtmlEditorProps { placeholder?: string nodes?: 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' emojiPicker?: boolean; + darkMode?: boolean; } declare global { @@ -61,7 +62,8 @@ const KoenigWrapper: React.FC = ({ onBlur, placeholder, nodes, - emojiPicker = true + emojiPicker = true, + darkMode = false }) => { const onError = useCallback((error: unknown) => { try { @@ -128,12 +130,12 @@ const KoenigWrapper: React.FC = ({ return ( { - const {fetchKoenigLexical} = useDesignSystem(); + const {fetchKoenigLexical, darkMode} = useDesignSystem(); const editorResource = useMemo(() => loadKoenig(fetchKoenigLexical), [fetchKoenigLexical]); return
    Loading editor...

    }> - +
    diff --git a/apps/admin-x-design-system/src/providers/DesignSystemProvider.tsx b/apps/admin-x-design-system/src/providers/DesignSystemProvider.tsx index c655165d9c..a79413283c 100644 --- a/apps/admin-x-design-system/src/providers/DesignSystemProvider.tsx +++ b/apps/admin-x-design-system/src/providers/DesignSystemProvider.tsx @@ -9,12 +9,14 @@ interface DesignSystemContextType { isAnyTextFieldFocused: boolean; setFocusState: (value: boolean) => void; fetchKoenigLexical: FetchKoenigLexical; + darkMode: boolean; } const DesignSystemContext = createContext({ isAnyTextFieldFocused: false, setFocusState: () => {}, - fetchKoenigLexical: async () => {} + fetchKoenigLexical: async () => {}, + darkMode: false }); export const useDesignSystem = () => useContext(DesignSystemContext); @@ -29,10 +31,11 @@ export const useFocusContext = () => { interface DesignSystemProviderProps { fetchKoenigLexical: FetchKoenigLexical; + darkMode: boolean; children: React.ReactNode; } -const DesignSystemProvider: React.FC = ({fetchKoenigLexical, children}) => { +const DesignSystemProvider: React.FC = ({fetchKoenigLexical, darkMode, children}) => { const [isAnyTextFieldFocused, setIsAnyTextFieldFocused] = useState(false); const setFocusState = (value: boolean) => { @@ -40,7 +43,7 @@ const DesignSystemProvider: React.FC = ({fetchKoenigL }; return ( - + From 61641bc7c608110d801a025e527625359b6a0480 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 30 Jul 2024 15:23:34 +0100 Subject: [PATCH 16/22] Fixed ActivityPub app tabs content (#20690) no refs The `Activity` and `Likes` tabs of the AcitivityPub app were not showing the correct content. This commit fixes that. --- .../src/components/ListIndex.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/ListIndex.tsx b/apps/admin-x-activitypub/src/components/ListIndex.tsx index 4bd3519a27..da6cf25995 100644 --- a/apps/admin-x-activitypub/src/components/ListIndex.tsx +++ b/apps/admin-x-activitypub/src/components/ListIndex.tsx @@ -115,16 +115,6 @@ const ActivityPubComponent: React.FC = () => { { id: 'activity', title: 'Activity', - contents:
    - {activities && activities.slice().reverse().map(activity => ( - activity.type === 'Like' && } id='list-item' title={
    {activity.actor.name} liked your post {activity.object.name}
    }>
    - ))} -
    -
    - }, - { - id: 'likes', - title: 'Likes', contents:
      {activities && activities.slice().reverse().map(activity => ( @@ -136,6 +126,16 @@ const ActivityPubComponent: React.FC = () => {
    }, + { + id: 'likes', + title: 'Likes', + contents:
    + {activities && activities.slice().reverse().map(activity => ( + activity.type === 'Like' && } id='list-item' title={
    {activity.actor.name} liked your post {activity.object.name}
    }>
    + ))} +
    +
    + }, { id: 'profile', title: 'Profile', @@ -167,7 +167,7 @@ const ActivityPubComponent: React.FC = () => { onClick: () => { setSelectedOption({label: 'Feed', value: 'feed'}); } - + }, { icon: 'cardview', @@ -191,9 +191,9 @@ const ActivityPubComponent: React.FC = () => { tabs={tabs} title='ActivityPub' toolbarBorder={true} - type='page' + type='page' onTabChange={setSelectedTab} - > + > ) : ( @@ -303,7 +303,7 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp return
    ; - + case 'audio/mpeg': case 'audio/ogg': return
    @@ -319,7 +319,7 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp const [isClicked, setIsClicked] = useState(false); const [isLiked, setIsLiked] = useState(false); - + const handleLikeClick = (event: React.MouseEvent | undefined) => { event?.stopPropagation(); setIsClicked(true); @@ -397,7 +397,7 @@ const ViewArticle: React.FC = ({object, onBackToList}) => { const [isClicked, setIsClicked] = useState(false); const [isLiked, setIsLiked] = useState(false); - + const handleLikeClick = (event: React.MouseEvent | undefined) => { event?.stopPropagation(); setIsClicked(true); @@ -415,7 +415,7 @@ const ViewArticle: React.FC = ({object, onBackToList}) => {
    -
    +
    From bb33a84058a2e2123e5b846d7d17642ef09478fc Mon Sep 17 00:00:00 2001 From: Princi Vershwal Date: Wed, 31 Jul 2024 09:37:34 +0530 Subject: [PATCH 17/22] Optimising count query : Added option param to skip distinct from count query for members events API Ref https://linear.app/tryghost/issue/SLO-193/optimise-count-query-skip-distinct-from-count-query-for-members-events The member events endpoint have many queries like:- select count(distinct members_subscribe_events.id) as aggregate from `members_subscribe_events` where `members_subscribe_events`.`created_at` < '2024-07-30 11:30:39' In these queries, distinct is not required as id is a primary key. Skipping distinct would improve the performance. This PR will changed the query to:- select count(*) as aggregate from `members_subscribe_events` where `members_subscribe_events`.`created_at` < '2024-07-30 11:30:39' --- .../lib/repositories/EventRepository.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ghost/members-api/lib/repositories/EventRepository.js b/ghost/members-api/lib/repositories/EventRepository.js index eb3574e391..a44541b50c 100644 --- a/ghost/members-api/lib/repositories/EventRepository.js +++ b/ghost/members-api/lib/repositories/EventRepository.js @@ -151,6 +151,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'newsletter'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -193,6 +194,7 @@ module.exports = class EventRepository { 'stripeSubscription.stripePrice.stripeProduct.product' ], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -245,6 +247,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -277,6 +280,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -314,6 +318,7 @@ module.exports = class EventRepository { 'tagAttribution' ], filter: 'subscriptionCreatedEvent.id:null+custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -368,6 +373,7 @@ module.exports = class EventRepository { 'tagAttribution' ], filter: 'member_id:-null+custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -416,6 +422,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'post', 'parent'], filter: 'member_id:-null+custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -449,6 +456,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'link', 'link.post'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -537,6 +545,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'post'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -571,6 +580,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'email'], filter: filterStr, + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -613,6 +623,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'email'], filter: 'delivered_at:-null+custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -655,6 +666,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'email'], filter: 'opened_at:-null+custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -697,6 +709,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'email'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -730,6 +743,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member', 'email'], filter: 'failed_at:-null+custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), @@ -772,6 +786,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter), From ffbcb5a69ea105963f3a9e2c0a0cbf94e15437bd Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 31 Jul 2024 09:57:10 +0100 Subject: [PATCH 18/22] Refactored ActivityPub tabs to make tabs more maintainable (#20692) no refs Refactorings include: - Formatting JSX to be more readable - Filtering activities before rendering - Fixing invalid inbox empty state HTML (nesting div inside ul) - Adding initial support for announce activities in the inbox --- .../src/components/ListIndex.tsx | 153 +++++++++++++----- 1 file changed, 109 insertions(+), 44 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/ListIndex.tsx b/apps/admin-x-activitypub/src/components/ListIndex.tsx index da6cf25995..4c785eead9 100644 --- a/apps/admin-x-activitypub/src/components/ListIndex.tsx +++ b/apps/admin-x-activitypub/src/components/ListIndex.tsx @@ -14,6 +14,13 @@ interface ViewArticleProps { onBackToList: () => void; } +type Activity = { + type: string, + object: { + type: string + } +} + function useBrowseInboxForUser(handle: string) { const site = useBrowseSite(); const siteData = site.data?.site; @@ -89,69 +96,127 @@ const ActivityPubComponent: React.FC = () => { const [selectedTab, setSelectedTab] = useState('inbox'); + const inboxTabActivities = activities.filter((activity: Activity) => { + const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type); + const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note'; + + return isCreate || isAnnounce; + }); + const activityTabActivities = activities.filter((activity: Activity) => activity.type === 'Create' && activity.object.type === 'Article'); + const likeTabActivies = activities.filter((activity: Activity) => activity.type === 'Like'); + const tabs: ViewTab[] = [ { id: 'inbox', title: 'Inbox', - contents:
    -
      - {activities && activities.some(activity => activity.type === 'Create' && (activity.object.type === 'Article' || activity.object.type === 'Note')) ? (activities.slice().reverse().map(activity => ( - activity.type === 'Create' && (activity.object.type === 'Article' || activity.object.type === 'Note') && -
    • handleViewContent(activity.object, activity.actor)}> - -
    • - ))) :
      -
      - Ghost site logos - Welcome to ActivityPub -

      We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.

      -

      You can see all of the users on the right—find your favorite ones and give them a follow.

      -
      -
    } - -
    + )} +
    + ) }, { id: 'activity', title: 'Activity', - contents:
    -
      - {activities && activities.slice().reverse().map(activity => ( - activity.type === 'Create' && activity.object.type === 'Article' && -
    • handleViewContent(activity.object, activity.actor)}> - -
    • - ))} -
    -
    + contents: ( +
    +
      + {activityTabActivities.reverse().map(activity => ( +
    • handleViewContent(activity.object, activity.actor)} + > + +
    • + ))} +
    +
    + ) }, { id: 'likes', title: 'Likes', - contents:
    - {activities && activities.slice().reverse().map(activity => ( - activity.type === 'Like' && } id='list-item' title={
    {activity.actor.name} liked your post {activity.object.name}
    }>
    - ))} -
    -
    + contents: ( +
    + + {likeTabActivies.reverse().map(activity => ( + } + id='list-item' + title={ +
    + {activity.actor.name} + liked your post + {activity.object.name} +
    + } + /> + ))} +
    +
    + ) }, { id: 'profile', title: 'Profile', - contents:
    -
    -
    -
    updateRoute('/view-following')}> - {followingCount} - Following -
    -
    updateRoute('/view-followers')}> - {followersCount} - Followers + contents: ( +
    +
    +
    +
    +
    updateRoute('/view-following')}> + {followingCount} + Following +
    +
    updateRoute('/view-followers')}> + {followersCount} + Followers +
    +
    -
    + ) } ]; From ae1ac83fc543ae955266289c5b340909d4f0039f Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 31 Jul 2024 11:16:25 +0100 Subject: [PATCH 19/22] Fixed members import-with-tier alpha creating unexpected invoices (#20695) ref https://linear.app/tryghost/issue/ONC-199 The `updateSubscriptionItemPrice()` method in our Stripe library used by the importer when moving a subscription over to a Ghost product/price was setting `proration_behavior: 'always_invoice'`. This resulted in invoices being created when changing the subscription (even though no prices were changing as far as the customer is concerned) and in some cases where a customer previously had a one-off discount the customer was incorrectly charged the proration difference because the discount was no longer applied to the new invoice. - updated `updateSubscriptionItemPrice()` to accept an `options` param allowing the `proration_behavior` property passed to the Stripe API to be overridden on a per-call basis - updated the `forceStripeSubscriptionToProduct()` method used by the importer to pass an options object with `prorationBehavior: 'none'` when updating the subscription item price so that no invoice and no unexpected charges occur when importing --- .../lib/MembersCSVImporterStripeUtils.js | 6 ++++-- .../test/MembersCSVImporterStripeUtils.test.js | 6 ++++-- ghost/stripe/lib/StripeAPI.js | 9 ++++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js b/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js index 82f19adb40..113d97295a 100644 --- a/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js +++ b/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js @@ -153,7 +153,8 @@ module.exports = class MembersCSVImporterStripeUtils { await this._stripeAPIService.updateSubscriptionItemPrice( stripeSubscription.id, stripeSubscriptionItem.id, - newStripePrice.id + newStripePrice.id, + {prorationBehavior: 'none'} ); stripePriceId = newStripePrice.id; @@ -167,7 +168,8 @@ module.exports = class MembersCSVImporterStripeUtils { await this._stripeAPIService.updateSubscriptionItemPrice( stripeSubscription.id, stripeSubscriptionItem.id, - stripePriceId + stripePriceId, + {prorationBehavior: 'none'} ); } } diff --git a/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js b/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js index 9b1176613d..f76159c72e 100644 --- a/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js +++ b/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js @@ -304,7 +304,8 @@ describe('MembersCSVImporterStripeUtils', function () { stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly( stripeCustomer.subscriptions.data[0].id, stripeCustomerSubscriptionItem.id, - GHOST_PRODUCT_STRIPE_PRICE_ID + GHOST_PRODUCT_STRIPE_PRICE_ID, + {prorationBehavior: 'none'} ).should.be.true(); }); @@ -346,7 +347,8 @@ describe('MembersCSVImporterStripeUtils', function () { stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly( stripeCustomer.subscriptions.data[0].id, stripeCustomerSubscriptionItem.id, - NEW_STRIPE_PRICE_ID + NEW_STRIPE_PRICE_ID, + {prorationBehavior: 'none'} ).should.be.true(); }); diff --git a/ghost/stripe/lib/StripeAPI.js b/ghost/stripe/lib/StripeAPI.js index 504b8b0c1f..999e5a0155 100644 --- a/ghost/stripe/lib/StripeAPI.js +++ b/ghost/stripe/lib/StripeAPI.js @@ -698,20 +698,23 @@ module.exports = class StripeAPI { * @param {string} subscriptionId - The ID of the Subscription to modify * @param {string} id - The ID of the SubscriptionItem * @param {string} price - The ID of the new Price + * @param {object} [options={}] - Additional data to set on the subscription object + * @param {('always_invoice'|'create_prorations'|'none')} [options.prorationBehavior='always_invoice'] - The proration behavior to use. See [Stripe docs](https://docs.stripe.com/api/subscriptions/update#update_subscription-proration_behavior) for more info + * @param {string} [options.cancellationReason=null] - The user defined cancellation reason * * @returns {Promise} */ - async updateSubscriptionItemPrice(subscriptionId, id, price) { + async updateSubscriptionItemPrice(subscriptionId, id, price, options = {}) { await this._rateLimitBucket.throttle(); const subscription = await this._stripe.subscriptions.update(subscriptionId, { - proration_behavior: 'always_invoice', + proration_behavior: options.prorationBehavior || 'always_invoice', items: [{ id, price }], cancel_at_period_end: false, metadata: { - cancellation_reason: null + cancellation_reason: options.cancellationReason ?? null } }); return subscription; From dde7e70f7ccef50dcb1de4107d92045c7eb80bd7 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 31 Jul 2024 11:16:25 +0100 Subject: [PATCH 20/22] Fixed members import-with-tier alpha creating unexpected invoices (#20695) ref https://linear.app/tryghost/issue/ONC-199 The `updateSubscriptionItemPrice()` method in our Stripe library used by the importer when moving a subscription over to a Ghost product/price was setting `proration_behavior: 'always_invoice'`. This resulted in invoices being created when changing the subscription (even though no prices were changing as far as the customer is concerned) and in some cases where a customer previously had a one-off discount the customer was incorrectly charged the proration difference because the discount was no longer applied to the new invoice. - updated `updateSubscriptionItemPrice()` to accept an `options` param allowing the `proration_behavior` property passed to the Stripe API to be overridden on a per-call basis - updated the `forceStripeSubscriptionToProduct()` method used by the importer to pass an options object with `prorationBehavior: 'none'` when updating the subscription item price so that no invoice and no unexpected charges occur when importing --- .../lib/MembersCSVImporterStripeUtils.js | 6 ++++-- .../test/MembersCSVImporterStripeUtils.test.js | 6 ++++-- ghost/stripe/lib/StripeAPI.js | 9 ++++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js b/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js index 82f19adb40..113d97295a 100644 --- a/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js +++ b/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js @@ -153,7 +153,8 @@ module.exports = class MembersCSVImporterStripeUtils { await this._stripeAPIService.updateSubscriptionItemPrice( stripeSubscription.id, stripeSubscriptionItem.id, - newStripePrice.id + newStripePrice.id, + {prorationBehavior: 'none'} ); stripePriceId = newStripePrice.id; @@ -167,7 +168,8 @@ module.exports = class MembersCSVImporterStripeUtils { await this._stripeAPIService.updateSubscriptionItemPrice( stripeSubscription.id, stripeSubscriptionItem.id, - stripePriceId + stripePriceId, + {prorationBehavior: 'none'} ); } } diff --git a/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js b/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js index 9b1176613d..f76159c72e 100644 --- a/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js +++ b/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js @@ -304,7 +304,8 @@ describe('MembersCSVImporterStripeUtils', function () { stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly( stripeCustomer.subscriptions.data[0].id, stripeCustomerSubscriptionItem.id, - GHOST_PRODUCT_STRIPE_PRICE_ID + GHOST_PRODUCT_STRIPE_PRICE_ID, + {prorationBehavior: 'none'} ).should.be.true(); }); @@ -346,7 +347,8 @@ describe('MembersCSVImporterStripeUtils', function () { stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly( stripeCustomer.subscriptions.data[0].id, stripeCustomerSubscriptionItem.id, - NEW_STRIPE_PRICE_ID + NEW_STRIPE_PRICE_ID, + {prorationBehavior: 'none'} ).should.be.true(); }); diff --git a/ghost/stripe/lib/StripeAPI.js b/ghost/stripe/lib/StripeAPI.js index 504b8b0c1f..999e5a0155 100644 --- a/ghost/stripe/lib/StripeAPI.js +++ b/ghost/stripe/lib/StripeAPI.js @@ -698,20 +698,23 @@ module.exports = class StripeAPI { * @param {string} subscriptionId - The ID of the Subscription to modify * @param {string} id - The ID of the SubscriptionItem * @param {string} price - The ID of the new Price + * @param {object} [options={}] - Additional data to set on the subscription object + * @param {('always_invoice'|'create_prorations'|'none')} [options.prorationBehavior='always_invoice'] - The proration behavior to use. See [Stripe docs](https://docs.stripe.com/api/subscriptions/update#update_subscription-proration_behavior) for more info + * @param {string} [options.cancellationReason=null] - The user defined cancellation reason * * @returns {Promise} */ - async updateSubscriptionItemPrice(subscriptionId, id, price) { + async updateSubscriptionItemPrice(subscriptionId, id, price, options = {}) { await this._rateLimitBucket.throttle(); const subscription = await this._stripe.subscriptions.update(subscriptionId, { - proration_behavior: 'always_invoice', + proration_behavior: options.prorationBehavior || 'always_invoice', items: [{ id, price }], cancel_at_period_end: false, metadata: { - cancellation_reason: null + cancellation_reason: options.cancellationReason ?? null } }); return subscription; From f4f7b91c12dcc8fb1fb606a597b0a151db7c633f Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:55:36 +0000 Subject: [PATCH 21/22] v5.88.3 --- ghost/admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 21dc8328ed..61ca1e00f4 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.88.2", + "version": "5.88.3", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index d9551825f9..eb88247ff9 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.88.2", + "version": "5.88.3", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", From 086ed9e7a95fc1c636a8ab11ee2a707314062816 Mon Sep 17 00:00:00 2001 From: Princi Vershwal Date: Thu, 1 Aug 2024 13:14:40 +0530 Subject: [PATCH 22/22] Optimising count query : Added option param to skip distinct from count query for members events API (#20700) Ref: https://linear.app/tryghost/issue/ENG-1470/improve-the-performance-of-the-membersevents-aggregated-click-event --- ghost/members-api/lib/repositories/EventRepository.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ghost/members-api/lib/repositories/EventRepository.js b/ghost/members-api/lib/repositories/EventRepository.js index a44541b50c..7bf1f8fc10 100644 --- a/ghost/members-api/lib/repositories/EventRepository.js +++ b/ghost/members-api/lib/repositories/EventRepository.js @@ -507,6 +507,7 @@ module.exports = class EventRepository { ...options, withRelated: ['member'], filter: 'custom:true', + useBasicCount: true, mongoTransformer: chainTransformers( // First set the filter manually replaceCustomFilterTransformer(filter),