From d433d4836593c882eedea0871a95ab663fb2ea29 Mon Sep 17 00:00:00 2001 From: Thomas Rogers Date: Wed, 30 Aug 2023 19:37:24 +0200 Subject: [PATCH 1/6] Add Filters, and editing filters. --- packages/api/src/entity/filter.ts | 10 +- packages/api/src/generated/graphql.ts | 69 +- packages/api/src/generated/schema.graphql | 33 +- packages/api/src/resolvers/filters/index.ts | 131 ++- .../api/src/resolvers/function_resolvers.ts | 5 +- packages/api/src/schema.ts | 33 +- packages/api/src/services/create_user.ts | 25 + .../0119.do.add_defaults_to_filters.sql | 59 ++ .../0119.undo.add_defaults_to_filters.sql | 10 + .../components/templates/PrimaryDropdown.tsx | 5 +- .../templates/homeFeed/LibraryFilterMenu.tsx | 57 +- .../templates/settings/SettingsTable.tsx | 4 +- .../fragments/savedSearchFragment.ts | 21 + .../mutations/deleteFilterMutation.ts | 36 + .../mutations/saveFilterMutation.ts | 43 + .../mutations/updateFilterMutation.ts | 46 + .../queries/useGetSavedSearchQuery.tsx | 50 + .../web/pages/settings/saved-searches.tsx | 998 ++++++++++++++++++ 18 files changed, 1577 insertions(+), 58 deletions(-) create mode 100644 packages/db/migrations/0119.do.add_defaults_to_filters.sql create mode 100644 packages/db/migrations/0119.undo.add_defaults_to_filters.sql create mode 100644 packages/web/lib/networking/fragments/savedSearchFragment.ts create mode 100644 packages/web/lib/networking/mutations/deleteFilterMutation.ts create mode 100644 packages/web/lib/networking/mutations/saveFilterMutation.ts create mode 100644 packages/web/lib/networking/mutations/updateFilterMutation.ts create mode 100644 packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx create mode 100644 packages/web/pages/settings/saved-searches.tsx diff --git a/packages/api/src/entity/filter.ts b/packages/api/src/entity/filter.ts index 93a48e219..2ed86ae5f 100644 --- a/packages/api/src/entity/filter.ts +++ b/packages/api/src/entity/filter.ts @@ -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 } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 566cc87ca..9f730a73b 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -765,12 +765,14 @@ export type Filter = { __typename?: 'Filter'; category: Scalars['String']; createdAt: Scalars['Date']; + defaultFilter?: Maybe; description?: Maybe; filter: Scalars['String']; id: Scalars['ID']; name: Scalars['String']; position: Scalars['Int']; updatedAt: Scalars['Date']; + visible?: Maybe; }; 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; description?: InputMaybe; filter: Scalars['String']; - id?: InputMaybe; name: Scalars['String']; + position?: InputMaybe; }; export type SaveFilterResult = SaveFilterError | SaveFilterSuccess; @@ -2837,6 +2845,34 @@ export type UnsubscribeSuccess = { subscription: Subscription; }; +export type UpdateFilterError = { + __typename?: 'UpdateFilterError'; + errorCodes: Array; +}; + +export enum UpdateFilterErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type UpdateFilterInput = { + category?: InputMaybe; + description?: InputMaybe; + filter?: InputMaybe; + id: Scalars['String']; + name?: InputMaybe; + position?: InputMaybe; + visible?: InputMaybe; +}; + +export type UpdateFilterResult = UpdateFilterError | UpdateFilterSuccess; + +export type UpdateFilterSuccess = { + __typename?: 'UpdateFilterSuccess'; + filter: Filter; +}; + export type UpdateHighlightError = { __typename?: 'UpdateHighlightError'; errorCodes: Array; @@ -3777,6 +3813,11 @@ export type ResolversTypes = { UnsubscribeErrorCode: UnsubscribeErrorCode; UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess']; UnsubscribeSuccess: ResolverTypeWrapper; + UpdateFilterError: ResolverTypeWrapper; + UpdateFilterErrorCode: UpdateFilterErrorCode; + UpdateFilterInput: UpdateFilterInput; + UpdateFilterResult: ResolversTypes['UpdateFilterError'] | ResolversTypes['UpdateFilterSuccess']; + UpdateFilterSuccess: ResolverTypeWrapper; UpdateHighlightError: ResolverTypeWrapper; 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 = { category?: Resolver; createdAt?: Resolver; + defaultFilter?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; filter?: Resolver; id?: Resolver; name?: Resolver; position?: Resolver; updatedAt?: Resolver; + visible?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -5200,6 +5247,7 @@ export type MutationResolvers>; subscribe?: Resolver>; unsubscribe?: Resolver>; + updateFilter?: Resolver>; updateHighlight?: Resolver>; updateHighlightReply?: Resolver>; updateLabel?: Resolver>; @@ -5907,6 +5955,20 @@ export type UnsubscribeSuccessResolvers; }; +export type UpdateFilterErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type UpdateFilterResultResolvers = { + __resolveType: TypeResolveFn<'UpdateFilterError' | 'UpdateFilterSuccess', ParentType, ContextType>; +}; + +export type UpdateFilterSuccessResolvers = { + filter?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateHighlightErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6466,6 +6528,9 @@ export type Resolvers = { UnsubscribeError?: UnsubscribeErrorResolvers; UnsubscribeResult?: UnsubscribeResultResolvers; UnsubscribeSuccess?: UnsubscribeSuccessResolvers; + UpdateFilterError?: UpdateFilterErrorResolvers; + UpdateFilterResult?: UpdateFilterResultResolvers; + UpdateFilterSuccess?: UpdateFilterSuccessResolvers; UpdateHighlightError?: UpdateHighlightErrorResolvers; UpdateHighlightReplyError?: UpdateHighlightReplyErrorResolvers; UpdateHighlightReplyResult?: UpdateHighlightReplyResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 988946951..a9347f774 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -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!]! } diff --git a/packages/api/src/resolvers/filters/index.ts b/packages/api/src/resolvers/filters/index.ts index 19619b5f1..06eb0b9b0 100644 --- a/packages/api/src/resolvers/filters/index.ts +++ b/packages/api/src/resolvers/filters/index.ts @@ -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( } } ) +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 }, + relations: ['user'], + }) + if (!filter) { + return { + __typename: 'UpdateFilterError', + errorCodes: [UpdateFilterErrorCode.NotFound], + } + } + if (filter.user.id !== uid) { + return { + __typename: 'UpdateFilterError', + errorCodes: [UpdateFilterErrorCode.Unauthorized], + } + } + + if (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, diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 173a86fa4..b10dae9cb 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -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 }, diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 575ec1d73..305a75528 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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! diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index e53f8a15a..33644f015 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -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,27 @@ export const createUser = async (input: { return [user, profile] } +const createDefaultFiltersForUser = + (t: EntityManager) => + async (userId: string): Promise => { + const defaultFilters = [ + { name: 'Inbox', filter: 'in:inbox' }, + { name: 'Continue Reading', filter: 'in:inbox sort:read-desc is:unread' }, + { name: 'Read Later', filter: 'in:library' }, + { name: 'Highlights', filter: 'has:highlights mode:highlights' }, + { name: 'Unlabelled', filter: 'no:label' }, + { 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, diff --git a/packages/db/migrations/0119.do.add_defaults_to_filters.sql b/packages/db/migrations/0119.do.add_defaults_to_filters.sql new file mode 100644 index 000000000..3c7858e20 --- /dev/null +++ b/packages/db/migrations/0119.do.add_defaults_to_filters.sql @@ -0,0 +1,59 @@ +-- 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', 'Read Later', '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','Unlabelled', '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', 'Archived', 'in:archive', 5, true +FROM omnivore.user +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/packages/db/migrations/0119.undo.add_defaults_to_filters.sql b/packages/db/migrations/0119.undo.add_defaults_to_filters.sql new file mode 100644 index 000000000..3e30d524a --- /dev/null +++ b/packages/db/migrations/0119.undo.add_defaults_to_filters.sql @@ -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; diff --git a/packages/web/components/templates/PrimaryDropdown.tsx b/packages/web/components/templates/PrimaryDropdown.tsx index 19e25d159..cbd0283cd 100644 --- a/packages/web/components/templates/PrimaryDropdown.tsx +++ b/packages/web/components/templates/PrimaryDropdown.tsx @@ -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,7 @@ export type HeaderDropdownAction = | 'navigate-to-subscriptions' | 'navigate-to-api' | 'navigate-to-integrations' + | 'navigate-to-saved-searches' | 'increaseFontSize' | 'decreaseFontSize' | 'logout' @@ -66,6 +66,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 diff --git a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx index f3177cbcf..a305fd3b1 100644 --- a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx +++ b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx @@ -1,9 +1,9 @@ -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, DotsThree } 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 +11,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" export const LIBRARY_LEFT_MENU_WIDTH = '233px' @@ -82,39 +84,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: 'Read Later', - term: 'in:library', - }, - { - name: 'Highlights', - term: 'has:highlights mode:highlights', - }, - { - name: 'Unlabeled', - term: 'no:label', - }, - { - 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}`, @@ -123,20 +102,22 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element { section: 'Saved Searches', keywords: '?' + item.name, perform: () => { - props.applySearchQuery(item.term) + props.applySearchQuery(item.filter) }, } }), - [] + [isLoading] ) return ( - - {items.map((item) => ( + { + window.location.href = '/settings/saved-searches' + }}> + {sortedSearches && sortedSearches?.map((item) => ( ))} diff --git a/packages/web/components/templates/settings/SettingsTable.tsx b/packages/web/components/templates/settings/SettingsTable.tsx index a71e290d1..89c9f3d3f 100644 --- a/packages/web/components/templates/settings/SettingsTable.tsx +++ b/packages/web/components/templates/settings/SettingsTable.tsx @@ -11,7 +11,7 @@ import { SettingsLayout } from '../SettingsLayout' type SettingsTableProps = { pageId: string - pageInfoLink: string + pageInfoLink?: string | undefined headerTitle: string createTitle?: string @@ -342,7 +342,7 @@ export const SettingsTable = (props: SettingsTableProps): JSX.Element => { > {props.headerTitle} - + { props.pageInfoLink && } diff --git a/packages/web/lib/networking/fragments/savedSearchFragment.ts b/packages/web/lib/networking/fragments/savedSearchFragment.ts new file mode 100644 index 000000000..70bd3b4fd --- /dev/null +++ b/packages/web/lib/networking/fragments/savedSearchFragment.ts @@ -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 + } +` diff --git a/packages/web/lib/networking/mutations/deleteFilterMutation.ts b/packages/web/lib/networking/mutations/deleteFilterMutation.ts new file mode 100644 index 000000000..cacfea10c --- /dev/null +++ b/packages/web/lib/networking/mutations/deleteFilterMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/mutations/saveFilterMutation.ts b/packages/web/lib/networking/mutations/saveFilterMutation.ts new file mode 100644 index 000000000..1bdef13bc --- /dev/null +++ b/packages/web/lib/networking/mutations/saveFilterMutation.ts @@ -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 { + 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 +} diff --git a/packages/web/lib/networking/mutations/updateFilterMutation.ts b/packages/web/lib/networking/mutations/updateFilterMutation.ts new file mode 100644 index 000000000..8ae562362 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateFilterMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx b/packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx new file mode 100644 index 000000000..5b6376d05 --- /dev/null +++ b/packages/web/lib/networking/queries/useGetSavedSearchQuery.tsx @@ -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, + } +} diff --git a/packages/web/pages/settings/saved-searches.tsx b/packages/web/pages/settings/saved-searches.tsx new file mode 100644 index 000000000..5c552d35b --- /dev/null +++ b/packages/web/pages/settings/saved-searches.tsx @@ -0,0 +1,998 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react" +import { SettingsLayout } from '../../components/templates/SettingsLayout' +import { Button } from '../../components/elements/Button' +import { styled } from '../../components/tokens/stitches.config' +import { + Box, + SpanBox, + HStack, + VStack, +} from '../../components/elements/LayoutPrimitives' +import { Toaster } from 'react-hot-toast' +import { applyStoredTheme, isDarkTheme } from '../../lib/themeUpdater' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { StyledText } from '../../components/elements/StyledText' +import { + DotsThree, + PencilSimple, + Trash, + Plus, + ArrowsDownUp, +} from 'phosphor-react' +import { + Dropdown, + DropdownOption, +} from '../../components/elements/DropdownElements' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { InfoLink } from '../../components/elements/InfoLink' +import { useGetSavedSearchQuery } from "../../lib/networking/queries/useGetSavedSearchQuery" +import { SavedSearch } from "../../lib/networking/fragments/savedSearchFragment" +import CheckboxComponent from "../../components/elements/Checkbox" +import { updateFilterMutation } from "../../lib/networking/mutations/updateFilterMutation" +import { saveFilterMutation } from "../../lib/networking/mutations/saveFilterMutation" +import { inRange } from 'lodash'; +import { deleteFilterMutation } from "../../lib/networking/mutations/deleteFilterMutation" + +const HeaderWrapper = styled(Box, { + width: '100%', +}) + +const TableCard = styled(Box, { + padding: '0px', + backgroundColor: '$grayBg', + display: 'flex', + alignItems: 'center', + border: '0.3px solid $grayBgActive', + width: '100%', + '@md': { + paddingLeft: '0', + }, +}) + +const TableCardBox = styled(Box, { + display: 'grid', + width: '100%', + gridGap: '$1', + gridTemplateColumns: '3fr 1fr', + '.showHidden': { + display: 'none', + }, + '&:hover': { + '.showHidden': { + display: 'unset', + gridColumn: 'span 2', + width: '100%', + padding: '$2 $3 0 $3', + }, + }, + '@md': { + gridTemplateColumns: '20% 15% 1fr 1fr 1fr', + '&:hover': { + '.showHidden': { + display: 'none', + }, + }, + }, +}) + +const inputStyles = { + height: '35px', + backgroundColor: 'transparent', + color: '$grayTextContrast', + padding: '6px 6px', + margin: '$2 0', + border: '1px solid $grayBorder', + borderRadius: '6px', + fontSize: '16px', + FontFamily: '$fontFamily', + width: '100%', + '@md': { + width: 'auto', + minWidth: '180px', + }, + '&[disabled]': { + border: 'none', + }, + '&:focus': { + outlineColor: '$omnivoreYellow', + outlineStyle: 'solid', + }, +} + +const ActionsWrapper = styled(Box, { + mr: '$1', + display: 'flex', + width: 40, + height: 40, + alignItems: 'center', + bg: 'transparent', + cursor: 'pointer', + fontFamily: 'inter', + fontSize: '$2', + lineHeight: '1.25', + color: '$grayText', + '&:hover': { + opacity: 0.8, + }, +}) + +const IconButton = styled(Button, { + variants: { + style: { + ctaWhite: { + color: 'red', + padding: '10px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: '1px solid $grayBorder', + boxSizing: 'border-box', + borderRadius: 6, + width: 40, + height: 40, + }, + }, + }, +}) + +const TOP_SETTINGS_PANEL = 147 +const HEIGHT_SETTING_CARD = 56 + +const Input = styled('input', { ...inputStyles }) + +const TextArea = styled('textarea', { ...inputStyles }) + +export default function SavedSearchesPage(): JSX.Element { + const { savedSearches, isLoading } = useGetSavedSearchQuery() + const [nameInputText, setNameInputText] = useState('') + const [queryInputText, setQueryInputText] = useState('') + const [editingId, setEditingId] = useState(null); + const [isCreateMode, setIsCreateMode] = useState(false) + const [windowWidth, setWindowWidth] = useState(0) + const [confirmRemoveSavedSearchId, setConfirmRemoveSavedSearchId] = useState< + string | null + >(null) + const [draggedElementId, setDraggedElementId] = useState(null); + const [draggedElementPosition, setDraggedElementPosition] = useState<{ x: number, y: number } | null>(null); + const [sortedSavedSearch, setSortedSavedSearch] = useState([]); + + // Some theming stuff here. + const breakpoint = 768 + applyStoredTheme(false) + + useEffect(() => { + setSortedSavedSearch([...(savedSearches ?? [])].sort((l, r) => l.position - r.position)) + }, [isLoading]) + + useEffect(() => { + const handleResizeWindow = () => setWindowWidth(window.innerWidth) + if (windowWidth === 0) { + setWindowWidth(window.innerWidth) + } + window.addEventListener('resize', handleResizeWindow) + return () => { + window.removeEventListener('resize', handleResizeWindow) + } + }, [windowWidth]) + + const resetSavedSearchState = () => { + setIsCreateMode(false) + setNameInputText('') + setQueryInputText('') + setEditingId(null) + } + + async function createSavedSearch(): Promise { + try { + const savedFilter = await saveFilterMutation({ name: nameInputText, filter: queryInputText, category: 'Search', position: sortedSavedSearch?.length ?? 0 }); + showSuccessToast(`Added Filter: ${nameInputText}`) + + if (savedFilter) { + setSortedSavedSearch([...sortedSavedSearch, savedFilter]) + } + resetSavedSearchState() + } catch (e) { + showErrorToast('Unable to create filter. Unknown error occurred.') + } + } + + async function updateSavedSearch(id: string): Promise { + resetSavedSearchState() + + const changedSortedSearch = sortedSavedSearch?.find(it => it.id == id) + if (changedSortedSearch != undefined) { + changedSortedSearch.name = nameInputText + changedSortedSearch.filter = queryInputText + setSortedSavedSearch(sortedSavedSearch) + await updateFilterMutation( { id, name: nameInputText, filter: queryInputText }); + } + } + + const onEditPress = (savedSearch: SavedSearch | null) => { + if (savedSearch) { + setNameInputText(savedSearch.name) + setQueryInputText(savedSearch.filter || '') + setEditingId(savedSearch.id) + } else { + resetSavedSearchState() + } + } + + async function onDeleteSavedSearch(id: string): Promise { + await deleteFilterMutation(id) + const currentElement = sortedSavedSearch?.find((it) => it.id == id); + + setSortedSavedSearch( + sortedSavedSearch + .filter(it => it.id !== id) + .map(it => { + return { ...it, position: currentElement?.position > it.position ? it.position : it.position - 1} + }) + ) + + return + } + + async function deleteSavedSearch(id: string): Promise { + setConfirmRemoveSavedSearchId(id) + } + + async function updatePositionOnMouseUp(y: number): Promise { + const idx = Math.floor(((y - 25) - TOP_SETTINGS_PANEL) / HEIGHT_SETTING_CARD) + const correctedIdx = Math.min(Math.max(idx, 0), sortedSavedSearch?.length - 1) + const currentElement = sortedSavedSearch?.find(({ id }) => id == draggedElementId); + const moveUp = correctedIdx < currentElement?.position + + if (correctedIdx != currentElement?.position) { + const newlyOrdered = sortedSavedSearch + ?.map((search) => { + let pos = search.position; + if (inRange(pos, Math.min(correctedIdx, currentElement.position), Math.max(correctedIdx, currentElement.position)) || search.position == correctedIdx) { + pos = search.position + (moveUp ? +1 : -1) + console.log(pos) + } + if (draggedElementId == search?.id) { + pos = correctedIdx + } + return { + ...search, + position: pos + } + }) + ?.sort((l, r) => l.position - r.position); + setSortedSavedSearch(newlyOrdered) + console.log(newlyOrdered) + return updateFilterMutation({ ...currentElement, position: correctedIdx }) + } + + return + } + + return ( + + + + + {confirmRemoveSavedSearchId ? ( + { + onDeleteSavedSearch(confirmRemoveSavedSearchId) + setConfirmRemoveSavedSearchId(null) + }} + onOpenChange={() => setConfirmRemoveSavedSearchId(null)} + /> + ) : null} + + + + Saved Searches + + + + {isCreateMode ? null : ( + <> + + + )} + + + + <> + {isCreateMode ? ( + windowWidth > breakpoint ? ( + + ) : ( + + ) + ) : null} + + {sortedSavedSearch + ? sortedSavedSearch.map((savedSearch, i) => { + const isLastChild = i === sortedSavedSearch.length - 1 + const isFirstChild = i === 0 + const positionY = TOP_SETTINGS_PANEL + (HEIGHT_SETTING_CARD * (i + 1)); + const cardProps = { + savedSearch, + editingId, + isCreateMode: isCreateMode, + isLastChild: isLastChild, + isFirstChild: isFirstChild, + setEditingId, + nameInputText: nameInputText, + queryInputText: queryInputText, + setNameInputText: setNameInputText, + setQueryInputText: setQueryInputText, + setIsCreateMode: setIsCreateMode, + resetState: resetSavedSearchState, + updateSavedSearch, + deleteSavedSearch, + createSavedSearch, + draggedElementId, + setDraggedElementId, + onEditPress, + setDraggedElementPosition, + isSwappedCard: draggedElementId != savedSearch.id && (draggedElementPosition?.y + HEIGHT_SETTING_CARD)> positionY && draggedElementPosition?.y + HEIGHT_SETTING_CARD < positionY + HEIGHT_SETTING_CARD, + updatePositionOnMouseUp + } + if (editingId == savedSearch.id) { + if (windowWidth >= breakpoint) { + return + } else { + return + } + } + + return ( + + ) + }) + : null} + + + + + ) +} + +type EditCardProps = { + savedSearch: SavedSearch | null + editingId: string | null + nameInputText: string + queryInputText: string + setQueryInputText: Dispatch> + isCreateMode: boolean + setIsCreateMode: Dispatch> + setEditingId: Dispatch> + setNameInputText: Dispatch> + createSavedSearch: () => Promise + updateSavedSearch: (id: string) => Promise + deleteSavedSearch: (id: string) => Promise + resetState: () => void + onEditPress: (savedSearch: SavedSearch | null) => void + isFirstChild?: boolean | undefined + isLastChild?: boolean | undefined + draggedElementId: string | null + setDraggedElementId: Dispatch> + setDraggedElementPosition: Dispatch> + isSwappedCard: boolean, + updatePositionOnMouseUp: (y: number) => Promise +} + +function GenericTableCard( + props: EditCardProps & { + isLastChild?: boolean + isFirstChild?: boolean + } +) { + const { + savedSearch, + isLastChild, + isFirstChild, + editingId, + isCreateMode, + nameInputText, + queryInputText, + setQueryInputText, + setEditingId, + deleteSavedSearch, + setNameInputText, + createSavedSearch, + updateSavedSearch, + onEditPress, + resetState, + draggedElementId, + setDraggedElementId, + setDraggedElementPosition, + isSwappedCard, + updatePositionOnMouseUp + } = props + const [isVisible, setIsVisible] = useState(!!savedSearch?.visible) + const showInput = editingId === savedSearch?.id || (isCreateMode && !savedSearch) + const iconColor = isDarkTheme() ? '#D8D7D5' : '#5F5E58' + const DEFAULT_STYLE = { position: null }; + const [style, setStyle] = useState>(DEFAULT_STYLE) + const handleEdit = () => { + editingId && updateSavedSearch(editingId) + setEditingId(null) + } + const moreActionsButton = () => { + return ( + + } + > + null}> + + + null}> + + + + + ) + } + + const onMouseDown= (e: MouseEvent) => { + if (savedSearch) { + setDraggedElementId(savedSearch.id) + setDraggedElementPosition({ y: e.clientY - 25, x: e.clientX - 25}) + } + } + + const onMouseUp = async (e: MouseEvent) => { + const updatePosition = updatePositionOnMouseUp(e.clientY) + setDraggedElementId(null); + setStyle(DEFAULT_STYLE); + setDraggedElementPosition(null); + await updatePosition; + } + + const onMouseMove = (e: MouseEvent) => { + if (draggedElementId != null && draggedElementId == savedSearch?.id) { + setStyle({ position: "absolute", top: `${e.clientY - 25}px`, left: `${e.clientX - 25}px`, maxWidth: '865px' }); + setDraggedElementPosition({ y: e.clientY - 25, x: e.clientX - 25}); + } + } + + useEffect(() => { + window.addEventListener('mousemove', onMouseMove) + return () => { + window.removeEventListener('mousemove', onMouseMove) + } + }, [draggedElementId]) + + const setVisibility = () => { + updateFilterMutation({ ...savedSearch, visible: !isVisible} ) + setIsVisible(!isVisible); + } + + return ( + + + + + + + + + + + {showInput && !savedSearch ? ( + + + setNameInputText(event.target.value)} + required + autoFocus + /> + + ) : ( + + {editingId === savedSearch?.id + ? nameInputText + : savedSearch?.name || ''} + + )} + + + + {showInput ? ( + setQueryInputText(event.target.value)} + autoFocus={!!savedSearch} + /> + ) : ( + + {editingId === savedSearch?.id + ? queryInputText + : savedSearch?.filter || ''} + + )} + + + + {!showInput && ( + + {moreActionsButton()} + + )} + + + + {showInput && ( + setQueryInputText(event.target.value)} + autoFocus={!!savedSearch} + /> + )} + + + + {(editingId === savedSearch?.id || !savedSearch) ? ( + <> + + + + ) : ( + + onEditPress(savedSearch)} + disabled={isCreateMode} + > + + + deleteSavedSearch(savedSearch.id)} + disabled={isCreateMode || savedSearch?.defaultFilter} + > + + + + )} + + + + ) +} +function MobileEditCard(props: EditCardProps) { + const { + savedSearch, + editingId, + setEditingId, + isCreateMode, + nameInputText, + setNameInputText, + queryInputText, + setQueryInputText, + createSavedSearch, + resetState, + updateSavedSearch, + isFirstChild, + isLastChild, + } = props + + const handleEdit = () => { + editingId && setEditingId(editingId) + setEditingId(null) + } + + return ( + + + setNameInputText(event.target.value)} + autoFocus + /> + +