diff --git a/apps/admin-x-settings/src/components/settings/general/users/email-notifications-tab.tsx b/apps/admin-x-settings/src/components/settings/general/users/email-notifications-tab.tsx index 9d9b915fe9f..c27283ae0de 100644 --- a/apps/admin-x-settings/src/components/settings/general/users/email-notifications-tab.tsx +++ b/apps/admin-x-settings/src/components/settings/general/users/email-notifications-tab.tsx @@ -107,8 +107,8 @@ const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User) align='center' checked={user.gift_subscription_purchase_notification} direction='rtl' - hint='Every time someone purchases a gift subscription' - label='Gift subscription purchases' + hint='Every time someone purchases or redeems a gift subscription' + label='Gift subscriptions' onChange={(e) => { setUserData?.({...user, gift_subscription_purchase_notification: e.target.checked}); }} diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index 03d375413d8..195a33f6b0c 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -137,6 +137,39 @@ describe('useEmberDataSync', () => { expect(invalidateSpy).not.toHaveBeenCalled(); }); + queryTest('invalidates comment queries for mapped Ember comment events', async ({ queryClient, wrapper }) => { + const mock = createMockStateBridge(); + window.EmberBridge = { state: mock.stateBridge }; + + queryClient.setQueryData(['MembersResponseType', '/members'], { members: [] }); + queryClient.setQueryData(['CommentsResponseType', '/comments'], { comments: [] }); + queryClient.setQueryData(['PostsResponseType', '/posts'], { posts: [] }); + + renderHook(() => useEmberDataSync(), { wrapper }); + + await waitFor(() => { + expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function)); + }); + + act(() => { + mock.emit('emberDataChange', { + operation: 'update', + modelName: 'comment', + id: 'member-1', + data: null + }); + }); + + await waitFor(() => { + const queries = queryClient.getQueryCache().getAll(); + const commentQueries = queries.filter(q => q.queryKey[0] === 'CommentsResponseType'); + const nonCommentQueries = queries.filter(q => q.queryKey[0] !== 'CommentsResponseType'); + + expect(commentQueries.every(q => q.state.isInvalidated)).toBe(true); + expect(nonCommentQueries.every(q => !q.state.isInvalidated)).toBe(true); + }); + }); + queryTest('does not subscribe if unmounted before the bridge becomes available', async ({ wrapper }) => { vi.useFakeTimers(); const mock = createMockStateBridge(); @@ -383,4 +416,3 @@ describe('useEmberRouting', () => { }); }); }); - diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index 763f706002f..b154658968d 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -77,6 +77,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { 'user': 'UsersResponseType', 'post': 'PostsResponseType', 'member': 'MembersResponseType', + 'comment': 'CommentsResponseType', 'tag': 'TagsResponseType', 'label': 'LabelsResponseType', 'webhook': 'WebhooksResponseType' @@ -310,4 +311,3 @@ export function useForceUpgrade(): boolean { return true; } - diff --git a/apps/admin/src/onboarding/components/share-publication-dialog.tsx b/apps/admin/src/onboarding/components/share-publication-dialog.tsx index 72e51efc54c..a3dc5148f6f 100644 --- a/apps/admin/src/onboarding/components/share-publication-dialog.tsx +++ b/apps/admin/src/onboarding/components/share-publication-dialog.tsx @@ -1,5 +1,6 @@ import {Button} from "@tryghost/shade/components"; import {ShareModal, type ShareModalSocialLink} from "@tryghost/shade/patterns"; +import {LucideIcon} from "@tryghost/shade/utils"; interface SharePublicationDialogProps { description: string; @@ -47,37 +48,52 @@ export function SharePublicationDialog({ ]; return ( - + + + Share your publication + onOpenChange(false)} /> + + + {imageUrl ? +
+ : +
+ +
+ } +
+
{siteTitle}
+ {description && ( +

{description}

+ )} +
+

Set your publication's cover image and description in{" "} .

- )} - open={open} - preview={{ - description, - imageURL: imageUrl, - title: siteTitle, - url: siteUrl, - }} - socialLinks={socialLinks} - title="Share your publication" - variant="publication" - onClose={() => onOpenChange(false)} - onOpenChange={onOpenChange} - /> + + + + +
+ ); } diff --git a/apps/portal/package.json b/apps/portal/package.json index df1783783ca..c8e0728e04b 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.32", + "version": "2.68.33", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/components/pages/gift-page.js b/apps/portal/src/components/pages/gift-page.js index ff4c1642882..4a9e0d5fee3 100644 --- a/apps/portal/src/components/pages/gift-page.js +++ b/apps/portal/src/components/pages/gift-page.js @@ -64,7 +64,7 @@ export const GiftPageStyles = ` align-items: center; justify-content: center; background: var(--white); - padding: 64px 48px 128px; + padding: 64px 48px; } /* Selection page only: useLayoutEffect locks the inner's vertical @@ -122,7 +122,7 @@ export const GiftPageStyles = ` .gh-portal-gift-checkout-tiers { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; } .gh-portal-gift-checkout-tier-item { @@ -137,6 +137,10 @@ export const GiftPageStyles = ` border-color: var(--grey9); } +.gh-portal-gift-checkout-tiers.single .gh-portal-gift-checkout-tier-item:hover { + border-color: var(--grey11); +} + .gh-portal-gift-checkout-tier-item.selected { border-color: var(--brandcolor); background: color-mix(in srgb, var(--brandcolor) 6%, var(--white)); @@ -145,7 +149,7 @@ export const GiftPageStyles = ` .gh-portal-gift-checkout-tier { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; width: 100%; background: transparent; @@ -157,10 +161,15 @@ export const GiftPageStyles = ` color: inherit; } +.gh-portal-gift-checkout-tiers.single .gh-portal-gift-checkout-tier { + cursor: default; +} + .gh-portal-gift-checkout-tier-radio { flex-shrink: 0; width: 18px; height: 18px; + margin-top: 3px; border-radius: 50%; border: 1.5px solid var(--grey9); background: var(--white); @@ -184,6 +193,20 @@ export const GiftPageStyles = ` transform: translate(-50%, -50%); } +.gh-portal-gift-checkout-tier-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.gh-portal-gift-checkout-tier-heading { + display: flex; + align-items: baseline; + gap: 10px; +} + .gh-portal-gift-checkout-tier-name { flex: 1; font-size: 1.5rem; @@ -197,6 +220,14 @@ export const GiftPageStyles = ` color: var(--grey0); } +.gh-portal-gift-checkout-tier-description { + margin: 0; + margin-top: -2px; + font-size: 1.4rem; + line-height: 1.4; + color: var(--grey4); +} + .gh-portal-gift-checkout-tier-benefits { display: grid; grid-template-rows: 0fr; @@ -237,7 +268,7 @@ export const GiftPageStyles = ` .gh-portal-gift-checkout-benefit svg { width: 14px; height: 14px; - margin-top: 4px; + margin-top: 3px; color: var(--grey1); flex-shrink: 0; } @@ -258,7 +289,7 @@ export const GiftPageStyles = ` .gh-portal-gift-checkout-cta-wrapper { position: sticky; bottom: 0; - margin: 32px 0 -64px; + margin: 0 0 -64px; padding: 32px 0 64px; background: linear-gradient(0deg, rgba(var(--whitergb), 1) 60%, rgba(var(--whitergb), 0) 100%); z-index: 1; @@ -284,13 +315,15 @@ export const GiftPageStyles = ` .gh-portal-gift-checkout-right-panel { flex: 1; display: flex; + flex-direction: column; align-items: center; - justify-content: center; background: linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0) 100%), var(--brandcolor); border-radius: 32px; padding: 64px 48px 64px; + overflow-y: auto; + min-height: 0; } .gh-portal-gift-checkout-card-stack { @@ -299,6 +332,8 @@ export const GiftPageStyles = ` align-items: center; width: 100%; max-width: 280px; + margin-block: auto; + flex-shrink: 0; } .gh-portal-gift-checkout-card-frame { @@ -306,6 +341,9 @@ export const GiftPageStyles = ` transform-style: preserve-3d; perspective: 1200px; transition: transform 0.3s ease; + position: sticky; + top: 0; + z-index: 1; } .gh-portal-gift-checkout-card-stack[data-revealing="true"] .gh-portal-gift-checkout-card-frame { @@ -359,6 +397,17 @@ export const GiftPageStyles = ` overflow: hidden; } +.gh-portal-gift-checkout-details-description { + margin: 0 0 12px; + font-size: 1.45rem; + line-height: 1.4; + color: rgba(255, 255, 255, 0.85); +} + +.gh-portal-gift-checkout-details-description:last-child { + margin-bottom: 0; +} + .gh-portal-gift-checkout-card { position: relative; width: 100%; @@ -476,6 +525,12 @@ export const GiftPageStyles = ` @media (max-width: 880px) { + .gh-portal-popup-container.full-size.gift, + .gh-portal-popup-container.full-size.giftSuccess, + .gh-portal-popup-container.full-size.giftRedemption { + padding: 0 !important; + } + .gh-portal-gift-checkout { grid-template-columns: 1fr; min-height: 0; @@ -485,21 +540,34 @@ export const GiftPageStyles = ` order: -1; position: static; height: auto; - padding: 12px 12px 0; + padding: 0; overflow: visible; } .gh-portal-gift-checkout-right-panel { - padding: 32px 24px; + padding: 56px 24px 32px; + border-radius: 0 0 32px 32px; } .gh-portal-gift-checkout-left { - padding: 32px 24px 80px; + padding: 32px 24px 0; + } + + /* Screens without a sticky CTA wrapper need their own bottom padding. */ + .gh-portal-content.giftSuccess .gh-portal-gift-checkout-left, + .gh-portal-content.giftRedemption .gh-portal-gift-checkout-left { + padding-bottom: 24px; } .gh-portal-gift-checkout-card, .gh-portal-gift-checkout-card-stack { - max-width: 320px; + max-width: 240px; + } + + .gh-portal-gift-checkout-cta-wrapper { + bottom: 0; + margin: 0; + padding: 32px 0 24px; } } @@ -542,36 +610,23 @@ function GiftPriceSwitch({selectedInterval, setSelectedInterval}) { ); } -function getTierPriceLabel(product, selectedInterval) { - const activePrice = selectedInterval === 'month' ? product.monthlyPrice : product.yearlyPrice; - - if (!activePrice) { +export function formatGiftValue(price) { + const {amount, currency} = price ?? {}; + if (amount === null || amount === undefined || !currency) { return ''; } + return `${getCurrencySymbol(currency)}${formatNumber(getStripeAmount(amount))}`; +} - const currencySymbol = getCurrencySymbol(activePrice.currency); - return `${currencySymbol}${formatNumber(getStripeAmount(activePrice.amount))}`; +function getTierPriceLabel(product, selectedInterval) { + const activePrice = selectedInterval === 'month' ? product.monthlyPrice : product.yearlyPrice; + return formatGiftValue(activePrice); } function getDurationLabel(selectedInterval) { return selectedInterval === 'month' ? '1 month' : '1 year'; } -const GIFT_EXPIRY_DAYS = 365; - -export function getPreviewGiftExpiresAt(fromDate = new Date()) { - const date = new Date(fromDate); - date.setDate(date.getDate() + GIFT_EXPIRY_DAYS); - return date; -} - -export function formatGiftExpiresAt(date) { - const d = date instanceof Date ? date : new Date(date); - const day = d.getDate(); - const month = d.toLocaleDateString('en-US', {month: 'short'}); - return `${day} ${month}, ${d.getFullYear()}`; -} - const GiftPage = () => { const {site, brandColor, action, doAction} = useContext(AppContext); const [selectedInterval, setSelectedInterval] = useState(null); @@ -586,6 +641,8 @@ const GiftPage = () => { // half. After this single measurement we never recompute — so when the // benefits change height on tier switch, only the bottom of the column // (the CTA) shifts, leaving the title and tier picker anchored. + // Skipped on mobile (single-column stack) where natural top-aligned flow + // is what we want; centering would push content under the sticky CTA. useLayoutEffect(() => { if (centeringDoneRef.current) { return; @@ -595,6 +652,11 @@ const GiftPage = () => { if (!inner || !left) { return; } + if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 880px)').matches) { + inner.style.marginTop = ''; + centeringDoneRef.current = true; + return; + } const leftRect = left.getBoundingClientRect(); if (leftRect.height === 0) { return; @@ -646,6 +708,7 @@ const GiftPage = () => { } const activeProduct = products.find(p => p.id === selectedProductId) || products[0]; + const isSingleTier = products.length === 1; const isPurchasing = action === 'checkoutGift:running'; const isDisabled = isCookiesDisabled() || isPurchasing; @@ -681,27 +744,40 @@ const GiftPage = () => {
-
Tier
-
+
{isSingleTier ? 'Membership details' : 'Tier'}
+
{products.map((product) => { const isSelected = product.id === activeProduct.id; const benefits = product.benefits || []; return (
{benefits.length > 0 && (
{
-
Expires
-
{formatGiftExpiresAt(getPreviewGiftExpiresAt())}
+
Gift value
+
{getTierPriceLabel(activeProduct, activeInterval)}
diff --git a/apps/portal/src/components/pages/gift-redemption-page.js b/apps/portal/src/components/pages/gift-redemption-page.js index 24dfdb82440..f0ffe72a14a 100644 --- a/apps/portal/src/components/pages/gift-redemption-page.js +++ b/apps/portal/src/components/pages/gift-redemption-page.js @@ -9,7 +9,7 @@ import {getGiftDurationLabel, getGiftRedemptionErrorMessage} from '../../utils/g import {t} from '../../utils/i18n'; import {hasGiftSubscriptions, removePortalLinkFromUrl} from '../../utils/helpers'; import useCardTilt from '../../utils/use-card-tilt'; -import {formatGiftExpiresAt, getPreviewGiftExpiresAt} from './gift-page'; +import {formatGiftValue} from './gift-page'; export const GiftRedemptionStyles = ` .gh-portal-gift-redemption-form { @@ -163,6 +163,7 @@ const GiftRedemptionPage = () => { ? `You've been gifted a membership to ${siteTitle}` : 'You\'ve been gifted a membership'; const benefits = gift.tier.benefits || []; + const tierDescription = gift.tier.description || ''; return ( <> @@ -216,8 +217,8 @@ const GiftRedemptionPage = () => { )}
{/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} -
Expires
-
{formatGiftExpiresAt(gift.expires_at || getPreviewGiftExpiresAt())}
+
Gift value
+
{formatGiftValue(gift)}
@@ -229,7 +230,7 @@ const GiftRedemptionPage = () => {
- {benefits.length > 0 && ( + {(tierDescription || benefits.length > 0) && ( <>
{ aria-hidden={!showDetails} >
-
- {benefits.map((benefit, index) => { - const benefitName = typeof benefit === 'string' ? benefit : benefit?.name; - const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`; - - if (!benefitName) { - return null; - } - - return ( -
- - {benefitName} -
- ); - })} -
+ {tierDescription && ( +

{tierDescription}

+ )} + {benefits.length > 0 && ( +
+ {benefits.map((benefit, index) => { + const benefitName = typeof benefit === 'string' ? benefit : benefit?.name; + const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`; + + if (!benefitName) { + return null; + } + + return ( +
+ + {benefitName} +
+ ); + })} +
+ )}
)} - {cadence && ( + {tier && cadence && (
-
Expires
-
{formatGiftExpiresAt(getPreviewGiftExpiresAt())}
+
Gift value
+
{formatGiftValue(cadence === 'month' ? tier.monthlyPrice : tier.yearlyPrice)}
)} @@ -174,7 +174,7 @@ const GiftSuccessPage = () => {
- {tier && tier.benefits && tier.benefits.length > 0 && ( + {tier && (tier.description || (tier.benefits && tier.benefits.length > 0)) && ( <>
{ aria-hidden={!showDetails} >
-
- {tier.benefits.map((benefit, idx) => { - const key = benefit?.id || `benefit-${idx}`; - return ( -
- - {benefit.name} -
- ); - })} -
+ {tier.description && ( +

{tier.description}

+ )} + {tier.benefits && tier.benefits.length > 0 && ( +
+ {tier.benefits.map((benefit, idx) => { + const key = benefit?.id || `benefit-${idx}`; + return ( +
+ + {benefit.name} +
+ ); + })} +
+ )}
+ + + + + + + ); +} + interface FilterRemoveButtonProps extends React.ButtonHTMLAttributes, VariantProps { @@ -1733,13 +1942,11 @@ function FilterValueSelector({field, values, onChange, operator}: F cursorPointer: context.cursorPointer })} > - onChange([e.target.value, endDate] as T[])} - onInputChange={field.onInputChange} + onChange={v => onChange([v, endDate] as T[])} />
({field, values, onChange, operator}: F > {context.i18n.to}
- onChange([startDate, e.target.value] as T[])} - onInputChange={field.onInputChange} + onChange={v => onChange([startDate, v] as T[])} /> ); @@ -1824,13 +2029,11 @@ function FilterValueSelector({field, values, onChange, operator}: F if (field.type === 'date') { return ( - onChange([e.target.value] as T[])} - onInputChange={field.onInputChange} + onChange={v => onChange([v] as T[])} /> ); } @@ -2234,15 +2437,7 @@ export function Filters({ // For select and multiselect types, show the options popover if (field.type === 'select' || field.type === 'multiselect') { setSelectedFieldKeyForOptions(field.key); - - // When editing an existing filter (single-filter mode), pre-populate with its values - if (!allowMultiple && field.type === 'multiselect') { - const existingFilter = filters.find(f => f.field === fieldKey); - setTempSelectedValues(existingFilter ? existingFilter.values : []); - } else { - setTempSelectedValues([]); - } - + setTempSelectedValues([]); return; } @@ -2271,37 +2466,19 @@ export function Filters({ ); const addFilterWithOption = useCallback( - (field: FilterFieldConfig, values: unknown[], closePopover: boolean = true) => { + (field: FilterFieldConfig, values: unknown[]) => { if (!field.key) { return; } - // In single-filter mode, update the existing filter for this field if one exists - if (!allowMultiple) { - const existingFilter = filters.find(f => f.field === field.key); - if (existingFilter) { - onChange(filters.map(f => (f.id === existingFilter.id ? {...f, values: values as T[]} : f))); - setTempSelectedValues(values as T[]); - - if (closePopover) { - closeFilterPopover(); - } - return; - } - } - - // Create a new filter + // Every commit creates a new filter. Multi-value editing of an existing + // filter happens through the filter row's own picker, not here. const defaultOperator = field.defaultOperator || (field.type === 'multiselect' ? 'is_any_of' : 'is'); const newFilter = createFilter(field.key, defaultOperator, values as T[]); onChange([...filters, newFilter]); - - if (closePopover) { - closeFilterPopover(); - } else { - setTempSelectedValues(values as unknown[]); - } + closeFilterPopover(); }, - [allowMultiple, closeFilterPopover, filters, onChange] + [closeFilterPopover, filters, onChange] ); const selectableFields = useMemo(() => { @@ -2422,18 +2599,21 @@ export function Filters({ )} > {selectedFieldForOptions ? ( - // Show original select/multiselect rendering without back button - // SelectOptionsPopover renders its own Command component when inline={true} + // The inline "add filter" picker always commits one filter per + // pick and closes — for both `select` and `multiselect` fields. + // We override `multiselect` → `select` so SelectOptionsPopover + // renders the single-pick UI (one click → onChange + onClose). + // Multi-value editing of an existing filter happens through the + // filter row's own picker, not here. - field={selectedFieldForOptions} + field={ + selectedFieldForOptions.type === 'multiselect' + ? {...selectedFieldForOptions, type: 'select'} + : selectedFieldForOptions + } inline={true} values={tempSelectedValues as T[]} - onChange={(values) => { - // For multiselect, create filter immediately but keep popover open - // For single select, create filter and close popover - const shouldClosePopover = selectedFieldForOptions.type === 'select'; - addFilterWithOption(selectedFieldForOptions, values as unknown[], shouldClosePopover); - }} + onChange={values => addFilterWithOption(selectedFieldForOptions, values as unknown[])} onClose={closeFilterPopover} /> ) : ( diff --git a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx index d6167b17198..3bbd813adf7 100644 --- a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx +++ b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx @@ -1,4 +1,5 @@ import ShareModal, {type ShareModalSocialLink} from '@/components/features/share-modal/share-modal'; +import {H3} from '@/components/layout/heading'; import {Button} from '@/components/ui/button'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import React from 'react'; @@ -62,40 +63,65 @@ const PostShareModal: React.FC = ({ ]; return ( - - Close - - ) : undefined} - preview={{ - description: postExcerpt, - imageURL: featureImageURL, - meta: ( -
-
-
- {siteTitle} - - {author} + + {children && ( + + {children} + + )} + +
+ +
+ + + {primaryTitle && {primaryTitle}} + {primaryTitle && secondaryTitle &&
} + {secondaryTitle && {secondaryTitle}} +
+ {description && ( + + {description} + + )} +
+ + {featureImageURL && ( +
+ )} +
+

{postTitle}

+ {postExcerpt && ( +

{postExcerpt}

+ )} +
+
+
+ {siteTitle} + + {author} +
- ), - title: postTitle, - url: postURL - }} - primaryTitle={primaryTitle} - secondaryTitle={secondaryTitle} - socialLinks={socialLinks} - variant="post" - onClose={onClose} - {...props} - > - {children} - +
+ + {emailOnly ? ( + + ) : ( + <> + + + + )} + +
+
); }; diff --git a/apps/shade/src/components/features/share-modal/index.ts b/apps/shade/src/components/features/share-modal/index.ts index f2e2a3d2f94..990e854ad6d 100644 --- a/apps/shade/src/components/features/share-modal/index.ts +++ b/apps/shade/src/components/features/share-modal/index.ts @@ -1,2 +1,2 @@ export {default} from './share-modal'; -export type {ShareModalSocialLink} from './share-modal'; +export type {ShareModalPreviewProps, ShareModalSocialLink} from './share-modal'; diff --git a/apps/shade/src/components/features/share-modal/share-modal.stories.tsx b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx index 5233e7d0b23..79c4711aea7 100644 --- a/apps/shade/src/components/features/share-modal/share-modal.stories.tsx +++ b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx @@ -1,20 +1,14 @@ import type {Meta, StoryObj} from '@storybook/react-vite'; import {useState} from 'react'; +import {H3} from '@/components/layout/heading'; import {Button} from '@/components/ui/button'; import ShareModal, {type ShareModalSocialLink} from './share-modal'; const meta = { title: 'Features / Share Modal', - component: ShareModal, - tags: ['autodocs'], - argTypes: { - children: { - table: { - disable: true - } - } - } -} satisfies Meta; + component: ShareModal.Root, + tags: ['autodocs'] +} satisfies Meta; export default meta; @@ -74,149 +68,145 @@ const publicationSocialLinks: ShareModalSocialLink[] = [ } ]; -const postArgs = { - copyURL: postUrl, - description: <> - Your post was published on your site and sent to 3 subscribers of Ghost Blog, on June 13th at 12:02. - , - preview: { - description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications with proper error handling and user feedback.', - imageURL: 'https://picsum.photos/800/600?random=1', - meta: ( -
-
-
- Ghost Blog - - Jane Smith -
+const postPreview = { + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications with proper error handling and user feedback.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith
- ), - title: 'Copy to Clipboard in React: Complete Guide', - url: postUrl - }, - primaryTitle: 'Your post is published.', - secondaryTitle: 'Spread the word!', - socialLinks: postSocialLinks, - variant: 'post' as const +
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl }; -const publicationArgs = { - actionsLayout: 'footer' as const, - copyURL: publicationUrl, - guidance: ( -

- Set your publication's cover image and description in . -

- ), - preview: { - description: 'Thoughts, stories and ideas.', - imageURL: 'https://picsum.photos/800/600?random=2', - title: 'Ghostbusters', - url: publicationUrl - }, - socialLinks: publicationSocialLinks, - title: 'Share your publication', - variant: 'publication' as const +const publicationPreview = { + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghost Journal', + url: publicationUrl }; -const postSource = `const [isOpen, setIsOpen] = useState(false); +function PostPreview() { + return ( + +
+
+

{postPreview.title}

+

{postPreview.description}

+ {postPreview.meta} +
+
+ ); +} -const postUrl = 'https://example.com/copy-clipboard-react-guide'; -const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); -const encodedPostUrl = encodeURIComponent(postUrl); +function PublicationPreview() { + return ( + +
+
+
{publicationPreview.title}
+

{publicationPreview.description}

+
+
+ ); +} -Your post was published on your site and sent to 3 subscribers.} - open={isOpen} - preview={{ - description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications.', - imageURL: 'https://picsum.photos/800/600?random=1', - meta: ( -
-
-
- Ghost Blog - - Jane Smith -
+const postSource = `const [isOpen, setIsOpen] = useState(false); + + + + + + +
+ setIsOpen(false)} /> +
+ + + Your post is published.
+ Spread the word! +
+ + Your post was published on your site and sent to 3 subscribers. + +
+ +
+
+

Copy to Clipboard in React: Complete Guide

+

A comprehensive guide to implementing copy-to-clipboard functionality in React applications.

- ), - title: 'Copy to Clipboard in React: Complete Guide', - url: postUrl - }} - primaryTitle="Your post is published." - secondaryTitle="Spread the word!" - socialLinks={[ - { - href: \`https://twitter.com/intent/tweet?text=\${encodedPostTitle}%0A\${encodedPostUrl}\`, - label: 'Share on X', - service: 'x' - } - ]} - variant="post" - onClose={() => setIsOpen(false)} - onOpenChange={setIsOpen} -> - -`; + + + + + + +`; const publicationSource = `const [isOpen, setIsOpen] = useState(false); -const publicationUrl = 'https://ghost.org'; -const encodedPublicationUrl = encodeURIComponent(publicationUrl); - - - Set your publication's cover image and description in{' '} - . -

- )} - open={isOpen} - preview={{ - description: 'Thoughts, stories and ideas.', - imageURL: 'https://picsum.photos/800/600?random=2', - title: 'Ghostbusters', - url: publicationUrl - }} - socialLinks={[ - { - href: \`https://threads.net/intent/post?text=\${encodedPublicationUrl}\`, - label: 'Share your publication on Threads', - service: 'threads' - } - ]} - title="Share your publication" - variant="publication" - onClose={() => setIsOpen(false)} - onOpenChange={setIsOpen} -> - -
`; + + + + + + + Share your publication + setIsOpen(false)} /> + + +
+
+
Ghost Journal
+

Thoughts, stories and ideas.

+
+ +

Set your publication's cover image and description in Design settings.

+ + + + + +`; export const Post: Story = { - args: { - ...postArgs - }, - render: (args) => { + render: () => { const PostExample = () => { const [isOpen, setIsOpen] = useState(false); return ( - setIsOpen(false)} - onOpenChange={setIsOpen} - > - - + + + + + +
+ setIsOpen(false)} /> +
+ + + Your post is published. +
+ Spread the word! +
+ + Your post was published on your site and sent to 3 subscribers of Ghost Blog, on June 13th at 12:02. + +
+ + + + + +
+
); }; @@ -232,22 +222,30 @@ export const Post: Story = { }; export const Publication: Story = { - args: { - ...publicationArgs - }, - render: (args) => { + render: () => { const PublicationExample = () => { const [isOpen, setIsOpen] = useState(false); return ( - setIsOpen(false)} - onOpenChange={setIsOpen} - > - - + + + + + + + Share your publication + setIsOpen(false)} /> + + +

+ Set your publication's cover image and description in . +

+ + + + +
+
); }; @@ -262,39 +260,36 @@ export const Publication: Story = { } }; -export const ControlledPublication: Story = { - args: { - copyURL: publicationUrl, - preview: { - title: 'Ghostbusters', - url: publicationUrl - } - }, - parameters: { - docs: { - source: { - code: publicationSource.replace('Share publication', 'Open publication share modal') - } - } - }, +export const StackedActions: Story = { render: () => { - const ControlledExample = () => { + const StackedExample = () => { const [isOpen, setIsOpen] = useState(false); return ( - setIsOpen(false)} - onOpenChange={setIsOpen} - > - - + + + + + + + Share your publication + setIsOpen(false)} /> + + + + + + + + ); }; - return ; + return ; } }; diff --git a/apps/shade/src/components/features/share-modal/share-modal.tsx b/apps/shade/src/components/features/share-modal/share-modal.tsx index 3f099026f6d..7db6c101f9f 100644 --- a/apps/shade/src/components/features/share-modal/share-modal.tsx +++ b/apps/shade/src/components/features/share-modal/share-modal.tsx @@ -1,10 +1,9 @@ -import {H3} from '@/components/layout/heading'; -import {Button} from '@/components/ui/button'; -import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; -import {cn} from '@/lib/utils'; import * as DialogPrimitive from '@radix-ui/react-dialog'; -import {Check, Copy, Image as ImageIcon, Link, X} from 'lucide-react'; +import {Check, Copy, Link, X} from 'lucide-react'; import React, {useState} from 'react'; +import {Button, type ButtonProps} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; +import {cn} from '@/lib/utils'; type ShareService = 'x' | 'threads' | 'facebook' | 'linkedin'; @@ -16,35 +15,9 @@ export type ShareModalSocialLink = { title?: string; }; -interface ShareModalPreview { - description?: React.ReactNode; - imageURL?: string; - meta?: React.ReactNode; - title: React.ReactNode; - url: string; -} - -interface ShareModalProps extends React.ComponentPropsWithoutRef { - actionsLayout?: 'footer' | 'stacked'; - children?: React.ReactNode; - closeButtonId?: string; - copyButtonId?: string; - copyButtonTestId?: string; - copyLabel?: string; - copySuccessLabel?: string; - copyURL: string; - contentProps?: React.ComponentPropsWithoutRef & Record<`data-${string}`, string | undefined>; - description?: React.ReactNode; - footerAction?: React.ReactNode; - guidance?: React.ReactNode; - onClose?: () => void; - preview: ShareModalPreview; - primaryTitle?: React.ReactNode; - secondaryTitle?: React.ReactNode; - socialLinks?: ShareModalSocialLink[]; - title?: React.ReactNode; - variant?: 'post' | 'publication'; -} +export type ShareModalPreviewProps = { + href: string; +} & React.AnchorHTMLAttributes; async function copyTextToClipboard(text: string) { if (navigator.clipboard?.writeText) { @@ -68,6 +41,98 @@ async function copyTextToClipboard(text: string) { document.body.removeChild(textarea); } +function Root(props: React.ComponentPropsWithoutRef) { + return ; +} + +const Trigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({asChild = true, className, ...props}, ref) => ( + +)); +Trigger.displayName = 'ShareModal.Trigger'; + +const Content = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +Content.displayName = 'ShareModal.Content'; + +function Header({className, ...props}: React.ComponentPropsWithoutRef) { + return ; +} + +function Title({className, ...props}: React.ComponentPropsWithoutRef) { + return ; +} + +function Description({className, ...props}: React.ComponentPropsWithoutRef) { + return ; +} + +type CloseButtonProps = ButtonProps; + +const CloseButton = React.forwardRef(({ + children, + className, + size = 'lg', + title = 'Close', + variant = 'link', + ...props +}, ref) => ( + +)); +CloseButton.displayName = 'ShareModal.CloseButton'; + +const Preview = React.forwardRef(({ + className, + href, + rel = 'noopener noreferrer', + target = '_blank', + ...props +}, ref) => { + return ( + + ); +}); +Preview.displayName = 'ShareModal.Preview'; + function SocialIcon({service}: {service: ShareService}) { if (service === 'threads') { return ( @@ -92,10 +157,15 @@ function SocialIcon({service}: {service: ShareService}) { ); } -function SocialLinks({layout, links}: {layout: 'footer' | 'stacked'; links: ShareModalSocialLink[]}) { +interface SocialLinksProps extends React.HTMLAttributes { + layout?: 'footer' | 'stacked'; + links: ShareModalSocialLink[]; +} + +function SocialLinks({className, layout = 'footer', links, ...props}: SocialLinksProps) { if (layout === 'stacked') { return ( -
+
{links.map(link => ( -
- )} - - {showPostHeader ? - - {primaryTitle && {primaryTitle}} - {primaryTitle && secondaryTitle &&
} - {secondaryTitle && {secondaryTitle}} -
- : - {title} - } - {!showPostHeader && ( - - )} - {description && - - {description} - - } -
- -
- {preview.imageURL ? -
- : - !showPostHeader && ( -
- -
- ) - } -
- {showPostHeader ? -

{preview.title}

- : -
{preview.title}
- } - {preview.description && ( -

{preview.description}

- )} - {preview.meta} -
-
- - {guidance} - - {actionsLayout === 'stacked' ? + const Icon = icon === 'copy' ? Copy : Link; + + return ( + -
- + {isCopied ? : } + {isCopied ? successLabel : label} - : - - {footerAction || ( - <> - - - - )} - - } -
+ )} + ); +} +interface CopyURLBoxProps extends React.HTMLAttributes { + copyURL: string; +} + +function CopyURLBox({children, className, copyURL, ...props}: CopyURLBoxProps) { return ( - - {children ? - - {children} - - : - null - } - {content} - +
+ {copyURL} + {children} +
); +} + +function Footer({className, ...props}: React.ComponentPropsWithoutRef) { + return ( + + ); +} + +const ShareModal = { + CloseButton, + Content, + CopyButton, + CopyURLBox, + Description, + Footer, + Header, + Preview, + Root, + SocialLinks, + Title, + Trigger }; export default ShareModal; diff --git a/apps/shade/src/components/ui/calendar.stories.tsx b/apps/shade/src/components/ui/calendar.stories.tsx new file mode 100644 index 00000000000..c2f55495d0b --- /dev/null +++ b/apps/shade/src/components/ui/calendar.stories.tsx @@ -0,0 +1,73 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {useState} from 'react'; +import {Calendar} from './calendar'; +import {Popover, PopoverContent, PopoverTrigger} from './popover'; +import {Button} from './button'; + +const meta = { + title: 'Components / Calendar', + component: Calendar, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Date picker calendar built on react-day-picker. Pair with a Popover to compose a date input control.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const SingleCalendarExample = () => { + const [date, setDate] = useState(new Date()); + return ( + + ); +}; + +const DatePickerExample = () => { + const [date, setDate] = useState(undefined); + return ( +
+ + + + + + + + +
+ ); +}; + +export const Default: Story = { + parameters: { + docs: { + description: { + story: 'Inline single-date selection.' + } + } + }, + render: () => +}; + +export const DatePicker: Story = { + parameters: { + docs: { + description: { + story: 'Calendar inside a Popover, the typical date-picker composition.' + } + } + }, + render: () => +}; diff --git a/apps/shade/src/components/ui/calendar.tsx b/apps/shade/src/components/ui/calendar.tsx new file mode 100644 index 00000000000..a0f4e0a59a0 --- /dev/null +++ b/apps/shade/src/components/ui/calendar.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import {ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon} from 'lucide-react'; +import {DayButton, DayPicker, getDefaultClassNames} from 'react-day-picker'; + +import {cn} from '@/lib/utils'; +import {Button, buttonVariants} from '@/components/ui/button'; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = 'label', + buttonVariant = 'ghost', + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant'] +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + classNames={{ + root: cn('w-fit', defaultClassNames.root), + months: cn( + 'flex gap-4 flex-col md:flex-row relative', + defaultClassNames.months + ), + month: cn('flex flex-col w-full gap-4', defaultClassNames.month), + nav: cn( + 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between', + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({variant: buttonVariant}), + 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({variant: buttonVariant}), + 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', + defaultClassNames.button_next + ), + month_caption: cn( + 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)', + defaultClassNames.month_caption + ), + dropdowns: cn( + 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5', + defaultClassNames.dropdowns + ), + dropdown_root: cn( + 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md', + defaultClassNames.dropdown_root + ), + dropdown: cn('absolute bg-popover inset-0 opacity-0', defaultClassNames.dropdown), + caption_label: cn( + 'select-none font-medium', + captionLayout === 'label' + ? 'text-sm' + : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', + defaultClassNames.caption_label + ), + table: 'w-full border-collapse', + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'text-muted-foreground rounded-md w-(--cell-size) font-normal text-sm select-none', + defaultClassNames.weekday + ), + week: cn('flex w-full mt-2', defaultClassNames.week), + week_number_header: cn( + 'select-none w-(--cell-size)', + defaultClassNames.week_number_header + ), + week_number: cn( + 'text-[0.8rem] select-none text-muted-foreground', + defaultClassNames.week_number + ), + day: cn( + 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none', + defaultClassNames.day + ), + range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), + today: cn( + 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', + defaultClassNames.today + ), + outside: cn( + 'text-muted-foreground aria-selected:text-muted-foreground', + defaultClassNames.outside + ), + disabled: cn( + 'text-muted-foreground opacity-50', + defaultClassNames.disabled + ), + hidden: cn('invisible', defaultClassNames.hidden), + ...classNames + }} + components={{ + Root: ({className: rootClassName, rootRef, ...rootProps}) => { + return ( +
+ ); + }, + Chevron: ({className: chevronClassName, orientation, ...chevronProps}) => { + if (orientation === 'left') { + return ; + } + if (orientation === 'right') { + return ; + } + return ; + }, + DayButton: CalendarDayButton, + WeekNumber: ({children, ...weekProps}) => { + return ( + +
+ {children} +
+ + ); + }, + ...components + }} + formatters={{ + formatMonthDropdown: date => date.toLocaleString('default', {month: 'short'}), + ...formatters + }} + showOutsideDays={showOutsideDays} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) { + ref.current?.focus(); + } + }, [modifiers.focused]); + + return ( + +
+ ); + } +})); type TestOption = { value: string; @@ -13,6 +33,12 @@ const ALL_OPTIONS: TestOption[] = [ {value: 'draft', label: 'Draft'} ]; +interface DateFiltersProps { + initialValue?: string; + onFiltersChange: ReturnType; + onInputChange: ReturnType; +} + function TestFilters({valueSource}: Readonly<{valueSource: ValueSource}>) { const [filters, setFilters] = useState([createFilter('status', 'is', ['published'])]); const fields = useMemo(() => ([ @@ -46,6 +72,35 @@ function StaticLoadingFilters({isLoading, options}: Readonly<{isLoading: boolean return fields={fields} filters={filters} showSearchInput={false} onChange={setFilters} />; } +function DateFilters({ + initialValue = '2026-05-07', + onFiltersChange, + onInputChange +}: Readonly) { + const [filters, setFilters] = useState([createFilter('created_at', 'is', [initialValue])]); + const fields = useMemo[]>(() => ([ + { + key: 'created_at', + label: 'Date', + type: 'date' as const, + operators: [{value: 'is', label: 'is'}], + onInputChange: event => onInputChange(event.target.value) + } + ]), [onInputChange]); + + return ( + + fields={fields} + filters={filters} + showSearchInput={false} + onChange={(nextFilters) => { + setFilters(nextFilters); + onFiltersChange(String(nextFilters[0]?.values[0] || '')); + }} + /> + ); +} + function getSelectedValueTrigger() { return screen.getByRole('button', {name: 'Published'}); } @@ -70,6 +125,10 @@ function createMatchingValueSource() { return {id: 'status', useOptions}; } +function openCalendar() { + fireEvent.click(screen.getByRole('button', {name: 'Open calendar'})); +} + describe('Filters ValueSource', () => { beforeAll(() => { global.ResizeObserver = class { @@ -213,4 +272,167 @@ describe('Filters ValueSource', () => { expect(await screen.findByPlaceholderText('Search status...')).toBeDefined(); expect(document.querySelector('.animate-spin')).toBeTruthy(); }); + + it('calls date field onInputChange when a typed date is committed', () => { + const handleFiltersChange = vi.fn(); + const handleInputChange = vi.fn(); + + render(); + + const input = screen.getByDisplayValue('2026-05-07'); + fireEvent.change(input, {target: {value: '2026-05-08'}}); + fireEvent.blur(input); + + expect(handleFiltersChange).toHaveBeenCalledWith('2026-05-08'); + expect(handleInputChange).toHaveBeenCalledWith('2026-05-08'); + }); + + it('resets manually entered invalid date values to today', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 9)); + const handleFiltersChange = vi.fn(); + const handleInputChange = vi.fn(); + + render(); + + const input = screen.getByDisplayValue('2026-05-07'); + fireEvent.change(input, {target: {value: '2026-02-30'}}); + fireEvent.blur(input); + + expect(screen.getByDisplayValue('2026-05-09')).toBeDefined(); + expect(handleFiltersChange).toHaveBeenCalledWith('2026-05-09'); + expect(handleInputChange).toHaveBeenCalledWith('2026-05-09'); + }); + + it('passes valid date values to the calendar selection', async () => { + render(); + + openCalendar(); + + expect((await screen.findByTestId('calendar-selected')).getAttribute('data-selected')).toBe('2026-05-07'); + }); + + it('updates the date input when a calendar date is selected', async () => { + const handleFiltersChange = vi.fn(); + const handleInputChange = vi.fn(); + + render(); + + openCalendar(); + fireEvent.click(await screen.findByRole('button', {name: 'Select May 8'})); + + expect(screen.getByDisplayValue('2026-05-08')).toBeDefined(); + expect(handleFiltersChange).toHaveBeenCalledWith('2026-05-08'); + expect(handleInputChange).toHaveBeenCalledWith('2026-05-08'); + }); + + it('uses an editable text input for date values', () => { + render(); + + const input = screen.getByDisplayValue('2026-05-07') as HTMLInputElement; + + expect(input.type).toBe('text'); + expect(input.pattern).toBe('\\d{4}-\\d{2}-\\d{2}'); + }); + + it('does not normalize overflow date values for the calendar selection', async () => { + render(); + + openCalendar(); + + expect((await screen.findByTestId('calendar-selected')).getAttribute('data-selected')).toBe(''); + }); + + it('requires date values to use the HTML date input format', async () => { + render(); + + openCalendar(); + + expect((await screen.findByTestId('calendar-selected')).getAttribute('data-selected')).toBe(''); + }); +}); + +describe('Filters allowMultiple multiselect', () => { + beforeAll(() => { + global.ResizeObserver = class { + observe() { + return undefined; + } + + unobserve() { + return undefined; + } + + disconnect() { + return undefined; + } + } as unknown as typeof ResizeObserver; + HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + + function MultiselectTestFilters({initialFilters, onChangeSpy}: Readonly<{ + initialFilters: Filter[]; + // eslint-disable-next-line no-unused-vars + onChangeSpy: (filters: Filter[]) => void; + }>) { + const [filters, setFilters] = useState[]>(initialFilters); + const fields = useMemo[]>(() => ([ + { + key: 'label', + label: 'Label', + type: 'multiselect', + searchable: false, + operators: [{value: 'is-any', label: 'is any of'}], + defaultOperator: 'is-any', + options: [ + {value: 'vip', label: 'VIP'}, + {value: 'premium', label: 'Premium'}, + {value: 'gold', label: 'Gold'} + ] + } + ]), []); + + return ( + { + onChangeSpy(next); + setFilters(next); + }} + /> + ); + } + + it('commits a new single-value label filter and closes the picker after one selection', async () => { + const onChangeSpy = vi.fn(); + const initial = [createFilter('label', 'is-any', ['vip'])]; + + render(); + + fireEvent.click(screen.getByRole('button', {name: 'Add filter'})); + + const labelMenuItem = await screen.findByRole('option', {name: 'Label'}); + fireEvent.click(labelMenuItem); + + const premiumOption = await screen.findByRole('option', {name: 'Premium'}); + fireEvent.click(premiumOption); + + await waitFor(() => { + const lastCall = onChangeSpy.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const finalFilters = lastCall![0] as Filter[]; + expect(finalFilters).toHaveLength(2); + expect(finalFilters[0].field).toBe('label'); + expect(finalFilters[0].values).toEqual(['vip']); + expect(finalFilters[1].field).toBe('label'); + expect(finalFilters[1].values).toEqual(['premium']); + }); + + // Picker should have closed — no more option role elements visible. + expect(screen.queryByRole('option', {name: 'Gold'})).toBeNull(); + }); }); diff --git a/apps/stats/src/views/Stats/Growth/components/new-subscribers-cadence.tsx b/apps/stats/src/views/Stats/Growth/components/new-subscribers-cadence.tsx index 54ac3c274f2..a0fb4726e74 100644 --- a/apps/stats/src/views/Stats/Growth/components/new-subscribers-cadence.tsx +++ b/apps/stats/src/views/Stats/Growth/components/new-subscribers-cadence.tsx @@ -1,11 +1,11 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import moment from 'moment'; import {Card, CardContent, CardDescription, CardHeader, CardTitle, ChartConfig, ChartContainer, ChartTooltip, EmptyIndicator, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {LucideIcon, Recharts, formatNumber} from '@tryghost/shade/utils'; import {formatQueryDate, getRangeDates} from '@tryghost/shade/app'; import {getPeriodText} from '@src/utils/chart-helpers'; import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {useMemberCountHistory, useSubscriptionStats} from '@tryghost/admin-x-framework/api/stats'; +import {useSubscriptionStats} from '@tryghost/admin-x-framework/api/stats'; type NewSubscribersCadenceProps = { isLoading: boolean; @@ -47,11 +47,6 @@ const CustomTooltip = ({active, payload}: { const NewSubscribersCadence: React.FC = ({isLoading, range}) => { const {data: subscriptionStatsResponse} = useSubscriptionStats(); - const {data: memberCountResponse} = useMemberCountHistory({ - searchParams: { - date_from: formatQueryDate(getRangeDates(range).startDate) - } - }); const {data: {tiers: tierObjects = []} = {}} = useBrowseTiers(); const [breakdownType, setBreakdownType] = useState('billing-period'); @@ -70,40 +65,6 @@ const NewSubscribersCadence: React.FC = ({isLoading, })); }, [tierObjects]); - // Helper: compute positive delta for a given status field (e.g. comped) - // from the first to the last data point within the date range. - const calculateStatusSignups = useCallback((statusField: 'comped') => { - if (!memberCountResponse?.stats || memberCountResponse.stats.length === 0) { - return 0; - } - - const stats = memberCountResponse.stats; - const dateFromMoment = moment(dateFrom); - const dateToMoment = moment(dateTo); - - const filteredStats = stats.filter((item) => { - const itemDate = moment(item.date); - return itemDate.isSameOrAfter(dateFromMoment) && itemDate.isSameOrBefore(dateToMoment); - }); - - if (filteredStats.length === 0) { - return 0; - } - - const sortedStats = [...filteredStats].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() - ); - - const firstValue = sortedStats[0][statusField] ?? 0; - const lastValue = sortedStats[sortedStats.length - 1][statusField] ?? 0; - - // Only count positive deltas (new signups, not removals) - const delta = lastValue - firstValue; - return delta > 0 ? delta : 0; - }, [memberCountResponse, dateFrom, dateTo]); - - // Calculate complimentary member signups (change in comped) within date range - const compedSignups = useMemo(() => calculateStatusSignups('comped'), [calculateStatusSignups]); - // Process subscription data for billing period breakdown (cadence) - NEW SUBSCRIBERS in date range const billingPeriodData = useMemo(() => { if (!subscriptionStatsResponse?.stats) { @@ -128,11 +89,6 @@ const NewSubscribersCadence: React.FC = ({isLoading, return acc; }, {} as Record); - // Add complimentary signups if any exist - if (compedSignups > 0) { - cadenceTotals.complimentary = compedSignups; - } - // Convert to array format for pie chart const chartData = Object.entries(cadenceTotals).map(([cadence, count], index) => { // Map cadence values to display labels and colors @@ -148,10 +104,6 @@ const NewSubscribersCadence: React.FC = ({isLoading, label = 'Annual'; fillGradient = 'url(#gradientTeal)'; solidColor = 'var(--chart-teal)'; - } else if (cadence === 'complimentary') { - label = 'Complimentary'; - fillGradient = 'url(#gradientBlue)'; - solidColor = 'var(--chart-blue)'; } return { @@ -164,7 +116,7 @@ const NewSubscribersCadence: React.FC = ({isLoading, }); return chartData; - }, [subscriptionStatsResponse, dateFrom, dateTo, compedSignups]); + }, [subscriptionStatsResponse, dateFrom, dateTo]); // Process subscription data for tier breakdown - NEW SUBSCRIBERS in date range const tierData = useMemo(() => { diff --git a/apps/stats/test/unit/components/growth/new-subscribers-cadence.test.tsx b/apps/stats/test/unit/components/growth/new-subscribers-cadence.test.tsx index cd03ff4a18c..03915d21d78 100644 --- a/apps/stats/test/unit/components/growth/new-subscribers-cadence.test.tsx +++ b/apps/stats/test/unit/components/growth/new-subscribers-cadence.test.tsx @@ -6,8 +6,7 @@ import {render, screen} from '@testing-library/react'; // Mock the API hooks vi.mock('@tryghost/admin-x-framework/api/stats', () => ({ - useSubscriptionStats: vi.fn(), - useMemberCountHistory: vi.fn() + useSubscriptionStats: vi.fn() })); vi.mock('@tryghost/admin-x-framework/api/tiers', () => ({ @@ -41,10 +40,9 @@ vi.mock('@tryghost/shade/utils', async () => { }); import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {useMemberCountHistory, useSubscriptionStats} from '@tryghost/admin-x-framework/api/stats'; +import {useSubscriptionStats} from '@tryghost/admin-x-framework/api/stats'; const mockedUseSubscriptionStats = useSubscriptionStats as ReturnType; -const mockedUseMemberCountHistory = useMemberCountHistory as ReturnType; const mockedUseBrowseTiers = useBrowseTiers as ReturnType; describe('NewSubscribersCadence Component', () => { @@ -57,21 +55,6 @@ describe('NewSubscribersCadence Component', () => { {id: 'tier-1', name: 'Premium', type: 'paid', active: true} ] }); - - // Default mock for member count history - no complimentary members - mockSuccess(mockedUseMemberCountHistory, { - stats: [ - {date: '2024-01-01', paid: 100, free: 50, comped: 0}, - {date: '2024-01-31', paid: 120, free: 60, comped: 0} - ], - meta: { - totals: { - paid: 120, - free: 60, - comped: 0 - } - } - }); }); it('renders with mixed monthly/yearly signups and calculates percentages correctly', () => { @@ -163,115 +146,6 @@ describe('NewSubscribersCadence Component', () => { expect(screen.getByText('40%')).toBeInTheDocument(); }); - it('includes complimentary member signups when comped count increases in date range', () => { - const mockSubscriptionData = { - stats: [ - {date: '2024-01-15', tier: 'tier-1', cadence: 'month', signups: 50, cancellations: 5, count: 100}, - {date: '2024-01-15', tier: 'tier-1', cadence: 'year', signups: 30, cancellations: 3, count: 50} - ], - meta: { - totals: [ - {tier: 'tier-1', cadence: 'month', count: 100}, - {tier: 'tier-1', cadence: 'year', count: 50} - ] - } - }; - - mockSuccess(mockedUseSubscriptionStats, mockSubscriptionData); - - // Mock complimentary member increase (comped went from 5 to 25 = 20 new comped) - mockSuccess(mockedUseMemberCountHistory, { - stats: [ - {date: '2024-01-01', paid: 80, free: 50, comped: 5}, - {date: '2024-01-31', paid: 100, free: 60, comped: 25} - ], - meta: { - totals: { - paid: 100, - free: 60, - comped: 25 - } - } - }); - - render(); - - // Should show all three: Monthly (50), Annual (30), Complimentary (20) = 100 total - expect(screen.getByText('Monthly')).toBeInTheDocument(); - expect(screen.getByText('Annual')).toBeInTheDocument(); - expect(screen.getByText('Complimentary')).toBeInTheDocument(); - - // Check percentages: 50/100 = 50%, 30/100 = 30%, 20/100 = 20% - expect(screen.getByText('50%')).toBeInTheDocument(); - expect(screen.getByText('30%')).toBeInTheDocument(); - expect(screen.getByText('20%')).toBeInTheDocument(); - }); - - it('folds gift signups into the matching cadence bucket', () => { - mockSuccess(mockedUseSubscriptionStats, { - stats: [ - // 30 paid monthly + 6 monthly gifts = 36 total monthly signups - {date: '2024-01-15', tier: 'tier-1', cadence: 'month', signups: 36, cancellations: 0, count: 100}, - // 10 paid yearly + 4 yearly gifts = 14 total yearly signups - {date: '2024-01-15', tier: 'tier-1', cadence: 'year', signups: 14, cancellations: 0, count: 50} - ], - meta: { - totals: [ - {tier: 'tier-1', cadence: 'month', count: 100}, - {tier: 'tier-1', cadence: 'year', count: 50} - ] - } - }); - - render(); - - expect(screen.getByText('Monthly')).toBeInTheDocument(); - expect(screen.getByText('Annual')).toBeInTheDocument(); - expect(screen.queryByText('Gift')).not.toBeInTheDocument(); - - // 36/50 = 72%, 14/50 = 28% - expect(screen.getByText('72%')).toBeInTheDocument(); - expect(screen.getByText('28%')).toBeInTheDocument(); - }); - - it('does not show complimentary when comped count does not increase', () => { - const mockSubscriptionData = { - stats: [ - {date: '2024-01-15', tier: 'tier-1', cadence: 'month', signups: 75, cancellations: 5, count: 100}, - {date: '2024-01-15', tier: 'tier-1', cadence: 'year', signups: 25, cancellations: 3, count: 50} - ], - meta: { - totals: [ - {tier: 'tier-1', cadence: 'month', count: 100}, - {tier: 'tier-1', cadence: 'year', count: 50} - ] - } - }; - - mockSuccess(mockedUseSubscriptionStats, mockSubscriptionData); - - // No complimentary member increase (comped stays the same) - mockSuccess(mockedUseMemberCountHistory, { - stats: [ - {date: '2024-01-01', paid: 100, free: 50, comped: 10}, - {date: '2024-01-31', paid: 120, free: 60, comped: 10} - ], - meta: { - totals: { - paid: 120, - free: 60, - comped: 10 - } - } - }); - - render(); - - expect(screen.getByText('Monthly')).toBeInTheDocument(); - expect(screen.getByText('Annual')).toBeInTheDocument(); - expect(screen.queryByText('Complimentary')).not.toBeInTheDocument(); - }); - it('hides breakdown type dropdown when only one tier is available', () => { // Single tier setup (from beforeEach default) const mockSubscriptionData = { diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 7ee53292ea3..d3b4e9400a3 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -7,15 +7,7 @@ export interface ExportedFile { content: string; } -export interface MembersListSurface { - goto(): Promise; - openActionsMenu(): Promise; - applyLabelFilter(labelName: string): Promise; - getVisibleMemberCount(): Promise; - exportMembers(): Promise; -} - -export class MembersListPage extends AdminPage implements MembersListSurface { +export class MembersListPage extends AdminPage { readonly memberRows: Locator; readonly searchInput: Locator; readonly actionsButton: Locator; diff --git a/e2e/helpers/pages/admin/members/members-page.ts b/e2e/helpers/pages/admin/members/members-page.ts index 3fbe40af0ed..0e725cf420a 100644 --- a/e2e/helpers/pages/admin/members/members-page.ts +++ b/e2e/helpers/pages/admin/members/members-page.ts @@ -1,151 +1,27 @@ import {AdminPage} from '@/admin-pages'; -import {BasePage} from '@/helpers/pages'; -import {Download, JSHandle, Locator, Page} from '@playwright/test'; -import {readFileSync} from 'fs'; -import type {ExportedFile, MembersListSurface} from './members-list-page'; +import {JSHandle, Locator, Page} from '@playwright/test'; -class FilterSection extends BasePage { - readonly actionsButton: Locator; - readonly applyFilterButton: Locator; - - readonly selectType: Locator; - readonly input: Locator; - - constructor(page: Page) { - super(page); - - this.actionsButton = page.getByTestId('members-filter-actions'); - this.applyFilterButton = page.getByTestId('members-apply-filter'); - this.selectType = page.getByTestId('members-filter'); - this.input = page.getByTestId('token-input-search'); - } - - async applyLabel(labelName: string): Promise { - await this.actionsButton.click(); - await this.selectType.selectOption('label'); - - await this.addLabelToLabelFilter(labelName); - - await this.applyFilterButton.click(); - } - - private async addLabelToLabelFilter(labelName: string) { - await this.input.fill(labelName); - await this.page.keyboard.press('Tab'); - } -} - -class SettingsSection extends BasePage { - readonly addLabelForSelectedMembersButton: Locator; - readonly removeLabelForSelectedMembersButton: Locator; - readonly selectLabel: Locator; - readonly confirmAddLabelButton: Locator; - readonly confirmRemoveLabelButton: Locator; - readonly closeModalButton: Locator; - - private readonly labelRemoved: Locator; - private readonly labelAdded: Locator; - - constructor(page: Page) { - super(page); - - this.addLabelForSelectedMembersButton = page.getByTestId('add-label-selected'); - this.removeLabelForSelectedMembersButton = page.getByTestId('remove-label-selected'); - - this.selectLabel = page.getByTestId('label-select'); - this.confirmAddLabelButton = page.getByTestId('confirm'); - this.confirmRemoveLabelButton = page.getByTestId('confirm'); - this.closeModalButton = page.getByTestId('close-modal'); - - this.labelAdded = page.getByTestId('add-label-complete'); - this.labelRemoved = page.getByTestId('remove-label-complete'); - } - - async addLabelToSelectedMembers(labelName: string): Promise { - await this.addLabelForSelectedMembersButton.click(); - await this.selectLabel.waitFor({state: 'visible'}); - await this.selectLabelOption(labelName); - - await this.confirmAddLabelButton.click(); - await this.labelAdded.waitFor({state: 'visible'}); - } - - async removeLabelFromSelectedMembers(labelName: string): Promise { - await this.removeLabelForSelectedMembersButton.click(); - await this.selectLabel.waitFor({state: 'visible'}); - await this.selectLabelOption(labelName); - - await this.confirmRemoveLabelButton.click(); - await this.labelRemoved.waitFor({state: 'visible'}); - } - - getSuccessMessage(): Locator { - return this.page.getByTestId('label-success-message'); - } - - private async selectLabelOption(labelName: string): Promise { - await this.selectLabel.waitFor({state: 'visible'}); - await this.selectLabel.click(); - - const dropdown = this.page.locator('.ember-power-select-dropdown').last(); - await dropdown.waitFor({state: 'visible'}); - - const searchInput = dropdown.locator('.ember-power-select-search input'); - await searchInput.waitFor({state: 'visible'}); - await searchInput.fill(labelName); - - const option = dropdown.getByRole('option', {name: labelName, exact: true}); - await option.waitFor({state: 'visible'}); - await option.click(); - } -} - -export class MembersPage extends AdminPage implements MembersListSurface { +export class MembersPage extends AdminPage { readonly newMemberButton: Locator; public readonly loadMoreButton: Locator; public readonly membersListScrollRoot: Locator; readonly memberListItems: Locator; - readonly emptyStateHeading: Locator; - - readonly membersActionsButton: Locator; - readonly exportMembersButton: Locator; - - readonly filterSection: FilterSection; - readonly settingsSection: SettingsSection; constructor(page: Page, {route = 'members'}: {route?: string} = {}) { super(page); this.pageUrl = `/ghost/#/${route}`; - this.membersActionsButton = page.getByTestId('members-actions'); this.newMemberButton = page.getByRole('link', {name: 'New member'}); - this.exportMembersButton = page.getByTestId('export-members'); this.loadMoreButton = page.getByRole('button', {name: 'Load more'}); this.membersListScrollRoot = page.getByTestId('members-list-scroll-root'); this.memberListItems = page.getByTestId('members-list-item'); - this.emptyStateHeading = page.getByRole('heading', {name: 'Start building your audience'}); - - this.filterSection = new FilterSection(page); - this.settingsSection = new SettingsSection(page); } async clickMemberByEmail(email: string): Promise { await this.memberListItems.filter({hasText: email}).click(); } - async openActionsMenu(): Promise { - await this.membersActionsButton.click(); - } - - async applyLabelFilter(labelName: string): Promise { - await this.filterSection.applyLabel(labelName); - } - - async getVisibleMemberCount(): Promise { - return await this.memberListItems.count(); - } - async getMaxRenderedIndex(): Promise { return await this.memberListItems.evaluateAll((rows) => { return rows.reduce((maxIndex, row) => { @@ -213,38 +89,7 @@ export class MembersPage extends AdminPage implements MembersListSurface { return maxRenderedIndex; } - getMemberListItemByIndex(index: number): Locator { - return this.page.locator(`[data-testid="members-list-item"][data-index="${index}"]`); - } - getMemberByName(name: string): Locator { return this.memberListItems.filter({hasText: name}); } - - getMemberEmail(memberName: string): Locator { - return this.memberListItems.filter({hasText: memberName}).getByRole('paragraph'); - } - - async getMemberCount(): Promise { - return await this.memberListItems.count(); - } - - async exportMembers(): Promise { - const download = await this.exportMembersData(); - const suggestedFilename = download.suggestedFilename(); - - const downloadPath = await download.path(); - const downloadContent = readFileSync(downloadPath as string, 'utf-8'); - - return { - suggestedFilename: suggestedFilename, - content: downloadContent - }; - } - - async exportMembersData(): Promise { - const downloadPromise = this.page.waitForEvent('download'); - await this.exportMembersButton.click(); - return await downloadPromise; - } } diff --git a/ghost/admin/app/components/gh-member-single-label-input.hbs b/ghost/admin/app/components/gh-member-single-label-input.hbs deleted file mode 100644 index fffbf012404..00000000000 --- a/ghost/admin/app/components/gh-member-single-label-input.hbs +++ /dev/null @@ -1,18 +0,0 @@ - - {{label.name}} - diff --git a/ghost/admin/app/components/gh-member-single-label-input.js b/ghost/admin/app/components/gh-member-single-label-input.js deleted file mode 100644 index 6b521678c13..00000000000 --- a/ghost/admin/app/components/gh-member-single-label-input.js +++ /dev/null @@ -1,98 +0,0 @@ -import Component from '@glimmer/component'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class GhMemberSingleLabelInput extends Component { - @service store; - @service labelsManager; - - @tracked _selectedLabel = null; - @tracked _searchedLabels = new TrackedArray(); - - _searchedLabelsQuery = null; - _searchedLabelsMeta = null; - - _powerSelectAPI = null; - - get availableLabels() { - return this.labelsManager.labels; - } - - get useServerSideSearch() { - return !this.labelsManager.hasLoadedAll; - } - - constructor(...args) { - super(...args); - this.loadInitialLabelsTask.perform(); - } - - @task - *loadInitialLabelsTask() { - if (!this.labelsManager.hasLoaded) { - yield this.labelsManager.loadMoreTask.perform(); - } - - const sorted = this.availableLabels; - if (this.args.label) { - const found = sorted.find(l => l.id === this.args.label); - if (found) { - this._selectedLabel = found; - } - } else { - this._selectedLabel = sorted[0]; - if (this._selectedLabel) { - this.args.onChange(this._selectedLabel.id); - } - } - } - - @action - registerPowerSelectAPI(api) { - this._powerSelectAPI = api; - } - - @task({drop: true}) - *loadMoreLabelsTask() { - const isSearch = !!this._powerSelectAPI?.searchText; - if (isSearch) { - if (!this.useServerSideSearch) { - return; - } - - if (this.searchLabelsTask.isRunning) { - return; - } - - if (!this._searchedLabelsMeta || (this._searchedLabelsMeta.pagination.pages <= this._searchedLabelsMeta.pagination.page)) { - return; - } - - const page = this._searchedLabelsMeta.pagination.page + 1; - const labels = yield this.labelsManager.searchLabelsTask.perform(this._searchedLabelsQuery, {page}); - this._searchedLabels.push(...labels.toArray()); - this._searchedLabelsMeta = labels.meta; - } else { - yield this.labelsManager.loadMoreTask.perform(); - } - } - - @task - *searchLabelsTask(term) { - this._searchedLabelsQuery = term; - const labels = yield this.labelsManager.searchLabelsTask.perform(term); - this._searchedLabelsMeta = labels.meta; - - this._searchedLabels = new TrackedArray(this.labelsManager.sortLabels(labels.toArray())); - return this._searchedLabels; - } - - @action - updateLabel(label) { - this._selectedLabel = label; - this.args.onChange(label?.id); - } -} diff --git a/ghost/admin/app/components/gh-members-import-mapping-input.hbs b/ghost/admin/app/components/gh-members-import-mapping-input.hbs deleted file mode 100644 index 2f064f44f5e..00000000000 --- a/ghost/admin/app/components/gh-members-import-mapping-input.hbs +++ /dev/null @@ -1,14 +0,0 @@ - - - {{svg-jar "arrow-down-small"}} - \ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-import-mapping-input.js b/ghost/admin/app/components/gh-members-import-mapping-input.js deleted file mode 100644 index 09a0d3f2f6e..00000000000 --- a/ghost/admin/app/components/gh-members-import-mapping-input.js +++ /dev/null @@ -1,39 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -const FIELD_MAPPINGS = [ - {label: 'Email', value: 'email'}, - {label: 'Name', value: 'name'}, - {label: 'Note', value: 'note'}, - {label: 'Subscribed to emails', value: 'subscribed_to_emails'}, - {label: 'Stripe Customer ID', value: 'stripe_customer_id'}, - {label: 'Complimentary plan', value: 'complimentary_plan'}, - {label: 'Labels', value: 'labels'}, - {label: 'Created at', value: 'created_at'} -]; - -export default class extends Component { - @service feature; - @tracked availableFields = [ - ...FIELD_MAPPINGS, - ...( - this.feature.importMemberTier ? [{label: 'Tier', value: 'import_tier'}] : [] - ), - ...( - this.feature.giftSubscriptions ? [{label: 'Gift ID', value: 'gift_id'}] : [] - ) - ]; - - get mapTo() { - return this.args.mapTo; - } - - @action - updateMapping(newMapTo) { - if (this.args.updateMapping) { - this.args.updateMapping(this.args.mapFrom, newMapTo); - } - } -} diff --git a/ghost/admin/app/components/gh-members-import-table.hbs b/ghost/admin/app/components/gh-members-import-table.hbs deleted file mode 100644 index b2cecde89d1..00000000000 --- a/ghost/admin/app/components/gh-members-import-table.hbs +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - {{#each this.currentlyDisplayedData as |row|}} - - - - - - {{else}} - - - - {{/each}} - -
Field - - Import as
{{row.key}}{{row.value}}
No data found in the uploaded CSV.
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-import-table.js b/ghost/admin/app/components/gh-members-import-table.js deleted file mode 100644 index 2e09496bf86..00000000000 --- a/ghost/admin/app/components/gh-members-import-table.js +++ /dev/null @@ -1,121 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {run} from '@ember/runloop'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -class MembersFieldMapping { - @tracked _mapping = {}; - - constructor(mapping) { - if (mapping) { - for (const [key, value] of Object.entries(mapping)) { - this._mapping[value] = key; - } - } - } - - get(key) { - return this._mapping[key]; - } - - toJSON() { - return this._mapping; - } - - getKeyByValue(searchedValue) { - for (const [key, value] of Object.entries(this._mapping)) { - if (value === searchedValue) { - return key; - } - } - - return null; - } - - updateMapping(from, to) { - for (const key in this._mapping) { - if (this.get(key) === to) { - this._mapping[key] = null; - } - } - - this._mapping[from] = to; - - // trigger an update - // eslint-disable-next-line no-self-assign - this._mapping = this._mapping; - } -} - -export default class GhMembersImportTable extends Component { - @tracked dataPreviewIndex = 0; - - @service memberImportValidator; - - constructor(...args) { - super(...args); - const mapping = this.memberImportValidator.check(this.args.data); - this.data = this.args.data; - this.mapping = new MembersFieldMapping(mapping); - run.schedule('afterRender', () => this.args.setMapping(this.mapping)); - } - - get currentlyDisplayedData() { - let rows = []; - - if (this.data && this.data.length && this.mapping) { - let currentRecord = this.data[this.dataPreviewIndex]; - - for (const [key, value] of Object.entries(currentRecord)) { - rows.push({ - key: key, - value: value, - mapTo: this.mapping.get(key) - }); - } - } - - return rows; - } - - get hasNextRecord() { - return this.data && !!(this.data[this.dataPreviewIndex + 1]); - } - - get hasPrevRecord() { - return this.data && !!(this.data[this.dataPreviewIndex - 1]); - } - - get currentRecord() { - return this.dataPreviewIndex + 1; - } - - get allRecords() { - if (this.data) { - return this.data; - } else { - return 0; - } - } - - @action - updateMapping(mapFrom, mapTo) { - this.mapping.updateMapping(mapFrom, mapTo); - this.args.setMapping(this.mapping); - } - - @action - next() { - if (this.hasNextRecord) { - this.dataPreviewIndex += 1; - } - } - - @action - prev() { - if (this.hasPrevRecord) { - this.dataPreviewIndex -= 1; - } - } -} diff --git a/ghost/admin/app/components/gh-members-no-members.hbs b/ghost/admin/app/components/gh-members-no-members.hbs deleted file mode 100644 index 3a98b4d3a58..00000000000 --- a/ghost/admin/app/components/gh-members-no-members.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
- {{svg-jar "members-placeholder" class="gh-members-placeholder"}} -

Start building your audience

- {{#if (not-eq this.settings.membersSignupAccess "none")}} -

Use memberships to allow your readers to sign up and subscribe to your content.

- -

Have members already? Add them manually or import from CSV

- {{else}} -

Memberships have been disabled. Adjust your Subscription Access settings to start adding members.

- - Membership settings - - {{/if}} -
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-no-members.js b/ghost/admin/app/components/gh-members-no-members.js deleted file mode 100644 index 42692f186ca..00000000000 --- a/ghost/admin/app/components/gh-members-no-members.js +++ /dev/null @@ -1,52 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default class GhMembersNoMembersComponent extends Component { - @service session; - @service store; - @service notifications; - @service settings; - @service membersCountCache; - - @action - addYourself() { - return this.addTask.perform(); - } - - @task({drop: true}) - *addTask() { - const user = yield this.session.user; - const defaultNewsletters = yield this.store.query('newsletter', {filter: 'status:active+subscribe_on_signup:true+visibility:members'}); - - const member = this.store.createRecord('member', { - email: user.get('email'), - name: user.get('name'), - newsletters: defaultNewsletters - }); - - try { - yield member.save(); - - if (this.args.afterCreate) { - this.args.afterCreate(); - } - - this.notifications.showNotification('Member added', - { - description: 'You\'ve added yourself as a member.' - } - ); - - // force update the member count; this otherwise only updates every minute - yield this.membersCountCache.count({}); - - return member; - } catch (error) { - if (error) { - this.notifications.showAPIError(error, {key: 'member.save'}); - } - } - } -} diff --git a/ghost/admin/app/components/members/filter-value.hbs b/ghost/admin/app/components/members/filter-value.hbs deleted file mode 100644 index e6b4358395e..00000000000 --- a/ghost/admin/app/components/members/filter-value.hbs +++ /dev/null @@ -1,144 +0,0 @@ -{{#if (eq @filter.type 'name')}} - - -{{else if (eq @filter.type 'email')}} - - -{{else if (eq @filter.type 'label')}} - - -{{else if (eq @filter.type 'tier_id')}} -
- -
- -{{else if (eq @filter.type 'offer_redemptions')}} -
- -
- -{{else if this.isResourceFilter }} -
- -
- -{{else if (eq @filter.valueType 'options')}} - - - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'email_count')}} - - -{{else if (eq @filter.type 'email_opened_count')}} - - -{{else if (eq @filter.type 'email_open_rate')}} -
- % - -
-{{else if (or (eq @filter.type 'last_seen_at') (eq @filter.type 'created_at'))}} - - -{{else if (eq @filter.valueType 'date')}} - - -{{else}} - -{{/if}} diff --git a/ghost/admin/app/components/members/filter-value.js b/ghost/admin/app/components/members/filter-value.js deleted file mode 100644 index 77177e1ad50..00000000000 --- a/ghost/admin/app/components/members/filter-value.js +++ /dev/null @@ -1,100 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {tracked} from '@glimmer/tracking'; - -export default class MembersFilterValue extends Component { - @tracked filterValue; - - constructor(...args) { - super(...args); - this.filterValue = this.args.filter.value; - } - - get tierFilterValue() { - if (this.args.filter?.type === 'tier_id') { - const tiers = Array.isArray(this.args.filter?.value) ? this.args.filter?.value : []; - return tiers.map((tier) => { - return { - id: tier - }; - }); - } - return []; - } - - get offersFilterValue() { - if (this.args.filter?.type === 'offer_redemptions') { - const offers = Array.isArray(this.args.filter?.value) ? this.args.filter?.value : []; - return offers.map((offer) => { - return { - id: offer - }; - }); - } - return []; - } - - @action - setInputFilterValue(filter, event) { - this.filterValue = event.target.value; - } - - @action - updateInputFilterValue(filter, event) { - if (event.type === 'blur') { - this.filterValue = event.target.value; - } - this.args.setFilterValue(filter, this.filterValue); - } - - @action - updateInputFilterValueOnEnter(filter, event) { - if (event.key === 'Enter') { - event.preventDefault(); - this.args.setFilterValue(filter, this.filterValue); - } - } - - @action - setLabelsFilterValue(filter, labels) { - this.args.setFilterValue(filter, labels.map(label => label.slug)); - } - - @action - setTiersFilterValue(filter, tiers) { - this.args.setFilterValue(filter, tiers.map(tier => tier.id)); - } - - @action - setOffersFilterValue(filter, offers) { - this.args.setFilterValue(filter, offers.map(offer => offer.id)); - } - - get isResourceFilter() { - return !!this.args.filter?.isResourceFilter; - } - - get resourceFilterType() { - if (!this.isResourceFilter) { - return ''; - } - - return this.args.filter?.properties?.resource ?? ''; - } - - get resourceFilterValue() { - if (!this.isResourceFilter) { - return {}; - } - const resource = this.args.filter?.resource || undefined; - const resourceId = this.args.filter?.value || undefined; - return resource ?? { - id: resourceId - }; - } - - @action - setResourceFilterValue(filter, resource) { - this.args.setResourceValue(filter, resource); - } -} diff --git a/ghost/admin/app/components/members/filter.hbs b/ghost/admin/app/components/members/filter.hbs deleted file mode 100644 index 7cec3c890a9..00000000000 --- a/ghost/admin/app/components/members/filter.hbs +++ /dev/null @@ -1,108 +0,0 @@ - - - - - {{svg-jar "filter"}} - Filter - {{#if @isFiltered}} - ({{this.totalFilters}}) - {{/if}} - - - - - - - - diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js deleted file mode 100644 index d7f075cd2fc..00000000000 --- a/ghost/admin/app/components/members/filter.js +++ /dev/null @@ -1,675 +0,0 @@ -import Component from '@glimmer/component'; -import moment from 'moment-timezone'; -import nql from '@tryghost/nql-lang'; -import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTERS, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {didCancel, task} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -function escapeNqlString(value) { - return '\'' + value.replace(/'/g, '\\\'') + '\''; -} - -const FILTER_GROUPS = [ - { - name: 'Basic', - filters: [ - NAME_FILTER, - EMAIL_FILTER, - LABEL_FILTER, - SUBSCRIBED_FILTER, - LAST_SEEN_FILTER, - CREATED_AT_FILTER, - SIGNUP_ATTRIBUTION_FILTER - ] - }, - { - name: 'Newsletters', - filters: [ - NEWSLETTERS_FILTERS - ] - }, - { - name: 'Subscription', - filters: [ - TIER_FILTER, - STATUS_FILTER, - PLAN_INTERVAL_FILTER, - SUBSCRIPTION_STATUS_FILTER, - SUBSCRIPTION_START_DATE_FILTER, - NEXT_BILLING_DATE_FILTER, - SUBSCRIPTION_ATTRIBUTION_FILTER - ] - }, - { - name: 'Email', - filters: [ - EMAIL_COUNT_FILTER, - EMAIL_OPENED_COUNT_FILTER, - EMAIL_OPEN_RATE_FILTER, - EMAIL_SENT_FILTER, - EMAIL_OPENED_FILTER, - EMAIL_CLICKED_FILTER, - AUDIENCE_FEEDBACK_FILTER - ] - } -]; - -const FILTER_PROPERTIES = FILTER_GROUPS.flatMap(group => group.filters.map((f) => { - if (typeof f === 'function') { - return (options) => { - return f({ - ...options, - group: group.name - }); - }; - } - - f.group = group.name; - return f; -})); - -class Filter { - @tracked value; - @tracked relation; - @tracked properties; - @tracked resource; - - constructor(options) { - this.properties = options.properties; - this.timezone = options.timezone ?? 'Etc/UTC'; - - let defaultRelation = options.properties.relationOptions[0].name; - if (options.properties.valueType === 'date') { - defaultRelation = 'is-or-less'; - } - - let defaultValue = ''; - if (options.properties.valueType === 'options' && options.properties.options.length > 0) { - defaultValue = options.properties.options[0].name; - } else if (options.properties.valueType === 'array') { - defaultValue = []; - } else if (options.properties.valueType === 'date') { - defaultValue = moment(moment.tz(this.timezone).format('YYYY-MM-DD')).toDate(); - } - - this.relation = options.relation ?? defaultRelation; - - // date string values are passed in as UTC strings - // we need to convert them to the site timezone and make a local date that matches - // so the date string output in the filter inputs is correct - this.value = options.value ?? defaultValue; - - if (this.properties.valueType === 'date' && typeof this.value === 'string') { - // Convert string to Date - this.value = moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate(); - } - - // Validate value - if (options.properties.valueType === 'options') { - if (!options.properties.options.find(option => option.name === this.value)) { - this.value = defaultValue; - } - } - - this.resource = null; - } - - get valueType() { - return this.properties.valueType; - } - - get type() { - return this.properties.name; - } - - get isResourceFilter() { - return typeof this.properties.resource === 'string' && this.properties.valueType === 'string'; - } - - get relationOptions() { - return this.properties.relationOptions; - } - - get options() { - return this.properties.options ?? []; - } - - get group() { - return this.properties.group; - } - - get isValid() { - if (Array.isArray(this.value)) { - return !!this.value.length; - } - return !!this.value; - } -} - -export default class MembersFilter extends Component { - @service feature; - @service session; - @service settings; - @service store; - @service membersUtils; - - @tracked filters = new TrackedArray([ - new Filter({ - properties: NAME_FILTER - }) - ]); - - newsletters; - tiersList; - offers; - - @tracked isLoading = false; - - get filterProperties() { - // Ensure we have all required data before proceeding - if (!this.newsletters || !this.tiersList || !this.offers) { - return []; - } - - let availableFilters = FILTER_PROPERTIES; - - // Convert the method filters to properties - availableFilters = availableFilters.flatMap((filter) => { - if (typeof filter === 'function') { - const filters = filter({ - newsletters: this.newsletters ?? [], - feature: this.feature - }); - if (Array.isArray(filters)) { - return filters; - } - return [filters]; - } - return [filter]; - }); - - // only add the offers filter if there are any offers - if (this.offers.length > 0) { - availableFilters = availableFilters.concat(OFFERS_FILTER); - } - - // exclude any filters that are behind disabled feature flags - availableFilters = availableFilters.filter(prop => !prop.feature || this.feature[prop.feature]); - availableFilters = availableFilters.filter(prop => !prop.setting || this.settings[prop.setting]); - - return availableFilters; - } - - get availableFilterProperties() { - let availableFilters = this.filterProperties; - const hasMultipleTiers = this.membersUtils.hasMultipleTiers; - - // exclude tiers filter if site has only single tier - availableFilters = availableFilters - .filter((filter) => { - return filter.name === 'tier_id' ? hasMultipleTiers : true; - }); - - // exclude subscription filters if Stripe isn't connected - if (!this.settings.paidMembersEnabled) { - availableFilters = availableFilters.reject(prop => prop.group === 'Subscription'); - } - - // exclude email filters if email functionality is disabled - if (this.settings.editorDefaultEmailRecipients === 'disabled') { - availableFilters = availableFilters.reject(prop => prop.group === 'Email'); - } - - return availableFilters; - } - - get totalFilters() { - return this.filters?.length; - } - - constructor(...args) { - super(...args); - this.parseDefaultFilters(); - } - - /** - * This method is not super clean as it uses did-update, but for now this is required to make URL changes work - * properly. - * Problem: filter parameter is changed in the members controller by modifying the URL directly - * -> the filters property is not updated in the members controller because the new parameter is not parsed again - * -> we need to listen for changes in the property and parse it again - * -> better future proof solution: move the filter parsing logic elsewhere so it can be parsed in the members controller - */ - @action - async parseDefaultFilters() { - // we need to make sure all the filters are loaded before parsing the default filter - // otherwise the filter will be parsed with the wrong properties - try { - this.isLoading = true; - - await this.fetchTiers.perform(); - await this.fetchNewsletters.perform(); - await this.fetchOffers.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } finally { - this.isLoading = false; - } - - if (this.args.defaultFilterParam) { - // check if it is different before parsing - const validFilters = this.validFilters; - const currentFilter = this.generateNqlFilter(validFilters); - - if (currentFilter !== this.args.defaultFilterParam) { - this.parseNqlFilterString(this.args.defaultFilterParam); - - // Pass the parsed filter to the parent component - // this doesn't start a new network request, and doesn't update filterParam again - this.applyParsedFilter(); - } - } - } - - @action - addFilter() { - this.filters.push(new Filter({ - properties: NAME_FILTER - })); - this.applySoftFilter(); - } - - @action - onDropdownClose() { - this.applyFilter(); - } - - generateNqlFilter(filters) { - const nqlDateFormat = 'YYYY-MM-DD HH:mm:ss'; - - let query = ''; - filters.forEach((filter) => { - const filterProperty = this.filterProperties.find(prop => prop.name === filter.type); - if (filterProperty.buildNqlFilter) { - query += `${filterProperty.buildNqlFilter(filter)}+`; - return; - } - const relationStr = this.getFilterRelationOperator(filter.relation); - - if (filterProperty.valueType === 'array' && filter.value?.length) { - const filterValue = '[' + filter.value.join(',') + ']'; - query += `${filter.type}:${relationStr}${filterValue}+`; - } else if (filterProperty.valueType === 'string') { - let filterValue = escapeNqlString(filter.value); - query += `${filter.type}:${relationStr}${filterValue}+`; - } else if (filterProperty.valueType === 'date') { - let filterValue; - - let tzMoment = moment.tz(moment(filter.value).format('YYYY-MM-DD'), this.settings.timezone); - - if (relationStr === '>') { - tzMoment = tzMoment.set({hour: 23, minute: 59, second: 59}); - } - if (relationStr === '>=') { - tzMoment = tzMoment.set({hour: 0, minute: 0, second: 0}); - } - if (relationStr === '<') { - tzMoment = tzMoment.set({hour: 0, minute: 0, second: 0}); - } - if (relationStr === '<=') { - tzMoment = tzMoment.set({hour: 23, minute: 59, second: 59}); - } - - filterValue = `'${tzMoment.utc().format(nqlDateFormat)}'`; - query += `${filter.type}:${relationStr}${filterValue}+`; - } else { - const filterValue = (typeof filter.value === 'string' && filter.value.includes(' ')) ? `'${filter.value}'` : filter.value; - query += `${filter.type}:${relationStr}${filterValue}+`; - } - }); - return query.slice(0, -1); - } - - parseNqlFilterString(filterParam) { - let filters; - try { - filters = nql.parse(filterParam); - } catch (e) { - // Invalid nql filter - this.filters = new TrackedArray([]); - return; - } - this.filters = new TrackedArray(this.parseNqlFilter(filters)); - } - - parseNqlFilter(filter) { - const parsedFilters = []; - for (const filterProperties of this.filterProperties) { - if (filterProperties.parseNqlFilter) { - // This filter has a custom parsing function - const parsedFilter = filterProperties.parseNqlFilter(filter); - if (parsedFilter) { - parsedFilters.push(new Filter({ - properties: filterProperties, - timezone: this.settings.timezone, - ...parsedFilter - })); - return parsedFilters; - } - } - } - - if (filter.$and) { - parsedFilters.push(...this.parseNqlFilters(filter.$and)); - } else { - const filterKeys = Object.keys(filter); - const validKeys = this.filterProperties.map(prop => prop.name); - - for (const key of filterKeys) { - if (validKeys.includes(key)) { - const parsedFilter = this.parseNqlFilterKey({ - [key]: filter[key] - }); - if (parsedFilter) { - parsedFilters.push(parsedFilter); - } - } - } - } - return parsedFilters; - } - - /** - * Parses an array of filters - */ - parseNqlFilters(filters) { - const parsedFilters = []; - - for (const filter of filters) { - parsedFilters.push(...this.parseNqlFilter(filter)); - } - - return parsedFilters; - } - - parseNqlFilterKey(nqlFilter) { - const keys = Object.keys(nqlFilter); - const key = keys[0]; - const nqlValue = nqlFilter[key]; - - const filterProperty = this.filterProperties.find(prop => prop.name === key); - - let relation; - let value; - - if (typeof nqlValue === 'object') { - if (nqlValue.$in !== undefined && filterProperty.valueType === 'array') { - relation = 'is'; - value = nqlValue.$in; - } - - if (nqlValue.$nin !== undefined && filterProperty.valueType === 'array') { - relation = 'is-not'; - value = nqlValue.$nin; - } - - if (nqlValue.$ne !== undefined) { - relation = 'is-not'; - value = nqlValue.$ne; - } - - if (nqlValue.$gt !== undefined) { - relation = 'is-greater'; - value = nqlValue.$gt; - } - - if (nqlValue.$gte !== undefined) { - relation = 'is-or-greater'; - value = nqlValue.$gte; - } - - if (nqlValue.$lt !== undefined) { - relation = 'is-less'; - value = nqlValue.$lt; - } - - if (nqlValue.$lte !== undefined) { - relation = 'is-or-less'; - value = nqlValue.$lte; - } - - if (nqlValue.$regex !== undefined) { - const source = nqlValue.$regex.source; - - if (source.indexOf('^') === 0) { - relation = 'starts-with'; - value = source.substring(1); - } else if (source.indexOf('$') === source.length - 1) { - relation = 'ends-with'; - value = source.slice(0, -1); - } else { - relation = 'contains'; - value = source; - } - - value = value.replace(/\\/g, ''); - } - - if (nqlValue.$not !== undefined) { - relation = 'does-not-contain'; - value = nqlValue.$not.source; - - value = value.replace(/\\/g, ''); - } - } else { - relation = 'is'; - value = nqlValue; - } - - if (typeof value === 'boolean' || typeof value === 'number') { - // Transform it to a string, to keep it compatible with the internally used value in admin - // + make sure false and 0 are truthy - value = value.toString(); - } - - if (relation && value) { - const properties = this.filterProperties.find(prop => key === prop.name); - if (this.filterProperties.find(prop => key === prop.name)) { - return new Filter({ - properties, - relation, - value, - timezone: this.settings.timezone - }); - } - } - } - - getFilterRelationOperator(relation) { - // TODO: unify operator naming with NQL - const relationMap = { - 'is-less': '<', - 'is-or-less': '<=', - is: '', - 'is-not': '-', - 'is-greater': '>', - 'is-or-greater': '>=', - contains: '~', - 'does-not-contain': '-~', - 'starts-with': '~^', - 'ends-with': '~$' - }; - - return relationMap[relation] || ''; - } - - @action - handleSubmitKeyup(e) { - e.preventDefault(); - - if (e.key === 'Enter') { - this.applyFilter(); - } - } - - @action - deleteFilter(filter, event) { - event.stopPropagation(); - event.preventDefault(); - - if (this.filters.length === 1) { - this.resetFilter(); - } else { - this.filters = new TrackedArray(this.filters.reject(f => f === filter)); - this.applySoftFilter(); - } - } - - @action - setFilterType(filter, newType) { - if (newType instanceof Event) { - newType = newType.target.value; - } - - const newProp = this.filterProperties.find(prop => prop.name === newType); - - if (!newProp) { - // eslint-disable-next-line no-console - console.warn('Invalid Filter Type Selected', newType); - return; - } - - const newFilter = new Filter({ - properties: newProp, - timezone: this.settings.timezone - }); - - const filterToSwap = this.filters.find(f => f === filter); - this.filters[this.filters.indexOf(filterToSwap)] = newFilter; - - if (newFilter.isValid) { - this.applySoftFilter(); - } - } - - @action - setFilterRelation(filter, newRelation) { - filter.relation = newRelation; - this.applySoftFilter(); - } - - @action - setFilterValue(filter, newValue) { - filter.value = newValue; - filter.resource = null; - this.applySoftFilter(); - } - - @action - setResourceValue(filter, resource) { - filter.value = resource.id; - filter.resource = resource; - this.applySoftFilter(); - } - - get validFilters() { - return this.filters.filter(filter => filter.isValid); - } - - @action - applySoftFilter() { - const validFilters = this.validFilters; - const query = this.generateNqlFilter(validFilters); - this.args.onApplySoftFilter(query, validFilters); - this.fetchFilterResourcesTask.perform(); - } - - @action - applyFilter() { - const validFilters = this.validFilters; - const query = this.generateNqlFilter(validFilters); - this.args.onApplyFilter(query, validFilters); - this.fetchFilterResourcesTask.perform(); - } - - @action - applyFiltersPressed(dropdown) { - dropdown?.actions.close(); - this.applyFilter(); - } - - @action - applyParsedFilter() { - const validFilters = this.validFilters; - this.args.onApplyParsedFilter(validFilters); - this.fetchFilterResourcesTask.perform(); - } - - @action - resetFilter() { - const filters = []; - - filters.push(new Filter({ - properties: NAME_FILTER - })); - - this.filters = new TrackedArray(filters); - this.args.onResetFilter(); - } - - @task({drop: true}) - *fetchTiers() { - const response = yield this.store.query('tier', {filter: 'type:paid'}); - this.tiersList = response; - } - - @task({drop: true}) - *fetchNewsletters() { - const response = yield this.store.query('newsletter', {filter: 'status:active'}); - this.newsletters = response; - return response; - } - - @task({drop: true}) - *fetchOffers() { - const response = yield this.store.findAll('offer'); - this.offers = response; - return response; - } - - @task({restartable: true}) - *fetchFilterResourcesTask() { - const ids = []; - for (const filter of this.filters) { - if (filter.isResourceFilter) { - // for now we only support post filters - if (filter.value && !ids.includes(filter.value)) { - ids.push(filter.value); - } - } - } - if (ids.length > 0) { - const posts = yield this.store.query('post', {limit: 'all', filter: `id:[${ids.join(',')}]`}); - - for (const filter of this.filters) { - if (filter.isResourceFilter) { - // for now we only support post filters - if (filter.value) { - const post = posts.find(p => p.id === filter.value); - if (post) { - filter.resource = post; - } - } - } - } - } - } -} diff --git a/ghost/admin/app/components/members/filters/audience-feedback.js b/ghost/admin/app/components/members/filters/audience-feedback.js deleted file mode 100644 index 2adb962eb5c..00000000000 --- a/ghost/admin/app/components/members/filters/audience-feedback.js +++ /dev/null @@ -1,50 +0,0 @@ -const FEEDBACK_RELATION_OPTIONS = [ - {label: 'More like this', name: 1}, - {label: 'Less like this', name: 0} -]; - -export const AUDIENCE_FEEDBACK_FILTER = { - label: 'Responded with feedback', - name: 'newsletter_feedback', - valueType: 'string', - resource: 'email', - relationOptions: FEEDBACK_RELATION_OPTIONS, - buildNqlFilter: (filter) => { - // Added brackets to make sure we can parse as a single AND filter - return `(feedback.post_id:'${filter.value}'+feedback.score:${filter.relation})`; - }, - parseNqlFilter: (filter) => { - if (!filter.$and) { - return; - } - if (filter.$and.length === 2) { - if (filter.$and[0]['feedback.post_id'] && filter.$and[1]['feedback.score'] !== undefined) { - return { - relation: parseInt(filter.$and[1]['feedback.score']), - value: filter.$and[0]['feedback.post_id'] - }; - } - } - }, - getColumns: filter => [ - { - label: 'Email', - getValue: () => { - return { - class: '', - text: filter.resource?.title ?? '' - }; - } - }, - { - label: 'Feedback', - getValue: () => { - return { - class: 'gh-members-list-feedback', - text: filter.relation === 1 ? 'More like this' : 'Less like this', - icon: filter.relation === 1 ? 'event-more-like-this' : 'event-less-like-this' - }; - } - } - ] -}; diff --git a/ghost/admin/app/components/members/filters/columns/date-column.js b/ghost/admin/app/components/members/filters/columns/date-column.js deleted file mode 100644 index 62bdcb9ef3a..00000000000 --- a/ghost/admin/app/components/members/filters/columns/date-column.js +++ /dev/null @@ -1,13 +0,0 @@ -import moment from 'moment-timezone'; - -export function getDateColumnValue(date, filter) { - if (!date) { - return null; - } - return { - class: '', - text: date ? moment.tz(date, filter.timezone).format('DD MMM YYYY') : '', - subtext: moment(date).from(moment()), - subtextClass: 'gh-members-list-subscribed-moment' - }; -} diff --git a/ghost/admin/app/components/members/filters/created-at.js b/ghost/admin/app/components/members/filters/created-at.js deleted file mode 100644 index f8577711157..00000000000 --- a/ghost/admin/app/components/members/filters/created-at.js +++ /dev/null @@ -1,8 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; - -export const CREATED_AT_FILTER = { - label: 'Created', - name: 'created_at', - valueType: 'date', - relationOptions: DATE_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/email-clicked.js b/ghost/admin/app/components/members/filters/email-clicked.js deleted file mode 100644 index c78dae0c410..00000000000 --- a/ghost/admin/app/components/members/filters/email-clicked.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_CLICKED_FILTER = { - label: 'Clicked email', - name: 'clicked_links.post_id', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Clicked email', - setting: 'emailTrackClicks', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-count.js b/ghost/admin/app/components/members/filters/email-count.js deleted file mode 100644 index 03a0a4242f7..00000000000 --- a/ghost/admin/app/components/members/filters/email-count.js +++ /dev/null @@ -1,15 +0,0 @@ -import {NUMBER_RELATION_OPTIONS} from './relation-options'; -import {formatNumber} from 'ghost-admin/helpers/format-number'; - -export const EMAIL_COUNT_FILTER = { - label: 'Emails sent (all time)', - name: 'email_count', - columnLabel: 'Email count', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - text: formatNumber(member.emailCount) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-open-rate.js b/ghost/admin/app/components/members/filters/email-open-rate.js deleted file mode 100644 index 6361239b057..00000000000 --- a/ghost/admin/app/components/members/filters/email-open-rate.js +++ /dev/null @@ -1,9 +0,0 @@ -import {NUMBER_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_OPEN_RATE_FILTER = { - label: 'Open rate (all time)', - name: 'email_open_rate', - valueType: 'number', - setting: 'emailTrackOpens', - relationOptions: NUMBER_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/email-opened-count.js b/ghost/admin/app/components/members/filters/email-opened-count.js deleted file mode 100644 index dece4042add..00000000000 --- a/ghost/admin/app/components/members/filters/email-opened-count.js +++ /dev/null @@ -1,15 +0,0 @@ -import {NUMBER_RELATION_OPTIONS} from './relation-options'; -import {formatNumber} from 'ghost-admin/helpers/format-number'; - -export const EMAIL_OPENED_COUNT_FILTER = { - label: 'Emails opened (all time)', - name: 'email_opened_count', - columnLabel: 'Email opened count', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - text: formatNumber(member.emailOpenedCount) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-opened.js b/ghost/admin/app/components/members/filters/email-opened.js deleted file mode 100644 index d9535523aa1..00000000000 --- a/ghost/admin/app/components/members/filters/email-opened.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_OPENED_FILTER = { - label: 'Opened email', - name: 'opened_emails.post_id', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Opened email', - setting: 'emailTrackOpens', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-sent.js b/ghost/admin/app/components/members/filters/email-sent.js deleted file mode 100644 index 45fdbb4d6e0..00000000000 --- a/ghost/admin/app/components/members/filters/email-sent.js +++ /dev/null @@ -1,15 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_SENT_FILTER = { - label: 'Sent email', - name: 'emails.post_id', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Sent email', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email.js b/ghost/admin/app/components/members/filters/email.js deleted file mode 100644 index 1aa094a3953..00000000000 --- a/ghost/admin/app/components/members/filters/email.js +++ /dev/null @@ -1,8 +0,0 @@ -import {CONTAINS_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_FILTER = { - label: 'Email', - name: 'email', - valueType: 'string', - relationOptions: CONTAINS_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/index.js b/ghost/admin/app/components/members/filters/index.js deleted file mode 100644 index 6b10fa69ee0..00000000000 --- a/ghost/admin/app/components/members/filters/index.js +++ /dev/null @@ -1,23 +0,0 @@ -export * from './name'; -export * from './email'; -export * from './label'; -export * from './subscribed'; -export * from './last-seen'; -export * from './created-at'; -export * from './signup-attribution'; -export * from './tier'; -export * from './status'; -export * from './plan-interval'; -export * from './subscription-status'; -export * from './subscription-start-date'; -export * from './next-billing-date'; -export * from './subscription-attribution'; -export * from './email-count'; -export * from './email-opened'; -export * from './email-clicked'; -export * from './email-opened-count'; -export * from './email-open-rate'; -export * from './email-clicked'; -export * from './email-sent'; -export * from './audience-feedback'; -export * from './offers'; diff --git a/ghost/admin/app/components/members/filters/label.js b/ghost/admin/app/components/members/filters/label.js deleted file mode 100644 index 857845a2dbe..00000000000 --- a/ghost/admin/app/components/members/filters/label.js +++ /dev/null @@ -1,15 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const LABEL_FILTER = { - label: 'Label', - name: 'label', - valueType: 'array', - columnLabel: 'Label', - relationOptions: MATCH_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - class: 'gh-members-list-labels', - text: (member.labels ?? []).map(label => label.name).join(', ') - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/last-seen.js b/ghost/admin/app/components/members/filters/last-seen.js deleted file mode 100644 index d0667dbda3f..00000000000 --- a/ghost/admin/app/components/members/filters/last-seen.js +++ /dev/null @@ -1,13 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; -import {getDateColumnValue} from './columns/date-column'; - -export const LAST_SEEN_FILTER = { - label: 'Last seen', - name: 'last_seen_at', - valueType: 'date', - columnLabel: 'Last seen at', - relationOptions: DATE_RELATION_OPTIONS, - getColumnValue: (member, filter) => { - return getDateColumnValue(member.lastSeenAtUTC, filter); - } -}; diff --git a/ghost/admin/app/components/members/filters/name.js b/ghost/admin/app/components/members/filters/name.js deleted file mode 100644 index c5571d55679..00000000000 --- a/ghost/admin/app/components/members/filters/name.js +++ /dev/null @@ -1,8 +0,0 @@ -import {CONTAINS_RELATION_OPTIONS} from './relation-options'; - -export const NAME_FILTER = { - label: 'Name', - name: 'name', - valueType: 'string', - relationOptions: CONTAINS_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/next-billing-date.js b/ghost/admin/app/components/members/filters/next-billing-date.js deleted file mode 100644 index 621d8393652..00000000000 --- a/ghost/admin/app/components/members/filters/next-billing-date.js +++ /dev/null @@ -1,15 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; -import {getDateColumnValue} from './columns/date-column'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const NEXT_BILLING_DATE_FILTER = { - label: 'Next billing date', - name: 'subscriptions.current_period_end', - valueType: 'date', - columnLabel: 'Next billing date', - relationOptions: DATE_RELATION_OPTIONS, - getColumnValue: (member, filter) => { - const subscription = mostRelevantSubscription(member.subscriptions); - return getDateColumnValue(subscription?.current_period_end, filter); - } -}; diff --git a/ghost/admin/app/components/members/filters/offers.js b/ghost/admin/app/components/members/filters/offers.js deleted file mode 100644 index be734dc9a65..00000000000 --- a/ghost/admin/app/components/members/filters/offers.js +++ /dev/null @@ -1,36 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -const getOfferNameForColumn = (offer) => { - if (!offer) { - return null; - } - - if (offer.redemption_type === 'retention') { - if (offer.cadence === 'month') { - return 'Monthly Retention'; - } - - if (offer.cadence === 'year') { - return 'Yearly Retention'; - } - } - - return offer.name; -}; - -export const OFFERS_FILTER = { - label: 'Offers', - name: 'offer_redemptions', - group: 'Subscription', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'array', - columnLabel: 'Offers redeemed', - getColumnValue: (member) => { - return { - class: 'gh-members-list-labels', - text: (member.subscriptions ?? []) - .flatMap(sub => (sub.offer_redemptions ?? []).map(getOfferNameForColumn).filter(Boolean)) - .join(', ') - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/plan-interval.js b/ghost/admin/app/components/members/filters/plan-interval.js deleted file mode 100644 index 79c3cc18992..00000000000 --- a/ghost/admin/app/components/members/filters/plan-interval.js +++ /dev/null @@ -1,24 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; -import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const PLAN_INTERVAL_FILTER = { - label: 'Billing period', - name: 'subscriptions.plan_interval', - columnLabel: 'Billing period', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Monthly', name: 'month'}, - {label: 'Yearly', name: 'year'} - ], - getColumnValue: (member) => { - const subscription = mostRelevantSubscription(member.subscriptions); - if (!subscription) { - return null; - } - return { - text: capitalizeFirstLetter(subscription.price?.interval) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/relation-options/contains.js b/ghost/admin/app/components/members/filters/relation-options/contains.js deleted file mode 100644 index 62daf9329bc..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/contains.js +++ /dev/null @@ -1,7 +0,0 @@ -export const CONTAINS_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'contains', name: 'contains'}, - {label: 'does not contain', name: 'does-not-contain'}, - {label: 'starts with', name: 'starts-with'}, - {label: 'ends with', name: 'ends-with'} -]; diff --git a/ghost/admin/app/components/members/filters/relation-options/date.js b/ghost/admin/app/components/members/filters/relation-options/date.js deleted file mode 100644 index 88725e3686a..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/date.js +++ /dev/null @@ -1,6 +0,0 @@ -export const DATE_RELATION_OPTIONS = [ - {label: 'before', name: 'is-less'}, - {label: 'on or before', name: 'is-or-less'}, - {label: 'after', name: 'is-greater'}, - {label: 'on or after', name: 'is-or-greater'} -]; diff --git a/ghost/admin/app/components/members/filters/relation-options/index.js b/ghost/admin/app/components/members/filters/relation-options/index.js deleted file mode 100644 index a4357d6a83e..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export * from './contains'; -export * from './match'; -export * from './date'; -export * from './number'; diff --git a/ghost/admin/app/components/members/filters/relation-options/match.js b/ghost/admin/app/components/members/filters/relation-options/match.js deleted file mode 100644 index a907a9ac05b..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/match.js +++ /dev/null @@ -1,4 +0,0 @@ -export const MATCH_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'is not', name: 'is-not'} -]; diff --git a/ghost/admin/app/components/members/filters/relation-options/number.js b/ghost/admin/app/components/members/filters/relation-options/number.js deleted file mode 100644 index b4588892ea7..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/number.js +++ /dev/null @@ -1,5 +0,0 @@ -export const NUMBER_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'is greater than', name: 'is-greater'}, - {label: 'is less than', name: 'is-less'} -]; diff --git a/ghost/admin/app/components/members/filters/signup-attribution.js b/ghost/admin/app/components/members/filters/signup-attribution.js deleted file mode 100644 index 6bcf2b27626..00000000000 --- a/ghost/admin/app/components/members/filters/signup-attribution.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const SIGNUP_ATTRIBUTION_FILTER = { - label: 'Signed up on post/page', - name: 'signup', - valueType: 'string', - resource: 'post', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Signed up on', - setting: 'membersTrackSources', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/status.js b/ghost/admin/app/components/members/filters/status.js deleted file mode 100644 index 14696a5029b..00000000000 --- a/ghost/admin/app/components/members/filters/status.js +++ /dev/null @@ -1,22 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const STATUS_FILTER = ({feature, group}) => { - const options = [ - {label: 'Paid', name: 'paid'}, - {label: 'Free', name: 'free'}, - {label: 'Complimentary', name: 'comped'} - ]; - - if (feature.giftSubscriptions) { - options.push({label: 'Gift', name: 'gift'}); - } - - return { - label: 'Member status', - name: 'status', - group, - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options - }; -}; diff --git a/ghost/admin/app/components/members/filters/subscribed.js b/ghost/admin/app/components/members/filters/subscribed.js deleted file mode 100644 index 9a4d143e841..00000000000 --- a/ghost/admin/app/components/members/filters/subscribed.js +++ /dev/null @@ -1,168 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const SUBSCRIBED_FILTER = ({newsletters, group}) => { - return { - label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription', - name: 'subscribed', - columnLabel: 'Subscribed', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - group: newsletters.length > 1 ? 'Newsletters' : group, - buildNqlFilter: (flt) => { - const relation = flt.relation; - const value = flt.value; - - if (value === 'email-disabled') { - if (relation === 'is') { - return '(email_disabled:1)'; - } - return '(email_disabled:0)'; - } - - if (relation === 'is') { - if (value === 'subscribed') { - return '(subscribed:true+email_disabled:0)'; - } - return '(subscribed:false+email_disabled:0)'; - } - - // relation === 'is-not' - if (value === 'subscribed') { - return '(subscribed:false,email_disabled:1)'; - } - return '(subscribed:true,email_disabled:1)'; - }, - parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility - - if (!comparator || comparator.length !== 2) { - const filter = flt; - if (filter && filter.email_disabled !== undefined) { - if (filter.email_disabled) { - return { - value: 'email-disabled', - relation: 'is' - }; - } - return { - value: 'email-disabled', - relation: 'is-not' - }; - } - return; - } - - if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) { - return; - } - - const usedOr = flt.$or !== undefined; - const subscribed = comparator[0].subscribed; - - if (usedOr) { - // Is not - return { - value: !subscribed ? 'subscribed' : 'unsubscribed', - relation: 'is-not' - }; - } - - return { - value: subscribed ? 'subscribed' : 'unsubscribed', - relation: 'is' - }; - }, - options: [ - {label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'}, - {label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'}, - {label: 'Email disabled', name: 'email-disabled'} - ], - getColumnValue: (member) => { - if (member.emailSuppression && member.emailSuppression.suppressed) { - return { - text: 'Email disabled' - }; - } - - return member.newsletters.length > 0 ? { - text: 'Subscribed' - } : { - text: 'Unsubscribed' - }; - } - }; -}; - -export const NEWSLETTERS_FILTERS = ({newsletters, group}) => { - if (newsletters.length <= 1) { - return []; - } - return newsletters.map((newsletter) => { - return { - label: newsletter.name, - name: `newsletters.slug:${newsletter.slug}`, - relationOptions: MATCH_RELATION_OPTIONS, - group, - valueType: 'options', - buildNqlFilter: (flt) => { - const relation = flt.relation; - const value = flt.value; - - return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? `(newsletters.slug:${newsletter.slug}+email_disabled:0)` - : `(newsletters.slug:-${newsletter.slug},email_disabled:1)`; - }, - parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; - - if (!comparator || comparator.length !== 2) { - return; - } - - if (!comparator[0]['newsletters.slug'] || comparator[1].email_disabled === undefined) { - return; - } - - let value = comparator[0]['newsletters.slug']; - let invert = false; - if (typeof value === 'object') { - if (!value.$ne) { - // Unsupported relation type - return; - } - invert = true; - value = value.$ne; - } - if (value !== newsletter.slug) { - // This filter is for a different newsletter - return; - } - return { - value: invert ? 'false' : 'true', - relation: 'is' - }; - }, - options: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} - ], - columnLabel: newsletter.name, - getColumnValue: (member, flt) => { - const relation = flt.relation; - const value = flt.value; - - if (member.emailSuppression && member.emailSuppression.suppressed) { - return { - text: 'Email disabled' - }; - } - - return { - text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? 'Subscribed' - : 'Unsubscribed' - }; - } - }; - }); -}; diff --git a/ghost/admin/app/components/members/filters/subscription-attribution.js b/ghost/admin/app/components/members/filters/subscription-attribution.js deleted file mode 100644 index 373b51c0b34..00000000000 --- a/ghost/admin/app/components/members/filters/subscription-attribution.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const SUBSCRIPTION_ATTRIBUTION_FILTER = { - label: 'Subscription started on post/page', - name: 'conversion', - valueType: 'string', - resource: 'post', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Subscription started on', - setting: 'membersTrackSources', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/subscription-start-date.js b/ghost/admin/app/components/members/filters/subscription-start-date.js deleted file mode 100644 index 771c1e52ecd..00000000000 --- a/ghost/admin/app/components/members/filters/subscription-start-date.js +++ /dev/null @@ -1,15 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; -import {getDateColumnValue} from './columns/date-column'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const SUBSCRIPTION_START_DATE_FILTER = { - label: 'Paid start date', - name: 'subscriptions.start_date', - valueType: 'date', - columnLabel: 'Paid start date', - relationOptions: DATE_RELATION_OPTIONS, - getColumnValue: (member, filter) => { - const subscription = mostRelevantSubscription(member.subscriptions); - return getDateColumnValue(subscription?.start_date, filter); - } -}; diff --git a/ghost/admin/app/components/members/filters/subscription-status.js b/ghost/admin/app/components/members/filters/subscription-status.js deleted file mode 100644 index d6266932062..00000000000 --- a/ghost/admin/app/components/members/filters/subscription-status.js +++ /dev/null @@ -1,29 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; -import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const SUBSCRIPTION_STATUS_FILTER = { - label: 'Stripe subscription status', - name: 'subscriptions.status', - columnLabel: 'Subscription Status', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Active', name: 'active'}, - {label: 'Trialing', name: 'trialing'}, - {label: 'Canceled', name: 'canceled'}, - {label: 'Unpaid', name: 'unpaid'}, - {label: 'Past Due', name: 'past_due'}, - {label: 'Incomplete', name: 'incomplete'}, - {label: 'Incomplete - Expired', name: 'incomplete_expired'} - ], - getColumnValue: (member) => { - const subscription = mostRelevantSubscription(member.subscriptions); - if (!subscription) { - return null; - } - return { - text: capitalizeFirstLetter(subscription.status) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/tier.js b/ghost/admin/app/components/members/filters/tier.js deleted file mode 100644 index bbd1c00e2d7..00000000000 --- a/ghost/admin/app/components/members/filters/tier.js +++ /dev/null @@ -1,15 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const TIER_FILTER = { - label: 'Membership tier', - name: 'tier_id', - valueType: 'array', - columnLabel: 'Membership tier', - relationOptions: MATCH_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - class: 'gh-members-list-labels', - text: (member.tiers ?? []).map(label => label.name).join(', ') - }; - } -}; diff --git a/ghost/admin/app/components/members/list-item-column.hbs b/ghost/admin/app/components/members/list-item-column.hbs deleted file mode 100644 index f9feff51411..00000000000 --- a/ghost/admin/app/components/members/list-item-column.hbs +++ /dev/null @@ -1,15 +0,0 @@ - - {{#if this.columnValue}} -
- {{#if this.columnValue.icon}} - {{svg-jar this.columnValue.icon}} - {{/if}} - {{this.columnValue.text}} - {{#if this.columnValue.subtext}} -
{{this.columnValue.subtext}}
- {{/if}} -
- {{else}} - - - {{/if}} -
diff --git a/ghost/admin/app/components/members/list-item-column.js b/ghost/admin/app/components/members/list-item-column.js deleted file mode 100644 index 14b1c5fbdd8..00000000000 --- a/ghost/admin/app/components/members/list-item-column.js +++ /dev/null @@ -1,15 +0,0 @@ -import Component from '@glimmer/component'; - -export default class MembersListItemColumn extends Component { - constructor(...args) { - super(...args); - } - - get columnName() { - return this.args.filterColumn.name; - } - - get columnValue() { - return this.args.filterColumn?.getValue ? this.args.filterColumn?.getValue(this.args.member) : null; - } -} diff --git a/ghost/admin/app/components/members/list-item-loading.hbs b/ghost/admin/app/components/members/list-item-loading.hbs deleted file mode 100644 index bf4d6680ded..00000000000 --- a/ghost/admin/app/components/members/list-item-loading.hbs +++ /dev/null @@ -1,13 +0,0 @@ - -
-
-
-
-
-
-
-
- {{#each @filterColumns}} -
- {{/each}} - \ No newline at end of file diff --git a/ghost/admin/app/components/members/list-item.hbs b/ghost/admin/app/components/members/list-item.hbs deleted file mode 100644 index 685d9bf7689..00000000000 --- a/ghost/admin/app/components/members/list-item.hbs +++ /dev/null @@ -1,67 +0,0 @@ - - -
- -
-

{{or @member.name @member.email}}

- {{#if @member.name}} -

{{@member.email}}

- {{/if}} -
-
-
- {{#if this.hasMultipleTiers}} - - {{#if (not (is-empty @member.status))}} - {{capitalize @member.status}} - {{else}} - - - {{/if}} -
{{this.tiers}}
-
- {{else}} - - {{#if (not (is-empty @member.status))}} - {{capitalize @member.status}} - {{else}} - - - {{/if}} - - {{/if}} - {{#if @newsletterEnabled}} - - {{#if (not (is-empty @member.emailOpenRate))}} - {{@member.emailOpenRate}}% - {{else}} - N/A - {{/if}} - - {{/if}} - - - {{#if (and @member.geolocation @member.geolocation.country)}} - {{#if (and (eq @member.geolocation.country_code "US") @member.geolocation.region)}} - {{@member.geolocation.region}}, US - {{else}} - {{#if @member.geolocation.country}} - {{@member.geolocation.country}} - {{else}} - Unknown - {{/if}} - {{/if}} - {{else}} - Unknown - {{/if}} - - - - {{#if @member.createdAtUTC}} -
{{moment-format (moment-site-tz @member.createdAtUTC) "DD MMM YYYY"}}
-
{{moment-from-now @member.createdAtUTC}}
- {{/if}} -
- - {{#each @filterColumns as |filterColumn|}} - - {{/each}} - diff --git a/ghost/admin/app/components/members/list-item.js b/ghost/admin/app/components/members/list-item.js deleted file mode 100644 index d559550b125..00000000000 --- a/ghost/admin/app/components/members/list-item.js +++ /dev/null @@ -1,19 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; - -export default class MembersListItem extends Component { - @service store; - - constructor(...args) { - super(...args); - } - - get hasMultipleTiers() { - return this.store.peekAll('tier')?.length > 1; - } - - get tiers() { - const tierData = this.args.member?.tiers || []; - return tierData.map(tier => tier.name).join(', '); - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-add-label.hbs b/ghost/admin/app/components/members/modals/bulk-add-label.hbs deleted file mode 100644 index 1934ecdc7b8..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-add-label.hbs +++ /dev/null @@ -1,83 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-add-label.js b/ghost/admin/app/components/members/modals/bulk-add-label.js deleted file mode 100644 index d4866b929cf..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-add-label.js +++ /dev/null @@ -1,60 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkAddMembersLabelModal extends Component { - @service ajax; - @service ghostPaths; - - @tracked error; - @tracked response; - @tracked selectedLabel; - - get isDisabled() { - return !this.args.data.query || !this.selectedLabel; - } - - get hasRun() { - return !!(this.error || this.response); - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @task({drop: true}) - *addLabelTask() { - try { - const query = new URLSearchParams(this.args.data.query); - const addLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(addLabelUrl, { - data: { - bulk: { - action: 'addLabel', - meta: { - label: { - id: this.selectedLabel - } - } - } - } - }); - - this.args.data.onComplete?.(); - - this.response = response?.bulk?.meta; - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-delete.hbs b/ghost/admin/app/components/members/modals/bulk-delete.hbs deleted file mode 100644 index fcf233919cd..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-delete.hbs +++ /dev/null @@ -1,81 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-delete.js b/ghost/admin/app/components/members/modals/bulk-delete.js deleted file mode 100644 index 5bc63c369f4..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-delete.js +++ /dev/null @@ -1,81 +0,0 @@ -import Component from '@glimmer/component'; -import config from 'ghost-admin/config/environment'; -import fetch from 'fetch'; -import moment from 'moment-timezone'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkDeleteMembersModal extends Component { - @service ajax; - @service ghostPaths; - - @tracked error; - @tracked response; - - get isDisabled() { - return !this.args.data.query; - } - - get hasRun() { - return !!(this.error || this.response); - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @task({drop: true}) - *bulkDeleteTask() { - try { - const query = new URLSearchParams(this.args.data.query); - - // Trigger download before deleting. Uses the CSV export endpoint but - // needs to fetch the file and trigger a download directly rather than - // via an iframe. The iframe approach can't tell us when a download has - // started/finished meaning we could end up deleting the data before exporting it - const exportParams = new URLSearchParams(this.args.data.query); - exportParams.set('limit', 'all'); - const exportUrl = `${this.ghostPaths.url.api('members/upload')}?${exportParams.toString()}`; - - yield fetch(exportUrl, {method: 'GET'}) - .then(res => res.blob()) - .then((blob) => { - const blobUrl = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = `members.${moment().format('YYYY-MM-DD')}.csv`; - document.body.appendChild(a); - - if (config.environment !== 'test') { - a.click(); - } - - a.remove(); - URL.revokeObjectURL(blobUrl); - }); - - // backup downloaded, continue with deletion - - const deleteUrl = `${this.ghostPaths.url.api('members')}?${query}`; - - // response contains details of which members failed to be deleted - const response = yield this.ajax.del(deleteUrl); - - this.response = response.meta; - - this.args.data.onComplete?.(); - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-remove-label.hbs b/ghost/admin/app/components/members/modals/bulk-remove-label.hbs deleted file mode 100644 index c5251b4bd58..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-remove-label.hbs +++ /dev/null @@ -1,83 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-remove-label.js b/ghost/admin/app/components/members/modals/bulk-remove-label.js deleted file mode 100644 index 4f9722ef0b5..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-remove-label.js +++ /dev/null @@ -1,60 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkRemoveMembersLabelModal extends Component { - @service ajax; - @service ghostPaths; - - @tracked error; - @tracked response; - @tracked selectedLabel; - - get isDisabled() { - return !this.args.data.query || !this.selectedLabel; - } - - get hasRun() { - return !!(this.error || this.response); - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @task({drop: true}) - *removeLabelTask() { - try { - const query = new URLSearchParams(this.args.data.query); - const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(removeLabelUrl, { - data: { - bulk: { - action: 'removeLabel', - meta: { - label: { - id: this.selectedLabel - } - } - } - } - }); - - this.args.data.onComplete?.(); - - this.response = response?.bulk?.meta; - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs b/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs deleted file mode 100644 index 2fd81b2f71c..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs +++ /dev/null @@ -1,113 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-unsubscribe.js b/ghost/admin/app/components/members/modals/bulk-unsubscribe.js deleted file mode 100644 index a584adaf2a4..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-unsubscribe.js +++ /dev/null @@ -1,93 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkUnsubscribeMembersModal extends Component { - @service ajax; - @service ghostPaths; - @service store; - - @tracked error; - @tracked response; - - @tracked selectedNewsletterId = null; - - get isDisabled() { - return !this.args.data.query; - } - - get hasRun() { - return !!(this.error || this.response); - } - - get hasMultipleNewsletters() { - const newsletters = this.store.peekAll('newsletter'); - const activeNewsletters = newsletters.filter(newsletter => newsletter.status !== 'archived'); - if (activeNewsletters.length <= 1) { - return false; - } else { - return true; - } - } - - get newsletterList() { - const newsletters = this.store.peekAll('newsletter'); - const activeNewsletters = newsletters.filter(newsletter => newsletter.status !== 'archived'); - let list = [{ - name: 'All newsletters', - value: 'all' - }]; - activeNewsletters.forEach((newsletter) => { - list.push({ - name: newsletter.name, - value: newsletter.id - }); - }); - return list; - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @action - setSelectedNewsletter(newsletter) { - if (newsletter === 'all') { - this.selectedNewsletterId = null; - } else { - this.selectedNewsletterId = newsletter; - } - } - - @task({drop: true}) - *bulkUnsubscribeTask() { - try { - let args = this.args.data.query; - const query = new URLSearchParams(args); - const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(removeLabelUrl, {data: { - bulk: { - action: 'unsubscribe', - newsletter: (this.selectedNewsletterId ? this.selectedNewsletterId : null), - meta: {} - } - }}); - - this.args.data.onComplete?.(); - - this.response = response?.bulk?.meta; - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/disable-commenting.js b/ghost/admin/app/components/members/modals/disable-commenting.js index cea24ec8600..07e657ae0cd 100644 --- a/ghost/admin/app/components/members/modals/disable-commenting.js +++ b/ghost/admin/app/components/members/modals/disable-commenting.js @@ -26,12 +26,6 @@ export default class DisableCommentingModal extends Component { contentType: 'application/json' }); - // Invalidate React Query cache so comments list reflects changes - if (window.adminXQueryClient) { - window.adminXQueryClient.invalidateQueries({queryKey: ['CommentsResponseType']}); - window.adminXQueryClient.invalidateQueries({queryKey: ['MembersResponseType']}); - } - this.args.data.afterDisable?.(); this.notifications.showNotification(`Commenting has been disabled for ${this.member.name || this.member.email}.`, {type: 'success'}); this.args.close(true); diff --git a/ghost/admin/app/components/modal-import-members.hbs b/ghost/admin/app/components/modal-import-members.hbs deleted file mode 100644 index 99b785fc75a..00000000000 --- a/ghost/admin/app/components/modal-import-members.hbs +++ /dev/null @@ -1,190 +0,0 @@ -
- {{#if (eq this.state 'INIT')}} - -

Need some help? Learn more about importing members or download a sample CSV file.

- {{/if}} - - {{#if (or (eq this.state 'MAPPING') (eq this.state 'UPLOADING'))}} - - {{/if}} - - {{#if (eq this.state 'PROCESSING')}} - - {{/if}} - - {{#if (eq this.state 'COMPLETE')}} - - {{/if}} - - {{#if (eq this.state 'ERROR')}} - - {{/if}} - - - {{svg-jar "close"}} - - - - - - -
\ No newline at end of file diff --git a/ghost/admin/app/components/modal-import-members.js b/ghost/admin/app/components/modal-import-members.js deleted file mode 100644 index 410a22f463f..00000000000 --- a/ghost/admin/app/components/modal-import-members.js +++ /dev/null @@ -1,236 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import moment from 'moment-timezone'; -import unparse from '@tryghost/members-csv/lib/unparse'; -import { - AcceptedResponse, - isDataImportError, - isHostLimitError, - isRequestEntityTooLargeError, - isUnsupportedMediaTypeError, - isVersionMismatchError -} from 'ghost-admin/services/ajax'; -import {computed} from '@ember/object'; -import {htmlSafe} from '@ember/template'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; - -export default ModalComponent.extend({ - ajax: service(), - notifications: service(), - store: service(), - - state: 'INIT', - - file: null, - mappingResult: null, - mappingFileData: null, - paramName: 'membersfile', - importResponse: null, - errorMessage: null, - errorHeader: null, - showMappingErrors: false, - showTryAgainButton: true, - - // Allowed actions - confirm: () => {}, - - config: inject(), - - uploadUrl: computed(function () { - return `${ghostPaths().apiRoot}/members/upload/`; - }), - - formData: computed('file', function () { - let formData = new FormData(); - - formData.append(this.paramName, this.file); - - if (this.mappingResult.labels) { - this.mappingResult.labels.forEach((label) => { - formData.append('labels', label.name); - }); - } - - if (this.mappingResult.mapping) { - let mapping = this.mappingResult.mapping.toJSON(); - for (let [key, val] of Object.entries(mapping)) { - formData.append(`mapping[${key}]`, val); - } - } - - return formData; - }), - - actions: { - setFile(file) { - this.set('file', file); - this.set('state', 'MAPPING'); - }, - - setMappingResult(mappingResult) { - this.set('mappingResult', mappingResult); - }, - - setMappingFileData(mappingFileData) { - this.set('mappingFileData', mappingFileData); - }, - - upload() { - if (this.file && !this.mappingResult.error) { - this.generateRequest(); - this.set('showMappingErrors', false); - } else { - this.set('showMappingErrors', true); - } - }, - - reset() { - this.set('showMappingErrors', false); - this.set('errorMessage', null); - this.set('errorHeader', null); - this.set('file', null); - this.set('mapping', null); - this.set('state', 'INIT'); - this.set('showTryAgainButton', true); - }, - - closeModal() { - if (this.state !== 'UPLOADING') { - this._super(...arguments); - } - }, - - // noop - we don't want the enter key doing anything - confirm() {} - }, - - generateRequest() { - let ajax = this.ajax; - let formData = this.formData; - let url = this.uploadUrl; - - this.set('state', 'UPLOADING'); - ajax.post(url, { - data: formData, - processData: false, - contentType: false, - dataType: 'text' - }).then((importResponse) => { - if (importResponse instanceof AcceptedResponse) { - this.set('state', 'PROCESSING'); - } else { - this._uploadSuccess(JSON.parse(importResponse)); - this.set('state', 'COMPLETE'); - } - }).catch((error) => { - this._uploadError(error); - this.set('state', 'ERROR'); - }); - }, - - _uploadSuccess(importResponse) { - let importedCount = importResponse.meta.stats.imported; - const erroredMembers = importResponse.meta.stats.invalid; - let errorCount = erroredMembers.length; - const errorList = {}; - - const errorsWithFormattedMessages = erroredMembers.map((row) => { - const formattedError = row.error - .replace( - 'Value in [members.email] cannot be blank.', - 'Missing email address' - ) - .replace( - 'Value in [members.note] exceeds maximum length of 2000 characters.', - 'Note is too long' - ) - .replace( - 'Value in [members.subscribed] must be one of true, false, 0 or 1.', - 'Value of "Subscribed to emails" must be "true" or "false"' - ) - .replace( - 'Validation (isEmail) failed for email', - 'Invalid email address' - ) - .replace( - /No such customer:[^,]*/, - 'Could not find Stripe customer' - ); - formattedError.split(',').forEach((errorMssg) => { - if (errorList[errorMssg]) { - errorList[errorMssg].count = errorList[errorMssg].count + 1; - } else { - errorList[errorMssg] = { - message: errorMssg, - count: 1 - }; - } - }); - return { - ...row, - error: formattedError - }; - }); - - let errorCsv = unparse(errorsWithFormattedMessages); - let errorCsvBlob = new Blob([errorCsv], {type: 'text/csv'}); - let errorCsvUrl = URL.createObjectURL(errorCsvBlob); - let errorCsvName = importResponse.meta.import_label ? `${importResponse.meta.import_label.name} - Errors.csv` : `Import ${moment().format('YYYY-MM-DD HH:mm')} - Errors.csv`; - - this.set('importResponse', { - importedCount, - errorCount, - errorCsvUrl, - errorCsvName, - errorList: Object.values(errorList) - }); - - // insert auto-created import label into store immediately if present - // ready for filtering the members list - if (importResponse.meta.import_label) { - this.store.pushPayload({ - labels: [importResponse.meta.import_label] - }); - } - - // invoke the passed in confirm action to refresh member data - // @TODO wtf does confirm mean? - this.confirm({label: importResponse.meta.import_label}); - }, - - _uploadError(error) { - let message; - let header = 'Import error'; - - if (isVersionMismatchError(error)) { - this.notifications.showAPIError(error); - } - - // Handle all the specific errors that we know about - if (isUnsupportedMediaTypeError(error)) { - message = 'The file type you uploaded is not supported.'; - } else if (isRequestEntityTooLargeError(error)) { - message = 'The file you uploaded was larger than the maximum file size your server allows.'; - } else if (isDataImportError(error, error.payload)) { - message = htmlSafe(error.payload.errors[0].message); - } else if (isHostLimitError(error) && error?.payload?.errors?.[0]?.code === 'EMAIL_VERIFICATION_NEEDED') { - message = htmlSafe(error.payload.errors[0].message); - - header = 'Woah there cowboy, that\'s a big list'; - this.set('showTryAgainButton', false); - // NOTE: confirm makes sure to refresh the members data in the background - this.confirm(); - } else { // Generic fallback error - message = 'An unexpected error occurred, please try again'; - - console.error(error); // eslint-disable-line - if (error?.payload?.errors?.[0]?.id) { - console.error(`Error ID: ${error.payload.errors[0].id}`); // eslint-disable-line - } - } - - this.set('errorMessage', message); - this.set('errorHeader', header); - } -}); diff --git a/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs b/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs deleted file mode 100644 index 574c25a4b06..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs +++ /dev/null @@ -1,28 +0,0 @@ -{{#if this.hasFileData}} - -
-
- -
-
- {{#if (and this.error @showErrors)}} -

{{this.error.message}}

- {{/if}} - - {{#if this.membersStats.memberCount}} -

If an email address in your CSV matches an existing member, they will be updated with the mapped values.

- {{/if}} - -
- - -
-
-{{else}} -
- -
-{{/if}} diff --git a/ghost/admin/app/components/modal-import-members/csv-file-mapping.js b/ghost/admin/app/components/modal-import-members/csv-file-mapping.js deleted file mode 100644 index eb9d3d6c741..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-mapping.js +++ /dev/null @@ -1,72 +0,0 @@ -import Component from '@glimmer/component'; -import MemberImportError from 'ghost-admin/errors/member-import-error'; -import papaparse from 'papaparse'; -import {action} from '@ember/object'; -import {isNone} from '@ember/utils'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class CsvFileMapping extends Component { - @tracked error = null; - @tracked fileData = null; - @tracked labels = null; - - @service membersStats; - - constructor(...args) { - super(...args); - this.parseFileAndGenerateMapping(this.args.file); - } - - parseFileAndGenerateMapping(file) { - papaparse.parse(file, { - header: true, - skipEmptyLines: true, - complete: (result) => { - if (result.data && result.data.length) { - this.fileData = result.data; - } else { - this.fileData = []; - } - this.args.setFileData(this.fileData); - } - }); - } - - get hasFileData() { - return !isNone(this.fileData); - } - - @action - setMapping(mapping) { - if (this.fileData.length === 0) { - this.error = new MemberImportError({ - message: 'File is empty, nothing to import. Please select a different file.' - }); - } else if (!mapping.getKeyByValue('email')) { - this.error = new MemberImportError({ - message: 'Please map "Email" to one of the fields in the CSV.' - }); - } else { - this.error = null; - } - - this.mapping = mapping; - this.setMappingResult(); - } - - @action - updateLabels(labels) { - this.labels = labels; - this.setMappingResult(); - } - - setMappingResult() { - this.args.setMappingResult({ - mapping: this.mapping, - labels: this.labels, - membersCount: this.fileData?.length, - error: this.error - }); - } -} diff --git a/ghost/admin/app/components/modal-import-members/csv-file-select.hbs b/ghost/admin/app/components/modal-import-members/csv-file-select.hbs deleted file mode 100644 index bf675cbfc26..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-select.hbs +++ /dev/null @@ -1,20 +0,0 @@ -{{#if this.error}} -
-
{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}
-

{{this.error.message}}

-
-{{/if}} -
-
- -
- {{svg-jar "upload"}} -
{{this.labelText}}
-
-
-
-
diff --git a/ghost/admin/app/components/modal-import-members/csv-file-select.js b/ghost/admin/app/components/modal-import-members/csv-file-select.js deleted file mode 100644 index 43be7be1ef2..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-select.js +++ /dev/null @@ -1,79 +0,0 @@ -import Component from '@glimmer/component'; -import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax'; -import {action} from '@ember/object'; -import {tracked} from '@glimmer/tracking'; - -export default class CsvFileSelect extends Component { - labelText = 'Select or drop a CSV file'; - - @tracked error = null; - @tracked dragClass = null; - - /* - constructor(...args) { - super(...args); - assert(this.args.setFile); - } - */ - - @action - fileSelected(fileList) { - let [file] = Array.from(fileList); - - try { - this._validateFileType(file); - this.error = null; - } catch (err) { - this.error = err; - return; - } - - this.args.setFile(file); - } - - @action - dragOver(event) { - if (!event.dataTransfer) { - return; - } - - // this is needed to work around inconsistencies with dropping files - // from Chrome's downloads bar - if (navigator.userAgent.indexOf('Chrome') > -1) { - let eA = event.dataTransfer.effectAllowed; - event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy'; - } - - event.stopPropagation(); - event.preventDefault(); - - this.dragClass = '-drag-over'; - } - - @action - dragLeave(event) { - event.preventDefault(); - this.dragClass = null; - } - - @action - drop(event) { - event.preventDefault(); - this.dragClass = null; - if (event.dataTransfer.files) { - this.fileSelected(event.dataTransfer.files); - } - } - - _validateFileType(file) { - let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); - - if (extension.toLowerCase() !== 'csv') { - throw new UnsupportedMediaTypeError({ - message: 'The file type you uploaded is not supported' - }); - } - - return true; - } -} diff --git a/ghost/admin/app/components/modal-unsubscribe-members.hbs b/ghost/admin/app/components/modal-unsubscribe-members.hbs deleted file mode 100644 index 26895291aaf..00000000000 --- a/ghost/admin/app/components/modal-unsubscribe-members.hbs +++ /dev/null @@ -1,66 +0,0 @@ - -{{svg-jar "close"}} - -{{#if this.confirmed}} -
- {{#if this.error}} -
- {{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}} -
-

- {{this.error}} -

-
-
- {{else}} -
- {{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}} -

- {{gh-pluralize this.response.stats.successful "member"}} - successfully unsubscribed -

-
- {{#if this.response.stats.unsuccessful}} -
- {{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}} -
-

- {{gh-pluralize this.response.stats.unsuccessful "member"}} - failed to unsubscribe -

-
-
- {{/if}} - {{/if}} -
-{{else}} - -{{/if}} - - diff --git a/ghost/admin/app/components/modal-unsubscribe-members.js b/ghost/admin/app/components/modal-unsubscribe-members.js deleted file mode 100644 index b462223f491..00000000000 --- a/ghost/admin/app/components/modal-unsubscribe-members.js +++ /dev/null @@ -1,35 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import {alias} from '@ember/object/computed'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default ModalComponent.extend({ - membersStats: service(), - - shouldCancelSubscriptions: false, - - // Allowed actions - confirm: () => {}, - - member: alias('model'), - - actions: { - confirm() { - this.unsubscribeMemberTask.perform(); - } - }, - - unsubscribeMemberTask: task(function* () { - try { - const response = yield this.confirm(); - this.set('response', response); - this.set('confirmed', true); - } catch (e) { - if (e.payload?.errors) { - this.set('confirmed', true); - this.set('error', e.payload.errors[0].message); - } - throw e; - } - }).drop() -}); diff --git a/ghost/admin/app/components/offers/segment-select.hbs b/ghost/admin/app/components/offers/segment-select.hbs deleted file mode 100644 index 72c381042c7..00000000000 --- a/ghost/admin/app/components/offers/segment-select.hbs +++ /dev/null @@ -1,22 +0,0 @@ - - {{option.name}} - - -{{#if @showMemberCount}} - -{{/if}} diff --git a/ghost/admin/app/components/offers/segment-select.js b/ghost/admin/app/components/offers/segment-select.js deleted file mode 100644 index 942a21ad6c1..00000000000 --- a/ghost/admin/app/components/offers/segment-select.js +++ /dev/null @@ -1,193 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -const RETENTION_OFFER_OPTIONS = [ - { - cadence: 'month', - id: 'retention:month', - name: 'Monthly Retention' - }, - { - cadence: 'year', - id: 'retention:year', - name: 'Yearly Retention' - } -]; - -export default class OffersSegmentSelect extends Component { - @service store; - - @tracked _options = []; - @tracked offers = []; - - get renderInPlace() { - return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; - } - - constructor() { - super(...arguments); - this.fetchOptionsTask.perform(); - } - - get options() { - return this._options; - } - - get flatOptions() { - const options = []; - - function getOptions(option) { - if (option.options) { - return option.options.forEach(getOptions); - } - - options.push(option); - } - this._options.forEach(getOptions); - return options; - } - - getOfferById(id) { - return this.offers.find((offer) => { - return offer.id === id; - }); - } - - get selectedOptions() { - const selectedIds = new Set((this.args.offers || []).map(offer => offer.id).filter(id => !!id)); - const selected = []; - const consumedIds = new Set(); - const retentionCadenceOptions = this.flatOptions.filter(option => Array.isArray(option.offerIds)); - - retentionCadenceOptions.forEach((option) => { - if (option.offerIds.length > 0 && option.offerIds.every(id => selectedIds.has(id))) { - selected.push(option); - option.offerIds.forEach(id => consumedIds.add(id)); - } - }); - - selectedIds.forEach((id) => { - if (consumedIds.has(id)) { - return; - } - - const option = this.flatOptions.find((flatOption) => { - return !Array.isArray(flatOption.offerIds) && flatOption.id === id; - }); - - if (option) { - selected.push(option); - return; - } - - const offer = this.getOfferById(id); - if (offer) { - selected.push({ - id: offer.id, - name: offer.name, - class: 'segment-offer-redemptions-hidden' - }); - } - }); - - return selected; - } - - @action - setSegment(options) { - const offerIds = new Set(); - - options.forEach((option) => { - if (Array.isArray(option.offerIds)) { - option.offerIds.forEach(id => offerIds.add(id)); - return; - } - - if (option.id) { - offerIds.add(option.id); - } - }); - - const ids = Array.from(offerIds).reduce((result, id) => { - const offer = this.getOfferById(id); - - if (!offer) { - return result; - } - - result.push({ - id, - name: offer.name - }); - - return result; - }, []); - - this.args.onChange?.(ids); - } - - getRetentionOptions(offers) { - const retentionOffersByCadence = { - month: [], - year: [] - }; - - offers.forEach((offer) => { - const redemptionType = offer.redemptionType; - if (redemptionType !== 'retention') { - return; - } - - if (offer.cadence === 'month' || offer.cadence === 'year') { - retentionOffersByCadence[offer.cadence].push(offer.id); - } - }); - - return RETENTION_OFFER_OPTIONS - .filter(definition => retentionOffersByCadence[definition.cadence].length > 0) - .map(definition => ({ - name: definition.name, - id: definition.id, - offerIds: retentionOffersByCadence[definition.cadence], - class: 'segment-offer-redemptions' - })); - } - - @task - *fetchOptionsTask() { - const options = yield []; - - const offers = yield this.store.findAll('offer'); - this.offers = offers; - - if (offers.length > 0) { - const offersGroup = { - groupName: 'Offers', - options: [] - }; - - offers.forEach((offer) => { - if (offer.redemptionType === 'retention') { - return; - } - - offersGroup.options.push({ - name: offer.name, - id: offer.id, - class: 'segment-offer-redemptions' - }); - }); - - offersGroup.options.push(...this.getRetentionOptions(offers)); - - if (offersGroup.options.length > 0) { - options.push(offersGroup); - } - } - - this._options = options; - } -} diff --git a/ghost/admin/app/components/tiers/segment-select.hbs b/ghost/admin/app/components/tiers/segment-select.hbs deleted file mode 100644 index c0e5df44ac2..00000000000 --- a/ghost/admin/app/components/tiers/segment-select.hbs +++ /dev/null @@ -1,22 +0,0 @@ - - {{option.name}} - - -{{#if @showMemberCount}} - -{{/if}} diff --git a/ghost/admin/app/components/tiers/segment-select.js b/ghost/admin/app/components/tiers/segment-select.js deleted file mode 100644 index 421b038a53e..00000000000 --- a/ghost/admin/app/components/tiers/segment-select.js +++ /dev/null @@ -1,100 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class TiersSegmentSelect extends Component { - @service store; - @service feature; - - @tracked _options = []; - @tracked tiers = []; - - get renderInPlace() { - return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; - } - - constructor() { - super(...arguments); - this.fetchOptionsTask.perform(); - } - - get options() { - return this._options; - } - - get flatOptions() { - const options = []; - - function getOptions(option) { - if (option.options) { - return option.options.forEach(getOptions); - } - - options.push(option); - } - - this._options.forEach(getOptions); - - return options; - } - - get selectedOptions() { - const tierList = (this.args.tiers || []).map((tier) => { - return this.tiers.find((p) => { - return p.id === tier.id || p.id === tier.id; - }); - }).filter(d => !!d); - const tierIdList = tierList.map(d => d.id); - return this.flatOptions.filter(option => tierIdList.includes(option.id)); - } - - @action - setSegment(options) { - let ids = options.mapBy('id').map((id) => { - let tier = this.tiers.find((p) => { - return p.id === id; - }); - return { - id: tier.id, - slug: tier.slug, - name: tier.name - }; - }) || []; - this.args.onChange?.(ids); - } - - @task - *fetchOptionsTask() { - const options = yield []; - - // fetch all tiers with count - // TODO: add `include: 'count.members` to query once API supports - const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'}); - this.tiers = tiers; - - if (tiers.length > 0) { - const tiersGroup = { - groupName: 'Tiers', - options: [] - }; - - tiers.forEach((tier) => { - tiersGroup.options.push({ - name: tier.name, - id: tier.id, - count: tier.count?.members, - class: 'segment-tier' - }); - }); - - options.push(tiersGroup); - if (this.args.selectDefaultTier && !this.args.tiers) { - this.setSegment([tiersGroup.options[0]]); - } - } - - this._options = options; - } -} diff --git a/ghost/admin/app/controllers/member.js b/ghost/admin/app/controllers/member.js index d1d23409b2f..f3ca17829cd 100644 --- a/ghost/admin/app/controllers/member.js +++ b/ghost/admin/app/controllers/member.js @@ -1,4 +1,4 @@ -import Controller, {inject as controller} from '@ember/controller'; +import Controller from '@ember/controller'; import DeleteMemberModal from '../components/members/modals/delete-member'; import DisableCommentingModal from '../components/members/modals/disable-commenting'; import EmberObject, {action, defineProperty} from '@ember/object'; @@ -12,7 +12,6 @@ import {tracked} from '@glimmer/tracking'; const SCRATCH_PROPS = ['name', 'email', 'note']; export default class MemberController extends Controller { - @controller members; @service ajax; @service session; @service dropdown; @@ -24,6 +23,7 @@ export default class MemberController extends Controller { @service notifications; @service router; @service labelsManager; + @service stateBridge; @service store; queryParams = [ @@ -127,6 +127,15 @@ export default class MemberController extends Controller { return `${createdDate} (${memberSince})`; } + invalidateMembersCache() { + this.stateBridge.triggerEmberDataChange('update', 'member', this.member.id, null); + } + + invalidateMemberCommenting() { + this.invalidateMembersCache(); + this.stateBridge.triggerEmberDataChange('update', 'comment', this.member.id, null); + } + // Actions ----------------------------------------------------------------- @action @@ -162,7 +171,7 @@ export default class MemberController extends Controller { member: this.member, afterDelete: () => { this.membersStats.invalidate(); - this.members.refreshData(); + this.invalidateMembersCache(); this.membersCountCache.clear(); this.router.transitionTo(this.membersListPath); } @@ -174,7 +183,7 @@ export default class MemberController extends Controller { this.modals.open(LogoutMemberModal, { member: this.member, afterLogout: () => { - this.members.refreshData(); + this.invalidateMembersCache(); } }); } @@ -184,6 +193,7 @@ export default class MemberController extends Controller { this.modals.open(DisableCommentingModal, { member: this.member, afterDisable: () => { + this.invalidateMemberCommenting(); this.fetchMemberTask.perform(this.member.id); } }); @@ -196,11 +206,7 @@ export default class MemberController extends Controller { const url = this.ghostPaths.url.api('members', this.member.id, 'commenting', 'enable'); await this.ajax.post(url); - // Invalidate React Query cache so comments list reflects changes - if (window.adminXQueryClient) { - window.adminXQueryClient.invalidateQueries({queryKey: ['CommentsResponseType']}); - window.adminXQueryClient.invalidateQueries({queryKey: ['MembersResponseType']}); - } + this.invalidateMemberCommenting(); await this.fetchMemberTask.perform(this.member.id); this.notifications.showNotification(`Commenting has been enabled for ${this.member.name || this.member.email}.`, {type: 'success'}); @@ -241,7 +247,7 @@ export default class MemberController extends Controller { yield member.save(); member.updateLabels(); member.labels.forEach(label => this.labelsManager.addLabel(label)); - this.members.refreshData(); + this.invalidateMembersCache(); this.setInitialRelationshipValues(); diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js deleted file mode 100644 index 204e48bea6f..00000000000 --- a/ghost/admin/app/controllers/members.js +++ /dev/null @@ -1,619 +0,0 @@ -import BulkAddMembersLabelModal from '../components/members/modals/bulk-add-label'; -import BulkDeleteMembersModal from '../components/members/modals/bulk-delete'; -import BulkRemoveMembersLabelModal from '../components/members/modals/bulk-remove-label'; -import BulkUnsubscribeMembersModal from '../components/members/modals/bulk-unsubscribe'; -import Controller from '@ember/controller'; -import fetch from 'fetch'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import moment from 'moment-timezone'; -import {A} from '@ember/array'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {didCancel, task, timeout} from 'ember-concurrency'; -import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; -import {inject} from 'ghost-admin/decorators/inject'; -import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -const PAID_PARAMS = [{ - name: 'All members', - value: null -}, { - name: 'Free members', - value: 'false' -}, { - name: 'Paid members', - value: 'true' -}]; - -export default class MembersController extends Controller { - @service ajax; - @service ellaSparse; - @service feature; - @service ghostPaths; - @service membersStats; - @service modals; - @service router; - @service labelsManager; - @service store; - @service utils; - @service settings; - - @inject config; - - queryParams = [ - 'label', - {paidParam: 'paid'}, - {searchParam: 'search'}, - {orderParam: 'order'}, - {filterParam: 'filter'}, - {postAnalytics: 'post'} - ]; - - @tracked members = A([]); - @tracked searchParam = ''; - @tracked searchIsFocused = false; - @tracked filterParam = null; - @tracked softFilterParam = null; - @tracked paidParam = null; - @tracked label = null; - @tracked orderParam = null; - @tracked modalLabel = null; - @tracked showLabelModal = false; - @tracked filters = A([]); - @tracked softFilters = A([]); - @tracked isExporting = false; - - @tracked _searchedLabels = new TrackedArray(); - _searchedLabelsQuery = null; - _searchedLabelsMeta = null; - - @tracked parseFilterParamCounter = 0; - - /** - * Flag used to determine if we should return to the analytics page - */ - @tracked postAnalytics = null; - - get fromAnalytics() { - if (!this.postAnalytics) { - return null; - } - return [this.postAnalytics]; - } - - paidParams = PAID_PARAMS; - - constructor() { - super(...arguments); - } - - // Computed properties ----------------------------------------------------- - - get listHeader() { - let {searchParam, selectedLabel, members} = this; - - if (members.loading) { - return 'Loading...'; - } - - if (searchParam) { - return 'Search result'; - } - - let count = ghPluralize(members.length, 'member'); - - if (selectedLabel && selectedLabel.slug) { - if (members.length > 1) { - return `${count} match current filter`; - } else { - return `${count} matches current filter`; - } - } - - return count; - } - - get hideSearchBar() { - return !this.members.length - && !this.searchParam - && !this.searchIsFocused; - } - - get showingAll() { - return !this.searchParam && !this.paidParam && !this.label && !this.filterParam && !this.softFilterParam; - } - - get availableOrders() { - // don't return anything if email analytics is disabled because - // we don't want to show an order dropdown with only a single option - - if (this.feature.get('emailAnalytics')) { - return [{ - name: 'Newest', - value: null - }, { - name: 'Open rate', - value: 'email_open_rate' - }]; - } - - return []; - } - - get selectedOrder() { - return this.availableOrders.find(order => order.value === this.orderParam); - } - - get availableLabels() { - let options = [{name: 'All labels', slug: null}]; - - options = options.concat(this.labelsManager.labels); - - if (this.label && !options.findBy('slug', this.label)) { - const foundLabel = this.labelsManager.findBySlug(this.label); - if (foundLabel) { - options.push(foundLabel); - } - } - - return options; - } - - @action - async loadInitialLabels() { - if (!this.labelsManager.hasLoaded) { - await this.labelsManager.loadMoreTask.perform(); - } - } - - @task({drop: true}) - *loadMoreLabelsTask(isSearch = false) { - if (isSearch) { - if (this.searchLabelsTask.isRunning) { - return; - } - - if (!this._searchedLabelsMeta || (this._searchedLabelsMeta.pagination.pages <= this._searchedLabelsMeta.pagination.page)) { - return; - } - - const page = this._searchedLabelsMeta.pagination.page + 1; - const labels = yield this.labelsManager.searchLabelsTask.perform(this._searchedLabelsQuery, {page}); - this._searchedLabels.push(...this.labelsManager.sortLabels(labels.toArray())); - this._searchedLabelsMeta = labels.meta; - } else { - yield this.labelsManager.loadMoreTask.perform(); - } - } - - @task - *searchLabelsTask(term) { - this._searchedLabelsQuery = term; - const labels = yield this.labelsManager.searchLabelsTask.perform(term); - this._searchedLabelsMeta = labels.meta; - - this._searchedLabels = new TrackedArray(this.labelsManager.sortLabels(labels.toArray())); - return this._searchedLabels; - } - - get selectedLabel() { - let {label, availableLabels} = this; - return availableLabels.findBy('slug', label); - } - - get labelModalData() { - let label = this.modalLabel; - let labels = this.availableLabels; - - return { - label, - labels - }; - } - - get selectedPaidParam() { - return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'}; - } - - get isFiltered() { - return !!(this.label || this.paidParam || this.searchParam || this.filterParam); - } - - get availableFilters() { - return this.softFilters.length ? this.softFilters : this.filters; - } - - get filterColumns() { - const columns = this.availableFilters.flatMap((filter) => { - if (filter.properties?.getColumns) { - return filter.properties?.getColumns(filter).map((c) => { - return { - label: filter.properties.columnLabel, // default value if not provided - ...c, - name: filter.type - }; - }); - } - if (filter.properties?.columnLabel) { - return [ - { - name: filter.type, - label: filter.properties.columnLabel, - getValue: filter.properties.getColumnValue ? (member => filter.properties.getColumnValue(member, filter)) : null - } - ]; - } - return []; - }); - // Remove duplicates by label - const uniqueColumns = columns.filter((c, i) => { - return columns.findIndex(c2 => c2.label === c.label) === i; - }); - return uniqueColumns.splice(0, 2); // Maximum 2 columns - } - - /* - * Due to a limitation with NQL, member bulk deletion is not permitted if any of the following Stripe subscription filters is used: - * - Billing period - * - Stripe subscription status - * - Paid start date - * - Next billing date - * - Subscription started on post/page - * - Offers - * - * For more context, see: - * - https://linear.app/tryghost/issue/ENG-1484 - * - https://linear.app/tryghost/issue/ENG-1466 - */ - get isBulkDeletePermitted() { - if (!this.isFiltered) { - return false; - } - - const stripeFilters = this.filters.filter(f => [ - 'subscriptions.plan_interval', - 'subscriptions.status', - 'subscriptions.start_date', - 'subscriptions.current_period_end', - 'conversion', - 'offer_redemptions' - ].includes(f.type)); - - if (stripeFilters && stripeFilters.length >= 1) { - return false; - } - - return true; - } - - includeTierQuery() { - const availableFilters = this.filters.length ? this.filters : this.softFilters; - return availableFilters.some((f) => { - return f.type === 'tier'; - }); - } - - getApiQueryObject({params, extraFilters = []} = {}) { - let {label, paidParam, searchParam, filterParam} = params ? params : this; - - if (filterParam) { - // If the provided filter param is a single filter related to newsletter subscription status - // remove the surrounding brackets to prevent https://github.com/TryGhost/NQL/issues/16 - const BRACKETS_SURROUNDED_RE = /^\(.*\)$/; - const MULTIPLE_GROUPS_RE = /\).*\(/; - - if (BRACKETS_SURROUNDED_RE.test(filterParam) && !MULTIPLE_GROUPS_RE.test(filterParam)) { - filterParam = filterParam.slice(1, -1); - } - } - - let filters = []; - - filters = filters.concat(extraFilters); - - if (label) { - filters.push(`label:'${label}'`); - } - - if (paidParam !== null) { - if (paidParam === 'true') { - filters.push('status:-free'); - } else { - filters.push('status:free'); - } - } - if (filterParam) { - filters.push(filterParam); - } - - let searchQuery = searchParam ? {search: searchParam} : {}; - - return Object.assign({}, {filter: filters.join('+')}, searchQuery); - } - - // Actions ----------------------------------------------------------------- - - @action - refreshData() { - try { - this.fetchMembersTask.perform(); - this.fetchLabelsTask.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } - - this.membersStats.invalidate(); - this.membersStats.fetchCounts(); - this.membersStats.fetchMemberCount(); - } - - @action - changeOrder(order) { - this.orderParam = order.value; - } - - /** - * A user clicked 'Apply filters' when editing the filter - */ - @action - applyFilter(filterStr, filters) { - this.softFilters = A([]); - this.filterParam = filterStr || null; - this.filters = filters; - } - - /** - * Called to set the filters after the url filterParam has been parsed again - */ - @action - applyParsedFilter(filters) { - this.softFilters = A([]); - this.filters = filters; - } - - /** - * Already start filtering when the user is editing a filter, without applying it to the URL yet, - * and to still allow a cancel action to revert to the previous filters. - */ - @action - applySoftFilter(filterStr, filters) { - this.softFilters = filters; - this.softFilterParam = filterStr || null; - let {label, paidParam, searchParam, orderParam} = this; - this.fetchMembersTask.perform({label, paidParam, searchParam, orderParam, filterParam: filterStr}); - } - - @action - resetSoftFilter() { - if (this.softFilters.length > 0 || !!this.softFilterParam) { - this.softFilters = A([]); - this.softFilterParam = null; - this.fetchMembersTask.perform(); - } - } - - @action - resetFilter() { - this.softFilters = A([]); - this.softFilterParam = null; - this.filters = A([]); - this.filterParam = null; - this.fetchMembersTask.perform(); - } - - @action - search(e) { - this.searchTask.perform(e.target.value); - } - - @action - exportData() { - let exportUrl = ghostPaths().url.api('members/upload'); - let downloadParams = new URLSearchParams(this.getApiQueryObject()); - downloadParams.set('limit', 'all'); - - const url = `${exportUrl}?${downloadParams.toString()}`; - - // Set loading state - this.isExporting = true; - - fetch(url, {method: 'GET'}) - .then(res => res.blob()) - .then((blob) => { - const blobUrl = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - const datetime = (new Date()).toJSON().substring(0, 10); - - a.href = blobUrl; - a.download = `members.${datetime}.csv`; - document.body.appendChild(a); - - a.click(); - - // Cleanup - a.remove(); - URL.revokeObjectURL(blobUrl); - }) - .catch(() => { - // Handle errors silently - // A more robust implementation would show an error notification - }) - .finally(() => { - // Reset loading state - this.isExporting = false; - }); - } - - @action - changeLabel(label, e) { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - this.label = label.slug; - } - - @action - editLabel(label, e) { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - let modalLabel = this.availableLabels.findBy('slug', label); - this.modalLabel = modalLabel; - this.showLabelModal = !this.showLabelModal; - } - - @action - toggleLabelModal() { - this.showLabelModal = !this.showLabelModal; - } - - @action - bulkAddLabel() { - this.modals.open(BulkAddMembersLabelModal, { - query: this.getApiQueryObject(), - onComplete: this.resetAndReloadMembers - }); - } - - @action - bulkRemoveLabel() { - this.modals.open(BulkRemoveMembersLabelModal, { - query: this.getApiQueryObject(), - onComplete: this.resetAndReloadMembers - }); - } - - @action - bulkUnsubscribe() { - this.modals.open(BulkUnsubscribeMembersModal, { - query: this.getApiQueryObject(), - onComplete: this.resetAndReloadMembers - }); - } - - @action - resetAndReloadMembers() { - this.store.unloadAll('member'); - this.reload(); - } - - @action - bulkDelete() { - this.modals.open(BulkDeleteMembersModal, { - query: this.getApiQueryObject(), - onComplete: () => { - // reset, clear filters, and reload list and counts - this.store.unloadAll('member'); - this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))}); - this.membersStats.invalidate(); - this.membersStats.fetchCounts(); - } - }); - } - - @action - changePaidParam(paid) { - this.paidParam = paid.value; - } - - // Tasks ------------------------------------------------------------------- - - @task({restartable: true}) - *searchTask(query) { - yield timeout(250); // debounce - this.searchParam = query; - } - - @task({restartable: true}) - *fetchLabelsTask() { - this.labelsManager.reset(); - yield this.labelsManager.loadMoreTask.perform(); - } - - @task({restartable: true}) - *fetchMembersTask(params) { - // params is undefined when called as a "refresh" of the model - let {label, paidParam, searchParam, orderParam, filterParam} = typeof params === 'undefined' ? this : params; - - // use a fixed created_at date so that subsequent pages have a consistent index - let startDate = new Date(); - - // bypass the stale data shortcut if params change - let forceReload = !params - || label !== this._lastLabel - || paidParam !== this._lastPaidParam - || searchParam !== this._lastSearchParam - || orderParam !== this._lastOrderParam - || filterParam !== this._lastFilterParam; - this._lastLabel = label; - this._lastPaidParam = paidParam; - this._lastSearchParam = searchParam; - this._lastOrderParam = orderParam; - this._lastFilterParam = filterParam; - - // unless we have a forced reload, do not re-fetch the members list unless it's more than a minute old - // keeps navigation between list->details->list snappy - if (!forceReload && this._startDate && !(this._startDate - startDate > 1 * 60 * 1000)) { - return this.members; - } - - this._startDate = startDate; - - this.members = yield this.ellaSparse.array((range = {}, query = {}) => { - const searchQuery = this.getApiQueryObject({ - params, - extraFilters: [`created_at:<='${moment.utc(this._startDate).format('YYYY-MM-DD HH:mm:ss')}'`] - }); - const order = orderParam ? `${orderParam} desc` : `created_at desc`; - const includes = ['labels', 'tiers']; - - query = Object.assign({ - include: includes.join(','), - order, - limit: range.length, - page: range.page - }, searchQuery, query); - - return this.store.query('member', query).then((result) => { - return { - data: result, - total: result.meta.pagination.total - }; - }); - }, { - limit: 50 - }); - } - - // Internal ---------------------------------------------------------------- - - resetFilters(params) { - if (!params?.filterParam) { - this.filters = A([]); - this.softFilterParam = null; - this.softFilters = A([]); - } else { - this.filterParam = params.filterParam; - - // Trigger a did-update call in the filter component, so we get freshly parsed filters - // This is temporary, and a ugly pattern, but essential to make it work for now, until we moved the filter parsing logic - // out of the component - this.parseFilterParamCounter += 1; - } - } - - reload(params) { - this.membersStats.invalidate(); - this.membersStats.fetchCounts(); - this.fetchMembersTask.perform(params); - } -} diff --git a/ghost/admin/app/controllers/members/import.js b/ghost/admin/app/controllers/members/import.js deleted file mode 100644 index 6ea239b50e4..00000000000 --- a/ghost/admin/app/controllers/members/import.js +++ /dev/null @@ -1,28 +0,0 @@ -import Controller, {inject as controller} from '@ember/controller'; -import {action} from '@ember/object'; -import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; -import {inject as service} from '@ember/service'; - -export default class ImportController extends Controller { - @service feature; - @service router; - @controller members; - - @action - refreshMembers({label} = {}) { - if (label) { - let queryParams = Object.assign(resetQueryParams('members.index'), {filter: `label:[${label.slug}]`}); - this.router.transitionTo({queryParams}); - } - this.members.refreshData(); - } - - @action - close(from) { - if (from === 'background') { - return; - } - - this.router.transitionTo('members'); - } -} diff --git a/ghost/admin/app/errors/member-import-error.js b/ghost/admin/app/errors/member-import-error.js deleted file mode 100644 index b41ad2e3f9a..00000000000 --- a/ghost/admin/app/errors/member-import-error.js +++ /dev/null @@ -1,8 +0,0 @@ -export default class EmailFailedError extends Error { - constructor({message, context, type = 'error'}) { - super(message); - this.name = 'MemberImportError'; - this.context = context; - this.type = type; - } -} diff --git a/ghost/admin/app/helpers/reset-query-params.js b/ghost/admin/app/helpers/reset-query-params.js index 68fbc4cc65f..5c2d2d00242 100644 --- a/ghost/admin/app/helpers/reset-query-params.js +++ b/ghost/admin/app/helpers/reset-query-params.js @@ -15,13 +15,6 @@ export const DEFAULT_QUERY_PARAMS = { tag: null, order: null }, - 'members.index': { - label: null, - paid: null, - search: null, - filter: null, - order: null - }, 'members-activity': { excludedEvents: null, member: null diff --git a/ghost/admin/app/routes/member.js b/ghost/admin/app/routes/member.js index 7f0da2f1167..40b367d662d 100644 --- a/ghost/admin/app/routes/member.js +++ b/ghost/admin/app/routes/member.js @@ -5,7 +5,6 @@ import {action} from '@ember/object'; import {inject as service} from '@ember/service'; export default class MembersRoute extends MembersManagementRoute { - @service feature; @service modals; @service router; @service('unsaved-changes') unsavedChanges; diff --git a/ghost/admin/app/routes/members.js b/ghost/admin/app/routes/members.js deleted file mode 100644 index fbc30f49bf6..00000000000 --- a/ghost/admin/app/routes/members.js +++ /dev/null @@ -1,65 +0,0 @@ -import MembersManagementRoute from './members-management'; -import {didCancel} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; - -export default class MembersRoute extends MembersManagementRoute { - @service store; - - queryParams = { - label: {refreshModel: true}, - searchParam: {refreshModel: true, replace: true}, - paidParam: {refreshModel: true}, - orderParam: {refreshModel: true}, - filterParam: {refreshModel: true}, - postAnalytics: {refreshModel: false} - }; - - model(params) { - if (this.feature.membersForward) { - return null; - } - - this.controllerFor('members').resetFilters(params); - return this.controllerFor('members').fetchMembersTask.perform(params); - } - - // trigger a background load of members plus labels for filter dropdown - setupController(controller) { - super.setupController(...arguments); - - if (this.feature.membersForward) { - return; - } - - try { - controller.fetchLabelsTask.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } - } - - resetController(controller, _isExiting, transition) { - super.resetController(...arguments); - - if (controller.postAnalytics) { - controller.set('postAnalytics', null); - // Only reset filters if we are not going to member route - // Otherwise the filters will be gone if we return - if (!transition?.to?.name?.startsWith('member')) { - controller.set('filterParam', null); - } - } - } - - buildRouteInfoMetadata() { - return { - titleToken: 'Members', - mainClasses: ['gh-main-fullwidth'] - }; - } -} diff --git a/ghost/admin/app/routes/members/import.js b/ghost/admin/app/routes/members/import.js deleted file mode 100644 index 23eaf73fad4..00000000000 --- a/ghost/admin/app/routes/members/import.js +++ /dev/null @@ -1,4 +0,0 @@ -import MembersManagementRoute from '../members-management'; - -export default class MembersImportRoute extends MembersManagementRoute { -} diff --git a/ghost/admin/app/services/member-import-validator.js b/ghost/admin/app/services/member-import-validator.js deleted file mode 100644 index 061f1f812cb..00000000000 --- a/ghost/admin/app/services/member-import-validator.js +++ /dev/null @@ -1,133 +0,0 @@ -import Service, {inject as service} from '@ember/service'; -import classic from 'ember-classic-decorator'; -import validator from 'validator'; -import {isEmpty} from '@ember/utils'; - -@classic -export default class MemberImportValidatorService extends Service { - @service ajax; - @service feature; - @service membersUtils; - - @service ghostPaths; - - check(data) { - let sampledData = this._sampleData(data); - let mapping = this._detectDataTypes(sampledData); - return mapping; - } - - /** - * Method implements following sampling logic: - * Locate 10 non-empty cells from the start/middle(ish)/end of each column (30 non-empty values in total). - * If the data contains 30 rows or fewer, all rows should be validated. - * - * @param {Array} data JSON objects mapped from CSV file - * @param {number} validationSampleSize number of rows to sample - */ - _sampleData(data, validationSampleSize = 30) { - let validatedSet = [{}]; - - if (data && data.length > validationSampleSize) { - let sampleKeys = Object.keys(data[0]); - - sampleKeys.forEach(function (key) { - const nonEmptyKeyEntries = data.filter(entry => !isEmpty(entry[key])); - let sampledEntries = []; - - if (nonEmptyKeyEntries.length <= validationSampleSize) { - sampledEntries = nonEmptyKeyEntries; - } else { - // take 3 equal parts from head, tail and middle of the data set - const partitionSize = validationSampleSize / 3; - - const head = data.slice(0, partitionSize); - const tail = data.slice((data.length - partitionSize), data.length); - - const middleIndex = Math.floor(data.length / 2); - const middleStartIndex = middleIndex - 2; - const middleEndIndex = middleIndex + 3; - const middle = data.slice(middleStartIndex, middleEndIndex); - - validatedSet.push(...head); - validatedSet.push(...middle); - validatedSet.push(...tail); - } - - sampledEntries.forEach((entry, index) => { - if (!validatedSet[index]) { - validatedSet[index] = {}; - } - - validatedSet[index][key] = entry[key]; - }); - }); - } else { - validatedSet = data; - } - - return validatedSet; - } - - /** - * Detects supported data types and auto-detects following needed for validation: email - * - * Returned "mapping" object contains mappings that could be accepted by the API - * to map validated types. - * @param {Array} data sampled data containing non empty values - */ - _detectDataTypes(data) { - const supportedTypes = [ - 'email', - 'name', - 'note', - 'subscribed_to_emails', - 'complimentary_plan', - 'stripe_customer_id', - 'labels', - 'created_at' - ]; - - if (this.feature.importMemberTier) { - supportedTypes.push('import_tier'); - } - - if (this.feature.giftSubscriptions) { - supportedTypes.push('gift_id'); - } - - const autoDetectedTypes = [ - 'email' - ]; - - let mapping = {}; - let i = 0; - // looping through all sampled data until needed data types are detected - while (i <= (data.length - 1)) { - if (mapping.email && mapping.stripe_customer_id) { - break; - } - - let entry = data[i]; - for (const [key, value] of Object.entries(entry)) { - if (!mapping.email && validator.isEmail(value)) { - mapping.email = key; - continue; - } - - if (!mapping.name && /name/.test(key)) { - mapping.name = key; - continue; - } - - if (!mapping[key] && supportedTypes.includes(key) && !(autoDetectedTypes.includes(key))) { - mapping[key] = key; - } - } - - i += 1; - } - - return mapping; - } -} diff --git a/ghost/admin/app/services/state-bridge.js b/ghost/admin/app/services/state-bridge.js index 2ce3dfd2900..93cdb0cc7c5 100644 --- a/ghost/admin/app/services/state-bridge.js +++ b/ghost/admin/app/services/state-bridge.js @@ -277,8 +277,8 @@ export default class StateBridgeService extends Service.extend(Evented) { // Check if current route matches any of the specified routes const routeMatches = routes.some((route) => { - // Support both exact matches and subpath matches (e.g., "members" - // matches "members.index") + // Support both exact matches and subpath matches (e.g., "settings" + // matches "settings.history") return currentRouteName === route || currentRouteName.startsWith(route + '.'); }); diff --git a/ghost/admin/app/styles/app-dark.css b/ghost/admin/app/styles/app-dark.css index d3f1763fa2d..f52dd4f9a54 100644 --- a/ghost/admin/app/styles/app-dark.css +++ b/ghost/admin/app/styles/app-dark.css @@ -41,7 +41,6 @@ @import "components/browser-preview.css"; @import "components/stacks.css"; @import "components/browser-preview.css"; -@import "components/filter-builder.css"; @import "components/modal-about.css"; @@ -883,34 +882,6 @@ input:focus, } } -/* Members */ -.members-header .view-actions input.gh-members-list-searchfield { - border-color: var(--lightgrey) -} - -.gh-members-help-card { - background: var(--dark-main-bg-color); -} - -.gh-members-chart-header { - background: var(--white); -} -.gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger { - box-shadow: 0 0 0 1px color-mod(var(--darkgrey) l(-27%) blackness(+15%) alpha(50%)); -} - -.gh-members-import-table::before { - background: #191b1f; -} - -.gh-members-import-table::after { - background: #191b1f; -} -.gh-import-member-select, -.gh-import-member-select select { - background: none !important; -} - .fullscreen-modal-email-preview .gh-pe-mobile-container, .fullscreen-modal-email-preview .gh-pe-desktop-container{ background: var(--dark-main-bg-color); @@ -934,10 +905,6 @@ input:focus, border: 1px solid var(--hairline-color-1); } -.members-list .gh-list-header { - background: var(--dark-main-bg-color); -} - /* Members activity */ .gh-member-newsletter-row { border-bottom: 1px solid var(--grey-900); @@ -1054,10 +1021,6 @@ input:focus, background: linear-gradient(90deg, rgba(21,23,26,1) 90%, rgba(21,23,26,0) 100%); } -.gh-filter-builder .gh-filters { - background: var(--whitegrey); -} - .kg-settings-headerstyle-btn-group .gh-btn { border-color: var(--midlightgrey); } @@ -1166,7 +1129,6 @@ kbd { .gh-pages-placeholder, .gh-posts-placeholder, .gh-tags-placeholder, -.gh-members-empty .gh-members-placeholder, .no-posts .gh-members-placeholder { fill: var(--midgrey-d2); } diff --git a/ghost/admin/app/styles/app.css b/ghost/admin/app/styles/app.css index 5d77aee413d..cebb6e94a23 100644 --- a/ghost/admin/app/styles/app.css +++ b/ghost/admin/app/styles/app.css @@ -42,7 +42,6 @@ @import "components/browser-preview.css"; @import "components/stacks.css"; @import "components/browser-preview.css"; -@import "components/filter-builder.css"; @import "components/pintura.css"; @import "components/modal-about.css"; diff --git a/ghost/admin/app/styles/components/filter-builder.css b/ghost/admin/app/styles/components/filter-builder.css deleted file mode 100644 index c9c2950054b..00000000000 --- a/ghost/admin/app/styles/components/filter-builder.css +++ /dev/null @@ -1,186 +0,0 @@ -.gh-filter-builder { - padding: 20px; - max-width: 780px; - min-width: 400px; -} - -.gh-filter-builder h3 { - font-size: 1.9rem; - font-weight: 600; -} - -.gh-filter-builder .gh-filters { - display: grid; - grid-template-columns: 1fr; - grid-gap: 12px; - background: var(--whitegrey-l1); - border-radius: 3px; - padding: 16px; - margin-top: 20px; -} - -.gh-filter-builder .gh-filter-block { - display: flex; - align-items: center; -} - -.gh-filter-builder .gh-filter-block .form-group { - margin: 0; -} - -.gh-filter-builder .gh-filter-inputgroup { - display: grid; - grid-template-columns: 1fr 146px minmax(0, 1fr) 18px; - grid-column-gap: 8px; -} - -.gh-filter-builder .gh-input, -.gh-filter-builder .gh-select, -.gh-filter-builder select { - height: 33px; - font-size: 1.35rem; -} - -.gh-filter-builder .gh-select svg { - width: 9px; - height: 9px; - margin-right: 0; -} - -.gh-filter-builder .gh-delete-filter { - width: 20px; - height: 33px; - margin-left: 4px; - color: var(--middarkgrey); -} - -.gh-filter-builder .gh-delete-filter:hover, -.gh-filter-builder .gh-delete-filter:focus { - color: var(--red); -} - -.gh-filter-builder .gh-delete-filter svg { - width: 10px; - height: 10px; -} - -.gh-add-filter svg { - width: 10px; - height: 10px; - margin: 0 6px 0 2px; -} - -.gh-filter-builder .gh-btn-text.green.gh-add-filter:hover span, -.gh-filter-builder .gh-btn-text.green.gh-add-filter:focus-visible span { - color: #1da42d; -} - -.gh-filter-builder-footer .gh-btn:not(.gh-btn-primary):focus-visible { - color: #394047; - background: #dde0e2; -} - -.gh-filter-builder-footer .gh-btn.gh-btn-primary:focus-visible { - box-shadow: 0 0 0 2px var(--green-d2); -} - -.gh-filter-builder .gh-filter-block-divider { - display: flex; - align-items: center; - font-size: 1.1rem; - font-weight: 500; - letter-spacing: .1px; - color: var(--midgrey); - text-transform: uppercase; - margin: 12px 0; -} - -.gh-filter-builder .gh-filter-block-divider::before { - content: ""; - display: block; - width: 16px; - height: 1px; - background: var(--whitegrey-d2); - margin: 0 4px 0 -16px; -} - -.gh-filter-builder .gh-filter-block-divider::after { - content: ""; - flex-grow: 1; - display: block; - height: 1px; - background: var(--whitegrey-d2); - margin: 0 -16px 0 4px; -} - -.gh-filter-builder-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 20px; -} - -.gh-filter-block .label-token { - margin: 2px !important; -} - -.gh-filter-block .token-segment-tier { - margin: 2px !important; -} - -.gh-filter-block .token-segment-tier .ember-power-select-multiple-remove-btn svg { - margin-right: 0!important; -} - -.gh-filter-builder .ember-power-select-multiple-trigger { - padding: 2px; -} - -.gh-filter-builder .ember-power-select-dropdown.ember-basic-dropdown-content--below { - font-size: 1.3rem; -} - -.gh-filter-builder .ember-power-select-trigger { - max-height: 72px; -} - -.gh-filter-builder .ember-power-select-option { - padding: 6px 0px 6px 12px; -} - -.gh-filter-builder .ember-power-select-multiple-option { - padding: 1px 1px 1px 6px; - z-index: 9999; -} - -.gh-filter-builder .ember-power-select-trigger-multiple-input { - height: 23px; - display: flex; -} - -.gh-filter-builder .ember-power-select-multiple-options { - padding-right: 28px; -} - -@media (max-width: 690px) { - .gh-filter-builder .gh-filter-inputgroup { - grid-template-columns: 1fr 18px; - grid-template-rows: 3fr; - grid-row-gap: 4px; - margin-bottom: 12px; - } - - .gh-filter-builder .gh-filter-inputgroup :not(.gh-delete-filter) { - grid-column: 1 / 2; - } - - .gh-filter-builder .gh-filter-inputgroup .gh-delete-filter { - grid-row: 1/2; - grid-column: 2/3; - } - - .gh-filter-builder .gh-filters { - max-height: calc(75vh - 180px); - overflow-y: auto; - } -} diff --git a/ghost/admin/app/styles/layouts/dashboard.css b/ghost/admin/app/styles/layouts/dashboard.css index d57d9f2f4c2..7a482a39bc3 100644 --- a/ghost/admin/app/styles/layouts/dashboard.css +++ b/ghost/admin/app/styles/layouts/dashboard.css @@ -323,10 +323,6 @@ Dashboard Layout */ margin-bottom: 12px; } -.gh-dashboard .gh-members-help { - margin-top: 0; -} - .gh-dashboard-select { position: absolute; top: 14px; @@ -1654,10 +1650,6 @@ Dashboard Resources */ margin-top: 20px; } -.gh-dashboard-resources .gh-members-help-card { - padding: 24px; -} - .gh-dashboard-resources .gh-dashboare5-article-content { display: flex; flex-direction: row; @@ -1709,26 +1701,6 @@ Dashboard Multi */ margin-top: 20px; } -.gh-dashboard-multi .gh-members-help-card { - flex: 1; - padding: 24px; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: space-between; - background: var(--white); - border-radius: 3px; - box-shadow: 0 2px 4px rgb(0 0 0 / 7%); - color: #7c8b9a; - font-size: 1.4rem; - transition: none; - margin-right: 16px; -} - -.gh-dashboard-multi .gh-members-help-card:hover { - transform: translate(0); -} - .gh-dashboard-multi .gh-dashboard-list-header { padding-bottom: 12px; } diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index 4ee65132afc..664f45d4755 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -1,10 +1,3 @@ -/* Global -/* ----------------------------------------- */ -:root { - --member-import-table-outline: var(--whitegrey-d2); - --member-import-table-border: var(--whitegrey-d1); -} - /* Members avatar /* ----------------------------------------- */ .gh-member-gravatar { @@ -50,1768 +43,698 @@ box-shadow: 0 0 0 1px var(--main-bg-color); } -/* Members list +/* Shared member rows /* ----------------------------------------- */ -.members-list { - table-layout: fixed; +p.gh-members-list-email { + margin: -2px 0 -1px; } -.members-list-container-stretch { - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: calc(100vh - 144px); /*Height of top menu + negative margin*/ - padding-bottom: 0; +.gh-member-list-avatar { + font-size: 1.65rem; + font-weight: 500; + line-height: 0; + letter-spacing: -0.6px; } -@media (max-width: 1450px) { - .members-list-container-stretch { - min-height: calc(100vh - 176px); - overflow: hidden; - } +.gh-members-list-email, +.gh-members-list-name { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; } -@media (max-width: 1100px) { - .members-list { - border-bottom: none - } +.gh-list h3.gh-members-name-noname { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.members-list .gh-list-row.header { - z-index: 1; +.gh-member-actions-menu { + top: calc(100% + 6px); + left: auto; + right: 0; } -.members-list .gh-list-header { - position: sticky; - top: 96px; - z-index: 1; - background: var(--white); +.gh-member-actions-menu.fade-out { + animation-duration: .001s; + pointer-events: none; } -.gh-list-scrolling-h .members-list .gh-list-header { - top: 0; +/* Member details +/* ----------------------------------------- */ + +label[for="member-description"] + p { + margin: 0 0 4px; } -.gh-list-with-helpsection { - height: unset; - margin: 0 -48px; +.gh-member-settings .gh-main-section.columns-3 { + grid-column-gap: 48px; } -.members-header .gh-canvas-header-content { - z-index: 1100; /* Ensure the header content is above loading spinner */ +.gh-member-details { + position: sticky; + top: 158px; + left: 0; + height: max-content; } -.members-header .view-actions input.gh-members-list-searchfield { - min-width: 220px; - padding-left: 32px; - height: 34px; - background: var(--whitegrey-l1); - border: var(--input-border); - border-color: transparent; +.gh-member-details h3 { + margin: 0; + padding: 0; + font-size: 1.6rem; + font-weight: 600; } -.members-header.grey .view-actions .gh-btn, -.members-header.grey .view-actions input.gh-members-list-searchfield { - background: color-mod(var(--whitegrey) l(-1%)); +.gh-member-details p { + margin: 0; + padding: 0; + font-size: 1.4rem; + color: var(--darkgrey-l1); } -.members-header .view-actions input.gh-members-list-searchfield:focus { - background: var(--white); - border-color: var(--green); +.gh-member-details a, +.gh-member-details a:hover { + color: var(--darkgrey-l1); + word-break: break-all; } -.members-header .view-actions .gh-input-search-icon { - width: 16px; - height: 16px; - top: 9px; - left: 9px; - fill: var(--midlightgrey); +.gh-member-details-identity { + display: flex; + align-items: center; } -.members-header.black .view-actions input.gh-members-list-searchfield { - background: var(--darkgrey-d1); +.gh-member-details-meta { + display: grid; + padding: 3.2rem 0; } -.gh-members-list-searchfield.active { - border-color: var(--green) !important; - box-shadow: inset 0 0 0 1px var(--green); +.gh-member-details-meta p { + display: flex; + align-items: center; + white-space: nowrap; + min-width: 0; } -.gh-members-list-checkbox { - width: 36px; +.gh-member-details-meta .gh-member-last-seen { + margin-top: -1px; } -p.gh-members-list-email { - margin: -2px 0 -1px; +.gh-member-details-meta svg { + width: 1.6rem; + height: 1.6rem; + margin-right: .8rem; + flex-shrink: 0; } -.gh-members-list-open-rate, -.gh-members-list-geolocation { - width: 150px; +.gh-member-details-meta p a { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-weight: 600; + color: var(--darkgrey); } -.gh-members-list-subscribed-at { - width: 220px; - margin-right: -8px; - padding-right: 0; +.gh-member-details-meta svg path { + stroke: var(--midgrey); } -.gh-members-list-labels { - display: inline-block; - max-width: 300px; - min-width: 220px; - white-space: wrap; +.gh-member-details-meta svg line { + stroke: var(--midgrey); } -.gh-members-list-feedback{ - display: flex; - align-items: center; +.gh-member-commenting-disabled { + margin-top: -1px; } -.gh-members-list-feedback svg { - width: 24px; - min-width: 24px; - height: 24px; - margin-right: 3px; +.gh-member-details-enable-link { + opacity: 0; + transition: opacity 0.15s ease; } -.gh-member-list-avatar { - font-size: 1.65rem; - font-weight: 500; - line-height: 0; - letter-spacing: -0.6px; +.gh-member-commenting-disabled:hover .gh-member-details-enable-link, +.gh-member-commenting-disabled:focus-within .gh-member-details-enable-link { + opacity: 1; } -.gh-member-actions-menu { - top: calc(100% + 6px); - left: auto; - right: 0; +.gh-member-details-enable-link button { + background: none; + border: none; + padding: 0; + color: var(--green); + cursor: pointer; + text-decoration: underline; + font-size: inherit; } -.gh-member-actions-menu.fade-out { - animation-duration: .001s; - pointer-events: none; +.gh-member-details-attribution { + display: grid; + grid-template-columns: 1fr; + padding: 0 0 3.2rem 0; } -.member-link-copied svg { - margin-right: 4px; +.gh-member-details-attribution svg { + width: 1.6rem; + height: 1.6rem; + margin-right: .8rem; + flex-shrink: 0; } -.gh-members-chart-header { +.gh-member-details-attribution p { display: flex; align-items: center; - justify-content: space-between; - padding: 16px 24px 4px 0; - margin-bottom: 10px; + white-space: nowrap; + min-width: 0; } -.gh-members-chart-header .gh-contentfilter { - margin: 0 0 0 20px; - height: 16px; +.gh-member-details-attribution p a, .gh-member-details-attribution p span { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-weight: 600; + color: var(--darkgrey); } -.gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger { - border-radius: 3px; - height: 16px; - padding: 0 8px; - margin-right: 0; +.gh-member-details-attribution p a::first-letter { + text-transform: capitalize; } -.gh-members-chart-dropdown { - margin-left: -103px; +.gh-member-details-attribution svg path, .gh-member-details-attribution svg circle { + stroke: var(--midgrey); + fill: none; } -.gh-members-chart-xlabels { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 1.3rem; - color: var(--middarkgrey); - padding: 0 28px 16px 0; +.gh-member-details-attribution .gh-main-section-header { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--whitegrey); + grid-column: 1 !important; } -.gh-members-chart-summary { +.gh-member-details-stats-container { display: flex; flex-direction: column; - justify-content: space-between; - flex-basis: 28%; - min-width: 280px; } -.gh-members-chart-summary section { - flex: 1 1 auto; - min-width: 0; - min-height: 0; +.gh-member-details-stats { display: flex; flex-direction: column; - align-items: flex-start; - justify-content: center; - padding: 16px 24px; } -.gh-members-chart-summary-heading { - margin: 0; - padding: 0; +.gh-member-details-stat { + display: flex; + flex-direction: column; + margin-bottom: 1.6rem; } -.gh-members-chart.black { - background: var(--black); +.gh-member-details-stats-container .gh-main-section-header { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--whitegrey); } -.gh-members-chart.black .gh-members-chart-header { - border-color: var(--darkgrey); +.gh-member-details-stat.open-rate span { + margin-left: 2px; + font-size: 1.8rem; } -.gh-members-chart.black .gh-members-chart-summary-heading { - color: var(--midlightgrey); +.gh-members-no-stats p { + color: var(--midgrey); + font-size: 1.3rem; + line-height: 1.5em; } -.gh-members-chart.black .gh-members-chart-summary-data { - color: var(--whitegrey); +textarea.gh-member-details-textarea { + max-width: 100%; + min-width: auto; + min-height: 50px; + height: 85px; } -.gh-members-chart.black .gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger, -.gh-members-chart-box.black .gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger { - background: transparent; - border: 1px solid var(--darkgrey); - color: var(--whitegrey); +.gh-member-info-icon { + width: 18px; + height: 18px; } -.gh-members-chart.black .gh-contentfilter-menu-trigger svg path { - stroke: var(--whitegrey) !important; +.gh-member-email-stats { + font-size: 3.6rem; + color: var(--darkgrey); + line-height: 4.0rem; } -.gh-members-chart-box.black .gh-members-chart-summary-heading { - color: var(--lightgrey); +.gh-member-header-stripeinfo { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 24px; + margin-top: -8px; } -.gh-members-chart-box.black .gh-members-chart-header { - border-color: var(--darkgrey); +.gh-member-stripe-info { + margin-top: 24px; } -.members-header .gh-contentfilter { - margin-right: 0; +.gh-member-stripe-info p { + font-size: 1.25rem; + font-weight: 400; + margin: 4px 0 0; } -.members-header .gh-contentfilter-tag .gh-contentfilter-menu-trigger { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - border-top-right-radius: 0px !important; - border-bottom-right-radius: 0px !important; +.gh-member-stripe-table { + width: 100%; + margin: 6px 0 12px; } -.dropdown.members-label-list { - width: 225px; +@media (max-width: 1160px) { + .gh-member-stripe-table { + max-width: 520px; + } } -.dropdown.members-label-list .dropdown-label { - width: 205px; +.gh-member-stripe-table td { + vertical-align: top; + font-size: 1.3rem; } -.gh-members-chart + .content-list .members-list { - margin-top: var(--main-layout-section-vpadding); +.gh-member-stripe-id, +.gh-member-stripe-email { + display: inline-block; + word-break: break-all; } -.gh-members-list-subscribed-moment::first-letter { - text-transform: uppercase; +.gh-member-stripe-label { + color: var(--midgrey-d1); + white-space: nowrap; + padding: 5px 12px 5px 0; + width: 170px; } -.gh-members-list-row .gh-list-data:first-child { - width: 30%; - min-width: 360px; - padding-right: 20px !important; +.gh-member-stripe-data { + padding: 5px 12px 5px 0; } -.gh-members-list-row .gh-list-data[data-test-table-data="status"], - .gh-members-list-row .gh-list-data[data-test-table-data="open-rate"] { - width: 90px; - max-width: 90px; - min-width: 90px; -} +@media (max-width: 1400px) and (min-width: 1160px) { + .gh-member-stripe-row { + display: flex; + flex-direction: column; + } -.gh-members-list-row .gh-list-data[data-test-table-data="location"] { - width: 150px; - max-width: 150px; - min-width: 150px; -} + .gh-member-stripe-label { + padding-bottom: 0; + font-weight: 500; + } -.gh-members-list-row .gh-list-data[data-test-table-data="created-at"] { - width: 120px; - max-width: 120px; - min-width: 120px; -} + .gh-member-stripe-data { + padding-top: 0; + } + .gh-members-comped { + flex-direction: column; + align-items: flex-start; + } -.gh-members-list-row .gh-list-data.member-filter-column { - width: 250px; - max-width: 250px; - min-width: 250px; + .gh-members-comped-switch { + margin-top: 2rem; + } } -/* Ensure table headers have consistent widths with data cells */ -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="status"], -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="email_open_rate"] { - width: 90px; - max-width: 90px; - min-width: 90px; +.gh-members-subscribed-checkbox, +.gh-members-comped-checkbox { + max-width: 100%; + margin-top: 24px; + margin-bottom: 0; } -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="location"] { - width: 150px; - max-width: 150px; - min-width: 150px; +.gh-new-member-avatar { + background: var(--midlightgrey-l1); + width: 81px; + height: 81px; } -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="created"] { - width: 120px; - max-width: 120px; - min-width: 120px; +.gh-member-cancels-on-label { + display: inline-block; + background: color-mod(var(--pink) a(10%)); + border-radius: 4px; + padding: 0px 5px; + margin: -2px 0 -2px -5px; + color: var(--pink); + font-size: 1.3rem; + font-weight: 400; } -@media (max-width: 1100px) { - .gh-members-chart-summary-data { - font-size: 2.8rem; - line-height: 2.8rem; - } +.gh-member-stripe-status { + display: inline-block; + text-transform: capitalize; + margin-right: 6px; } -@media (max-width: 1000px) { - .members-list .gh-list-header, .gh-list-hidecell-m { - display: table-cell; - } +.gh-member-btn-contsub { + border-color: var(--blue); + box-shadow: none; } -@media (max-width: 800px) { - .gh-list-with-helpsection { - margin-left: 0; - margin-right: 0; - } - - .gh-members-list-row .gh-list-data:first-child { - min-width: 280px; - overflow-x: hidden; - } - - .gh-members-list-email, - .gh-members-list-name { - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - } +.gh-member-btn-contsub:hover { + border-color: color-mod(var(--blue) l(-7%) saturation(-10%)); } -@media (min-width: 440px) and (max-width: 1000px) { - .gh-members-chart-summary { - flex-direction: row; - } - - .gh-members-chart-summary div { - flex-basis: 33%; - border-bottom: none; - justify-content: flex-start; - } - - .gh-members-chart-summary > div:nth-of-type(1), - .gh-members-chart-summary > div:nth-of-type(2) { - border-right: 1px solid var(--whitegrey); - } +.gh-member-btn-contsub span { + color: var(--blue); } -@media (max-width: 1100px) { - .members-list .gh-list-header, .gh-list-hidecell-m { - display: none; - } - - .gh-members-list-basic { - display: block; - flex: 1 1 100%; - } - - .gh-members-list-subscribed-at { - display: inline-block; - width: auto; - padding: 0 0 16px 4px; - margin-top: -16px; - font-size: 1.2rem; - } - - .gh-members-list-subscribed-at div { - display: inline; - margin-right: 1px; - } - - .gh-members-list-subscribed-moment::first-letter { - text-transform: none; - } - - .gh-members-list-subscribed-moment::before { - content: "("; - } - - .gh-members-list-subscribed-moment::after { - content: ")"; - } - - .gh-members-list-chevron { - display: block; - position: absolute; - right: 0; - top: 0; - bottom: 0; - } - - .gh-list h3.gh-members-name-noname { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .gh-members-subscribed-noname { - display: inline-block; - margin-top: -32px; - padding-bottom: 16px; - } - - .gh-members-list-open-rate { - display: inline-block; - width: auto; - margin-top: -16px; - padding: 0 0 0 49px; - } - - .gh-members-list-open-rate-noname { - margin-top: -32px; - padding-bottom: 16px; - } - - .gh-members-list-geolocation { - display: inline-block; - width: auto; - margin-top: -16px; - padding: 0; - } - - .gh-members-list-geolocation::after { - content: "•"; - } - - .gh-members-geolocation-noname { - margin-top: -32px; - padding-bottom: 16px; - } +.gh-member-btn-contsub:hover span { + color: color-mod(var(--blue) l(-7%) saturation(-10%)); } -@media (max-width: 600px) { - .gh-members-list-subscribed-moment { - display: none; - } - - .gh-members-list-chevron { - display: none; - } - - .members-header .view-actions .gh-members-header-search { - width: 100%; - } +.gh-member-internal-info, +.gh-member-stripe { + float: right; } -@media (max-width: 450px) { - .members-header { - justify-content: flex-end; - min-height: 120px; - } - - .members-header.gh-canvas-header.break.tablet .view-actions { - top: 0; - } - - .members-header.gh-canvas-header.break.tablet .view-actions-bottom-row { - width: 100% !important; - max-width: 100% !important; +@media (max-width: 1160px) { + .gh-member-settings .gh-main-section { + display: flex; + flex-direction: column; } - .members-header .view-actions { - margin-top: 2px; + .gh-member-settings .gh-main-section > div { + float: none; width: 100%; } - .members-header .members-actions-dropdown { - display: none; - } - - .members-header .view-actions .gh-members-header-search { - width: 100%; + .gh-member-details { + position: relative; + top: unset; + left: unset; } - .view-actions input.gh-members-list-searchfield { - min-width: 0; + .gh-member-header.sticky { + position: relative; } } -/* Members empty state -/* ----------------------------------------- */ - -.gh-members-empty { - display: flex; - flex-direction: column; - flex-grow: 1; - align-items: center; - justify-content: center; - padding: 2vw 4vw; -} - -.gh-members-empty .gh-members-placeholder { - fill: var(--lightgrey); - width: 60px; - height: 60px; - margin-bottom: 32px; -} - -.gh-members-empty h4 { - color: var(--black); - text-align: center; - font-weight: 600; - margin-bottom: 8px; -} - -.gh-members-empty p { - max-width: 390px; - color: var(--midgrey); - text-align: center; - line-height: 1.45em; - margin: 0 0 20px; - padding: 0; -} - -.gh-members-empty .gh-members-empty-secondary-cta { - margin-top: 3.2rem; - max-width: max-content; -} - -.gh-members-empty-secondary-cta a { - font-weight: 500; -} - -.gh-members-help { - margin-top: 40px; - margin-bottom: 0; -} - -.gh-members-help .gh-main-section-content { - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-gap: 24px; - padding: 0; - border: none; - box-shadow: none; -} - -@media (max-width: 1080px) { - .gh-members-help .gh-main-section-content { - grid-template-columns: 1fr; +@media (min-width: 960px) and (max-width: 1160px), +(min-width: 600px) and (max-width: 800px) { + .gh-member-details-stats { + flex-direction: row; + justify-content: space-between; } } -.gh-members-help-card { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: space-between; - padding: 24px; - background: var(--white); - border: 1px solid var(--whitegrey); - color: var(--midgrey); - font-size: 1.4rem; - border-radius: 12px; - box-shadow: 0 1px 4px -1px rgb(0 0 0 / 10%); - transition: all 0.15s ease-in-out; -} - -.gh-members-help-card p { - line-height: 1.4em; - margin-top: 12px; -} - -.gh-members-help-card .thumbnail { - width: 100%; - height: 200px; - max-width: 200px; - background-size: cover; - background-position: left -80px top 0; - aspect-ratio: 1 / 1; -} - -.gh-members-help-card .thumbnail.right { - background-position: left -40px top 0; -} - -@media (max-width: 620px), -(min-width: 800px) and (max-width: 960px), -(min-width: 1080px) and (max-width: 1440px) { - .gh-members-help-card .thumbnail { - max-width: unset; - margin-top: 2rem; - background-position: unset; - } - - .gh-members-help-card .thumbnail.right { - background-position: unset; - } +.gh-member-labels, +.gh-member-note { + max-width: none; } - - -.gh-members-help-card:hover { - box-shadow: - 0 0 1px rgba(0,0,0,.12), - 0 1px 6px rgba(0,0,0,.03), - 0 8px 10px -8px rgba(0,0,0,.1); - transition: all 0.15s ease-in-out; +.gh-member-cancelstripe-checkbox { + margin-bottom: 4px; } -.gh-members-help-content { +.gh-member-cancelstripe-checkbox label { display: flex; - width: 100%; - height: 100%; -} - -@media (max-width: 620px), -(min-width: 800px) and (max-width: 960px), -(min-width: 1080px) and (max-width: 1440px) { - .gh-members-help-content { - flex-direction: column; - } -} - -.gh-members-help-content .text { - position: relative; - margin: 2rem 0 0 3.2rem; - flex-grow: 1; -} - -.gh-members-help-content .gh-btn-link { - position: absolute; - bottom: 0; - margin: 1rem 0; -} - -@media (min-width: 1440px) and (max-width: 1560px) { - .gh-members-help-content .text { - margin: 0 0 0 2rem; - } - - .gh-members-help-content .gh-btn-link { - margin: 0; - } } -@media (max-width: 620px), -(min-width: 800px) and (max-width: 960px), -(min-width: 1080px) and (max-width: 1440px) { - .gh-members-help-content .text { - margin: 2rem 0 0; - } - - .gh-members-help-content .text p { - margin-bottom: 2.8em; - } - - .gh-members-help-content .gh-btn-link { - margin: 0; - } +.gh-member-cancelstripe-checkbox h4 { + font-size: 1.4rem; + font-weight: 600; + line-height: 1.15em; + margin-top: 2px; } -/* Member details -/* ----------------------------------------- */ - -label[for="member-description"] + p { - margin: 0 0 4px; +.gh-member-cancelstripe-checkbox label p { + margin-top: -2px; + color: var(--middarkgrey); } -.gh-member-settings .gh-main-section.columns-3 { - grid-column-gap: 48px; +.gh-member-cancelstripe-checkbox input:checked + .input-toggle-component { + border-color: color-mod(var(--red) l(-5%)); + background: var(--red); } -.gh-member-details { - position: sticky; - top: 158px; - left: 0; - height: max-content; +.gh-members-no-data { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; } -.gh-member-details h3 { - margin: 0; - padding: 0; - font-size: 1.6rem; - font-weight: 600; +.gh-members-no-data svg { + width: 56px; + height: auto; + margin-bottom: 8px; } -.gh-member-details p { - margin: 0; - padding: 0; - font-size: 1.4rem; - color: var(--darkgrey-l1); +.gh-members-no-data svg path, +.gh-members-no-data svg rect, +.gh-members-no-data svg circle { + stroke-width: 0.8px; } -.gh-member-details a, -.gh-member-details a:hover { - color: var(--darkgrey-l1); - word-break: break-all; -} - -.gh-member-details-identity { - display: flex; - align-items: center; -} - -.gh-member-details-meta { - display: grid; - padding: 3.2rem 0; -} - -.gh-member-details-meta p { - display: flex; - align-items: center; - white-space: nowrap; - min-width: 0; -} - -.gh-member-details-meta .gh-member-last-seen { - margin-top: -1px; -} - -.gh-member-details-meta svg { - width: 1.6rem; - height: 1.6rem; - margin-right: .8rem; - flex-shrink: 0; -} - -.gh-member-details-meta p a { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - font-weight: 600; - color: var(--darkgrey); -} - -.gh-member-details-meta svg path { - stroke: var(--midgrey); -} - -.gh-member-details-meta svg line { - stroke: var(--midgrey); -} - -.gh-member-commenting-disabled { - margin-top: -1px; -} - -.gh-member-details-enable-link { - opacity: 0; - transition: opacity 0.15s ease; -} - -.gh-member-commenting-disabled:hover .gh-member-details-enable-link, -.gh-member-commenting-disabled:focus-within .gh-member-details-enable-link { - opacity: 1; -} - -.gh-member-details-enable-link button { - background: none; - border: none; - padding: 0; - color: var(--green); - cursor: pointer; - text-decoration: underline; - font-size: inherit; -} - -.gh-member-details-attribution { - display: grid; - grid-template-columns: 1fr; - padding: 0 0 3.2rem 0; -} - -.gh-member-details-attribution svg { - width: 1.6rem; - height: 1.6rem; - margin-right: .8rem; - flex-shrink: 0; -} - -.gh-member-details-attribution p { - display: flex; - align-items: center; - white-space: nowrap; - min-width: 0; -} - -.gh-member-details-attribution p a, .gh-member-details-attribution p span { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - font-weight: 600; - color: var(--darkgrey); -} - -.gh-member-details-attribution p a::first-letter { - text-transform: capitalize; -} - -.gh-member-details-attribution svg path, .gh-member-details-attribution svg circle { - stroke: var(--midgrey); - fill: none; -} - -.gh-member-details-attribution .gh-main-section-header { - margin-bottom: 1.6rem; - border-bottom: 1px solid var(--whitegrey); - grid-column: 1 !important; -} - -.gh-member-details-stats-container { - display: flex; - flex-direction: column; -} - -.gh-member-details-stats { - display: flex; - flex-direction: column; -} - -.gh-member-details-stat { - display: flex; - flex-direction: column; - margin-bottom: 1.6rem; -} - -.gh-member-details-stats-container .gh-main-section-header { - margin-bottom: 1.6rem; - border-bottom: 1px solid var(--whitegrey); -} - -.gh-member-details-stat.open-rate span { - margin-left: 2px; - font-size: 1.8rem; -} - -.gh-members-no-stats p { - color: var(--midgrey); - font-size: 1.3rem; - line-height: 1.5em; -} - -textarea.gh-member-details-textarea { - max-width: 100%; - min-width: auto; - min-height: 50px; - height: 85px; -} - -.gh-member-info-icon { - width: 18px; - height: 18px; -} - -.gh-member-email-stats { - font-size: 3.6rem; - color: var(--darkgrey); - line-height: 4.0rem; -} - -.gh-member-header-stripeinfo { - display: flex; - align-items: center; - justify-content: flex-start; - min-height: 24px; - margin-top: -8px; -} - -.gh-member-stripe-info { - margin-top: 24px; -} - -.gh-member-stripe-info p { - font-size: 1.25rem; - font-weight: 400; - margin: 4px 0 0; -} - -.gh-member-stripe-table { - width: 100%; - margin: 6px 0 12px; -} - -@media (max-width: 1160px) { - .gh-member-stripe-table { - max-width: 520px; - } -} - -.gh-member-stripe-table td { - vertical-align: top; - font-size: 1.3rem; -} - -.gh-member-stripe-id, -.gh-member-stripe-email { - display: inline-block; - word-break: break-all; -} - -.gh-member-stripe-label { - color: var(--midgrey-d1); - white-space: nowrap; - padding: 5px 12px 5px 0; - width: 170px; -} - -.gh-member-stripe-data { - padding: 5px 12px 5px 0; -} - -@media (max-width: 1400px) and (min-width: 1160px) { - .gh-member-stripe-row { - display: flex; - flex-direction: column; - } - - .gh-member-stripe-label { - padding-bottom: 0; - font-weight: 500; - } - - .gh-member-stripe-data { - padding-top: 0; - } - .gh-members-comped { - flex-direction: column; - align-items: flex-start; - } - - .gh-members-comped-switch { - margin-top: 2rem; - } -} - -.gh-members-subscribed-checkbox, -.gh-members-comped-checkbox { - max-width: 100%; - margin-top: 24px; - margin-bottom: 0; -} - -.gh-new-member-avatar { - background: var(--midlightgrey-l1); - width: 81px; - height: 81px; -} - -.gh-member-cancels-on-label { - display: inline-block; - background: color-mod(var(--pink) a(10%)); - border-radius: 4px; - padding: 0px 5px; - margin: -2px 0 -2px -5px; - color: var(--pink); - font-size: 1.3rem; - font-weight: 400; -} - -.gh-member-stripe-status { - display: inline-block; - text-transform: capitalize; - margin-right: 6px; -} - -.gh-member-btn-contsub { - border-color: var(--blue); - box-shadow: none; -} - -.gh-member-btn-contsub:hover { - border-color: color-mod(var(--blue) l(-7%) saturation(-10%)); -} - -.gh-member-btn-contsub span { - color: var(--blue); -} - -.gh-member-btn-contsub:hover span { - color: color-mod(var(--blue) l(-7%) saturation(-10%)); -} - -.gh-member-internal-info, -.gh-member-stripe { - float: right; -} - -@media (max-width: 1160px) { - .gh-member-settings .gh-main-section { - display: flex; - flex-direction: column; - } - - .gh-member-settings .gh-main-section > div { - float: none; - width: 100%; - } - - .gh-member-details { - position: relative; - top: unset; - left: unset; - } - - .gh-member-header.sticky { - position: relative; - } -} - -@media (min-width: 960px) and (max-width: 1160px), -(min-width: 600px) and (max-width: 800px) { - .gh-member-details-stats { - flex-direction: row; - justify-content: space-between; - } -} - -.gh-member-labels, -.gh-member-note { - max-width: none; -} - -.gh-member-cancelstripe-checkbox { - margin-bottom: 4px; -} - -.gh-member-cancelstripe-checkbox label { - display: flex; -} - -.gh-member-cancelstripe-checkbox h4 { - font-size: 1.4rem; - font-weight: 600; - line-height: 1.15em; - margin-top: 2px; -} - -.gh-member-cancelstripe-checkbox label p { - margin-top: -2px; - color: var(--middarkgrey); -} - -.gh-member-cancelstripe-checkbox input:checked + .input-toggle-component { - border-color: color-mod(var(--red) l(-5%)); - background: var(--red); -} - -.gh-members-no-data { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.gh-members-no-data svg { - width: 56px; - height: auto; - margin-bottom: 8px; -} - -.gh-members-no-data svg path, -.gh-members-no-data svg rect, -.gh-members-no-data svg circle { - stroke-width: 0.8px; -} - -.gh-members-no-data h4 { - font-size: 1.5rem; - letter-spacing: 0; - font-weight: 600; - color: var(--middarkgrey); -} - -.gh-members-no-data p { - margin: 0 40px .3rem; - color: var(--midgrey); - font-size: 1.3rem; - line-height: 1.5em; -} - -.gh-members-no-list h4 { - margin-top: 8px; -} - -.gh-members-no-list svg path { - stroke-width: 1px; -} - -.gh-members-no-subs svg { - width: 52px; - margin-left: 12px; -} - -.gh-members-no-subs svg path, -.gh-members-no-subs svg rect, -.gh-members-no-subs svg circle { - stroke-width: 1px; -} - -.gh-member-newsletters { - padding: 16px 20px; - background: var(--main-bg-color); - box-shadow: 0 1px 4px -1px rgb(0 0 0 / 10%); - border-radius: 3px; -} - -.gh-member-newsletter-row { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--grey-250); - padding: 16px 0; -} - -.gh-member-newsletter-row .for-switch { - display: flex; -} - -.gh-member-newsletter-row:first-child { - padding-top: 0; -} - -.gh-member-newsletter-row:last-child { - padding-bottom: 0; - border-bottom: none; -} - -.gh-member-newsletter-title { - font-weight: 600; - font-size: 1.4rem !important; - margin-bottom: 0!important; -} - -.gh-main-section-content.gh-member-newsletter-section { - padding-bottom: 16px; -} - -.gh-member-newsletter-no-data { - padding: 24px 0 28px; -} - -.gh-member-newsletter-no-data .gh-member-newsletter-icon path { - stroke-width: 2.8; -} - -.gh-member-newsletter-no-data a { - color: var(--midgrey); - opacity: 0.6; - font-weight: 500; - letter-spacing: 0.02em; - text-decoration: underline; -} - -.gh-member-newsletter-no-data p { - margin-bottom: 0.8rem; -} - -.gh-member-newsletter-footer { - font-size: 1.3rem; - margin-top: 12px; -} - -.gh-member-feed-container { - display: flex; - flex-grow: 1; - flex-direction: row; - align-items: center; - padding: 1.6rem 0; -} - -.gh-member-feed { - margin: 0; - padding: 20px; - background: var(--white); - box-shadow: 0 1px 4px -1px rgba(0,0,0,0.1); - border-radius: 3px; -} - -.gh-member-settings .gh-member-feed-no-data { - margin: 0; - padding: 24px 0 28px; - background: transparent; - box-shadow: none; -} - -.gh-member-feed-row { - display: flex; - align-items: center; - padding: 0; -} - -.gh-member-feed-activity { - display: flex; - align-items: center; - padding: 12px 0; -} - -.gh-member-feed-activity svg { - width: 16px; - margin-right: 1rem; -} - -.gh-member-feed-row a { - font-weight: 600; - font-size: 14px; - color: var(--darkgrey); -} - -.gh-member-feed-title { - display: table-cell; - padding: 10px 0; - line-height: 1.4em; - vertical-align: middle; - color: var(--darkgrey); - text-align: left; - font-weight: 500; -} - -.gh-member-feed-title:hover { - color: var(--black); -} - -.gh-member-feed-title a { - color: var(--darkgrey); -} - -.gh-member-feed-title a:hover { - color: var(--black); -} - -.gh-member-feed-date { - margin-left: auto; - padding: 10px 0 10px 16px; - color: var(--midgrey); - font-size: 1.3rem; - text-align: right; - white-space: nowrap; -} - -.gh-member-feed-row:hover .gh-member-feed-date { - color: var(--darkgrey); -} - -.gh-member-feed-row:first-child .gh-member-feed-container { - padding-top: 0; -} - -.gh-member-feed-row { - border-bottom: 1px solid var(--grey-250); -} - -.gh-member-feed-detail { - display: flex; - flex-grow: 1; - justify-content: space-between; - align-items: center; - width: 0; -} - -.gh-member-feed-event { - flex: 1; - min-width: 0; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; -} - -.gh-member-feed-event-inner { - color: var(--middarkgrey); - font-weight: 500; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - padding-right: 1rem; - width: 100%; -} - -.gh-member-feed-event-inner:first-letter { - text-transform: uppercase; -} - -.gh-member-feed-icon { - flex-shrink: 0; - width: 24px; - height: 24px; - margin: 0 0.5rem 0 -1px; -} - -.gh-member-feed-icon svg { - width: 100%; - height: 100%; -} - -.gh-member-feed-time { - font-weight: 500; - font-size: 1.3rem; - color: var(--midlightgrey); - white-space: nowrap; -} - -.gh-member-feed-footer { - padding-top: 16px; -} - -.gh-member-feed-footer a { - font-weight: 500; - transition: color 100ms ease; -} - -.gh-member-feed-footer a:hover { - color: #269a34; -} - - -/* Import modal -/* ---------------------------------------------------------- */ - -.fullscreen-modal-import-members { - max-width: unset !important; -} - -.gh-member-import-wrapper { - width: 420px; -} - -.gh-member-import-wrapper.wide { - width: 580px; -} - -.gh-member-import-wrapper .gh-btn.disabled, -.gh-member-import-wrapper .gh-btn.disabled:hover { - cursor: auto !important; - opacity: 0.6 !important; -} - -.gh-member-import-wrapper .gh-btn.disabled span, -.gh-member-import-wrapper .gh-btn.disabled span:hover { - cursor: auto !important; - pointer-events: none; -} - -.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger[aria-disabled=true], -.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger-multiple-input:disabled { - background: var(--whitegrey-l2); -} - -@media (max-width: 600px) { - .gh-member-import-wrapper, - .gh-member-import-wrapper.wide { - width: calc(100vw - 128px); - } -} - -.gh-members-import-uploader { - width: 100%; - min-height: 180px; -} - -.gh-members-import-uploader svg { - width: 3.2rem; - height: 3.2rem; - margin-bottom: 1rem; -} - -.gh-members-import-uploader svg path { - stroke: var(--midlightgrey); -} - -.gh-members-import-uploader:hover svg path { - stroke: var(--midgrey-l1); -} - -.gh-members-import-uploader .description { - color: var(--midgrey); - font-size: 1.4rem; - font-weight: 500; -} - -.gh-members-import-uploader:hover .description { - color: var(--midgrey-d2); -} - -.gh-members-import-file { - min-height: 180px; -} - -.gh-members-import-spinner { - position: relative; - display: flex; - min-height: 182px; - justify-content: center; - align-items: center; - margin-bottom: -20px; -} - -.gh-members-import-spinner .gh-loading-content { - padding-bottom: 0px; -} - -.gh-members-import-spinner .description { - padding-top: 46px; -} - -.gh-members-upload-errorcontainer { - border: 1px solid var(--whitegrey); - border-radius: 4px; - padding: 12px; - margin-bottom: 24px; - color: var(--middarkgrey); -} - -.gh-members-upload-errorcontainer.warning { - border-left: 4px solid var(--yellow); -} - - -.gh-members-upload-errorcontainer.warning p a { - color: color-mod(var(--yellow) l(-12%)); - text-decoration: underline; -} - -.gh-members-upload-errorcontainer.error { - border-left: 4px solid var(--red); -} - -.gh-members-upload-errorcontainer.error p a { - color: var(--red); - text-decoration: underline; -} - -.gh-members-import-errormessage { - font-size: 1.25rem; - font-weight: 600; - margin: 12px 0 0; -} - -p.gh-members-import-errorcontext { - font-size: 1.25rem; - line-height: 1.3em; - margin: 0; - font-weight: 400; -} - -.gh-members-import-mapping .error { - color: var(--red); -} - -.gh-members-import-mappingwrapper.error { - position: relative; -} - -.gh-members-import-mappingwrapper.error::before { - display: block; - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 1px solid red; - z-index: 9999; - pointer-events: none; -} - -.gh-members-import-scrollarea { - position: relative; - max-height: calc(100vh - 350px - 12vw); - overflow-y: scroll; - margin: 0 -32px; - padding: 0 32px; - background: - /* Shadow covers */ - linear-gradient(var(--white) 30%, rgba(255,255,255,0)), - linear-gradient(rgba(255,255,255,0), var(--white) 70%) 0 100%, - - /* Shadows */ - /* radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.12), rgba(0,0,0,0)) -64px 0, - radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.12), rgba(0,0,0,0)) -64px 100%; */ - linear-gradient(rgba(0,0,0,0.08), rgba(0,0,0,0)), - linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.08)) 0 100%; - background-repeat: no-repeat; - background-color: var(--white); - background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; - - /* Opera doesn't support this in the shorthand */ - background-attachment: local, local, scroll, scroll; - margin-top: 4px; -} - -.gh-members-import-errorheading { - font-size: 1.4rem; - line-height: 1.55em; - margin-top: 2px; +.gh-members-no-data h4 { + font-size: 1.5rem; + letter-spacing: 0; + font-weight: 600; + color: var(--middarkgrey); } -p.gh-members-import-errordetailtext { - font-size: 1.3rem; - line-height: 1.4em; +.gh-members-no-data p { + margin: 0 40px .3rem; color: var(--midgrey); + font-size: 1.3rem; + line-height: 1.5em; } -.gh-members-import-errordetailtext:first-of-type { - border-top: 1px solid var(--lightgrey); - padding-top: 8px; +.gh-members-no-list h4 { margin-top: 8px; } -.gh-members-import-errordetailtext:not(:last-of-type) { - padding-bottom: 4px; - margin-bottom: 6px; +.gh-members-no-list svg path { + stroke-width: 1px; } -.gh-members-import-table { - position: relative; - margin-bottom: 1px; - border-collapse: separate; +.gh-members-no-subs svg { + width: 52px; + margin-left: 12px; } -.gh-members-import-table::before { - position: absolute; - display: block; - content: ""; - top: 0; - left: -33px; - bottom: 0; - height: 100%; - width: 32px; - background: var(--white); +.gh-members-no-subs svg path, +.gh-members-no-subs svg rect, +.gh-members-no-subs svg circle { + stroke-width: 1px; } -.gh-members-import-table::after { - position: absolute; - display: block; - content: ""; - top: 0; - right: -32px; - bottom: 0; - height: 100%; - width: 32px; - background: var(--white); +.gh-member-newsletters { + padding: 16px 20px; + background: var(--main-bg-color); + box-shadow: 0 1px 4px -1px rgb(0 0 0 / 10%); + border-radius: 3px; } -.gh-members-import-table th { - position: sticky; - top: 0; - padding: 3px 8px; - background: var(--whitegrey-l2); - border-left: 1px solid var(--member-import-table-border); - border-top: 1px solid var(--member-import-table-outline); - border-bottom: 1px solid var(--member-import-table-border); +.gh-member-newsletter-row { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--grey-250); + padding: 16px 0; } -.gh-members-import-table tr th:first-of-type { - border-left: 1px solid var(--member-import-table-outline); - width: 180px; +.gh-member-newsletter-row .for-switch { + display: flex; } -.gh-members-import-table tr th:last-of-type { - border-right: 1px solid var(--member-import-table-outline); +.gh-member-newsletter-row:first-child { + padding-top: 0; } -.gh-members-import-table td.empty-cell { - background: color-mod(var(--darkgrey) a(3%) s(+50%)); +.gh-member-newsletter-row:last-child { + padding-bottom: 0; + border-bottom: none; } -.gh-members-import-table td { - padding: 7px 8px 6px; - border-left: 1px solid var(--member-import-table-border); - border-bottom: 1px solid var(--member-import-table-border); - vertical-align: top; +.gh-member-newsletter-title { + font-weight: 600; + font-size: 1.4rem !important; + margin-bottom: 0!important; } -.gh-members-import-table tr td:first-of-type { - border-left: 1px solid var(--member-import-table-outline); - width: 180px; +.gh-main-section-content.gh-member-newsletter-section { + padding-bottom: 16px; } -.gh-members-import-table tr td:last-of-type { - padding: 0; - border-right: 1px solid var(--member-import-table-outline); +.gh-member-newsletter-no-data { + padding: 24px 0 28px; } -.gh-members-import-table tr:last-of-type td { - border-bottom: 1px solid var(--member-import-table-outline); +.gh-member-newsletter-no-data .gh-member-newsletter-icon path { + stroke-width: 2.8; } -.gh-members-import-table td span, -.gh-members-import-table th span { - user-select: none !important; +.gh-member-newsletter-no-data a { + color: var(--midgrey); + opacity: 0.6; + font-weight: 500; + letter-spacing: 0.02em; + text-decoration: underline; } -.gh-members-import-datanav { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 1px 2px rgba(0, 0, 0, 0.05); +.gh-member-newsletter-no-data p { + margin-bottom: 0.8rem; } -p.gh-members-import-errordetail { - font-size: 1.2rem; - line-height: 1.4em; - margin: 10px 0 0 24px; +.gh-member-newsletter-footer { + font-size: 1.3rem; + margin-top: 12px; } -p.gh-members-import-errordetail:first-of-type { - border-top: 1px solid var(--whitegrey); - padding-top: 8px; - margin-top: 8px; +.gh-member-feed-container { + display: flex; + flex-grow: 1; + flex-direction: row; + align-items: center; + padding: 1.6rem 0; } -.gh-import-member-select { - height: auto; - border: none; - background: none; - border-radius: 0; +.gh-member-feed { + margin: 0; + padding: 20px; + background: var(--white); + box-shadow: 0 1px 4px -1px rgba(0,0,0,0.1); + border-radius: 3px; } -.gh-import-member-select select { - height: 34px; - border: none; - font-size: 1.3rem; - line-height: 1em; - padding: 4px 4px 4px 8px; - background: none; - color: var(--middarkgrey); - font-weight: 600; - border-radius: 0; +.gh-member-settings .gh-member-feed-no-data { + margin: 0; + padding: 24px 0 28px; + background: transparent; + box-shadow: none; } -.gh-import-member-select select option { - font-weight: 400; - color: var(--darkgrey); +.gh-member-feed-row { + display: flex; + align-items: center; + padding: 0; } -.gh-import-member-select select:focus { - background: none; - color: var(--middarkgrey); +.gh-member-feed-activity { + display: flex; + align-items: center; + padding: 12px 0; } -.gh-import-member-select.unmapped select, -.gh-import-member-select.unmapped select:focus { - color: var(--midlightgrey); - font-weight: 400; +.gh-member-feed-activity svg { + width: 16px; + margin-right: 1rem; +} + +.gh-member-feed-row a { + font-weight: 600; + font-size: 14px; + color: var(--darkgrey); } -.gh-import-member-select svg { - right: 9px; +.gh-member-feed-title { + display: table-cell; + padding: 10px 0; + line-height: 1.4em; + vertical-align: middle; + color: var(--darkgrey); + text-align: left; + font-weight: 500; } -.gh-members-import-table th.table-cell-field, -.gh-members-import-table td.table-cell-field, -.gh-members-import-table th.table-cell-data, -.gh-members-import-table td.table-cell-data { - max-width: 180px; - overflow-wrap: break-word; +.gh-member-feed-title:hover { + color: var(--black); } -.gh-member-import-resultcontainer { - margin-bottom: 28px; +.gh-member-feed-title a { + color: var(--darkgrey); } -.gh-member-import-result-summary { - flex-basis: 50%; +.gh-member-feed-title a:hover { + color: var(--black); } -.gh-member-import-result-summary h2 { - font-size: 3.6rem; - font-weight: 600; - margin: 0; - padding: 0; +.gh-member-feed-date { + margin-left: auto; + padding: 10px 0 10px 16px; + color: var(--midgrey); + font-size: 1.3rem; + text-align: right; + white-space: nowrap; } -.gh-member-import-result-summary p { +.gh-member-feed-row:hover .gh-member-feed-date { color: var(--darkgrey); - margin: 0; - padding: 0; - line-height: 1.6em; - margin-bottom: 12px; } -.gh-member-import-result-summary p strong { - font-size: 1.5rem; - letter-spacing: 0; +.gh-member-feed-row:first-child .gh-member-feed-container { + padding-top: 0; } -.gh-member-import-errorlist { - width: 100%; - margin: 8px 0 28px; +.gh-member-feed-row { + border-bottom: 1px solid var(--grey-250); } -.gh-member-import-errorlist h4 { - font-size: 13px; - font-weight: 500; - border-bottom: 1px solid var(--whitegrey); - padding-bottom: 8px; - margin-top: 0px; - color: var(--midgrey); +.gh-member-feed-detail { + display: flex; + flex-grow: 1; + justify-content: space-between; + align-items: center; + width: 0; } -.gh-member-import-errorlist ul li { - font-size: 13px; - font-weight: 400; - color: var(--midlightgrey-d2); - padding: 0; - margin-bottom: 6px; +.gh-member-feed-event { + flex: 1; + min-width: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; } -.gh-member-import-resultcontainer hr { - margin: 24px -32px; - border-color: var(--whitegrey); +.gh-member-feed-event-inner { + color: var(--middarkgrey); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-right: 1rem; + width: 100%; } -.gh-member-import-nodata span { - display: flex; - min-height: 144px; - align-items: center; - justify-content: center; - color: var(--midgrey); +.gh-member-feed-event-inner:first-letter { + text-transform: uppercase; } -.gh-member-import-icon-members path, -.gh-member-import-icon-members circle { - stroke-width: 0.85px; +.gh-member-feed-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + margin: 0 0.5rem 0 -1px; } -.gh-member-import-icon-confetti { - color: var(--pink); - margin-left: 12px; +.gh-member-feed-icon svg { + width: 100%; + height: 100%; } -.gh-member-import-icon-confetti path, -.gh-member-import-icon-confetti circle, -.gh-member-import-icon-confetti ellipse { - stroke-width: 0.85px; +.gh-member-feed-time { + font-weight: 500; + font-size: 1.3rem; + color: var(--midlightgrey); + white-space: nowrap; } -.gh-import-member-icon { - color: var(--darkgrey); - width: 54px !important; - height: 54px !important; - margin-right: -8px; +.gh-member-feed-footer { + padding-top: 16px; } -.gh-import-member-icon * { - stroke-width: 0.8px !important; +.gh-member-feed-footer a { + font-weight: 500; + transition: color 100ms ease; } -/* Fixing Firefox's select padding */ -@-moz-document url-prefix() { - .gh-import-member-select select { - padding: 4px; - } +.gh-member-feed-footer a:hover { + color: #269a34; } + /* Email newsletter design settings /* -------------------------------------------------------- */ @@ -2864,71 +1787,6 @@ a.gh-members-emailpreview-subscription-link { margin-top: -16px; } -.gh-members-resource-filter .ember-power-select-selected-item { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-right: 16px; -} - -.members-header .view-actions-top-row > .ember-basic-dropdown-content-wormhole-origin { - position: absolute; -} - -.gh-members-filter-builder { - width: 780px; - margin-top: 8px; -} - -@media (max-width: 980px) { - .gh-members-filter-builder { - width: 640px; - } -} - -@media (max-width: 890px) { - .gh-members-filter-builder { - margin-right: -180px; - } -} - -@media (max-width: 690px) { - .gh-members-filter-builder { - position: fixed; - top: 9vh !important; - left: 10px !important; - right: 10px !important; - width: auto; - min-width: 0; - max-width: none; - margin: 0; - } -} - -@media (max-width: 800px) { - .members-header { - left: 0; - } - - .members-header .gh-canvas-title { - left: 25px; - } - - .members-header .view-actions .gh-members-header-search { - width: 100%; - } -} - -@media (max-width: 430px) { - .members-header .view-actions .gh-contentfilter { - border-right: 1px solid var(--whitegrey-d1); - } - - .gh-contentfilter-menu:last-of-type { - padding-right: 8px; - } -} - /* This needs to be moved once the flag is removed */ .gh-cp-membertier-attribution.gh-membertier-subscription { display: block !important; diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs deleted file mode 100644 index 57249e9e78d..00000000000 --- a/ghost/admin/app/templates/members.hbs +++ /dev/null @@ -1,235 +0,0 @@ -{{#unless this.feature.membersForward}} -
- -
- {{#if this.fromAnalytics}} -
- - Posts - - {{svg-jar "arrow-right-small"}} - - Analytics - - {{svg-jar "arrow-right-small"}}Members -
- {{/if}} -

Members

-
-
-
- -
- -
- - - - - {{svg-jar "settings"}} - - - - - {{#if (not-eq this.settings.membersSignupAccess "none")}} -
  • - - Import members - -
  • - {{/if}} -
  • - {{#if this.members.length}} - - {{else}} - - {{/if}} -
  • - {{#if (and this.members.length this.isFiltered)}} -
  • -
  • - -
  • -
  • - -
  • - {{#if (not-eq this.settings.membersSignupAccess "none")}} -
  • - -
  • - {{/if}} - {{#if this.isBulkDeletePermitted}} -
  • -
  • - -
  • - {{/if}} - {{/if}} -
    -
    - {{#if (not-eq this.settings.membersSignupAccess "none")}} - New memberNew - {{/if}} -
    -
    -
    - - {{#if this.members.loading}} -
    - -
    - {{else}} -
    - {{#if this.members}} -
    - - - - - - {{#if (and (not-eq this.settings.editorDefaultEmailRecipients "disabled") this.settings.emailTrackOpens)}} - - {{/if}} - - - {{#each this.filterColumns as |column|}} - - {{/each}} - - - - {{#if member.is_loading}} - - {{else}} - - {{/if}} - -
    {{this.listHeader}}StatusOpen rateLocationCreated{{column.label}}
    -
    - {{else}} - {{#if this.showingAll}} - - {{else}} -
    - {{svg-jar "members-placeholder" class="gh-members-placeholder"}} -

    No members match the current filter

    - - Show all members - -
    - {{/if}} - {{/if}} - {{#if (lt this.members.length 6)}} - - {{/if}} -
    - {{/if}} -
    - -{{outlet}} - -{{#if this.showUnsubscribeMembersModal}} - -{{/if}} - -{{#if this.showLabelModal}} - -{{/if}} -{{/unless}} diff --git a/ghost/admin/app/templates/members/import.hbs b/ghost/admin/app/templates/members/import.hbs deleted file mode 100644 index b02ea750a14..00000000000 --- a/ghost/admin/app/templates/members/import.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{#unless this.feature.membersForward}} - -{{/unless}} diff --git a/ghost/admin/config/environment.js b/ghost/admin/config/environment.js index beb9bba2507..5be127486da 100644 --- a/ghost/admin/config/environment.js +++ b/ghost/admin/config/environment.js @@ -49,7 +49,8 @@ module.exports = function (environment) { // Enable mirage here in order to mock API endpoints during development ENV['ember-cli-mirage'] = { - enabled: false + enabled: false, + excludeFilesFromBuild: process.env.EMBER_INCLUDE_TESTS !== 'true' }; } diff --git a/ghost/admin/ember-cli-build.js b/ghost/admin/ember-cli-build.js index a50c9720017..f40a0d3c8aa 100644 --- a/ghost/admin/ember-cli-build.js +++ b/ghost/admin/ember-cli-build.js @@ -14,6 +14,8 @@ const environment = EmberApp.env(); const isDevelopment = environment === 'development'; const isProduction = environment === 'production'; const isTesting = environment === 'test'; +const shouldIncludeTestAssets = buildEnvironment => buildEnvironment !== 'production' && (buildEnvironment === 'test' || process.env.EMBER_INCLUDE_TESTS === 'true'); +const includeTestAssets = shouldIncludeTestAssets(environment); const postcssImport = require('postcss-import'); const postcssCustomProperties = require('postcss-custom-properties'); @@ -58,7 +60,7 @@ const codemirrorAssets = function () { }; // put the files in vendor ready for importing into the test-support file - if (environment === 'development') { + if (environment === 'development' && includeTestAssets) { config.vendor = codemirrorFiles; } @@ -83,6 +85,9 @@ if (isTesting) { module.exports = function (defaults) { let app = new EmberApp(defaults, { addons: {denylist}, + tests: includeTestAssets, + hinting: includeTestAssets, + trees: includeTestAssets ? {} : {tests: false}, babel: { plugins: [ require.resolve('babel-plugin-transform-react-jsx') @@ -261,11 +266,13 @@ module.exports = function (defaults) { // pull things we rely on via lazy-loading into the test-support.js file so // that tests don't break when running via http://localhost:4200/tests - if (app.env === 'development') { + const includeAppTestAssets = shouldIncludeTestAssets(app.env); + + if (app.env === 'development' && includeAppTestAssets) { app.import('vendor/codemirror/lib/codemirror.js', {type: 'test'}); } - if (app.env === 'development' || app.env === 'test') { + if (includeAppTestAssets) { // pull dynamic imports into the assets folder so that they can be lazy-loaded in tests // also done in development env so http://localhost:4200/tests works app.import('node_modules/@tryghost/koenig-lexical/dist/koenig-lexical.umd.js', {outputFile: 'ghost/assets/koenig-lexical/koenig-lexical.umd.js'}); diff --git a/ghost/admin/mirage/config/members.js b/ghost/admin/mirage/config/members.js index 33efccfc6c3..1a20914548d 100644 --- a/ghost/admin/mirage/config/members.js +++ b/ghost/admin/mirage/config/members.js @@ -275,31 +275,6 @@ export default function mockMembers(server) { members.find(id).destroy(); })); - server.get('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function () { - return new Response(200, { - 'Content-Disposition': 'attachment', - filename: `members.${moment().format('YYYY-MM-DD')}.csv`, - 'Content-Type': 'text/csv' - }, ''); - })); - - server.post('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function ({labels}, request) { - const label = labels.create(); - - // TODO: parse CSV and create member records - for (const kvPair of request.requestBody.entries()) { - const [key, value] = kvPair; - console.log({key, value}); // eslint-disable-line - } - - return new Response(201, {}, { - meta: { - import_label: label, - stats: {imported: 1, invalid: []} - } - }); - })); - server.get('/members/events/', withPermissionsCheck(ALLOWED_ROLES, function ({memberActivityEvents}, {queryParams}) { let {limit, filter, page} = queryParams; diff --git a/ghost/admin/mirage/routes-test.js b/ghost/admin/mirage/routes-test.js index 0f812bf9a4b..bb06925be83 100644 --- a/ghost/admin/mirage/routes-test.js +++ b/ghost/admin/mirage/routes-test.js @@ -85,12 +85,8 @@ export default function () { const url = new URL(request.url, window.location.origin); const limit = url.searchParams.get('limit'); - const ALLOWED_LIMIT_ALL = [ - '/api/admin/members/upload/' - ]; - // limit=all is completely blocked, we shouldn't have any requests reach the server with this - if (limit === 'all' && !ALLOWED_LIMIT_ALL.some(allowed => path.includes(allowed))) { + if (limit === 'all') { throw new Error(`Blocked mirage request with limit=all: ${verb} ${path}.`); } diff --git a/ghost/admin/package.json b/ghost/admin/package.json index d9f14fc3253..18cb2ac573c 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.36.1-rc.0", + "version": "6.37.1-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", @@ -52,9 +52,7 @@ "@tryghost/kg-converters": "1.2.1", "@tryghost/koenig-lexical": "1.8.1", "@tryghost/limit-service": "1.5.2", - "@tryghost/members-csv": "2.0.5", "@tryghost/nql": "0.12.10", - "@tryghost/nql-lang": "0.6.4", "@tryghost/string": "0.3.2", "@tryghost/timezone-data": "0.4.18", "animejs": "3.2.2", @@ -98,7 +96,6 @@ "ember-data": "3.24.0", "ember-decorators": "6.1.1", "ember-drag-drop": "0.4.8", - "ember-ella-sparse": "0.16.0", "ember-exam": "6.0.1", "ember-export-application-global": "2.0.1", "ember-fetch": "8.1.2", @@ -137,7 +134,6 @@ "miragejs": "0.1.48", "moment-timezone": "0.5.45", "normalize.css": "3.0.3", - "papaparse": "5.5.3", "postcss-color-mod-function": "3.0.3", "postcss-custom-media": "7.0.8", "postcss-custom-properties": "10.0.0", diff --git a/ghost/admin/public/assets/icons/email-member.svg b/ghost/admin/public/assets/icons/email-member.svg deleted file mode 100644 index 1c218fa2766..00000000000 --- a/ghost/admin/public/assets/icons/email-member.svg +++ /dev/null @@ -1,4 +0,0 @@ - - email-member - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/import-in-progress.svg b/ghost/admin/public/assets/icons/import-in-progress.svg deleted file mode 100644 index eaff7ed932b..00000000000 --- a/ghost/admin/public/assets/icons/import-in-progress.svg +++ /dev/null @@ -1 +0,0 @@ -download-dash-arrow \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-all.svg b/ghost/admin/public/assets/icons/members-all.svg deleted file mode 100644 index 0112982dc14..00000000000 --- a/ghost/admin/public/assets/icons/members-all.svg +++ /dev/null @@ -1,6 +0,0 @@ - - members-all - - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-outline.svg b/ghost/admin/public/assets/icons/members-outline.svg deleted file mode 100644 index 06fde14ce63..00000000000 --- a/ghost/admin/public/assets/icons/members-outline.svg +++ /dev/null @@ -1,20 +0,0 @@ - - members-outline - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-paid.svg b/ghost/admin/public/assets/icons/members-paid.svg deleted file mode 100644 index 7df69799e4c..00000000000 --- a/ghost/admin/public/assets/icons/members-paid.svg +++ /dev/null @@ -1,5 +0,0 @@ - - members-paid - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-post.svg b/ghost/admin/public/assets/icons/members-post.svg deleted file mode 100644 index 6cb7aff52bd..00000000000 --- a/ghost/admin/public/assets/icons/members-post.svg +++ /dev/null @@ -1,6 +0,0 @@ - - members-post - - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-segment.svg b/ghost/admin/public/assets/icons/members-segment.svg deleted file mode 100644 index bb94fc4ed17..00000000000 --- a/ghost/admin/public/assets/icons/members-segment.svg +++ /dev/null @@ -1,5 +0,0 @@ - - members-segment - - - \ No newline at end of file diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js deleted file mode 100644 index a5800490794..00000000000 --- a/ghost/admin/tests/acceptance/members-test.js +++ /dev/null @@ -1,483 +0,0 @@ -import moment from 'moment-timezone'; -import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; -import {beforeEach, describe, it} from 'mocha'; -import {blur, click, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; -import {enableLabsFlag} from '../helpers/labs-flag'; -import {expect} from 'chai'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {visit} from '../helpers/visit'; - -describe.skip('Acceptance: Members Test', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - it('redirects to signin when not authenticated', async function () { - await invalidateSession(); - await visit('/members'); - - expect(currentURL()).to.equal('/signin'); - }); - - it('redirects roles without member management permission to site', async function () { - let role = this.server.create('role', {name: 'Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - await visit('/members'); - - expect(currentURL()).to.equal('/site'); - }); - - describe('as owner', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('does not load or render the Ember members list when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - this.server.createList('member', 2); - - await visit('/members'); - - expect(currentURL()).to.equal('/members'); - expect(find('[data-test-screen-title]')).to.not.exist; - expect(find('[data-test-table="members"]')).to.not.exist; - - const membersRequests = this.server.pretender.handledRequests.filter(request => request.url.match(/\/members\/(\?|$)/)); - expect(membersRequests.length, 'members API requests').to.equal(0); - }); - - it('it renders, can be navigated, can edit member', async function () { - let member1 = this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - this.server.create('member', {createdAt: moment.utc().subtract(2, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(2); - - let member = find('[data-test-list="members-list-item"]'); - expect(member.querySelector('.gh-members-list-name').textContent, 'member list item title') - .to.equal(member1.name); - - // it does not add ?include=email_recipients - const membersRequests = this.server.pretender.handledRequests.filter(r => r.url.match(/\/members\/(\?|$)/)); - expect(membersRequests[0].url).to.not.have.string('email_recipients'); - - await visit(`/members/${member1.id}`); - - // it shows selected member form - expect(find('[data-test-input="member-name"]').value, 'loads correct member into form') - .to.equal(member1.name); - - expect(find('[data-test-input="member-email"]').value, 'loads correct email into form') - .to.equal(member1.email); - - // trigger save - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await click('[data-test-button="save"]'); - - await click('[data-test-link="members-back"]'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - }); - - it('displays member correctly with blank string name in list item', async function () { - this.server.create('member', { - name: ' ', - email: 'blank@example.com', - createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss') - }); - - await visit('/members'); - - // it lists the member - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); - - let member = find('[data-test-list="members-list-item"]'); - expect(member.querySelector('h3').textContent.trim(), 'member list item shows email in h3') - .to.equal('blank@example.com'); - }); - - it('displays member correctly with blank string name in member details', async function () { - let member = this.server.create('member', { - name: ' ', - email: 'blank@example.com', - createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss') - }); - - await visit(`/members/${member.id}`); - // check that the email is in an h3 tag - expect(find('h3').textContent.trim(), 'member details title shows email') - .to.equal('blank@example.com'); - }); - - it('can create a new member', async function () { - this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); - - // start new member - await click('[data-test-new-member-button="true"]'); - - // it navigates to the new member route - expect(currentURL(), 'new member URL').to.equal('/members/new'); - // it displays the new member form - expect(find('.gh-canvas-header h2').textContent, 'settings pane title') - .to.contain('New'); - - // all fields start blank - findAll('.gh-member-settings-primary .gh-input').forEach(function (elem) { - expect(elem.value, `input field for ${elem.getAttribute('name')}`) - .to.be.empty; - }); - - // save new member - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await fillIn('[data-test-input="member-email"]', 'example@domain.com'); - await blur('[data-test-input="member-email"]'); - - await click('[data-test-button="save"]'); - - expect(find('[data-test-input="member-name"]').value, 'name has been preserved') - .to.equal('New Name'); - - expect(find('[data-test-input="member-email"]').value, 'email has been preserved') - .to.equal('example@domain.com'); - }); - - /* - * Due to a limitation with NQL, member bulk deletion is not permitted if any of the following Stripe subscription filters is used: - * - Billing period - * - Stripe subscription status - * - Paid start date - * - Next billing date - * - Subscription started on post/page - * - Offers - * - * For more context, see: - * - https://linear.app/tryghost/issue/ENG-1484 - * - https://linear.app/tryghost/issue/ENG-1466 - * - * See code: ghost/admin/app/controllers/members.js:isBulkDeletePermitted - * TODO: delete this block of tests once the guardrail has been removed - */ - describe('[Temp] Guardrail against bulk deletion', function () { - it('can bulk delete members if a non-Stripe subscription filter is in use (member tier, status)', async function () { - const tier = this.server.create('tier', {id: 'qwerty123456789'}); - this.server.createList('member', 2, {status: 'free'}); - this.server.createList('member', 2, {status: 'paid', tiers: [tier]}); - - 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; - - // 1) Membership tier filter: permitted - await visit(`/members?filter=tier_id:[${tier.id}]`); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.exist; - - // 2) Member status filter: permitted - await visit('/members?filter=status%3Afree'); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.exist; - }); - - it('cannot bulk delete members if a Stripe subscription filter is in use', async function () { - // Create free and paid members - const tier = this.server.create('tier'); - const offer = this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(1, 'day').valueOf()}); - this.server.createList('member', 2, {status: 'free'}); - this.server.createList('member', 2, {status: 'paid'}).forEach(member => this.server.create('subscription', {member, planInterval: 'month', status: 'active', start_date: '2000-01-01T00:00:00.000Z', current_period_end: '2000-02-01T00:00:00.000Z', offer: offer, tier: tier})); - this.server.createList('member', 2, {status: 'paid'}).forEach(member => this.server.create('subscription', {member, planInterval: 'year', status: 'active'})); - - await visit('/members'); - expect(findAll('[data-test-member]').length).to.equal(6); - - // 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; - - // 1) Stripe billing period filter: not permitted - await visit('/members?filter=subscriptions.plan_interval%3Amonth'); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 2) Stripe subscription status filter: not permitted - await visit('/members?filter=subscriptions.status%3Aactive'); - expect(findAll('[data-test-member]').length).to.equal(4); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 3) Stripe paid start date filter: not permitted - await visit(`/members?filter=subscriptions.start_date%3A>'1999-01-01%2005%3A59%3A59'`); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 4) Next billing date filter: not permitted - await visit(`/members?filter=subscriptions.current_period_end%3A>'2000-01-01%2005%3A59%3A59'`); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 5) Offers redeemed filter: not permitted - await visit('/members?filter=' + encodeURIComponent(`offer_redemptions:'${offer.id}'`)); - expect(findAll('[data-test-member]').length).to.equal(2); - 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); - - // imported members to be deleted - const label = this.server.create('label'); - this.server.createList('member', 5, {labels: [label]}); - - await visit('/members'); - - expect(findAll('[data-test-member]').length).to.equal(11); - - await click('[data-test-button="members-actions"]'); - - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // a filter is needed for the delete-selected button to show - 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="${label.name}"]`); - 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%5B${label.slug}%5D`); - - await click('[data-test-button="members-actions"]'); - - expect(find('[data-test-button="delete-selected"]')).to.exist; - - await click('[data-test-button="delete-selected"]'); - - expect(find('[data-test-modal="delete-members"]')).to.exist; - expect(find('[data-test-text="delete-count"]')).to.have.text('5 members'); - - // ensure export endpoint gets hit with correct query params when deleting - let exportQueryParams; - this.server.get('/members/upload', (schema, request) => { - exportQueryParams = request.queryParams; - }); - - await click('[data-test-button="confirm"]'); - - expect(exportQueryParams).to.deep.equal({filter: 'label:[label-0]', limit: 'all'}); - - expect(find('[data-test-text="deleted-count"]')).to.have.text('5 members'); - expect(find('[data-test-button="confirm"]')).to.not.exist; - - // members filter is reset - expect(currentURL()).to.equal('/members'); - expect(findAll('[data-test-member]').length).to.equal(6); - - await click('[data-test-button="close-modal"]'); - - expect(find('[data-test-modal="delete-members"]')).to.not.exist; - }); - - it('formats counts in members actions menu for filtered lists', async function () { - this.server.createList('member', 1000, {status: 'free'}); - - await visit('/members?filter=status%3Afree'); - await click('[data-test-button="members-actions"]'); - - expect(find('[data-test-button="export-members"] span')).to.have.text('Export selected members (1,000)'); - expect(find('[data-test-button="add-label-selected"] span')).to.have.text('Add label for selected members (1,000)'); - expect(find('[data-test-button="remove-label-selected"] span')).to.have.text('Remove label from selected members (1,000)'); - expect(find('[data-test-button="delete-selected"] span')).to.have.text('Delete selected members (1,000)'); - }); - - it('can delete a member (via list)', async function () { - const newsletter = this.server.create('newsletter'); - const label = this.server.create('label'); - this.server.createList('member', 2, {newsletters: [newsletter], labels: [label]}); - - await visit('/members'); - - expect(findAll('[data-test-member]').length).to.equal(2); - - await click('[data-test-member] a'); - - expect(currentURL()).to.match(/members\/\d+/); - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - - expect(find('[data-test-modal="delete-member"]')).to.exist; - - await click('[data-test-modal="delete-member"] [data-test-button="cancel"]'); - - expect(currentURL()).to.match(/members\/\d+/); - expect(find('[data-test-modal="delete-member"]')).to.not.exist; - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - await click('[data-test-modal="delete-member"] [data-test-button="confirm"]'); - - expect(currentURL()).to.equal('/members'); - expect(findAll('[data-test-modal]')).to.have.length(0); - expect(findAll('[data-test-member]')).to.have.length(1); - }); - - it('can delete a member (via url)', async function () { - const newsletter = this.server.create('newsletter'); - const label = this.server.create('label'); - const [memberOne] = this.server.createList('member', 2, {newsletters: [newsletter], labels: [label]}); - - await visit(`/members/${memberOne.id}`); - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - - expect(find('[data-test-modal="delete-member"]')).to.exist; - - await click('[data-test-modal="delete-member"] [data-test-button="cancel"]'); - - expect(currentURL()).to.match(/members\/\d+/); - expect(find('[data-test-modal="delete-member"]')).to.not.exist; - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - await click('[data-test-modal="delete-member"] [data-test-button="confirm"]'); - - expect(currentURL()).to.equal('/members'); - expect(findAll('[data-test-modal]')).to.have.length(0); - expect(findAll('[data-test-member]')).to.have.length(1); - }); - }); - describe('as super editor', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Super Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('it renders, can be navigated, can edit member', async function () { - let member1 = this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - this.server.create('member', {createdAt: moment.utc().subtract(2, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(2); - - let member = find('[data-test-list="members-list-item"]'); - expect(member.querySelector('.gh-members-list-name').textContent, 'member list item title') - .to.equal(member1.name); - - // it does not add ?include=email_recipients - const membersRequests = this.server.pretender.handledRequests.filter(r => r.url.match(/\/members\/(\?|$)/)); - expect(membersRequests[0].url).to.not.have.string('email_recipients'); - - await visit(`/members/${member1.id}`); - - // it shows selected member form - expect(find('[data-test-input="member-name"]').value, 'loads correct member into form') - .to.equal(member1.name); - - expect(find('[data-test-input="member-email"]').value, 'loads correct email into form') - .to.equal(member1.email); - - // trigger save - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await click('[data-test-button="save"]'); - - await click('[data-test-link="members-back"]'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - }); - - it('can create a new member', async function () { - this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); - - // start new member - await click('[data-test-new-member-button="true"]'); - - // it navigates to the new member route - expect(currentURL(), 'new member URL').to.equal('/members/new'); - // it displays the new member form - expect(find('.gh-canvas-header h2').textContent, 'settings pane title') - .to.contain('New'); - - // all fields start blank - findAll('.gh-member-settings-primary .gh-input').forEach(function (elem) { - expect(elem.value, `input field for ${elem.getAttribute('name')}`) - .to.be.empty; - }); - - // save new member - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await fillIn('[data-test-input="member-email"]', 'example@domain.com'); - await blur('[data-test-input="member-email"]'); - - await click('[data-test-button="save"]'); - - expect(find('[data-test-input="member-name"]').value, 'name has been preserved') - .to.equal('New Name'); - - expect(find('[data-test-input="member-email"]').value, 'email has been preserved') - .to.equal('example@domain.com'); - }); - }); -}); diff --git a/ghost/admin/tests/acceptance/members/filter-test.js b/ghost/admin/tests/acceptance/members/filter-test.js deleted file mode 100644 index 412116113af..00000000000 --- a/ghost/admin/tests/acceptance/members/filter-test.js +++ /dev/null @@ -1,1687 +0,0 @@ -import moment from 'moment-timezone'; -import sinon from 'sinon'; -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {blur, click, currentURL, fillIn, find, findAll, focus} from '@ember/test-helpers'; -import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../../helpers/mock-analytics-apps'; -import {datepickerSelect} from 'ember-power-datepicker/test-support'; -import {enableNewsletters} from '../../helpers/newsletters'; -import {enablePaidMembers} from '../../helpers/members'; -import {enableStripe} from '../../helpers/stripe'; -import {expect} from 'chai'; -import {selectChoose} from 'ember-power-select/test-support/helpers'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {visit} from '../../helpers/visit'; - -describe.skip('Acceptance: Members filtering', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - let clock; - - beforeEach(async function () { - mockAnalyticsApps(); - - this.server.loadFixtures('configs'); - this.server.loadFixtures('settings'); - this.server.loadFixtures('newsletters'); - enableStripe(this.server); - enableNewsletters(this.server, true); - enablePaidMembers(this.server); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - afterEach(function () { - cleanupMockAnalyticsApps(); - clock?.restore(); - }); - - it('has a known base-state', async function () { - this.server.createList('member', 7); - - await visit('/members'); - - // members are listed - expect(find('[data-test-table="members"]')).to.exist; - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows').to.equal(7); - - // export is available - expect(find('[data-test-button="export-members"]'), 'export members button').to.exist; - expect(find('[data-test-button="export-members"]'), 'export members button').to.not.have.attribute('disabled'); - - // bulk actions are hidden - expect(find('[data-test-button="add-label-selected"]'), 'add label to selected button').to.not.exist; - expect(find('[data-test-button="remove-label-selected"]'), 'remove label from selected button').to.not.exist; - expect(find('[data-test-button="unsubscribe-selected"]'), 'unsubscribe selected button').to.not.exist; - expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.not.exist; - - // filter and search are inactive - expect(find('[data-test-input="members-search"]'), 'search input').to.exist; - expect(find('[data-test-input="members-search"]'), 'search input').to.not.have.class('active'); - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.not.have.class('gh-btn-label-green'); - - // standard columns are shown - expect(findAll('[data-test-table="members"] [data-test-table-column]').length).to.equal(4); - }); - - describe('filtering', function () { - it('can filter by label', async function () { - // add some labels to test the selection dropdown - this.server.createList('label', 4); - - // add a labelled member so we can test the filter includes correctly - const label = this.server.create('label'); - this.server.createList('member', 3, {labels: [label]}); - // add some non-labelled members so we can see the filter excludes correctly - this.server.createList('member', 4); - - await visit('/members'); - - const getLabelRequests = () => { - return this.server.pretender.handledRequests.filter((request) => { - return request.url.includes('/ghost/api/admin/labels/'); - }); - }; - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'label'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // value dropdown can open and has all labels - await click(`${filterSelector} .gh-member-label-input`); - expect(findAll(`${filterSelector} [data-test-label-filter]`).length, '# of label options').to.equal(5); - - const labelRequests = getLabelRequests(); - expect(labelRequests.length).to.be.greaterThan(0); - labelRequests.forEach((request) => { - const parsedUrl = new URL(request.url); - expect(parsedUrl.searchParams.get('limit')).to.not.equal('all'); - }); - expect(labelRequests.some((request) => { - const parsedUrl = new URL(request.url); - return parsedUrl.searchParams.get('limit') === '100'; - })).to.be.true; - - // selecting a value updates table - await selectChoose(`${filterSelector} .gh-member-label-input`, label.name); - - expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${label.name}`) - .to.equal(3); - - // table shows labels column+data - expect(find('[data-test-table-column="label"]')).to.exist; - expect(findAll('[data-test-table-data="label"]').length).to.equal(3); - expect(find('[data-test-table-data="label"]')).to.contain.text(label.name); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by tier', async function () { - // add multiple tiers to activate tiers filtering - const newsletter = this.server.create('newsletter', {status: 'active'}); - this.server.createList('tier', 4); - - // add some members with tiers - const tier = this.server.create('tier', {id: 'qwerty123456789'}); - this.server.createList('member', 3, {tiers: [tier], newsletters: [newsletter]}); - - // add some free members so we can see the filter excludes correctly - this.server.createList('member', 4, {newsletters: [newsletter]}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'tier_id'); - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // value dropdown can open and has all labels - await click(`${filterSelector} .gh-tier-token-input`); - expect(findAll(`${filterSelector} [data-test-tiers-segment]`).length, '# of label options').to.equal(5); - - // selecting a value updates table - await selectChoose(`${filterSelector} .gh-tier-token-input`, tier.name); - - expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${tier.name}`) - .to.equal(3); - // table shows labels column+data - expect(find('[data-test-table-column="status"]')).to.exist; - expect(findAll('[data-test-table-data="status"]').length).to.equal(3); - expect(find('[data-test-table-data="status"]')).to.contain.text(tier.name); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by offer redeemed', async function () { - // add some offers to test the selection dropdown - const tier = this.server.create('tier'); - - // create 3 offers - const offer = this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(1, 'day').valueOf()}); - this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(2, 'day').valueOf()}); - this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(3, 'day').valueOf()}); - this.server.createList('member', 3, {status: 'paid', tiers: [tier]}); - const sub = this.server.create('subscription', {member: this.server.schema.members.first(), tier: tier, offer: offer}); - const member = this.server.schema.members.first(); - member.update({subscriptions: [sub]}); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'offer_redemptions'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - await click(`${filterSelector} [data-test-token-input]`); - // this ensures that the offers are loaded into the multi-select dropdown in the filter - expect(findAll(`${filterSelector} [data-test-offers-segment]`).length, '# of label options').to.equal(3); - - // can set filter by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent(`offer_redemptions:'${offer.id}'`)); // ensure that the id is parsed as a string and not an integer - - // only one redeemed offer so only 1 member should be shown - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows').to.equal(1); - }); - - it('shows synthetic retention options instead of individual retention offers', async function () { - const tier = this.server.create('tier'); - - this.server.create('offer', { - name: 'Welcome offer', - tier: {id: tier.id}, - redemptionType: 'signup', - cadence: 'month' - }); - this.server.create('offer', { - name: 'Monthly retention v1', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - this.server.create('offer', { - name: 'Monthly retention v2', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - this.server.create('offer', { - name: 'Yearly retention v1', - tier: null, - redemptionType: 'retention', - cadence: 'year' - }); - - this.server.createList('member', 2, {status: 'paid', tiers: [tier]}); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'offer_redemptions'); - await click(`${filterSelector} [data-test-token-input]`); - - const offerOptions = findAll(`${filterSelector} [data-test-offers-segment]`).map(node => node.textContent.trim()); - - expect(offerOptions).to.include('Welcome offer'); - expect(offerOptions).to.include('Monthly Retention'); - expect(offerOptions).to.include('Yearly Retention'); - expect(offerOptions).to.not.include('Monthly retention v1'); - expect(offerOptions).to.not.include('Monthly retention v2'); - expect(offerOptions).to.not.include('Yearly retention v1'); - }); - - it('keeps specific retention offer URL filters without listing that version in dropdown', async function () { - const tier = this.server.create('tier'); - const monthlyRetentionV1 = this.server.create('offer', { - name: 'Monthly retention v1', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - const monthlyRetentionV2 = this.server.create('offer', { - name: 'Monthly retention v2', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - - const memberA = this.server.create('member', {status: 'paid', tiers: [tier]}); - const memberB = this.server.create('member', {status: 'paid', tiers: [tier]}); - - const subscriptionA = this.server.create('subscription', {member: memberA, tier, offer: monthlyRetentionV1}); - const subscriptionB = this.server.create('subscription', {member: memberB, tier, offer: monthlyRetentionV2}); - - memberA.update({subscriptions: [subscriptionA]}); - memberB.update({subscriptions: [subscriptionB]}); - - await visit('/members?filter=' + encodeURIComponent(`offer_redemptions:'${monthlyRetentionV2.id}'`)); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows').to.equal(1); - - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - await click(`${filterSelector} [data-test-token-input]`); - - const offerOptions = findAll(`${filterSelector} [data-test-offers-segment]`).map(node => node.textContent.trim()); - - expect(offerOptions).to.include('Monthly Retention'); - expect(offerOptions).to.not.include('Monthly retention v1'); - expect(offerOptions).to.not.include('Monthly retention v2'); - }); - - it('can filter by newsletter subscription when there is only one newsletter', async function () { - // Create a single newsletter - this.server.createList('newsletter', 1); - // Add some members to filter - this.server.createList('member', 3, {subscribed: true, email_disabled: 0}); - this.server.createList('member', 4, {subscribed: false, email_disabled: 0}); - this.server.createList('member', 1, {subscribed: true, email_disabled: 1}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(8); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'subscribed'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(3); - expect(valueOptions[0]).to.have.value('subscribed'); - expect(valueOptions[1]).to.have.value('unsubscribed'); - expect(valueOptions[2]).to.have.value('email-disabled'); - - // applies default filter subscribed immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed') - .to.equal(3); - - // can change filter to unsubscribed - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'unsubscribed'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed') - .to.equal(4); - expect(find('[data-test-table-column="subscribed"]')).to.exist; - - // can change filter to email-disabled - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'email-disabled'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled') - .to.equal(1); - expect(find('[data-test-table-column="subscribed"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(8); - - // Can set filter to 'subscribed' by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent('(subscribed:true+email_disabled:0)')); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed - from URL') - .to.equal(3); - await click('[data-test-button="members-filter-actions"]'); - expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('subscribed'); - - // Can set filter to 'unsubscribed' by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent('(subscribed:false+email_disabled:0)')); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed - from URL') - .to.equal(4); - await click('[data-test-button="members-filter-actions"]'); - expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('unsubscribed'); - - // Can set filter to 'email-disabled' by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent('(email_disabled:1)')); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled - from URL') - .to.equal(1); - await click('[data-test-button="members-filter-actions"]'); - expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('email-disabled'); - }); - - it('can filter by specific newsletter subscription when there are multiple newsletters', async function () { - // Create: - // - 1 subscribed member to newsletter - // - 1 subscribed member to newsletter with email disabled - // - 4 unsubscribed members - const newsletter = this.server.create('newsletter', {status: 'active', slug: 'test-newsletter'}); - const tier = this.server.create('tier'); - - const subscribedMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 0}); - subscribedMember.update({newsletters: [newsletter]}); - - const emailDisabledMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 1}); - emailDisabledMember.update({newsletters: [newsletter]}); - - this.server.createList('member', 4, {subscribed: false, email_disabled: 0}); - - // Test initial member count - await visit('/members'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(6); - - // Test newsletters options are in the filter dropdown - await click('[data-test-button="members-filter-actions"]'); - const newslettersCount = this.server.schema.newsletters.all().models.length; - let options = this.element.querySelectorAll('option'); - let matchingOptions = [...options].filter(option => option.value.includes('newsletters.slug')); - expect(matchingOptions).to.have.length(newslettersCount); - - const filterSelector = `[data-test-members-filter="0"]`; - - // Select first newsletter - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, `newsletters.slug:${newsletter.slug}`); - - // Test that the filter has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // Test that the filter has the right operators - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions[0]).to.have.value('true'); - expect(valueOptions[1]).to.have.value('false'); - - // applies default filter subscribed immediately, and only count subscribed members without email disabled - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed') - .to.equal(1); - - // can change filter to unsubscribed - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'false'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed') - .to.equal(5); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(6); - - // Can filter members subscribed to that newsletter by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:${newsletter.slug}+email_disabled:0`)); - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(1); - - // Can filter members unsubscribed to that newsletter by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:-${newsletter.slug},email_disabled:1`)); - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(5); - }); - - it('can filter by member status', async function () { - // add some members to filter - this.server.createList('member', 3, {status: 'paid'}); - this.server.createList('member', 4, {status: 'free'}); - this.server.createList('member', 2, {status: 'comped'}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(9); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - expect( - find(`${filterSelector} [data-test-select="members-filter"] option[value="status"]`), - 'status filter option' - ).to.exist; - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'status'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(3); - expect(valueOptions[0]).to.have.value('paid'); - expect(valueOptions[1]).to.have.value('free'); - expect(valueOptions[2]).to.have.value('comped'); - - // applies default filter immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - paid') - .to.equal(3); - - // can change filter - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'comped'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - comped') - .to.equal(2); - expect(find('[data-test-table-column="status"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(9); - }); - - it('can filter by billing period', async function () { - // add some members to filter - this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, planInterval: 'month'})); - this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, planInterval: 'year'})); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'subscriptions.plan_interval'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(2); - expect(valueOptions[0]).to.have.value('month'); - expect(valueOptions[1]).to.have.value('year'); - - // applies default filter immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - month') - .to.equal(3); - - // can change filter - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'year'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - year') - .to.equal(4); - expect(find('[data-test-table-column="subscriptions.plan_interval"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by stripe subscription status', async function () { - // add some members to filter - this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'})); - this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, status: 'trialing'})); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'subscriptions.status'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(7); - expect(valueOptions[0]).to.have.value('active'); - expect(valueOptions[1]).to.have.value('trialing'); - expect(valueOptions[2]).to.have.value('canceled'); - expect(valueOptions[3]).to.have.value('unpaid'); - expect(valueOptions[4]).to.have.value('past_due'); - expect(valueOptions[5]).to.have.value('incomplete'); - expect(valueOptions[6]).to.have.value('incomplete_expired'); - - // applies default filter immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - active') - .to.equal(3); - - // can change filter - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'trialing'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - trialing') - .to.equal(4); - expect(find('[data-test-table-column="subscriptions.status"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by emails sent', async function () { - // add some members to filter - this.server.createList('member', 3, {emailCount: 5}); - this.server.createList('member', 4, {emailCount: 10}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'email_count'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(3); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-greater'); - expect(operatorOptions[2]).to.have.value('is-less'); - - const valueInput = `${filterSelector} [data-test-input="members-filter-value"]`; - - // has no default filter - expect(find(valueInput)).to.have.value(''); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true') - .to.equal(7); - - // can change filter - await fillIn(valueInput, '5'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false') - .to.equal(3); - expect(find('[data-test-table-column="email_count"]')).to.exist; - - // can clear filter - await fillIn(valueInput, ''); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false') - .to.equal(7); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows') - .to.equal(7); - }); - - it('can filter by emails opened', async function () { - // add some members to filter - this.server.createList('member', 3, {emailOpenedCount: 5}); - this.server.createList('member', 4, {emailOpenedCount: 10}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'email_opened_count'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(3); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-greater'); - expect(operatorOptions[2]).to.have.value('is-less'); - - const valueInput = `${filterSelector} [data-test-input="members-filter-value"]`; - - // has no default filter - expect(find(valueInput)).to.have.value(''); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - after blur') - .to.equal(7); - - // can change filter - await fillIn(valueInput, '5'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - input 5') - .to.equal(3); - expect(find('[data-test-table-column="email_opened_count"]')).to.exist; - - // can clear filter - await fillIn(valueInput, ''); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - cleared') - .to.equal(7); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by open rate', async function () { - // add some members to filter - this.server.createList('member', 3, {emailOpenRate: 50}); - this.server.createList('member', 4, {emailOpenRate: 100}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'email_open_rate'); - - const operatorSelector = `${filterSelector} [data-test-select="members-filter-operator"]`; - - // has the right operators - const operatorOptions = findAll(`${operatorSelector} option`); - expect(operatorOptions).to.have.length(3); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-greater'); - expect(operatorOptions[2]).to.have.value('is-less'); - - const valueInput = `${filterSelector} [data-test-input="members-filter-value"]`; - - // has no default filter - expect(find(valueInput)).to.have.value(''); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - after blur') - .to.equal(7); - - // can change filter - await fillIn(valueInput, '50'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - value 50') - .to.equal(3); - expect(find('[data-test-table-column="email_open_rate"]')).to.exist; - - // can change operator - await fillIn(operatorSelector, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - operator is-greater') - .to.equal(4); - - // it does not add duplicate column - expect(find('[data-test-table-column="email_open_rate"]')).to.exist; - expect(findAll('[data-test-table-column="email_open_rate"]').length).to.equal(1); - - // can clear filter - await fillIn(valueInput, ''); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - cleared') - .to.equal(7); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by last seen date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-02-05 11:50:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - this.server.createList('member', 3, {lastSeenAt: moment('2022-02-01 11:00:00').format('YYYY-MM-DD HH:mm:ss')}); - this.server.createList('member', 4, {lastSeenAt: moment('2022-02-05 11:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - await click('[data-test-button="members-filter-actions"]'); - await fillIn(typeSelect, 'last_seen_at'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - // has the right default operator - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // has expected default value - expect(find(valueInput)).to.have.value('2022-02-05'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - after blur') - .to.equal(7); - - // can change operator - await fillIn(operatorSelect, 'is-less'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is before 2022-02-05') - .to.equal(3); - - // can change filter via input - await fillIn(operatorSelect, 'is-greater'); - await fillIn(valueInput, '2022-02-01'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is after 2022-02-01') - .to.equal(4); - - // can change filter via date picker - await fillIn(operatorSelect, 'is-or-greater'); - await datepickerSelect(valueDatePicker, moment.utc('2022-01-01').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is after 2022-01-01') - .to.equal(7); - - // table shows last seen column+data - expect(find('[data-test-table-column="last_seen_at"]')).to.exist; - expect(findAll('[data-test-table-data="last_seen_at"]').length).to.equal(7); - expect(find('[data-test-table-data="last_seen_at"]')).to.contain.trimmed.text('1 Feb 2022'); - expect(find('[data-test-table-data="last_seen_at"]')).to.contain.trimmed.text('4 days ago'); - }); - - it('can filter by created at date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 09:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - this.server.createList('member', 3, {createdAt: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - this.server.createList('member', 4, {createdAt: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="created_at"]`), 'created_at filter option').to.exist; - - await fillIn(typeSelect, 'created_at'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - // expect(operatorOptions[2]).to.have.value('is'); - // expect(operatorOptions[3]).to.have.value('is-not'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - const valueDateInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - // operator defaults to "on or before" - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // value defaults to today's date - expect(find(valueDateInput)).to.have.value('2022-03-01'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can change date - await datepickerSelect(valueDatePicker, moment.utc('2022-02-03').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - 2022-02-03') - .to.equal(3); - - // can change operator - await fillIn(operatorSelect, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-greater') - .to.equal(4); - - // can populate filter from URL - // TODO: leaving screen is needed, suggests component is not fully reactive and needs to be torn down. - // - see constructor - await visit(`/`); - const filter = encodeURIComponent(`created_at:<='2022-02-01 23:59:59'`); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - - expect(find(typeSelect), 'type select - from URL').to.have.value('created_at'); - expect(find(operatorSelect), 'operator select - from URL').to.have.value('is-or-less'); - expect(find(valueDateInput), 'date input - from URL').to.have.value('2022-02-01'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL') - .to.equal(3); - - // "on or after" doesn't break - await fillIn(operatorSelect, 'is-or-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-or-greater after URL change') - .to.equal(7); - - // it does not add extra column to table - expect(find('[data-test-table-column="created_at"]')).to.not.exist; - }); - - it('uses site timezone when filtering by date', async function () { - // with a site timezone UTC-5 (Eastern Time Zone) we would expect date-based NQL filter strings - // to be adjusted to UTC. - // - // Eg. "created on or after 2022-02-22" = `created_at:>='2022-02-22 05:00:00' - // - // we also need to convert back when parsing the NQL-based query param and make sure dates - // shown in the members table match site timezone - - // UTC-5 timezone - this.server.db.settings.update({key: 'timezone'}, {value: 'America/New_York'}); - - // 2022-02-21 signups - this.server.createList('member', 3, {createdAt: moment.utc('2022-02-22 04:00:00.000Z').format('YYYY-MM-DD HH:mm:ss')}); - // 2022-02-22 signups - this.server.createList('member', 4, {createdAt: moment.utc('2022-02-22 05:00:00.000Z').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - // created dates in table should match the date in site timezone not UTC (in UTC they would all be 21st) - const createdAtFields = findAll('[data-test-list="members-list-item"] [data-test-table-data="created-at"]'); - expect(createdAtFields.filter(el => el.textContent.match(/21 Feb 2022/)).length).to.equal(3); - expect(createdAtFields.filter(el => el.textContent.match(/22 Feb 2022/)).length).to.equal(4); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - - // filter date is transformed to UTC equivalent timeframe when querying - await click('[data-test-button="members-filter-actions"]'); - await fillIn(typeSelect, 'created_at'); - await fillIn(operatorSelect, 'is-or-greater'); - await fillIn(valueInput, '2022-02-22'); - await blur(valueInput); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows - post filter') - .to.equal(4); - - // query param is transformed back to expected filter date value - await visit('/'); // TODO: remove once component reacts to filter updates - const filterQuery = encodeURIComponent(`created_at:<='2022-02-22 04:59:59'`); - await visit(`/members?filter=${filterQuery}`); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows - post URL parse') - .to.equal(3); - - await click('[data-test-button="members-filter-actions"]'); - - expect(find(operatorSelect)).to.have.value('is-or-less'); - expect(find(valueInput)).to.have.value('2022-02-21'); - - // it initializes date filter with correct site timezone date - // "local" is 1st March 04:00 but site time is 28th Feb 00:00 - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 04:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - await click('[data-test-delete-members-filter="0"]'); - await click('[data-test-button="members-filter-actions"]'); - await fillIn(typeSelect, 'created_at'); - - expect(find(valueInput)).to.have.value('2022-02-28'); - }); - - it('can filter by paid subscription start date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 09:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, startDate: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')})); - this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, startDate: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')})); - this.server.createList('member', 2); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(9); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="subscriptions.start_date"]`), 'subscriptions.start_date filter option').to.exist; - - await fillIn(typeSelect, 'subscriptions.start_date'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - // expect(operatorOptions[2]).to.have.value('is'); - // expect(operatorOptions[3]).to.have.value('is-not'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - const valueDateInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - // operator defaults to "on or before" - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // value defaults to today's date - expect(find(valueDateInput)).to.have.value('2022-03-01'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can change date - await datepickerSelect(valueDatePicker, moment.utc('2022-02-03').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - 2022-02-03') - .to.equal(3); - - // can change operator - await fillIn(operatorSelect, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-greater') - .to.equal(4); - - // can populate filter from URL - // TODO: leaving screen is needed, suggests component is not fully reactive and needs to be torn down. - // - see constructor - await visit(`/`); - const filter = encodeURIComponent(`subscriptions.start_date:<='2022-02-01 23:59:59'`); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - - expect(find(typeSelect), 'type select - from URL').to.have.value('subscriptions.start_date'); - expect(find(operatorSelect), 'operator select - from URL').to.have.value('is-or-less'); - expect(find(valueDateInput), 'date input - from URL').to.have.value('2022-02-01'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL') - .to.equal(3); - - // it adds extra column to table - expect(find('[data-test-table-column="subscriptions.start_date"]')).to.exist; - expect(find('[data-test-table-column="subscriptions.start_date"]')).to.contain.text('Paid start date'); - expect(findAll('[data-test-table-data="subscriptions.start_date"]').length).to.equal(3); - expect(find('[data-test-table-data="subscriptions.start_date"]')).to.contain.text('1 Feb 2022'); - expect(find('[data-test-table-data="subscriptions.start_date"]')).to.contain.text('a month ago'); - }); - - it('can filter by name', async function () { - this.server.create('member', {name: 'test-1'}); - this.server.create('member', {name: 'test-2'}); - this.server.create('member', {name: 'tset-1'}); - this.server.create('member', {name: 'tset-2'}); - this.server.create('member', {name: 'tset-3'}); - this.server.create('member', {name: 'hello'}); - this.server.create('member', {name: 'John O\'Nolan'}); - this.server.create('member', {name: null}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(8); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="name"]`), 'name filter option').to.exist; - - await fillIn(typeSelect, 'name'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(5); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('contains'); - expect(operatorOptions[2]).to.have.value('does-not-contain'); - expect(operatorOptions[3]).to.have.value('starts-with'); - expect(operatorOptions[4]).to.have.value('ends-with'); - - // has expected default operator and value - expect(find(operatorSelect)).to.have.value('is'); - expect(find(valueInput)).to.have.value(''); - - // can change filter - await fillIn(valueInput, 'hello'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is "hello"') - .to.equal(1); - - // can change operator - await fillIn(operatorSelect, 'contains'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "hello"') - .to.equal(1); - - // contains query works - await fillIn(valueInput, 'test'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "test"') - .to.equal(2); - - // starts with query works - await fillIn(operatorSelect, 'starts-with'); - await fillIn(valueInput, 'tset'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - starts with "tset"') - .to.equal(3); - - // ends with query works - await fillIn(operatorSelect, 'ends-with'); - await fillIn(valueInput, '2'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - ends with "2"') - .to.equal(2); - - // does not contain query works - await fillIn(operatorSelect, 'does-not-contain'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - does not contain "2"') - .to.equal(6); - - // can query with escaped chars - await fillIn(operatorSelect, 'contains'); - await fillIn(valueInput, `O'Nolan`); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "O\'Nolan"') - .to.equal(1); - - // no duplicate column added (name is included in the "details" column) - expect(find('[data-test-table-column="name"]')).to.not.exist; - - // can handle contains operator in URL - let filter = encodeURIComponent(`name:~'hello'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "hello"') - .to.equal(1); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value('hello'); - - // can handle starts-with operator in URL - filter = encodeURIComponent(`name:~^'tset'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL starts with "tset"') - .to.equal(3); - expect(find(operatorSelect)).to.have.value('starts-with'); - expect(find(valueInput)).to.have.value('tset'); - - // can handle ends-with operator in URL - filter = encodeURIComponent(`name:~$'2'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL ends with "2"') - .to.equal(2); - expect(find(operatorSelect)).to.have.value('ends-with'); - expect(find(valueInput)).to.have.value('2'); - - // can handle does-not-contain operator in URL - filter = encodeURIComponent(`name:-~'2'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL does not contain "2"') - .to.equal(6); - expect(find(operatorSelect)).to.have.value('does-not-contain'); - expect(find(valueInput)).to.have.value('2'); - - // can handle escaped values in URL - filter = encodeURIComponent(`name:~'O\\'Nolan'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "O\'Nolan"') - .to.equal(1); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value(`O'Nolan`); - - // can handle regex special chars in URL - filter = encodeURIComponent(`name:~'test+test'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "test+test"') - .to.equal(0); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value(`test+test`); - }); - - it('can filter by email', async function () { - this.server.create('member', {email: 'test-1@one.com'}); - this.server.create('member', {email: 'test-2@one.com'}); - this.server.create('member', {email: 'test-1@two.com'}); - this.server.create('member', {email: 'test-2@two.com'}); - this.server.create('member', {email: 'test-3@two.com'}); - this.server.create('member', {email: 'hello@hi.com'}); - this.server.create('member', {email: 'with+plus@fuzzy.org'}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="email"]`), 'email filter option').to.exist; - - await fillIn(typeSelect, 'email'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(5); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('contains'); - expect(operatorOptions[2]).to.have.value('does-not-contain'); - expect(operatorOptions[3]).to.have.value('starts-with'); - expect(operatorOptions[4]).to.have.value('ends-with'); - - // has expected default operator and value - expect(find(operatorSelect)).to.have.value('is'); - expect(find(valueInput)).to.have.value(''); - - // can change filter - await fillIn(valueInput, 'hello@hi.com'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is "hello@hi.com"') - .to.equal(1); - - // can change operator - await fillIn(operatorSelect, 'contains'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "hello"') - .to.equal(1); - - // contains query works - await fillIn(valueInput, 'test'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "test"') - .to.equal(5); - - // starts with query works - await fillIn(operatorSelect, 'starts-with'); - await fillIn(valueInput, 'test-2'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - starts with "test-2"') - .to.equal(2); - - // ends with query works - await fillIn(operatorSelect, 'ends-with'); - await fillIn(valueInput, '.com'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - ends with ".com"') - .to.equal(6); - - // does not contain query works - await fillIn(operatorSelect, 'does-not-contain'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - does not contain ".com"') - .to.equal(1); - - // can query with special chars - await fillIn(operatorSelect, 'contains'); - await fillIn(valueInput, `with+plus`); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "with+plus"') - .to.equal(1); - - // no duplicate column added (email is included in the "details" column) - expect(find('[data-test-table-column="email"]')).to.not.exist; - - // can handle contains operator in URL - let filter = encodeURIComponent(`email:~'hello'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "hello"') - .to.equal(1); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value('hello'); - }); - - it('can filter by next billing date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 09:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, currentPeriodEnd: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')})); - this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, currentPeriodEnd: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')})); - this.server.createList('member', 2); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(9); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="subscriptions.current_period_end"]`), 'subscriptions.current_period_end filter option').to.exist; - - await fillIn(typeSelect, 'subscriptions.current_period_end'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - // expect(operatorOptions[2]).to.have.value('is'); - // expect(operatorOptions[3]).to.have.value('is-not'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - const valueDateInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - // operator defaults to "on or before" - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // value defaults to today's date - expect(find(valueDateInput)).to.have.value('2022-03-01'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can change date - await datepickerSelect(valueDatePicker, moment.utc('2022-02-03').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - 2022-02-03') - .to.equal(3); - - // can change operator - await fillIn(operatorSelect, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-greater') - .to.equal(4); - - // can populate filter from URL - // TODO: leaving screen is needed, suggests component is not fully reactive and needs to be torn down. - // - see constructor - await visit(`/`); - const filter = encodeURIComponent(`subscriptions.current_period_end:<='2022-02-01 23:59:59'`); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - - expect(find(typeSelect), 'type select - from URL').to.have.value('subscriptions.current_period_end'); - expect(find(operatorSelect), 'operator select - from URL').to.have.value('is-or-less'); - expect(find(valueDateInput), 'date input - from URL').to.have.value('2022-02-01'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL') - .to.equal(3); - - // it adds extra column to table - expect(find('[data-test-table-column="subscriptions.current_period_end"]')).to.exist; - expect(find('[data-test-table-column="subscriptions.current_period_end"]')).to.contain.text('Next billing date'); - expect(findAll('[data-test-table-data="subscriptions.current_period_end"]').length).to.equal(3); - expect(find('[data-test-table-data="subscriptions.current_period_end"]')).to.contain.text('1 Feb 2022'); - expect(find('[data-test-table-data="subscriptions.current_period_end"]')).to.contain.text('a month ago'); - }); - - it('can handle multiple filters', async function () { - // add some members to filter - this.server.createList('member', 1).forEach(member => this.server.create('subscription', {member, status: 'active'})); - this.server.createList('member', 2).forEach(member => this.server.create('subscription', {member, status: 'trialing'})); - this.server.createList('member', 3, {emailOpenRate: 50}).forEach(member => this.server.create('subscription', {member, status: 'trialing'})); - this.server.createList('member', 4, {emailOpenRate: 100}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(10); - - await click('[data-test-button="members-filter-actions"]'); - - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'email_open_rate'); - await fillIn('[data-test-members-filter="0"] [data-test-input="members-filter-value"]', '50'); - await blur('[data-test-members-filter="0"] [data-test-input="members-filter-value"]'); - - await click('[data-test-button="add-members-filter"]'); - - await fillIn(`[data-test-members-filter="1"] [data-test-select="members-filter"]`, 'subscriptions.status'); - await fillIn(`[data-test-members-filter="1"] [data-test-select="members-filter-value"]`, 'trialing'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of members rows after filter') - .to.equal(3); - - await click('[data-test-button="members-apply-filter"]'); - - // all filtered columns are shown - expect(find('[data-test-table-column="email_open_rate"]')).to.exist; - expect(find('[data-test-table-column="subscriptions.status"]')).to.exist; - - // bulk actions are shown - 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; - - /* 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'); - expect(find('[data-test-button="members-filter-actions"]'), 'filter button').to.contain.text('(2)'); - - // search is inactive - expect(find('[data-test-input="members-search"]'), 'search input').to.exist; - expect(find('[data-test-input="members-search"]'), 'search input').to.not.have.class('active'); - - // can reset filter - await click('[data-test-button="members-filter-actions"]'); - await click('[data-test-button="reset-members-filter"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(10); - - // filter is inactive - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.not.have.class('gh-btn-label-green'); - }); - - it('has a no-match state', async function () { - this.server.createList('member', 5).forEach(member => this.server.create('subscription', {member, status: 'active'})); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(5); - - await click('[data-test-button="members-filter-actions"]'); - - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'email_open_rate'); - await fillIn('[data-test-members-filter="0"] [data-test-input="members-filter-value"]', '50'); - await blur('[data-test-members-filter="0"] [data-test-input="members-filter-value"]'); - - await click('[data-test-button="members-apply-filter"]'); - - // replaces members table with the no-matching members state - expect(find('[data-test-table="members"]')).to.not.exist; - expect(find('[data-test-no-matching-members]')).to.exist; - - // search input is hidden - expect(find('[data-test-input="members-search"]')).to.not.be.visible; - - // export is disabled - expect(find('[data-test-button="export-members"]')).to.have.attribute('disabled'); - - // bulk actions are hidden - expect(find('[data-test-button="add-label-selected"]')).to.not.exist; - expect(find('[data-test-button="remove-label-selected"]')).to.not.exist; - expect(find('[data-test-button="unsubscribe-selected"]')).to.not.exist; - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // can clear the filter - await click('[data-test-no-matching-members] [data-test-button="show-all-members"]'); - - expect(currentURL()).to.equal('/members'); - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.not.have.class('gh-btn-label-green'); - }); - - it('resets filter operator when changing filter type', async function () { - // BUG: changing the filter type was not resetting the filter operator - // meaning you could have an "is-greater" operator applied to an - // "is/is-not" filter type - - this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'})); - this.server.createList('member', 4, {emailCount: 10}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filter = '[data-test-members-filter="0"]'; - - await fillIn(`${filter} [data-test-select="members-filter"]`, 'email_count'); - await fillIn(`${filter} [data-test-select="members-filter-operator"]`, 'is-greater'); - await fillIn(`${filter} [data-test-input="members-filter-value"]`, '9'); - await blur(`${filter} [data-test-input="members-filter-value"]`); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of members after email_count filter') - .to.equal(4); - - await fillIn(`${filter} [data-test-select="members-filter"]`, 'subscriptions.status'); - - expect(find(`${filter} [data-test-select="members-filter-operator"]`)).to.have.value('is'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of members after email_count filter') - .to.equal(3); - }); - - it('hides paid filters when stripe isn\'t connected', async function () { - // disconnect stripe - this.server.db.settings.update({key: 'paid_members_enabled'}, {value: false}); - this.server.createList('member', 10); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - - expect( - find('[data-test-members-filter="0"] [data-test-select="members-filter"] optgroup[label="Subscription"]'), - 'Subscription option group doesn\'t exist' - ).to.not.exist; - - const filterOptions = findAll('[data-test-members-filter="0"] [data-test-select="members-filter"] option') - .map(option => option.value); - - expect(filterOptions).to.not.include('status'); - expect(filterOptions).to.not.include('subscriptions.plan_interval'); - expect(filterOptions).to.not.include('subscriptions.status'); - }); - - it('hides email filters when email is disabled', async function () { - // disable email - this.server.db.settings.update({key: 'editor_default_email_recipients'}, {value: 'disabled'}); - this.server.createList('member', 10); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - - expect( - find('[data-test-members-filter="0"] [data-test-select="members-filter"] optgroup[label="Email"]'), - 'Email option group doesn\'t exist' - ).to.not.exist; - - const filterOptions = findAll('[data-test-members-filter="0"] [data-test-select="members-filter"] option') - .map(option => option.value); - - expect(filterOptions).to.not.include('email_count'); - expect(filterOptions).to.not.include('email_opened_count'); - expect(filterOptions).to.not.include('email_open_rate'); - }); - }); - - describe('search', function () { - beforeEach(function () { - // specific member names+emails so search is deterministic - // (default factory has random names+emails) - this.server.create('member', {name: 'X', email: 'x@x.xxx'}); - this.server.create('member', {name: 'Y', email: 'y@y.yyy'}); - this.server.create('member', {name: 'Z', email: 'z@z.zzz'}); - }); - - it('works', async function () { - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(3); - - await fillIn('[data-test-input="members-search"]', 'X'); - - // list updates - expect(findAll('[data-test-list="members-list-item"]').length, '# of members matching "X"') - .to.equal(1); - - // URL reflects search - expect(currentURL()).to.equal('/members?search=X'); - - // search input is active - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - - // bulk actions become available - 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; - - // clearing search returns us to starting state - await fillIn('[data-test-input="members-search"]', ''); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of members after clearing search') - .to.equal(3); - - expect(find('[data-test-input="members-search"]')).to.not.have.class('active'); - }); - - it('populates from query param', async function () { - await visit('/members?search=Y'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(1); - - expect(find('[data-test-input="members-search"]')).to.have.value('Y'); - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - }); - - it('has a no-match state', async function () { - await visit('/members'); - await fillIn('[data-test-input="members-search"]', 'unknown'); - - expect(currentURL()).to.equal('/members?search=unknown'); - - // replaces members table with the no-matching members state - expect(find('[data-test-table="members"]')).to.not.exist; - expect(find('[data-test-no-matching-members]')).to.exist; - - // search input is still shown - expect(find('[data-test-input="members-search"]')).to.be.visible; - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - - // export is disabled - expect(find('[data-test-button="export-members"]')).to.have.attribute('disabled'); - - // bulk actions are hidden - expect(find('[data-test-button="add-label-selected"]')).to.not.exist; - expect(find('[data-test-button="remove-label-selected"]')).to.not.exist; - expect(find('[data-test-button="unsubscribe-selected"]')).to.not.exist; - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // can clear the search - await click('[data-test-no-matching-members] [data-test-button="show-all-members"]'); - - expect(currentURL()).to.equal('/members'); - expect(find('[data-test-input="members-search"]')).to.have.value(''); - expect(find('[data-test-input="members-search"]')).to.not.have.class('active'); - expect(findAll('[data-test-list="members-list-item"]').length).to.equal(3); - }); - - it('can search + filter', async function () { - this.server.create('member', {name: 'A', email: 'a@aaa.aaa', subscriptions: [this.server.create('subscription', {status: 'active'})]}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(4); - - await click('[data-test-button="members-filter-actions"]'); - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'subscriptions.status'); - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter-value"]', 'active'); - await click('[data-test-button="members-apply-filter"]'); - - await fillIn('[data-test-input="members-search"]', 'a'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows after filter+search') - .to.equal(1); - - // 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'); - expect(find('[data-test-button="members-filter-actions"]'), 'filter button').to.contain.text('(1)'); - - // search input is active - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - }); - }); -}); diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js deleted file mode 100644 index 871f2115d69..00000000000 --- a/ghost/admin/tests/acceptance/members/import-test.js +++ /dev/null @@ -1,256 +0,0 @@ -import {Response} from 'miragejs'; -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {click, currentRouteName, currentURL, find, findAll} from '@ember/test-helpers'; -import {enableLabsFlag} from '../../helpers/labs-flag'; -import {expect} from 'chai'; -import {fileUpload} from '../../helpers/file-upload'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {visit} from '../../helpers/visit'; - -describe.skip('Acceptance: Members import', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - describe('Owner tests', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('can open and close import modal', async function () { - await visit('/members'); - await click('[data-test-button="members-actions"]'); - await click('[data-test-link="import-csv"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.exist; - expect(currentURL()).to.equal('/members/import'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; - expect(currentURL()).to.equal('/members'); - }); - - it('has working happy path for small import with no mapper changes and Stripe not connected', async function () { - await visit('/members/import'); - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - expect(find('[data-test-csv-file-mapping]'), 'csv file mapper').to.exist; - expect(find('[data-test-members-import-table]'), 'csv file mapper').to.exist; - expect(findAll('[data-test-members-import-mapper]').length, '# of mapper rows').to.equal(6); - expect(find('[data-test-button="perform-import"]')).to.contain.text(' 1 '); - - await click('[data-test-button="perform-import"]'); - - expect(find('[data-test-modal="import-members"]')).to.contain.text('Import complete'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]')).to.not.exist; - }); - - it('can assign labels in import mapper', async function () { - const label1 = this.server.create('label'); - - await visit('/members/import'); - - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - const labelInput = '[data-test-csv-file-mapping] .gh-member-label-input'; - expect(find(labelInput), 'label input').to.exist; - - const dropdownContentId = find(`${labelInput}`).getAttribute('aria-owns'); - await click(`${labelInput}`); - - expect(findAll(`#${dropdownContentId} li.ember-power-select-option`).length, '# of label options').to.equal(1); - - // label input doesn't allow editing from the import modal - expect(findAll(`#${dropdownContentId} [data-test-edit-label]`).length, '# of label edit buttons').to.equal(0); - - await click(find(`#${dropdownContentId} li.ember-power-select-option`)); - - expect(findAll(`${labelInput} .ember-power-select-multiple-options li`).length, '# of selected labels').to.equal(1); - expect(find(`${labelInput} .ember-power-select-multiple-options li`)).to.contain.text(label1.name); - - let apiLabels = null; - - this.server.post('/members/upload/', function ({labels}, request) { - const label = labels.create(); - - apiLabels = request.requestBody.get('labels'); - - return new Response(201, {}, { - meta: { - import_label: label, - stats: {imported: 1, invalid: []} - } - }); - }); - - await click('[data-test-button="perform-import"]'); - - expect(apiLabels).to.equal(label1.name); - }); - - it('opts out of the Ember import route when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - - await visit('/members/import'); - - expect(currentRouteName()).to.equal('members.import'); - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; - }); - - it('preserves query params when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - - await visit('/members/import?filter=label%3AVIP&search=alice'); - - expect(currentRouteName()).to.equal('members.import'); - expect(currentURL()).to.equal('/members/import?filter=label%3AVIP&search=alice'); - }); - }); - describe ('super editors functions', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Super Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('can open and close import modal', async function () { - await visit('/members'); - await click('[data-test-button="members-actions"]'); - await click('[data-test-link="import-csv"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.exist; - expect(currentURL()).to.equal('/members/import'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; - expect(currentURL()).to.equal('/members'); - }); - it('has working happy path for small import with no mapper changes and Stripe not connected', async function () { - await visit('/members/import'); - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - expect(find('[data-test-csv-file-mapping]'), 'csv file mapper').to.exist; - expect(find('[data-test-members-import-table]'), 'csv file mapper').to.exist; - expect(findAll('[data-test-members-import-mapper]').length, '# of mapper rows').to.equal(6); - expect(find('[data-test-button="perform-import"]')).to.contain.text(' 1 '); - - await click('[data-test-button="perform-import"]'); - - expect(find('[data-test-modal="import-members"]')).to.contain.text('Import complete'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]')).to.not.exist; - }); - - it('can assign labels in import mapper', async function () { - const label1 = this.server.create('label'); - - await visit('/members/import'); - - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - const labelInput = '[data-test-csv-file-mapping] .gh-member-label-input'; - expect(find(labelInput), 'label input').to.exist; - - const dropdownContentId = find(`${labelInput}`).getAttribute('aria-owns'); - await click(`${labelInput}`); - - expect(findAll(`#${dropdownContentId} li.ember-power-select-option`).length, '# of label options').to.equal(1); - - // label input doesn't allow editing from the import modal - expect(findAll(`#${dropdownContentId} [data-test-edit-label]`).length, '# of label edit buttons').to.equal(0); - - await click(find(`#${dropdownContentId} li.ember-power-select-option`)); - - expect(findAll(`${labelInput} .ember-power-select-multiple-options li`).length, '# of selected labels').to.equal(1); - expect(find(`${labelInput} .ember-power-select-multiple-options li`)).to.contain.text(label1.name); - - let apiLabels = null; - - this.server.post('/members/upload/', function ({labels}, request) { - const label = labels.create(); - - apiLabels = request.requestBody.get('labels'); - - return new Response(201, {}, { - meta: { - import_label: label, - stats: {imported: 1, invalid: []} - } - }); - }); - - await click('[data-test-button="perform-import"]'); - - expect(apiLabels).to.equal(label1.name); - }); - }); - describe('Editor functions', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('Editor cannot access members import', async function () { - await visit('/members/import'); - - expect(currentURL()).to.equal('/site'); - }); - - it('Editor cannot access members import when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - - await visit('/members/import?filter=label%3AVIP&search=alice'); - - expect(currentURL()).to.equal('/site'); - }); - }); -}); diff --git a/ghost/admin/tests/integration/components/gh-members-import-table-test.js b/ghost/admin/tests/integration/components/gh-members-import-table-test.js deleted file mode 100644 index 5e5442ac200..00000000000 --- a/ghost/admin/tests/integration/components/gh-members-import-table-test.js +++ /dev/null @@ -1,110 +0,0 @@ -import hbs from 'htmlbars-inline-precompile'; -import {click, findAll, render} from '@ember/test-helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupRenderingTest} from 'ember-mocha'; - -describe('Integration: Component: gh-members-import-table', function () { - setupRenderingTest(); - - it('renders members data with all the properties', async function () { - this.set('importData', [{ - name: 'Kevin', - email: 'kevin@example.com' - }]); - this.set('setMapping', () => {}); - - await render(hbs` - - `); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - }); - - it('navigates through data when next and previous are clicked', async function () { - this.set('importData', [{ - name: 'Kevin', - email: 'kevin@example.com' - }, { - name: 'Rish', - email: 'rish@example.com' - }]); - this.set('setMapping', () => {}); - - await render(hbs` - - `); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-next]'); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Rish'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('rish@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-prev]'); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - }); - - it('cannot navigate through data when only one data item is present', async function () { - this.set('importData', [{ - name: 'Egg', - email: 'egg@example.com' - }]); - this.set('setMapping', () => {}); - - await render(hbs` - - `); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Egg'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('egg@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-next]'); - - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Egg'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('egg@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-prev]'); - - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Egg'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('egg@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - }); -}); diff --git a/ghost/admin/tests/integration/components/modal-import-members-test.js b/ghost/admin/tests/integration/components/modal-import-members-test.js deleted file mode 100644 index 1e6f0df9d05..00000000000 --- a/ghost/admin/tests/integration/components/modal-import-members-test.js +++ /dev/null @@ -1,194 +0,0 @@ -import Pretender from 'pretender'; -import Service from '@ember/service'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import hbs from 'htmlbars-inline-precompile'; -import sinon from 'sinon'; -import {click, find, findAll, render, waitFor} from '@ember/test-helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {fileUpload} from '../../helpers/file-upload'; -import {setupRenderingTest} from 'ember-mocha'; - -const notificationsStub = Service.extend({ - showAPIError() { - // noop - to be stubbed - } -}); - -const stubSuccessfulUpload = function (server, delay = 0) { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [200, {'Content-Type': 'application/json'}, '{"url":"/content/images/test.png"}']; - }, delay); -}; - -const stubFailedUpload = function (server, code, error, delay = 0) { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [code, {'Content-Type': 'application/json'}, JSON.stringify({ - errors: [{ - type: error, - message: `Error: ${error}` - }] - })]; - }, delay); -}; - -describe('Integration: Component: modal-import-members-test', function () { - setupRenderingTest(); - - let server; - - beforeEach(function () { - server = new Pretender(); - this.set('uploadUrl', `${ghostPaths().apiRoot}/members/upload/`); - - this.owner.register('service:notifications', notificationsStub); - }); - - afterEach(function () { - server.shutdown(); - }); - - it('renders', async function () { - await render(hbs``); - - expect(find('h1').textContent.trim(), 'default header') - .to.equal('Import members'); - expect(find('.description').textContent.trim(), 'upload label') - .to.equal('Select or drop a CSV file'); - }); - - it('generates request to supplied endpoint', async function () { - stubSuccessfulUpload(server); - - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - await waitFor('table', {timeout: 50}); - - expect(find('label').textContent.trim(), 'labels label') - .to.equal('Label these members'); - expect(find('.gh-btn-green').textContent).to.match(/Import/g); - - await click('.gh-btn-green'); - - expect(server.handledRequests.length).to.equal(1); - expect(server.handledRequests[0].url).to.equal(`${ghostPaths().apiRoot}/members/upload/`); - }); - - it('displays server error', async function () { - stubFailedUpload(server, 415, 'UnsupportedMediaTypeError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/); - }); - - it('displays file too large for server error', async function () { - stubFailedUpload(server, 413, 'RequestEntityTooLargeError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file you uploaded was larger/); - }); - - it('handles file too large error directly from the web server', async function () { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [413, {}, '']; - }); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file you uploaded was larger/); - }); - - it('displays other server-side error with message', async function () { - stubFailedUpload(server, 400, 'UnknownError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/An unexpected error occurred, please try again/); - }); - - it('handles unknown failure', async function () { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [500, {'Content-Type': 'application/json'}, '']; - }); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/An unexpected error occurred, please try again/); - }); - - it('triggers notifications.showAPIError for VersionMismatchError', async function () { - let showAPIError = sinon.spy(); - let notifications = this.owner.lookup('service:notifications'); - notifications.set('showAPIError', showAPIError); - - stubFailedUpload(server, 400, 'VersionMismatchError'); - - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(showAPIError.calledOnce).to.be.true; - }); - - it('doesn\'t trigger notifications.showAPIError for other errors', async function () { - let showAPIError = sinon.spy(); - let notifications = this.owner.lookup('service:notifications'); - notifications.set('showAPIError', showAPIError); - - stubFailedUpload(server, 400, 'UnknownError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(showAPIError.called).to.be.false; - }); - - it('validates extension by default', async function () { - stubFailedUpload(server, 415); - - await render(hbs``); - - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/); - }); -}); diff --git a/ghost/admin/tests/integration/services/member-import-validator-test.js b/ghost/admin/tests/integration/services/member-import-validator-test.js deleted file mode 100644 index 99641d5d1b0..00000000000 --- a/ghost/admin/tests/integration/services/member-import-validator-test.js +++ /dev/null @@ -1,163 +0,0 @@ -import Pretender from 'pretender'; -import Service from '@ember/service'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -let MembersUtilsStub = Service.extend({ - isStripeEnabled: true -}); - -describe('Integration: Service: member-import-validator', function () { - setupTest(); - - let server; - - beforeEach(function () { - server = new Pretender(); - this.owner.register('service:membersUtils', MembersUtilsStub); - }); - - afterEach(function () { - server.shutdown(); - }); - - it('checks correct data without Stripe customer', async function () { - let service = this.owner.lookup('service:member-import-validator'); - - const mapping = await service.check([{ - name: 'Rish', - email: 'validemail@example.com' - }]); - - expect(mapping.email).to.equal('email'); - }); - - describe('data sampling method', function () { - it('returns whole data set when sampled size is less then default 30', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = await service._sampleData([{ - email: 'email@example.com' - }, { - email: 'email2@example.com' - }]); - - expect(result.length).to.equal(2); - }); - - it('returns dataset with sample size for non empty values only', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - let data = [{ - email: null - }, { - email: 'email2@example.com' - }, { - email: 'email3@example.com' - }, { - email: 'email4@example.com' - }, { - email: '' - }]; - - const result = await service._sampleData(data, 3); - - expect(result.length).to.equal(3); - expect(result[0].email).to.equal('email2@example.com'); - expect(result[1].email).to.equal('email3@example.com'); - expect(result[2].email).to.equal('email4@example.com'); - }); - - it('returns dataset with sample size for non empty values for objects with multiple properties', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - let data = [{ - email: null, - other_prop: 'non empty 1' - }, { - email: 'email2@example.com', - other_prop: 'non empty 2' - }, { - email: 'email3@example.com', - other_prop: '' - }, { - email: 'email4@example.com' - }, { - email: '', - other_prop: 'non empty 5' - }]; - - const result = await service._sampleData(data, 3); - - expect(result.length).to.equal(3); - expect(result[0].email).to.equal('email2@example.com'); - expect(result[0].other_prop).to.equal('non empty 1'); - expect(result[1].email).to.equal('email3@example.com'); - expect(result[1].other_prop).to.equal('non empty 2'); - expect(result[2].email).to.equal('email4@example.com'); - expect(result[2].other_prop).to.equal('non empty 5'); - }); - }); - - describe('data detection method', function () { - it('correctly detects only email mapping', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = service._detectDataTypes([{ - correo_electronico: 'email@example.com' - }, { - correo_electronico: 'email2@example.com' - }]); - - expect(result.email).to.equal('correo_electronico'); - expect(result.stripe_customer_id).to.equal(undefined); - }); - - it('correctly detects email mapping', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = service._detectDataTypes([{ - correo_electronico: 'email@example.com', - stripe_id: '' - }, { - correo_electronico: '', - stripe_id: 'cus_' - }]); - - expect(result.email).to.equal('correo_electronico'); - }); - - it('correctly detects variation of "name" mapping', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = service._detectDataTypes([{ - first_name: 'Rish' - }]); - - expect(result.name).to.equal('first_name'); - }); - }); -}); diff --git a/ghost/admin/tests/unit/components/members/filters/offers-test.js b/ghost/admin/tests/unit/components/members/filters/offers-test.js deleted file mode 100644 index 5f099ae491d..00000000000 --- a/ghost/admin/tests/unit/components/members/filters/offers-test.js +++ /dev/null @@ -1,33 +0,0 @@ -import {OFFERS_FILTER} from 'ghost-admin/components/members/filters/offers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; - -describe('Unit: Component: members/filters/offers', function () { - describe('OFFERS_FILTER.getColumnValue', function () { - it('renders retention offers using cadence labels', function () { - const member = { - subscriptions: [{ - offer_redemptions: [ - {name: 'One month on us', redemption_type: 'retention', cadence: 'month'}, - {name: 'Welcome discount', redemption_type: 'signup', cadence: 'month'}, - {name: 'Two months on us', redemption_type: 'retention', cadence: 'year'} - ] - }] - }; - - const value = OFFERS_FILTER.getColumnValue(member); - - expect(value.text).to.equal('Monthly Retention, Welcome discount, Yearly Retention'); - }); - - it('returns empty text when offer_redemptions is missing', function () { - const member = { - subscriptions: [{}] - }; - - const value = OFFERS_FILTER.getColumnValue(member); - - expect(value.text).to.equal(''); - }); - }); -}); diff --git a/ghost/admin/tests/unit/controllers/member-test.js b/ghost/admin/tests/unit/controllers/member-test.js new file mode 100644 index 00000000000..e3da7b35329 --- /dev/null +++ b/ghost/admin/tests/unit/controllers/member-test.js @@ -0,0 +1,40 @@ +import sinon from 'sinon'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +describe('Unit: Controller: member', function () { + setupTest(); + + let controller; + let triggerEmberDataChange; + + beforeEach(function () { + triggerEmberDataChange = sinon.spy(); + controller = this.owner.lookup('controller:member'); + Object.defineProperty(controller, 'stateBridge', { + configurable: true, + value: {triggerEmberDataChange} + }); + controller.member = {id: 'member-1'}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('invalidates the React members cache through the Ember bridge when member data changes', function () { + controller.invalidateMembersCache(); + + expect(triggerEmberDataChange.calledOnce).to.be.true; + expect(triggerEmberDataChange.calledWith('update', 'member', 'member-1', null)).to.be.true; + }); + + it('notifies the Ember bridge when member commenting changes', function () { + controller.invalidateMemberCommenting(); + + expect(triggerEmberDataChange.calledTwice).to.be.true; + expect(triggerEmberDataChange.firstCall.calledWith('update', 'member', 'member-1', null)).to.be.true; + expect(triggerEmberDataChange.secondCall.calledWith('update', 'comment', 'member-1', null)).to.be.true; + }); +}); diff --git a/ghost/admin/tests/unit/services/state-bridge-test.js b/ghost/admin/tests/unit/services/state-bridge-test.js index ac59f6db949..9420cac968f 100644 --- a/ghost/admin/tests/unit/services/state-bridge-test.js +++ b/ghost/admin/tests/unit/services/state-bridge-test.js @@ -531,7 +531,7 @@ describe('Unit: Service: state-bridge', function () { }); describe('#getRouteUrl', function () { - let postsController, membersController, originalLookup; + let postsController, settingsHistoryController, originalLookup; beforeEach(function () { // Mock controllers @@ -540,9 +540,9 @@ describe('Unit: Service: state-bridge', function () { type: null }); - membersController = EmberObject.create({ - queryParams: [{filterParam: 'filter'}], - filterParam: null + settingsHistoryController = EmberObject.create({ + queryParams: [{excludedEvents: 'excludedEvents'}], + excludedEvents: null }); // Stub the owner's lookup method to return our mock controllers @@ -551,8 +551,8 @@ describe('Unit: Service: state-bridge', function () { if (name === 'controller:posts') { return postsController; } - if (name === 'controller:members') { - return membersController; + if (name === 'controller:settings.history') { + return settingsHistoryController; } // Fall back to original lookup for services, etc. return originalLookup(name); @@ -586,10 +586,10 @@ describe('Unit: Service: state-bridge', function () { }); it('returns base route when on a subpath of the route', function () { - sinon.stub(service.router, 'currentRouteName').get(() => 'members.index'); + sinon.stub(service.router, 'currentRouteName').get(() => 'settings.history'); - const url = service.getRouteUrl('members'); - expect(url).to.equal('members'); + const url = service.getRouteUrl('settings'); + expect(url).to.equal('settings'); }); it('generates URL with provided query params', function () { @@ -639,11 +639,11 @@ describe('Unit: Service: state-bridge', function () { it('handles mapped query params correctly', function () { sinon.stub(service.router, 'currentRouteName').get(() => 'dashboard'); - membersController.set('filterParam', 'status:free'); + settingsHistoryController.set('excludedEvents', 'user.updated'); - // The controller has {filterParam: 'filter'}, so the URL should use 'filter' not 'filterParam' - const url = service.getRouteUrl('members'); - expect(url).to.equal('members?filter=status%3Afree'); + // The controller has {excludedEvents: 'excludedEvents'}, so the URL should use the mapped param + const url = service.getRouteUrl('settings.history'); + expect(url).to.equal('settings.history?excludedEvents=user.updated'); }); it('returns base route when controller does not exist', function () { @@ -697,10 +697,10 @@ describe('Unit: Service: state-bridge', function () { }); it('returns true when current route is a subpath of provided route', function () { - sinon.stub(service.router, 'currentRouteName').get(() => 'members.index'); + sinon.stub(service.router, 'currentRouteName').get(() => 'settings.history'); sinon.stub(service.customViews, 'activeView').get(() => null); - const isActive = service.isRouteActive('members'); + const isActive = service.isRouteActive('settings'); expect(isActive).to.be.true; }); diff --git a/ghost/core/core/app.js b/ghost/core/core/app.js index ff5d17609c9..cb61899b538 100644 --- a/ghost/core/core/app.js +++ b/ghost/core/core/app.js @@ -12,7 +12,7 @@ const path = require('path'); * @returns {boolean} */ const isMaintenanceModeEnabled = (req) => { - if (req.app.get('maintenance') || config.get('maintenance').enabled || !urlService.hasFinished()) { + if (req.app.get('maintenance') || config.get('maintenance').enabled || !urlService.facade.hasFinished()) { return true; } diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index f19bc11e5a4..f942b884407 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -248,7 +248,10 @@ async function initExpressApps({frontend, backend, config}) { if (frontend) { // SITE + MEMBERS - const urlService = require('./server/services/url'); + // RouterManager and migrated frontend callers expect the facade + // (getUrlForResource / ownsResource), not the raw eager UrlService + // (which only exposes the legacy id-based methods). + const urlService = require('./server/services/url').facade; const frontendApp = require('./server/web/parent/frontend')({urlService}); parentApp.use(vhost(config.getFrontendMountPath(), frontendApp)); } diff --git a/ghost/core/core/bridge.js b/ghost/core/core/bridge.js index e000a8bef5a..389741221d7 100644 --- a/ghost/core/core/bridge.js +++ b/ghost/core/core/bridge.js @@ -114,7 +114,7 @@ class Bridge { const routerConfig = { routeSettings: await routeSettings.loadRouteSettings(), - urlService + urlService: urlService.facade }; await siteApp.reload(routerConfig); diff --git a/ghost/core/core/frontend/helpers/authors.js b/ghost/core/core/frontend/helpers/authors.js index a402e245f10..c52326d4a11 100644 --- a/ghost/core/core/frontend/helpers/authors.js +++ b/ghost/core/core/frontend/helpers/authors.js @@ -34,7 +34,7 @@ module.exports = function authors(options = {}) { function createAuthorsList(authorsList) { function processAuthor(author) { return autolink ? templates.link({ - url: urlService.getUrlByResourceId(author.id, {withSubdirectory: true}), + url: urlService.facade.getUrlForResource({...author, type: 'authors'}, {withSubdirectory: true}), text: escapeExpression(author.name) }) : escapeExpression(author.name); } diff --git a/ghost/core/core/frontend/helpers/tags.js b/ghost/core/core/frontend/helpers/tags.js index 70614632add..542732b9cbc 100644 --- a/ghost/core/core/frontend/helpers/tags.js +++ b/ghost/core/core/frontend/helpers/tags.js @@ -27,7 +27,7 @@ module.exports = function tags(options) { function createTagList(tagsList) { function processTag(tag) { return autolink ? templates.link({ - url: urlService.getUrlByResourceId(tag.id, {withSubdirectory: true}), + url: urlService.facade.getUrlForResource({...tag, type: 'tags'}, {withSubdirectory: true}), text: escapeExpression(tag.name) }) : escapeExpression(tag.name); } diff --git a/ghost/core/core/frontend/meta/author-url.js b/ghost/core/core/frontend/meta/author-url.js index c840fe0fedf..bca82484e05 100644 --- a/ghost/core/core/frontend/meta/author-url.js +++ b/ghost/core/core/frontend/meta/author-url.js @@ -7,11 +7,11 @@ function getAuthorUrl(data, absolute) { const contextObject = getContextObject(data, context); if (data.author) { - return urlService.getUrlByResourceId(data.author.id, {absolute: absolute, withSubdirectory: true}); + return urlService.facade.getUrlForResource({...data.author, type: 'authors'}, {absolute: absolute, withSubdirectory: true}); } if (contextObject && contextObject.primary_author) { - return urlService.getUrlByResourceId(contextObject.primary_author.id, {absolute: absolute, withSubdirectory: true}); + return urlService.facade.getUrlForResource({...contextObject.primary_author, type: 'authors'}, {absolute: absolute, withSubdirectory: true}); } return null; diff --git a/ghost/core/core/frontend/meta/url.js b/ghost/core/core/frontend/meta/url.js index bd67f8961d0..f4579e8ca23 100644 --- a/ghost/core/core/frontend/meta/url.js +++ b/ghost/core/core/frontend/meta/url.js @@ -16,15 +16,24 @@ function getUrl(data, absolute) { * * A long term solution should be part of the final version of Dynamic Routing. */ - if (data.status !== 'published' && urlService.getUrlByResourceId(data.id) === '/404/') { + // checks.isPost matches both posts and pages (they share the Post + // model and only differ on the page-only `show_title_and_feature_image` + // field). Disambiguate so the router-level type is correct in both + // cases. + const postResource = {...data, type: checks.isPage(data) ? 'pages' : 'posts'}; + if (data.status !== 'published' && urlService.facade.getUrlForResource(postResource) === '/404/') { return urlUtils.urlFor({relativeUrl: urlUtils.urlJoin('/p', data.uuid, '/')}, null, absolute); } - return urlService.getUrlByResourceId(data.id, {absolute: absolute, withSubdirectory: true}); + return urlService.facade.getUrlForResource(postResource, {absolute: absolute, withSubdirectory: true}); } - if (checks.isTag(data) || checks.isUser(data)) { - return urlService.getUrlByResourceId(data.id, {absolute: absolute, withSubdirectory: true}); + if (checks.isTag(data)) { + return urlService.facade.getUrlForResource({...data, type: 'tags'}, {absolute: absolute, withSubdirectory: true}); + } + + if (checks.isUser(data)) { + return urlService.facade.getUrlForResource({...data, type: 'authors'}, {absolute: absolute, withSubdirectory: true}); } if (checks.isNav(data)) { diff --git a/ghost/core/core/frontend/services/routing/controllers/collection.js b/ghost/core/core/frontend/services/routing/controllers/collection.js index a68c4cf6312..0919cda80c9 100644 --- a/ghost/core/core/frontend/services/routing/controllers/collection.js +++ b/ghost/core/core/frontend/services/routing/controllers/collection.js @@ -73,7 +73,12 @@ module.exports = function collectionController(req, res, next) { * People should always invert their filters to ensure that the database query loads unique posts per collection. */ result.posts = _.filter(result.posts, (post) => { - if (routerManager.owns(res.routerOptions.identifier, post.id)) { + // Tag the resource with the router-level type — the post + // objects from the API have their DB `type` column stripped + // by the serializer, but the collection router knows what + // type it serves. + const resource = {...post, type: res.routerOptions.resourceType}; + if (routerManager.ownsResource(res.routerOptions.identifier, resource)) { return post; } diff --git a/ghost/core/core/frontend/services/routing/controllers/email-post.js b/ghost/core/core/frontend/services/routing/controllers/email-post.js index 7b06630c127..a77e721474e 100644 --- a/ghost/core/core/frontend/services/routing/controllers/email-post.js +++ b/ghost/core/core/frontend/services/routing/controllers/email-post.js @@ -48,7 +48,11 @@ module.exports = function emailPostController(req, res, next) { } if (post.status === 'published') { - return urlUtils.redirect301(res, routerManager.getUrlByResourceId(post.id, {withSubdirectory: true})); + // Email-only mode is post-resources only (per the comment + // above), so the router-level type is always 'posts'. The + // post object on the public API has its DB `type` column + // stripped, so we tag the resource explicitly. + return urlUtils.redirect301(res, routerManager.getUrlForResource({...post, type: 'posts'}, {withSubdirectory: true})); } return renderer.renderEntry(req, res)(post); diff --git a/ghost/core/core/frontend/services/routing/controllers/entry.js b/ghost/core/core/frontend/services/routing/controllers/entry.js index e1803266d01..80a9314b017 100644 --- a/ghost/core/core/frontend/services/routing/controllers/entry.js +++ b/ghost/core/core/frontend/services/routing/controllers/entry.js @@ -1,7 +1,6 @@ const debug = require('@tryghost/debug')('services:routing:controllers:entry'); const url = require('url'); const config = require('../../../../shared/config'); -const {routerManager} = require('../'); const urlUtils = require('../../../../shared/url-utils'); const dataService = require('../../data'); const renderer = require('../../rendering'); @@ -45,27 +44,6 @@ module.exports = function entryController(req, res, next) { return urlUtils.redirectToAdmin(302, res, `/#/editor/${resourceType}/${entry.id}`); } - /** - * CASE: check if type of router owns this resource - * - * Static pages have a hardcoded permalink, which is `/:slug/`. - * Imagine you define a collection under `/` with the permalink `/:slug/`. - * - * The router hierarchy is: - * - * 1. collections - * 2. static pages - * - * Both permalinks are registered in express. If you serve a static page, the - * collection router will try to serve this as a post resource. - * - * That's why we have to check against the router type. - */ - if (routerManager.getResourceById(entry.id).config.type !== res.routerOptions.resourceType) { - debug('not my resource type'); - return next(); - } - /** * CASE: Permalink is not valid anymore, we redirect him permanently to the correct one * This should only happen if you have date permalinks enabled and you change diff --git a/ghost/core/core/frontend/services/routing/controllers/previews.js b/ghost/core/core/frontend/services/routing/controllers/previews.js index 542811c9a46..29fa08347ce 100644 --- a/ghost/core/core/frontend/services/routing/controllers/previews.js +++ b/ghost/core/core/frontend/services/routing/controllers/previews.js @@ -51,7 +51,13 @@ module.exports = function previewController(req, res, next) { // published content should only resolve to /:slug - /p/:uuid is for drafts only in lieu of an actual preview api if (post.status === 'published') { - return urlUtils.redirect301(res, routerManager.getUrlByResourceId(post.id, {withSubdirectory: true})); + // The preview controller serves either posts or pages + // depending on the routerOptions; query.resource is the + // routing-level type ('posts' / 'pages'). The post object + // has its DB `type` column stripped by the serializer, so + // we tag the resource explicitly here. + const type = res.routerOptions.query.resource; + return urlUtils.redirect301(res, routerManager.getUrlForResource({...post, type}, {withSubdirectory: true})); } // once an email-only post has been sent it shouldn't be available via /p/ to avoid leaking members-only content diff --git a/ghost/core/core/frontend/services/routing/router-manager.js b/ghost/core/core/frontend/services/routing/router-manager.js index f8a70bae571..4fd8cd7884a 100644 --- a/ghost/core/core/frontend/services/routing/router-manager.js +++ b/ghost/core/core/frontend/services/routing/router-manager.js @@ -17,21 +17,17 @@ class RouterManager { this.registry = registry; this.siteRouter = null; /** - * @type {URLService} + * @type {URLServiceFacade} */ this.urlService = null; } - owns(routerId, id) { - return this.urlService.owns(routerId, id); + ownsResource(routerId, resource) { + return this.urlService.ownsResource(routerId, resource); } - getUrlByResourceId(id, options) { - return this.urlService.getUrlByResourceId(id, options); - } - - getResourceById(resourceId) { - return this.urlService.getResourceById(resourceId); + getUrlForResource(resource, options) { + return this.urlService.getUrlForResource(resource, options); } routerCreated(router) { @@ -190,7 +186,7 @@ module.exports = RouterManager; /** * @typedef {Object} RouterConfig * @property {RouteSettings} [routeSettings] - JSON config representing routes - * @property {URLService} urlService - service providing resource URL utility functions such as owns, getUrlByResourceId, and getResourceById + * @property {URLServiceFacade} urlService - resource-based URL service facade */ /** @@ -201,10 +197,10 @@ module.exports = RouterManager; */ /** - * @typedef {Object} URLService - * @property {Function} owns - * @property {Function} getUrlByResourceId - * @property {Function} getResourceById + * @typedef {Object} URLServiceFacade + * @property {Function} getUrlForResource + * @property {Function} ownsResource + * @property {Function} resolveUrl * @property {Function} onRouterAddedType * @property {Function} onRouterUpdated */ diff --git a/ghost/core/core/frontend/services/rss/generate-feed.js b/ghost/core/core/frontend/services/rss/generate-feed.js index 9330a09f437..818e52f4dd1 100644 --- a/ghost/core/core/frontend/services/rss/generate-feed.js +++ b/ghost/core/core/frontend/services/rss/generate-feed.js @@ -19,7 +19,10 @@ const generateTags = function generateTags(data) { const generateItem = function generateItem(post) { const cheerio = require('cheerio'); - const itemUrl = routerManager.getUrlByResourceId(post.id, {absolute: true}); + // RSS feeds carry posts (pages don't appear in RSS), so the router-level + // type is always 'posts'. The post object on the public API has its DB + // `type` column stripped by the serializer, so we tag the resource here. + const itemUrl = routerManager.getUrlForResource({...post, type: 'posts'}, {absolute: true}); const htmlContent = cheerio.load(post.html || ''); const item = { title: post.title, diff --git a/ghost/core/core/frontend/web/middleware/cors.js b/ghost/core/core/frontend/web/middleware/cors.js index 2bebe0bba94..33fb307cca7 100644 --- a/ghost/core/core/frontend/web/middleware/cors.js +++ b/ghost/core/core/frontend/web/middleware/cors.js @@ -1,4 +1,3 @@ -const {URL} = require('url'); const cors = require('cors'); const errors = require('@tryghost/errors'); const config = require('../../../shared/config'); diff --git a/ghost/core/core/frontend/web/middleware/frontend-caching.js b/ghost/core/core/frontend/web/middleware/frontend-caching.js index 83e9990c966..e2025aeb55d 100644 --- a/ghost/core/core/frontend/web/middleware/frontend-caching.js +++ b/ghost/core/core/frontend/web/middleware/frontend-caching.js @@ -4,6 +4,7 @@ const config = require('../../../shared/config'); const shared = require('../../../server/web/shared'); const {api} = require('../../services/proxy'); +const preview = require('../../services/theme-engine/preview'); /** * Calculate the member's active tier. @@ -43,6 +44,10 @@ const getMiddleware = async (getFreeTier = async () => { * @param {import('express').NextFunction} next */ function setFrontendCacheHeadersMiddleware(req, res, next) { + if (req.header?.(preview._PREVIEW_HEADER_NAME)) { + return shared.middleware.cacheControl('noCache')(req, res, next); + } + // Caching member's content is an experimental feature, enabled via config const shouldCacheMembersContent = config.get('cacheMembersContent:enabled'); // CASE: Never cache if the blog is set to private diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js index 6a3bf109ffe..444a84d010e 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js @@ -42,7 +42,15 @@ module.exports = async (model, frame, options = {}) => { jsonModel.email_segment = jsonModel.email_recipient_filter; delete jsonModel.email_recipient_filter; - url.forPost(model.id, jsonModel, frame); + // Read the type from the bookshelf model rather than jsonModel: + // `?fields=...` may have stripped `type` from jsonModel, but the + // underlying record always knows whether it's a post or a page. + // Some test fakes pass a plain `{toJSON}` object without `.get`; fall + // back to jsonModel.type in that case (no fields stripping in unit + // tests). + const dbType = typeof model.get === 'function' ? model.get('type') : jsonModel.type; + const routerType = dbType === 'page' ? 'pages' : 'posts'; + url.forPost(model.id, jsonModel, frame, routerType); extraAttrs.forPost(frame.options, model, jsonModel); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/url.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/url.js index 4a5b6ead34b..b99e6133408 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/url.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/url.js @@ -2,8 +2,17 @@ const urlService = require('../../../../../../services/url'); const urlUtils = require('../../../../../../../shared/url-utils'); const localUtils = require('../../../index'); -const forPost = (id, attrs, frame) => { - attrs.url = urlService.getUrlByResourceId(id, {absolute: true}); +const forPost = (id, attrs, frame, type = 'posts') => { + // `forPost` is shared between the posts and pages mappers (pages.js + // delegates to posts.js). The router-level resource type is passed in + // explicitly because `attrs.type` can't be relied on — `?fields=url` + // strips every attribute except the requested ones, so deriving the + // type from `attrs` would silently fall back to 'posts' for pages. + // The mapper that owns the resource always knows which it is. + // + // `id` is passed separately for the same reason: without it, the eager + // facade's id-based fallback hits /404/ for every record. + attrs.url = urlService.facade.getUrlForResource({...attrs, id, type}, {absolute: true}); /** * CASE: admin api should serve preview urls @@ -44,7 +53,7 @@ const forPost = (id, attrs, frame) => { const forUser = (id, attrs, options) => { if (!options.columns || (options.columns && options.columns.includes('url'))) { - attrs.url = urlService.getUrlByResourceId(id, {absolute: true}); + attrs.url = urlService.facade.getUrlForResource({...attrs, id, type: 'authors'}, {absolute: true}); } return attrs; @@ -52,7 +61,7 @@ const forUser = (id, attrs, options) => { const forTag = (id, attrs, options) => { if (!options.columns || (options.columns && options.columns.includes('url'))) { - attrs.url = urlService.getUrlByResourceId(id, {absolute: true}); + attrs.url = urlService.facade.getUrlForResource({...attrs, id, type: 'tags'}, {absolute: true}); } return attrs; diff --git a/ghost/core/core/server/data/migrations/versions/6.37/2026-05-04-07-51-14-add-members-created-at-id-index.js b/ghost/core/core/server/data/migrations/versions/6.37/2026-05-04-07-51-14-add-members-created-at-id-index.js new file mode 100644 index 00000000000..e26995e5b08 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.37/2026-05-04-07-51-14-add-members-created-at-id-index.js @@ -0,0 +1,63 @@ +const logging = require('@tryghost/logging'); +const DatabaseInfo = require('@tryghost/database-info'); +const {createNonTransactionalMigration} = require('../../utils'); + +const INDEX_NAME = 'members_created_at_id_index'; + +async function hasIndex(knex) { + if (DatabaseInfo.isSQLite(knex)) { + const result = await knex.raw(`select * from sqlite_master where type = 'index' and tbl_name = 'members' and name = '${INDEX_NAME}'`); + return result.length !== 0; + } + + const result = await knex.raw(`show index from members where Key_name = '${INDEX_NAME}'`); + return result[0].length !== 0; +} + +module.exports = createNonTransactionalMigration( + async function up(knex) { + if (await hasIndex(knex)) { + logging.info(`Skipping creation of index ${INDEX_NAME} on members for created_at, id - already exists`); + return; + } + + logging.info(`Creating index ${INDEX_NAME} on members for created_at, id`); + + if (DatabaseInfo.isMySQL(knex)) { + await knex.raw(` + ALTER TABLE members + ADD INDEX ${INDEX_NAME} (created_at, id), + ALGORITHM=INPLACE, + LOCK=NONE + `); + return; + } + + await knex.schema.table('members', (table) => { + table.index(['created_at', 'id']); + }); + }, + + async function down(knex) { + if (!(await hasIndex(knex))) { + logging.info(`Skipping drop of index ${INDEX_NAME} on members for created_at, id - does not exist`); + return; + } + + logging.info(`Dropping index ${INDEX_NAME} on members for created_at, id`); + + if (DatabaseInfo.isMySQL(knex)) { + await knex.raw(` + ALTER TABLE members + DROP INDEX ${INDEX_NAME}, + ALGORITHM=INPLACE, + LOCK=NONE + `); + return; + } + + await knex.schema.table('members', (table) => { + table.dropIndex(['created_at', 'id']); + }); + } +); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index d0c6dece6b5..b2891dbb19f 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -441,7 +441,8 @@ module.exports = { created_at: {type: 'dateTime', nullable: false}, updated_at: {type: 'dateTime', nullable: true}, '@@INDEXES@@': [ - ['email_disabled'] + ['email_disabled'], + ['created_at', 'id'] ] }, // NOTE: this is the tiers table diff --git a/ghost/core/core/server/lib/common/to-plain.js b/ghost/core/core/server/lib/common/to-plain.js new file mode 100644 index 00000000000..5c5e4d4ab19 --- /dev/null +++ b/ghost/core/core/server/lib/common/to-plain.js @@ -0,0 +1,21 @@ +/** + * Normalise a Bookshelf model or plain object to its JSON representation. + * + * Bookshelf models expose their data via `.toJSON()` and store it on a private + * `attributes` map; spreading them with `{...model}` skips the prototype-defined + * getters (including `id`) and so silently loses the data. This helper lets + * callers accept either shape uniformly: + * + * const data = toPlain(post); + * urlService.facade.getUrlForResource({...data, type: 'posts'}, ...) + * + * @template T + * @param {T | {toJSON(): T}} modelOrObj + * @returns {T} + */ +module.exports = function toPlain(modelOrObj) { + if (modelOrObj && typeof modelOrObj.toJSON === 'function') { + return modelOrObj.toJSON(); + } + return modelOrObj; +}; diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js index c496bd5b52a..9791c228bbb 100644 --- a/ghost/core/core/server/models/user.js +++ b/ghost/core/core/server/models/user.js @@ -61,6 +61,7 @@ User = ghostBookshelf.Model.extend({ defaults: function defaults() { return { + // secretlint-disable-next-line @secretlint/secretlint-rule-pattern password: security.identifier.uid(50), visibility: 'public', status: 'active', @@ -513,7 +514,7 @@ User = ghostBookshelf.Model.extend({ filter += '+donation_notifications:true'; } else if (type === 'recommendation-received') { filter += '+recommendation_notifications:true'; - } else if (type === 'gift-subscription-purchased') { + } else if (type === 'gift-subscriptions') { filter += '+gift_subscription_purchase_notification:true'; } const updatedOptions = Object.assign({}, options, {filter, withRelated: ['roles']}); diff --git a/ghost/core/core/server/services/adapter-manager/adapter-manager.js b/ghost/core/core/server/services/adapter-manager/adapter-manager.js index b79f772dfdd..ea94daf3c75 100644 --- a/ghost/core/core/server/services/adapter-manager/adapter-manager.js +++ b/ghost/core/core/server/services/adapter-manager/adapter-manager.js @@ -135,8 +135,11 @@ module.exports = class AdapterManager { throw new errors.IncorrectUsageError({err}); } - // Catch missing dependencies BUT NOT missing adapter - if (!err.message.includes(pathToAdapter)) { + // Catch missing dependencies BUT NOT missing adapter. + // Only check the first line — Node appends a "Require stack" + // that includes the adapter's own path, which would false-positive. + const firstLine = err.message.split('\n')[0]; + if (!firstLine.includes(pathToAdapter)) { throw new errors.IncorrectUsageError({ message: `You are missing dependencies in your adapter ${pathToAdapter}`, err diff --git a/ghost/core/core/server/services/audience-feedback/audience-feedback-service.js b/ghost/core/core/server/services/audience-feedback/audience-feedback-service.js index f72cc0aa785..cfeb50bc699 100644 --- a/ghost/core/core/server/services/audience-feedback/audience-feedback-service.js +++ b/ghost/core/core/server/services/audience-feedback/audience-feedback-service.js @@ -1,3 +1,5 @@ +const toPlain = require('../../lib/common/to-plain'); + class AudienceFeedbackService { /** @type URL */ #baseURL; @@ -15,18 +17,19 @@ class AudienceFeedbackService { } /** * @param {string} uuid - * @param {string} postId + * @param {{id: string}} post * @param {0 | 1} score * @param {string} key - hashed uuid value */ - buildLink(uuid, postId, score, key) { - let postUrl = this.#urlService.getUrlByResourceId(postId, {absolute: true}); + buildLink(uuid, post, score, key) { + const postData = toPlain(post); + let postUrl = this.#urlService.facade.getUrlForResource({...postData, type: 'posts'}, {absolute: true}); if (postUrl.match(/\/404\//)) { postUrl = this.#baseURL; } const url = new URL(postUrl); - url.hash = `#/feedback/${postId}/${score}/?uuid=${encodeURIComponent(uuid)}&key=${encodeURIComponent(key)}`; + url.hash = `#/feedback/${postData.id}/${score}/?uuid=${encodeURIComponent(uuid)}&key=${encodeURIComponent(key)}`; return url; } } diff --git a/ghost/core/core/server/services/automations/temporary-fake-database.js b/ghost/core/core/server/services/automations/temporary-fake-database.js new file mode 100644 index 00000000000..9ea93715ee9 --- /dev/null +++ b/ghost/core/core/server/services/automations/temporary-fake-database.js @@ -0,0 +1,122 @@ +/** + * This is a temporary fake database that we're using to test automations in + * development. + * + * We intend to delete this file by June 2026, if not sooner. See + * TODO(NY-1260). If we haven't deleted this for months, something has gone + * wrong with our plan! + * + * This approach will be easier to iterate on. We'll "commit" these as a real + * migration once we're sure this schema is correct. + */ + +/** + * @returns {import('node:sqlite').DatabaseSync} + */ +function createTemporaryFakeAutomationsDatabase() { + const {DatabaseSync} = require('node:sqlite'); + + const database = new DatabaseSync(':memory:'); + + database.exec(` +CREATE TABLE automations ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL +) STRICT; + +CREATE TABLE automation_actions ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + automation_id TEXT NOT NULL REFERENCES automations(id), + type TEXT NOT NULL +) STRICT; + +CREATE TABLE automation_action_revisions ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + action_id TEXT NOT NULL REFERENCES automation_actions(id), + wait_hours INTEGER, + email_subject TEXT, + email_lexical TEXT, + email_sender_name TEXT, + email_sender_email TEXT, + email_sender_reply_to TEXT, + email_design_setting_id TEXT, -- not a real foreign key here + UNIQUE (created_at, action_id) +) STRICT; + +CREATE TABLE automation_action_edges ( + source_action_id TEXT NOT NULL REFERENCES automation_actions(id), + target_action_id TEXT NOT NULL REFERENCES automation_actions(id), + PRIMARY KEY (source_action_id, target_action_id) +) STRICT; + +CREATE TABLE automation_runs ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + automation_id TEXT NOT NULL REFERENCES automations(id), + member_id TEXT, -- not a real foreign key here + member_email TEXT NOT NULL +) STRICT; + +CREATE TABLE automation_run_steps ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + automation_run_id TEXT NOT NULL REFERENCES automation_runs(id), + automation_action_revision_id TEXT NOT NULL REFERENCES automation_action_revisions(id), + ready_at INTEGER NOT NULL, + step_attempts INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + status TEXT NOT NULL, + locked_by TEXT, + locked_at INTEGER +) STRICT; + +INSERT INTO automations (id, created_at, updated_at, slug, name, status) VALUES +('auto_free_01', 1715016000, 1715016000, 'member-welcome-email-free', 'Welcome Email (Free)', 'active'), +('auto_paid_01', 1715016000, 1715016000, 'member-welcome-email-paid', 'Welcome Email (Paid)', 'active'); + +INSERT INTO automation_actions (id, created_at, updated_at, automation_id, type) VALUES +('f_act_1', 1715016000, 1715016000, 'auto_free_01', 'wait'), +('f_act_2', 1715016000, 1715016000, 'auto_free_01', 'send email'), +('f_act_3', 1715016000, 1715016000, 'auto_free_01', 'wait'), +('f_act_4', 1715016000, 1715016000, 'auto_free_01', 'send email'); + +INSERT INTO automation_actions (id, created_at, updated_at, automation_id, type) VALUES +('p_act_1', 1715016000, 1715016000, 'auto_paid_01', 'wait'), +('p_act_2', 1715016000, 1715016000, 'auto_paid_01', 'send email'), +('p_act_3', 1715016000, 1715016000, 'auto_paid_01', 'wait'), +('p_act_4', 1715016000, 1715016000, 'auto_paid_01', 'send email'); + +INSERT INTO automation_action_revisions (id, created_at, action_id, wait_hours, email_subject, email_lexical) VALUES +('f_rev_1', 1715016001, 'f_act_1', 48, NULL, NULL), +('f_rev_2', 1715016002, 'f_act_2', NULL, 'Welcome!', '{"root":{"children":[]}}'), +('f_rev_3', 1715016003, 'f_act_3', 72, NULL, NULL), +('f_rev_4', 1715016004, 'f_act_4', NULL, 'Follow up', '{"root":{"children":[]}}'), +('p_rev_1', 1715016005, 'p_act_1', 48, NULL, NULL), +('p_rev_2', 1715016006, 'p_act_2', NULL, 'Welcome to Paid!', '{"root":{"children":[]}}'), +('p_rev_3', 1715016007, 'p_act_3', 72, NULL, NULL), +('p_rev_4', 1715016008, 'p_act_4', NULL, 'Exclusive Insights', '{"root":{"children":[]}}'); + +INSERT INTO automation_action_edges (source_action_id, target_action_id) VALUES +('f_act_1', 'f_act_2'), +('f_act_2', 'f_act_3'), +('f_act_3', 'f_act_4'), +('p_act_1', 'p_act_2'), +('p_act_2', 'p_act_3'), +('p_act_3', 'p_act_4'); +`); + + return database; +} + +exports.temporaryFakeAutomationsDatabase = process.env.NODE_ENV === 'development' ? createTemporaryFakeAutomationsDatabase() : null; diff --git a/ghost/core/core/server/services/comments/comments-service-emails.js b/ghost/core/core/server/services/comments/comments-service-emails.js index 6ca09b676d2..0747db42d8c 100644 --- a/ghost/core/core/server/services/comments/comments-service-emails.js +++ b/ghost/core/core/server/services/comments/comments-service-emails.js @@ -2,6 +2,7 @@ const moment = require('moment'); const htmlToPlaintext = require('@tryghost/html-to-plaintext'); const emailService = require('../email-service'); const CommentsServiceEmailRenderer = require('./comments-service-email-renderer'); +const toPlain = require('../../lib/common/to-plain'); const {t} = require('../i18n'); class CommentsServiceEmails { @@ -20,17 +21,22 @@ class CommentsServiceEmails { /** * Build the post URL with comment fragment for email links - * @param {string} postId - The ID of the post + * @param {{id: string}} post - The post (model or plain object with `id`) * @param {string} commentId - The ID of the comment to link to * @returns {string} The post URL with appropriate comment fragment */ - getPostUrl(postId, commentId) { - const baseUrl = this.urlService.getUrlByResourceId(postId, {absolute: true}); + getPostUrl(post, commentId) { + const postData = toPlain(post); + const baseUrl = this.urlService.facade.getUrlForResource({...postData, type: 'posts'}, {absolute: true}); return `${baseUrl}#ghost-comments-${commentId}`; } async notifyPostAuthors(comment) { - const post = await this.models.Post.findOne({id: comment.get('post_id')}, {withRelated: ['authors']}); + // withRelated tags+authors so getPostUrl can resolve `:primary_tag` / + // `:primary_author` permalinks (only sites using those custom + // permalinks need it, but loading the relations is cheap and + // guarantees correctness regardless of the site's routes.yaml). + const post = await this.models.Post.findOne({id: comment.get('post_id')}, {withRelated: ['tags', 'authors']}); const member = await this.models.Member.findOne({id: comment.get('member_id')}); for (const author of post.related('authors')) { @@ -48,7 +54,7 @@ class CommentsServiceEmails { siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, postTitle: post.get('title'), - postUrl: this.getPostUrl(post.get('id'), comment.get('id')), + postUrl: this.getPostUrl(post, comment.get('id')), commentHtml: comment.get('html'), commentDate: moment(comment.get('created_at')).tz(this.settingsCache.get('timezone')).format('D MMM YYYY'), memberName: memberName, @@ -100,7 +106,7 @@ class CommentsServiceEmails { interpolation: {escapeValue: false} }); - const post = await this.models.Post.findOne({id: reply.get('post_id')}); + const post = await this.models.Post.findOne({id: reply.get('post_id')}, {withRelated: ['tags', 'authors']}); const member = await this.models.Member.findOne({id: memberId}); const memberName = member.get('name') || 'Anonymous'; @@ -110,7 +116,7 @@ class CommentsServiceEmails { siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, postTitle: post.get('title'), - postUrl: this.getPostUrl(post.get('id'), reply.get('id')), + postUrl: this.getPostUrl(post, reply.get('id')), replyHtml: reply.get('html'), replyDate: moment(reply.get('created_at')).tz(this.settingsCache.get('timezone')).format('D MMM YYYY'), memberName: memberName, @@ -141,7 +147,7 @@ class CommentsServiceEmails { * @param {*} reporter The member object who reported this comment */ async notifyReport(comment, reporter) { - const post = await this.models.Post.findOne({id: comment.get('post_id')}, {withRelated: ['authors']}); + const post = await this.models.Post.findOne({id: comment.get('post_id')}, {withRelated: ['tags', 'authors']}); const member = await this.models.Member.findOne({id: comment.get('member_id')}); const owner = await this.models.User.getOwnerUser(); @@ -158,7 +164,7 @@ class CommentsServiceEmails { siteUrl: this.urlUtils.getSiteUrl(), siteDomain: this.siteDomain, postTitle: post.get('title'), - postUrl: this.getPostUrl(post.get('id'), comment.get('id')), + postUrl: this.getPostUrl(post, comment.get('id')), commentHtml: comment.get('html'), commentText: htmlToPlaintext.comment(comment.get('html')), commentDate: moment(comment.get('created_at')).tz(this.settingsCache.get('timezone')).format('D MMM YYYY'), diff --git a/ghost/core/core/server/services/email-service/email-renderer.js b/ghost/core/core/server/services/email-service/email-renderer.js index 8f586593fc0..995e485f797 100644 --- a/ghost/core/core/server/services/email-service/email-renderer.js +++ b/ghost/core/core/server/services/email-service/email-renderer.js @@ -1058,13 +1058,13 @@ class EmailRenderer { // Audience feedback const positiveLink = this.#audienceFeedbackService.buildLink( '--uuid--', - post.id, + post, 1, '--key--' ).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%'); const negativeLink = this.#audienceFeedbackService.buildLink( '--uuid--', - post.id, + post, 0, '--key--' ).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%'); diff --git a/ghost/core/core/server/services/indexnow.js b/ghost/core/core/server/services/indexnow.js index cca2521ef56..d665eb94036 100644 --- a/ghost/core/core/server/services/indexnow.js +++ b/ghost/core/core/server/services/indexnow.js @@ -84,7 +84,7 @@ async function ping(post) { } try { - const url = urlService.getUrlByResourceId(post.id, {absolute: true}); + const url = urlService.facade.getUrlForResource({...post, type: 'posts'}, {absolute: true}); // Get the API key (auto-generated on boot by settings service) const key = getApiKey(); diff --git a/ghost/core/core/server/services/member-attribution/url-translator.js b/ghost/core/core/server/services/member-attribution/url-translator.js index 303e894ff06..30d6c732cf8 100644 --- a/ghost/core/core/server/services/member-attribution/url-translator.js +++ b/ghost/core/core/server/services/member-attribution/url-translator.js @@ -1,10 +1,20 @@ /** * @typedef {Object} UrlService - * @prop {(resourceId: string, options) => Object} getResource - * @prop {(resourceId: string, options) => string} getUrlByResourceId - * + * @prop {{ + * getUrlForResource: (resource: Object, options: Object) => string, + * resolveUrl: (path: string) => Promise + * }} facade */ +const toPlain = require('../../lib/common/to-plain'); + +const TYPE_TO_RESOURCE = { + post: 'posts', + page: 'pages', + tag: 'tags', + author: 'authors' +}; + /** * Translate a url into, (id+type), or a resource, and vice versa */ @@ -80,7 +90,7 @@ class UrlTranslator { return { type: 'url', id: null, - ...this.getTypeAndIdFromPath(path), + ...(await this.getTypeAndIdFromPath(path)), url: path }; } @@ -89,45 +99,50 @@ class UrlTranslator { * Get the resource type and ID from a path that was visited on the site * @param {string} path (excluding subdirectory) */ - getTypeAndIdFromPath(path) { - const resource = this.urlService.getResource(path); + async getTypeAndIdFromPath(path) { + // resolveUrl may reject during route rebuilds (URL service not yet + // ready). Member attribution is best-effort: a failed lookup should + // fall back to the URL-typed result (handled by the caller), not + // surface as an error. + let resource; + try { + resource = await this.urlService.facade.resolveUrl(path); + } catch (err) { + return; + } if (!resource) { return; } - if (resource.config.type === 'posts') { + if (resource.type === 'posts') { return { type: 'post', - id: resource.data.id + id: resource.id }; } - if (resource.config.type === 'pages') { + if (resource.type === 'pages') { return { type: 'page', - id: resource.data.id + id: resource.id }; } - if (resource.config.type === 'tags') { + if (resource.type === 'tags') { return { type: 'tag', - id: resource.data.id + id: resource.id }; } - if (resource.config.type === 'authors') { + if (resource.type === 'authors') { return { type: 'author', - id: resource.data.id + id: resource.id }; } } - getUrlByResourceId(id, options = {absolute: true}) { - return this.urlService.getUrlByResourceId(id, options); - } - /** * Get the URL for a resource, handling email-only posts which have no * public URL (the URL service returns /404/ for them). @@ -138,14 +153,26 @@ class UrlTranslator { const emailPath = `/email/${model.get('uuid')}/`; return absolute ? this.relativeToAbsolute(emailPath) : emailPath; } - return this.getUrlByResourceId(id, {absolute}); + // Lazy URL service evaluates permalink templates against resource fields + // (slug, published_at, primary_tag, ...). Caller already loaded the model, + // so spread its plain data so those fields reach the facade. + const data = toPlain(model); + const resource = {...data, id, type: TYPE_TO_RESOURCE[type]}; + return this.urlService.facade.getUrlForResource(resource, {absolute}); } async getResourceById(id, type) { switch (type) { case 'post': case 'page': { - const post = await this.models.Post.findOne({id}, {require: false, filter: 'type:[post,page]+status:[published,sent]'}); + // withRelated: tags+authors so the lazy URL service can evaluate + // `:primary_tag` / `:primary_author` permalink templates against + // the resource. Mirrors services/url/config.js's posts config. + const post = await this.models.Post.findOne({id}, { + require: false, + filter: 'type:[post,page]+status:[published,sent]', + withRelated: ['tags', 'authors'] + }); if (!post) { return null; } diff --git a/ghost/core/core/server/services/members/members-config-provider.js b/ghost/core/core/server/services/members/members-config-provider.js index b895760a3d7..c587ae6079d 100644 --- a/ghost/core/core/server/services/members/members-config-provider.js +++ b/ghost/core/core/server/services/members/members-config-provider.js @@ -1,5 +1,4 @@ const logging = require('@tryghost/logging'); -const {URL} = require('url'); const createKeypair = require('keypair'); class MembersConfigProvider { diff --git a/ghost/core/core/server/services/members/stripe-connect.js b/ghost/core/core/server/services/members/stripe-connect.js index 2fd9f0769de..7f0305fd4c3 100644 --- a/ghost/core/core/server/services/members/stripe-connect.js +++ b/ghost/core/core/server/services/members/stripe-connect.js @@ -2,7 +2,6 @@ const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const {Buffer} = require('buffer'); const {randomBytes} = require('crypto'); -const {URL} = require('url'); const config = require('../../../shared/config'); const urlUtils = require('../../../shared/url-utils'); diff --git a/ghost/core/core/server/services/mentions/resource-service.js b/ghost/core/core/server/services/mentions/resource-service.js index 6dc6bbb9fa0..765a92a9360 100644 --- a/ghost/core/core/server/services/mentions/resource-service.js +++ b/ghost/core/core/server/services/mentions/resource-service.js @@ -30,17 +30,17 @@ module.exports = class ResourceService { */ async getByURL(url) { const path = this.#urlUtils.absoluteToRelative(url.href, {withoutSubdirectory: true}); - const resource = this.#urlService.getResource(path); - if (resource?.config?.type === 'posts') { + const resource = await this.#urlService.facade.resolveUrl(path); + if (resource?.type === 'posts') { return { type: 'post', - id: ObjectID.createFromHexString(resource.data.id) + id: ObjectID.createFromHexString(resource.id) }; } - if (resource?.config?.type === 'pages') { + if (resource?.type === 'pages') { return { type: 'page', - id: ObjectID.createFromHexString(resource.data.id) + id: ObjectID.createFromHexString(resource.id) }; } return { diff --git a/ghost/core/core/server/services/slack.js b/ghost/core/core/server/services/slack.js index 738c1794613..694d188da4e 100644 --- a/ghost/core/core/server/services/slack.js +++ b/ghost/core/core/server/services/slack.js @@ -56,7 +56,7 @@ function ping(post) { // If this is a post, we want to send the link of the post if (hasPostProperties(post)) { - message = urlService.getUrlByResourceId(post.id, {absolute: true}); + message = urlService.facade.getUrlForResource({...post, type: 'posts'}, {absolute: true}); title = post.title ? post.title : null; author = post.authors ? post.authors[0] : null; @@ -136,7 +136,7 @@ function ping(post) { fields: [ { title: 'Author', - value: author ? `<${urlService.getUrlByResourceId(author.id, {absolute: true})} | ${author.name}>` : null, + value: author ? `<${urlService.facade.getUrlForResource({...author, type: 'authors'}, {absolute: true})} | ${author.name}>` : null, short: true } ], diff --git a/ghost/core/core/server/services/staff/email-templates/gift.hbs b/ghost/core/core/server/services/staff/email-templates/gift.hbs index d9d82efadbc..e5566c00e81 100644 --- a/ghost/core/core/server/services/staff/email-templates/gift.hbs +++ b/ghost/core/core/server/services/staff/email-templates/gift.hbs @@ -28,7 +28,7 @@ {{/if}} -

    Someone purchased a gift subscription!

    +

    Someone purchased a gift subscription

    diff --git a/ghost/core/core/server/services/staff/email-templates/gift.txt.js b/ghost/core/core/server/services/staff/email-templates/gift.txt.js index 92e7c5d7e53..8344b7e0bfa 100644 --- a/ghost/core/core/server/services/staff/email-templates/gift.txt.js +++ b/ghost/core/core/server/services/staff/email-templates/gift.txt.js @@ -1,7 +1,7 @@ module.exports = function giftText(data) { // Be careful when you indent the email, because whitespaces are visible in emails! return ` -Someone purchased a gift subscription! +Someone purchased a gift subscription From: ${data.gift.name} Tier: ${data.gift.tierName} • ${data.gift.cadenceLabel} diff --git a/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.hbs b/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.hbs index 7e3e34f221a..203bef87b95 100644 --- a/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.hbs +++ b/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.hbs @@ -3,7 +3,7 @@ - 🎁 Paid subscription started: {{memberData.name}} + 🎁 Gift subscription redeemed: {{memberData.name}} {{> styles}} @@ -16,7 +16,7 @@ {{#> preview}} {{#*inline "content"}} - New paid subscriber: {{memberData.name}} + Gift subscription redeemed: {{memberData.name}} {{/inline}} {{/preview}} @@ -33,7 +33,7 @@ {{/if}}
    -

    You have a new paid subscriber

    +

    Someone redeemed a gift subscription

    diff --git a/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.txt.js b/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.txt.js index 7ba62f9c46c..b7531c75c29 100644 --- a/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.txt.js +++ b/ghost/core/core/server/services/staff/email-templates/new-gift-subscription.txt.js @@ -1,10 +1,9 @@ module.exports = function (data) { // Be careful when you indent the email, because whitespaces are visible in emails! return ` -Congratulations! - -You have a new paid member: ${data.memberData.name} +Someone redeemed a gift subscription +Member: ${data.memberData.name} Tier: ${data.tierData.name}${data.tierData.details ? ` • ${data.tierData.details}` : ''} Gifted by: ${data.giftedByEmail} diff --git a/ghost/core/core/server/services/staff/staff-service-emails.js b/ghost/core/core/server/services/staff/staff-service-emails.js index bfb2c4ef4f6..7be8b9858ae 100644 --- a/ghost/core/core/server/services/staff/staff-service-emails.js +++ b/ghost/core/core/server/services/staff/staff-service-emails.js @@ -308,7 +308,7 @@ class StaffServiceEmails { * @returns {Promise} */ async notifyGiftReceived({name, email, memberId, amount, currency, tierName, cadence, duration}) { - const users = await this.models.User.getEmailAlertUsers('gift-subscription-purchased'); + const users = await this.models.User.getEmailAlertUsers('gift-subscriptions'); const formattedAmount = this.getFormattedAmount({currency, amount: amount / 100}); const displayName = name ?? email; @@ -338,13 +338,13 @@ class StaffServiceEmails { } async notifyGiftSubscriptionStarted({memberId, memberName, memberEmail, tierName, cadence, duration, buyerEmail}, options = {}) { - const users = await this.models.User.getEmailAlertUsers('paid-started', options); + const users = await this.models.User.getEmailAlertUsers('gift-subscriptions', options); const memberData = this.getMemberData({ id: memberId, name: memberName ?? null, email: memberEmail }); - const subject = `🎁 Paid subscription started: ${memberData.name}`; + const subject = `🎁 Gift subscription redeemed: ${memberData.name}`; const cadenceLabel = duration === 1 ? `1 ${cadence}` : `${duration} ${cadence}s`; await this.sendToStaff({ diff --git a/ghost/core/core/server/services/stats/content-stats-service.js b/ghost/core/core/server/services/stats/content-stats-service.js index 140685b17e9..795ed29c5e9 100644 --- a/ghost/core/core/server/services/stats/content-stats-service.js +++ b/ghost/core/core/server/services/stats/content-stats-service.js @@ -1,5 +1,14 @@ const logging = require('@tryghost/logging'); +// Pre-migration the resourceType field on this service's response was the +// raw `data.type` column on the underlying record: 'post' / 'page' for +// posts and pages, undefined for tags / authors (those tables have no type +// column). Preserve that contract by only mapping the two singular types. +const ROUTER_TYPE_TO_SINGULAR = { + posts: 'post', + pages: 'page' +}; + /** * @typedef {Object} TopContentDataItem * @property {string} pathname - Page path @@ -176,27 +185,31 @@ class ContentStatsService { /** * Get resource title using UrlService * @param {string} pathname - Path to look up - * @returns {Object|null} Resource title and type, or null if not found + * @returns {Promise} Resource title and type, or null if not found */ - getResourceTitle(pathname) { + async getResourceTitle(pathname) { if (!this.urlService) { return null; } try { - const resource = this.urlService.getResource(pathname); - - if (resource && resource.data) { - if (resource.data.title) { + const resource = await this.urlService.facade.resolveUrl(pathname); + + if (resource) { + // resource.type is the routing-level plural form (posts/pages/tags/authors). + // Keep singular/undefined here for backwards-compatibility with the previous + // data.type semantics (post/page/undefined). + const resourceType = ROUTER_TYPE_TO_SINGULAR[resource.type]; + if (resource.title) { return { - title: resource.data.title, - resourceType: resource.data.type + title: resource.title, + resourceType }; - } else if (resource.data.name) { + } else if (resource.name) { // For authors, tags, etc. return { - title: resource.data.name, - resourceType: resource.data.type + title: resource.name, + resourceType }; } } @@ -232,8 +245,8 @@ class ContentStatsService { if (this.urlService && item.pathname) { try { // Check if URL service is ready - if (this.urlService.hasFinished && this.urlService.hasFinished()) { - const resource = this.urlService.getResource(item.pathname); + if (this.urlService.facade.hasFinished && this.urlService.facade.hasFinished()) { + const resource = await this.urlService.facade.resolveUrl(item.pathname); urlExists = !!resource; // Convert to boolean } // If URL service isn't ready, we default to true (clickable) @@ -255,7 +268,7 @@ class ContentStatsService { } // Use UrlService for pages without post_uuid - const resourceInfo = this.getResourceTitle(item.pathname); + const resourceInfo = await this.getResourceTitle(item.pathname); if (resourceInfo) { return { ...item, diff --git a/ghost/core/core/server/services/stats/posts-stats-service.js b/ghost/core/core/server/services/stats/posts-stats-service.js index a7f48a3c741..d654ce6f5c4 100644 --- a/ghost/core/core/server/services/stats/posts-stats-service.js +++ b/ghost/core/core/server/services/stats/posts-stats-service.js @@ -213,7 +213,7 @@ class PostsStatsService { } // Transform results and enrich with titles and URL existence validation - return results.map((row) => { + return Promise.all(results.map(async (row) => { const title = row.title || this._generateTitleFromPath(row.attribution_url); // Check if URL exists using the URL service @@ -222,8 +222,8 @@ class PostsStatsService { if (this.urlService && row.attribution_url) { try { // Check if URL service is ready - if (this.urlService.hasFinished && this.urlService.hasFinished()) { - const resource = this.urlService.getResource(row.attribution_url); + if (this.urlService.facade.hasFinished && this.urlService.facade.hasFinished()) { + const resource = await this.urlService.facade.resolveUrl(row.attribution_url); urlExists = !!resource; // Convert to boolean } // If URL service isn't ready, we default to true (clickable) @@ -246,7 +246,7 @@ class PostsStatsService { post_type: row.attribution_type === 'post' ? 'post' : (row.attribution_type === 'page' ? 'page' : null), url_exists: urlExists }; - }); + })); } _generateTitleFromPath(path) { diff --git a/ghost/core/core/server/services/stats/subscription-stats-service.js b/ghost/core/core/server/services/stats/subscription-stats-service.js index 36726dc6d54..fe3bccd35a2 100644 --- a/ghost/core/core/server/services/stats/subscription-stats-service.js +++ b/ghost/core/core/server/services/stats/subscription-stats-service.js @@ -12,13 +12,7 @@ class SubscriptionStatsService { * @returns {Promise<{data: SubscriptionHistoryEntry[]}>} **/ async getSubscriptionHistory() { - const paidDeltas = await this.fetchAllSubscriptionDeltas(); - const giftDeltas = await this.fetchAllGiftDeltas(); - // Sum paid+gift entries that share (date, tier, cadence) so each - // row produces a single coherent `count` snapshot. Sort ascending - // because the rollback loop below walks the array backwards. - const subscriptionDeltaEntries = aggregateDeltas(paidDeltas.concat(giftDeltas)) - .sort((a, b) => moment(a.date).valueOf() - moment(b.date).valueOf()); + const subscriptionDeltaEntries = await this.fetchAllSubscriptionDeltas(); const counts = await this.fetchSubscriptionCounts(); /** @type {Object.>} */ @@ -132,131 +126,26 @@ class SubscriptionStatsService { } /** - * Get the current total subscriptions grouped by Cadence and Tier. - * Includes both paid subscriptions and currently-redeemed gifts so the - * totals stay consistent with the gift-aware delta stream. + * Get the current total subscriptions grouped by Cadence and Tier * @returns {Promise} **/ async fetchSubscriptionCounts() { const knex = this.knex; - const [paidCounts, giftCounts] = await Promise.all([ - knex('members_stripe_customers_subscriptions') - .select(knex.raw(` - COUNT(members_stripe_customers_subscriptions.id) AS count, - products.id AS tier, - stripe_prices.interval AS cadence - `)) - .join('stripe_prices', 'stripe_prices.stripe_price_id', '=', 'members_stripe_customers_subscriptions.stripe_price_id') - .join('stripe_products', 'stripe_products.stripe_product_id', '=', 'stripe_prices.stripe_product_id') - .join('products', 'products.id', '=', 'stripe_products.product_id') - .whereNot('members_stripe_customers_subscriptions.mrr', 0) - .groupBy('tier', 'cadence'), - knex('gifts') - .where('status', 'redeemed') - .select(knex.raw('COUNT(id) as count')) - .select('tier_id as tier') - .select('cadence') - .groupBy('tier_id', 'cadence') - ]); - - return aggregateCounts(paidCounts.concat(giftCounts)); - } - - /** - * Gift redemptions (signups) and gifts that have been consumed, expired, - * or refunded (cancellations), shaped as SubscriptionDelta rows so they - * appear alongside paid subscription deltas in the cadence breakdown. - * @returns {Promise} - */ - async fetchAllGiftDeltas() { - const knex = this.knex; - // A gift is no longer active once it has been consumed, expired, or refunded. - // Use whichever of those timestamps was set as the cancellation date. - const cancellationDate = 'COALESCE(consumed_at, expired_at, refunded_at)'; - const [signups, cancellations] = await Promise.all([ - knex('gifts') - .whereNotNull('redeemed_at') - .select(knex.raw('DATE(redeemed_at) as date')) - .select('tier_id as tier') - .select('cadence') - .select(knex.raw('COUNT(id) as count')) - .groupByRaw('DATE(redeemed_at), tier_id, cadence'), - knex('gifts') - .whereIn('status', ['consumed', 'expired', 'refunded']) - .whereNotNull('redeemed_at') - .whereRaw(`${cancellationDate} IS NOT NULL`) - // Exclude gifts that were consumed because the member upgraded - // to a paid subscription. In that case `consumed_at` is set - // before the gift's planned end (`consumes_at`). When the gift - // ends naturally via the cron job, `consumed_at` is always - // >= `consumes_at`, so this condition cleanly distinguishes - // upgrades (not churn) from natural endings (real churn). - .whereRaw(`NOT (status = 'consumed' AND consumed_at < consumes_at)`) - .select(knex.raw(`DATE(${cancellationDate}) as date`)) - .select('tier_id as tier') - .select('cadence') - .select(knex.raw('COUNT(id) as count')) - .groupByRaw(`DATE(${cancellationDate}), tier_id, cadence`) - ]); - - return [ - ...signups.map(r => ({date: r.date, tier: r.tier, cadence: r.cadence, positive_delta: r.count, negative_delta: 0, signups: r.count, cancellations: 0})), - ...cancellations.map(r => ({date: r.date, tier: r.tier, cadence: r.cadence, positive_delta: 0, negative_delta: r.count, signups: 0, cancellations: r.count})) - ]; - } -} - -/** - * Sum SubscriptionCount entries that share the same (tier, cadence) key. - * @param {SubscriptionCount[]} counts - * @returns {SubscriptionCount[]} - */ -function aggregateCounts(counts) { - /** @type {Map} */ - const byKey = new Map(); - for (const entry of counts) { - const key = `${entry.tier}|${entry.cadence}`; - const existing = byKey.get(key); - if (existing) { - existing.count += entry.count; - } else { - byKey.set(key, {tier: entry.tier, cadence: entry.cadence, count: entry.count}); - } - } - return Array.from(byKey.values()); -} - -/** - * Sum SubscriptionDelta entries that share the same (date, tier, cadence) key. - * @param {SubscriptionDelta[]} deltas - * @returns {SubscriptionDelta[]} - */ -function aggregateDeltas(deltas) { - /** @type {Map} */ - const byKey = new Map(); - for (const entry of deltas) { - const date = moment(entry.date).format('YYYY-MM-DD'); - const key = `${date}|${entry.tier}|${entry.cadence}`; - const existing = byKey.get(key); - if (existing) { - existing.positive_delta += entry.positive_delta; - existing.negative_delta += entry.negative_delta; - existing.signups += entry.signups; - existing.cancellations += entry.cancellations; - } else { - byKey.set(key, { - date, - tier: entry.tier, - cadence: entry.cadence, - positive_delta: entry.positive_delta, - negative_delta: entry.negative_delta, - signups: entry.signups, - cancellations: entry.cancellations - }); - } + const data = await knex('members_stripe_customers_subscriptions') + .select(knex.raw(` + COUNT(members_stripe_customers_subscriptions.id) AS count, + products.id AS tier, + stripe_prices.interval AS cadence + `)) + .join('stripe_prices', 'stripe_prices.stripe_price_id', '=', 'members_stripe_customers_subscriptions.stripe_price_id') + .join('stripe_products', 'stripe_products.stripe_product_id', '=', 'stripe_prices.stripe_product_id') + .join('products', 'products.id', '=', 'stripe_products.product_id') + .whereNot('members_stripe_customers_subscriptions.mrr', 0) + .groupBy('tier', 'cadence'); + + return data; } - return Array.from(byKey.values()); } /** @typedef {object} SubscriptionCount diff --git a/ghost/core/core/server/services/url/index.js b/ghost/core/core/server/services/url/index.js index 3dd80b2abe8..1074e5b99b0 100644 --- a/ghost/core/core/server/services/url/index.js +++ b/ghost/core/core/server/services/url/index.js @@ -1,6 +1,7 @@ const config = require('../../../shared/config'); const LocalFileCache = require('./local-file-cache'); const UrlService = require('./url-service'); +const UrlServiceFacade = require('./url-service-facade'); // NOTE: instead of a path we could give UrlService a "data-resolver" of some sort // so it doesn't have to contain the logic to read data at all. This would be @@ -21,6 +22,12 @@ if (process.env.NODE_ENV.startsWith('test')){ const cache = new LocalFileCache({storagePath, writeDisabled}); const urlService = new UrlService({cache}); +const urlServiceFacade = new UrlServiceFacade({urlService}); + +// Singleton: default export remains the eager UrlService for backwards +// compatibility with existing imports. The new facade is exposed alongside +// it via `urlService.facade` so RouterManager and migrating callers can +// reach for it without forcing every consumer to update at once. +urlService.facade = urlServiceFacade; -// Singleton module.exports = urlService; diff --git a/ghost/core/core/server/services/url/url-service-facade.ts b/ghost/core/core/server/services/url/url-service-facade.ts new file mode 100644 index 00000000000..8a57ac3f474 --- /dev/null +++ b/ghost/core/core/server/services/url/url-service-facade.ts @@ -0,0 +1,162 @@ +/** + * UrlServiceFacade + * + * Sits in front of the URL service so callers can use a stable, resource-based + * interface regardless of the underlying implementation. The facade can be + * built with two backends: + * + * - urlService: the legacy eager UrlService that precomputes a full + * resource → URL map at boot. + * - lazyUrlService: an on-demand implementation (LazyUrlService) that + * computes URLs and ownership per call. + * + * When `lazyUrlService` is provided the facade routes calls to it; otherwise + * it delegates to the eager `urlService`. This lets the lazy implementation be + * swapped in behind a config flag without touching individual callers. + */ + +/** + * Routing-level resource. `type` is one of the plural router keys + * ('posts', 'pages', 'tags', 'authors'). Concrete records carry additional + * fields (slug, published_at, primary_tag, ...) used by permalink templates. + * + * `id` is required: the eager backend's URL lookup is id-based, so a missing + * `id` would silently return `/404/`. Every in-tree caller already passes an + * id; the type makes that mandatory. + */ +export interface Resource { + type: string; + id: string; + [key: string]: unknown; +} + +export interface UrlOptions { + absolute?: boolean; + withSubdirectory?: boolean; +} + +/** + * Eager UrlService's resource envelope: `{config: {type}, data: {...}}`. + * Returned by `getResource(path)`. The `data` field carries the raw record; + * `id` is always present because the eager URL service keys its in-memory + * map by id, and the type makes that contract explicit. + */ +export interface LegacyResourceEnvelope { + config: {type: string; [key: string]: unknown}; + data: {id: string; [key: string]: unknown}; +} + +/** + * Surface of the eager UrlService that the facade depends on. The full class + * has many more methods; only those used here need typing. + */ +export interface EagerUrlService { + getUrlByResourceId(id: string, options?: UrlOptions): string; + owns(routerId: string, id: string): boolean; + getResource(path: string): LegacyResourceEnvelope | null; + hasFinished(): boolean; + onRouterAddedType(...args: unknown[]): unknown; + onRouterUpdated(...args: unknown[]): unknown; +} + +export interface LazyUrlServiceBackend { + getUrlForResource(resource: Resource, options?: UrlOptions): string; + ownsResource(routerId: string, resource: Resource): boolean; + resolveUrl(path: string): Promise; + hasFinished(): boolean; + onRouterAddedType(...args: unknown[]): unknown; + onRouterUpdated(...args: unknown[]): unknown; + reset(): void; +} + +export class UrlServiceFacade { + private urlService: EagerUrlService; + private lazyUrlService: LazyUrlServiceBackend | null; + + constructor({ + urlService, + lazyUrlService = null + }: {urlService: EagerUrlService; lazyUrlService?: LazyUrlServiceBackend | null}) { + this.urlService = urlService; + this.lazyUrlService = lazyUrlService; + } + + isLazy(): boolean { + return !!this.lazyUrlService; + } + + /** + * The full resource record is required: the lazy backend evaluates NQL + * filters and applies permalink templates against it. + */ + getUrlForResource(resource: Resource, options?: UrlOptions): string { + if (this.lazyUrlService) { + return this.lazyUrlService.getUrlForResource(resource, options); + } + return this.urlService.getUrlByResourceId(resource.id, options); + } + + ownsResource(routerIdentifier: string, resource: Resource): boolean { + if (this.lazyUrlService) { + return this.lazyUrlService.ownsResource(routerIdentifier, resource); + } + return this.urlService.owns(routerIdentifier, resource.id); + } + + /** + * Reverse URL lookup. Returns a flat resource shape (e.g. `{type, id, slug}`) + * rather than the legacy `{config: {type}, data: {...}}` envelope. Async to + * match the lazy implementation's contract. + */ + async resolveUrl(urlPath: string): Promise { + if (this.lazyUrlService) { + return this.lazyUrlService.resolveUrl(urlPath); + } + const resource = this.urlService.getResource(urlPath); + if (!resource) { + return null; + } + // The routing-level type ('posts', 'pages', 'tags', 'authors') wins + // over any DB type field on resource.data so the flat Resource is + // unambiguous. + return Object.assign({}, resource.data, {type: resource.config.type}) as Resource; + } + + hasFinished(): boolean { + if (this.lazyUrlService) { + return this.lazyUrlService.hasFinished(); + } + return this.urlService.hasFinished(); + } + + onRouterAddedType(...args: unknown[]): unknown { + if (this.lazyUrlService) { + return this.lazyUrlService.onRouterAddedType(...args); + } + return this.urlService.onRouterAddedType(...args); + } + + onRouterUpdated(...args: unknown[]): unknown { + if (this.lazyUrlService) { + return this.lazyUrlService.onRouterUpdated(...args); + } + return this.urlService.onRouterUpdated(...args); + } + + /** + * Reset all router registrations. Used when routes.yaml is reloaded in + * lazy mode. In eager mode the URL service handles resets via its queue. + */ + reset(): void { + if (this.lazyUrlService) { + this.lazyUrlService.reset(); + } + } +} + +// `export class` already emits `exports.UrlServiceFacade`. We additionally +// re-attach `module.exports = UrlServiceFacade` AND keep the named export, so +// both `const UrlServiceFacade = require('./url-service-facade')` and +// `const { UrlServiceFacade } = require('./url-service-facade')` work. +module.exports = UrlServiceFacade; +module.exports.UrlServiceFacade = UrlServiceFacade; diff --git a/ghost/core/core/server/services/welcome-email-automations/poll.js b/ghost/core/core/server/services/welcome-email-automations/poll.js index 5c6ca25d290..a4ad9a79d8b 100644 --- a/ghost/core/core/server/services/welcome-email-automations/poll.js +++ b/ghost/core/core/server/services/welcome-email-automations/poll.js @@ -32,7 +32,7 @@ const {AutomatedEmailRecipient, Member, WelcomeEmailAutomationRun} = require('.. * }) => PromiseLike} api.send */ -const LOG_KEY = '[WELCOME-EMAIL-AUTOMATIONS]'; +const LOG_KEY = '[AUTOMATIONS]'; const MAX_RUNS_PER_BATCH = 100; const MAX_ATTEMPTS = 10; const RETRY_DELAY_MS = 10 * 60 * 1000; diff --git a/ghost/core/core/server/web/members/middleware/cors.js b/ghost/core/core/server/web/members/middleware/cors.js index 5e2c0d2c8f6..623eac9ebd7 100644 --- a/ghost/core/core/server/web/members/middleware/cors.js +++ b/ghost/core/core/server/web/members/middleware/cors.js @@ -1,4 +1,3 @@ -const {URL} = require('url'); const cors = require('cors'); const config = require('../../../../shared/config'); diff --git a/ghost/core/package.json b/ghost/core/package.json index 05078acef31..9c85f3b5d02 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.36.1-rc.0", + "version": "6.37.1-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -231,6 +231,8 @@ "simple-dom": "1.4.0", "stoppable": "1.1.0", "stripe": "8.222.0", + "superagent": "5.3.1", + "superagent-throttle": "1.0.1", "terser": "5.46.1", "tiny-glob": "0.2.9", "ua-parser-js": "1.0.41", diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js index cd6c9cae188..b979336fa1d 100644 --- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js +++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js @@ -636,7 +636,7 @@ describe('Gift Subscriptions', function () { // Verify staff notification email was sent mockManager.assert.sentEmail({ - subject: /paid subscription started/i, + subject: /gift subscription redeemed/i, to: 'jbloggs@example.com' }); }); @@ -832,10 +832,10 @@ describe('Gift Subscriptions', function () { 'Should enqueue the paid welcome email automation, not the free one' ); - // Verify gift subscription started staff notification was sent, + // Verify gift subscription redeemed staff notification was sent, // and that no other unwanted staff notifications were sent (i.e. no "Free member signup" email) mockManager.assert.sentEmail({ - subject: /paid subscription started/i, + subject: /gift subscription redeemed/i, to: 'jbloggs@example.com' }); mockManager.assert.sentEmailCount(1); @@ -970,9 +970,9 @@ describe('Gift Subscriptions', function () { 'Should enqueue the paid welcome email automation, not the free one' ); - // Verify gift subscription started staff notification was sent + // Verify gift subscription redeemed staff notification was sent mockManager.assert.sentEmail({ - subject: /paid subscription started/i, + subject: /gift subscription redeemed/i, to: 'jbloggs@example.com' }); diff --git a/ghost/core/test/unit/api/canary/utils/serializers/output/utils/url.test.js b/ghost/core/test/unit/api/canary/utils/serializers/output/utils/url.test.js index b75c386851a..27a3de18ca6 100644 --- a/ghost/core/test/unit/api/canary/utils/serializers/output/utils/url.test.js +++ b/ghost/core/test/unit/api/canary/utils/serializers/output/utils/url.test.js @@ -6,8 +6,10 @@ const urlUtils = require('../../../../../../../../core/shared/url-utils'); const urlUtil = require('../../../../../../../../core/server/api/endpoints/utils/serializers/output/utils/url'); describe('Unit: endpoints/utils/serializers/output/utils/url', function () { + let getUrlForResourceStub; + beforeEach(function () { - sinon.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId'); + getUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource').returns('getUrlForResource'); sinon.stub(urlUtils, 'urlFor').returns('urlFor'); }); @@ -15,7 +17,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/url', function () { sinon.restore(); }); - describe('Ensure calls url service', function () { + describe('forPost', function () { let pageModel; beforeEach(function () { @@ -24,28 +26,119 @@ describe('Unit: endpoints/utils/serializers/output/utils/url', function () { }; }); - it('meta & models & relations', function () { + it('passes a posts resource (with id and slug) to the facade', function () { const post = pageModel(testUtils.DataGenerator.forKnex.createPost({ id: 'id1', mobiledoc: '{}', - html: 'html', - custom_excerpt: 'customExcerpt', - codeinjection_head: 'codeinjectionHead', - codeinjection_foot: 'codeinjectionFoot', - feature_image: 'featureImage', - posts_meta: { - og_image: 'ogImage', - twitter_image: 'twitterImage' - }, - canonical_url: 'canonicalUrl' + html: 'html' })); urlUtil.forPost(post.id, post, {options: {}}); assert(Object.hasOwn(post, 'url')); + sinon.assert.callCount(getUrlForResourceStub, 1); + const [resource, options] = getUrlForResourceStub.firstCall.args; + assert.equal(resource.type, 'posts'); + assert.equal(resource.id, 'id1'); + assert.equal(resource.slug, post.slug); + assert.deepEqual(options, {absolute: true}); + }); + + it('still passes id when attrs has been stripped (e.g. fields=url)', function () { + // Content API request like `?fields=url` runs jsonModel through a + // serializer that strips every attribute except `url`. The mapper + // calls forPost(model.id, jsonModel, frame) — id is on the model, + // not on attrs. Regression: a previous spread `{...attrs, type}` + // sent id-less resources, so the eager facade's id-fallback hit + // /404/ for every post. + const stripped = {}; + + urlUtil.forPost('post-id', stripped, {options: {}}); + + sinon.assert.calledOnce(getUrlForResourceStub); + const [resource] = getUrlForResourceStub.firstCall.args; + assert.equal(resource.id, 'post-id'); + assert.equal(resource.type, 'posts'); + }); + + it('routes pages through the pages router type when the mapper passes type=pages', function () { + // The pages mapper delegates to the posts mapper, which derives + // the router type from the model (which is reliable even under + // `?fields=url`) and passes it explicitly to forPost. Regression: + // a previous version derived the type from `attrs.type` directly, + // which silently fell back to 'posts' when fields stripped the + // type column. + const stripped = {}; + + urlUtil.forPost('page-id', stripped, {options: {}}, 'pages'); + + sinon.assert.calledOnce(getUrlForResourceStub); + const [resource] = getUrlForResourceStub.firstCall.args; + assert.equal(resource.id, 'page-id'); + assert.equal(resource.type, 'pages'); + }); + + it('defaults to posts when no type is passed', function () { + // Other callers of forPost (comments mapper, activity-feed-events) + // pass post records and rely on the default. + const stripped = {}; + + urlUtil.forPost('post-id', stripped, {options: {}}); + + sinon.assert.calledOnce(getUrlForResourceStub); + const [resource] = getUrlForResourceStub.firstCall.args; + assert.equal(resource.id, 'post-id'); + assert.equal(resource.type, 'posts'); + }); + }); + + describe('forTag', function () { + it('passes a tags resource to the facade when url is requested', function () { + const tag = {id: 'tag1', slug: 'food', name: 'Food'}; + + urlUtil.forTag(tag.id, tag, {}); + + assert.equal(tag.url, 'getUrlForResource'); + sinon.assert.calledOnce(getUrlForResourceStub); + const [resource, options] = getUrlForResourceStub.firstCall.args; + assert.equal(resource.type, 'tags'); + assert.equal(resource.id, 'tag1'); + assert.equal(resource.slug, 'food'); + assert.deepEqual(options, {absolute: true}); + }); + + it('skips url generation when columns excludes url', function () { + const tag = {id: 'tag1', slug: 'food'}; + + urlUtil.forTag(tag.id, tag, {columns: ['id', 'slug']}); + + assert.equal(tag.url, undefined); + sinon.assert.notCalled(getUrlForResourceStub); + }); + }); + + describe('forUser', function () { + it('passes an authors resource to the facade when url is requested', function () { + const user = {id: 'user1', slug: 'jane', name: 'Jane'}; + + urlUtil.forUser(user.id, user, {}); + + assert.equal(user.url, 'getUrlForResource'); + sinon.assert.calledOnce(getUrlForResourceStub); + const [resource, options] = getUrlForResourceStub.firstCall.args; + assert.equal(resource.type, 'authors'); + assert.equal(resource.id, 'user1'); + assert.equal(resource.slug, 'jane'); + assert.deepEqual(options, {absolute: true}); + }); + + it('skips url generation when columns excludes url', function () { + const user = {id: 'user1', slug: 'jane'}; + + urlUtil.forUser(user.id, user, {columns: ['id', 'slug']}); - sinon.assert.callCount(urlService.getUrlByResourceId, 1); - sinon.assert.calledWithExactly(urlService.getUrlByResourceId, 'id1', {absolute: true}); + assert.equal(user.url, undefined); + sinon.assert.notCalled(getUrlForResourceStub); }); }); -}); \ No newline at end of file +}); diff --git a/ghost/core/test/unit/frontend/helpers/authors.test.js b/ghost/core/test/unit/frontend/helpers/authors.test.js index ff00e050887..c85449bad8f 100644 --- a/ghost/core/test/unit/frontend/helpers/authors.test.js +++ b/ghost/core/test/unit/frontend/helpers/authors.test.js @@ -6,10 +6,10 @@ const authorsHelper = require('../../../../core/frontend/helpers/authors'); const testUtils = require('../../../utils'); describe('{{authors}} helper', function () { - let urlServiceGetUrlByResourceIdStub; + let urlServiceGetUrlForResourceStub; beforeEach(function () { - urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId'); + urlServiceGetUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource'); }); afterEach(function () { @@ -113,8 +113,8 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[0].id).returns('author url 1'); - urlServiceGetUrlByResourceIdStub.withArgs(authors[1].id).returns('author url 2'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[0].id})).returns('author url 1'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[1].id})).returns('author url 2'); const rendered = authorsHelper.call({authors: authors}); assertExists(rendered); @@ -128,7 +128,7 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[0].id).returns('author url 1'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[0].id})).returns('author url 1'); const rendered = authorsHelper.call({authors: authors}, {hash: {limit: '1'}}); assertExists(rendered); @@ -142,7 +142,7 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[1].id).returns('author url 2'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[1].id})).returns('author url 2'); const rendered = authorsHelper.call({authors: authors}, {hash: {from: '2'}}); assertExists(rendered); @@ -156,7 +156,7 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[0].id).returns('author url'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[0].id})).returns('author url'); const rendered = authorsHelper.call({authors: authors}, {hash: {to: '1'}}); assertExists(rendered); @@ -171,8 +171,8 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'baz', slug: 'baz'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[1].id).returns('author url 2'); - urlServiceGetUrlByResourceIdStub.withArgs(authors[2].id).returns('author url 3'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[1].id})).returns('author url 2'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[2].id})).returns('author url 3'); const rendered = authorsHelper.call({authors: authors}, {hash: {from: '2', to: '3'}}); assertExists(rendered); @@ -187,7 +187,7 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'baz', slug: 'baz'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[1].id).returns('author url x'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[1].id})).returns('author url x'); const rendered = authorsHelper.call({authors: authors}, {hash: {from: '2', limit: '1'}}); assertExists(rendered); @@ -202,9 +202,9 @@ describe('{{authors}} helper', function () { testUtils.DataGenerator.forKnex.createUser({name: 'baz', slug: 'baz'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(authors[0].id).returns('author url a'); - urlServiceGetUrlByResourceIdStub.withArgs(authors[1].id).returns('author url b'); - urlServiceGetUrlByResourceIdStub.withArgs(authors[2].id).returns('author url c'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[0].id})).returns('author url a'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[1].id})).returns('author url b'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: authors[2].id})).returns('author url c'); const rendered = authorsHelper.call({authors: authors}, {hash: {from: '1', to: '3', limit: '2'}}); assertExists(rendered); diff --git a/ghost/core/test/unit/frontend/helpers/tags.test.js b/ghost/core/test/unit/frontend/helpers/tags.test.js index 50c6bb60595..502b411c6e0 100644 --- a/ghost/core/test/unit/frontend/helpers/tags.test.js +++ b/ghost/core/test/unit/frontend/helpers/tags.test.js @@ -6,10 +6,10 @@ const urlService = require('../../../../core/server/services/url'); const tagsHelper = require('../../../../core/frontend/helpers/tags'); describe('{{tags}} helper', function () { - let urlServiceGetUrlByResourceIdStub; + let urlServiceGetUrlForResourceStub; beforeEach(function () { - urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId'); + urlServiceGetUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource'); }); afterEach(function () { @@ -101,8 +101,8 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url 1'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url 2'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[0].id})).returns('tag url 1'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[1].id})).returns('tag url 2'); const rendered = tagsHelper.call({tags: tags}); assertExists(rendered); @@ -116,7 +116,7 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url 1'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[0].id})).returns('tag url 1'); const rendered = tagsHelper.call({tags: tags}, {hash: {limit: '1'}}); assertExists(rendered); @@ -130,7 +130,7 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url 2'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[1].id})).returns('tag url 2'); const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2'}}); assertExists(rendered); @@ -144,7 +144,7 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url x'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[0].id})).returns('tag url x'); const rendered = tagsHelper.call({tags: tags}, {hash: {to: '1'}}); assertExists(rendered); @@ -159,8 +159,8 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('tag url c'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[1].id})).returns('tag url b'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[2].id})).returns('tag url c'); const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', to: '3'}}); assertExists(rendered); @@ -175,7 +175,7 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[1].id})).returns('tag url b'); const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', limit: '1'}}); assertExists(rendered); @@ -190,9 +190,9 @@ describe('{{tags}} helper', function () { testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'}) ]; - urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url a'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('tag url c'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[0].id})).returns('tag url a'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[1].id})).returns('tag url b'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[2].id})).returns('tag url c'); const rendered = tagsHelper.call({tags: tags}, {hash: {from: '1', to: '3', limit: '2'}}); assertExists(rendered); @@ -215,11 +215,11 @@ describe('{{tags}} helper', function () { ]; beforeEach(function () { - urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('1'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('2'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('3'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[3].id).returns('4'); - urlServiceGetUrlByResourceIdStub.withArgs(tags[4].id).returns('5'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[0].id})).returns('1'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[1].id})).returns('2'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[2].id})).returns('3'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[3].id})).returns('4'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tags[4].id})).returns('5'); }); it('will not output internal tags by default', function () { diff --git a/ghost/core/test/unit/frontend/meta/author-url.test.js b/ghost/core/test/unit/frontend/meta/author-url.test.js index af970291dfe..c1d8a4f73c1 100644 --- a/ghost/core/test/unit/frontend/meta/author-url.test.js +++ b/ghost/core/test/unit/frontend/meta/author-url.test.js @@ -7,10 +7,10 @@ const getAuthorUrl = require('../../../../core/frontend/meta/author-url'); describe('getAuthorUrl', function () { /** @type {import('sinon').SinonStub} */ - let urlServiceGetUrlByResourceIdStub; + let urlServiceGetUrlForResourceStub; beforeEach(function () { - urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId'); + urlServiceGetUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource'); }); afterEach(function () { @@ -25,7 +25,7 @@ describe('getAuthorUrl', function () { } }; - urlServiceGetUrlByResourceIdStub.withArgs(post.primary_author.id, {absolute: undefined, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: post.primary_author.id, type: 'authors'}), {absolute: undefined, withSubdirectory: true}) .returns('author url'); assertExists(getAuthorUrl({ @@ -42,7 +42,7 @@ describe('getAuthorUrl', function () { } }; - urlServiceGetUrlByResourceIdStub.withArgs(post.primary_author.id, {absolute: true, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: post.primary_author.id, type: 'authors'}), {absolute: true, withSubdirectory: true}) .returns('absolute author url'); assertExists(getAuthorUrl({ @@ -57,7 +57,7 @@ describe('getAuthorUrl', function () { slug: 'test-author' }; - urlServiceGetUrlByResourceIdStub.withArgs(author.id, {absolute: undefined, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: author.id, type: 'authors'}), {absolute: undefined, withSubdirectory: true}) .returns('author url'); assertExists(getAuthorUrl({ diff --git a/ghost/core/test/unit/frontend/meta/url.test.js b/ghost/core/test/unit/frontend/meta/url.test.js index 9c239e3a10f..bc0bb3fa6bb 100644 --- a/ghost/core/test/unit/frontend/meta/url.test.js +++ b/ghost/core/test/unit/frontend/meta/url.test.js @@ -6,11 +6,11 @@ const getUrl = require('../../../../core/frontend/meta/url'); const testUtils = require('../../../utils'); describe('getUrl', function () { - let urlServiceGetUrlByResourceIdStub; + let urlServiceGetUrlForResourceStub; let urlUtilsUrlForStub; beforeEach(function () { - urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId'); + urlServiceGetUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource'); urlUtilsUrlForStub = sinon.stub(urlUtils, 'urlFor'); }); @@ -21,7 +21,7 @@ describe('getUrl', function () { it('should return url for a post', function () { const post = testUtils.DataGenerator.forKnex.createPost(); - urlServiceGetUrlByResourceIdStub.withArgs(post.id, {absolute: undefined, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: post.id, type: 'posts'}), {absolute: undefined, withSubdirectory: true}) .returns('post url'); assert.equal(getUrl(post), 'post url'); @@ -30,11 +30,11 @@ describe('getUrl', function () { describe('preview url: drafts/scheduled posts', function () { it('relative', function () { const post = testUtils.DataGenerator.forKnex.createPost({status: 'draft'}); - urlServiceGetUrlByResourceIdStub.withArgs(post.id).returns('/404/'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: post.id, type: 'posts'})).returns('/404/'); urlUtilsUrlForStub.withArgs({relativeUrl: '/p/' + post.uuid + '/'}, null, undefined).returns('relative'); let url = getUrl(post); - sinon.assert.calledOnce(urlServiceGetUrlByResourceIdStub); + sinon.assert.calledOnce(urlServiceGetUrlForResourceStub); sinon.assert.calledOnce(urlUtilsUrlForStub.withArgs({relativeUrl: '/p/' + post.uuid + '/'}, null, undefined)); assert.equal(url, 'relative'); @@ -42,11 +42,11 @@ describe('getUrl', function () { it('absolute', function () { const post = testUtils.DataGenerator.forKnex.createPost({status: 'draft'}); - urlServiceGetUrlByResourceIdStub.withArgs(post.id).returns('/404/'); + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: post.id, type: 'posts'})).returns('/404/'); urlUtilsUrlForStub.withArgs({relativeUrl: '/p/' + post.uuid + '/'}, null, true).returns('absolute'); let url = getUrl(post, true); - sinon.assert.calledOnce(urlServiceGetUrlByResourceIdStub); + sinon.assert.calledOnce(urlServiceGetUrlForResourceStub); sinon.assert.calledOnce(urlUtilsUrlForStub.withArgs({relativeUrl: '/p/' + post.uuid + '/'}, null, true)); assert.equal(url, 'absolute'); @@ -56,7 +56,7 @@ describe('getUrl', function () { it('should return absolute url for a post', function () { const post = testUtils.DataGenerator.forKnex.createPost(); - urlServiceGetUrlByResourceIdStub.withArgs(post.id, {absolute: true, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: post.id, type: 'posts'}), {absolute: true, withSubdirectory: true}) .returns('absolute post url'); assert.equal(getUrl(post, true), 'absolute post url'); @@ -70,7 +70,7 @@ describe('getUrl', function () { // the tag object contains a `parent` attribute. the tag model contains a `parent_id` attr. tag.parent = null; - urlServiceGetUrlByResourceIdStub.withArgs(tag.id, {absolute: undefined, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: tag.id, type: 'tags'}), {absolute: undefined, withSubdirectory: true}) .returns('tag url'); assert.equal(getUrl(tag), 'tag url'); @@ -79,7 +79,7 @@ describe('getUrl', function () { it('should return url for a author', function () { const author = testUtils.DataGenerator.forKnex.createUser(); - urlServiceGetUrlByResourceIdStub.withArgs(author.id, {absolute: undefined, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: author.id, type: 'authors'}), {absolute: undefined, withSubdirectory: true}) .returns('author url'); assert.equal(getUrl(author), 'author url'); @@ -88,7 +88,7 @@ describe('getUrl', function () { it('should return absolute url for a author', function () { const author = testUtils.DataGenerator.forKnex.createUser(); - urlServiceGetUrlByResourceIdStub.withArgs(author.id, {absolute: true, withSubdirectory: true}) + urlServiceGetUrlForResourceStub.withArgs(sinon.match({id: author.id, type: 'authors'}), {absolute: true, withSubdirectory: true}) .returns('absolute author url'); assert.equal(getUrl(author, true), 'absolute author url'); diff --git a/ghost/core/test/unit/frontend/services/routing/controllers/collection.test.js b/ghost/core/test/unit/frontend/services/routing/controllers/collection.test.js index 772068f759b..285c5fc7ffd 100644 --- a/ghost/core/test/unit/frontend/services/routing/controllers/collection.test.js +++ b/ghost/core/test/unit/frontend/services/routing/controllers/collection.test.js @@ -16,7 +16,7 @@ describe('Unit - services/routing/controllers/collection', function () { let renderStub; let posts; let postsPerPage; - let ownsStub; + let ownsResourceStub; let next; beforeEach(function () { @@ -46,8 +46,8 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.stub(renderer, 'renderEntries').returns(renderStub); - ownsStub = sinon.stub(routerManager, 'owns'); - ownsStub.withArgs('identifier', posts[0].id).returns(true); + ownsResourceStub = sinon.stub(routerManager, 'ownsResource'); + ownsResourceStub.withArgs('identifier', posts[0]).returns(true); req = { path: '/', @@ -83,7 +83,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.calledOnce(themeEngine.getActive); sinon.assert.notCalled(security.string.safe); sinon.assert.calledOnce(fetchDataStub); - sinon.assert.calledOnce(ownsStub); + sinon.assert.calledOnce(ownsResourceStub); sinon.assert.notCalled(next); }); @@ -104,7 +104,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.calledOnce(themeEngine.getActive); sinon.assert.notCalled(security.string.safe); sinon.assert.calledOnce(fetchDataStub); - sinon.assert.calledOnce(ownsStub); + sinon.assert.calledOnce(ownsResourceStub); sinon.assert.notCalled(next); }); @@ -127,7 +127,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.calledOnce(themeEngine.getActive().updateTemplateOptions.withArgs({data: {config: {posts_per_page: 3}}})); sinon.assert.notCalled(security.string.safe); sinon.assert.calledOnce(fetchDataStub); - sinon.assert.calledOnce(ownsStub); + sinon.assert.calledOnce(ownsResourceStub); sinon.assert.notCalled(next); }); @@ -150,7 +150,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.notCalled(security.string.safe); sinon.assert.calledOnce(fetchDataStub); sinon.assert.notCalled(renderStub); - sinon.assert.notCalled(ownsStub); + sinon.assert.notCalled(ownsResourceStub); }); it('slug param', async function () { @@ -170,7 +170,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.calledOnce(themeEngine.getActive); sinon.assert.calledOnce(security.string.safe); sinon.assert.calledOnce(fetchDataStub); - sinon.assert.calledOnce(ownsStub); + sinon.assert.calledOnce(ownsResourceStub); sinon.assert.notCalled(next); }); @@ -191,7 +191,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.calledOnce(themeEngine.getActive); sinon.assert.notCalled(security.string.safe); sinon.assert.calledOnce(fetchDataStub); - sinon.assert.calledOnce(ownsStub); + sinon.assert.calledOnce(ownsResourceStub); sinon.assert.notCalled(next); }); @@ -205,11 +205,11 @@ describe('Unit - services/routing/controllers/collection', function () { res.routerOptions.filter = 'featured:true'; - ownsStub.reset(); - ownsStub.withArgs('identifier', posts[0].id).returns(false); - ownsStub.withArgs('identifier', posts[1].id).returns(true); - ownsStub.withArgs('identifier', posts[2].id).returns(false); - ownsStub.withArgs('identifier', posts[3].id).returns(false); + ownsResourceStub.reset(); + ownsResourceStub.withArgs('identifier', posts[0]).returns(false); + ownsResourceStub.withArgs('identifier', posts[1]).returns(true); + ownsResourceStub.withArgs('identifier', posts[2]).returns(false); + ownsResourceStub.withArgs('identifier', posts[3]).returns(false); fetchDataStub.withArgs({page: 1, slug: undefined, limit: postsPerPage}, res.routerOptions) .resolves({ @@ -228,7 +228,7 @@ describe('Unit - services/routing/controllers/collection', function () { sinon.assert.calledOnce(themeEngine.getActive); sinon.assert.notCalled(security.string.safe); sinon.assert.calledOnce(fetchDataStub); - sinon.assert.callCount(ownsStub, 4); + sinon.assert.callCount(ownsResourceStub, 4); sinon.assert.notCalled(next); }); }); diff --git a/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.js b/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.js index ae8ecafb83c..bbcbdcdbb42 100644 --- a/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.js +++ b/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.js @@ -4,7 +4,6 @@ const sinon = require('sinon'); const testUtils = require('../../../../../utils'); const configUtils = require('../../../../../utils/config-utils'); const urlUtils = require('../../../../../../core/shared/url-utils'); -const routerManager = require('../../../../../../core/frontend/services/routing/').routerManager; const controllers = require('../../../../../../core/frontend/services/routing/controllers'); const renderer = require('../../../../../../core/frontend/services/rendering'); const dataService = require('../../../../../../core/frontend/services/data'); @@ -16,7 +15,6 @@ describe('Unit - services/routing/controllers/entry', function () { let entryLookUpStub; let renderStub; let urlUtilsRedirect301Stub; - let routerManagerGetResourceByIdStub; let urlUtilsRedirectToAdminStub; let post; @@ -37,7 +35,6 @@ describe('Unit - services/routing/controllers/entry', function () { urlUtilsRedirectToAdminStub = sinon.stub(urlUtils, 'redirectToAdmin'); urlUtilsRedirect301Stub = sinon.stub(urlUtils, 'redirect301'); - routerManagerGetResourceByIdStub = sinon.stub(routerManager, 'getResourceById'); req = { path: '/', @@ -77,12 +74,6 @@ describe('Unit - services/routing/controllers/entry', function () { res.routerOptions.resourceType = 'posts'; - routerManagerGetResourceByIdStub.withArgs(post.id).returns({ - config: { - type: 'posts' - } - }); - entryLookUpStub.withArgs(req.path, res.routerOptions) .resolves({ entry: post @@ -153,27 +144,6 @@ describe('Unit - services/routing/controllers/entry', function () { }); }); - it('type of router !== type of resource', function (done) { - req.path = post.url; - res.routerOptions.resourceType = 'posts'; - - routerManagerGetResourceByIdStub.withArgs(post.id).returns({ - config: { - type: 'pages' - } - }); - - entryLookUpStub.withArgs(req.path, res.routerOptions) - .resolves({ - entry: post - }); - - controllers.entry(req, res, function (err) { - assert.equal(err, undefined); - done(); - }); - }); - it('requested url !== resource url', function (done) { post.url = '/2017/08' + post.url; req.path = '/2017/07' + post.url; @@ -181,12 +151,6 @@ describe('Unit - services/routing/controllers/entry', function () { res.routerOptions.resourceType = 'posts'; - routerManagerGetResourceByIdStub.withArgs(post.id).returns({ - config: { - type: 'posts' - } - }); - entryLookUpStub.withArgs(req.path, res.routerOptions) .resolves({ entry: post @@ -210,12 +174,6 @@ describe('Unit - services/routing/controllers/entry', function () { res.routerOptions.resourceType = 'posts'; - routerManagerGetResourceByIdStub.withArgs(post.id).returns({ - config: { - type: 'posts' - } - }); - entryLookUpStub.withArgs(req.path, res.routerOptions) .resolves({ entry: post diff --git a/ghost/core/test/unit/frontend/services/rss/generate-feed.test.js b/ghost/core/test/unit/frontend/services/rss/generate-feed.test.js index 7fb8cd000fb..cc8399c7a2f 100644 --- a/ghost/core/test/unit/frontend/services/rss/generate-feed.test.js +++ b/ghost/core/test/unit/frontend/services/rss/generate-feed.test.js @@ -10,7 +10,7 @@ const generateFeed = require('../../../../../core/frontend/services/rss/generate describe('RSS: Generate Feed', function () { const data = {}; let baseUrl; - let routerManagerGetUrlByResourceIdStub; + let routerManagerGetUrlForResourceStub; // Static set of posts let posts; @@ -45,7 +45,7 @@ describe('RSS: Generate Feed', function () { }); beforeEach(function () { - routerManagerGetUrlByResourceIdStub = sinon.stub(routerManager, 'getUrlByResourceId'); + routerManagerGetUrlForResourceStub = sinon.stub(routerManager, 'getUrlForResource'); baseUrl = '/rss/'; @@ -91,7 +91,7 @@ describe('RSS: Generate Feed', function () { data.posts = posts; _.each(data.posts, function (post) { - routerManagerGetUrlByResourceIdStub.withArgs(post.id, {absolute: true}).returns('http://my-ghost-blog.com/' + post.slug + '/'); + routerManagerGetUrlForResourceStub.withArgs(sinon.match({id: post.id}), {absolute: true}).returns('http://my-ghost-blog.com/' + post.slug + '/'); }); const xmlData = await generateFeed(baseUrl, data); @@ -189,7 +189,7 @@ describe('RSS: Generate Feed', function () { data.posts = [posts[0]]; _.each(data.posts, function (post) { - routerManagerGetUrlByResourceIdStub.withArgs(post.id, {absolute: true}).returns('http://my-ghost-blog.com/' + post.slug + '/'); + routerManagerGetUrlForResourceStub.withArgs(sinon.match({id: post.id}), {absolute: true}).returns('http://my-ghost-blog.com/' + post.slug + '/'); }); const xmlData = await generateFeed(baseUrl, data); diff --git a/ghost/core/test/unit/frontend/web/middleware/frontend-caching.test.js b/ghost/core/test/unit/frontend/web/middleware/frontend-caching.test.js index b9dcc84d74b..42201b2731e 100644 --- a/ghost/core/test/unit/frontend/web/middleware/frontend-caching.test.js +++ b/ghost/core/test/unit/frontend/web/middleware/frontend-caching.test.js @@ -73,6 +73,15 @@ describe('frontendCaching', function () { sinon.assert.calledWith(res.set, {'Cache-Control': testUtils.cacheRules.noCache}); }); + it('should set cache control to no-cache if the request includes preview data', function () { + req.header = sinon.stub().withArgs('x-ghost-preview').returns('c=%23ff0000'); + + middleware(req, res, next); + + sinon.assert.calledOnce(res.set); + sinon.assert.calledWith(res.set, {'Cache-Control': testUtils.cacheRules.noCache}); + }); + describe('calculateMemberTier', function () { it('should return null if the member has more than one active subscription', function () { const member = { diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 7ffc629a390..8fb274b2c64 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '20b3032b2ec14edfdac13d1485391f19'; + const currentSchemaHash = 'f6eb178fe153ce8cf8e9df09f3a34fe9'; const currentFixturesHash = 'b76d01321e02fb99b11e7a29f91859f7'; const currentSettingsHash = '857b77a8e1c7072d9eb639152c78a49e'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/lib/common/to-plain.test.js b/ghost/core/test/unit/server/lib/common/to-plain.test.js new file mode 100644 index 00000000000..dc530bebaf1 --- /dev/null +++ b/ghost/core/test/unit/server/lib/common/to-plain.test.js @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const toPlain = require('../../../../../core/server/lib/common/to-plain'); + +describe('toPlain', function () { + it('returns a plain object unchanged', function () { + const obj = {id: 'abc', slug: 'hello'}; + assert.equal(toPlain(obj), obj); + }); + + it('serialises a Bookshelf-shaped model via toJSON', function () { + const model = { + // own keys are not the data; only `.toJSON()` produces it. + toJSON: () => ({id: 'xyz', slug: 'world'}) + }; + assert.deepEqual(toPlain(model), {id: 'xyz', slug: 'world'}); + }); + + it('returns null and undefined unchanged (no toJSON deref)', function () { + assert.equal(toPlain(null), null); + assert.equal(toPlain(undefined), undefined); + }); + + it('returns primitives unchanged (they have no toJSON)', function () { + assert.equal(toPlain(0), 0); + assert.equal(toPlain(''), ''); + assert.equal(toPlain(false), false); + }); + + it('returns input unchanged when toJSON is present but not callable', function () { + // pins the `typeof === 'function'` guard: a non-function `toJSON` + // (e.g. a JSON-serialised payload that already has a `toJSON` field) + // must not throw and must round-trip the input. + const obj = {id: 'abc', toJSON: 'not-a-fn'}; + assert.equal(toPlain(obj), obj); + }); +}); diff --git a/ghost/core/test/unit/server/services/adapter-manager/adapter-manager.test.js b/ghost/core/test/unit/server/services/adapter-manager/adapter-manager.test.js index 210f90b1451..c68974062a3 100644 --- a/ghost/core/test/unit/server/services/adapter-manager/adapter-manager.test.js +++ b/ghost/core/test/unit/server/services/adapter-manager/adapter-manager.test.js @@ -1,4 +1,7 @@ const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const sinon = require('sinon'); const AdapterManager = require('../../../../../core/server/services/adapter-manager/adapter-manager'); @@ -82,6 +85,33 @@ describe('AdapterManager', function () { sinon.assert.calledWith(loadAdapterFromPath, 'some-node-module-adapter'); }); + it('Throws missing-dependency error when adapter exists but requires a missing package', function () { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-adapter-test-')); + const adapterDir = path.join(tmpDir, 'scheduling'); + fs.mkdirSync(adapterDir, {recursive: true}); + fs.writeFileSync( + path.join(adapterDir, 'BrokenAdapter.js'), + `require('this-package-does-not-exist-at-all');\nmodule.exports = class {};` + ); + + try { + const adapterManager = new AdapterManager({ + loadAdapterFromPath: require, + pathsToAdapters: [tmpDir] + }); + adapterManager.registerAdapter('scheduling', BaseMailAdapter); + + assert.throws(() => { + adapterManager.getAdapter('scheduling', 'BrokenAdapter', {}); + }, { + errorType: 'IncorrectUsageError', + message: /missing dependencies/ + }); + } finally { + fs.rmSync(tmpDir, {recursive: true, force: true}); + } + }); + it('Loads registered adapters in the order defined by the paths', function () { const pathsToAdapters = [ 'first/path', diff --git a/ghost/core/test/unit/server/services/audience-feedback/audience-feedback-service.test.js b/ghost/core/test/unit/server/services/audience-feedback/audience-feedback-service.test.js index 725347bfc93..de2f3acd6ac 100644 --- a/ghost/core/test/unit/server/services/audience-feedback/audience-feedback-service.test.js +++ b/ghost/core/test/unit/server/services/audience-feedback/audience-feedback-service.test.js @@ -9,18 +9,21 @@ describe('audienceFeedbackService', function () { score: 1, key: 'somekey' }; + const mockPost = {id: mockData.postId}; describe('build link', function () { it('Can build link to post', async function () { const instance = new AudienceFeedbackService({ urlService: { - getUrlByResourceId: () => `https://localhost:2368/${mockData.postTitle}/` + facade: { + getUrlForResource: () => `https://localhost:2368/${mockData.postTitle}/` + } }, config: { baseURL: new URL('https://localhost:2368') } }); - const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score, mockData.key); + const link = instance.buildLink(mockData.uuid, mockPost, mockData.score, mockData.key); const expectedLink = `https://localhost:2368/${mockData.postTitle}/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}&key=somekey`; assert.equal(link.href, expectedLink); }); @@ -28,15 +31,73 @@ describe('audienceFeedbackService', function () { it('Can build link to home page if post wasn\'t published', async function () { const instance = new AudienceFeedbackService({ urlService: { - getUrlByResourceId: () => `https://localhost:2368/${mockData.postTitle}/404/` + facade: { + getUrlForResource: () => `https://localhost:2368/${mockData.postTitle}/404/` + } }, config: { baseURL: new URL('https://localhost:2368') } }); - const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score, mockData.key); + const link = instance.buildLink(mockData.uuid, mockPost, mockData.score, mockData.key); const expectedLink = `https://localhost:2368/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}&key=somekey`; assert.equal(link.href, expectedLink); }); + + it('Passes a posts resource (with id) to the facade', async function () { + let receivedResource; + const instance = new AudienceFeedbackService({ + urlService: { + facade: { + getUrlForResource: (resource) => { + receivedResource = resource; + return `https://localhost:2368/${mockData.postTitle}/`; + } + } + }, + config: { + baseURL: new URL('https://localhost:2368') + } + }); + instance.buildLink(mockData.uuid, mockPost, mockData.score, mockData.key); + assert.equal(receivedResource.id, mockData.postId); + assert.equal(receivedResource.type, 'posts'); + }); + + it('Serialises Bookshelf-model input so spread does not lose the id', async function () { + // Real callers (email-renderer) pass a Bookshelf model. Spreading + // one with `{...model}` skips prototype getters like `.id`. The + // service must call `.toJSON()` first; this test pins that. + // + // toJSON also returns the DB-level `type: 'post'` (singular). The + // service must override that to the routing-level `'posts'` + // (plural) before handing the resource to the facade — the + // assertion below captures that override explicitly. + let receivedResource; + const fakeBookshelfModel = { + // No own `id` / `slug` properties; only `.toJSON()` exposes them. + toJSON: () => ({id: mockData.postId, slug: mockData.postTitle, type: 'post'}) + }; + const instance = new AudienceFeedbackService({ + urlService: { + facade: { + getUrlForResource: (resource) => { + receivedResource = resource; + return `https://localhost:2368/${mockData.postTitle}/`; + } + } + }, + config: { + baseURL: new URL('https://localhost:2368') + } + }); + const link = instance.buildLink(mockData.uuid, fakeBookshelfModel, mockData.score, mockData.key); + assert.equal(receivedResource.id, mockData.postId); + assert.equal(receivedResource.slug, mockData.postTitle); + assert.equal(receivedResource.type, 'posts'); + // The hash fragment also depends on the post id, so the same bug + // would surface in the produced URL. + assert.match(link.href, new RegExp(`#/feedback/${mockData.postId}/`)); + }); }); }); diff --git a/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js b/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js index 58fe38013d2..2eeeb2a0a83 100644 --- a/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js +++ b/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js @@ -5,7 +5,9 @@ const CommentsServiceEmails = require('../../../../../core/server/services/comme describe('Comments Service: CommentsServiceEmails', function () { function createClassInstance({labs = {}}) { const urlService = { - getUrlByResourceId: sinon.stub().returns('https://example.com/my-post/') + facade: { + getUrlForResource: sinon.stub().returns('https://example.com/my-post/') + } }; const labsStub = { isSet: sinon.stub().callsFake(flag => labs[flag] || false) @@ -30,10 +32,40 @@ describe('Comments Service: CommentsServiceEmails', function () { it('returns post URL with comment permalink', function () { const {instance} = createClassInstance({}); - const result = instance.getPostUrl('123', '456'); + const result = instance.getPostUrl({id: '123'}, '456'); assert.equal(result, 'https://example.com/my-post/#ghost-comments-456'); }); + + it('passes a posts resource to the facade', function () { + const {instance, urlService} = createClassInstance({}); + + instance.getPostUrl({id: '123'}, '456'); + + sinon.assert.calledWith( + urlService.facade.getUrlForResource, + sinon.match({id: '123', type: 'posts'}), + {absolute: true} + ); + }); + + it('serialises Bookshelf-model input so spread does not lose the id', function () { + // Real callers (notify*Authors / notifyParentCommentAuthor / notifyReport) + // pass a Bookshelf model from Post.findOne. Spreading one with + // `{...model}` skips prototype getters like `.id`. + const {instance, urlService} = createClassInstance({}); + const fakeBookshelfModel = { + toJSON: () => ({id: '123', slug: 'my-post'}) + }; + + instance.getPostUrl(fakeBookshelfModel, '456'); + + sinon.assert.calledWith( + urlService.facade.getUrlForResource, + sinon.match({id: '123', slug: 'my-post', type: 'posts'}), + {absolute: true} + ); + }); }); // Characterisation tests for the three notification entry points. These @@ -89,15 +121,15 @@ describe('Comments Service: CommentsServiceEmails', function () { const renderStub = sinon.stub().resolves({html: 'h', text: 't'}); const mailerSendStub = sinon.stub().resolves(); - // Stub `getUrlByResourceId` with a withArgs match: the URL only - // resolves when the post's id is passed. Anything else returns - // undefined, so a regression that drops `post.id` somewhere - // between notifyX and the URL service surfaces as a bad URL - // landing in templateData. - const getUrlByResourceIdStub = sinon.stub().returns(undefined); - getUrlByResourceIdStub.withArgs('post-id', sinon.match.any) + // Stub the facade's getUrlForResource with a withArgs match: + // the URL only resolves when a resource carrying the post's id + // is passed. Anything else returns undefined, so a regression + // that drops `post.id` somewhere between notifyX and the URL + // service surfaces as a bad URL landing in templateData. + const getUrlForResourceStub = sinon.stub().returns(undefined); + getUrlForResourceStub.withArgs(sinon.match({id: 'post-id'}), sinon.match.any) .returns('https://example.com/my-post/'); - const urlService = {getUrlByResourceId: getUrlByResourceIdStub}; + const urlService = {facade: {getUrlForResource: getUrlForResourceStub}}; const instance = new CommentsServiceEmails({ config: {}, @@ -133,14 +165,15 @@ describe('Comments Service: CommentsServiceEmails', function () { sinon.assert.calledOnce(getPostUrlSpy); const [postArg, commentIdArg] = getPostUrlSpy.firstCall.args; assert.equal(commentIdArg, 'comment-id'); - // postArg is either the post model or its id; both yield the same URL - assert.equal(typeof postArg === 'string' ? postArg : postArg && postArg.id, post.id); + assert.equal(postArg.id, post.id); + // After this commit's migration the call always passes a model. + assert.notEqual(typeof postArg, 'string'); - // End-to-end pin: getUrlByResourceId must receive the post's id, - // not whatever shape happens to render to a string. The stub - // only returns the canonical URL for 'post-id', so a regression - // that drops the id somewhere in the chain surfaces here. - sinon.assert.calledWith(urlService.getUrlByResourceId, 'post-id'); + // End-to-end pin: the facade must receive a resource carrying + // the post's id. The stub only returns the canonical URL for + // {id: 'post-id'}, so a regression that drops the id somewhere + // in the chain surfaces here. + sinon.assert.calledWith(urlService.facade.getUrlForResource, sinon.match({id: 'post-id'})); sinon.assert.calledOnce(renderStub); const [, templateData] = renderStub.firstCall.args; @@ -167,9 +200,11 @@ describe('Comments Service: CommentsServiceEmails', function () { sinon.assert.calledOnce(getPostUrlSpy); const [postArg, commentIdArg] = getPostUrlSpy.firstCall.args; assert.equal(commentIdArg, 'comment-id'); - assert.equal(typeof postArg === 'string' ? postArg : postArg && postArg.id, post.id); + assert.equal(postArg.id, post.id); + // After this commit's migration the call always passes a model. + assert.notEqual(typeof postArg, 'string'); - sinon.assert.calledWith(urlService.getUrlByResourceId, 'post-id'); + sinon.assert.calledWith(urlService.facade.getUrlForResource, sinon.match({id: 'post-id'})); sinon.assert.calledOnce(renderStub); const [, templateData] = renderStub.firstCall.args; diff --git a/ghost/core/test/unit/server/services/indexnow.test.js b/ghost/core/test/unit/server/services/indexnow.test.js index 177c48fbe5d..a25bc05bf0b 100644 --- a/ghost/core/test/unit/server/services/indexnow.test.js +++ b/ghost/core/test/unit/server/services/indexnow.test.js @@ -412,11 +412,18 @@ describe('IndexNow', function () { describe('ping() URL output', function () { const ping = indexnow.__get__('ping'); const POST_URL = 'https://my-blog.example/some-post/'; + let getUrlForResourceStub; let requestStub; let resetIndexNow; beforeEach(function () { - sinon.stub(urlService, 'getUrlByResourceId').returns(POST_URL); + // Bind the stub to the exact resource shape production passes + // (`{...post, type: 'posts'}`) so a regression that drops the + // type override or the spread surfaces here. + getUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource'); + getUrlForResourceStub + .withArgs(sinon.match({id: 'abc', type: 'posts'}), {absolute: true}) + .returns(POST_URL); requestStub = sinon.stub().resolves({statusCode: 200}); resetIndexNow = indexnow.__set__('request', requestStub); @@ -433,6 +440,7 @@ describe('IndexNow', function () { await ping(post); + sinon.assert.calledOnce(getUrlForResourceStub); sinon.assert.calledOnce(requestStub); const indexNowUrl = new URL(requestStub.firstCall.args[0]); assert.equal(indexNowUrl.searchParams.get('url'), POST_URL); diff --git a/ghost/core/test/unit/server/services/member-attribution/url-translator.test.js b/ghost/core/test/unit/server/services/member-attribution/url-translator.test.js index ea200e71046..f5e85fba90d 100644 --- a/ghost/core/test/unit/server/services/member-attribution/url-translator.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/url-translator.test.js @@ -49,27 +49,18 @@ describe('UrlTranslator', function () { } }, urlService: { - getUrlByResourceId: (id) => { - return '/path/' + id; - }, - getResource: (path) => { - switch (path) { - case '/path/post': return { - config: {type: 'posts'}, - data: {id: 'post'} - }; - case '/path/tag': return { - config: {type: 'tags'}, - data: {id: 'tag'} - }; - case '/path/page': return { - config: {type: 'pages'}, - data: {id: 'page'} - }; - case '/path/author': return { - config: {type: 'authors'}, - data: {id: 'author'} - }; + facade: { + getUrlForResource: (resource) => { + return '/path/' + resource.id; + }, + resolveUrl: async (path) => { + switch (path) { + case '/path/post': return {type: 'posts', id: 'post'}; + case '/path/tag': return {type: 'tags', id: 'tag'}; + case '/path/page': return {type: 'pages', id: 'page'}; + case '/path/author': return {type: 'authors', id: 'author'}; + } + return null; } } }, @@ -150,60 +141,51 @@ describe('UrlTranslator', function () { before(function () { translator = new UrlTranslator({ urlService: { - getResource: (path) => { - switch (path) { - case '/post': return { - config: {type: 'posts'}, - data: {id: 'post'} - }; - case '/tag': return { - config: {type: 'tags'}, - data: {id: 'tag'} - }; - case '/page': return { - config: {type: 'pages'}, - data: {id: 'page'} - }; - case '/author': return { - config: {type: 'authors'}, - data: {id: 'author'} - }; + facade: { + resolveUrl: async (path) => { + switch (path) { + case '/post': return {type: 'posts', id: 'post'}; + case '/tag': return {type: 'tags', id: 'tag'}; + case '/page': return {type: 'pages', id: 'page'}; + case '/author': return {type: 'authors', id: 'author'}; + } + return null; } } } }); }); - it('returns posts', function () { - assert.deepEqual(translator.getTypeAndIdFromPath('/post'), { + it('returns posts', async function () { + assert.deepEqual(await translator.getTypeAndIdFromPath('/post'), { type: 'post', id: 'post' }); }); - it('returns pages', function () { - assert.deepEqual(translator.getTypeAndIdFromPath('/page'), { + it('returns pages', async function () { + assert.deepEqual(await translator.getTypeAndIdFromPath('/page'), { type: 'page', id: 'page' }); }); - it('returns authors', function () { - assert.deepEqual(translator.getTypeAndIdFromPath('/author'), { + it('returns authors', async function () { + assert.deepEqual(await translator.getTypeAndIdFromPath('/author'), { type: 'author', id: 'author' }); }); - it('returns tags', function () { - assert.deepEqual(translator.getTypeAndIdFromPath('/tag'), { + it('returns tags', async function () { + assert.deepEqual(await translator.getTypeAndIdFromPath('/tag'), { type: 'tag', id: 'tag' }); }); - it('returns undefined', function () { - assert.equal(translator.getTypeAndIdFromPath('/other'), undefined); + it('returns undefined', async function () { + assert.equal(await translator.getTypeAndIdFromPath('/other'), undefined); }); }); @@ -212,8 +194,8 @@ describe('UrlTranslator', function () { before(function () { translator = new UrlTranslator({ urlService: { - getUrlByResourceId: () => { - return '/path'; + facade: { + getUrlForResource: () => '/path' } }, models @@ -261,6 +243,69 @@ describe('UrlTranslator', function () { }); }); + describe('getResourceUrl', function () { + // Lazy URL service evaluates permalink templates against resource fields + // (slug, published_at, primary_tag, ...). The facade contract requires + // the full resource shape, not just `{id, type}`. + it('passes the model\'s plain data (slug, etc.) to the facade', function () { + let captured; + const translator = new UrlTranslator({ + urlUtils: { + relativeToAbsolute: t => 'https://abs' + t + }, + urlService: { + facade: { + getUrlForResource: (resource) => { + captured = resource; + return '/' + resource.slug + '/'; + } + } + }, + models: {} + }); + + const tag = { + get: () => undefined, + toJSON: () => ({id: 'abc', slug: 'changelog', visibility: 'public'}) + }; + + const url = translator.getResourceUrl('abc', 'tag', tag, {absolute: false}); + + assert.equal(url, '/changelog/'); + assert.equal(captured.id, 'abc'); + assert.equal(captured.type, 'tags'); + assert.equal(captured.slug, 'changelog'); + }); + + it('keeps the email-only short-circuit for sent posts', function () { + const translator = new UrlTranslator({ + urlUtils: { + relativeToAbsolute: t => 'https://abs' + t + }, + urlService: { + facade: { + getUrlForResource: () => { + throw new Error('facade should not be consulted for email-only posts'); + } + } + }, + models: {} + }); + + const post = { + get(k) { + return k === 'status' ? 'sent' : 'uuid-123'; + }, + toJSON: () => ({id: 'pid', uuid: 'uuid-123', status: 'sent'}) + }; + + assert.equal( + translator.getResourceUrl('pid', 'post', post, {absolute: false}), + '/email/uuid-123/' + ); + }); + }); + describe('relativeToAbsolute', function () { let translator; before(function () { diff --git a/ghost/core/test/unit/server/services/mentions/resource-service.test.js b/ghost/core/test/unit/server/services/mentions/resource-service.test.js index 298e9e80060..8253af3e989 100644 --- a/ghost/core/test/unit/server/services/mentions/resource-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/resource-service.test.js @@ -2,32 +2,26 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const ResourceService = require('../../../../../core/server/services/mentions/resource-service'); const UrlUtils = require('@tryghost/url-utils'); -const UrlService = require('../../../../../core/server/services/url/url-service'); - -function stubGetResource(urlService) { - const getResource = sinon.stub(urlService, 'getResource'); - - getResource.withArgs('/post-resource').returns({ - config: { - type: 'posts' - }, - data: { - id: '63ce473f992390b739b00b01' - } + +function buildUrlServiceWithStubbedFacade() { + const resolveUrl = sinon.stub(); + + resolveUrl.withArgs('/post-resource').resolves({ + type: 'posts', + id: '63ce473f992390b739b00b01' }); - getResource.withArgs('/tag-resource').returns({ - config: { - type: 'tags' - }, - data: { - id: '63ce473f992390b739b00b02' - } + resolveUrl.withArgs('/tag-resource').resolves({ + type: 'tags', + id: '63ce473f992390b739b00b02' }); - getResource.withArgs('/no-resource').returns(null); + resolveUrl.withArgs('/no-resource').resolves(null); - return getResource; + return { + urlService: {facade: {resolveUrl}}, + resolveUrl + }; } describe('ResourceService', function () { @@ -45,19 +39,17 @@ describe('ResourceService', function () { } }); - const urlService = new UrlService(); + const {urlService, resolveUrl} = buildUrlServiceWithStubbedFacade(); const resourceService = new ResourceService({ urlUtils, urlService }); - const getResource = stubGetResource(urlService); - const result = await resourceService.getByURL( new URL('https://site.com/blah/post-resource') ); - sinon.assert.calledWithExactly(getResource, '/post-resource'); + sinon.assert.calledWithExactly(resolveUrl, '/post-resource'); assert.equal(result.type, 'post'); assert.equal(result.id.toHexString(), '63ce473f992390b739b00b01'); @@ -76,19 +68,17 @@ describe('ResourceService', function () { } }); - const urlService = new UrlService(); + const {urlService, resolveUrl} = buildUrlServiceWithStubbedFacade(); const resourceService = new ResourceService({ urlUtils, urlService }); - const getResource = stubGetResource(urlService); - const result = await resourceService.getByURL( new URL('https://site.com/blah/tag-resource') ); - sinon.assert.calledWithExactly(getResource, '/tag-resource'); + sinon.assert.calledWithExactly(resolveUrl, '/tag-resource'); assert.equal(result.type, null); assert.equal(result.id, null); @@ -107,19 +97,17 @@ describe('ResourceService', function () { } }); - const urlService = new UrlService(); + const {urlService, resolveUrl} = buildUrlServiceWithStubbedFacade(); const resourceService = new ResourceService({ urlUtils, urlService }); - const getResource = stubGetResource(urlService); - const result = await resourceService.getByURL( new URL('https://site.com/blah/no-resource') ); - sinon.assert.calledWithExactly(getResource, '/no-resource'); + sinon.assert.calledWithExactly(resolveUrl, '/no-resource'); assert.equal(result.type, null); assert.equal(result.id, null); diff --git a/ghost/core/test/unit/server/services/slack.test.js b/ghost/core/test/unit/server/services/slack.test.js index 32930eff38b..fbf2df39a06 100644 --- a/ghost/core/test/unit/server/services/slack.test.js +++ b/ghost/core/test/unit/server/services/slack.test.js @@ -113,11 +113,11 @@ describe('Slack', function () { let settingsCacheStub; let slackReset; let makeRequestStub; - let urlServiceGetUrlByResourceIdStub; + let urlServiceGetUrlForResourceStub; const ping = slack.__get__('ping'); beforeEach(function () { - urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId'); + urlServiceGetUrlForResourceStub = sinon.stub(urlService.facade, 'getUrlForResource'); settingsCacheStub = sinon.stub(settingsCache, 'get'); @@ -142,7 +142,9 @@ describe('Slack', function () { slug: 'webhook-test', html: `

    Hello World!

    This is a test post.

    This is members only content.

    ` }); - urlServiceGetUrlByResourceIdStub.withArgs(post.id, {absolute: true}).returns('http://myblog.com/' + post.slug + '/'); + urlServiceGetUrlForResourceStub + .withArgs(sinon.match({id: post.id, type: 'posts'}), {absolute: true}) + .returns('http://myblog.com/' + post.slug + '/'); settingsCacheStub.withArgs('slack_url').returns(slackURL); @@ -151,7 +153,7 @@ describe('Slack', function () { // assertions sinon.assert.calledOnce(makeRequestStub); - sinon.assert.calledOnce(urlServiceGetUrlByResourceIdStub); + sinon.assert.calledOnce(urlServiceGetUrlForResourceStub); sinon.assert.calledWith(settingsCacheStub, 'slack_url'); requestUrl = makeRequestStub.firstCall.args[0]; @@ -179,7 +181,7 @@ describe('Slack', function () { // assertions sinon.assert.calledOnce(makeRequestStub); - sinon.assert.notCalled(urlServiceGetUrlByResourceIdStub); + sinon.assert.notCalled(urlServiceGetUrlForResourceStub); sinon.assert.calledWith(settingsCacheStub, 'slack_url'); requestUrl = makeRequestStub.firstCall.args[0]; @@ -220,7 +222,7 @@ describe('Slack', function () { // assertions sinon.assert.notCalled(makeRequestStub); - sinon.assert.calledOnce(urlServiceGetUrlByResourceIdStub); + sinon.assert.calledOnce(urlServiceGetUrlForResourceStub); sinon.assert.calledWith(settingsCacheStub, 'slack_url'); }); @@ -233,7 +235,7 @@ describe('Slack', function () { // assertions sinon.assert.notCalled(makeRequestStub); - sinon.assert.calledOnce(urlServiceGetUrlByResourceIdStub); + sinon.assert.calledOnce(urlServiceGetUrlForResourceStub); sinon.assert.calledWith(settingsCacheStub, 'slack_url'); }); @@ -246,7 +248,7 @@ describe('Slack', function () { // assertions sinon.assert.notCalled(makeRequestStub); - sinon.assert.called(urlServiceGetUrlByResourceIdStub); + sinon.assert.called(urlServiceGetUrlForResourceStub); sinon.assert.calledWith(settingsCacheStub, 'slack_url'); }); }); diff --git a/ghost/core/test/unit/server/services/staff/staff-service.test.js b/ghost/core/test/unit/server/services/staff/staff-service.test.js index 389d316908b..471f64d9448 100644 --- a/ghost/core/test/unit/server/services/staff/staff-service.test.js +++ b/ghost/core/test/unit/server/services/staff/staff-service.test.js @@ -987,7 +987,7 @@ describe('StaffService', function () { duration: 1 }); - sinon.assert.calledWith(getEmailAlertUsersStub, 'gift-subscription-purchased'); + sinon.assert.calledWith(getEmailAlertUsersStub, 'gift-subscriptions'); sinon.assert.calledOnce(mailStub); sinon.assert.calledWith(mailStub, sinon.match.has('subject', sinon.match('Gift subscription purchased: $60.00 from Alice'))); }); @@ -1102,9 +1102,9 @@ describe('StaffService', function () { buyerEmail: 'gifter@example.com' }); - sinon.assert.calledWith(getEmailAlertUsersStub, 'paid-started'); + sinon.assert.calledWith(getEmailAlertUsersStub, 'gift-subscriptions'); sinon.assert.calledOnce(mailStub); - sinon.assert.calledWith(mailStub, sinon.match.has('subject', sinon.match('🎁 Paid subscription started: Jamie'))); + sinon.assert.calledWith(mailStub, sinon.match.has('subject', sinon.match('🎁 Gift subscription redeemed: Jamie'))); }); it('includes the tier and cadence in HTML and plain text', async function () { diff --git a/ghost/core/test/unit/server/services/stats/content.test.js b/ghost/core/test/unit/server/services/stats/content.test.js index 833ab83aace..b9bc3fe4349 100644 --- a/ghost/core/test/unit/server/services/stats/content.test.js +++ b/ghost/core/test/unit/server/services/stats/content.test.js @@ -22,8 +22,10 @@ describe('ContentStatsService', function () { }; mockUrlService = { - getResource: sinon.stub(), - hasFinished: sinon.stub().returns(true) + facade: { + resolveUrl: sinon.stub().resolves(null), + hasFinished: sinon.stub().returns(true) + } }; // Create mock Tinybird client @@ -160,56 +162,54 @@ describe('ContentStatsService', function () { }); describe('getResourceTitle', function () { - it('returns null if urlService is not available', function () { + it('returns null if urlService is not available', async function () { // Create service without urlService const serviceNoUrl = new ContentStatsService({ knex: mockKnex, urlService: null }); - const result = serviceNoUrl.getResourceTitle('/about/'); + const result = await serviceNoUrl.getResourceTitle('/about/'); assert.equal(result, null); }); - it('returns title from resource with title property', function () { - mockUrlService.getResource.withArgs('/about/').returns({ - data: { - title: 'About Us', - type: 'page' - } + it('returns title from resource with title property', async function () { + mockUrlService.facade.resolveUrl.withArgs('/about/').resolves({ + title: 'About Us', + type: 'pages' }); - const result = service.getResourceTitle('/about/'); + const result = await service.getResourceTitle('/about/'); assertExists(result); assert.equal(result.title, 'About Us'); assert.equal(result.resourceType, 'page'); }); - it('returns name from resource with name property (tags, authors)', function () { - mockUrlService.getResource.withArgs('/tag/news/').returns({ - data: { - name: 'News', - type: 'tag' - } + it('returns name from resource with name property (tags, authors)', async function () { + mockUrlService.facade.resolveUrl.withArgs('/tag/news/').resolves({ + name: 'News', + type: 'tags' }); - const result = service.getResourceTitle('/tag/news/'); + const result = await service.getResourceTitle('/tag/news/'); assertExists(result); assert.equal(result.title, 'News'); - assert.equal(result.resourceType, 'tag'); + // Pre-migration tags/authors had no `data.type` column so this + // surfaced as undefined; keep that contract for API consumers. + assert.equal(result.resourceType, undefined); }); - it('returns null if resource lookup fails', function () { - mockUrlService.getResource.withArgs('/not-found/').throws(new Error('Resource not found')); + it('returns null if resource lookup fails', async function () { + mockUrlService.facade.resolveUrl.withArgs('/not-found/').rejects(new Error('Resource not found')); - const result = service.getResourceTitle('/not-found/'); + const result = await service.getResourceTitle('/not-found/'); assert.equal(result, null); }); - it('returns null if resource has no data or title/name', function () { - mockUrlService.getResource.withArgs('/empty/').returns({}); + it('returns null if resource has no title or name', async function () { + mockUrlService.facade.resolveUrl.withArgs('/empty/').resolves({type: 'posts'}); - const result = service.getResourceTitle('/empty/'); + const result = await service.getResourceTitle('/empty/'); assert.equal(result, null); }); }); @@ -238,8 +238,8 @@ describe('ContentStatsService', function () { ]; // Mock urlService to return resources for these paths - mockUrlService.getResource.withArgs('/post-1/').returns({data: {title: 'Post 1'}}); - mockUrlService.getResource.withArgs('/post-2/').returns({data: {title: 'Post 2'}}); + mockUrlService.facade.resolveUrl.withArgs('/post-1/').resolves({title: 'Post 1', type: 'posts'}); + mockUrlService.facade.resolveUrl.withArgs('/post-2/').resolves({title: 'Post 2', type: 'posts'}); const result = await service.enrichTopContentData(data); @@ -262,11 +262,9 @@ describe('ContentStatsService', function () { {pathname: '/about/', visits: 100} ]; - mockUrlService.getResource.withArgs('/about/').returns({ - data: { - title: 'About Us', - type: 'page' - } + mockUrlService.facade.resolveUrl.withArgs('/about/').resolves({ + title: 'About Us', + type: 'pages' }); const result = await service.enrichTopContentData(data); @@ -286,7 +284,7 @@ describe('ContentStatsService', function () { {pathname: '/unknown-page/', visits: 100} ]; - mockUrlService.getResource.withArgs('/unknown-page/').returns(null); + mockUrlService.facade.resolveUrl.withArgs('/unknown-page/').resolves(null); const result = await service.enrichTopContentData(data); @@ -304,7 +302,7 @@ describe('ContentStatsService', function () { {pathname: '/', visits: 100} ]; - mockUrlService.getResource.withArgs('/').returns(null); + mockUrlService.facade.resolveUrl.withArgs('/').resolves(null); const result = await service.enrichTopContentData(data); diff --git a/ghost/core/test/unit/server/services/stats/posts.test.js b/ghost/core/test/unit/server/services/stats/posts.test.js index 92311a106b4..5ec58386f70 100644 --- a/ghost/core/test/unit/server/services/stats/posts.test.js +++ b/ghost/core/test/unit/server/services/stats/posts.test.js @@ -316,10 +316,12 @@ describe('PostsStatsService', function () { // Mock urlService for URL existence checking const mockUrlService = { - hasFinished: () => true, - getResource: () => { - // Mock that all URLs exist for testing - return {data: {title: 'Mock Title'}}; + facade: { + hasFinished: () => true, + resolveUrl: async () => { + // Mock that all URLs exist for testing + return {title: 'Mock Title', type: 'posts'}; + } } }; @@ -364,6 +366,40 @@ describe('PostsStatsService', function () { assert.ok(service, 'Service instance should exist'); }); + // _enrichWithTitles assigns `url_exists` based on the urlService lookup. + // The fixtures above always return truthy, so the falsy branch was + // unreached. Pin both branches here so the future migration to a + // different lookup API has to keep them coherent. + describe('_enrichWithTitles url_exists', function () { + function svcWithUrlLookup(lookup) { + return new PostsStatsService({ + knex: db, + urlService: { + facade: { + hasFinished: () => true, + resolveUrl: async path => lookup(path) + } + } + }); + } + + it('flags url_exists: true when the URL resolves to a resource', async function () { + const svc = svcWithUrlLookup(() => ({type: 'posts', title: 'present'})); + const out = await svc._enrichWithTitles([ + {attribution_url: '/known/', title: 'Known', attribution_type: 'post', attribution_id: 'p', post_id: 'p'} + ]); + assert.equal(out[0].url_exists, true); + }); + + it('flags url_exists: false when the URL has no resource', async function () { + const svc = svcWithUrlLookup(() => null); + const out = await svc._enrichWithTitles([ + {attribution_url: '/missing/', title: 'Missing', attribution_type: 'post', attribution_id: 'p', post_id: 'p'} + ]); + assert.equal(out[0].url_exists, false); + }); + }); + describe('getTopPosts', function () { it('returns all posts with zero stats when no events exist', async function () { const result = await service.getTopPosts({}); diff --git a/ghost/core/test/unit/server/services/stats/subscriptions.test.js b/ghost/core/test/unit/server/services/stats/subscriptions.test.js index a647fc0e338..153cbf4c940 100644 --- a/ghost/core/test/unit/server/services/stats/subscriptions.test.js +++ b/ghost/core/test/unit/server/services/stats/subscriptions.test.js @@ -40,17 +40,6 @@ describe('SubscriptionStatsService', function () { table.string('stripe_price_id'); table.integer('mrr'); }); - await db.schema.createTable('gifts', function (table) { - table.string('id'); - table.string('tier_id'); - table.string('cadence'); - table.string('status'); - table.dateTime('redeemed_at'); - table.dateTime('consumes_at'); - table.dateTime('consumed_at'); - table.dateTime('expired_at'); - table.dateTime('refunded_at'); - }); }); afterEach(async function () { @@ -393,192 +382,5 @@ describe('SubscriptionStatsService', function () { assert.equal(days[1].beyond.yearly.signups, 1); assert.equal(days[1].beyond.yearly.cancellations, 0); }); - - it('Includes gift redemptions and end-of-life events alongside paid deltas', async function () { - const tiers = await createTiers(['basic']); - - // One paid monthly signup on day 1 - const NEW = createEvent('created'); - await insertEvents([ - [NEW('A', tiers.basic.monthly)] - ]); - - // Gifts covering each end-of-life branch: - // - g-month: redeemed day 1, still active - // - g-year-consumed: redeemed day 1, period ended day 2 (natural end via cron) - // - g-year-expired: redeemed day 1, expired day 3 - // - g-month-refunded: redeemed day 1, refunded day 4 - await db('gifts').insert([ - {id: 'g-month', tier_id: 'basic', cadence: 'month', status: 'redeemed', redeemed_at: '1970-01-01T00:00:00.000Z'}, - {id: 'g-year-consumed', tier_id: 'basic', cadence: 'year', status: 'consumed', redeemed_at: '1970-01-01T00:00:00.000Z', consumes_at: '1970-01-02T00:00:00.000Z', consumed_at: '1970-01-02T00:00:00.000Z'}, - {id: 'g-year-expired', tier_id: 'basic', cadence: 'year', status: 'expired', redeemed_at: '1970-01-01T00:00:00.000Z', expired_at: '1970-01-03T00:00:00.000Z'}, - {id: 'g-month-refunded', tier_id: 'basic', cadence: 'month', status: 'refunded', redeemed_at: '1970-01-01T00:00:00.000Z', refunded_at: '1970-01-04T00:00:00.000Z'} - ]); - - const stats = new SubscriptionStatsService({knex: db}); - const result = await stats.getSubscriptionHistory(); - - // Sum signups/cancellations across all rows for a given (tier, cadence, date). - const sumWhere = (tier, cadence, date, field) => result.data - .filter(r => r.tier === tier && r.cadence === cadence && r.date === date) - .reduce((acc, r) => acc + r[field], 0); - - // Day 1 monthly: 1 paid + 2 gift redemptions (g-month, g-month-refunded) = 3 signups - assert.equal(sumWhere('basic', 'month', '1970-01-01', 'signups'), 3); - // Day 1 yearly: 2 gift redemptions (g-year-consumed, g-year-expired) - assert.equal(sumWhere('basic', 'year', '1970-01-01', 'signups'), 2); - - // Each end-of-life branch produces a cancellation on its own date: - // - day 2: g-year-consumed - assert.equal(sumWhere('basic', 'year', '1970-01-02', 'cancellations'), 1); - // - day 3: g-year-expired - assert.equal(sumWhere('basic', 'year', '1970-01-03', 'cancellations'), 1); - // - day 4: g-month-refunded - assert.equal(sumWhere('basic', 'month', '1970-01-04', 'cancellations'), 1); - }); - - it('Includes active gifts in the current totals so rolled-back snapshots stay accurate', async function () { - await createTiers(['basic']); - - // Two active redeemed gifts, no paid subs. - await db('gifts').insert([ - {id: 'g1', tier_id: 'basic', cadence: 'year', status: 'redeemed', redeemed_at: '1970-01-01T00:00:00.000Z'}, - {id: 'g2', tier_id: 'basic', cadence: 'year', status: 'redeemed', redeemed_at: '1970-01-02T00:00:00.000Z'} - ]); - - const stats = new SubscriptionStatsService({knex: db}); - const result = await stats.getSubscriptionHistory(); - - // Without including gifts in totals, the baseline would be 0 and rolled-back - // counts would all be negative (clamped to 0), undercounting history. - const yearlyTotal = result.meta.totals.find(t => t.tier === 'basic' && t.cadence === 'year'); - assert(yearlyTotal); - assert.equal(yearlyTotal.count, 2); - }); - - it('Aggregates paid and gift deltas that share (date, tier, cadence) into a single row', async function () { - const tiers = await createTiers(['basic']); - - // Paid yearly signup on day 1 - const NEW = createEvent('created'); - await insertEvents([ - [NEW('A', tiers.basic.yearly)] - ]); - - // Gift yearly signup on the same day (basic tier) - await db('gifts').insert({ - id: 'g-same-day', - tier_id: 'basic', - cadence: 'year', - status: 'redeemed', - redeemed_at: '1970-01-01T00:00:00.000Z' - }); - - const stats = new SubscriptionStatsService({knex: db}); - const result = await stats.getSubscriptionHistory(); - - // There should be exactly one row for (basic, year, day 1) with combined deltas, - // not two — otherwise the row's `count` snapshot is ambiguous. - const rows = result.data.filter(r => r.tier === 'basic' && r.cadence === 'year' && r.date === '1970-01-01'); - assert.equal(rows.length, 1); - assert.equal(rows[0].signups, 2); - assert.equal(rows[0].positive_delta, 2); - }); - - it('Excludes consumed gifts where the member upgraded to paid (consumed_at < consumes_at) from cancellations', async function () { - const tiers = await createTiers(['basic']); - - // Two consumed gifts at the same tier/cadence on the same day: - // - g-upgrade: consumed BEFORE its planned end (consumed_at < consumes_at) → upgrade, NOT churn - // - g-natural: consumed AT/AFTER its planned end (consumed_at >= consumes_at) → real churn - await db('gifts').insert([ - {id: 'g-upgrade', tier_id: 'basic', cadence: 'month', status: 'consumed', redeemed_at: '1970-01-01T00:00:00.000Z', consumes_at: '1970-02-01T00:00:00.000Z', consumed_at: '1970-01-05T00:00:00.000Z'}, - {id: 'g-natural', tier_id: 'basic', cadence: 'month', status: 'consumed', redeemed_at: '1970-01-01T00:00:00.000Z', consumes_at: '1970-01-05T00:00:00.000Z', consumed_at: '1970-01-05T00:00:00.000Z'} - ]); - - // Add a paid signup so the service produces a non-empty result regardless. - const NEW = createEvent('created'); - await insertEvents([ - [NEW('A', tiers.basic.monthly)] - ]); - - const stats = new SubscriptionStatsService({knex: db}); - const result = await stats.getSubscriptionHistory(); - - // Day 5 monthly: only g-natural should count as a cancellation; g-upgrade is excluded. - const day5Cancellations = result.data - .filter(r => r.tier === 'basic' && r.cadence === 'month' && r.date === '1970-01-05') - .reduce((acc, r) => acc + r.cancellations, 0); - assert.equal(day5Cancellations, 1); - - // Both gifts still count as signups on day 1 (the upgrade was a real signup at the time). - const day1Signups = result.data - .filter(r => r.tier === 'basic' && r.cadence === 'month' && r.date === '1970-01-01') - .reduce((acc, r) => acc + r.signups, 0); - // 1 paid + 2 gift redemptions = 3 - assert.equal(day1Signups, 3); - }); - - it('Excludes unredeemed gifts (expired/refunded before redemption) from cancellations', async function () { - await createTiers(['basic']); - - // Both branches of "ended without ever being redeemed": - // - refunded before redemption - // - expired before redemption - // Neither produced a signup, so neither must produce a cancellation. - await db('gifts').insert([ - {id: 'g-refunded', tier_id: 'basic', cadence: 'year', status: 'refunded', redeemed_at: null, refunded_at: '1970-01-02T00:00:00.000Z'}, - {id: 'g-expired', tier_id: 'basic', cadence: 'month', status: 'expired', redeemed_at: null, expired_at: '1970-01-03T00:00:00.000Z'} - ]); - - const stats = new SubscriptionStatsService({knex: db}); - const result = await stats.getSubscriptionHistory(); - - // No rows should exist for either tier/cadence combination. - assert.equal(result.data.filter(r => r.tier === 'basic' && r.cadence === 'year').length, 0); - assert.equal(result.data.filter(r => r.tier === 'basic' && r.cadence === 'month').length, 0); - }); - - it('Sorts merged paid+gift deltas by date so running counts roll back correctly', async function () { - const tiers = await createTiers(['basic']); - - // Gift yearly redemption on day 1 (earliest event) - await db('gifts').insert({ - id: 'g-early', - tier_id: 'basic', - cadence: 'year', - status: 'redeemed', - redeemed_at: '1970-01-01T00:00:00.000Z' - }); - - // Paid yearly signup on day 3 (latest event). paidDeltas comes first - // in the concat, so without sorting the order would be: - // [paid@day3, gift@day1] - // Walking backwards: gift@day1 then paid@day3 — earlier-dated entry - // applied to the running count BEFORE the later one, corrupting snapshots. - const NEW = createEvent('created'); - await insertEvents([ - [], - [], - [NEW('A', tiers.basic.yearly)] - ]); - - const stats = new SubscriptionStatsService({knex: db}); - const result = await stats.getSubscriptionHistory(); - - // fetchSubscriptionCounts() returns 2 yearly basic subs (1 paid + 1 active gift). - // Walking backwards from countData=2 in ascending date order: - // day 3 (paid signup): emit count=2, then countData -= 1 → 1 - // day 1 (gift signup): emit count=1, then countData -= 1 → 0 - const day1Yearly = result.data.find(r => r.tier === 'basic' && r.cadence === 'year' && r.date === '1970-01-01'); - const day3Yearly = result.data.find(r => r.tier === 'basic' && r.cadence === 'year' && r.date === '1970-01-03'); - - assert(day1Yearly); - assert(day3Yearly); - // After both signups: 2 yearly subs total - assert.equal(day3Yearly.count, 2); - // After only the gift signup, before the paid signup: 1 yearly sub - assert.equal(day1Yearly.count, 1); - }); }); }); diff --git a/ghost/core/test/unit/server/services/url/url-service-facade.test.js b/ghost/core/test/unit/server/services/url/url-service-facade.test.js new file mode 100644 index 00000000000..91baf61ad13 --- /dev/null +++ b/ghost/core/test/unit/server/services/url/url-service-facade.test.js @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const UrlServiceFacade = require('../../../../../core/server/services/url/url-service-facade'); + +describe('UrlServiceFacade', function () { + let urlService; + let facade; + + beforeEach(function () { + urlService = { + getUrlByResourceId: sinon.stub().returns('/hello-world/'), + owns: sinon.stub().returns(true), + getResource: sinon.stub(), + getResourceById: sinon.stub(), + hasFinished: sinon.stub().returns(true), + onRouterAddedType: sinon.stub(), + onRouterUpdated: sinon.stub() + }; + facade = new UrlServiceFacade({urlService}); + }); + + describe('getUrlForResource', function () { + it('extracts the id from the resource and forwards options', function () { + const url = facade.getUrlForResource({id: 'abc', type: 'posts'}, {absolute: true}); + + sinon.assert.calledWith(urlService.getUrlByResourceId, 'abc', {absolute: true}); + assert.equal(url, '/hello-world/'); + }); + + it('forwards an undefined options argument unchanged', function () { + facade.getUrlForResource({id: 'abc'}); + + sinon.assert.calledWith(urlService.getUrlByResourceId, 'abc', undefined); + }); + }); + + describe('ownsResource', function () { + it('extracts the id from the resource', function () { + const owned = facade.ownsResource('collectionRouter', {id: 'abc'}); + + sinon.assert.calledWith(urlService.owns, 'collectionRouter', 'abc'); + assert.equal(owned, true); + }); + }); + + describe('resolveUrl', function () { + it('returns null when the underlying lookup misses', async function () { + urlService.getResource.returns(null); + + const result = await facade.resolveUrl('/missing/'); + + assert.equal(result, null); + }); + + it('flattens the legacy {config, data} envelope into a Resource', async function () { + urlService.getResource.returns({ + config: {type: 'posts'}, + data: {id: 'abc', slug: 'hello-world', title: 'Hello'} + }); + + const result = await facade.resolveUrl('/hello-world/'); + + assert.deepEqual(result, { + type: 'posts', + id: 'abc', + slug: 'hello-world', + title: 'Hello' + }); + }); + + it('returns a promise (the lazy implementation will be async)', function () { + urlService.getResource.returns(null); + const result = facade.resolveUrl('/x/'); + assert.ok(result instanceof Promise); + }); + }); + + describe('hasFinished', function () { + it('delegates to the underlying url service', function () { + urlService.hasFinished.returns(false); + assert.equal(facade.hasFinished(), false); + }); + }); + + describe('lifecycle pass-throughs', function () { + it('forwards onRouterAddedType', function () { + facade.onRouterAddedType('id', 'filter', 'posts', '/{slug}/'); + sinon.assert.calledWith(urlService.onRouterAddedType, 'id', 'filter', 'posts', '/{slug}/'); + }); + + it('forwards onRouterUpdated', function () { + facade.onRouterUpdated('id'); + sinon.assert.calledWith(urlService.onRouterUpdated, 'id'); + }); + }); +}); diff --git a/ghost/core/test/utils/url-service-utils.js b/ghost/core/test/utils/url-service-utils.js index 3884ad3df3b..647a34adc38 100644 --- a/ghost/core/test/utils/url-service-utils.js +++ b/ghost/core/test/utils/url-service-utils.js @@ -7,7 +7,7 @@ module.exports.isFinished = async () => { (function retry() { clearTimeout(timeout); - if (urlService.hasFinished()) { + if (urlService.facade.hasFinished()) { return resolve(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21bf22799e8..fbb169014e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1119,6 +1119,9 @@ importers: color: specifier: ^5.0.3 version: 5.0.3 + date-fns: + specifier: 4.1.0 + version: 4.1.0 lucide-react: specifier: 0.577.0 version: 0.577.0(react@18.3.1) @@ -1128,6 +1131,9 @@ importers: react: specifier: 18.3.1 version: 18.3.1 + react-day-picker: + specifier: 9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) @@ -1601,15 +1607,9 @@ importers: '@tryghost/limit-service': specifier: 1.5.2 version: 1.5.2 - '@tryghost/members-csv': - specifier: 2.0.5 - version: 2.0.5 '@tryghost/nql': specifier: 0.12.10 version: 0.12.10 - '@tryghost/nql-lang': - specifier: 0.6.4 - version: 0.6.4 '@tryghost/string': specifier: 0.3.2 version: 0.3.2 @@ -1739,9 +1739,6 @@ importers: ember-drag-drop: specifier: 0.4.8 version: 0.4.8(@babel/core@7.29.0) - ember-ella-sparse: - specifier: 0.16.0 - version: 0.16.0(@babel/core@7.29.0) ember-exam: specifier: 6.0.1 version: 6.0.1(ember-mocha@0.16.2(@babel/core@7.29.0)) @@ -1856,9 +1853,6 @@ importers: normalize.css: specifier: 3.0.3 version: 3.0.3 - papaparse: - specifier: 5.5.3 - version: 5.5.3 postcss-color-mod-function: specifier: 3.0.3 version: 3.0.3 @@ -2357,6 +2351,12 @@ importers: stripe: specifier: 8.222.0 version: 8.222.0 + superagent: + specifier: 5.3.1 + version: 5.3.1 + superagent-throttle: + specifier: 1.0.1 + version: 1.0.1 terser: specifier: 5.46.1 version: 5.46.1 @@ -3792,6 +3792,9 @@ packages: peerDependencies: postcss-selector-parser: ^6.0.10 + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@distributed-systems/callsite@1.1.1': resolution: {integrity: sha512-YSA3kWjClnLmFKNpdQCZlMQoWI4N6KpR/T4MaREEQczaehcagsVorT3YDV17KR6zuJXDs7f+kkSt1o/D6SufAQ==} @@ -8290,6 +8293,10 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + '@tailwindcss/line-clamp@0.4.4': resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==} peerDependencies: @@ -12414,10 +12421,16 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + date-format@4.0.14: resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} engines: {node: '>=4.0'} @@ -13121,10 +13134,6 @@ packages: peerDependencies: ember-source: ^3.8 || 4 - ember-ella-sparse@0.16.0: - resolution: {integrity: sha512-JLea/LYH3Juy0XzDCulmmpVXSOEiJIT6Pg4M3yXNVBQasU0eNj+ssO9Orpd7AHq5X4aFGWcKUnsVod3JFuMOfw==} - engines: {node: 8.* || >= 10.*} - ember-eslint-parser@0.5.13: resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==} engines: {node: '>=16.0.0'} @@ -14275,6 +14284,10 @@ packages: engines: {node: '>=18.3.0'} hasBin: true + formidable@1.2.6: + resolution: {integrity: sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==} + deprecated: 'Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau' + formidable@2.1.5: resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} @@ -19031,7 +19044,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.14.2: @@ -19133,6 +19145,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -20447,6 +20465,14 @@ packages: sum-up@1.0.3: resolution: {integrity: sha512-zw5P8gnhiqokJUWRdR6F4kIIIke0+ubQSGyYUY506GCbJWtV7F6Xuy0j6S125eSX2oF+a8KdivsZ8PlVEH0Mcw==} + superagent-throttle@1.0.1: + resolution: {integrity: sha512-m5Ngf0S5QoA84zgwVqVnVA34u9uvo8uM+QEF9B7eNI5FDaSoSoUwQsx7V1GmLXgYLkolhIiucFDVJXF9z49hgQ==} + + superagent@5.3.1: + resolution: {integrity: sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==} + engines: {node: '>= 7.0.0'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} engines: {node: '>=6.4.0 <13 || >=14'} @@ -21353,23 +21379,27 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -23998,6 +24028,8 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 + '@date-fns/tz@1.4.1': {} + '@distributed-systems/callsite@1.1.1': dependencies: ee-log: 3.0.9 @@ -29212,6 +29244,8 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tabby_ai/hijri-converter@1.0.5': {} + '@tailwindcss/line-clamp@0.4.4(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))': dependencies: tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) @@ -34877,10 +34911,14 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-jalali@4.1.0-0: {} + date-fns@2.30.0: dependencies: '@babel/runtime': 7.29.2 + date-fns@4.1.0: {} + date-format@4.0.14: {} date-time@2.1.0: @@ -36191,14 +36229,6 @@ snapshots: - '@glint/template' - supports-color - ember-ella-sparse@0.16.0(@babel/core@7.29.0): - dependencies: - ember-cli-babel: 7.26.11 - ember-concurrency: 1.3.0(@babel/core@7.29.0) - transitivePeerDependencies: - - '@babel/core' - - supports-color - ember-eslint-parser@0.5.13(@babel/core@7.29.0)(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 @@ -38119,6 +38149,8 @@ snapshots: dependencies: fd-package-json: 2.0.0 + formidable@1.2.6: {} + formidable@2.1.5: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -44199,6 +44231,14 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.4.1 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -45875,6 +45915,24 @@ snapshots: dependencies: chalk: 1.1.3 + superagent-throttle@1.0.1: {} + + superagent@5.3.1: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 3.0.4 + formidable: 1.2.6 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.2 + readable-stream: 3.6.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + superagent@8.1.2: dependencies: component-emitter: 1.3.1