Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/graphql/gql-tada/graphql-env.d.ts

Large diffs are not rendered by default.

50 changes: 8 additions & 42 deletions client/src/apps/reviews/create/PostReviewForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,41 +39,28 @@ export namespace PostReviewForm {
const user = useUser();
const loading = useIsLoading();

// todo refac(UX): fix broken `reValidateMode: "onChange"` - after refactor to 2 forms validates only on <button type="submit"> click
const forms = {
tool: useForm<schemas.Tool>({
resolver: zodResolver(schemas.Tool),
mode: "onBlur",
reValidateMode: "onChange",
defaultValues: {
id: null,
title: "",
tool_type: "Program",
tags: loadTags(props.review?.parent?.tags),
tags: schemas.tags.deserialize({
tags: props.review?.parent?.tags,
user,
postIdForVote: props.review?.parent?.id,
}),
},
}),
review: useForm<schemas.Review>({
resolver: zodResolver(schemas.Review),
mode: "onBlur",
reValidateMode: "onChange",
defaultValues: props.review
? {
id: props.review.id,
title: props.review.title,
content_polite: props.review.content_polite,
content_direct: props.review.content_direct,
content_rant: props.review.content_rant,
// todo refac: move to schemas.props.review.deserialize()
review_rating: props.review.review_rating,
review_importance: props.review.review_importance,
review_usage_status: schemas.Review.shape.review_usage_status.parse(
props.review.review_usage_status,
),
reviewed_at: formatISO(new Date(props.review.reviewed_at), {
representation: "date",
}),
tags: loadTags(props.review.tags),
review_tags: loadTags(props.review.review_tags, { isReviewTags: true }),
...schemas.sharable.deserialize(props.review),
}
? schemas.review.deserialize({ review: props.review, user })
: {
id: null,
title: "",
Expand All @@ -90,27 +77,6 @@ export namespace PostReviewForm {

const reviewTags = useWatch({ control: forms.review.control, name: "tags" });

function loadTags(tags?: schemas.PostAbstract["tags"], opts?: { isReviewTags: true }) {
if (!tags) {
return [];
}
return tags.map(tag => {
const userTagVote = user?.post_tag_votes.find(vote => {
const isThisPostVote = [props.review?.id, props.review?.parent?.id].includes(
vote.post.id,
);
return isThisPostVote && vote.tag.id === tag.id;
});
return {
id: tag.id,
name: tag.name,
is_vote_positive: userTagVote?.is_vote_positive ?? null,
// non-review_tags .label includes all `tag_parent.name`s - verbose af
label: opts?.isReviewTags && tag.label,
};
});
}

async function handleSubmit() {
const isFormInvalid = !(await forms.review.trigger());
if (isFormInvalid) {
Expand Down
1 change: 1 addition & 0 deletions client/src/apps/users/useUserCurrent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const UserQueryDoc = graphql.persisted(
id
is_vote_positive
is_changed_my_mind
comment
post {
id
}
Expand Down
8 changes: 3 additions & 5 deletions client/src/components/forms/FormChakraCheckboxCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,11 @@ export function FormChakraCheckboxCard<
return (
<CheckboxCard.Root
{...checkboxCardProps}
checked={Boolean(field.value)}
onCheckedChange={details => field.onChange(details.checked === true)}
_hover={{ bg: "bg.light", _dark: { bg: "bg.light" }, cursor: "pointer" }}
>
<CheckboxCard.HiddenInput
{...field}
checked={field.value}
onChange={e => field.onChange(e.currentTarget.checked)}
/>
<CheckboxCard.HiddenInput ref={field.ref} name={field.name} onBlur={field.onBlur} />
<CheckboxCard.Control>
<CheckboxCard.Content gap={3}>
<Icon fontSize="2xl">{icon}</Icon>
Expand Down
55 changes: 49 additions & 6 deletions client/src/components/posts/form/SelectVotable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,34 @@ export function SelectVotable(
const state = useStateValtio({
isDialogOpen: false,
optionSelected: null as SelectVotableOption | null,
commentOnDialogOpen: "",
});

function getOptionIndex(option: SelectVotableOption): number {
return options.findIndex(opt => opt.id === option.id);
return form.getValues(props.fieldName).findIndex(opt => opt.id === option.id);
}

function getOptionComment(option: SelectVotableOption): string {
const optionNumber = getOptionIndex(option);
return options[optionNumber]?.comment ?? "";
return options.find(opt => opt.id === option.id)?.comment ?? "";
}

function onCommentDialogClose() {
const optionSelected = state.mutable.optionSelected;
if (!isSelectReadOnlyInReviewForm || !optionSelected) {
return;
}
const optionIndex = getOptionIndex(optionSelected);
const isCommentUnchanged =
(form.getValues(`${props.fieldName}.${optionIndex}.comment`) ?? "") ===
state.mutable.commentOnDialogOpen;
if (isCommentUnchanged) {
return;
}
void mutatePostTagVote({
// @ts-expect-error #bad-infer
postId: props.postId,
optionNew: form.getValues(`${props.fieldName}.${optionIndex}`),
});
}

const isSelectReadOnlyInReviewForm = Boolean(
Expand All @@ -104,6 +123,7 @@ export function SelectVotable(
{props.helpText && <Field.HelperText>{props.helpText}</Field.HelperText>}

<AsyncCreatableSelect
placeholder={isSelectReadOnlyInReviewForm ? "No tags" : "Select..."}
isDisabled={props.isReadOnly}
cacheOptions
defaultOptions
Expand Down Expand Up @@ -183,6 +203,9 @@ export function SelectVotable(
<OptionButton
onClick={() => {
state.mutable.optionSelected = propsMultiVal.data;
state.mutable.commentOnDialogOpen = getOptionComment(
propsMultiVal.data,
);
state.mutable.isDialogOpen = true;
}}
icon={getOptionComment(propsMultiVal.data) ? FaMessage : FaRegMessage}
Expand Down Expand Up @@ -210,14 +233,19 @@ export function SelectVotable(
initialFocusEl={() => commentInputRef.current}
open={state.snap.isDialogOpen}
onOpenChange={event => {
if (!event.open) {
onCommentDialogClose();
}
state.mutable.isDialogOpen = event.open;
}}
>
<DialogBackdrop />
<DialogContent>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>{state.snap.optionSelected?.name}</DialogTitle>
<DialogTitle>
{state.snap.optionSelected?.label ?? state.snap.optionSelected?.name}
</DialogTitle>
</DialogHeader>
<DialogBody>
{state.snap.isDialogOpen && state.snap.optionSelected && (
Expand All @@ -226,6 +254,7 @@ export function SelectVotable(
control={form.control}
key={state.snap.optionSelected.name}
onKeyEnter={() => {
onCommentDialogClose();
state.mutable.isDialogOpen = false;
}}
inputProps={{
Expand Down Expand Up @@ -316,7 +345,14 @@ export function OptionButton(props: {
}) {
return (
<IconButton
onClick={props.onClick}
onMouseDown={event => {
event.stopPropagation();
event.preventDefault();
}}
onClick={event => {
event.stopPropagation();
props.onClick();
}}
color={props.color}
p={0}
h={6}
Expand All @@ -341,6 +377,7 @@ async function mutatePostTagVote(opts: { postId: ID; optionNew: SelectVotableOpt
post_id: opts.postId,
tag_id: opts.optionNew.id,
is_vote_positive: opts.optionNew.is_vote_positive,
comment: opts.optionNew.comment ?? null,
});
if (!response.success) {
toast.error(`Vote failed: ${response.errorMessage}`);
Expand Down Expand Up @@ -372,11 +409,17 @@ const ToolTagsQuery = graphql.persisted(
const CreateOrUpdatePostTagVote = graphql.persisted(
"CreateOrUpdatePostTagVote",
graphql(`
mutation CreateOrUpdatePostTagVote($post_id: ID!, $tag_id: ID!, $is_vote_positive: Boolean) {
mutation CreateOrUpdatePostTagVote(
$post_id: ID!
$tag_id: ID!
$is_vote_positive: Boolean
$comment: String
) {
post_tag_vote_create_or_update(
post_id: $post_id
tag_id: $tag_id
is_vote_positive: $is_vote_positive
comment: $comment
)
}
`),
Expand Down
1 change: 1 addition & 0 deletions client/src/components/posts/form/ToolAsyncSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function ToolAsyncSelect(props: { form: schemas.ToolForm }) {
};

useInit({
isReady: true,
onInit: async () => {
const res = await client.query({ query: ToolQuery, variables: { filterBy: null } });
if (res.data?.post_tools) {
Expand Down
81 changes: 70 additions & 11 deletions client/src/components/posts/form/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { formatISO } from "date-fns";
import { type UseFormReturn, useFormContext as useFormContextOriginal } from "react-hook-form";
import { z } from "zod/v4";

import { PostCategory, PostTypeEnum, UsageStatus, Visibility } from "~/graphql/enums";

import type { User } from "@/apps/users/useUserCurrent";
import type { ID } from "@/gql-tada";
import type { PostEditFragmentType } from "@/graphql/fragments/posts";
import type { PostReviewEditFragmentType } from "@/graphql/fragments/reviews";

Expand Down Expand Up @@ -171,17 +173,12 @@ export namespace schemas {
source_author: data.source_author,
category: data.category as PostCategory | null,
tags:
data.tags?.map(tag => {
const userTagVote = user?.post_tag_votes.find(vote => {
return vote.post.id === data.id && vote.tag.id === tag.id;
});
return {
id: tag.id,
name: tag.name,
label: tag.label,
is_vote_positive: userTagVote?.is_vote_positive ?? null,
};
}) ?? [],
data.tags?.map(tag => ({
id: tag.id,
name: tag.name,
label: tag.label,
...getTagVoteFields({ user, postIdForVote: data.id, tagId: tag.id }),
})) ?? [],
...sharable.deserialize(data),
};
}
Expand Down Expand Up @@ -212,6 +209,39 @@ export namespace schemas {
}
}

export namespace tags {
export function deserialize(args: {
tags?: PostAbstract["tags"];
user?: User | null;
postIdForVote?: ID;
isReviewTags?: boolean;
}) {
return (
args.tags?.map(tag => ({
id: tag.id,
name: tag.name,
// non-review_tags .label includes all `tag_parent.name`s - verbose af
label: args.isReviewTags ? tag.label : undefined,
...getTagVoteFields({
user: args.user,
postIdForVote: args.postIdForVote,
tagId: tag.id,
}),
})) ?? []
);
}
}

function getTagVoteFields(args: { user?: User | null; postIdForVote?: ID; tagId: ID }) {
const userTagVote = args.user?.post_tag_votes.find(
vote => vote.post.id === args.postIdForVote && vote.tag.id === args.tagId,
);
return {
is_vote_positive: userTagVote?.is_vote_positive ?? null,
comment: userTagVote?.comment ?? undefined,
};
}

export const Review = Abstract.safeExtend(sharable.Schema.shape).safeExtend({
review_rating: z.number().min(0).max(100).nullable(),
review_importance: z.number().min(0).max(100).nullable(),
Expand All @@ -223,6 +253,35 @@ export namespace schemas {
export type Review = z.infer<typeof Review>;
export type ReviewForm = UseFormReturn<Review>;

export namespace review {
export function deserialize(args: {
review: PostReviewEditFragmentType;
user?: User | null;
}): Review {
const review = args.review;
return {
id: review.id,
title: review.title,
content_polite: review.content_polite,
content_direct: review.content_direct,
content_rant: review.content_rant,
review_rating: review.review_rating,
review_importance: review.review_importance,
review_usage_status: Review.shape.review_usage_status.parse(review.review_usage_status),
reviewed_at: formatISO(new Date(review.reviewed_at), { representation: "date" }),
is_review_later: review.is_review_later,
tags: tags.deserialize({ tags: review.tags, user: args.user, postIdForVote: review.id }),
review_tags: tags.deserialize({
tags: review.review_tags,
user: args.user,
postIdForVote: review.id,
isReviewTags: true,
}),
...sharable.deserialize(review),
};
}
}

// excludes `sharable.Schema`, because it's always public
export const Tool = Abstract.safeExtend({
type: z.enum(enumConvert(PostTypeEnum)).default(PostTypeEnum.Tool).optional(),
Expand Down
1 change: 1 addition & 0 deletions client/src/utils/useInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function useInit(options: {
const state = useStateValtio({ isLoading: false });

useEffect(() => {
// todo refac: default isReady to true - a missing isReady silently no-ops instead of running
if (!options.isReady) {
return;
}
Expand Down
1 change: 1 addition & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ type PostTagVoteType {
post: PostSimpleType!
is_vote_positive: Boolean
is_changed_my_mind: Boolean!
comment: String!
}

type PostToolType implements PostTypeI {
Expand Down
1 change: 1 addition & 0 deletions server/neuronhub/apps/posts/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ class PostTagVoteType:

is_vote_positive: auto
is_changed_my_mind: auto
comment: auto


@strawberry_django.input(PostTag, partial=True)
Expand Down
Loading
Loading