From e02543f2b44c8ebd8d9c7d030aa2ce7f8c5e4763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Tue, 10 Dec 2024 12:22:17 +0000 Subject: [PATCH 01/35] chore: add tenant_id to cypress environment --- packages/cypress/.gitignore | 3 +- packages/cypress/scripts/start.mts | 9 +++ .../integration/questions/discussions.spec.ts | 62 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/cypress/src/integration/questions/discussions.spec.ts diff --git a/packages/cypress/.gitignore b/packages/cypress/.gitignore index c8d6cd5b9b..73a266c5cb 100644 --- a/packages/cypress/.gitignore +++ b/packages/cypress/.gitignore @@ -3,4 +3,5 @@ coverage screenshots fixtures/seed/*.rej build -*.js \ No newline at end of file +*.js +cypress.env.json \ No newline at end of file diff --git a/packages/cypress/scripts/start.mts b/packages/cypress/scripts/start.mts index d534ecde2b..c706d1d8c7 100644 --- a/packages/cypress/scripts/start.mts +++ b/packages/cypress/scripts/start.mts @@ -48,6 +48,14 @@ const e2eEnv = config() const isCi = process.argv.includes('ci') // const isProduction = process.argv.includes('prod') +const tenantId = generateAlphaNumeric(8) + +fs.writeFileSync( + 'cypress.env.json', + JSON.stringify({ + TENANT_ID: tenantId, + }), +) // Prevent unhandled errors being silently ignored process.on('unhandledRejection', (err) => { @@ -110,6 +118,7 @@ async function startAppServer() { env: { ...process.env, VITE_SITE_VARIANT: 'test-ci', + TENANT_ID: tenantId, }, }) diff --git a/packages/cypress/src/integration/questions/discussions.spec.ts b/packages/cypress/src/integration/questions/discussions.spec.ts new file mode 100644 index 0000000000..dcaa07c311 --- /dev/null +++ b/packages/cypress/src/integration/questions/discussions.spec.ts @@ -0,0 +1,62 @@ +// This is basically an identical set of steps to the discussion tests for +// how-tos and research. Any changes here should be replicated there. + +import { MOCK_DATA } from '../../data' +// import { question } from '../../fixtures/question' +// import { generateNewUserDetails } from '../../utils/TestUtils' + +describe('[Questions.Discussions]', () => { + it('can open using deep links', () => { + const item = Object.values(MOCK_DATA.questions)[0] + // const discussion = Object.values(MOCK_DATA.discussions).find( + // ({ sourceId }) => sourceId === item._id, + // ) + // const firstComment = discussion.comments[0] + cy.visit(`/questions/${item.slug}`) //#comment:${firstComment._id}`) + // cy.wait(2000) + // cy.checkCommentItem('@demo_user - I like your logo', 2) + }) + + // it('allows authenticated users to contribute to discussions', () => { + // const visitor = generateNewUserDetails() + // cy.addQuestion(question, visitor) + // cy.signUpNewUser(visitor) + // const newComment = `An interesting question. The answer must be... ${visitor.username}` + // const updatedNewComment = `An interesting question. The answer must be that when the sky is red, the apocalypse _might_ be on the way. Love, ${visitor.username}` + // const newReply = `Thanks Dave and Ben. What does everyone else think? - ${visitor.username}` + // const updatedNewReply = `Anyone else? Your truly ${visitor.username}` + // const questionPath = `/questions/quick-question-for-${visitor.username}` + // cy.step('Can add comment') + // cy.visit(questionPath) + // cy.contains('Start the discussion') + // cy.contains('0 comments') + // cy.addComment(newComment) + // cy.contains('1 comment') + // cy.step('Can edit their comment') + // cy.editDiscussionItem('CommentItem', newComment, updatedNewComment) + // cy.step('Another user can add reply') + // const secondCommentor = generateNewUserDetails() + // cy.logout() + // cy.signUpNewUser(secondCommentor) + // cy.visit(questionPath) + // cy.addReply(newReply) + // cy.wait(1000) + // cy.contains('2 comments') + // cy.step('Can edit their reply') + // cy.editDiscussionItem('ReplyItem', newReply, updatedNewReply) + // cy.step('Another user can leave a reply') + // const secondReply = `Quick reply. ${visitor.username}` + // cy.step('First commentor can respond') + // cy.logout() + // cy.login(visitor.email, visitor.password) + // cy.visit(questionPath) + // cy.addReply(secondReply) + // cy.step('Can delete their comment') + // cy.deleteDiscussionItem('CommentItem', updatedNewComment) + // cy.step('Replies still show for deleted comments') + // cy.get('[data-cy="deletedComment"]').should('be.visible') + // cy.get('[data-cy=OwnReplyItem]').contains(secondReply) + // cy.step('Can delete their reply') + // cy.deleteDiscussionItem('ReplyItem', secondReply) + // }) +}) From 1d508aa84286126a58296e3794d430f0e1196217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Tue, 10 Dec 2024 16:34:20 +0000 Subject: [PATCH 02/35] chore: seed database --- packages/cypress/scripts/start.mts | 3 ++ .../integration/questions/discussions.spec.ts | 51 +++++++++++++++++++ packages/cypress/src/support/commands.ts | 39 ++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/packages/cypress/scripts/start.mts b/packages/cypress/scripts/start.mts index c706d1d8c7..1b3aba8a65 100644 --- a/packages/cypress/scripts/start.mts +++ b/packages/cypress/scripts/start.mts @@ -45,6 +45,7 @@ export const generateAlphaNumeric = (length: number) => { } const e2eEnv = config() +config({ path: '.env.local' }) const isCi = process.argv.includes('ci') // const isProduction = process.argv.includes('prod') @@ -54,6 +55,8 @@ fs.writeFileSync( 'cypress.env.json', JSON.stringify({ TENANT_ID: tenantId, + SUPABASE_API_URL: process.env.SUPABASE_API_URL, + SUPABASE_KEY: process.env.SUPABASE_KEY, }), ) diff --git a/packages/cypress/src/integration/questions/discussions.spec.ts b/packages/cypress/src/integration/questions/discussions.spec.ts index dcaa07c311..32c1c040e6 100644 --- a/packages/cypress/src/integration/questions/discussions.spec.ts +++ b/packages/cypress/src/integration/questions/discussions.spec.ts @@ -2,16 +2,58 @@ // how-tos and research. Any changes here should be replicated there. import { MOCK_DATA } from '../../data' +import { clearDatabase, seedDatabase } from '../../support/commands' // import { question } from '../../fixtures/question' // import { generateNewUserDetails } from '../../utils/TestUtils' describe('[Questions.Discussions]', () => { + before(() => { + cy.then(async () => { + const tenantId = Cypress.env('TENANT_ID') + Cypress.log({ + displayName: 'Seeding database for tenant', + message: tenantId, + }) + + const profileData = await seedDatabase( + { + profiles: [ + { + firebase_auth_id: 'demo_user', + username: 'demo_user', + tenant_id: tenantId, + created_at: new Date().toUTCString(), + display_name: 'Demo User', + is_verified: false, + }, + ], + }, + tenantId, + ) + await seedDatabase( + { + comments: [ + { + tenant_id: tenantId, + created_at: new Date().toUTCString(), + comment: 'First comment', + created_by: profileData.profiles.data[0].id, + source_type: 'question', + }, + ], + }, + tenantId, + ) + }) + }) + it('can open using deep links', () => { const item = Object.values(MOCK_DATA.questions)[0] // const discussion = Object.values(MOCK_DATA.discussions).find( // ({ sourceId }) => sourceId === item._id, // ) // const firstComment = discussion.comments[0] + cy.visit(`/questions/${item.slug}`) //#comment:${firstComment._id}`) // cy.wait(2000) // cy.checkCommentItem('@demo_user - I like your logo', 2) @@ -59,4 +101,13 @@ describe('[Questions.Discussions]', () => { // cy.step('Can delete their reply') // cy.deleteDiscussionItem('ReplyItem', secondReply) // }) + + after(() => { + const tenantId = Cypress.env('TENANT_ID') + Cypress.log({ + displayName: 'Clearing database for tenant', + message: tenantId, + }) + clearDatabase(['profiles', 'comments'], tenantId) + }) }) diff --git a/packages/cypress/src/support/commands.ts b/packages/cypress/src/support/commands.ts index b488584d03..b5cf7f0827 100644 --- a/packages/cypress/src/support/commands.ts +++ b/packages/cypress/src/support/commands.ts @@ -1,5 +1,6 @@ import 'cypress-file-upload' +import { createClient } from '@supabase/supabase-js' import { signInWithEmailAndPassword } from 'firebase/auth' import { deleteDB } from 'idb' @@ -9,6 +10,10 @@ import type { IHowtoDB, IQuestionDB, IResearchDB } from 'oa-shared' import type { IUserSignUpDetails } from '../utils/TestUtils' import type { firebase } from './db/firebase' +type SeedData = { + [tableName: string]: Array> +} + declare global { namespace Cypress { interface Chainable { @@ -235,3 +240,37 @@ Cypress.Commands.add('checkCommentItem', (comment: string, length: number) => { cy.checkCommentInViewport() cy.contains(comment) }) + +const supabaseClient = (tenantId: string) => + createClient(Cypress.env('SUPABASE_API_URL'), Cypress.env('SUPABASE_KEY'), { + global: { + headers: { + 'x-tenant-id': tenantId, + }, + }, + }) + +export const seedDatabase = async ( + data: SeedData, + tenantId: string, +): Promise => { + const supabase = supabaseClient(tenantId) + const results = {} + + // Convert to Promise.All + for (const [table, rows] of Object.entries(data)) { + results[table] = await supabase.from(table).insert(rows).select() + } + + return results +} + +export const clearDatabase = async (tables: string[], tenantId: string) => { + const supabase = supabaseClient(tenantId) + + await Promise.all( + tables.map((table) => + supabase.from(table).delete().eq('tenant_id', tenantId), + ), + ) +} From 4df42f0a0bfd5ed5f57ff56e2b371e6525d067f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Tue, 10 Dec 2024 21:13:53 +0000 Subject: [PATCH 03/35] wip questions --- src/routes/_.questions.$slug._index.tsx | 54 +++++++++++++++++++------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/routes/_.questions.$slug._index.tsx b/src/routes/_.questions.$slug._index.tsx index 340d209287..4f40ff92fc 100644 --- a/src/routes/_.questions.$slug._index.tsx +++ b/src/routes/_.questions.$slug._index.tsx @@ -1,23 +1,53 @@ -import { json } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' import { NotFoundPage } from 'src/pages/NotFound/NotFound' -import { questionService } from 'src/pages/Question/question.service' import { QuestionPage } from 'src/pages/Question/QuestionPage' -import { pageViewService } from 'src/services/pageViewService.server' +import { createSupabaseServerClient } from 'src/repository/supabase.server' import { generateTags, mergeMeta } from 'src/utils/seo.utils' import type { LoaderFunctionArgs } from '@remix-run/node' -import type { IQuestionDB, IUploadedFileMeta } from 'oa-shared' +import type { IUploadedFileMeta } from 'oa-shared' -export async function loader({ params }: LoaderFunctionArgs) { - const question = await questionService.getBySlug(params.slug as string) +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, headers } = createSupabaseServerClient(request) - if (question?._id) { - // not awaited to not block the render - pageViewService.incrementViewCount('questions', question._id) + const result = await client + .from('questions') + .select( + ` + id, + created_at, + created_by, + modified_at, + comment_count, + description, + moderation, + slug, + category, + tags, + title, + total_views, + tenant_id, + profiles(id, firebase_auth_id, display_name, username, is_verified, country) + `, + ) + .or(`slug.eq.${params.slug},previous_slugs.cs.{"${params.slug}"}`) + .neq('deleted', true) + .single() + + if (result.error || !result.data) { + return Response.json({ question: null }, { headers }) + } + + const question = result.data as any + + if (question.id) { + client + .from('questions') + .update('total_views', (question.total_views || 0) + 1) + .eq('id', question.id) } - return json({ question }) + return Response.json({ question }, { headers }) } export function HydrateFallback() { @@ -27,7 +57,7 @@ export function HydrateFallback() { } export const meta = mergeMeta(({ data }) => { - const question = data?.question as IQuestionDB + const question = data?.question as DBQuestion if (!question) { return [] @@ -41,7 +71,7 @@ export const meta = mergeMeta(({ data }) => { export default function Index() { const data = useLoaderData() - const question = data.question as IQuestionDB + const question = data.question as DBQuestion if (!question) { return From e3d8b6a4f743448ac16439c30ed499d9157dd74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Tue, 17 Dec 2024 22:36:40 +0000 Subject: [PATCH 04/35] wip questions --- src/models/category.model.ts | 38 ++++++ src/models/comment.model.ts | 32 ++--- src/models/image.model.ts | 39 ++++++ src/models/moderation.model.ts | 6 + src/models/question.model.ts | 126 +++++++++++++++++++ src/models/tag.model.ts | 30 +++++ src/pages/Question/QuestionPage.tsx | 105 +++++++--------- src/pages/common/Breadcrumbs/Breadcrumbs.tsx | 15 +-- src/routes/_.questions.$slug._index.tsx | 19 +-- src/utils/formatImageListForGallery.ts | 19 +++ 10 files changed, 335 insertions(+), 94 deletions(-) create mode 100644 src/models/category.model.ts create mode 100644 src/models/image.model.ts create mode 100644 src/models/moderation.model.ts create mode 100644 src/models/question.model.ts create mode 100644 src/models/tag.model.ts diff --git a/src/models/category.model.ts b/src/models/category.model.ts new file mode 100644 index 0000000000..90f08a7b18 --- /dev/null +++ b/src/models/category.model.ts @@ -0,0 +1,38 @@ +import { Image } from './image.model' + +import type { DBImage } from './image.model' + +export class Category { + id: number + name: string + image: Image | null + + constructor(obj: Category) { + Object.assign(this, obj) + } + + static fromDB(obj: DBCategory) { + return new Category({ + id: obj.id, + name: obj.name, + image: obj.image ? Image.fromDB(obj.image) : null, + }) + } +} + +export class DBCategory { + readonly id: number + name: string + image: DBImage | null + + constructor(obj: Omit) { + Object.assign(this, obj) + } + + static toDB(obj: Category) { + return new DBCategory({ + name: obj.name, + image: obj.image ? Image.fromDB(obj.image) : null, + }) + } +} diff --git a/src/models/comment.model.ts b/src/models/comment.model.ts index 6afb447972..f9eb355bc4 100644 --- a/src/models/comment.model.ts +++ b/src/models/comment.model.ts @@ -1,27 +1,15 @@ export class DBCommentAuthor { - id: number - firebase_auth_id: string - display_name: string - username: string - photo_url: string - country: string - is_verified: boolean + readonly id: number + readonly firebase_auth_id: string + readonly display_name: string + readonly username: string + readonly photo_url: string + readonly country: string + readonly is_verified: boolean constructor(obj: DBCommentAuthor) { Object.assign(this, obj) } - - static toDB(obj: CommentAuthor) { - return new DBCommentAuthor({ - id: obj.id, - display_name: obj.name, - username: obj.username, - firebase_auth_id: obj.firebaseAuthId, - photo_url: obj.photoUrl, - is_verified: obj.isVerified, - country: obj.country, - }) - } } export class CommentAuthor { @@ -51,8 +39,9 @@ export class CommentAuthor { } export class DBComment { - id: number - created_at: string + readonly id: number + readonly created_at: string + readonly profile?: DBCommentAuthor created_by: number | null modified_at: string | null comment: string @@ -61,7 +50,6 @@ export class DBComment { source_id_legacy: string | null parent_id: number | null deleted: boolean | null - profile?: DBCommentAuthor constructor(obj: DBComment) { Object.assign(this, obj) diff --git a/src/models/image.model.ts b/src/models/image.model.ts new file mode 100644 index 0000000000..56718873fa --- /dev/null +++ b/src/models/image.model.ts @@ -0,0 +1,39 @@ +export class DBImage { + url: string + name: string + extension: string + size: number + + constructor(obj: DBImage) { + Object.assign(this, obj) + } + + static toDB(obj: Image) { + return new DBImage({ + url: obj.url, + name: obj.name, + extension: obj.extension, + size: obj.size, + }) + } +} + +export class Image { + url: string + name: string + extension: string + size: number + + constructor(obj: Image) { + Object.assign(this, obj) + } + + static fromDB(obj: DBImage) { + return new Image({ + url: obj.url, + name: obj.name, + extension: obj.extension, + size: obj.size, + }) + } +} diff --git a/src/models/moderation.model.ts b/src/models/moderation.model.ts new file mode 100644 index 0000000000..0537e2be1e --- /dev/null +++ b/src/models/moderation.model.ts @@ -0,0 +1,6 @@ +export type ModerationStatus = + | 'draft' + | 'awaiting-moderation' + | 'improvements-needed' + | 'rejected' + | 'accepted' diff --git a/src/models/question.model.ts b/src/models/question.model.ts new file mode 100644 index 0000000000..c5d14f8d63 --- /dev/null +++ b/src/models/question.model.ts @@ -0,0 +1,126 @@ +import { Category, DBCategory } from './category.model' +import { DBImage, Image } from './image.model' +import { Tag } from './tag.model' + +import type { DBTag } from './tag.model' + +export class DBQuestionAuthor { + readonly id: number + readonly firebase_auth_id: string + readonly display_name: string + readonly username: string + readonly photo_url: string + readonly country: string + readonly is_verified: boolean +} + +export class QuestionAuthor { + id: number + name: string + username: string + firebaseAuthId: string + photoUrl: string + country: string + isVerified: boolean + + constructor(obj: QuestionAuthor) { + Object.assign(this, obj) + } + + static fromDB(obj: DBQuestionAuthor) { + return new QuestionAuthor({ + id: obj.id, + name: obj.display_name, + username: obj.username, + firebaseAuthId: obj.firebase_auth_id, + photoUrl: obj.photo_url, + isVerified: obj.is_verified, + country: obj.country, + }) + } +} + +export class DBQuestion { + readonly id: number + readonly created_at: string + readonly deleted: boolean | null + readonly author?: DBQuestionAuthor + readonly useful_count?: number + readonly subscriber_count?: number + readonly total_views?: number + readonly category: DBCategory | null + readonly tags_db: DBTag[] + created_by: number | null + modified_at: string | null + title: string + slug: string + description: string + images: DBImage[] | null + category_id?: number + tagIds: number[] + + constructor(obj: Omit) { + Object.assign(this, obj) + } + + static toDB(obj: Question) { + return new DBQuestion({ + created_at: obj.createdAt.toUTCString(), + created_by: obj.createdBy?.id || null, + modified_at: obj.modifiedAt ? obj.modifiedAt.toUTCString() : null, + title: obj.title, + slug: obj.slug, + deleted: obj.deleted, + description: obj.description, + images: obj.images + ? obj.images.map((image) => DBImage.toDB(image)) + : null, + category: obj.category ? DBCategory.toDB(obj.category) : null, + tagIds: obj.tags ? obj.tags.map((tag) => tag.id) : [], + }) + } +} + +export class Question { + id: number + createdAt: Date + createdBy: QuestionAuthor | null + modifiedAt: Date | null + title: string + slug: string + description: string + images: Image[] | null + deleted: boolean + usefulCount: number + subscriberCount: number + category: Category | null + totalViews: number + tags: Tag[] | null + + constructor(obj: Question) { + Object.assign(this, obj) + } + + static fromDB(obj: DBQuestion) { + return new Question({ + id: obj.id, + createdAt: new Date(obj.created_at), + createdBy: obj.author ? QuestionAuthor.fromDB(obj.author) : null, + modifiedAt: obj.modified_at ? new Date(obj.modified_at) : null, + title: obj.title, + slug: obj.slug, + description: obj.description, + images: obj.images + ? obj.images.map((image) => Image.fromDB(image)) + : null, + deleted: obj.deleted || false, + usefulCount: obj.useful_count || 0, + subscriberCount: obj.subscriber_count || 0, + category: obj.category ? Category.fromDB(obj.category) : null, + totalViews: obj.total_views || 0, + tags: obj.tags_db ? obj.tags_db.map((tag) => Tag.fromDB(tag)) : null, + }) + } +} + +export type Reply = Omit diff --git a/src/models/tag.model.ts b/src/models/tag.model.ts new file mode 100644 index 0000000000..c4417eb0ed --- /dev/null +++ b/src/models/tag.model.ts @@ -0,0 +1,30 @@ +export class Tag { + id: number + name: string + + constructor(obj: Tag) { + Object.assign(this, obj) + } + + static fromDB(obj: DBTag) { + return new Tag({ + id: obj.id, + name: obj.name, + }) + } +} + +export class DBTag { + readonly id: number + name: string + + constructor(obj: Omit) { + Object.assign(this, obj) + } + + static toDB(obj: Tag) { + return new DBTag({ + name: obj.name, + }) + } +} diff --git a/src/pages/Question/QuestionPage.tsx b/src/pages/Question/QuestionPage.tsx index 5dd6adb5af..74228d0817 100644 --- a/src/pages/Question/QuestionPage.tsx +++ b/src/pages/Question/QuestionPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { Link } from '@remix-run/react' import { observer } from 'mobx-react' import { @@ -7,26 +7,26 @@ import { FollowButton, ImageGallery, LinkifyText, - ModerationStatus, + TagList, UsefulStatsButton, } from 'oa-components' // eslint-disable-next-line import/no-unresolved import { ClientOnly } from 'remix-utils/client-only' import { trackEvent } from 'src/common/Analytics' -import { TagList } from 'src/common/Tags/TagsList' import { Breadcrumbs } from 'src/pages/common/Breadcrumbs/Breadcrumbs' import { useQuestionStore } from 'src/stores/Question/question.store' -import { formatImagesForGallery } from 'src/utils/formatImageListForGallery' -import { buildStatisticsLabel, isAllowedToEditContent } from 'src/utils/helpers' +import { formatImagesForGalleryV2 } from 'src/utils/formatImageListForGallery' +import { buildStatisticsLabel, hasAdminRights } from 'src/utils/helpers' import { Box, Button, Card, Divider, Flex, Heading, Text } from 'theme-ui' import CommentSectionV2 from '../common/CommentsV2/CommentSectionV2' import { ContentAuthorTimestamp } from '../common/ContentAuthorTimestamp/ContentAuthorTimestamp' -import type { IQuestionDB } from 'oa-shared' +import type { IUser } from 'oa-shared' +import type { Question } from 'src/models/question.model' type QuestionPageProps = { - question: IQuestionDB + question: Question } export const QuestionPage = observer(({ question }: QuestionPageProps) => { @@ -34,44 +34,32 @@ export const QuestionPage = observer(({ question }: QuestionPageProps) => { const activeUser = store.activeUser const [voted, setVoted] = useState(false) const [subscribed, setSubscribed] = useState(false) - const [usefulCount, setUsefulCount] = useState( - question.votedUsefulBy?.length || 0, - ) + const [usefulCount, setUsefulCount] = useState(question.usefulCount) const [subscribersCount, setSubscribersCount] = useState( - question.subscribers?.length || 0, + question.subscriberCount, ) - const isEditable = - !!activeUser && isAllowedToEditContent(question, activeUser) - - useEffect(() => { - // This could be improved if we can load the user profile server-side - if (!store?.activeUser) { - return - } + const isEditable = useMemo(() => { + return ( + hasAdminRights(activeUser as IUser) || + (question.createdBy?.firebaseAuthId && + question.createdBy?.firebaseAuthId === activeUser?._authID) + ) + }, [activeUser, question.createdBy]) - if ( - store?.activeUser && - question.votedUsefulBy?.includes(store.activeUser._id) - ) { - setVoted(true) - } - - if (question.subscribers?.includes(store.activeUser._id)) { - setSubscribed(true) - } - }, [store?.activeUser]) + // TODO: Is Subscribed API + // TODO: Is Useful API const onUsefulClick = async (vote: 'add' | 'delete') => { if (!activeUser) { return } - await store.toggleUsefulByUser(question._id, activeUser?.userName) - setVoted((prev) => !prev) + // await store.toggleUsefulByUser(question._id, activeUser?.userName) + // setVoted((prev) => !prev) - setUsefulCount((prev) => { - return vote === 'add' ? prev + 1 : prev - 1 - }) + // setUsefulCount((prev) => { + // return vote === 'add' ? prev + 1 : prev - 1 + // }) trackEvent({ category: 'QuestionPage', @@ -85,18 +73,18 @@ export const QuestionPage = observer(({ question }: QuestionPageProps) => { return } - await store.toggleSubscriber(question._id, activeUser._id) - const action = subscribed ? 'Unsubscribed' : 'Subscribed' + // await store.toggleSubscriber(question._id, activeUser._id) + // const action = subscribed ? 'Unsubscribed' : 'Subscribed' - setSubscribersCount((prev) => prev + (subscribed ? -1 : 1)) - // toggle subscribed - setSubscribed((prev) => !prev) + // setSubscribersCount((prev) => prev + (subscribed ? -1 : 1)) + // // toggle subscribed + // setSubscribed((prev) => !prev) - trackEvent({ - category: 'Question', - action: action, - label: question.slug, - }) + // trackEvent({ + // category: 'Question', + // action: action, + // label: question.slug, + // }) } return ( @@ -133,23 +121,23 @@ export const QuestionPage = observer(({ question }: QuestionPageProps) => { - + /> */} - {question.questionCategory && ( - + {question.category && ( + )} { {question.images && ( )} {question.tags && ( - + ({ label: t.name }))} + /> )} @@ -192,7 +183,7 @@ export const QuestionPage = observer(({ question }: QuestionPageProps) => { { icon: 'view', label: buildStatisticsLabel({ - stat: question.total_views, + stat: question.totalViews, statUnit: 'view', usePlural: true, }), @@ -224,7 +215,7 @@ export const QuestionPage = observer(({ question }: QuestionPageProps) => { padding: 4, }} > - + )} diff --git a/src/pages/common/Breadcrumbs/Breadcrumbs.tsx b/src/pages/common/Breadcrumbs/Breadcrumbs.tsx index f5296a5547..3b195e4521 100644 --- a/src/pages/common/Breadcrumbs/Breadcrumbs.tsx +++ b/src/pages/common/Breadcrumbs/Breadcrumbs.tsx @@ -1,17 +1,18 @@ import { Breadcrumbs as BreadcrumbsComponent } from 'oa-components' -import type { IHowto, IQuestion, IResearch } from 'oa-shared' +import type { IHowto, IResearch } from 'oa-shared' +import type { Question } from 'src/models/question.model' type Step = { text: string; link?: string } interface BreadcrumbsProps { steps?: Step[] - content?: IResearch.ItemDB | IQuestion.Item | IHowto + content?: IResearch.ItemDB | Question | IHowto variant?: 'research' | 'question' | 'howto' } const generateSteps = ( - content: IResearch.ItemDB | IQuestion.Item | IHowto | undefined, + content: IResearch.ItemDB | Question | IHowto | undefined, variant: 'research' | 'question' | 'howto' | undefined, ) => { const steps: Step[] = [] @@ -28,13 +29,13 @@ const generateSteps = ( steps.push({ text: item.title }) } else if (variant == 'question') { - const item = content as IQuestion.Item + const item = content as Question steps.push({ text: 'Question', link: '/questions' }) - if (item.questionCategory) { + if (item.category) { steps.push({ - text: item.questionCategory.label, - link: `/questions?category=${item.questionCategory._id}`, + text: item.category.name, + link: `/questions?category=${item.category.id}`, }) } diff --git a/src/routes/_.questions.$slug._index.tsx b/src/routes/_.questions.$slug._index.tsx index 4f40ff92fc..79db1846c1 100644 --- a/src/routes/_.questions.$slug._index.tsx +++ b/src/routes/_.questions.$slug._index.tsx @@ -1,11 +1,12 @@ import { useLoaderData } from '@remix-run/react' +import { Question } from 'src/models/question.model' import { NotFoundPage } from 'src/pages/NotFound/NotFound' import { QuestionPage } from 'src/pages/Question/QuestionPage' import { createSupabaseServerClient } from 'src/repository/supabase.server' import { generateTags, mergeMeta } from 'src/utils/seo.utils' import type { LoaderFunctionArgs } from '@remix-run/node' -import type { IUploadedFileMeta } from 'oa-shared' +import type { DBQuestion } from 'src/models/question.model' export async function loader({ request, params }: LoaderFunctionArgs) { const { client, headers } = createSupabaseServerClient(request) @@ -38,15 +39,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return Response.json({ question: null }, { headers }) } - const question = result.data as any + const dbQuestion = result.data as unknown as DBQuestion - if (question.id) { + if (dbQuestion.id) { client .from('questions') - .update('total_views', (question.total_views || 0) + 1) - .eq('id', question.id) + .update({ total_views: (dbQuestion.total_views || 0) + 1 }) + .eq('id', dbQuestion.id) } + const question = Question.fromDB(dbQuestion) + return Response.json({ question }, { headers }) } @@ -57,21 +60,21 @@ export function HydrateFallback() { } export const meta = mergeMeta(({ data }) => { - const question = data?.question as DBQuestion + const question = data?.question as Question if (!question) { return [] } const title = `${question.title} - Question - ${import.meta.env.VITE_SITE_NAME}` - const imageUrl = (question.images?.at(0) as IUploadedFileMeta)?.downloadUrl + const imageUrl = question.images?.at(0)?.url return generateTags(title, question.description, imageUrl) }) export default function Index() { const data = useLoaderData() - const question = data.question as DBQuestion + const question = data.question as Question if (!question) { return diff --git a/src/utils/formatImageListForGallery.ts b/src/utils/formatImageListForGallery.ts index a2c66d37ed..5b612462b1 100644 --- a/src/utils/formatImageListForGallery.ts +++ b/src/utils/formatImageListForGallery.ts @@ -1,6 +1,7 @@ import { cdnImageUrl } from './cdnImageUrl' import type { IConvertedFileMeta, IUploadedFileMeta } from 'oa-shared' +import type { Image } from 'src/models/image.model' export const formatImagesForGallery = ( imageList: (IUploadedFileMeta | File | IConvertedFileMeta | null)[], @@ -21,3 +22,21 @@ export const formatImagesForGallery = ( alt: `${altPrefix ? altPrefix + ' ' : ''}Gallery image ${index + 1}`, })) } + +export const formatImagesForGalleryV2 = ( + imageList: Image[], + altPrefix?: string, +) => { + if (!imageList) { + return [] + } + + return imageList + .filter(Boolean) + .filter((i: Image) => !!i?.url) + .map((image: Image, index: number) => ({ + downloadUrl: image.url, + thumbnailUrl: image.url, // TODO + alt: `${altPrefix ? altPrefix + ' ' : ''}Gallery image ${index + 1}`, + })) +} From 69912aa6e04a997dd20137c03ec882ea79dfbffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Sat, 28 Dec 2024 17:25:33 +0000 Subject: [PATCH 05/35] feat: questions supabase --- server.js | 1 + src/common/Form/ImageInput.field.tsx | 43 ++- src/common/Form/TagsSelectFieldV2.tsx | 13 + src/common/Tags/TagsSelectV2.tsx | 71 ++++ src/models/category.model.ts | 14 - src/models/contentType.model.ts | 1 + src/models/image.model.ts | 31 +- src/models/question.model.ts | 47 ++- src/pages/Howto/Content/Common/Howto.form.tsx | 2 - .../Content/Common/HowtoFieldCategory.tsx | 29 +- .../Howto/Content/Common/HowtoFieldFiles.tsx | 9 +- .../FormFields/QuestionCategory.field.tsx | 12 +- .../Common/FormFields/QuestionImage.field.tsx | 67 ++-- .../Common/FormFields/QuestionTags.field.tsx | 9 +- .../Question/Content/Common/QuestionForm.tsx | 109 ++++-- src/pages/Question/QuestionCreate.tsx | 2 +- src/pages/Question/QuestionEdit.tsx | 8 +- src/pages/Question/QuestionFilterHeader.tsx | 4 +- src/pages/Question/QuestionListing.tsx | 9 +- src/pages/Question/QuestionPage.tsx | 93 ++++-- src/pages/Question/question.service.ts | 20 +- .../common/Category/CategoriesSelectV2.tsx | 8 +- src/routes/_.questions.$slug._index.tsx | 58 ++-- src/routes/_.questions.$slug.edit.tsx | 29 +- src/routes/api.categories.$type.ts | 23 ++ .../api.discussions.$sourceId.comments.$id.ts | 75 +++-- src/routes/api.questions.$id.ts | 251 ++++++++++++++ src/routes/api.questions.categories.ts | 24 -- src/routes/api.questions.ts | 312 ++++++++++++------ ...bers.$contentType.$contentId.subscribed.ts | 28 ++ ...api.subscribers.$contentType.$contentId.ts | 57 ++++ src/routes/api.tags.ts | 26 +- .../api.useful.$contentType.$contentId.ts | 57 ++++ ...pi.useful.$contentType.$contentId.voted.ts | 28 ++ src/services/questionService.server.ts | 32 ++ src/services/questionService.ts | 63 ++++ src/services/subscribersService.ts | 48 +++ src/services/tagsService.ts | 14 + src/services/usefulService.ts | 45 +++ src/utils/comparisons.ts | 4 + src/utils/slug.ts | 5 + src/utils/storage.ts | 8 + .../migrations/20241228172333_questions.sql | 251 ++++++++++++++ 43 files changed, 1623 insertions(+), 417 deletions(-) create mode 100644 src/common/Form/TagsSelectFieldV2.tsx create mode 100644 src/common/Tags/TagsSelectV2.tsx create mode 100644 src/models/contentType.model.ts create mode 100644 src/routes/api.categories.$type.ts create mode 100644 src/routes/api.questions.$id.ts delete mode 100644 src/routes/api.questions.categories.ts create mode 100644 src/routes/api.subscribers.$contentType.$contentId.subscribed.ts create mode 100644 src/routes/api.subscribers.$contentType.$contentId.ts create mode 100644 src/routes/api.useful.$contentType.$contentId.ts create mode 100644 src/routes/api.useful.$contentType.$contentId.voted.ts create mode 100644 src/services/questionService.server.ts create mode 100644 src/services/questionService.ts create mode 100644 src/services/subscribersService.ts create mode 100644 src/services/tagsService.ts create mode 100644 src/services/usefulService.ts create mode 100644 src/utils/slug.ts create mode 100644 src/utils/storage.ts create mode 100644 supabase/migrations/20241228172333_questions.sql diff --git a/server.js b/server.js index 03a9082a3d..c036632d13 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,7 @@ const imgSrc = [ 'onearmy.github.io', 'cdn.jsdelivr.net', '*.google-analytics.com', + process.env.SUPABASE_API_URL, ] const cdnUrl = import.meta.env?.VITE_CDN_URL || process.env?.VITE_CDN_URL diff --git a/src/common/Form/ImageInput.field.tsx b/src/common/Form/ImageInput.field.tsx index 63b3f27822..09138f1334 100644 --- a/src/common/Form/ImageInput.field.tsx +++ b/src/common/Form/ImageInput.field.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { Text } from 'theme-ui' import { ImageInput } from './ImageInput/ImageInput' @@ -23,27 +22,25 @@ export const ImageInputField = (props: IProps) => { } return ( - <> - - {meta.error && meta.touched && ( - {meta.error} - )} - - - - + + {meta.error && meta.touched && ( + {meta.error} + )} + + + ) } diff --git a/src/common/Form/TagsSelectFieldV2.tsx b/src/common/Form/TagsSelectFieldV2.tsx new file mode 100644 index 0000000000..7711a5b713 --- /dev/null +++ b/src/common/Form/TagsSelectFieldV2.tsx @@ -0,0 +1,13 @@ +import TagsSelectV2 from '../Tags/TagsSelectV2' + +export const TagsSelectFieldV2 = ({ input, ...rest }) => ( + input.onChange(tags)} + category={rest.category} + value={input.value} + {...rest} + /> +) diff --git a/src/common/Tags/TagsSelectV2.tsx b/src/common/Tags/TagsSelectV2.tsx new file mode 100644 index 0000000000..bc62e60474 --- /dev/null +++ b/src/common/Tags/TagsSelectV2.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react' +import { Select } from 'oa-components' +import { tagsService } from 'src/services/tagsService' + +import { FieldContainer } from '../Form/FieldContainer' + +import type { FieldRenderProps } from 'react-final-form' +import type { Tag } from 'src/models/tag.model' + +// we include props from react-final-form fields so it can be used as a custom field component +export interface IProps extends Partial> { + isForm?: boolean + value: number[] + onChange: (val: number[]) => void + styleVariant?: 'selector' | 'filter' + placeholder?: string + maxTotal?: number +} + +const TagsSelectV2 = (props: IProps) => { + const [allTags, setAllTags] = useState([]) + const [selectedTags, setSelectedTags] = useState([]) + + useEffect(() => { + const initTags = async () => { + const tags = await tagsService.getTags() + if (!tags) { + return + } + + setAllTags(tags) + } + + initTags() + }, []) + + useEffect(() => { + if (allTags.length > 0 && props.value.length > 0) { + setSelectedTags(allTags.filter((x) => props.value.includes(x.id))) + } + }, [props.value, allTags]) + + const onChange = (tags: Tag[]) => { + setSelectedTags(tags) + props.onChange(tags.map((x) => x.id)) + } + + const isOptionDisabled = () => selectedTags.length >= (props.maxTotal || 4) + + return ( + 0 ? 'tag-select' : 'tag-select-empty'} + > +