Merge pull request #2707 from Podginator/feat/saved-links

Add Saved Searches,  the ability to add new searches, delete searches, and reorder searches.
This commit is contained in:
Jackson Harper
2023-09-07 17:27:48 +08:00
committed by GitHub
20 changed files with 1646 additions and 106 deletions

View File

@ -23,13 +23,13 @@ export class Filter {
@Column('varchar', { length: 255 })
name!: string
@Column('varchar', { length: 255, nullable: true })
@Column('varchar', { length: 255, nullable: true, default: null })
description?: string | null
@Column('varchar', { length: 255 })
filter!: string
@Column('varchar', { length: 255 })
@Column('varchar', { length: 255, default: 'Search' })
category!: string
@Column('integer', { default: 0 })
@ -40,4 +40,10 @@ export class Filter {
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('boolean', { default: false })
defaultFilter!: boolean
@Column('boolean', { default: true })
visible!: boolean
}

View File

@ -765,12 +765,14 @@ export type Filter = {
__typename?: 'Filter';
category: Scalars['String'];
createdAt: Scalars['Date'];
defaultFilter?: Maybe<Scalars['Boolean']>;
description?: Maybe<Scalars['String']>;
filter: Scalars['String'];
id: Scalars['ID'];
name: Scalars['String'];
position: Scalars['Int'];
updatedAt: Scalars['Date'];
visible?: Maybe<Scalars['Boolean']>;
};
export type FiltersError = {
@ -1289,6 +1291,7 @@ export type Mutation = {
setWebhook: SetWebhookResult;
subscribe: SubscribeResult;
unsubscribe: UnsubscribeResult;
updateFilter: UpdateFilterResult;
updateHighlight: UpdateHighlightResult;
updateHighlightReply: UpdateHighlightReplyResult;
updateLabel: UpdateLabelResult;
@ -1589,6 +1592,11 @@ export type MutationUnsubscribeArgs = {
};
export type MutationUpdateFilterArgs = {
input: UpdateFilterInput;
};
export type MutationUpdateHighlightArgs = {
input: UpdateHighlightInput;
};
@ -2248,11 +2256,11 @@ export enum SaveFilterErrorCode {
}
export type SaveFilterInput = {
category: Scalars['String'];
category?: InputMaybe<Scalars['String']>;
description?: InputMaybe<Scalars['String']>;
filter: Scalars['String'];
id?: InputMaybe<Scalars['ID']>;
name: Scalars['String'];
position?: InputMaybe<Scalars['Int']>;
};
export type SaveFilterResult = SaveFilterError | SaveFilterSuccess;
@ -2837,6 +2845,34 @@ export type UnsubscribeSuccess = {
subscription: Subscription;
};
export type UpdateFilterError = {
__typename?: 'UpdateFilterError';
errorCodes: Array<UpdateFilterErrorCode>;
};
export enum UpdateFilterErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type UpdateFilterInput = {
category?: InputMaybe<Scalars['String']>;
description?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<Scalars['String']>;
id: Scalars['String'];
name?: InputMaybe<Scalars['String']>;
position?: InputMaybe<Scalars['Int']>;
visible?: InputMaybe<Scalars['Boolean']>;
};
export type UpdateFilterResult = UpdateFilterError | UpdateFilterSuccess;
export type UpdateFilterSuccess = {
__typename?: 'UpdateFilterSuccess';
filter: Filter;
};
export type UpdateHighlightError = {
__typename?: 'UpdateHighlightError';
errorCodes: Array<UpdateHighlightErrorCode>;
@ -3777,6 +3813,11 @@ export type ResolversTypes = {
UnsubscribeErrorCode: UnsubscribeErrorCode;
UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess'];
UnsubscribeSuccess: ResolverTypeWrapper<UnsubscribeSuccess>;
UpdateFilterError: ResolverTypeWrapper<UpdateFilterError>;
UpdateFilterErrorCode: UpdateFilterErrorCode;
UpdateFilterInput: UpdateFilterInput;
UpdateFilterResult: ResolversTypes['UpdateFilterError'] | ResolversTypes['UpdateFilterSuccess'];
UpdateFilterSuccess: ResolverTypeWrapper<UpdateFilterSuccess>;
UpdateHighlightError: ResolverTypeWrapper<UpdateHighlightError>;
UpdateHighlightErrorCode: UpdateHighlightErrorCode;
UpdateHighlightInput: UpdateHighlightInput;
@ -4185,6 +4226,10 @@ export type ResolversParentTypes = {
UnsubscribeError: UnsubscribeError;
UnsubscribeResult: ResolversParentTypes['UnsubscribeError'] | ResolversParentTypes['UnsubscribeSuccess'];
UnsubscribeSuccess: UnsubscribeSuccess;
UpdateFilterError: UpdateFilterError;
UpdateFilterInput: UpdateFilterInput;
UpdateFilterResult: ResolversParentTypes['UpdateFilterError'] | ResolversParentTypes['UpdateFilterSuccess'];
UpdateFilterSuccess: UpdateFilterSuccess;
UpdateHighlightError: UpdateHighlightError;
UpdateHighlightInput: UpdateHighlightInput;
UpdateHighlightReplyError: UpdateHighlightReplyError;
@ -4793,12 +4838,14 @@ export type FeedArticlesSuccessResolvers<ContextType = ResolverContext, ParentTy
export type FilterResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Filter'] = ResolversParentTypes['Filter']> = {
category?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
defaultFilter?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
filter?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
position?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
visible?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -5200,6 +5247,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'input'>>;
unsubscribe?: Resolver<ResolversTypes['UnsubscribeResult'], ParentType, ContextType, RequireFields<MutationUnsubscribeArgs, 'name'>>;
updateFilter?: Resolver<ResolversTypes['UpdateFilterResult'], ParentType, ContextType, RequireFields<MutationUpdateFilterArgs, 'input'>>;
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
updateLabel?: Resolver<ResolversTypes['UpdateLabelResult'], ParentType, ContextType, RequireFields<MutationUpdateLabelArgs, 'input'>>;
@ -5907,6 +5955,20 @@ export type UnsubscribeSuccessResolvers<ContextType = ResolverContext, ParentTyp
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateFilterErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateFilterError'] = ResolversParentTypes['UpdateFilterError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateFilterErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateFilterResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateFilterResult'] = ResolversParentTypes['UpdateFilterResult']> = {
__resolveType: TypeResolveFn<'UpdateFilterError' | 'UpdateFilterSuccess', ParentType, ContextType>;
};
export type UpdateFilterSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateFilterSuccess'] = ResolversParentTypes['UpdateFilterSuccess']> = {
filter?: Resolver<ResolversTypes['Filter'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateHighlightErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateHighlightError'] = ResolversParentTypes['UpdateHighlightError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateHighlightErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -6466,6 +6528,9 @@ export type Resolvers<ContextType = ResolverContext> = {
UnsubscribeError?: UnsubscribeErrorResolvers<ContextType>;
UnsubscribeResult?: UnsubscribeResultResolvers<ContextType>;
UnsubscribeSuccess?: UnsubscribeSuccessResolvers<ContextType>;
UpdateFilterError?: UpdateFilterErrorResolvers<ContextType>;
UpdateFilterResult?: UpdateFilterResultResolvers<ContextType>;
UpdateFilterSuccess?: UpdateFilterSuccessResolvers<ContextType>;
UpdateHighlightError?: UpdateHighlightErrorResolvers<ContextType>;
UpdateHighlightReplyError?: UpdateHighlightReplyErrorResolvers<ContextType>;
UpdateHighlightReplyResult?: UpdateHighlightReplyResultResolvers<ContextType>;

View File

@ -677,12 +677,14 @@ type FeedArticlesSuccess {
type Filter {
category: String!
createdAt: Date!
defaultFilter: Boolean
description: String
filter: String!
id: ID!
name: String!
position: Int!
updatedAt: Date!
visible: Boolean
}
type FiltersError {
@ -1157,6 +1159,7 @@ type Mutation {
setWebhook(input: SetWebhookInput!): SetWebhookResult!
subscribe(input: SubscribeInput!): SubscribeResult!
unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult!
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
@ -1629,11 +1632,11 @@ enum SaveFilterErrorCode {
}
input SaveFilterInput {
category: String!
category: String
description: String
filter: String!
id: ID
name: String!
position: Int
}
union SaveFilterResult = SaveFilterError | SaveFilterSuccess
@ -2174,6 +2177,32 @@ type UnsubscribeSuccess {
subscription: Subscription!
}
type UpdateFilterError {
errorCodes: [UpdateFilterErrorCode!]!
}
enum UpdateFilterErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input UpdateFilterInput {
category: String
description: String
filter: String
id: String!
name: String
position: Int
visible: Boolean
}
union UpdateFilterResult = UpdateFilterError | UpdateFilterSuccess
type UpdateFilterSuccess {
filter: Filter!
}
type UpdateHighlightError {
errorCodes: [UpdateHighlightErrorCode!]!
}

View File

@ -12,9 +12,13 @@ import {
MutationDeleteFilterArgs,
MutationMoveFilterArgs,
MutationSaveFilterArgs,
MutationUpdateFilterArgs,
SaveFilterError,
SaveFilterErrorCode,
SaveFilterSuccess,
UpdateFilterError,
UpdateFilterSuccess,
UpdateFilterErrorCode,
} from '../../generated/graphql'
import { Filter } from '../../entity/filter'
import { getRepository, setClaims } from '../../entity/utils'
@ -23,23 +27,24 @@ import { AppDataSource } from '../../server'
import { Between } from 'typeorm'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { isNil, mergeWith } from 'lodash'
export const saveFilterResolver = authorized<
SaveFilterSuccess,
SaveFilterError,
MutationSaveFilterArgs
>(async (_, { input }, { claims, log }) => {
>(async (_, { input }, { claims: { uid }, log }) => {
log.info('Saving filters', {
input,
labels: {
source: 'resolver',
resolver: 'saveFilterResolver',
uid: claims.uid,
uid,
},
})
try {
const user = await getRepository(User).findOneBy({ id: claims.uid })
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SaveFilterErrorCode.Unauthorized],
@ -47,9 +52,14 @@ export const saveFilterResolver = authorized<
}
const filter = await getRepository(Filter).save({
...input,
id: input.id ?? undefined,
user: { id: claims.uid },
user: { id: uid },
name: input.name,
category: 'Search',
description: '',
position: input.position ?? 0,
filter: input.filter,
defaultFilter: false,
visible: true,
})
return {
@ -61,7 +71,7 @@ export const saveFilterResolver = authorized<
labels: {
source: 'resolver',
resolver: 'saveFilterResolver',
uid: claims.uid,
uid,
},
})
@ -166,6 +176,113 @@ export const filtersResolver = authorized<FiltersSuccess, FiltersError>(
}
}
)
const updatePosition = async (
uid: string,
filter: Filter,
newPosition: number
) => {
const { position } = filter
const moveUp = newPosition < position
// move filter to the new position
const updated = await AppDataSource.transaction(async (t) => {
await setClaims(t, uid)
// update the position of the other filters
const updated = await t.getRepository(Filter).update(
{
user: { id: uid },
position: Between(
Math.min(newPosition, position),
Math.max(newPosition, position)
),
},
{
position: () => `position + ${moveUp ? 1 : -1}`,
}
)
if (!updated.affected) {
return null
}
// update the position of the filter
return t.getRepository(Filter).save({
...filter,
position: newPosition,
})
})
if (!updated) {
throw new Error('unable to update')
}
return updated
}
export const updateFilterResolver = authorized<
UpdateFilterSuccess,
UpdateFilterError,
MutationUpdateFilterArgs
>(async (_, { input }, { claims: { uid }, log }) => {
const repo = getRepository(Filter)
const { id } = input
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [UpdateFilterErrorCode.Unauthorized],
}
}
const filter = await getRepository(Filter).findOne({
where: { id, user: { id: uid } },
relations: ['user'],
})
if (!filter) {
return {
__typename: 'UpdateFilterError',
errorCodes: [UpdateFilterErrorCode.NotFound],
}
}
if (filter.user.id !== uid) {
return {
__typename: 'UpdateFilterError',
errorCodes: [UpdateFilterErrorCode.Unauthorized],
}
}
if (!isNil(input.position) && filter.position != input.position) {
await updatePosition(uid, filter, input.position)
}
const updated = await repo.save({
...mergeWith({}, filter, input, (a: unknown, b: unknown) =>
isNil(b) ? a : undefined
),
})
return {
__typename: 'UpdateFilterSuccess',
filter: updated,
}
} catch (error) {
log.error('Error Updating filters', {
error,
labels: {
source: 'resolver',
resolver: 'UpdateFilterResolver',
uid,
},
})
return {
__typename: 'UpdateFilterError',
errorCodes: [UpdateFilterErrorCode.BadRequest],
}
}
})
export const moveFilterResolver = authorized<
MoveFilterSuccess,
@ -211,9 +328,8 @@ export const moveFilterResolver = authorized<
return { filter }
}
const oldPosition = filter.position
// if afterFilterId is not provided, move to the top
let newPosition = 1
let newPosition = 0
if (afterFilterId) {
const afterFilter = await getRepository(Filter).findOne({
where: { id: afterFilterId },
@ -231,35 +347,7 @@ export const moveFilterResolver = authorized<
}
newPosition = afterFilter.position
}
const moveUp = newPosition < oldPosition
// move filter to the new position
const updated = await AppDataSource.transaction(async (t) => {
await setClaims(t, uid)
// update the position of the other filters
const updated = await t.getRepository(Filter).update(
{
user: { id: uid },
position: Between(
Math.min(newPosition, oldPosition),
Math.max(newPosition, oldPosition)
),
},
{
position: () => `position + ${moveUp ? 1 : -1}`,
}
)
if (!updated.affected) {
return null
}
// update the position of the filter
return t.getRepository(Filter).save({
...filter,
position: newPosition,
})
})
const updated = await updatePosition(uid, filter, newPosition)
if (!updated) {
return {

View File

@ -41,6 +41,7 @@ import {
createReminderResolver,
deleteAccountResolver,
deleteFilterResolver,
updateFilterResolver,
deleteHighlightResolver,
deleteIntegrationResolver,
deleteLabelResolver,
@ -210,6 +211,7 @@ export const functionResolvers = {
importFromIntegration: importFromIntegrationResolver,
setFavoriteArticle: setFavoriteArticleResolver,
updateSubscription: updateSubscriptionResolver,
updateFilter: updateFilterResolver,
},
Query: {
me: getMeUserResolver,
@ -391,8 +393,7 @@ export const functionResolvers = {
return undefined
}
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
const url = await generateDownloadSignedUrl(filePath)
return url
return generateDownloadSignedUrl(filePath)
}
return article.url
},

View File

@ -2165,11 +2165,11 @@ const schema = gql`
}
input SaveFilterInput {
id: ID
name: String!
filter: String!
category: String!
category: String
description: String
position: Int
}
union SaveFilterResult = SaveFilterSuccess | SaveFilterError
@ -2187,6 +2187,8 @@ const schema = gql`
description: String
createdAt: Date!
updatedAt: Date!
defaultFilter: Boolean
visible: Boolean
}
type SaveFilterError {
@ -2230,6 +2232,32 @@ const schema = gql`
NOT_FOUND
}
input UpdateFilterInput {
id: String!
name: String
filter: String
position: Int
category: String
description: String
visible: Boolean
}
enum UpdateFilterErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
}
union UpdateFilterResult = UpdateFilterSuccess | UpdateFilterError
type UpdateFilterSuccess {
filter: Filter!
}
type UpdateFilterError {
errorCodes: [UpdateFilterErrorCode!]!
}
input MoveFilterInput {
filterId: ID!
afterFilterId: ID # null to move to the top
@ -2616,6 +2644,7 @@ const schema = gql`
saveFilter(input: SaveFilterInput!): SaveFilterResult!
deleteFilter(id: ID!): DeleteFilterResult!
moveFilter(input: MoveFilterInput!): MoveFilterResult!
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
createGroup(input: CreateGroupInput!): CreateGroupResult!
recommend(input: RecommendInput!): RecommendResult!
joinGroup(inviteCode: String!): JoinGroupResult!

View File

@ -11,6 +11,7 @@ import { AppDataSource } from '../server'
import { logger } from '../utils/logger'
import { validateUsername } from '../utils/usernamePolicy'
import { sendConfirmationEmail } from './send_emails'
import { Filter } from '../entity/filter'
export const createUser = async (input: {
provider: AuthProvider
@ -85,6 +86,9 @@ export const createUser = async (input: {
group: invite.group,
})
}
await createDefaultFiltersForUser(t)(user.id)
return [user, profile]
}
)
@ -98,6 +102,29 @@ export const createUser = async (input: {
return [user, profile]
}
const createDefaultFiltersForUser =
(t: EntityManager) =>
async (userId: string): Promise<Filter[]> => {
const defaultFilters = [
{ name: 'Inbox', filter: 'in:inbox' },
{ name: 'Continue Reading', filter: 'in:inbox sort:read-desc is:unread' },
{ name: 'Non-Feed Items', filter: 'in:library' },
{ name: 'Highlights', filter: 'has:highlights mode:highlights' },
{ name: 'Unlabeled', filter: 'no:label' },
{ name: 'Oldest First', filter: 'sort:saved-asc' },
{ name: 'Files', filter: 'type:file' },
{ name: 'Archived', filter: 'in:archive' },
].map((it, position) => ({
...it,
user: { id: userId },
position,
defaultFilter: true,
category: 'Search',
}))
return t.getRepository(Filter).save(defaultFilters)
}
// TODO: Maybe this should be moved into a service
const validateInvite = async (
entityManager: EntityManager,

View File

@ -15,6 +15,7 @@ import { getRepository, setClaims } from '../src/entity/utils'
import { SubscriptionStatus, SubscriptionType } from '../src/generated/graphql'
import { AppDataSource } from '../src/server'
import { createUser } from '../src/services/create_user'
import { Filter } from "../src/entity/filter"
const runMigrations = async () => {
const migrationDirectory = __dirname + '/../../db/migrations'
@ -70,6 +71,19 @@ export const deleteTestUser = async (userId: string) => {
})
}
export const deleteFiltersFromUser = async (userId: string) => {
await AppDataSource.transaction(async (t) => {
await setClaims(t, userId)
const filterRepo = t.getRepository(Filter);
const userFilters = await filterRepo.findBy({ user: { id: userId }})
await Promise.all(userFilters.map(filter => {
return filterRepo.delete(filter.id)
}));
})
}
export const createTestUser = async (
name: string,
invite?: string | undefined,

View File

@ -2,10 +2,10 @@ import 'mocha'
import chai, { expect } from 'chai'
import {
createTestUser,
createUserWithoutProfile,
createUserWithoutProfile, deleteFiltersFromUser,
deleteTestUser,
getProfile,
} from '../db'
getProfile
} from "../db"
import { createGroup } from '../../src/services/groups'
import {
getUserFollowers,
@ -18,10 +18,29 @@ import * as util from '../../src/utils/sendEmail'
import { MailDataRequired } from '@sendgrid/helpers/classes/mail'
import { User } from '../../src/entity/user'
import { getRepository } from '../../src/entity/utils'
import { Filter } from "../../src/entity/filter"
chai.use(sinonChai)
describe('create user', () => {
context('creates a user through manual sign up', () => {
it ('adds the default filters to the user', async () => {
after(async () => {
const testUser = await getRepository(User).findOneBy({
name: 'filter_user',
})
await deleteTestUser(testUser!.id)
await deleteFiltersFromUser(testUser!.id)
})
const user = await createTestUser('filter_user');
const filters = await getRepository(Filter).findBy({ user: { id: user.id }})
expect(filters).not.to.be.empty
})
})
context('create a user with an invite', () => {
it('follows the other user in the group', async () => {
after(async () => {

View File

@ -0,0 +1,69 @@
-- Type: DO
-- Name: add_defaults_to_filters
-- Description: Add Defaults to Filters
BEGIN;
ALTER TABLE omnivore.filters
ADD COLUMN default_filter boolean NOT NULL DEFAULT false,
ADD COLUMN visible boolean NOT NULL DEFAULT true;
CREATE OR REPLACE FUNCTION update_filter_position()
RETURNS TRIGGER AS $$
DECLARE
new_position INTEGER;
BEGIN
IF (TG_OP = 'DELETE') THEN
UPDATE omnivore.filters SET position = position - 1 WHERE user_id = OLD.user_id AND position > OLD.position;
RETURN OLD;
ELSIF (TG_OP = 'INSERT' and NEW.position is null) THEN
SELECT COALESCE(MAX(position), 0) + 1 INTO new_position FROM omnivore.filters WHERE user_id = NEW.user_id AND name < NEW.name;
UPDATE omnivore.filters SET position = position + 1 WHERE user_id = NEW.user_id AND position >= new_position;
NEW.position = new_position;
RETURN NEW;
END IF;
RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search', 'Inbox', 'in:inbox', 0, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search','Continue Reading', 'in:inbox sort:read-desc is:unread', 1, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search', 'Non-Feed Items', 'in:library', 2, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search', 'Highlights', 'has:highlights mode:highlights', 3, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search','Unlabeled', 'no:label', 4, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search','Oldest First', 'sort:saved-asc', 5, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search','Files', 'type:file', 6, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
INSERT INTO omnivore.filters (user_id, category, name, filter, position, default_filter)
SELECT id, 'Search', 'Archived', 'in:archive', 7, true
FROM omnivore.user
ON CONFLICT DO NOTHING;
COMMIT;

View File

@ -0,0 +1,10 @@
-- Type: UNDO
-- Name: add_defaults_to_filters
-- Description: Add Defaults to Filters
BEGIN;
ALTER TABLE omnivore.filters
DROP COLUMN default,
DROP COLUMN visible;
COMMIT;

View File

@ -11,7 +11,6 @@ import {
DropdownSeparator,
} from '../elements/DropdownElements'
import GridLayoutIcon from '../elements/images/GridLayoutIcon'
import ListLayoutIcon from '../elements/images/ListLayoutIcon'
import { Box, HStack, VStack } from '../elements/LayoutPrimitives'
import { StyledText } from '../elements/StyledText'
import { styled, theme, ThemeId } from '../tokens/stitches.config'
@ -37,6 +36,9 @@ export type HeaderDropdownAction =
| 'navigate-to-subscriptions'
| 'navigate-to-api'
| 'navigate-to-integrations'
| 'navigate-to-saved-searches'
| 'increaseFontSize'
| 'decreaseFontSize'
| 'logout'
export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
@ -67,6 +69,9 @@ export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
case 'navigate-to-integrations':
router.push('/settings/integrations')
break
case 'navigate-to-saved-searches':
router.push('/settings/saved-searches')
break;
case 'logout':
document.dispatchEvent(new Event('logout'))
break

View File

@ -10,6 +10,8 @@ import { theme } from '../../tokens/stitches.config'
import { useRegisterActions } from 'kbar'
import { LogoBox } from '../../elements/LogoBox'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { useGetSavedSearchQuery} from '../../../lib/networking/queries/useGetSavedSearchQuery'
import { SavedSearch } from "../../../lib/networking/fragments/savedSearchFragment"
import { ToggleCaretDownIcon } from '../../elements/icons/ToggleCaretDownIcon'
import Link from 'next/link'
import { ToggleCaretRightIcon } from '../../elements/icons/ToggleCaretRightIcon'
@ -84,43 +86,16 @@ export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element {
}
function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
const items = [
{
name: 'Inbox',
term: 'in:inbox',
},
{
name: 'Continue Reading',
term: 'in:inbox sort:read-desc is:unread',
},
{
name: 'Non-Feed Items',
term: 'in:library',
},
{
name: 'Highlights',
term: 'has:highlights mode:highlights',
},
{
name: 'Unlabeled',
term: 'no:label',
},
{
name: 'Oldest First',
term: 'sort:saved-asc',
},
{
name: 'Files',
term: 'type:file',
},
{
name: 'Archived',
term: 'in:archive',
},
]
const { savedSearches, isLoading } = useGetSavedSearchQuery();
const sortedSearches = useMemo(() => {
return savedSearches?.filter(it => it.visible)?.sort((left: SavedSearch, right: SavedSearch) =>
left.position - right.position
)
}, [savedSearches])
useRegisterActions(
items.map((item, idx) => {
(sortedSearches ?? []).map((item, idx) => {
const key = String(idx + 1)
return {
id: `saved_search_${key}`,
@ -129,11 +104,11 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
section: 'Saved Searches',
keywords: '?' + item.name,
perform: () => {
props.applySearchQuery(item.term)
props.applySearchQuery(item.filter)
},
}
}),
[]
[isLoading]
)
const [collapsed, setCollapsed] = usePersistedState<boolean>({
@ -142,20 +117,23 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element {
})
return (
<MenuPanel
title="Saved Searches"
collapsed={collapsed}
setCollapsed={setCollapsed}
<MenuPanel title="Saved Searches"
collapsed={collapsed}
setCollapsed={setCollapsed}
>
{!collapsed &&
items.map((item) => (
<FilterButton
key={item.name}
text={item.name}
filterTerm={item.term}
{...props}
/>
))}
{!collapsed && sortedSearches && sortedSearches?.map((item) => (
<FilterButton
key={item.name}
text={item.name}
filterTerm={item.filter}
{...props}
/>
))}
{!collapsed && (
<EditButton
title="Edit Saved Searches"
destination="/settings/saved-searches"
/>)}
<Box css={{ height: '10px' }}></Box>
</MenuPanel>
@ -239,6 +217,7 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
return (
<MenuPanel
title="Labels"
editTitle="Edit Labels"
hideBottomBorder={true}
collapsed={collapsed}
setCollapsed={setCollapsed}
@ -258,9 +237,9 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element {
type MenuPanelProps = {
title: string
children: ReactNode
editFunc?: () => void
editTitle?: string
hideBottomBorder?: boolean
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
}

View File

@ -14,7 +14,7 @@ import { FeatureHelpBox } from '../../elements/FeatureHelpBox'
type SettingsTableProps = {
pageId: string
pageInfoLink: string
pageInfoLink?: string | undefined
headerTitle: string
createTitle?: string
@ -375,7 +375,7 @@ export const SettingsTable = (props: SettingsTableProps): JSX.Element => {
>
{props.headerTitle}
</StyledText>
<InfoLink href={props.pageInfoLink}></InfoLink>
{ props.pageInfoLink && <InfoLink href={props.pageInfoLink}></InfoLink> }
</HStack>
</Box>
</Box>

View File

@ -0,0 +1,21 @@
import { gql } from "graphql-request"
export type SavedSearch = {
id: string
name: string
filter: string
position: number
visible: boolean
defaultFilter: boolean
}
export const savedSearchFragment = gql`
fragment FiltersFragment on Filter {
id
name
filter
position
visible
defaultFilter
}
`

View File

@ -0,0 +1,36 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { SavedSearch } from "../fragments/savedSearchFragment"
export type DeleteFilterInput = string
type DeleteFilterOutput = {
deleteFilter: { filter: SavedSearch }
}
export async function deleteFilterMutation (
id: DeleteFilterInput
): Promise<SavedSearch | undefined> {
const mutation = gql`
mutation DeleteFilter($id: ID!) {
deleteFilter(id: $id) {
... on DeleteFilterSuccess {
filter {
id
}
}
... on DeleteFilterError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, { id })
const output = data as DeleteFilterOutput | undefined
return output?.deleteFilter.filter
} catch {
return undefined
}
}

View File

@ -0,0 +1,43 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { SavedSearch } from "../fragments/savedSearchFragment"
export type AddFilterInput = {
name: string
filter: string
category: string
position: number
}
type AddFilterOutput = {
saveFilter: { filter: SavedSearch }
}
export async function saveFilterMutation (
input: AddFilterInput
): Promise<SavedSearch | undefined> {
const mutation = gql`
mutation SaveFilter($input: SaveFilterInput!) {
saveFilter(input: $input) {
... on SaveFilterSuccess {
filter {
id
name
filter
position
visible
defaultFilter
}
}
... on SaveFilterError {
errorCodes
}
}
}
`
const data = await gqlFetcher(mutation, { input })
const output = data as AddFilterOutput | undefined
return output?.saveFilter.filter
}

View File

@ -0,0 +1,46 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { SavedSearch } from "../fragments/savedSearchFragment"
export type UpdateFilterInput = {
id?: string
name?: string
filter?: string
position?: number
category?: string
description?: string
visible?: boolean
}
type UpdateFilterOutput = {
filter: SavedSearch
}
export async function updateFilterMutation (
input: UpdateFilterInput
): Promise<string | undefined> {
const mutation = gql`
mutation UpdateFilter($input: UpdateFilterInput!) {
updateFilter(input: $input) {
... on UpdateFilterSuccess {
filter {
id
}
}
... on UpdateFilterError {
errorCodes
}
}
}
`
try {
const { id, name, visible, filter, position } = input
const data = await gqlFetcher(mutation, { input: {id, name, filter, position, visible }})
const output = data as UpdateFilterOutput | undefined
return output?.filter?.id
} catch {
return undefined
}
}

View File

@ -0,0 +1,50 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { publicGqlFetcher } from '../networkHelpers'
import { SavedSearch, savedSearchFragment } from "../fragments/savedSearchFragment"
type SavedSearchResponse = {
savedSearches?: SavedSearch[]
savedSearchErrors?: unknown
isLoading: boolean
}
type SavedSearchResponseData = {
filters: { filters: SavedSearch[] }
}
export function useGetSavedSearchQuery(): SavedSearchResponse {
const query = gql`
query SavedSearches {
filters {
... on FiltersSuccess {
filters {
...FiltersFragment
}
}
... on FiltersError {
errorCodes
}
}
}
${savedSearchFragment}
`
const { data, error } = useSWR(query, publicGqlFetcher);
if (data || error) {
const { filters } = data as SavedSearchResponseData
return {
savedSearches: filters?.filters ?? [],
savedSearchErrors: error ?? {},
isLoading: false,
}
}
return {
savedSearches: [],
savedSearchErrors: null,
isLoading: true,
}
}

File diff suppressed because it is too large Load Diff