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..fd6193a6c 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, 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 { 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..5324fd82d 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,29 @@ 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: '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, diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index 773b57167..6faf1803e 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -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, diff --git a/packages/api/test/services/create_user.test.ts b/packages/api/test/services/create_user.test.ts index 12e8fe9c7..42cde34ce 100644 --- a/packages/api/test/services/create_user.test.ts +++ b/packages/api/test/services/create_user.test.ts @@ -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 () => { 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..1d38162dd --- /dev/null +++ b/packages/db/migrations/0119.do.add_defaults_to_filters.sql @@ -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; 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 3d31202c7..e5679fe0f 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,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 diff --git a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx index 9c056182a..8825ff5ad 100644 --- a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx +++ b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx @@ -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({ @@ -142,20 +117,23 @@ function SavedSearches(props: LibraryFilterMenuProps): JSX.Element { }) return ( - - {!collapsed && - items.map((item) => ( - - ))} + {!collapsed && sortedSearches && sortedSearches?.map((item) => ( + + ))} + {!collapsed && ( + )} @@ -239,6 +217,7 @@ function Labels(props: LibraryFilterMenuProps): JSX.Element { return ( void + editTitle?: string hideBottomBorder?: boolean - collapsed: boolean setCollapsed: (collapsed: boolean) => void } diff --git a/packages/web/components/templates/settings/SettingsTable.tsx b/packages/web/components/templates/settings/SettingsTable.tsx index dda8dd4fa..ef3421651 100644 --- a/packages/web/components/templates/settings/SettingsTable.tsx +++ b/packages/web/components/templates/settings/SettingsTable.tsx @@ -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} - + { 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..06e3e4fd3 --- /dev/null +++ b/packages/web/pages/settings/saved-searches.tsx @@ -0,0 +1,1003 @@ +import { Dispatch, MouseEventHandler, SetStateAction, useCallback, useEffect, useState } from "react" +import { SettingsLayout } from '../../components/templates/SettingsLayout' +import { Button } from '../../components/elements/Button' +import { styled, theme } 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 { + const currentElement = sortedSavedSearch?.find((it) => it.id == id); + if (currentElement) { + await deleteFilterMutation(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 currentElement = sortedSavedSearch?.find(({ id }) => id == draggedElementId); + if (currentElement) { + const idx = Math.floor(((y + window.scrollY - 25) - TOP_SETTINGS_PANEL) / HEIGHT_SETTING_CARD) + const correctedIdx = Math.min(Math.max(idx, 0), sortedSavedSearch?.length - 1) + 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) + } + if (draggedElementId == search?.id) { + pos = correctedIdx + } + return { + ...search, + position: pos + } + }) + ?.sort((l, r) => l.position - r.position); + setSortedSavedSearch(newlyOrdered) + return updateFilterMutation({ ...currentElement, position: correctedIdx }) + } + } + + return + } + + return ( + + + + + {confirmRemoveSavedSearchId ? ( + { + await 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: (draggedElementPosition && draggedElementId != savedSearch.id && (draggedElementPosition.y + window.scrollY + HEIGHT_SETTING_CARD)> positionY && draggedElementPosition?.y + window.scrollY + HEIGHT_SETTING_CARD < positionY + HEIGHT_SETTING_CARD) || undefined, + 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) => { + if (draggedElementId != null && draggedElementId == savedSearch?.id && updatePositionOnMouseUp) { + const updatePosition = updatePositionOnMouseUp(e.clientY) + setDraggedElementId(null); + setStyle(DEFAULT_STYLE); + setDraggedElementPosition(null); + await updatePosition; + } + } + + const onMouseMove = useCallback((e: MouseEvent) => { + if (draggedElementId != null && draggedElementId == savedSearch?.id) { + setStyle({ position: "absolute", top: `${e.clientY - 25 + window.scrollY}px`, left: `${e.clientX - 25 + window.scrollX}px`, maxWidth: '865px' }); + setDraggedElementPosition({ y: e.clientY - 25, x: e.clientX - 25}); + } + }, [draggedElementId, savedSearch, setDraggedElementPosition]) + + useEffect(() => { + window.addEventListener('mousemove', onMouseMove) + return () => { + window.removeEventListener('mousemove', onMouseMove) + } + }, [draggedElementId, onMouseMove]) + + const setVisibility = async () => { + await 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, + nameInputText, + setNameInputText, + queryInputText, + setQueryInputText, + createSavedSearch, + resetState, + isFirstChild, + isLastChild, + } = props + + const handleEdit = () => { + editingId && setEditingId(editingId) + setEditingId(null) + } + + return ( + + + setNameInputText(event.target.value)} + autoFocus + /> + +