Merge pull request #98 from omnivore-app/feature/labels
Labels API and testing page
This commit is contained in:
@ -36,6 +36,7 @@ type UserArticleStats = {
|
||||
}
|
||||
|
||||
const LINK_COLS = [
|
||||
'omnivore.links.id as linkId',
|
||||
'omnivore.links.userId',
|
||||
'omnivore.links.slug',
|
||||
'omnivore.links.article_url as url',
|
||||
@ -403,6 +404,7 @@ class UserArticleModel extends DataModel<
|
||||
inFilter: InFilter
|
||||
readFilter: ReadFilter
|
||||
typeFilter: PageType | undefined
|
||||
labelFilters?: string[]
|
||||
},
|
||||
userId: string,
|
||||
tx = this.kx,
|
||||
@ -441,6 +443,15 @@ class UserArticleModel extends DataModel<
|
||||
}
|
||||
}
|
||||
|
||||
// search by labels using lowercase
|
||||
if (args.labelFilters) {
|
||||
queryPromise
|
||||
.innerJoin(Table.LINK_LABELS, 'link_labels.link_id', 'links.id')
|
||||
.innerJoin(Table.LABELS, 'labels.id', 'link_labels.label_id')
|
||||
.whereRaw('LOWER(omnivore.labels.name) = ANY(?)', [args.labelFilters])
|
||||
.distinct('links.id')
|
||||
}
|
||||
|
||||
if (notNullField) {
|
||||
queryPromise.whereNotNull(notNullField)
|
||||
}
|
||||
@ -469,12 +480,11 @@ class UserArticleModel extends DataModel<
|
||||
.orderBy('omnivore.links.id', sortOrder)
|
||||
.limit(limit)
|
||||
|
||||
// console.log('query', queryPromise.toString())
|
||||
const rows = await queryPromise
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
|
||||
return [rows, parseInt(totalCount as string)]
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
import { Link } from './link'
|
||||
|
||||
@Entity({ name: 'labels' })
|
||||
export class Label extends BaseEntity {
|
||||
@ -22,9 +21,11 @@ export class Label extends BaseEntity {
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@ManyToOne(() => Link)
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link!: Link
|
||||
@Column('text')
|
||||
color!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description?: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@ -15,7 +15,8 @@ import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
@ -62,6 +63,11 @@ export class Link extends BaseEntity {
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@OneToMany(() => Label, (label) => label.link)
|
||||
@ManyToMany(() => Label)
|
||||
@JoinTable({
|
||||
name: 'link_labels',
|
||||
joinColumn: { name: 'link_id' },
|
||||
inverseJoinColumn: { name: 'label_id' },
|
||||
})
|
||||
labels?: Label[]
|
||||
}
|
||||
|
||||
27
packages/api/src/entity/link_label.ts
Normal file
27
packages/api/src/entity/link_label.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { Link } from './link'
|
||||
import { Label } from './label'
|
||||
|
||||
@Entity({ name: 'link_labels' })
|
||||
export class LinkLabel extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => Link)
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link!: Link
|
||||
|
||||
@ManyToOne(() => Label)
|
||||
@JoinColumn({ name: 'label_id' })
|
||||
label!: Label
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
}
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import { MembershipTier, RegistrationType } from '../datalayer/user/model'
|
||||
import { NewsletterEmail } from './newsletter_email'
|
||||
import { Profile } from './profile'
|
||||
import { Label } from './label'
|
||||
|
||||
@Entity()
|
||||
export class User extends BaseEntity {
|
||||
@ -46,4 +47,7 @@ export class User extends BaseEntity {
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
password?: string
|
||||
|
||||
@OneToMany(() => Label, (label) => label.user)
|
||||
labels?: Label[]
|
||||
}
|
||||
|
||||
@ -53,6 +53,8 @@ export type Article = {
|
||||
id: Scalars['ID'];
|
||||
image?: Maybe<Scalars['String']>;
|
||||
isArchived: Scalars['Boolean'];
|
||||
labels?: Maybe<Array<Label>>;
|
||||
linkId?: Maybe<Scalars['ID']>;
|
||||
originalArticleUrl?: Maybe<Scalars['String']>;
|
||||
originalHtml?: Maybe<Scalars['String']>;
|
||||
pageType?: Maybe<PageType>;
|
||||
@ -276,12 +278,14 @@ export type CreateLabelError = {
|
||||
|
||||
export enum CreateLabelErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
LabelAlreadyExists = 'LABEL_ALREADY_EXISTS',
|
||||
NotFound = 'NOT_FOUND',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type CreateLabelInput = {
|
||||
linkId: Scalars['ID'];
|
||||
color: Scalars['String'];
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
@ -624,6 +628,9 @@ export type HighlightStats = {
|
||||
|
||||
export type Label = {
|
||||
__typename?: 'Label';
|
||||
color: Scalars['String'];
|
||||
createdAt: Scalars['Date'];
|
||||
description?: Maybe<Scalars['String']>;
|
||||
id: Scalars['ID'];
|
||||
name: Scalars['String'];
|
||||
};
|
||||
@ -773,6 +780,7 @@ export type Mutation = {
|
||||
setBookmarkArticle: SetBookmarkArticleResult;
|
||||
setDeviceToken: SetDeviceTokenResult;
|
||||
setFollow: SetFollowResult;
|
||||
setLabels: SetLabelsResult;
|
||||
setLinkArchived: ArchiveLinkResult;
|
||||
setShareArticle: SetShareArticleResult;
|
||||
setShareHighlight: SetShareHighlightResult;
|
||||
@ -914,6 +922,11 @@ export type MutationSetFollowArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetLabelsArgs = {
|
||||
input: SetLabelsInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetLinkArchivedArgs = {
|
||||
input: ArchiveLinkInput;
|
||||
};
|
||||
@ -1120,11 +1133,6 @@ export type QueryGetFollowingArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryLabelsArgs = {
|
||||
linkId: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryReminderArgs = {
|
||||
linkId: Scalars['ID'];
|
||||
};
|
||||
@ -1350,6 +1358,29 @@ export type SetFollowSuccess = {
|
||||
updatedUser: User;
|
||||
};
|
||||
|
||||
export type SetLabelsError = {
|
||||
__typename?: 'SetLabelsError';
|
||||
errorCodes: Array<SetLabelsErrorCode>;
|
||||
};
|
||||
|
||||
export enum SetLabelsErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
NotFound = 'NOT_FOUND',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type SetLabelsInput = {
|
||||
labelIds: Array<Scalars['ID']>;
|
||||
linkId: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type SetLabelsResult = SetLabelsError | SetLabelsSuccess;
|
||||
|
||||
export type SetLabelsSuccess = {
|
||||
__typename?: 'SetLabelsSuccess';
|
||||
labels: Array<Label>;
|
||||
};
|
||||
|
||||
export type SetShareArticleError = {
|
||||
__typename?: 'SetShareArticleError';
|
||||
errorCodes: Array<SetShareArticleErrorCode>;
|
||||
@ -2014,6 +2045,11 @@ export type ResolversTypes = {
|
||||
SetFollowInput: SetFollowInput;
|
||||
SetFollowResult: ResolversTypes['SetFollowError'] | ResolversTypes['SetFollowSuccess'];
|
||||
SetFollowSuccess: ResolverTypeWrapper<SetFollowSuccess>;
|
||||
SetLabelsError: ResolverTypeWrapper<SetLabelsError>;
|
||||
SetLabelsErrorCode: SetLabelsErrorCode;
|
||||
SetLabelsInput: SetLabelsInput;
|
||||
SetLabelsResult: ResolversTypes['SetLabelsError'] | ResolversTypes['SetLabelsSuccess'];
|
||||
SetLabelsSuccess: ResolverTypeWrapper<SetLabelsSuccess>;
|
||||
SetShareArticleError: ResolverTypeWrapper<SetShareArticleError>;
|
||||
SetShareArticleErrorCode: SetShareArticleErrorCode;
|
||||
SetShareArticleInput: SetShareArticleInput;
|
||||
@ -2250,6 +2286,10 @@ export type ResolversParentTypes = {
|
||||
SetFollowInput: SetFollowInput;
|
||||
SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess'];
|
||||
SetFollowSuccess: SetFollowSuccess;
|
||||
SetLabelsError: SetLabelsError;
|
||||
SetLabelsInput: SetLabelsInput;
|
||||
SetLabelsResult: ResolversParentTypes['SetLabelsError'] | ResolversParentTypes['SetLabelsSuccess'];
|
||||
SetLabelsSuccess: SetLabelsSuccess;
|
||||
SetShareArticleError: SetShareArticleError;
|
||||
SetShareArticleInput: SetShareArticleInput;
|
||||
SetShareArticleResult: ResolversParentTypes['SetShareArticleError'] | ResolversParentTypes['SetShareArticleSuccess'];
|
||||
@ -2349,6 +2389,8 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
isArchived?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
labels?: Resolver<Maybe<Array<ResolversTypes['Label']>>, ParentType, ContextType>;
|
||||
linkId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
|
||||
originalArticleUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
originalHtml?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
pageType?: Resolver<Maybe<ResolversTypes['PageType']>, ParentType, ContextType>;
|
||||
@ -2762,6 +2804,9 @@ export type HighlightStatsResolvers<ContextType = ResolverContext, ParentType ex
|
||||
};
|
||||
|
||||
export type LabelResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Label'] = ResolversParentTypes['Label']> = {
|
||||
color?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@ -2875,6 +2920,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
||||
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
|
||||
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
|
||||
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
|
||||
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
|
||||
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
|
||||
setShareArticle?: Resolver<ResolversTypes['SetShareArticleResult'], ParentType, ContextType, RequireFields<MutationSetShareArticleArgs, 'input'>>;
|
||||
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
|
||||
@ -2955,7 +3001,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
|
||||
getFollowing?: Resolver<ResolversTypes['GetFollowingResult'], ParentType, ContextType, Partial<QueryGetFollowingArgs>>;
|
||||
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
|
||||
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType, RequireFields<QueryLabelsArgs, 'linkId'>>;
|
||||
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType>;
|
||||
me?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
|
||||
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
|
||||
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
|
||||
@ -3081,6 +3127,20 @@ export type SetFollowSuccessResolvers<ContextType = ResolverContext, ParentType
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SetLabelsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsError'] = ResolversParentTypes['SetLabelsError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['SetLabelsErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SetLabelsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsResult'] = ResolversParentTypes['SetLabelsResult']> = {
|
||||
__resolveType: TypeResolveFn<'SetLabelsError' | 'SetLabelsSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SetLabelsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsSuccess'] = ResolversParentTypes['SetLabelsSuccess']> = {
|
||||
labels?: Resolver<Array<ResolversTypes['Label']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SetShareArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetShareArticleError'] = ResolversParentTypes['SetShareArticleError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['SetShareArticleErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@ -3457,6 +3517,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
SetFollowError?: SetFollowErrorResolvers<ContextType>;
|
||||
SetFollowResult?: SetFollowResultResolvers<ContextType>;
|
||||
SetFollowSuccess?: SetFollowSuccessResolvers<ContextType>;
|
||||
SetLabelsError?: SetLabelsErrorResolvers<ContextType>;
|
||||
SetLabelsResult?: SetLabelsResultResolvers<ContextType>;
|
||||
SetLabelsSuccess?: SetLabelsSuccessResolvers<ContextType>;
|
||||
SetShareArticleError?: SetShareArticleErrorResolvers<ContextType>;
|
||||
SetShareArticleResult?: SetShareArticleResultResolvers<ContextType>;
|
||||
SetShareArticleSuccess?: SetShareArticleSuccessResolvers<ContextType>;
|
||||
|
||||
@ -34,6 +34,8 @@ type Article {
|
||||
id: ID!
|
||||
image: String
|
||||
isArchived: Boolean!
|
||||
labels: [Label!]
|
||||
linkId: ID
|
||||
originalArticleUrl: String
|
||||
originalHtml: String
|
||||
pageType: PageType
|
||||
@ -234,12 +236,14 @@ type CreateLabelError {
|
||||
|
||||
enum CreateLabelErrorCode {
|
||||
BAD_REQUEST
|
||||
LABEL_ALREADY_EXISTS
|
||||
NOT_FOUND
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
input CreateLabelInput {
|
||||
linkId: ID!
|
||||
color: String!
|
||||
description: String
|
||||
name: String!
|
||||
}
|
||||
|
||||
@ -548,6 +552,9 @@ type HighlightStats {
|
||||
}
|
||||
|
||||
type Label {
|
||||
color: String!
|
||||
createdAt: Date!
|
||||
description: String
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
@ -686,6 +693,7 @@ type Mutation {
|
||||
setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult!
|
||||
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
|
||||
setFollow(input: SetFollowInput!): SetFollowResult!
|
||||
setLabels(input: SetLabelsInput!): SetLabelsResult!
|
||||
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
|
||||
setShareArticle(input: SetShareArticleInput!): SetShareArticleResult!
|
||||
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
|
||||
@ -787,7 +795,7 @@ type Query {
|
||||
getFollowing(userId: ID): GetFollowingResult!
|
||||
getUserPersonalization: GetUserPersonalizationResult!
|
||||
hello: String
|
||||
labels(linkId: ID!): LabelsResult!
|
||||
labels: LabelsResult!
|
||||
me: User
|
||||
newsletterEmails: NewsletterEmailsResult!
|
||||
reminder(linkId: ID!): ReminderResult!
|
||||
@ -984,6 +992,27 @@ type SetFollowSuccess {
|
||||
updatedUser: User!
|
||||
}
|
||||
|
||||
type SetLabelsError {
|
||||
errorCodes: [SetLabelsErrorCode!]!
|
||||
}
|
||||
|
||||
enum SetLabelsErrorCode {
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
input SetLabelsInput {
|
||||
labelIds: [ID!]!
|
||||
linkId: ID!
|
||||
}
|
||||
|
||||
union SetLabelsResult = SetLabelsError | SetLabelsSuccess
|
||||
|
||||
type SetLabelsSuccess {
|
||||
labels: [Label!]!
|
||||
}
|
||||
|
||||
type SetShareArticleError {
|
||||
errorCodes: [SetShareArticleErrorCode!]!
|
||||
}
|
||||
|
||||
@ -4,21 +4,25 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import {
|
||||
FeedArticle,
|
||||
PageInfo,
|
||||
Article,
|
||||
ArticleError,
|
||||
ArticleErrorCode,
|
||||
ArticlesError,
|
||||
ArticleSuccess,
|
||||
ContentReader,
|
||||
CreateArticleError,
|
||||
CreateArticleErrorCode,
|
||||
CreateArticleSuccess,
|
||||
FeedArticle,
|
||||
MutationCreateArticleArgs,
|
||||
MutationSaveArticleReadingProgressArgs,
|
||||
MutationSetBookmarkArticleArgs,
|
||||
MutationSetShareArticleArgs,
|
||||
PageInfo,
|
||||
PageType,
|
||||
QueryArticleArgs,
|
||||
QueryArticlesArgs,
|
||||
ResolverFn,
|
||||
SaveArticleReadingProgressError,
|
||||
SaveArticleReadingProgressErrorCode,
|
||||
SaveArticleReadingProgressSuccess,
|
||||
@ -28,15 +32,12 @@ import {
|
||||
SetShareArticleError,
|
||||
SetShareArticleErrorCode,
|
||||
SetShareArticleSuccess,
|
||||
ResolverFn,
|
||||
PageType,
|
||||
ContentReader,
|
||||
} from '../../generated/graphql'
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Merge } from '../../util'
|
||||
import {
|
||||
makeStorageFilePublic,
|
||||
getStorageFileDetails,
|
||||
makeStorageFilePublic,
|
||||
} from '../../utils/uploads'
|
||||
import { ContentParseError } from '../../utils/errors'
|
||||
import {
|
||||
@ -49,9 +50,9 @@ import {
|
||||
validatedDate,
|
||||
} from '../../utils/helpers'
|
||||
import {
|
||||
ParsedContentPuppeteer,
|
||||
parseOriginalContent,
|
||||
parsePreparedContent,
|
||||
ParsedContentPuppeteer,
|
||||
} from '../../utils/parser'
|
||||
import { isSiteBlockedForParse } from '../../utils/blocked'
|
||||
import { Readability } from '@omnivore/readability'
|
||||
@ -530,16 +531,17 @@ type PaginatedPartialArticles = {
|
||||
|
||||
export const getArticlesResolver = authorized<
|
||||
PaginatedPartialArticles,
|
||||
ArticlesError
|
||||
>(async (_obj, _params, { models, claims, authTrx }) => {
|
||||
const notNullField = _params?.sharedOnly ? 'sharedAt' : null
|
||||
const startCursor = _params.after || ''
|
||||
const first = _params.first || 10
|
||||
ArticlesError,
|
||||
QueryArticlesArgs
|
||||
>(async (_obj, params, { models, claims, authTrx }) => {
|
||||
const notNullField = params.sharedOnly ? 'sharedAt' : null
|
||||
const startCursor = params.after || ''
|
||||
const first = params.first || 10
|
||||
|
||||
// Perform basic sanitization. Right now we just allow alphanumeric, space and quote
|
||||
// so queries can contain phrases like "human race". In the future we will need to
|
||||
// split out terms like "label:unread".
|
||||
const searchQuery = parseSearchQuery(_params.query)
|
||||
// so queries can contain phrases like "human race";
|
||||
// We can also split out terms like "label:unread".
|
||||
const searchQuery = parseSearchQuery(params.query || undefined)
|
||||
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
@ -549,6 +551,7 @@ export const getArticlesResolver = authorized<
|
||||
inFilter: searchQuery.inFilter,
|
||||
readFilter: searchQuery.readFilter,
|
||||
typeFilter: searchQuery.typeFilter,
|
||||
labelFilters: searchQuery.labelFilters,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
@ -559,11 +562,12 @@ export const getArticlesResolver = authorized<
|
||||
{
|
||||
cursor: startCursor,
|
||||
first: first + 1, // fetch one more item to get next cursor
|
||||
sort: _params.sort,
|
||||
sort: params.sort || undefined,
|
||||
query: searchQuery.query,
|
||||
inFilter: searchQuery.inFilter,
|
||||
readFilter: searchQuery.readFilter,
|
||||
typeFilter: searchQuery.typeFilter,
|
||||
labelFilters: searchQuery.labelFilters,
|
||||
},
|
||||
claims.uid,
|
||||
tx,
|
||||
|
||||
@ -59,6 +59,7 @@ import {
|
||||
setBookmarkArticleResolver,
|
||||
setDeviceTokenResolver,
|
||||
setFollowResolver,
|
||||
setLabelsResolver,
|
||||
setLinkArchivedResolver,
|
||||
setShareArticleResolver,
|
||||
setShareHighlightResolver,
|
||||
@ -78,6 +79,9 @@ import {
|
||||
generateDownloadSignedUrl,
|
||||
generateUploadFilePathName,
|
||||
} from '../utils/uploads'
|
||||
import { getRepository } from 'typeorm'
|
||||
import { Link } from '../entity/link'
|
||||
import { Label } from '../entity/label'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type ResultResolveType = {
|
||||
@ -135,6 +139,7 @@ export const functionResolvers = {
|
||||
deleteLabel: deleteLabelResolver,
|
||||
login: loginResolver,
|
||||
signup: signupResolver,
|
||||
setLabels: setLabelsResolver,
|
||||
},
|
||||
Query: {
|
||||
me: getMeUserResolver,
|
||||
@ -423,6 +428,12 @@ export const functionResolvers = {
|
||||
ctx.models
|
||||
)
|
||||
},
|
||||
async labels(article: { linkId: string }): Promise<Label[] | undefined> {
|
||||
const link = await getRepository(Link).findOne(article.linkId, {
|
||||
relations: ['labels'],
|
||||
})
|
||||
return link?.labels
|
||||
},
|
||||
},
|
||||
ArticleSavingRequest: {
|
||||
async article(
|
||||
@ -525,4 +536,5 @@ export const functionResolvers = {
|
||||
...resultResolveTypeResolver('DeleteLabel'),
|
||||
...resultResolveTypeResolver('Login'),
|
||||
...resultResolveTypeResolver('Signup'),
|
||||
...resultResolveTypeResolver('SetLabels'),
|
||||
}
|
||||
|
||||
@ -11,57 +11,53 @@ import {
|
||||
LabelsSuccess,
|
||||
MutationCreateLabelArgs,
|
||||
MutationDeleteLabelArgs,
|
||||
QueryLabelsArgs,
|
||||
MutationSetLabelsArgs,
|
||||
SetLabelsError,
|
||||
SetLabelsErrorCode,
|
||||
SetLabelsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { env } from '../../env'
|
||||
import { User } from '../../entity/user'
|
||||
import { Link } from '../../entity/link'
|
||||
import { Label } from '../../entity/label'
|
||||
import { getManager, getRepository } from 'typeorm'
|
||||
import { getManager, getRepository, ILike } from 'typeorm'
|
||||
import { setClaims } from '../../entity/utils'
|
||||
import { Link } from '../../entity/link'
|
||||
import { LinkLabel } from '../../entity/link_label'
|
||||
|
||||
export const labelsResolver = authorized<
|
||||
LabelsSuccess,
|
||||
LabelsError,
|
||||
QueryLabelsArgs
|
||||
>(async (_, { linkId }, { claims: { uid }, log }) => {
|
||||
log.info('labelsResolver')
|
||||
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
|
||||
async (_obj, _params, { claims: { uid }, log }) => {
|
||||
log.info('labelsResolver')
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels',
|
||||
properties: {
|
||||
linkId: linkId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const user = await User.findOne(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.Unauthorized],
|
||||
try {
|
||||
const user = await User.findOne(uid, {
|
||||
relations: ['labels'],
|
||||
})
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const link = await Link.findOne(linkId, { relations: ['labels'] })
|
||||
if (!link) {
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.NotFound],
|
||||
labels: user.labels || [],
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels: link.labels || [],
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const createLabelResolver = authorized<
|
||||
CreateLabelSuccess,
|
||||
@ -70,7 +66,7 @@ export const createLabelResolver = authorized<
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('createLabelResolver')
|
||||
|
||||
const { linkId, name } = input
|
||||
const { name, color, description } = input
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOne(uid)
|
||||
@ -80,18 +76,24 @@ export const createLabelResolver = authorized<
|
||||
}
|
||||
}
|
||||
|
||||
const link = await getRepository(Link).findOne(linkId)
|
||||
if (!link) {
|
||||
// Check if label already exists ignoring case of name
|
||||
const existingLabel = await getRepository(Label).findOne({
|
||||
where: {
|
||||
name: ILike(name),
|
||||
},
|
||||
})
|
||||
if (existingLabel) {
|
||||
return {
|
||||
errorCodes: [CreateLabelErrorCode.NotFound],
|
||||
errorCodes: [CreateLabelErrorCode.LabelAlreadyExists],
|
||||
}
|
||||
}
|
||||
|
||||
const label = await getRepository(Label)
|
||||
.create({
|
||||
user,
|
||||
link,
|
||||
name,
|
||||
color,
|
||||
description: description || '',
|
||||
})
|
||||
.save()
|
||||
|
||||
@ -99,8 +101,9 @@ export const createLabelResolver = authorized<
|
||||
userId: uid,
|
||||
event: 'createLabel',
|
||||
properties: {
|
||||
linkId,
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
@ -176,3 +179,70 @@ export const deleteLabelResolver = authorized<
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const setLabelsResolver = authorized<
|
||||
SetLabelsSuccess,
|
||||
SetLabelsError,
|
||||
MutationSetLabelsArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('setLabelsResolver')
|
||||
|
||||
const { linkId, labelIds } = input
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOne(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const link = await getRepository(Link).findOne(linkId)
|
||||
if (!link) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const labels = await getRepository(Label).findByIds(labelIds, {
|
||||
where: {
|
||||
user,
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
if (labels.length !== labelIds.length) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// delete all existing labels of the link
|
||||
await getManager().transaction(async (t) => {
|
||||
await t.getRepository(LinkLabel).delete({ link })
|
||||
|
||||
// add new labels
|
||||
await t
|
||||
.getRepository(LinkLabel)
|
||||
.save(labels.map((label) => ({ link, label })))
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'setLabels',
|
||||
properties: {
|
||||
linkId,
|
||||
labelIds,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
labels,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -333,6 +333,8 @@ const schema = gql`
|
||||
highlights(input: ArticleHighlightsInput): [Highlight!]!
|
||||
shareInfo: LinkShareInfo
|
||||
isArchived: Boolean!
|
||||
linkId: ID
|
||||
labels: [Label!]
|
||||
}
|
||||
|
||||
# Query: article
|
||||
@ -1242,6 +1244,9 @@ const schema = gql`
|
||||
type Label {
|
||||
id: ID!
|
||||
name: String!
|
||||
color: String!
|
||||
description: String
|
||||
createdAt: Date!
|
||||
}
|
||||
|
||||
type LabelsSuccess {
|
||||
@ -1261,8 +1266,9 @@ const schema = gql`
|
||||
union LabelsResult = LabelsSuccess | LabelsError
|
||||
|
||||
input CreateLabelInput {
|
||||
linkId: ID!
|
||||
name: String!
|
||||
color: String!
|
||||
description: String
|
||||
}
|
||||
|
||||
type CreateLabelSuccess {
|
||||
@ -1273,6 +1279,7 @@ const schema = gql`
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
LABEL_ALREADY_EXISTS
|
||||
}
|
||||
|
||||
type CreateLabelError {
|
||||
@ -1321,6 +1328,27 @@ const schema = gql`
|
||||
|
||||
union SignupResult = SignupSuccess | SignupError
|
||||
|
||||
input SetLabelsInput {
|
||||
linkId: ID!
|
||||
labelIds: [ID!]!
|
||||
}
|
||||
|
||||
union SetLabelsResult = SetLabelsSuccess | SetLabelsError
|
||||
|
||||
type SetLabelsSuccess {
|
||||
labels: [Label!]!
|
||||
}
|
||||
|
||||
type SetLabelsError {
|
||||
errorCodes: [SetLabelsErrorCode!]!
|
||||
}
|
||||
|
||||
enum SetLabelsErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
@ -1379,6 +1407,7 @@ const schema = gql`
|
||||
deleteLabel(id: ID!): DeleteLabelResult!
|
||||
login(input: LoginInput!): LoginResult!
|
||||
signup(input: SignupInput!): SignupResult!
|
||||
setLabels(input: SetLabelsInput!): SetLabelsResult!
|
||||
}
|
||||
|
||||
# FIXME: remove sort from feedArticles after all cahced tabs are closed
|
||||
@ -1414,7 +1443,7 @@ const schema = gql`
|
||||
articleSavingRequest(id: ID!): ArticleSavingRequestResult!
|
||||
newsletterEmails: NewsletterEmailsResult!
|
||||
reminder(linkId: ID!): ReminderResult!
|
||||
labels(linkId: ID!): LabelsResult!
|
||||
labels: LabelsResult!
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -23,4 +23,6 @@ export enum Table {
|
||||
HIGHLIGHT_REPLY = 'omnivore.highlight_reply',
|
||||
REACTION = 'omnivore.reaction',
|
||||
REMINDER = 'omnivore.reminders',
|
||||
LABELS = 'omnivore.labels',
|
||||
LINK_LABELS = 'omnivore.link_labels',
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
parse,
|
||||
SearchParserTextOffset,
|
||||
SearchParserKeyWordOffset,
|
||||
SearchParserTextOffset,
|
||||
} from 'search-query-parser'
|
||||
import { PageType } from '../generated/graphql'
|
||||
|
||||
@ -27,6 +27,7 @@ export type SearchFilter = {
|
||||
inFilter: InFilter
|
||||
readFilter: ReadFilter
|
||||
typeFilter?: PageType | undefined
|
||||
labelFilters?: string[]
|
||||
}
|
||||
|
||||
const parseIsFilter = (str: string | undefined): ReadFilter => {
|
||||
@ -77,6 +78,20 @@ const parseTypeFilter = (str: string | undefined): PageType | undefined => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parseLabelFilters = (
|
||||
str: string | undefined,
|
||||
labelFilters: string[] | undefined
|
||||
): string[] | undefined => {
|
||||
if (str === undefined) {
|
||||
return labelFilters
|
||||
}
|
||||
|
||||
// use lower case for label names
|
||||
const label = str.toLowerCase()
|
||||
|
||||
return labelFilters ? labelFilters.concat(label) : [label]
|
||||
}
|
||||
|
||||
export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
||||
const searchQuery = query ? query.replace(/\W\s":/g, '') : undefined
|
||||
const result: SearchFilter = {
|
||||
@ -94,7 +109,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
||||
}
|
||||
|
||||
const parsed = parse(searchQuery, {
|
||||
keywords: ['in', 'is', 'type'],
|
||||
keywords: ['in', 'is', 'type', 'label'],
|
||||
tokenize: true,
|
||||
})
|
||||
if (parsed.offsets) {
|
||||
@ -133,6 +148,12 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
||||
case 'type':
|
||||
result.typeFilter = parseTypeFilter(keyword.value)
|
||||
break
|
||||
case 'label':
|
||||
result.labelFilters = parseLabelFilters(
|
||||
keyword.value,
|
||||
result.labelFilters
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { Link } from '../src/entity/link'
|
||||
import { Reminder } from '../src/entity/reminder'
|
||||
import { NewsletterEmail } from '../src/entity/newsletter_email'
|
||||
import { UserDeviceToken } from '../src/entity/user_device_tokens'
|
||||
import { Label } from '../src/entity/label'
|
||||
|
||||
const runMigrations = async () => {
|
||||
const migrationDirectory = __dirname + '/../../db/migrations'
|
||||
@ -200,3 +201,17 @@ export const getUser = async (id: string): Promise<User | undefined> => {
|
||||
export const getLink = async (id: string): Promise<Link | undefined> => {
|
||||
return getRepository(Link).findOne(id)
|
||||
}
|
||||
|
||||
export const createTestLabel = async (
|
||||
user: User,
|
||||
name: string,
|
||||
color: string
|
||||
): Promise<Label> => {
|
||||
return getRepository(Label)
|
||||
.create({
|
||||
user: user,
|
||||
name: name,
|
||||
color: color,
|
||||
})
|
||||
.save()
|
||||
}
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import {
|
||||
createTestLabel,
|
||||
createTestLink,
|
||||
createTestPage,
|
||||
createTestUser,
|
||||
deleteTestUser,
|
||||
} from '../db'
|
||||
import { generateFakeUuid, graphqlRequest, request } from '../util'
|
||||
import * as chai from 'chai'
|
||||
import { expect } from 'chai'
|
||||
import { Page } from '../../src/entity/page'
|
||||
import { Link } from '../../src/entity/link'
|
||||
import 'mocha'
|
||||
import { User } from '../../src/entity/user'
|
||||
import * as chai from 'chai'
|
||||
import chaiString from 'chai-string'
|
||||
import { getRepository } from 'typeorm'
|
||||
import { LinkLabel } from '../../src/entity/link_label'
|
||||
import { Label } from '../../src/entity/label'
|
||||
|
||||
chai.use(chaiString)
|
||||
|
||||
@ -32,7 +36,7 @@ const archiveLink = async (authToken: string, linkId: string) => {
|
||||
}
|
||||
}
|
||||
`
|
||||
return await graphqlRequest(query, authToken).expect(200)
|
||||
return graphqlRequest(query, authToken).expect(200)
|
||||
}
|
||||
|
||||
const articlesQuery = (after = '', order = 'ASCENDING') => {
|
||||
@ -53,6 +57,12 @@ const articlesQuery = (after = '', order = 'ASCENDING') => {
|
||||
node {
|
||||
id
|
||||
url
|
||||
linkId
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@ -98,7 +108,8 @@ describe('Article API', () => {
|
||||
const username = 'fakeUser'
|
||||
let authToken: string
|
||||
let user: User
|
||||
let links: Page[] = []
|
||||
let links: Link[] = []
|
||||
let label: Label
|
||||
|
||||
before(async () => {
|
||||
// create test user and login
|
||||
@ -110,9 +121,17 @@ describe('Article API', () => {
|
||||
// Create some test links
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const page = await createTestPage()
|
||||
await createTestLink(user, page)
|
||||
links.push(page)
|
||||
const link = await createTestLink(user, page)
|
||||
links.push(link)
|
||||
}
|
||||
// create testing labels
|
||||
label = await createTestLabel(user, 'label', '#ffffff')
|
||||
// set label to a link
|
||||
await getRepository(LinkLabel).save({
|
||||
link: links[0],
|
||||
label: label,
|
||||
})
|
||||
|
||||
authToken = res.body.authToken
|
||||
})
|
||||
|
||||
@ -129,17 +148,29 @@ describe('Article API', () => {
|
||||
query = articlesQuery(after)
|
||||
})
|
||||
|
||||
it('should return linkId', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.articles.edges[0].node.linkId).to.eql(links[0].id)
|
||||
})
|
||||
|
||||
it('should return labels', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.articles.edges[0].node.labels[0].id).to.eql(label.id)
|
||||
})
|
||||
|
||||
context('when we fetch the first page', () => {
|
||||
it('should return the first five items', async () => {
|
||||
after = ''
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.articles.edges.length).to.eql(5)
|
||||
expect(res.body.data.articles.edges[0].node.id).to.eql(links[0].id)
|
||||
expect(res.body.data.articles.edges[1].node.id).to.eql(links[1].id)
|
||||
expect(res.body.data.articles.edges[2].node.id).to.eql(links[2].id)
|
||||
expect(res.body.data.articles.edges[3].node.id).to.eql(links[3].id)
|
||||
expect(res.body.data.articles.edges[4].node.id).to.eql(links[4].id)
|
||||
expect(res.body.data.articles.edges[0].node.id).to.eql(links[0].page.id)
|
||||
expect(res.body.data.articles.edges[1].node.id).to.eql(links[1].page.id)
|
||||
expect(res.body.data.articles.edges[2].node.id).to.eql(links[2].page.id)
|
||||
expect(res.body.data.articles.edges[3].node.id).to.eql(links[3].page.id)
|
||||
expect(res.body.data.articles.edges[4].node.id).to.eql(links[4].page.id)
|
||||
})
|
||||
|
||||
it('should set the pageInfo', async () => {
|
||||
@ -166,11 +197,11 @@ describe('Article API', () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.articles.edges.length).to.eql(5)
|
||||
expect(res.body.data.articles.edges[0].node.id).to.eql(links[5].id)
|
||||
expect(res.body.data.articles.edges[1].node.id).to.eql(links[6].id)
|
||||
expect(res.body.data.articles.edges[2].node.id).to.eql(links[7].id)
|
||||
expect(res.body.data.articles.edges[3].node.id).to.eql(links[8].id)
|
||||
expect(res.body.data.articles.edges[4].node.id).to.eql(links[9].id)
|
||||
expect(res.body.data.articles.edges[0].node.id).to.eql(links[5].page.id)
|
||||
expect(res.body.data.articles.edges[1].node.id).to.eql(links[6].page.id)
|
||||
expect(res.body.data.articles.edges[2].node.id).to.eql(links[7].page.id)
|
||||
expect(res.body.data.articles.edges[3].node.id).to.eql(links[8].page.id)
|
||||
expect(res.body.data.articles.edges[4].node.id).to.eql(links[9].page.id)
|
||||
})
|
||||
|
||||
it('should set the pageInfo', async () => {
|
||||
@ -208,27 +239,44 @@ describe('Article API', () => {
|
||||
context('when we save a new page', () => {
|
||||
it('should return a slugged url', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.savePage.url).to.startsWith("http://localhost:3000/fakeUser/example-title-")
|
||||
expect(res.body.data.savePage.url).to.startsWith(
|
||||
'http://localhost:3000/fakeUser/example-title-'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
context('when we save a page that is already archived', () => {
|
||||
it('it should return that page in the GetArticles Query', async () => {
|
||||
url = 'https://example.com/new-url'
|
||||
await graphqlRequest(savePageQuery(url, title, originalContent), authToken).expect(200)
|
||||
await graphqlRequest(
|
||||
savePageQuery(url, title, originalContent),
|
||||
authToken
|
||||
).expect(200)
|
||||
|
||||
// Save a link, then archive it
|
||||
let allLinks = await graphqlRequest(articlesQuery('', 'DESCENDING'), authToken).expect(200)
|
||||
let allLinks = await graphqlRequest(
|
||||
articlesQuery('', 'DESCENDING'),
|
||||
authToken
|
||||
).expect(200)
|
||||
const justSavedId = allLinks.body.data.articles.edges[0].node.id
|
||||
await archiveLink(authToken, justSavedId)
|
||||
|
||||
// test the negative case, ensuring the archive link wasn't returned
|
||||
allLinks = await graphqlRequest(articlesQuery('', 'DESCENDING'), authToken).expect(200)
|
||||
allLinks = await graphqlRequest(
|
||||
articlesQuery('', 'DESCENDING'),
|
||||
authToken
|
||||
).expect(200)
|
||||
expect(allLinks.body.data.articles.edges[0].node.url).to.not.eq(url)
|
||||
|
||||
// Now save the link again, and ensure it is returned
|
||||
const resaved = await graphqlRequest(savePageQuery(url, title, originalContent), authToken).expect(200)
|
||||
allLinks = await graphqlRequest(articlesQuery('', 'DESCENDING'), authToken).expect(200)
|
||||
const resaved = await graphqlRequest(
|
||||
savePageQuery(url, title, originalContent),
|
||||
authToken
|
||||
).expect(200)
|
||||
allLinks = await graphqlRequest(
|
||||
articlesQuery('', 'DESCENDING'),
|
||||
authToken
|
||||
).expect(200)
|
||||
expect(allLinks.body.data.articles.edges[0].node.url).to.eq(url)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
createTestLabel,
|
||||
createTestLink,
|
||||
createTestPage,
|
||||
createTestUser,
|
||||
@ -11,10 +12,13 @@ import { expect } from 'chai'
|
||||
import { Page } from '../../src/entity/page'
|
||||
import { getRepository } from 'typeorm'
|
||||
import 'mocha'
|
||||
import { LinkLabel } from '../../src/entity/link_label'
|
||||
import { User } from '../../src/entity/user'
|
||||
|
||||
describe('Labels API', () => {
|
||||
const username = 'fakeUser'
|
||||
|
||||
let user: User
|
||||
let authToken: string
|
||||
let page: Page
|
||||
let link: Link
|
||||
@ -22,31 +26,30 @@ describe('Labels API', () => {
|
||||
|
||||
before(async () => {
|
||||
// create test user and login
|
||||
const user = await createTestUser(username)
|
||||
user = await createTestUser(username)
|
||||
const res = await request
|
||||
.post('/local/debug/fake-user-login')
|
||||
.send({ fakeEmail: user.email })
|
||||
|
||||
authToken = res.body.authToken
|
||||
|
||||
// create test label
|
||||
// create testing labels
|
||||
const label1 = await createTestLabel(user, 'label_1', '#ffffff')
|
||||
const label2 = await createTestLabel(user, 'label_2', '#eeeeee')
|
||||
labels = [label1, label2]
|
||||
|
||||
page = await createTestPage()
|
||||
link = await createTestLink(user, page)
|
||||
const label1 = await getRepository(Label)
|
||||
.create({
|
||||
name: 'label1',
|
||||
user: user,
|
||||
link: link,
|
||||
})
|
||||
.save()
|
||||
const label2 = await getRepository(Label)
|
||||
.create({
|
||||
name: 'label2',
|
||||
user: user,
|
||||
link: link,
|
||||
})
|
||||
.save()
|
||||
labels = [label1, label2]
|
||||
const existingLabelOfLink = await createTestLabel(
|
||||
user,
|
||||
'different_label',
|
||||
'#dddddd'
|
||||
)
|
||||
// set another label to link
|
||||
await getRepository(LinkLabel).save({
|
||||
link,
|
||||
label: existingLabelOfLink,
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
@ -56,16 +59,18 @@ describe('Labels API', () => {
|
||||
|
||||
describe('GET labels', () => {
|
||||
let query: string
|
||||
let linkId: string
|
||||
|
||||
beforeEach(() => {
|
||||
query = `
|
||||
query {
|
||||
labels(linkId: "${linkId}") {
|
||||
labels {
|
||||
... on LabelsSuccess {
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
... on LabelsError {
|
||||
@ -76,33 +81,19 @@ describe('Labels API', () => {
|
||||
`
|
||||
})
|
||||
|
||||
context('when link exists', () => {
|
||||
before(() => {
|
||||
linkId = link.id
|
||||
})
|
||||
it('should return labels', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
it('should return labels', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.labels.labels).to.eql(
|
||||
labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
}))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
context('when link not exist', () => {
|
||||
before(() => {
|
||||
linkId = generateFakeUuid()
|
||||
})
|
||||
|
||||
it('should return error code NOT_FOUND', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.labels.errorCodes).to.eql(['NOT_FOUND'])
|
||||
})
|
||||
const labels = await getRepository(Label).find({ where: { user } })
|
||||
expect(res.body.data.labels.labels).to.eql(
|
||||
labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description,
|
||||
createdAt: new Date(label.createdAt.setMilliseconds(0)).toISOString(),
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
it('responds status code 400 when invalid query', async () => {
|
||||
@ -122,15 +113,15 @@ describe('Labels API', () => {
|
||||
|
||||
describe('Create label', () => {
|
||||
let query: string
|
||||
let linkId: string
|
||||
let name: string
|
||||
|
||||
beforeEach(() => {
|
||||
query = `
|
||||
mutation {
|
||||
createLabel(
|
||||
input: {
|
||||
linkId: "${linkId}",
|
||||
name: "label3"
|
||||
color: "#ffffff"
|
||||
name: "${name}"
|
||||
}
|
||||
) {
|
||||
... on CreateLabelSuccess {
|
||||
@ -147,9 +138,9 @@ describe('Labels API', () => {
|
||||
`
|
||||
})
|
||||
|
||||
context('when link exists', () => {
|
||||
context('when name not exists', () => {
|
||||
before(() => {
|
||||
linkId = link.id
|
||||
name = 'label3'
|
||||
})
|
||||
|
||||
it('should create label', async () => {
|
||||
@ -161,15 +152,17 @@ describe('Labels API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
context('when link not exist', () => {
|
||||
context('when name exists', () => {
|
||||
before(() => {
|
||||
linkId = generateFakeUuid()
|
||||
name = labels[0].name
|
||||
})
|
||||
|
||||
it('should return error code NOT_FOUND', async () => {
|
||||
it('should return error code LABEL_ALREADY_EXISTS', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.createLabel.errorCodes).to.eql(['NOT_FOUND'])
|
||||
expect(res.body.data.createLabel.errorCodes).to.eql([
|
||||
'LABEL_ALREADY_EXISTS',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -211,8 +204,9 @@ describe('Labels API', () => {
|
||||
})
|
||||
|
||||
context('when label exists', () => {
|
||||
before(() => {
|
||||
labelId = labels[0].id
|
||||
before(async () => {
|
||||
const toDeleteLabel = await createTestLabel(user, 'label4', '#ffffff')
|
||||
labelId = toDeleteLabel.id
|
||||
})
|
||||
|
||||
it('should delete label', async () => {
|
||||
@ -248,4 +242,89 @@ describe('Labels API', () => {
|
||||
return graphqlRequest(query, invalidAuthToken).expect(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Set labels', () => {
|
||||
let query: string
|
||||
let linkId: string
|
||||
let labelIds: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
query = `
|
||||
mutation {
|
||||
setLabels(
|
||||
input: {
|
||||
linkId: "${linkId}",
|
||||
labelIds: [
|
||||
"${labelIds[0]}",
|
||||
"${labelIds[1]}"
|
||||
]
|
||||
}
|
||||
) {
|
||||
... on SetLabelsSuccess {
|
||||
labels {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
... on SetLabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
context('when labels exists', () => {
|
||||
before(() => {
|
||||
linkId = link.id
|
||||
labelIds = [labels[0].id, labels[1].id]
|
||||
})
|
||||
|
||||
it('should set labels', async () => {
|
||||
await graphqlRequest(query, authToken).expect(200)
|
||||
const link = await getRepository(Link).findOne(linkId, {
|
||||
relations: ['labels'],
|
||||
})
|
||||
expect(link?.labels?.map((l) => l.id)).to.eql(labelIds)
|
||||
})
|
||||
})
|
||||
|
||||
context('when labels not exist', () => {
|
||||
before(() => {
|
||||
linkId = link.id
|
||||
labelIds = [generateFakeUuid(), generateFakeUuid()]
|
||||
})
|
||||
|
||||
it('should return error code NOT_FOUND', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.setLabels.errorCodes).to.eql(['NOT_FOUND'])
|
||||
})
|
||||
})
|
||||
|
||||
context('when link not exist', () => {
|
||||
before(() => {
|
||||
linkId = generateFakeUuid()
|
||||
labelIds = [labels[0].id, labels[1].id]
|
||||
})
|
||||
|
||||
it('should return error code NOT_FOUND', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(res.body.data.setLabels.errorCodes).to.eql(['NOT_FOUND'])
|
||||
})
|
||||
})
|
||||
|
||||
it('responds status code 400 when invalid query', async () => {
|
||||
const invalidQuery = `
|
||||
mutation {
|
||||
setLabels {}
|
||||
}
|
||||
`
|
||||
return graphqlRequest(invalidQuery, authToken).expect(400)
|
||||
})
|
||||
|
||||
it('responds status code 500 when invalid user', async () => {
|
||||
const invalidAuthToken = 'Fake token'
|
||||
return graphqlRequest(query, invalidAuthToken).expect(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
23
packages/db/migrations/0070.do.update_labels_table.sql
Executable file
23
packages/db/migrations/0070.do.update_labels_table.sql
Executable file
@ -0,0 +1,23 @@
|
||||
-- Type: DO
|
||||
-- Name: update_labels_table
|
||||
-- Description: Update labels table and create link_labels table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE omnivore.labels
|
||||
DROP COLUMN link_id,
|
||||
ADD COLUMN color text NOT NULL DEFAULT '#000000',
|
||||
ADD COLUMN description text,
|
||||
ADD CONSTRAINT label_name_unique UNIQUE (user_id, name);
|
||||
|
||||
CREATE TABLE omnivore.link_labels (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
|
||||
link_id uuid NOT NULL REFERENCES omnivore.links ON DELETE CASCADE,
|
||||
label_id uuid NOT NULL REFERENCES omnivore.labels ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
UNIQUE (link_id, label_id)
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE ON omnivore.link_labels TO omnivore_user;
|
||||
|
||||
COMMIT;
|
||||
15
packages/db/migrations/0070.undo.update_labels_table.sql
Executable file
15
packages/db/migrations/0070.undo.update_labels_table.sql
Executable file
@ -0,0 +1,15 @@
|
||||
-- Type: UNDO
|
||||
-- Name: update_labels_table
|
||||
-- Description: Update labels table and create link_labels table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE omnivore.labels
|
||||
ADD COLUMN link_id uuid REFERENCES omnivore.links ON DELETE CASCADE,
|
||||
DROP COLUMN color,
|
||||
DROP COLUMN description,
|
||||
DROP CONSTRAINT label_name_unique;
|
||||
|
||||
DROP TABLE omnivore.link_labels;
|
||||
|
||||
COMMIT;
|
||||
32
packages/web/components/elements/Label.tsx
Normal file
32
packages/web/components/elements/Label.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { StyledText } from './StyledText'
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
color: string // expected to be a RGB hex color string
|
||||
}
|
||||
|
||||
export function Label(props: LabelProps): JSX.Element {
|
||||
const hexToRgb = (hex: string) => {
|
||||
const bigint = parseInt(hex.substring(1), 16)
|
||||
const r = (bigint >> 16) & 255
|
||||
const g = (bigint >> 8) & 255
|
||||
const b = bigint & 255
|
||||
|
||||
return [r, g, b]
|
||||
}
|
||||
const color = hexToRgb(props.color)
|
||||
return (
|
||||
<StyledText
|
||||
css={{
|
||||
margin: '4px',
|
||||
borderRadius: '32px',
|
||||
color: props.color,
|
||||
padding: '4px 8px 4px 8px',
|
||||
border: `1px solid ${props.color}`,
|
||||
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.3)`,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</StyledText>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
|
||||
import { theme, ThemeId } from './../../tokens/stitches.config'
|
||||
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { useState, useEffect, MutableRefObject } from 'react'
|
||||
import { MutableRefObject, useEffect, useState } from 'react'
|
||||
import { ReportIssuesModal } from './ReportIssuesModal'
|
||||
import { reportIssueMutation } from '../../../lib/networking/mutations/reportIssueMutation'
|
||||
import { ArticleHeaderToolbar } from './ArticleHeaderToolbar'
|
||||
@ -17,6 +17,7 @@ import { ShareArticleModal } from './ShareArticleModal'
|
||||
import { userPersonalizationMutation } from '../../../lib/networking/mutations/userPersonalizationMutation'
|
||||
import { webBaseURL } from '../../../lib/appConfig'
|
||||
import { updateThemeLocally } from '../../../lib/themeUpdater'
|
||||
import { EditLabelsModal } from './EditLabelsModal'
|
||||
|
||||
type ArticleContainerProps = {
|
||||
viewerUsername: string
|
||||
@ -32,9 +33,13 @@ type ArticleContainerProps = {
|
||||
|
||||
export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showLabelsModal, setShowLabelsModal] = useState(false)
|
||||
const [showNotesSidebar, setShowNotesSidebar] = useState(false)
|
||||
const [showReportIssuesModal, setShowReportIssuesModal] = useState(false)
|
||||
const [fontSize, setFontSize] = useState(props.fontSize ?? 20)
|
||||
const [labels, setLabels] = useState(
|
||||
props.article.labels?.map((l) => l.id) || []
|
||||
)
|
||||
|
||||
const updateFontSize = (newFontSize: number) => {
|
||||
setFontSize(newFontSize)
|
||||
@ -56,6 +61,9 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
case 'decrementFontSize':
|
||||
updateFontSize(Math.max(fontSize - 2, 10))
|
||||
break
|
||||
case 'editLabels':
|
||||
setShowLabelsModal(true)
|
||||
break
|
||||
}
|
||||
})
|
||||
)
|
||||
@ -234,6 +242,18 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
onOpenChange={(open: boolean) => setShowShareModal(open)}
|
||||
/>
|
||||
)}
|
||||
{showLabelsModal && (
|
||||
<EditLabelsModal
|
||||
labels={labels}
|
||||
article={props.article}
|
||||
onOpenChange={() => {
|
||||
setShowLabelsModal(false)
|
||||
}}
|
||||
setLabels={(labels: string[]) => {
|
||||
setLabels(labels)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
111
packages/web/components/templates/article/EditLabelsModal.tsx
Normal file
111
packages/web/components/templates/article/EditLabelsModal.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalRoot,
|
||||
} from '../../elements/ModalPrimitives'
|
||||
import { HStack, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { CrossIcon } from '../../elements/images/CrossIcon'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { ChangeEvent, useCallback, useState } from 'react'
|
||||
import { Label } from '../../elements/Label'
|
||||
import { setLabelsMutation } from '../../../lib/networking/mutations/setLabelsMutation'
|
||||
import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
|
||||
type EditLabelsModalProps = {
|
||||
labels: string[]
|
||||
article: ArticleAttributes
|
||||
onOpenChange: (open: boolean) => void
|
||||
setLabels: (labels: string[]) => void
|
||||
}
|
||||
|
||||
export function EditLabelsModal(props: EditLabelsModalProps): JSX.Element {
|
||||
const [selectedLabels, setSelectedLabels] = useState(props.labels)
|
||||
const { labels } = useGetLabelsQuery()
|
||||
|
||||
const saveAndExit = useCallback(async () => {
|
||||
const result = await setLabelsMutation(props.article.linkId, selectedLabels)
|
||||
console.log('result of setting labels', result)
|
||||
props.onOpenChange(false)
|
||||
props.setLabels(selectedLabels)
|
||||
}, [props, selectedLabels])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const label = event.target.value
|
||||
if (event.target.checked) {
|
||||
setSelectedLabels([...selectedLabels, label])
|
||||
} else {
|
||||
setSelectedLabels(selectedLabels.filter((l) => l !== label))
|
||||
}
|
||||
},
|
||||
[selectedLabels]
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalRoot defaultOpen onOpenChange={saveAndExit}>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
css={{ overflow: 'auto', p: '0' }}
|
||||
>
|
||||
<VStack distribution="start" css={{ p: '0' }}>
|
||||
<HStack
|
||||
distribution="between"
|
||||
alignment="center"
|
||||
css={{ width: '100%' }}
|
||||
>
|
||||
<StyledText style="modalHeadline" css={{ p: '16px' }}>
|
||||
Edit Labels
|
||||
</StyledText>
|
||||
<Button
|
||||
css={{ pt: '16px', pr: '16px' }}
|
||||
style="ghost"
|
||||
onClick={() => {
|
||||
props.onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<CrossIcon
|
||||
size={20}
|
||||
strokeColor={theme.colors.grayText.toString()}
|
||||
/>
|
||||
</Button>
|
||||
</HStack>
|
||||
{labels &&
|
||||
labels.map((label) => (
|
||||
<HStack
|
||||
key={label.id}
|
||||
css={{ height: '50px', verticalAlign: 'middle' }}
|
||||
onClick={() => {
|
||||
if (selectedLabels.includes(label.id)) {
|
||||
setSelectedLabels(
|
||||
selectedLabels.filter((id) => id !== label.id)
|
||||
)
|
||||
} else {
|
||||
setSelectedLabels([...selectedLabels, label.id])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Label color={label.color} text={label.name} />
|
||||
<input
|
||||
type="checkbox"
|
||||
value={label.id}
|
||||
onChange={handleChange}
|
||||
checked={selectedLabels.includes(label.id)}
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
<HStack css={{ width: '100%', mb: '16px' }} alignment="center">
|
||||
<Button style="ctaDarkYellow" onClick={saveAndExit}>
|
||||
Save
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
)
|
||||
}
|
||||
@ -210,6 +210,7 @@ type ArticleKeyboardAction =
|
||||
| 'openOriginalArticle'
|
||||
| 'incrementFontSize'
|
||||
| 'decrementFontSize'
|
||||
| 'editLabels'
|
||||
|
||||
export function articleKeyboardCommands(
|
||||
actionHandler: (action: ArticleKeyboardAction) => void
|
||||
@ -233,5 +234,11 @@ export function articleKeyboardCommands(
|
||||
shortcutKeyDescription: '-',
|
||||
callback: () => actionHandler('decrementFontSize'),
|
||||
},
|
||||
{
|
||||
shortcutKeys: ['l'],
|
||||
actionDescription: 'Edit labels',
|
||||
shortcutKeyDescription: 'l',
|
||||
callback: () => actionHandler('editLabels'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ export const articleFragment = gql`
|
||||
slug
|
||||
isArchived
|
||||
description
|
||||
linkId
|
||||
}
|
||||
`
|
||||
|
||||
@ -38,4 +39,5 @@ export type ArticleFragmentData = {
|
||||
slug: string
|
||||
isArchived: boolean
|
||||
description: string
|
||||
linkId?: string
|
||||
}
|
||||
|
||||
18
packages/web/lib/networking/fragments/labelFragment.ts
Normal file
18
packages/web/lib/networking/fragments/labelFragment.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { gql } from 'graphql-request'
|
||||
|
||||
export const labelFragment = gql`
|
||||
fragment LabelFields on Label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
export type Label = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
description?: string
|
||||
createdAt: string
|
||||
}
|
||||
42
packages/web/lib/networking/mutations/createLabelMutation.ts
Normal file
42
packages/web/lib/networking/mutations/createLabelMutation.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
export async function createLabelMutation(
|
||||
name: string,
|
||||
color: string,
|
||||
description?: string
|
||||
): Promise<any | undefined> {
|
||||
const mutation = gql`
|
||||
mutation {
|
||||
createLabel(
|
||||
input: {
|
||||
color: "${color}"
|
||||
name: "${name}"
|
||||
description: "${description}"
|
||||
}
|
||||
) {
|
||||
... on CreateLabelSuccess {
|
||||
label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
... on CreateLabelError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation)
|
||||
console.log('created label', data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('createLabelMutation error', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
34
packages/web/lib/networking/mutations/deleteLabelMutation.ts
Normal file
34
packages/web/lib/networking/mutations/deleteLabelMutation.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
export async function deleteLabelMutation(
|
||||
labelId: string
|
||||
): Promise<any | undefined> {
|
||||
const mutation = gql`
|
||||
mutation {
|
||||
deleteLabel(id: "${labelId}") {
|
||||
... on DeleteLabelSuccess {
|
||||
label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
... on DeleteLabelError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation)
|
||||
console.log('deleted label', data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('deleteLabelMutation error', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
31
packages/web/lib/networking/mutations/setLabelsMutation.ts
Normal file
31
packages/web/lib/networking/mutations/setLabelsMutation.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
|
||||
export async function setLabelsMutation(
|
||||
linkId: string,
|
||||
labelIds: string[]
|
||||
): Promise<any | undefined> {
|
||||
const mutation = gql`
|
||||
mutation SetLabels($input: SetLabelsInput!) {
|
||||
setLabels(input: $input) {
|
||||
... on SetLabelsSuccess {
|
||||
labels {
|
||||
id
|
||||
}
|
||||
}
|
||||
... on SetLabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation, { input: { linkId, labelIds } })
|
||||
console.log(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('SetLabelsOutput error', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import useSWRImmutable, { useSWRConfig } from 'swr'
|
||||
import useSWRImmutable from 'swr'
|
||||
import { makeGqlFetcher, RequestContext, ssrFetcher } from '../networkHelpers'
|
||||
import { articleFragment, ContentReader } from '../fragments/articleFragment'
|
||||
import { highlightFragment, Highlight } from '../fragments/highlightFragment'
|
||||
import { Highlight, highlightFragment } from '../fragments/highlightFragment'
|
||||
import { ScopedMutator } from 'swr/dist/types'
|
||||
import { Label, labelFragment } from '../fragments/labelFragment'
|
||||
|
||||
type ArticleQueryInput = {
|
||||
username?: string
|
||||
@ -46,6 +47,8 @@ export type ArticleAttributes = {
|
||||
content: string
|
||||
shareInfo?: ArticleShareInfo
|
||||
highlights: Highlight[]
|
||||
linkId: string
|
||||
labels?: Label[]
|
||||
}
|
||||
|
||||
type ArticleShareInfo = {
|
||||
@ -73,6 +76,9 @@ const query = gql`
|
||||
highlights(input: { includeFriends: $includeFriendsHighlights }) {
|
||||
...HighlightFields
|
||||
}
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ArticleError {
|
||||
@ -82,10 +88,16 @@ const query = gql`
|
||||
}
|
||||
${articleFragment}
|
||||
${highlightFragment}
|
||||
${labelFragment}
|
||||
`
|
||||
export const cacheArticle = (mutate: ScopedMutator, username: string, article: ArticleAttributes, includeFriendsHighlights = false) => {
|
||||
export const cacheArticle = (
|
||||
mutate: ScopedMutator,
|
||||
username: string,
|
||||
article: ArticleAttributes,
|
||||
includeFriendsHighlights = false
|
||||
) => {
|
||||
mutate([query, username, article.slug, includeFriendsHighlights], {
|
||||
article: { article: {...article, cached: true} }
|
||||
article: { article: { ...article, cached: true } },
|
||||
})
|
||||
}
|
||||
|
||||
@ -134,4 +146,3 @@ export async function articleQuery(
|
||||
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
|
||||
70
packages/web/lib/networking/queries/useGetLabelsQuery.tsx
Normal file
70
packages/web/lib/networking/queries/useGetLabelsQuery.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import useSWR from 'swr'
|
||||
import { publicGqlFetcher } from '../networkHelpers'
|
||||
|
||||
type LabelsQueryResponse = {
|
||||
isValidating: boolean
|
||||
labels: Label[]
|
||||
revalidate: () => void
|
||||
}
|
||||
|
||||
type LabelsResponseData = {
|
||||
labels?: LabelsData
|
||||
}
|
||||
|
||||
type LabelsData = {
|
||||
labels?: unknown
|
||||
}
|
||||
|
||||
export type Label = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
description?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export function useGetLabelsQuery(): LabelsQueryResponse {
|
||||
const query = gql`
|
||||
query GetLabels {
|
||||
labels {
|
||||
... on LabelsSuccess {
|
||||
labels {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
... on LabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher)
|
||||
|
||||
try {
|
||||
if (data) {
|
||||
const result = data as LabelsResponseData
|
||||
const labels = result.labels?.labels as Label[]
|
||||
return {
|
||||
isValidating,
|
||||
labels,
|
||||
revalidate: () => {
|
||||
mutate()
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
return {
|
||||
isValidating: false,
|
||||
labels: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
revalidate: () => {},
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { gql } from 'graphql-request'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { gqlFetcher } from '../networkHelpers'
|
||||
import { articleFragment } from '../fragments/articleFragment'
|
||||
import type { ArticleFragmentData } from '../fragments/articleFragment'
|
||||
import { articleFragment } from '../fragments/articleFragment'
|
||||
import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation'
|
||||
import { deleteLinkMutation } from '../mutations/deleteLinkMutation'
|
||||
import { articleReadingProgressMutation } from '../mutations/articleReadingProgressMutation'
|
||||
@ -132,7 +132,9 @@ export function useGetLibraryItemsQuery({
|
||||
limit,
|
||||
sortDescending,
|
||||
searchQuery,
|
||||
pageIndex === 0 ? undefined : previousResult.articles.pageInfo.endCursor,
|
||||
pageIndex === 0
|
||||
? undefined
|
||||
: previousResult.articles.pageInfo.endCursor,
|
||||
]
|
||||
},
|
||||
(query, _l, _s, _sq, cursor: string) => {
|
||||
|
||||
107
packages/web/pages/settings/labels.tsx
Normal file
107
packages/web/pages/settings/labels.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
|
||||
import { Button } from '../../components/elements/Button'
|
||||
import { Box, VStack } from '../../components/elements/LayoutPrimitives'
|
||||
import { toast, Toaster } from 'react-hot-toast'
|
||||
import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { createLabelMutation } from '../../lib/networking/mutations/createLabelMutation'
|
||||
import { deleteLabelMutation } from '../../lib/networking/mutations/deleteLabelMutation'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function LabelsPage(): JSX.Element {
|
||||
const { labels, revalidate, isValidating } = useGetLabelsQuery()
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
async function createLabel(): Promise<void> {
|
||||
const res = await createLabelMutation(name, color, description)
|
||||
if (res) {
|
||||
if (res.createLabel.errorCodes && res.createLabel.errorCodes.length > 0) {
|
||||
toast.error(res.createLabel.errorCodes[0])
|
||||
} else {
|
||||
toast.success('Label created')
|
||||
setName('')
|
||||
setColor('')
|
||||
setDescription('')
|
||||
revalidate()
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to create label')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLabel(id: string): Promise<void> {
|
||||
await deleteLabelMutation(id)
|
||||
revalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimaryLayout pageTestId="settings-labels-tag">
|
||||
<Toaster />
|
||||
<VStack css={{ mx: '42px' }}>
|
||||
<h2>Create a new label</h2>
|
||||
<form
|
||||
onSubmit={async (e): Promise<void> => {
|
||||
e.preventDefault()
|
||||
await createLabel()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
placeholder="Color"
|
||||
required
|
||||
value={color}
|
||||
onChange={(event) => {
|
||||
setColor(event.target.value)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value)
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" disabled={isValidating}>
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2>Labels</h2>
|
||||
{labels &&
|
||||
labels.map((label) => {
|
||||
return (
|
||||
<Box key={label.id}>
|
||||
<form
|
||||
onSubmit={async (e): Promise<void> => {
|
||||
e.preventDefault()
|
||||
await deleteLabel(label.id)
|
||||
}}
|
||||
>
|
||||
<input type="text" value={label.name} disabled />
|
||||
<input type="text" value={label.color} disabled />
|
||||
<input type="text" value={label.description} disabled />
|
||||
<Button type="submit" disabled={isValidating}>
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
</PrimaryLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user