Add Filters, and editing filters.

This commit is contained in:
Thomas Rogers
2023-08-30 19:37:24 +02:00
parent 27a5ab5042
commit d433d48365
18 changed files with 1577 additions and 58 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 },
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,

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,27 @@ 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: '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,

View File

@ -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;

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,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

View File

@ -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 (
<MenuPanel title="Saved Searches">
{items.map((item) => (
<MenuPanel title="Saved Searches" editTitle="Edit Saved Searches" editFunc={() => {
window.location.href = '/settings/saved-searches'
}}>
{sortedSearches && sortedSearches?.map((item) => (
<FilterButton
key={item.name}
text={item.name}
filterTerm={item.term}
filterTerm={item.filter}
{...props}
/>
))}

View File

@ -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}
</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,
}
}

View File

@ -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<string>('')
const [queryInputText, setQueryInputText] = useState<string>('')
const [editingId, setEditingId] = useState<string|null>(null);
const [isCreateMode, setIsCreateMode] = useState<boolean>(false)
const [windowWidth, setWindowWidth] = useState<number>(0)
const [confirmRemoveSavedSearchId, setConfirmRemoveSavedSearchId] = useState<
string | null
>(null)
const [draggedElementId, setDraggedElementId] = useState<string | null>(null);
const [draggedElementPosition, setDraggedElementPosition] = useState<{ x: number, y: number } | null>(null);
const [sortedSavedSearch, setSortedSavedSearch] = useState<SavedSearch[]>([]);
// 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<void> {
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<void> {
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<void> {
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<void> {
setConfirmRemoveSavedSearchId(id)
}
async function updatePositionOnMouseUp(y: number): Promise<string | undefined> {
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 (
<SettingsLayout>
<Toaster
containerStyle={{
top: '5rem',
}}
/>
<HStack css={{ width: '100%', height: '100%' }}>
<VStack
css={{
mx: '10px',
color: '$grayText',
width: '100%',
maxWidth: '865px',
}}
>
{confirmRemoveSavedSearchId ? (
<ConfirmationModal
message={
'Are you sure?'
}
onAccept={() => {
onDeleteSavedSearch(confirmRemoveSavedSearchId)
setConfirmRemoveSavedSearchId(null)
}}
onOpenChange={() => setConfirmRemoveSavedSearchId(null)}
/>
) : null}
<HeaderWrapper>
<Box
style={{
display: 'flex',
alignItems: 'center',
}}
>
<Box>
<StyledText style="fixedHeadline">Saved Searches </StyledText>
</Box>
<InfoLink href="/help/search" />
<Box
css={{
display: 'flex',
justifyContent: 'flex-end',
marginLeft: 'auto',
}}
>
{isCreateMode ? null : (
<>
<Button
onClick={() => {
resetSavedSearchState()
setIsCreateMode(true)
}}
style="ctaDarkYellow"
css={{
display: 'flex',
alignItems: 'center',
marginLeft: 'auto',
}}
>
<SpanBox
css={{
display: 'none',
'@md': {
display: 'flex',
},
}}
>
<SpanBox>Add Saved Search</SpanBox>
</SpanBox>
<SpanBox
css={{
p: '0',
display: 'flex',
'@md': {
display: 'none',
},
}}
>
<Plus size={24} />
</SpanBox>
</Button>
</>
)}
</Box>
</Box>
</HeaderWrapper>
<>
{isCreateMode ? (
windowWidth > breakpoint ? (
<DesktopEditCard
savedSearch={null}
editingId={editingId}
setEditingId={setEditingId}
isCreateMode={isCreateMode}
deleteSavedSearch={deleteSavedSearch}
nameInputText={nameInputText}
queryInputText={queryInputText}
setNameInputText={setNameInputText}
setQueryInputText={setQueryInputText}
setIsCreateMode={setIsCreateMode}
createSavedSearch={createSavedSearch}
updateSavedSearch={updateSavedSearch}
onEditPress={onEditPress}
resetState={resetSavedSearchState}
draggedElementId={draggedElementId}
setDraggedElementId={setDraggedElementId}
setDraggedElementPosition={setDraggedElementPosition}
/>
) : (
<MobileEditCard
savedSearch={null}
editingId={editingId}
setEditingId={setEditingId}
isCreateMode={isCreateMode}
deleteSavedSearch={deleteSavedSearch}
nameInputText={nameInputText}
queryInputText={queryInputText}
setNameInputText={setNameInputText}
setQueryInputText={setQueryInputText}
setIsCreateMode={setIsCreateMode}
createSavedSearch={createSavedSearch}
updateSavedSearch={updateSavedSearch}
onEditPress={onEditPress}
resetState={resetSavedSearchState}
draggedElementId={draggedElementId}
setDraggedElementId={setDraggedElementId}
setDraggedElementPosition={setDraggedElementPosition}
/>
)
) : 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 <DesktopEditCard {...cardProps} />
} else {
return <MobileEditCard {...cardProps} />
}
}
return (
<GenericTableCard
key={savedSearch.id}
{...cardProps}
onEditPress={onEditPress}
/>
)
})
: null}
</VStack>
</HStack>
<Box css={{ height: '120px' }} />
</SettingsLayout>
)
}
type EditCardProps = {
savedSearch: SavedSearch | null
editingId: string | null
nameInputText: string
queryInputText: string
setQueryInputText: Dispatch<SetStateAction<string>>
isCreateMode: boolean
setIsCreateMode: Dispatch<SetStateAction<boolean>>
setEditingId: Dispatch<SetStateAction<string | null>>
setNameInputText: Dispatch<SetStateAction<string>>
createSavedSearch: () => Promise<void>
updateSavedSearch: (id: string) => Promise<void>
deleteSavedSearch: (id: string) => Promise<void>
resetState: () => void
onEditPress: (savedSearch: SavedSearch | null) => void
isFirstChild?: boolean | undefined
isLastChild?: boolean | undefined
draggedElementId: string | null
setDraggedElementId: Dispatch<SetStateAction<string | null>>
setDraggedElementPosition: Dispatch<SetStateAction<{ x: number, y: number } | null>>
isSwappedCard: boolean,
updatePositionOnMouseUp: (y: number) => Promise<void>
}
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<Partial<{ position: string | null, top: string, left: string }>>(DEFAULT_STYLE)
const handleEdit = () => {
editingId && updateSavedSearch(editingId)
setEditingId(null)
}
const moreActionsButton = () => {
return (
<ActionsWrapper>
<Dropdown
disabled={isCreateMode}
triggerElement={<DotsThree size={24} color={iconColor} />}
>
<DropdownOption onSelect={() => null}>
<Button
style="plainIcon"
css={{
mr: '0px',
display: 'flex',
alignItems: 'center',
backgroundColor: 'transparent',
border: 0,
}}
onClick={() => onEditPress(savedSearch)}
disabled={isCreateMode}
>
<PencilSimple size={24} color={"black"} />
<StyledText
color="$grayText"
css={{ m: '0px', fontSize: '$5', marginLeft: '$2' }}
>
Edit
</StyledText>
</Button>
</DropdownOption>
<DropdownOption onSelect={() => null}>
<Button
style="plainIcon"
css={{
mr: '$1',
display: 'flex',
alignItems: 'center',
backgroundColor: 'transparent',
border: 0,
}}
onClick={() => (savedSearch ? deleteSavedSearch(savedSearch.id) : null)}
disabled={isCreateMode}
>
<Trash size={24} color="#AA2D11" />
<StyledText
css={{
m: '0px',
fontSize: '$5',
marginLeft: '$2',
color: '#AA2D11',
}}
>
Delete
</StyledText>
</Button>
</DropdownOption>
</Dropdown>
</ActionsWrapper>
)
}
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 (
<TableCard
className={"tableCard"}
css={{
...style,
background: 'white',
'&:hover': {
background: 'rgba(255, 234, 159, 0.12)',
},
borderTop: isSwappedCard ? "56px solid white" : undefined,
borderTopLeftRadius: isFirstChild ? '5px' : '',
borderTopRightRadius: isFirstChild ? '5px' : '',
borderBottomLeftRadius: isLastChild ? '5px' : '',
borderBottomRightRadius: isLastChild ? '5px' : '',
}}
>
<TableCardBox
css={{
display: 'grid',
width: '100%',
gridGap: '$1',
gridTemplateColumns: '1fr 2fr',
height: editingId == savedSearch?.id ? '120px' : '56px',
'.showHidden': {
display: 'none',
},
'&:hover': {
'.showHidden': {
display: 'unset',
gridColumn: 'span 2',
width: '100%',
padding: '$2 $3 0 $3',
},
},
'@md': {
height: '56px',
gridTemplateColumns: '4% 3% 20% 28% 1fr 1fr',
},
}}
>
<HStack
distribution="start"
alignment="center"
css={{
padding: '0 5px',
}}
>
<ArrowsDownUp size={28} style={{ cursor: 'grab' }} onMouseDown={onMouseDown} onMouseUp={onMouseUp} />
</HStack>
<HStack
distribution="start"
alignment="center"
css={{
padding: '0 5px',
}}
>
<CheckboxComponent checked={isVisible} setChecked={setVisibility} />
</HStack>
<HStack
distribution="start"
alignment="center"
css={{
padding: '0 5px',
}}
>
{showInput && !savedSearch ? (
<SpanBox
css={{
'@smDown': {
display: 'none',
},
}}
>
<Input
type="text"
value={nameInputText}
onChange={(event) => setNameInputText(event.target.value)}
required
autoFocus
/>
</SpanBox>
) : (
<StyledText
style="body"
css={{
color: '$grayTextContrast',
fontSize: '14px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingLeft: '15px'
}}
>
{editingId === savedSearch?.id
? nameInputText
: savedSearch?.name || ''}
</StyledText>
)}
</HStack>
<HStack
distribution="start"
alignment="center"
css={{
display: 'none',
'@md': {
display: 'flex',
},
}}
>
{showInput ? (
<Input
type="text"
placeholder="Query (e.g. in:inbox)"
value={queryInputText}
onChange={(event) => setQueryInputText(event.target.value)}
autoFocus={!!savedSearch}
/>
) : (
<StyledText
style="body"
css={{
color: '$grayTextContrast',
fontSize: '14px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{editingId === savedSearch?.id
? queryInputText
: savedSearch?.filter || ''}
</StyledText>
)}
</HStack>
<HStack
distribution="start"
css={{
padding: '4px 8px',
paddingLeft: '10px',
alignItems: 'center',
}}
>
{!showInput && (
<Box css={{ marginLeft: 'auto', '@md': { display: 'none' } }}>
{moreActionsButton()}
</Box>
)}
</HStack>
<HStack
distribution="start"
alignment="center"
css={{
ml: '8px',
display: 'flex',
'@md': {
display: 'none',
},
}}
>
{showInput && (
<Input
type="text"
placeholder="The search query to execute (e.g. in:inbox)"
value={queryInputText}
onChange={(event) => setQueryInputText(event.target.value)}
autoFocus={!!savedSearch}
/>
)}
</HStack>
<HStack
distribution="end"
alignment="center"
css={{
padding: '0px 8px',
}}
>
{(editingId === savedSearch?.id || !savedSearch) ? (
<>
<Button
style="plainIcon"
css={{ mr: '$1' }}
onClick={() => {
resetState()
}}
>
Cancel
</Button>
<Button
style="ctaDarkYellow"
css={{ my: '0px', mr: '$1' }}
onClick={() => (savedSearch ? handleEdit() : createSavedSearch())}
>
Save
</Button>
</>
) : (
<HStack
distribution="end"
alignment="end"
css={{
display: 'none',
'@md': {
display: 'flex',
width: '100%',
},
}}
>
<IconButton
style="ctaWhite"
css={{ mr: '$1', background: '$labelButtonsBg', display: savedSearch?.defaultFilter ? "none" : "block" }}
onClick={() => onEditPress(savedSearch)}
disabled={isCreateMode}
>
<PencilSimple size={16} color={iconColor} />
</IconButton>
<IconButton
style="ctaWhite"
css={{ mr: '$1', background: '$labelButtonsBg', display: savedSearch?.defaultFilter ? "none" : "block" }}
onClick={() => deleteSavedSearch(savedSearch.id)}
disabled={isCreateMode || savedSearch?.defaultFilter}
>
<Trash size={16} color={iconColor} />
</IconButton>
</HStack>
)}
</HStack>
</TableCardBox>
</TableCard>
)
}
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 (
<TableCard
css={{
borderTopLeftRadius: isFirstChild ? '5px' : '',
borderTopRightRadius: isFirstChild ? '5px' : '',
borderBottomLeftRadius: isLastChild ? '5px' : '',
borderBottomRightRadius: isLastChild ? '5px' : '',
}}
>
<VStack distribution="center" css={{ width: '100%', margin: '8px' }}>
<Input
type="text"
value={nameInputText}
onChange={(event) => setNameInputText(event.target.value)}
autoFocus
/>
<TextArea
placeholder="Query (e.g. in:inbox)"
value={queryInputText}
onChange={(event) => setQueryInputText(event.target.value)}
rows={5}
/>
<HStack
distribution="end"
alignment="center"
css={{ width: '100%', margin: '$1 0' }}
>
<Button
style="plainIcon"
css={{ mr: '$1' }}
onClick={() => {
resetState()
}}
>
Cancel
</Button>
<Button
style="ctaDarkYellow"
css={{ mr: '$1' }}
onClick={() => (savedSearch ? handleEdit() : createSavedSearch())}
>
Save
</Button>
</HStack>
</VStack>
</TableCard>
)
}
function DesktopEditCard(props: EditCardProps) {
const {
savedSearch,
editingId,
setEditingId,
nameInputText,
setNameInputText,
queryInputText,
setQueryInputText,
createSavedSearch,
resetState,
updateSavedSearch,
isFirstChild,
isLastChild,
} = props
const handleEdit = () => {
editingId && updateSavedSearch(editingId)
setEditingId(null)
}
return (
<TableCard
css={{
width: '100%',
borderTopLeftRadius: isFirstChild ? '5px' : '',
borderTopRightRadius: isFirstChild ? '5px' : '',
borderBottomLeftRadius: isLastChild ? '5px' : '',
borderBottomRightRadius: isLastChild ? '5px' : '',
}}
>
<VStack
distribution="center"
css={{ width: '100%', my: '8px', ml: '8px', mr: '0px' }}
>
<HStack
distribution="start"
alignment="center"
css={{ pt: '6px', px: '13px', width: '100%', gap: '16px' }}
>
<Input
type="text"
placeholder="Name"
value={nameInputText}
onChange={(event) => setNameInputText(event.target.value)}
autoFocus
/>
<Input
type="text"
placeholder="Query (e.g. in:inbox)"
value={queryInputText}
onChange={(event) => setQueryInputText(event.target.value)}
/>
<HStack
distribution="end"
alignment="center"
css={{ marginLeft: 'auto', width: '100% ' }}
>
<Button
style="ctaOutlineYellow"
css={{ mr: '12px' }}
onClick={() => {
resetState()
}}
>
Cancel
</Button>
<Button
style="ctaDarkYellow"
css={{}}
onClick={() => (savedSearch ? handleEdit() : createSavedSearch())}
>
Save
</Button>
</HStack>
</HStack>
</VStack>
</TableCard>
)
}