[web] workspace/discussions: workspace comments (#1689)
This commit is contained in:
parent
961f01b453
commit
25e658c0a3
|
|
@ -0,0 +1,95 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "InterviewsDiscussionCommentDomain" AS ENUM ('QUESTION', 'OFFICIAL_SOLUTION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InterviewsActivityCategory" AS ENUM ('DISCUSSION', 'DISCUSSION_UPVOTE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InterviewsDiscussionComment" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"entityId" TEXT NOT NULL,
|
||||
"domain" "InterviewsDiscussionCommentDomain" NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"profileId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"parentCommentId" UUID,
|
||||
"repliedToId" UUID,
|
||||
|
||||
CONSTRAINT "InterviewsDiscussionComment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InterviewsDiscussionCommentVote" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"commentId" UUID NOT NULL,
|
||||
"profileId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InterviewsDiscussionCommentVote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InterviewsActivity" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"actorId" UUID NOT NULL,
|
||||
"recipientId" UUID,
|
||||
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"category" "InterviewsActivityCategory" NOT NULL,
|
||||
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||
"commentId" UUID,
|
||||
"voteId" UUID,
|
||||
|
||||
CONSTRAINT "InterviewsActivity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InterviewsDiscussionComment_entityId_idx" ON "InterviewsDiscussionComment"("entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InterviewsDiscussionComment_profileId_idx" ON "InterviewsDiscussionComment"("profileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InterviewsDiscussionCommentVote_commentId_idx" ON "InterviewsDiscussionCommentVote"("commentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InterviewsDiscussionCommentVote_profileId_idx" ON "InterviewsDiscussionCommentVote"("profileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InterviewsDiscussionCommentVote_commentId_profileId_key" ON "InterviewsDiscussionCommentVote"("commentId", "profileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InterviewsActivity_voteId_key" ON "InterviewsActivity"("voteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InterviewsActivity_actorId_idx" ON "InterviewsActivity"("actorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InterviewsActivity_recipientId_idx" ON "InterviewsActivity"("recipientId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsDiscussionComment" ADD CONSTRAINT "InterviewsDiscussionComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsDiscussionComment" ADD CONSTRAINT "InterviewsDiscussionComment_repliedToId_fkey" FOREIGN KEY ("repliedToId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsDiscussionComment" ADD CONSTRAINT "InterviewsDiscussionComment_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsDiscussionCommentVote" ADD CONSTRAINT "InterviewsDiscussionCommentVote_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsDiscussionCommentVote" ADD CONSTRAINT "InterviewsDiscussionCommentVote_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_voteId_fkey" FOREIGN KEY ("voteId") REFERENCES "InterviewsDiscussionCommentVote"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -75,6 +75,10 @@ model Profile {
|
|||
projectsProfile ProjectsProfile?
|
||||
rewardsTaskCompletions RewardsTaskCompletion[]
|
||||
sponsorsAdRequestReview SponsorsAdRequestReview[]
|
||||
discussionComments InterviewsDiscussionComment[]
|
||||
discussionCommentVotes InterviewsDiscussionCommentVote[]
|
||||
activitiesGiven InterviewsActivity[] @relation("ActivityActor")
|
||||
activitiesReceived InterviewsActivity[] @relation("ActivityRecipient")
|
||||
|
||||
@@index([username])
|
||||
@@index([stripeCustomer])
|
||||
|
|
@ -276,6 +280,74 @@ model LearningSessionProgress {
|
|||
@@index([sessionId])
|
||||
}
|
||||
|
||||
enum InterviewsDiscussionCommentDomain {
|
||||
QUESTION
|
||||
OFFICIAL_SOLUTION
|
||||
}
|
||||
|
||||
model InterviewsDiscussionComment {
|
||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
entityId String
|
||||
domain InterviewsDiscussionCommentDomain
|
||||
body String
|
||||
profileId String @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
parentCommentId String? @db.Uuid // For replying to a comment
|
||||
parentComment InterviewsDiscussionComment? @relation("DiscussionThread", fields: [parentCommentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
replies InterviewsDiscussionComment[] @relation("DiscussionThread")
|
||||
|
||||
repliedToId String? @db.Uuid // For replying to a reply
|
||||
repliedTo InterviewsDiscussionComment? @relation("DirectReply", fields: [repliedToId], references: [id], onDelete: Cascade)
|
||||
directReplies InterviewsDiscussionComment[] @relation("DirectReply")
|
||||
|
||||
author Profile @relation(fields: [profileId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
votes InterviewsDiscussionCommentVote[]
|
||||
activities InterviewsActivity[]
|
||||
|
||||
@@index([entityId])
|
||||
@@index([profileId])
|
||||
}
|
||||
|
||||
model InterviewsDiscussionCommentVote {
|
||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
commentId String @db.Uuid
|
||||
profileId String @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
|
||||
comment InterviewsDiscussionComment @relation(fields: [commentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
author Profile @relation(fields: [profileId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
activity InterviewsActivity?
|
||||
|
||||
@@unique([commentId, profileId])
|
||||
@@index([commentId])
|
||||
@@index([profileId])
|
||||
}
|
||||
|
||||
enum InterviewsActivityCategory {
|
||||
DISCUSSION
|
||||
DISCUSSION_UPVOTE
|
||||
}
|
||||
model InterviewsActivity {
|
||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
actorId String @db.Uuid // who performed the action
|
||||
recipientId String? @db.Uuid // who receives the notification
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
category InterviewsActivityCategory
|
||||
read Boolean @default(false)
|
||||
commentId String? @db.Uuid // For "discussion comment"
|
||||
voteId String? @unique @db.Uuid // For "discussion comment upvote"
|
||||
|
||||
comment InterviewsDiscussionComment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
vote InterviewsDiscussionCommentVote? @relation(fields: [voteId], references: [id], onDelete: Cascade)
|
||||
actor Profile @relation("ActivityActor", fields: [actorId], references: [id], onDelete: Cascade)
|
||||
recipient Profile? @relation("ActivityRecipient", fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([actorId])
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
enum ProjectsSubscriptionPlan {
|
||||
MONTH
|
||||
ANNUAL
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export default function InterviewsMarketingEmbedJavaScriptQuestion({
|
|||
<JavaScriptCodingWorkspaceDescription
|
||||
canViewPremiumContent={false}
|
||||
description={javaScriptEmbedExample.description}
|
||||
environment="embed"
|
||||
metadata={javaScriptEmbedExample.metadata}
|
||||
nextQuestions={[]}
|
||||
showAd={false}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export default function EmptyState({
|
|||
<Icon
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'size-10 mx-auto shrink-0',
|
||||
'mx-auto size-10 shrink-0',
|
||||
iconClassName,
|
||||
colors[variant],
|
||||
)}
|
||||
|
|
@ -121,13 +121,14 @@ export default function EmptyState({
|
|||
size: titleSize,
|
||||
weight: 'medium',
|
||||
})}
|
||||
level="custom">
|
||||
level="custom"
|
||||
weight="medium">
|
||||
{title}
|
||||
</Heading>
|
||||
<Section>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className="text-pretty mt-1 block"
|
||||
className="mt-1 block text-pretty"
|
||||
color="secondary"
|
||||
size={subtitleSize}>
|
||||
{subtitle}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { INTERVIEWS_DISCUSSIONS_IS_LIVE } from '~/data/FeatureFlags';
|
||||
|
||||
import type { QuestionMetadata } from '~/components/interviews/questions/common/QuestionsTypes';
|
||||
import QuestionNextQuestions from '~/components/interviews/questions/content/QuestionNextQuestions';
|
||||
import QuestionSimilarQuestions from '~/components/interviews/questions/content/QuestionSimilarQuestions';
|
||||
import type { SponsorsAdFormatInContentPlacement } from '~/components/sponsors/ads/SponsorsAdFormatInContent';
|
||||
import SponsorsAdFormatInContentContainer from '~/components/sponsors/ads/SponsorsAdFormatInContentContainer';
|
||||
import Divider from '~/components/ui/Divider';
|
||||
import CodingWorkspaceDiscussionsSection from '~/components/workspace/common/discussions/CodingWorkspaceDiscussionsSection';
|
||||
|
||||
import { hashQuestion } from '~/db/QuestionsUtils';
|
||||
|
||||
type Props = Readonly<{
|
||||
adPlacement: SponsorsAdFormatInContentPlacement;
|
||||
className?: string;
|
||||
contentType: 'description' | 'solution';
|
||||
environment?: 'embed' | 'workspace';
|
||||
metadata: QuestionMetadata;
|
||||
nextQuestions: ReadonlyArray<QuestionMetadata>;
|
||||
showAd?: boolean;
|
||||
similarQuestions: ReadonlyArray<QuestionMetadata>;
|
||||
|
|
@ -16,12 +24,26 @@ type Props = Readonly<{
|
|||
export default function CodingWorkspaceDescriptionAddOnItems({
|
||||
adPlacement,
|
||||
className,
|
||||
contentType,
|
||||
environment = 'workspace',
|
||||
metadata,
|
||||
nextQuestions,
|
||||
showAd = true,
|
||||
similarQuestions,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{INTERVIEWS_DISCUSSIONS_IS_LIVE && environment === 'workspace' && (
|
||||
<div className="space-y-6">
|
||||
<Divider />
|
||||
<CodingWorkspaceDiscussionsSection
|
||||
domain={
|
||||
contentType === 'solution' ? 'OFFICIAL_SOLUTION' : 'QUESTION'
|
||||
}
|
||||
entityId={hashQuestion(metadata)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{nextQuestions.length > 0 || similarQuestions.length > 0 ? (
|
||||
<div>
|
||||
<Divider className="mb-3" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import { createHeadlessEditor } from '@lexical/headless';
|
||||
import { $getRoot } from 'lexical';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { IntlShape } from '~/components/intl';
|
||||
import { useIntl } from '~/components/intl';
|
||||
import { RichTextEditorConfig } from '~/components/ui/RichTextEditor/RichTextEditorConfig';
|
||||
|
||||
const MIN_LENGTH = 6;
|
||||
const MAX_LENGTH = 1000;
|
||||
|
||||
const editor = createHeadlessEditor(RichTextEditorConfig);
|
||||
|
||||
function discussionsCommentBodySchema(options?: {
|
||||
maxMessage: string;
|
||||
minMessage: string;
|
||||
}) {
|
||||
const { maxMessage, minMessage } = options ?? {};
|
||||
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => {
|
||||
const editorState = editor.parseEditorState(value);
|
||||
const text = editorState.read(() => $getRoot().getTextContent());
|
||||
|
||||
return text.length >= MIN_LENGTH;
|
||||
},
|
||||
{
|
||||
message: minMessage,
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(value) => {
|
||||
const editorState = editor.parseEditorState(value);
|
||||
const text = editorState.read(() => $getRoot().getTextContent());
|
||||
|
||||
return text.length <= MAX_LENGTH;
|
||||
},
|
||||
{
|
||||
message: maxMessage,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Figure out how to reuse intl strings for the server.
|
||||
export const discussionsCommentBodySchemaServer = discussionsCommentBodySchema({
|
||||
maxMessage: `At most ${MAX_LENGTH} character(s).`,
|
||||
minMessage: `At least ${MIN_LENGTH} character(s).`,
|
||||
});
|
||||
|
||||
export function getDiscussionsCommentBodyAttributes(intl: IntlShape) {
|
||||
const placeholder = intl.formatMessage({
|
||||
defaultMessage: 'Type comment here...',
|
||||
description: 'Placeholder for discussion post input text area',
|
||||
id: '5fQIye',
|
||||
});
|
||||
const maxMessage = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: 'Character limit exceeded',
|
||||
description: 'Error message',
|
||||
id: 'NGLLNl',
|
||||
},
|
||||
{
|
||||
maxLength: MAX_LENGTH,
|
||||
},
|
||||
);
|
||||
const minMessage = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: 'At least {minLength} character(s).',
|
||||
description: 'Error message',
|
||||
id: 'Vp6BGE',
|
||||
},
|
||||
{
|
||||
minLength: MIN_LENGTH,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
placeholder,
|
||||
validation: {
|
||||
maxLength: MAX_LENGTH,
|
||||
maxMessage,
|
||||
minLength: MIN_LENGTH,
|
||||
minMessage,
|
||||
required: true,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function useDiscussionsCommentBodySchema() {
|
||||
const intl = useIntl();
|
||||
const intlStrings = getDiscussionsCommentBodyAttributes(intl);
|
||||
|
||||
return discussionsCommentBodySchema({
|
||||
maxMessage: intlStrings.validation.maxMessage,
|
||||
minMessage: intlStrings.validation.minMessage,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { useUser } from '@supabase/auth-helpers-react';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowUpSLine,
|
||||
RiPencilLine,
|
||||
RiReplyLine,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import RelativeTimestamp from '~/components/common/datetime/RelativeTimestamp';
|
||||
import { useIntl } from '~/components/intl';
|
||||
import UserProfileDisplayName from '~/components/profile/info/UserProfileDisplayName';
|
||||
import Avatar from '~/components/ui/Avatar';
|
||||
import Button from '~/components/ui/Button';
|
||||
import RichText from '~/components/ui/RichTextEditor/RichText';
|
||||
import Text from '~/components/ui/Text';
|
||||
import { themeTextSecondaryColor } from '~/components/ui/theme';
|
||||
|
||||
import CodingWorkspaceDiscussionsCommentDeleteButton from './CodingWorkspaceDiscussionsCommentDeleteButton';
|
||||
import CodingWorkspaceDiscussionsCommentEditInput from './CodingWorkspaceDiscussionsCommentEditInput';
|
||||
import CodingWorkspaceDiscussionsCommentVoteButton from './CodingWorkspaceDiscussionsCommentVoteButton';
|
||||
import CodingWorkspaceDiscussionsReplyInput from './CodingWorkspaceDiscussionsReplyInput';
|
||||
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
comment: CodingWorkspaceDiscussionsCommentItem;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsComment({ comment }: Props) {
|
||||
const intl = useIntl();
|
||||
const user = useUser();
|
||||
const isLoggedIn = user != null;
|
||||
const {
|
||||
_count: { votes: votesCount },
|
||||
author,
|
||||
body,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = comment;
|
||||
const isUserOwnComment = user?.id === author.id;
|
||||
|
||||
const replyCount = comment.replies?.length ?? 0;
|
||||
const hasReplies = replyCount > 0;
|
||||
const [mode, setMode] = useState<'delete' | 'edit' | 'reply' | null>(null);
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex gap-3">
|
||||
<Avatar
|
||||
alt={author.name ?? author.username}
|
||||
size="xs"
|
||||
src={author.avatarUrl ?? ''}
|
||||
/>{' '}
|
||||
<div>
|
||||
<Text color="secondary" size="body2">
|
||||
<Text color="default" size="inherit">
|
||||
<UserProfileDisplayName userProfile={author} />
|
||||
</Text>
|
||||
{' · '}
|
||||
<RelativeTimestamp timestamp={createdAt} />
|
||||
</Text>
|
||||
<div className="mt-1.5">
|
||||
{mode === 'edit' ? (
|
||||
<CodingWorkspaceDiscussionsCommentEditInput
|
||||
comment={comment}
|
||||
onCancel={() => {
|
||||
setMode(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RichText
|
||||
key={updatedAt.getTime()}
|
||||
color="body"
|
||||
size="sm"
|
||||
value={body}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="-ml-2 mt-3">
|
||||
<CodingWorkspaceDiscussionsCommentVoteButton
|
||||
comment={comment}
|
||||
count={votesCount}
|
||||
/>
|
||||
{isLoggedIn && (
|
||||
<Button
|
||||
addonPosition="start"
|
||||
className={themeTextSecondaryColor}
|
||||
icon={RiReplyLine}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Reply',
|
||||
description: 'Label for reply button',
|
||||
id: 'do3v9Q',
|
||||
})}
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
onClick={() => setMode(mode === 'reply' ? null : 'reply')}
|
||||
/>
|
||||
)}
|
||||
{isUserOwnComment && (
|
||||
<>
|
||||
<Button
|
||||
addonPosition="start"
|
||||
className={themeTextSecondaryColor}
|
||||
icon={RiPencilLine}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Edit',
|
||||
description: 'Edit button label',
|
||||
id: '2rcoOT',
|
||||
})}
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
onClick={() => setMode(mode === 'edit' ? null : 'edit')}
|
||||
/>
|
||||
<CodingWorkspaceDiscussionsCommentDeleteButton
|
||||
comment={comment}
|
||||
dialogShown={mode === 'delete'}
|
||||
onClick={() => setMode('delete')}
|
||||
onDismiss={() => setMode(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'reply' && user != null && (
|
||||
<div className="ml-9 mt-2">
|
||||
<CodingWorkspaceDiscussionsReplyInput
|
||||
author={author}
|
||||
parentComment={comment}
|
||||
onCancel={() => {
|
||||
setMode(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasReplies && (
|
||||
<div className="ml-9">
|
||||
{showReplies && (
|
||||
<div className="mt-1.5">
|
||||
{comment.replies?.map((reply) => (
|
||||
<CodingWorkspaceDiscussionsComment
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
addonPosition="end"
|
||||
className={clsx('-ml-2', showReplies ? 'mt-1' : 'mt-3')}
|
||||
icon={showReplies ? RiArrowDownSLine : RiArrowUpSLine}
|
||||
label={
|
||||
showReplies
|
||||
? intl.formatMessage({
|
||||
defaultMessage: 'Hide replies',
|
||||
description: 'Label for hide replies button',
|
||||
id: 'aUHfy4',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: 'See more replies',
|
||||
description: 'Label for see more replies button',
|
||||
id: 'xufXOh',
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
onClick={() => setShowReplies(!showReplies)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { RiDeleteBinLine } from 'react-icons/ri';
|
||||
|
||||
import { trpc } from '~/hooks/trpc';
|
||||
|
||||
import ConfirmationDialog from '~/components/common/ConfirmationDialog';
|
||||
import { FormattedMessage, useIntl } from '~/components/intl';
|
||||
import Button from '~/components/ui/Button';
|
||||
import { themeTextSecondaryColor } from '~/components/ui/theme';
|
||||
|
||||
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
comment: CodingWorkspaceDiscussionsCommentItem;
|
||||
dialogShown: boolean;
|
||||
onClick: () => void;
|
||||
onDismiss: () => void;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsCommentDeleteButton({
|
||||
comment,
|
||||
dialogShown,
|
||||
onClick,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const trpcUtils = trpc.useUtils();
|
||||
const deleteCommentMutation = trpc.questionComments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
trpcUtils.questionComments.list.invalidate({
|
||||
domain: comment.domain,
|
||||
entityId: comment.entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
confirmButtonLabel={intl.formatMessage({
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button label',
|
||||
id: 'WodcPq',
|
||||
})}
|
||||
confirmButtonVariant="danger"
|
||||
isDisabled={deleteCommentMutation.isLoading}
|
||||
isLoading={deleteCommentMutation.isLoading}
|
||||
isShown={dialogShown}
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: 'Delete comment?',
|
||||
description: 'Delete comment confirmation dialog title',
|
||||
id: 'hm0ODb',
|
||||
})}
|
||||
trigger={
|
||||
<Button
|
||||
addonPosition="start"
|
||||
className={themeTextSecondaryColor}
|
||||
icon={RiDeleteBinLine}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button label',
|
||||
id: 'WodcPq',
|
||||
})}
|
||||
size="xs"
|
||||
variant="tertiary"
|
||||
onClick={onClick}
|
||||
/>
|
||||
}
|
||||
onCancel={onDismiss}
|
||||
onConfirm={() => {
|
||||
deleteCommentMutation.mutate(
|
||||
{
|
||||
commentId: comment.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onDismiss();
|
||||
},
|
||||
},
|
||||
);
|
||||
}}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Are you sure want to delete your comment? There is no undo for this action."
|
||||
description="Confirmation text for deleting a comment"
|
||||
id="GkLfUb"
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '~/hooks/trpc';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import Button from '~/components/ui/Button';
|
||||
|
||||
import { useDiscussionsCommentBodySchema } from './CodingWorkspaceDiscussionsComentBodySchema';
|
||||
import CodingWorkspaceDiscussionsCommentEditor from './CodingWorkspaceDiscussionsCommentEditor';
|
||||
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
comment: CodingWorkspaceDiscussionsCommentItem;
|
||||
onCancel: () => void;
|
||||
}>;
|
||||
|
||||
type CommentFormInput = Readonly<{
|
||||
body: string;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsCommentEditInput({
|
||||
comment,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const updateCommentMutation = trpc.questionComments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
trpcUtils.questionComments.list.invalidate({
|
||||
domain: comment.domain,
|
||||
entityId: comment.entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
const discussionsCommentBodySchema = useDiscussionsCommentBodySchema();
|
||||
|
||||
const { control, handleSubmit } = useForm<CommentFormInput>({
|
||||
defaultValues: {
|
||||
body: comment.body,
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
body: discussionsCommentBodySchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function onSubmit(data: CommentFormInput) {
|
||||
updateCommentMutation.mutate(
|
||||
{
|
||||
body: data.body,
|
||||
commentId: comment.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full grow flex-col"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mt-2">
|
||||
<CodingWorkspaceDiscussionsCommentEditor
|
||||
control={control}
|
||||
isLoading={updateCommentMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
className="w-20"
|
||||
isDisabled={updateCommentMutation.isLoading}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Cancel button label',
|
||||
id: '0GT0SI',
|
||||
})}
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<Button
|
||||
className="w-20"
|
||||
isDisabled={updateCommentMutation.isLoading}
|
||||
isLoading={updateCommentMutation.isLoading}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Save',
|
||||
description: 'Save update button label',
|
||||
id: 'aYJLMU',
|
||||
})}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { Control } from 'react-hook-form';
|
||||
import { useController } from 'react-hook-form';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import RichTextEditor from '~/components/ui/RichTextEditor';
|
||||
|
||||
import { getDiscussionsCommentBodyAttributes } from './CodingWorkspaceDiscussionsComentBodySchema';
|
||||
|
||||
type Props = Readonly<{
|
||||
control: Control<{ body: string }>;
|
||||
editorRerenderKey?: number;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
|
||||
const fieldName = 'body';
|
||||
|
||||
export default function CodingWorkspaceDiscussionsCommentEditor({
|
||||
control,
|
||||
editorRerenderKey,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const attrs = getDiscussionsCommentBodyAttributes(intl);
|
||||
|
||||
const { field, formState } = useController({
|
||||
control,
|
||||
name: fieldName,
|
||||
rules: { required: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<RichTextEditor
|
||||
key={editorRerenderKey}
|
||||
disabled={isLoading}
|
||||
errorMessage={
|
||||
formState.dirtyFields.body || formState.submitCount > 0
|
||||
? formState.errors.body?.message
|
||||
: undefined
|
||||
}
|
||||
isLabelHidden={true}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Discussion post comment',
|
||||
description: 'Label for discussion post input textarea',
|
||||
id: 'NA1S3Z',
|
||||
})}
|
||||
maxLength={attrs.validation.maxLength}
|
||||
minHeight="100px"
|
||||
placeholder={attrs.placeholder}
|
||||
{...field}
|
||||
required={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { trpc } from '~/hooks/trpc';
|
||||
import usePagination from '~/hooks/usePagination';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import Divider from '~/components/ui/Divider';
|
||||
import EmptyState from '~/components/ui/EmptyState';
|
||||
import Pagination from '~/components/ui/Pagination';
|
||||
import Spinner from '~/components/ui/Spinner';
|
||||
import Text from '~/components/ui/Text';
|
||||
|
||||
import CodingWorkspaceDiscussionsComment from './CodingWorkspaceDiscussionsComment';
|
||||
import type { CodingWorkspaceDiscussionsCommentSortField } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
domain: InterviewsDiscussionCommentDomain;
|
||||
entityId: string;
|
||||
onUpdateCommentsCount: (value: number) => void;
|
||||
sort: {
|
||||
field: CodingWorkspaceDiscussionsCommentSortField;
|
||||
isAscendingOrder: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsCommentList({
|
||||
domain,
|
||||
entityId,
|
||||
onUpdateCommentsCount,
|
||||
sort,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
// Pagination
|
||||
const { currentPage, setCurrentPage } = usePagination({
|
||||
deps: [],
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
page: 1,
|
||||
});
|
||||
const { data, isLoading } = trpc.questionComments.list.useQuery(
|
||||
{
|
||||
domain,
|
||||
entityId,
|
||||
pagination: {
|
||||
limit: ITEMS_PER_PAGE,
|
||||
page: currentPage,
|
||||
},
|
||||
sort,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
const { comments, count } = data ?? {};
|
||||
|
||||
// Update the comments count when data changes
|
||||
useEffect(() => {
|
||||
if (count != null) {
|
||||
onUpdateCommentsCount(count);
|
||||
}
|
||||
}, [count, onUpdateCommentsCount]);
|
||||
|
||||
if (isLoading && comments?.length !== 0) {
|
||||
return (
|
||||
<div className="p-18 w-full">
|
||||
<Spinner display="block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (comments?.length === 0 || !comments) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: 'No comments yet',
|
||||
description: 'No comment title',
|
||||
id: '9QBgga',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil((count ?? 0) / ITEMS_PER_PAGE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{comments.map((comment, index) => (
|
||||
<div key={comment.id}>
|
||||
<CodingWorkspaceDiscussionsComment comment={comment} />
|
||||
{index !== comments.length - 1 && <Divider className="mb-1.5" />}
|
||||
</div>
|
||||
))}
|
||||
{(count ?? 0) > 0 && (
|
||||
<div
|
||||
className={clsx('flex items-center justify-between gap-1', 'mt-2.5')}>
|
||||
<Text color="secondary" size="body3">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
'Showing {from} to {to} comments out of {totalCount} discussions',
|
||||
description: 'Comments count',
|
||||
id: 'zJP+bP',
|
||||
},
|
||||
{
|
||||
from: (currentPage - 1) * ITEMS_PER_PAGE + 1,
|
||||
to: Math.min(currentPage * ITEMS_PER_PAGE, count ?? 0),
|
||||
totalCount: count ?? 0,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onPageChange={(value) => {
|
||||
setCurrentPage(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { RiSortDesc } from 'react-icons/ri';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import DropdownMenu from '~/components/ui/DropdownMenu';
|
||||
|
||||
import type { CodingWorkspaceDiscussionsCommentSortField } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
isAscendingOrder: boolean;
|
||||
setIsAscendingOrder: (value: boolean) => void;
|
||||
setSortField: (value: CodingWorkspaceDiscussionsCommentSortField) => void;
|
||||
sortField: CodingWorkspaceDiscussionsCommentSortField | null;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsCommentSort({
|
||||
isAscendingOrder,
|
||||
setIsAscendingOrder,
|
||||
setSortField,
|
||||
sortField,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
function makeDropdownItemProps(
|
||||
label: string,
|
||||
itemField: CodingWorkspaceDiscussionsCommentSortField,
|
||||
isItemAscendingOrder: boolean,
|
||||
) {
|
||||
return {
|
||||
isSelected:
|
||||
sortField === itemField && isAscendingOrder === isItemAscendingOrder,
|
||||
label,
|
||||
onClick: () => {
|
||||
setSortField(itemField);
|
||||
setIsAscendingOrder(isItemAscendingOrder);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
icon={RiSortDesc}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Sort by',
|
||||
description:
|
||||
'Label for sort button for projects discussion post list',
|
||||
id: 'NjnYqU',
|
||||
})}
|
||||
size="xs">
|
||||
{[
|
||||
makeDropdownItemProps(
|
||||
intl.formatMessage({
|
||||
defaultMessage: 'Popularity: Most to least upvotes',
|
||||
description:
|
||||
'Sorting option for discussions comment - popularity',
|
||||
id: 'ooGnHf',
|
||||
}),
|
||||
'votes',
|
||||
false,
|
||||
),
|
||||
makeDropdownItemProps(
|
||||
intl.formatMessage({
|
||||
defaultMessage: 'Popularity: Least to most upvotes',
|
||||
description:
|
||||
'Sorting option for discussions comment - popularity',
|
||||
id: 'L33SIl',
|
||||
}),
|
||||
'votes',
|
||||
true,
|
||||
),
|
||||
makeDropdownItemProps(
|
||||
intl.formatMessage({
|
||||
defaultMessage: 'Created: Newest to oldest',
|
||||
description:
|
||||
'Sorting option for discussions comment - sort by created',
|
||||
id: '87c2KI',
|
||||
}),
|
||||
'createdAt',
|
||||
false,
|
||||
),
|
||||
makeDropdownItemProps(
|
||||
intl.formatMessage({
|
||||
defaultMessage: 'Created: Oldest to newest',
|
||||
description:
|
||||
'Sorting option for discussions comment - sort by created',
|
||||
id: 'G3byny',
|
||||
}),
|
||||
'createdAt',
|
||||
true,
|
||||
),
|
||||
].map((props) => (
|
||||
<DropdownMenu.Item key={props.label} {...props} />
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useUser } from '@supabase/auth-helpers-react';
|
||||
import clsx from 'clsx';
|
||||
import { BiSolidUpvote, BiUpvote } from 'react-icons/bi';
|
||||
|
||||
import { trpc } from '~/hooks/trpc';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import Button from '~/components/ui/Button';
|
||||
import { themeTextColor, themeTextSecondaryColor } from '~/components/ui/theme';
|
||||
|
||||
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
comment: CodingWorkspaceDiscussionsCommentItem;
|
||||
count: number;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsCommentVoteButton({
|
||||
comment,
|
||||
count,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const user = useUser();
|
||||
const trpcUtils = trpc.useUtils();
|
||||
const voteCommentMutation = trpc.questionComments.vote.useMutation({
|
||||
onSuccess: () => {
|
||||
trpcUtils.questionComments.list.invalidate({
|
||||
domain: comment.domain,
|
||||
entityId: comment.entityId,
|
||||
});
|
||||
trpcUtils.questionComments.liked.invalidate({ commentId: comment.id });
|
||||
},
|
||||
});
|
||||
const unvoteCommentMutation = trpc.questionComments.unvote.useMutation({
|
||||
onSuccess: () => {
|
||||
trpcUtils.questionComments.list.invalidate({
|
||||
domain: comment.domain,
|
||||
entityId: comment.entityId,
|
||||
});
|
||||
trpcUtils.questionComments.liked.invalidate({ commentId: comment.id });
|
||||
},
|
||||
});
|
||||
const { data: hasLiked } = trpc.questionComments.liked.useQuery({
|
||||
commentId: comment.id,
|
||||
});
|
||||
const isLoggedIn = user != null;
|
||||
const hasVoted = hasLiked != null;
|
||||
|
||||
const actionLabel =
|
||||
isLoggedIn && hasVoted
|
||||
? intl.formatMessage({
|
||||
defaultMessage: 'Unvote',
|
||||
description: 'Vote button label',
|
||||
id: 'pjndkX',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: 'Upvote',
|
||||
description: 'Vote button label',
|
||||
id: 'bj5dJV',
|
||||
});
|
||||
const Icon = hasVoted ? BiSolidUpvote : BiUpvote;
|
||||
|
||||
return (
|
||||
<Button
|
||||
addonPosition="start"
|
||||
aria-label={actionLabel}
|
||||
className={clsx(themeTextSecondaryColor, !isLoggedIn && 'cursor-text')}
|
||||
icon={Icon}
|
||||
iconClassName={hasVoted ? themeTextColor : themeTextSecondaryColor}
|
||||
label={String(count)}
|
||||
size="xs"
|
||||
tooltip={actionLabel}
|
||||
variant="tertiary"
|
||||
onClick={
|
||||
isLoggedIn
|
||||
? () => {
|
||||
hasVoted
|
||||
? unvoteCommentMutation.mutate({
|
||||
commentId: comment.id,
|
||||
})
|
||||
: voteCommentMutation.mutate({
|
||||
commentId: comment.id,
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '~/hooks/trpc';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import Button from '~/components/ui/Button';
|
||||
|
||||
import { useDiscussionsCommentBodySchema } from './CodingWorkspaceDiscussionsComentBodySchema';
|
||||
import CodingWorkspaceDiscussionsCommentEditor from './CodingWorkspaceDiscussionsCommentEditor';
|
||||
|
||||
type CommentFormInput = Readonly<{
|
||||
body: string;
|
||||
}>;
|
||||
|
||||
type Props = Readonly<{
|
||||
domain: InterviewsDiscussionCommentDomain;
|
||||
entityId: string;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsNewComment({
|
||||
domain,
|
||||
entityId,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const trpcUtils = trpc.useUtils();
|
||||
const [editorRerenderKey, setEditorRerenderKey] = useState(0);
|
||||
const createCommentMutation = trpc.questionComments.create.useMutation({
|
||||
onSuccess: () => {
|
||||
trpcUtils.questionComments.list.invalidate({
|
||||
domain,
|
||||
entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
const discussionsCommentBodySchema = useDiscussionsCommentBodySchema();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<CommentFormInput>({
|
||||
defaultValues: {
|
||||
body: '',
|
||||
},
|
||||
mode: 'onTouched',
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
body: discussionsCommentBodySchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function onSubmit(data: CommentFormInput) {
|
||||
return createCommentMutation.mutate(
|
||||
{
|
||||
body: data.body,
|
||||
domain,
|
||||
entityId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditorRerenderKey((prevKey) => prevKey + 1);
|
||||
reset();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CodingWorkspaceDiscussionsCommentEditor
|
||||
control={control}
|
||||
editorRerenderKey={editorRerenderKey}
|
||||
isLoading={createCommentMutation.isLoading}
|
||||
/>
|
||||
<Button
|
||||
className="w-20"
|
||||
isDisabled={createCommentMutation.isLoading}
|
||||
isLoading={createCommentMutation.isLoading}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Post',
|
||||
description: 'Label for post button on project discussions page',
|
||||
id: 'bnqijt',
|
||||
})}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import clsx from 'clsx';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '~/hooks/trpc';
|
||||
|
||||
import { FormattedMessage, useIntl } from '~/components/intl';
|
||||
import Avatar from '~/components/ui/Avatar';
|
||||
import Button from '~/components/ui/Button';
|
||||
import Text from '~/components/ui/Text';
|
||||
|
||||
import { useDiscussionsCommentBodySchema } from './CodingWorkspaceDiscussionsComentBodySchema';
|
||||
import CodingWorkspaceDiscussionsCommentEditor from './CodingWorkspaceDiscussionsCommentEditor';
|
||||
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
author: {
|
||||
avatarUrl: string | null;
|
||||
id: string;
|
||||
name: string | null;
|
||||
username: string;
|
||||
};
|
||||
onCancel: () => void;
|
||||
parentComment: CodingWorkspaceDiscussionsCommentItem;
|
||||
}>;
|
||||
|
||||
type CommentFormInput = Readonly<{
|
||||
body: string;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsReplyInput({
|
||||
author,
|
||||
onCancel,
|
||||
parentComment,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const trpcUtils = trpc.useUtils();
|
||||
const createReplyMutation = trpc.questionComments.reply.useMutation({
|
||||
onSuccess: () => {
|
||||
trpcUtils.questionComments.list.invalidate({
|
||||
domain: parentComment.domain,
|
||||
entityId: parentComment.entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
const discussionsCommentBodySchema = useDiscussionsCommentBodySchema();
|
||||
const { control, handleSubmit } = useForm<CommentFormInput>({
|
||||
defaultValues: {
|
||||
body: '',
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
body: discussionsCommentBodySchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const isReplyingToAReply = parentComment.parentCommentId != null;
|
||||
|
||||
function onSubmit(data: CommentFormInput) {
|
||||
createReplyMutation.mutate(
|
||||
{
|
||||
body: data.body,
|
||||
domain: parentComment.domain,
|
||||
entityId: parentComment.entityId,
|
||||
parentCommentId: isReplyingToAReply
|
||||
? parentComment.parentCommentId
|
||||
: parentComment.id,
|
||||
repliedToId: isReplyingToAReply ? parentComment.id : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-3')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
alt={author.name ?? author.username}
|
||||
size="xs"
|
||||
src={author.avatarUrl ?? ''}
|
||||
/>
|
||||
<Text size="body2">
|
||||
<FormattedMessage
|
||||
defaultMessage="You are replying"
|
||||
description="Label for replying to discussions comment"
|
||||
id="Cuf2Et"
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
<form className="ml-9" onSubmit={handleSubmit(onSubmit)}>
|
||||
<CodingWorkspaceDiscussionsCommentEditor
|
||||
control={control}
|
||||
isLoading={false}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
className="w-20"
|
||||
isDisabled={createReplyMutation.isLoading}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Cancel',
|
||||
description:
|
||||
'Label for cancel reply button on workspace discussions page',
|
||||
id: 'y6al2b',
|
||||
})}
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<Button
|
||||
className="w-20"
|
||||
isDisabled={createReplyMutation.isLoading}
|
||||
isLoading={createReplyMutation.isLoading}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Post',
|
||||
description:
|
||||
'Label for post reply button on workspace discussions page',
|
||||
id: 'X143ov',
|
||||
})}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
|
||||
import { useUser } from '@supabase/auth-helpers-react';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { RiAddLine } from 'react-icons/ri';
|
||||
|
||||
import { useEnterViewport } from '~/hooks/useEnterViewport';
|
||||
import { useAuthSignInUp } from '~/hooks/user/useAuthFns';
|
||||
|
||||
import { useIntl } from '~/components/intl';
|
||||
import Button from '~/components/ui/Button';
|
||||
import Text from '~/components/ui/Text';
|
||||
|
||||
import CodingWorkspaceDiscussionsCommentList from './CodingWorkspaceDiscussionsCommentList';
|
||||
import CodingWorkspaceDiscussionsCommentSort from './CodingWorkspaceDiscussionsCommentSort';
|
||||
import CodingWorkspaceDiscussionsNewComment from './CodingWorkspaceDiscussionsNewComment';
|
||||
import type { CodingWorkspaceDiscussionsCommentSortField } from './types';
|
||||
|
||||
type Props = Readonly<{
|
||||
className?: string;
|
||||
domain: InterviewsDiscussionCommentDomain;
|
||||
entityId: string;
|
||||
}>;
|
||||
|
||||
export default function CodingWorkspaceDiscussionsSection({
|
||||
className,
|
||||
domain,
|
||||
entityId,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const user = useUser();
|
||||
const [isAscendingOrder, setIsAscendingOrder] = useState(false);
|
||||
const [sortField, setSortField] =
|
||||
useState<CodingWorkspaceDiscussionsCommentSortField>('createdAt');
|
||||
const [commentsCount, setCommentsCount] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const handleVisibilityChange = useCallback((inView: boolean) => {
|
||||
if (inView) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ref = useEnterViewport(handleVisibilityChange);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx('space-y-3', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text size="body1" weight="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: 'Discussions',
|
||||
description: 'Discussions section title',
|
||||
id: '6RewiX',
|
||||
})}
|
||||
{commentsCount > 0 && ` (${commentsCount})`}
|
||||
</Text>
|
||||
<CodingWorkspaceDiscussionsCommentSort
|
||||
isAscendingOrder={isAscendingOrder}
|
||||
setIsAscendingOrder={setIsAscendingOrder}
|
||||
setSortField={setSortField}
|
||||
sortField={sortField}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{user ? (
|
||||
<CodingWorkspaceDiscussionsNewComment
|
||||
domain={domain}
|
||||
entityId={entityId}
|
||||
/>
|
||||
) : (
|
||||
<AddCommentButton />
|
||||
)}
|
||||
{isVisible && (
|
||||
<CodingWorkspaceDiscussionsCommentList
|
||||
domain={domain}
|
||||
entityId={entityId}
|
||||
sort={{
|
||||
field: sortField,
|
||||
isAscendingOrder,
|
||||
}}
|
||||
onUpdateCommentsCount={setCommentsCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCommentButton() {
|
||||
const intl = useIntl();
|
||||
const { signInUpHref } = useAuthSignInUp();
|
||||
|
||||
return (
|
||||
<Button
|
||||
addonPosition="start"
|
||||
href={signInUpHref()}
|
||||
icon={RiAddLine}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: 'Add a comment',
|
||||
description: 'Button label for adding a comment',
|
||||
id: 'AzTlm9',
|
||||
})}
|
||||
variant="primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
|
||||
|
||||
export type CodingWorkspaceDiscussionsCommentAuthor = Readonly<{
|
||||
avatarUrl: string | null;
|
||||
id: string;
|
||||
name: string | null;
|
||||
username: string;
|
||||
}>;
|
||||
|
||||
export type CodingWorkspaceDiscussionsCommentItem = Readonly<{
|
||||
_count: {
|
||||
votes: number;
|
||||
};
|
||||
author: CodingWorkspaceDiscussionsCommentAuthor;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
domain: InterviewsDiscussionCommentDomain;
|
||||
entityId: string;
|
||||
id: string;
|
||||
parentCommentId: string | null;
|
||||
profileId: string;
|
||||
replies?: ReadonlyArray<CodingWorkspaceDiscussionsCommentItem>;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
|
||||
export type CodingWorkspaceDiscussionsCommentSortField = 'createdAt' | 'votes';
|
||||
|
|
@ -230,6 +230,7 @@ export default function JavaScriptCodingWorkspaceAboveMobile({
|
|||
<JavaScriptCodingWorkspaceDescription
|
||||
canViewPremiumContent={canViewPremiumContent}
|
||||
description={description}
|
||||
environment={embed ? 'embed' : 'workspace'}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={!embed}
|
||||
|
|
@ -271,6 +272,7 @@ export default function JavaScriptCodingWorkspaceAboveMobile({
|
|||
contents: (
|
||||
<JavaScriptCodingWorkspaceSolution
|
||||
canViewPremiumContent={canViewPremiumContent}
|
||||
environment={embed ? 'embed' : 'workspace'}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showLanguageSelector={embed}
|
||||
|
|
@ -441,6 +443,8 @@ export default function JavaScriptCodingWorkspaceAboveMobile({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_js"
|
||||
className={clsx('lg:hidden', 'space-y-3', 'px-3 pt-2')}
|
||||
contentType="description"
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={true}
|
||||
similarQuestions={similarQuestions}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import JavaScriptCodingWorkspaceLanguageDropdown from './language/JavaScriptCodi
|
|||
type Props = Readonly<{
|
||||
canViewPremiumContent: boolean;
|
||||
description: string | null;
|
||||
environment?: 'embed' | 'workspace';
|
||||
metadata: QuestionMetadata;
|
||||
nextQuestions: ReadonlyArray<QuestionMetadata>;
|
||||
showAd: boolean;
|
||||
|
|
@ -34,6 +35,7 @@ type Props = Readonly<{
|
|||
export default function JavaScriptCodingWorkspaceDescription({
|
||||
canViewPremiumContent,
|
||||
description,
|
||||
environment = 'workspace',
|
||||
metadata,
|
||||
nextQuestions,
|
||||
showAd,
|
||||
|
|
@ -102,6 +104,9 @@ export default function JavaScriptCodingWorkspaceDescription({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_js"
|
||||
className="space-y-3 max-lg:hidden"
|
||||
contentType="description"
|
||||
environment={environment}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={showAd}
|
||||
similarQuestions={similarQuestions}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ export default function JavaScriptCodingWorkspaceMobile({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_js"
|
||||
className={clsx('space-y-3', 'px-3 pb-6')}
|
||||
contentType={mode === 'solution' ? 'solution' : 'description'}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={true}
|
||||
similarQuestions={similarQuestions}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useQueryQuestionProgress } from '~/db/QuestionsProgressClient';
|
|||
|
||||
type Props = Readonly<{
|
||||
canViewPremiumContent: boolean;
|
||||
environment?: 'embed' | 'workspace';
|
||||
isMobile?: boolean;
|
||||
metadata: QuestionMetadata;
|
||||
nextQuestions: ReadonlyArray<QuestionMetadata>;
|
||||
|
|
@ -38,6 +39,7 @@ type Props = Readonly<{
|
|||
|
||||
export default function JavaScriptCodingWorkspaceSolution({
|
||||
canViewPremiumContent,
|
||||
environment = 'workspace',
|
||||
isMobile,
|
||||
metadata,
|
||||
nextQuestions,
|
||||
|
|
@ -142,6 +144,9 @@ export default function JavaScriptCodingWorkspaceSolution({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_js"
|
||||
className="space-y-3 max-lg:hidden"
|
||||
contentType="solution"
|
||||
environment={environment}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
similarQuestions={similarQuestions}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ export default function UserInterfaceCodingWorkspaceAboveMobile({
|
|||
<UserInterfaceCodingWorkspaceWriteup
|
||||
canViewPremiumContent={canViewPremiumContent}
|
||||
contentType="description"
|
||||
environment={embed ? 'embed' : 'workspace'}
|
||||
framework={framework}
|
||||
metadata={{ ...question.metadata, author: questionAuthor }}
|
||||
nextQuestions={nextQuestions}
|
||||
|
|
@ -336,6 +337,7 @@ export default function UserInterfaceCodingWorkspaceAboveMobile({
|
|||
<UserInterfaceCodingWorkspaceWriteup
|
||||
canViewPremiumContent={canViewPremiumContent}
|
||||
contentType="solution"
|
||||
environment={embed ? 'embed' : 'workspace'}
|
||||
framework={framework}
|
||||
metadata={{ ...question.metadata, author: solutionAuthor }}
|
||||
nextQuestions={nextQuestions}
|
||||
|
|
@ -522,6 +524,8 @@ export default function UserInterfaceCodingWorkspaceAboveMobile({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_ui"
|
||||
className={clsx('lg:hidden', 'space-y-3', 'px-3 pt-2')}
|
||||
contentType="description"
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={true}
|
||||
similarQuestions={similarQuestions}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,8 @@ export default function UserInterfaceCodingWorkspaceMobile({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_ui"
|
||||
className={clsx('space-y-3', 'px-3 pb-6')}
|
||||
contentType={mode === 'solution' ? 'solution' : 'description'}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={true}
|
||||
similarQuestions={similarQuestions}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,9 @@ export default function UserInterfaceCodingWorkspaceWriteup({
|
|||
<CodingWorkspaceDescriptionAddOnItems
|
||||
adPlacement="questions_ui"
|
||||
className="space-y-3 max-lg:hidden"
|
||||
contentType={contentType}
|
||||
environment={environment}
|
||||
metadata={metadata}
|
||||
nextQuestions={nextQuestions}
|
||||
showAd={showAd}
|
||||
similarQuestions={similarQuestions}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export const INTERVIEWS_JS_COMMUNITY_SOLUTIONS_IS_LIVE =
|
|||
export const INTERVIEWS_UI_COMMUNITY_SOLUTIONS_IS_LIVE =
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production';
|
||||
export const INTERVIEWS_TAZAPAY_IS_LIVE = true;
|
||||
export const INTERVIEWS_DISCUSSIONS_IS_LIVE =
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production';
|
||||
|
||||
export const PROJECTS_NOTIFICATION_AVAILABLE =
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { projectsRouter } from './projects';
|
|||
import { promotionsRouter } from './promotions';
|
||||
import { purchasesRouter } from './purchases';
|
||||
import { questionBookmarkRouter } from './question-bookmark';
|
||||
import { questionCommentsRouter } from './question-comments';
|
||||
import { questionCommunitySolutionRouter } from './question-community-solution';
|
||||
import { questionListsRouter } from './question-lists';
|
||||
import { questionProgressRouter } from './question-progress';
|
||||
|
|
@ -31,6 +32,7 @@ export const appRouter = router({
|
|||
projects: projectsRouter,
|
||||
promotions: promotionsRouter,
|
||||
purchases: purchasesRouter,
|
||||
questionComments: questionCommentsRouter,
|
||||
questionCommunitySolution: questionCommunitySolutionRouter,
|
||||
questionLists: questionListsRouter,
|
||||
questionProgress: questionProgressRouter,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
import { InterviewsDiscussionCommentDomain, Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { discussionsCommentBodySchemaServer } from '~/components/workspace/common/discussions/CodingWorkspaceDiscussionsComentBodySchema';
|
||||
|
||||
import prisma from '~/server/prisma';
|
||||
|
||||
import { publicProcedure, router, userProcedure } from '../trpc';
|
||||
|
||||
export const questionCommentsRouter = router({
|
||||
create: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
body: discussionsCommentBodySchemaServer,
|
||||
domain: z.nativeEnum(InterviewsDiscussionCommentDomain),
|
||||
entityId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({ ctx: { viewer }, input: { body, domain, entityId } }) => {
|
||||
const comment = await prisma.interviewsDiscussionComment.create({
|
||||
data: {
|
||||
body,
|
||||
domain,
|
||||
entityId,
|
||||
profileId: viewer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO(discussions): Add activity triggers
|
||||
|
||||
return comment;
|
||||
},
|
||||
),
|
||||
delete: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
commentId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { viewer }, input: { commentId } }) => {
|
||||
return await prisma.interviewsDiscussionComment.delete({
|
||||
where: {
|
||||
id: commentId,
|
||||
profileId: viewer.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
liked: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
commentId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx: { viewer }, input: { commentId } }) => {
|
||||
return await prisma.interviewsDiscussionCommentVote.findFirst({
|
||||
select: {
|
||||
commentId: true,
|
||||
},
|
||||
where: {
|
||||
author: {
|
||||
id: viewer.id,
|
||||
},
|
||||
comment: {
|
||||
id: commentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
list: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
domain: z.nativeEnum(InterviewsDiscussionCommentDomain),
|
||||
entityId: z.string(),
|
||||
pagination: z.object({
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.transform((val) => Math.min(30, val)),
|
||||
page: z.number().int().positive(),
|
||||
}),
|
||||
sort: z.object({
|
||||
field: z.enum([
|
||||
Prisma.InterviewsDiscussionCommentScalarFieldEnum.createdAt,
|
||||
'votes',
|
||||
]),
|
||||
isAscendingOrder: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { domain, entityId, pagination, sort } }) => {
|
||||
const { limit, page } = pagination;
|
||||
const commentIncludeFields = {
|
||||
_count: {
|
||||
select: {
|
||||
votes: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
avatarUrl: true,
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const sortBy =
|
||||
sort.field === 'votes'
|
||||
? ({
|
||||
votes: {
|
||||
_count: sort.isAscendingOrder ? 'asc' : 'desc',
|
||||
},
|
||||
} as const)
|
||||
: ({
|
||||
createdAt: sort.isAscendingOrder ? 'asc' : 'desc',
|
||||
} as const);
|
||||
|
||||
const [count, comments] = await Promise.all([
|
||||
prisma.interviewsDiscussionComment.count({
|
||||
where: {
|
||||
domain,
|
||||
entityId,
|
||||
parentCommentId: null, // Fetch top-level comments only.
|
||||
},
|
||||
}),
|
||||
prisma.interviewsDiscussionComment.findMany({
|
||||
include: {
|
||||
replies: {
|
||||
include: commentIncludeFields,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
...commentIncludeFields,
|
||||
},
|
||||
orderBy: sortBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
where: {
|
||||
domain,
|
||||
entityId,
|
||||
parentCommentId: null, // Fetch top-level comments only.
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
count,
|
||||
};
|
||||
}),
|
||||
reply: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
body: discussionsCommentBodySchemaServer,
|
||||
domain: z.nativeEnum(InterviewsDiscussionCommentDomain),
|
||||
entityId: z.string(),
|
||||
parentCommentId: z.string().uuid(),
|
||||
repliedToId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
ctx: { viewer },
|
||||
input: { body, domain, entityId, parentCommentId, repliedToId },
|
||||
}) => {
|
||||
const comment = await prisma.interviewsDiscussionComment.create({
|
||||
data: {
|
||||
body,
|
||||
domain,
|
||||
entityId,
|
||||
parentCommentId,
|
||||
profileId: viewer.id,
|
||||
repliedToId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO(discussions): Add activity triggers
|
||||
|
||||
return comment;
|
||||
},
|
||||
),
|
||||
unvote: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
commentId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { viewer }, input: { commentId } }) => {
|
||||
return await prisma.interviewsDiscussionCommentVote.delete({
|
||||
where: {
|
||||
commentId_profileId: {
|
||||
commentId,
|
||||
profileId: viewer.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
update: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
body: discussionsCommentBodySchemaServer,
|
||||
commentId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { viewer }, input: { body, commentId } }) => {
|
||||
return await prisma.interviewsDiscussionComment.update({
|
||||
data: {
|
||||
body,
|
||||
},
|
||||
where: {
|
||||
id: commentId,
|
||||
profileId: viewer.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
vote: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
commentId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { viewer }, input: { commentId } }) => {
|
||||
try {
|
||||
return await prisma.interviewsDiscussionCommentVote.create({
|
||||
data: {
|
||||
commentId,
|
||||
profileId: viewer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO(discussions): Add activity triggers
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
// Ignore duplicate upvote.
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
// No-op.
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
Loading…
Reference in New Issue