mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #2700 from omnivore-app/fix/subscriptions-cleanup
Subscriptions cleanup
This commit is contained in:
commit
85d5361d89
6 changed files with 271 additions and 154 deletions
|
|
@ -31,6 +31,7 @@ import { Merge } from '../../util'
|
|||
import { analytics } from '../../utils/analytics'
|
||||
import { enqueueRssFeedFetch } from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { Brackets } from 'typeorm'
|
||||
|
||||
type PartialSubscription = Omit<Subscription, 'newsletterEmail'>
|
||||
|
||||
|
|
@ -76,20 +77,26 @@ export const subscriptionsResolver = authorized<
|
|||
user: { id: uid },
|
||||
})
|
||||
|
||||
// Show all RSS, but only active newsletters
|
||||
queryBuilder
|
||||
.where({
|
||||
type: SubscriptionType.Newsletter,
|
||||
status: SubscriptionStatus.Active,
|
||||
})
|
||||
.orWhere({
|
||||
type: SubscriptionType.Rss,
|
||||
})
|
||||
|
||||
if (type) {
|
||||
if (type && type == SubscriptionType.Newsletter) {
|
||||
queryBuilder.andWhere({
|
||||
type,
|
||||
status: SubscriptionStatus.Active,
|
||||
})
|
||||
} else if (type && type == SubscriptionType.Rss) {
|
||||
queryBuilder.where({
|
||||
type,
|
||||
})
|
||||
} else {
|
||||
queryBuilder.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where({
|
||||
type: SubscriptionType.Newsletter,
|
||||
status: SubscriptionStatus.Active,
|
||||
}).orWhere({
|
||||
type: SubscriptionType.Rss,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const subscriptions = await queryBuilder
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
SubscriptionStatus,
|
||||
SubscriptionType,
|
||||
} from '../../src/generated/graphql'
|
||||
import { UNSUBSCRIBE_EMAIL_TEXT } from '../../src/services/subscriptions'
|
||||
import {
|
||||
UNSUBSCRIBE_EMAIL_TEXT,
|
||||
unsubscribe,
|
||||
} from '../../src/services/subscriptions'
|
||||
import * as sendEmail from '../../src/utils/sendEmail'
|
||||
import { createTestSubscription, createTestUser, deleteTestUser } from '../db'
|
||||
import { graphqlRequest, request } from '../util'
|
||||
|
|
@ -134,44 +137,52 @@ describe('Subscriptions API', () => {
|
|||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
await createTestSubscription(
|
||||
user,
|
||||
'sub_6',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Newsletter
|
||||
)
|
||||
const allSubscriptions = [sub5, ...subscriptions]
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
allSubscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
try {
|
||||
await createTestSubscription(
|
||||
user,
|
||||
'sub_6',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Newsletter
|
||||
)
|
||||
const allSubscriptions = [sub5, ...subscriptions]
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
allSubscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
} finally {
|
||||
unsubscribe(sub5)
|
||||
}
|
||||
})
|
||||
|
||||
it('should not return other users subscriptions', async () => {
|
||||
// create test user and login
|
||||
const user2 = await createTestUser('fakeUser')
|
||||
await createTestSubscription(
|
||||
user2,
|
||||
'sub_other',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
subscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
const user2 = await createTestUser('fakeUser2')
|
||||
try {
|
||||
await createTestSubscription(
|
||||
user2,
|
||||
'sub_other',
|
||||
undefined,
|
||||
SubscriptionStatus.Unsubscribed,
|
||||
undefined,
|
||||
SubscriptionType.Rss
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.subscriptions.subscriptions).to.eql(
|
||||
subscriptions.map((sub) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
}))
|
||||
)
|
||||
} finally {
|
||||
deleteTestUser(user2.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('responds status code 400 when invalid query', async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ToggleCaretDownIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M6.57812 10.3379L12.8281 16.5879L19.0781 10.3379"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ToggleCaretLeftIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M15.9531 6.77344L9.70312 13.0234L15.9531 19.2734"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ToggleCaretRightIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M9.70313 19.5742L15.9531 13.3242L9.70312 7.07422"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { ReactNode, useMemo, useState } from 'react'
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { CaretRight, Circle, DotsThree, Plus } from 'phosphor-react'
|
||||
import { CaretRight, Circle } from 'phosphor-react'
|
||||
import { useGetSubscriptionsQuery } from '../../../lib/networking/queries/useGetSubscriptionsQuery'
|
||||
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
||||
|
|
@ -11,6 +10,11 @@ import { theme } from '../../tokens/stitches.config'
|
|||
import { useRegisterActions } from 'kbar'
|
||||
import { LogoBox } from '../../elements/LogoBox'
|
||||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
|
||||
import { ToggleCaretLeftIcon } from '../../elements/icons/ToggleCaretLeftIcon'
|
||||
import Link from 'next/link'
|
||||
import { ArrowRightIcon } from '../../elements/icons/ArrowRightIcon'
|
||||
import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon'
|
||||
|
||||
export const LIBRARY_LEFT_MENU_WIDTH = '233px'
|
||||
|
||||
|
|
@ -103,6 +107,10 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
|
|||
name: 'Unlabeled',
|
||||
term: 'no:label',
|
||||
},
|
||||
{
|
||||
name: 'Oldest First',
|
||||
term: 'sort:saved-desc',
|
||||
},
|
||||
{
|
||||
name: 'Files',
|
||||
term: 'type:file',
|
||||
|
|
@ -130,16 +138,26 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
|
|||
[]
|
||||
)
|
||||
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>({
|
||||
key: `--saved-searches-collapsed`,
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuPanel title="Saved Searches">
|
||||
{items.map((item) => (
|
||||
<FilterButton
|
||||
key={item.name}
|
||||
text={item.name}
|
||||
filterTerm={item.term}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
<MenuPanel
|
||||
title="Saved Searches"
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
{!collapsed &&
|
||||
items.map((item) => (
|
||||
<FilterButton
|
||||
key={item.name}
|
||||
text={item.name}
|
||||
filterTerm={item.term}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box css={{ height: '10px' }}></Box>
|
||||
</MenuPanel>
|
||||
|
|
@ -148,8 +166,8 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
|
|||
|
||||
function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
|
||||
const { subscriptions } = useGetSubscriptionsQuery()
|
||||
const [viewAll, setViewAll] = usePersistedState<boolean>({
|
||||
key: `--subscriptions-view-all`,
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>({
|
||||
key: `--subscriptions-collapsed`,
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
|
|
@ -173,15 +191,10 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
|
|||
return (
|
||||
<MenuPanel
|
||||
title="Subscriptions"
|
||||
editTitle="Edit Subscriptions"
|
||||
editFunc={() => {
|
||||
window.location.href = '/settings/subscriptions'
|
||||
}}
|
||||
viewAll={() => {
|
||||
setViewAll(true)
|
||||
}}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
{viewAll ? (
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<FilterButton filterTerm={`label:RSS`} text="Feeds" {...props} />
|
||||
<FilterButton
|
||||
|
|
@ -199,7 +212,10 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
|
|||
/>
|
||||
)
|
||||
})}
|
||||
<ViewAllButton state={viewAll} setState={setViewAll} />
|
||||
<EditButton
|
||||
title="Edit Subscriptions"
|
||||
destination="/settings/subscriptions"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SpanBox css={{ mb: '10px' }} />
|
||||
|
|
@ -210,8 +226,8 @@ function Subscriptions(props: LibraryFilterMenuProps): JSX.Element {
|
|||
|
||||
function Labels(props: LibraryFilterMenuProps): JSX.Element {
|
||||
const { labels } = useGetLabelsQuery()
|
||||
const [viewAll, setViewAll] = usePersistedState<boolean>({
|
||||
key: `--labels-view-all`,
|
||||
const [collapsed, setCollapsed] = usePersistedState<boolean>({
|
||||
key: `--labels-collapsed`,
|
||||
initialValue: false,
|
||||
})
|
||||
|
||||
|
|
@ -224,16 +240,18 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
|
|||
return (
|
||||
<MenuPanel
|
||||
title="Labels"
|
||||
editTitle="Edit Labels"
|
||||
hideBottomBorder={true}
|
||||
editFunc={() => {
|
||||
window.location.href = '/settings/labels'
|
||||
}}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
>
|
||||
{sortedLabels.slice(0, viewAll ? undefined : 4).map((item) => {
|
||||
return <LabelButton key={item.id} label={item} {...props} />
|
||||
})}
|
||||
<ViewAllButton state={viewAll} setState={setViewAll} />
|
||||
{!collapsed && (
|
||||
<>
|
||||
{sortedLabels.map((item) => {
|
||||
return <LabelButton key={item.id} label={item} {...props} />
|
||||
})}
|
||||
<EditButton title="Edit Labels" destination="/settings/labels" />
|
||||
</>
|
||||
)}
|
||||
</MenuPanel>
|
||||
)
|
||||
}
|
||||
|
|
@ -241,10 +259,11 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
|
|||
type MenuPanelProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
editFunc?: () => void
|
||||
editTitle?: string
|
||||
|
||||
hideBottomBorder?: boolean
|
||||
viewAll?: () => void
|
||||
|
||||
collapsed: boolean
|
||||
setCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
function MenuPanel(props: MenuPanelProps): JSX.Element {
|
||||
|
|
@ -261,7 +280,7 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
|
|||
alignment="start"
|
||||
distribution="start"
|
||||
>
|
||||
<HStack css={{ width: '100%' }} distribution="start" alignment="start">
|
||||
<HStack css={{ width: '100%' }} distribution="start" alignment="center">
|
||||
<StyledText
|
||||
css={{
|
||||
fontFamily: 'Inter',
|
||||
|
|
@ -278,57 +297,32 @@ function MenuPanel(props: MenuPanelProps): JSX.Element {
|
|||
</StyledText>
|
||||
<SpanBox
|
||||
css={{
|
||||
mt: '15px',
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
mt: '10px',
|
||||
marginLeft: 'auto',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{props.editTitle && props.editFunc && (
|
||||
<Dropdown
|
||||
triggerElement={
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
height: '30px',
|
||||
width: '30px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '1000px',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bg: '$thBackground4',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DotsThree
|
||||
size={25}
|
||||
weight="bold"
|
||||
color={theme.colors.thTextSubtle2.toString()}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{props.viewAll && (
|
||||
<DropdownOption
|
||||
title="View All"
|
||||
onSelect={() => {
|
||||
if (props.viewAll) {
|
||||
props.viewAll()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DropdownOption
|
||||
title={props.editTitle}
|
||||
onSelect={() => {
|
||||
if (props.editFunc) {
|
||||
props.editFunc()
|
||||
}
|
||||
}}
|
||||
<Button
|
||||
style="articleActionIcon"
|
||||
onClick={(event) => {
|
||||
props.setCollapsed(!props.collapsed)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.collapsed ? (
|
||||
<ToggleCaretRightIcon
|
||||
size={15}
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
) : (
|
||||
<ToggleCaretDownIcon
|
||||
size={15}
|
||||
color={theme.colors.thLibraryMenuPrimary.toString()}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</SpanBox>
|
||||
</HStack>
|
||||
{props.children}
|
||||
|
|
@ -492,34 +486,43 @@ function LabelButton(props: LabelButtonProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
type ViewAllButtonProps = {
|
||||
state: boolean
|
||||
setState: (state: boolean) => void
|
||||
type EditButtonProps = {
|
||||
title: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
function ViewAllButton(props: ViewAllButtonProps): JSX.Element {
|
||||
function EditButton(props: EditButtonProps): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
style="ghost"
|
||||
css={{
|
||||
display: 'flex',
|
||||
pl: '10px',
|
||||
color: '#898989',
|
||||
fontWeight: '600',
|
||||
fontSize: '12px',
|
||||
py: '20px',
|
||||
gap: '2px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
props.setState(!props.state)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.state ? 'Hide' : 'View All'}
|
||||
{props.state ? null : (
|
||||
<CaretRight size={12} color="#898989" weight="bold" />
|
||||
)}
|
||||
</Button>
|
||||
<Link href={props.destination} passHref>
|
||||
<SpanBox
|
||||
css={{
|
||||
ml: '10px',
|
||||
mb: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '32px',
|
||||
|
||||
fontSize: '14px',
|
||||
fontWeight: 'regular',
|
||||
fontFamily: '$display',
|
||||
color: '$thLibraryMenuUnselected',
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</SpanBox>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue