diff --git a/__tests__/__snapshots__/sourceRequests.ts.snap b/__tests__/__snapshots__/sourceRequests.ts.snap index db45a5bde..e2901d813 100644 --- a/__tests__/__snapshots__/sourceRequests.ts.snap +++ b/__tests__/__snapshots__/sourceRequests.ts.snap @@ -95,6 +95,7 @@ Object { exports[`mutation publishSourceRequest should publish a source request 2`] = ` MachineSource { "active": true, + "categoryId": null, "color": null, "description": null, "flags": Object {}, diff --git a/__tests__/sources.ts b/__tests__/sources.ts index 280e1f16d..d0efe6736 100644 --- a/__tests__/sources.ts +++ b/__tests__/sources.ts @@ -40,6 +40,7 @@ import { DisallowHandle } from '../src/entity/DisallowHandle'; import { NotificationType } from '../src/notifications/common'; import { SourceTagView } from '../src/entity/SourceTagView'; import { isNullOrUndefined } from '../src/common/object'; +import { SourceCategory } from '../src/entity/sources/SourceCategory'; let con: DataSource; let state: GraphQLTestingState; @@ -55,9 +56,53 @@ beforeAll(async () => { client = state.client; }); +const getSourceCategories = () => [ + { + title: 'General', + enabled: true, + }, + { + title: 'Web', + enabled: true, + }, + { + title: 'Mobile', + enabled: true, + }, + { + title: 'Games', + enabled: true, + }, + { + title: 'DevOps', + enabled: true, + }, + { + title: 'Cloud', + enabled: true, + }, + { + title: 'Career', + enabled: true, + }, + { + title: 'Data', + enabled: true, + }, + { + title: 'Fun', + enabled: true, + }, + { + title: 'DevTools', + enabled: true, + }, +]; + beforeEach(async () => { loggedUser = null; premiumUser = false; + await saveFixtures(con, SourceCategory, getSourceCategories()); await saveFixtures(con, Source, [ sourcesFixture[0], sourcesFixture[1], @@ -105,16 +150,52 @@ beforeEach(async () => { afterAll(() => disposeGraphQLTesting(state)); +describe('query sourceCategories', () => { + it('should return source categories', async () => { + const res = await client.query(` + query SourceCategories($first: Int, $after: String) { + sourceCategories(first: $first, after: $after) { + pageInfo { + endCursor + hasNextPage + } + edges { + node { + id + title + } + } + } + } + `); + expect(res.errors).toBeFalsy(); + const categories = getSourceCategories(); + const isAllFound = res.data.sourceCategories.edges.every(({ node }) => + categories.some((category) => category.title === node.title), + ); + expect(isAllFound).toBeTruthy(); + }); +}); + describe('query sources', () => { - const QUERY = ( + interface Props { + first: number; + featured: boolean; + filterOpenSquads: boolean; + categoryId: string; + } + + const QUERY = ({ first = 10, filterOpenSquads = false, - featured?: boolean, - ): string => `{ + featured, + categoryId, + }: Partial = {}): string => `{ sources( first: ${first}, filterOpenSquads: ${filterOpenSquads} ${isNullOrUndefined(featured) ? '' : `, featured: ${featured}`} + ${isNullOrUndefined(categoryId) ? '' : `, categoryId: "${categoryId}"`} ) { pageInfo { endCursor @@ -132,17 +213,39 @@ describe('query sources', () => { flags { featured } + category { + id + } } } } }`; it('should return only public sources', async () => { - const res = await client.query(QUERY(10, true)); + const res = await client.query( + QUERY({ first: 10, filterOpenSquads: true }), + ); const isPublic = res.data.sources.edges.every(({ node }) => !!node.public); expect(isPublic).toBeTruthy(); }); + it('should filter by category', async () => { + const repo = con.getRepository(Source); + const general = await con + .getRepository(SourceCategory) + .findOneByOrFail({ title: 'General' }); + const web = await con + .getRepository(SourceCategory) + .findOneByOrFail({ title: 'Web' }); + await repo.update({ id: 'a' }, { categoryId: general.id }); + await repo.update({ id: 'b' }, { categoryId: web.id }); + const res = await client.query(QUERY({ first: 10, categoryId: web.id })); + const isAllWeb = res.data.sources.edges.every( + ({ node }) => node.category.id === web.id, + ); + expect(isAllWeb).toBeTruthy(); + }); + const prepareFeaturedTests = async () => { const repo = con.getRepository(Source); await repo.update( @@ -157,7 +260,9 @@ describe('query sources', () => { it('should return only featured sources', async () => { await prepareFeaturedTests(); - const res = await client.query(QUERY(10, false, true)); + const res = await client.query( + QUERY({ first: 10, filterOpenSquads: false, featured: true }), + ); const isFeatured = res.data.sources.edges.every( ({ node }) => !!node.flags.featured, ); @@ -166,7 +271,13 @@ describe('query sources', () => { it('should return only not featured sources', async () => { await prepareFeaturedTests(); - const res = await client.query(QUERY(10, false, false)); + const res = await client.query( + QUERY({ + first: 10, + filterOpenSquads: false, + featured: false, + }), + ); const isNotFeatured = res.data.sources.edges.every( ({ node }) => !node.flags.featured, ); @@ -174,7 +285,7 @@ describe('query sources', () => { }); it('should flag that more pages available', async () => { - const res = await client.query(QUERY(1)); + const res = await client.query(QUERY({ first: 1 })); expect(res.data.sources.pageInfo.hasNextPage).toBeTruthy(); }); @@ -197,7 +308,9 @@ describe('query sources', () => { const prepareSquads = async () => { const repo = con.getRepository(Source); - const res = await client.query(QUERY(10, true)); + const res = await client.query( + QUERY({ first: 10, filterOpenSquads: true }), + ); expect(res.errors).toBeFalsy(); expect(res.data.sources.edges.length).toEqual(0); @@ -211,7 +324,9 @@ describe('query sources', () => { it('should return only public squads', async () => { await prepareSquads(); - const res = await client.query(QUERY(10, true)); + const res = await client.query( + QUERY({ first: 10, filterOpenSquads: true }), + ); expect(res.errors).toBeFalsy(); expect(res.data.sources.edges.length).toEqual(1); const allSquad = res.data.sources.edges.every( @@ -222,7 +337,9 @@ describe('query sources', () => { it('should return public squad color and headerImage', async () => { await prepareSquads(); - const res = await client.query(QUERY(10, true)); + const res = await client.query( + QUERY({ first: 10, filterOpenSquads: true }), + ); expect(res.errors).toBeFalsy(); expect(res.data.sources.edges.length).toEqual(1); expect(res.data.sources.edges[0].node.public).toBeTruthy(); diff --git a/src/entity/Source.ts b/src/entity/Source.ts index d74ee1cb6..b6c209827 100644 --- a/src/entity/Source.ts +++ b/src/entity/Source.ts @@ -3,6 +3,7 @@ import { Column, Entity, Index, + ManyToOne, OneToMany, PrimaryColumn, TableInheritance, @@ -11,6 +12,7 @@ import { SourceDisplay } from './SourceDisplay'; import { SourceFeed } from './SourceFeed'; import { Post } from './posts'; import { SourceMember } from './SourceMember'; +import { SourceCategory } from './sources/SourceCategory'; export const COMMUNITY_PICKS_SOURCE = 'community'; @@ -109,6 +111,15 @@ export class Source { @Index('IDX_source_flags_featured', { synchronize: false }) flags: SourceFlagsPublic; + @Column({ type: 'text', nullable: true }) + categoryId?: string; + + @ManyToOne(() => SourceCategory, (category) => category.id, { + lazy: true, + onDelete: 'SET NULL', + }) + category: Promise; + @OneToMany(() => SourceDisplay, (display) => display.source, { lazy: true }) displays: Promise; diff --git a/src/entity/sources/SourceCategory.ts b/src/entity/sources/SourceCategory.ts new file mode 100644 index 000000000..bfd5a2693 --- /dev/null +++ b/src/entity/sources/SourceCategory.ts @@ -0,0 +1,25 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +export class SourceCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'text', unique: true }) + title: string; + + @Column() + enabled: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 9e4292e69..0aba763a6 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -266,6 +266,9 @@ const obj = new GraphORM({ }, }, }, + SourceCategory: { + requiredColumns: ['createdAt'], + }, Source: { requiredColumns: ['id', 'private', 'handle', 'type'], fields: { diff --git a/src/migration/1723579275223-SourceCategory.ts b/src/migration/1723579275223-SourceCategory.ts new file mode 100644 index 000000000..45c0ae165 --- /dev/null +++ b/src/migration/1723579275223-SourceCategory.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SourceCategory1723579275223 implements MigrationInterface { + name = 'SourceCategory1723579275223'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "source_category" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" text NOT NULL, "enabled" boolean NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_66d6dccf282b8104ef9c44c0fb5" UNIQUE ("title"), CONSTRAINT "PK_21e4d5359f2a23fd10053f516e9" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`ALTER TABLE "source" ADD "categoryId" uuid`); + await queryRunner.query( + `ALTER TABLE "source" ADD CONSTRAINT "FK_02e1cbb6e33fa90e68dd56de2a9" FOREIGN KEY ("categoryId") REFERENCES "source_category"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query(` + INSERT INTO "source_category" + (title, enabled) + VALUES + ('General', true), + ('Web', true), + ('Mobile', true), + ('Games', true), + ('DevOps', true), + ('Cloud', true), + ('Career', true), + ('Data', true), + ('Fun', true), + ('DevTools', true) + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`TRUNCATE "source_category"`); + await queryRunner.query( + `ALTER TABLE "source" DROP CONSTRAINT "FK_02e1cbb6e33fa90e68dd56de2a9"`, + ); + await queryRunner.query(`ALTER TABLE "source" DROP COLUMN "categoryId"`); + await queryRunner.query(`DROP TABLE "source_category"`); + } +} diff --git a/src/schema/sources.ts b/src/schema/sources.ts index 77bffca37..fb0d39192 100644 --- a/src/schema/sources.ts +++ b/src/schema/sources.ts @@ -34,6 +34,7 @@ import { } from 'typeorm'; import { GQLUser } from './users'; import { Connection } from 'graphql-relay/index'; +import { queryPaginatedByDate } from '../common/datePageGenerator'; import { FileUpload } from 'graphql-upload/GraphQLUpload'; import { randomUUID } from 'crypto'; import { @@ -67,6 +68,14 @@ import { PopularVideoSource } from '../entity/PopularVideoSource'; import { EntityTarget } from 'typeorm/common/EntityTarget'; import { traceResolvers } from './trace'; +export interface GQLSourceCategory { + id: string; + title: string; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + export interface GQLSource { id: string; type: SourceType; @@ -115,6 +124,14 @@ export const typeDefs = /* GraphQL */ ` totalUpvotes: Int } + type SourceCategory { + id: ID! + title: String! + enabled: Boolean! + createdAt: DateTime! + updatedAt: DateTime + } + """ Source to discover posts from (usually blogs) """ @@ -218,6 +235,11 @@ export const typeDefs = /* GraphQL */ ` URL for inviting and referring new users """ referralUrl: String + + """ + Category that the source/squad belongs to + """ + category: SourceCategory } type SourceConnection { @@ -234,6 +256,20 @@ export const typeDefs = /* GraphQL */ ` cursor: String! } + type SourceCategoryConnection { + pageInfo: PageInfo! + edges: [SourceCategoryEdge!]! + } + + type SourceCategoryEdge { + node: SourceCategory! + + """ + Used in \`before\` and \`after\` args + """ + cursor: String! + } + type SourceMemberFlagsPublic { """ Whether the source posts are hidden from feed for member @@ -324,6 +360,11 @@ export const typeDefs = /* GraphQL */ ` Add filter for featured sources """ featured: Boolean + + """ + Filter by category + """ + categoryId: String ): SourceConnection! """ @@ -510,6 +551,21 @@ export const typeDefs = /* GraphQL */ ` Check if source handle already exists """ sourceHandleExists(handle: String!): Boolean! @auth + + """ + Fetch all source categories + """ + sourceCategories( + """ + Paginate after opaque cursor + """ + after: String + + """ + Paginate first + """ + first: Int + ): SourceCategoryConnection! } extend type Mutation { @@ -1021,6 +1077,7 @@ export const getPermissionsForMember = ( interface SourcesArgs extends ConnectionArguments { filterOpenSquads?: boolean; + categoryId?: string; featured?: boolean; } @@ -1124,6 +1181,19 @@ export const resolvers: IResolvers = traceResolvers< BaseContext >({ Query: { + sourceCategories: async ( + _, + args, + ctx: Context, + info, + ): Promise> => + queryPaginatedByDate( + ctx, + info, + args, + { key: 'createdAt' }, + { orderByKey: 'DESC' }, + ), sources: async ( _, args: SourcesArgs, @@ -1137,6 +1207,10 @@ export const resolvers: IResolvers = traceResolvers< filter.private = false; } + if (args.categoryId) { + filter.categoryId = args.categoryId; + } + const page = sourcePageGenerator.connArgsToPage(args); return graphorm.queryPaginated( ctx,