Merge pull request #98 from omnivore-app/feature/labels

Labels API and testing page
This commit is contained in:
Jackson Harper
2022-02-24 19:51:28 -08:00
committed by GitHub
31 changed files with 1110 additions and 165 deletions

View File

@ -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)]
}

View File

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

View File

@ -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[]
}

View 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
}

View File

@ -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[]
}

View File

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

View File

@ -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!]!
}

View File

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

View File

@ -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'),
}

View File

@ -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],
}
}
})

View File

@ -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!
}
`

View File

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

View File

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

View File

@ -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()
}

View File

@ -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)
})
})

View File

@ -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)
})
})
})

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

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

View 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>
)
}

View File

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

View 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>
)
}

View File

@ -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'),
},
]
}

View File

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

View 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
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@ -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()
}

View 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: () => {},
}
}

View File

@ -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) => {

View 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>
)
}