From 6a2d329b18883d1c47ccd4f2f4f7f3d75a9c8055 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Wed, 3 Dec 2025 16:25:33 +0000 Subject: [PATCH 01/18] graffiti --- src/pages/HomePage.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 488776f..3c8da50 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,5 +1,5 @@ -import { initiateGitHubLogin, type GitHubUser } from '../lib/auth'; -import PullRequestList from '../components/PullRequestList'; +import { initiateGitHubLogin, type GitHubUser } from "../lib/auth"; +import PullRequestList from "../components/PullRequestList"; interface HomePageProps { user: GitHubUser | null; @@ -12,7 +12,7 @@ export default function HomePage({ user, onLogout }: HomePageProps) {

- GitHub Pull Request Viewer + Adam's GitHub Pull Request Viewer

Sign in with GitHub to view your open pull requests @@ -36,9 +36,7 @@ export default function HomePage({ user, onLogout }: HomePageProps) {

Welcome, {user.name || user.login}

-

- {user.email} -

+

{user.email}

+ +
diff --git a/src/components/PullRequestReactions.tsx b/src/components/PullRequestReactions.tsx new file mode 100644 index 0000000..dbf550b --- /dev/null +++ b/src/components/PullRequestReactions.tsx @@ -0,0 +1,61 @@ +import { graphql, useFragment } from "react-relay"; +import { PullRequestReactions_reactions$key } from "./__generated__/PullRequestReactions_reactions.graphql"; + +const REACTION_EMOJI: Record = { + CONFUSED: "😕", + EYES: "👀", + HEART: "❤️", + HOORAY: "🎉", + LAUGH: "😄", + ROCKET: "🚀", + THUMBS_DOWN: "👎", + THUMBS_UP: "👍", +}; + +type Props = { + reactions: PullRequestReactions_reactions$key; +}; + +const PullRequestReactions = ({ reactions }: Props) => { + const data = useFragment( + graphql` + fragment PullRequestReactions_reactions on PullRequest { + reactionGroups { + content + reactors { + totalCount + } + } + } + `, + reactions + ); + + if (!data.reactionGroups || data.reactionGroups.length === 0) { + return null; + } + + const activeReactions = data.reactionGroups.filter( + (group) => group.reactors.totalCount > 0 + ); + + if (activeReactions.length === 0) { + return null; + } + + return ( +
+ {activeReactions.map((group) => ( + + {REACTION_EMOJI[group.content]} + {group.reactors.totalCount} + + ))} +
+ ); +}; + +export default PullRequestReactions; From 959af622a4580b28f0d8b67800e84f7db02519a1 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 10:53:13 +0000 Subject: [PATCH 03/18] switch statement --- src/components/PullRequestReactions.tsx | 39 +++++++++++++++++-------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/PullRequestReactions.tsx b/src/components/PullRequestReactions.tsx index dbf550b..cc480e2 100644 --- a/src/components/PullRequestReactions.tsx +++ b/src/components/PullRequestReactions.tsx @@ -1,15 +1,30 @@ import { graphql, useFragment } from "react-relay"; -import { PullRequestReactions_reactions$key } from "./__generated__/PullRequestReactions_reactions.graphql"; - -const REACTION_EMOJI: Record = { - CONFUSED: "😕", - EYES: "👀", - HEART: "❤️", - HOORAY: "🎉", - LAUGH: "😄", - ROCKET: "🚀", - THUMBS_DOWN: "👎", - THUMBS_UP: "👍", +import { + PullRequestReactions_reactions$key, + ReactionContent, +} from "./__generated__/PullRequestReactions_reactions.graphql"; + +const getReactionEmoji = (content: ReactionContent): string => { + switch (content) { + case "CONFUSED": + return "😕"; + case "EYES": + return "👀"; + case "HEART": + return "❤️"; + case "HOORAY": + return "🎉"; + case "LAUGH": + return "😄"; + case "ROCKET": + return "🚀"; + case "THUMBS_DOWN": + return "👎"; + case "THUMBS_UP": + return "👍"; + default: + throw new Error(`Unknown reaction content: ${content}`); + } }; type Props = { @@ -50,7 +65,7 @@ const PullRequestReactions = ({ reactions }: Props) => { key={group.content} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs border border-zinc-200 dark:border-zinc-700 rounded-full bg-zinc-50 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400" > - {REACTION_EMOJI[group.content]} + {getReactionEmoji(group.content)} {group.reactors.totalCount}
))} From bf0a9f6125af2e0db67092dfd7af4786e28cb5c8 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 10:58:53 +0000 Subject: [PATCH 04/18] remove reactions --- src/components/PullRequestReactions.tsx | 48 ++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/components/PullRequestReactions.tsx b/src/components/PullRequestReactions.tsx index cc480e2..7d0ab0c 100644 --- a/src/components/PullRequestReactions.tsx +++ b/src/components/PullRequestReactions.tsx @@ -1,4 +1,4 @@ -import { graphql, useFragment } from "react-relay"; +import { graphql, useFragment, useMutation } from "react-relay"; import { PullRequestReactions_reactions$key, ReactionContent, @@ -35,8 +35,10 @@ const PullRequestReactions = ({ reactions }: Props) => { const data = useFragment( graphql` fragment PullRequestReactions_reactions on PullRequest { + id reactionGroups { content + viewerHasReacted reactors { totalCount } @@ -46,6 +48,32 @@ const PullRequestReactions = ({ reactions }: Props) => { reactions ); + const [commit, isInFlight] = useMutation(graphql` + mutation PullRequestReactionsRemoveReactionMutation( + $input: RemoveReactionInput! + ) { + removeReaction(input: $input) { + reaction { + content + reactable { + ...PullRequestReactions_reactions + } + } + } + } + `); + + const handleRemoveReaction = (content: ReactionContent) => { + commit({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + }); + }; + if (!data.reactionGroups || data.reactionGroups.length === 0) { return null; } @@ -61,13 +89,25 @@ const PullRequestReactions = ({ reactions }: Props) => { return (
{activeReactions.map((group) => ( - { + e.preventDefault(); + e.stopPropagation(); + if (group.viewerHasReacted && !isInFlight) { + handleRemoveReaction(group.content); + } + }} + disabled={!group.viewerHasReacted || isInFlight} + className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs border rounded-full transition-colors ${ + group.viewerHasReacted + ? "bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 cursor-pointer" + : "bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 cursor-default" + }`} > {getReactionEmoji(group.content)} {group.reactors.totalCount} - + ))}
); From 2bce7115a8b55a9b4a19505ade400e432a098c52 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 11:20:42 +0000 Subject: [PATCH 05/18] add reactions --- src/components/PullRequestReactions.tsx | 89 ++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/src/components/PullRequestReactions.tsx b/src/components/PullRequestReactions.tsx index 7d0ab0c..860f089 100644 --- a/src/components/PullRequestReactions.tsx +++ b/src/components/PullRequestReactions.tsx @@ -1,9 +1,21 @@ +import { useState } from "react"; import { graphql, useFragment, useMutation } from "react-relay"; import { PullRequestReactions_reactions$key, ReactionContent, } from "./__generated__/PullRequestReactions_reactions.graphql"; +const REACTION_TYPES: ReactionContent[] = [ + "THUMBS_UP", + "THUMBS_DOWN", + "LAUGH", + "HOORAY", + "CONFUSED", + "HEART", + "ROCKET", + "EYES", +]; + const getReactionEmoji = (content: ReactionContent): string => { switch (content) { case "CONFUSED": @@ -48,7 +60,9 @@ const PullRequestReactions = ({ reactions }: Props) => { reactions ); - const [commit, isInFlight] = useMutation(graphql` + const [showPicker, setShowPicker] = useState(false); + + const [commitRemove, isRemoveInFlight] = useMutation(graphql` mutation PullRequestReactionsRemoveReactionMutation( $input: RemoveReactionInput! ) { @@ -63,18 +77,45 @@ const PullRequestReactions = ({ reactions }: Props) => { } `); + const [commitAdd, isAddInFlight] = useMutation(graphql` + mutation PullRequestReactionsAddReactionMutation( + $input: AddReactionInput! + ) { + addReaction(input: $input) { + reaction { + content + reactable { + ...PullRequestReactions_reactions + } + } + } + } + `); + const handleRemoveReaction = (content: ReactionContent) => { - commit({ + commitRemove({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + }); + }; + + const handleAddReaction = (content: ReactionContent) => { + commitAdd({ variables: { input: { subjectId: data.id, content: content, }, }, + onCompleted: () => setShowPicker(false), }); }; - if (!data.reactionGroups || data.reactionGroups.length === 0) { + if (!data.reactionGroups) { return null; } @@ -82,23 +123,19 @@ const PullRequestReactions = ({ reactions }: Props) => { (group) => group.reactors.totalCount > 0 ); - if (activeReactions.length === 0) { - return null; - } - return ( -
+
{activeReactions.map((group) => ( ))} + +
+ + + {showPicker && ( +
+ {REACTION_TYPES.map((type) => ( + + ))} +
+ )} +
); }; From 4dbd4cb2be63f89e54efec6f8e74d21746bb21dc Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 11:28:54 +0000 Subject: [PATCH 06/18] create add reaction component --- src/components/AddReactionButton.tsx | 83 +++++++++++++++++++++ src/components/PullRequestReactions.tsx | 99 +------------------------ src/lib/reactions.ts | 36 +++++++++ 3 files changed, 123 insertions(+), 95 deletions(-) create mode 100644 src/components/AddReactionButton.tsx create mode 100644 src/lib/reactions.ts diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx new file mode 100644 index 0000000..ddc7ba1 --- /dev/null +++ b/src/components/AddReactionButton.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { graphql, useFragment, useMutation } from "react-relay"; +import { AddReactionButton_subject$key } from "./__generated__/AddReactionButton_subject.graphql"; +import { REACTION_TYPES, getReactionEmoji } from "../lib/reactions"; +import { ReactionContent } from "./__generated__/PullRequestReactions_reactions.graphql"; + +type Props = { + subject: AddReactionButton_subject$key; +}; + +const AddReactionButton = ({ subject }: Props) => { + const data = useFragment( + graphql` + fragment AddReactionButton_subject on Reactable { + id + } + `, + subject + ); + + const [showPicker, setShowPicker] = useState(false); + + const [commitAdd, isAddInFlight] = useMutation(graphql` + mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + reactable { + ...PullRequestReactions_reactions + } + } + } + } + `); + + const handleAddReaction = (content: ReactionContent) => { + commitAdd({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + onCompleted: () => setShowPicker(false), + }); + }; + + return ( +
+ + + {showPicker && ( +
+ {REACTION_TYPES.map((type) => ( + + ))} +
+ )} +
+ ); +}; + +export default AddReactionButton; diff --git a/src/components/PullRequestReactions.tsx b/src/components/PullRequestReactions.tsx index 860f089..7d7ff35 100644 --- a/src/components/PullRequestReactions.tsx +++ b/src/components/PullRequestReactions.tsx @@ -1,43 +1,10 @@ -import { useState } from "react"; import { graphql, useFragment, useMutation } from "react-relay"; import { PullRequestReactions_reactions$key, ReactionContent, } from "./__generated__/PullRequestReactions_reactions.graphql"; - -const REACTION_TYPES: ReactionContent[] = [ - "THUMBS_UP", - "THUMBS_DOWN", - "LAUGH", - "HOORAY", - "CONFUSED", - "HEART", - "ROCKET", - "EYES", -]; - -const getReactionEmoji = (content: ReactionContent): string => { - switch (content) { - case "CONFUSED": - return "😕"; - case "EYES": - return "👀"; - case "HEART": - return "❤️"; - case "HOORAY": - return "🎉"; - case "LAUGH": - return "😄"; - case "ROCKET": - return "🚀"; - case "THUMBS_DOWN": - return "👎"; - case "THUMBS_UP": - return "👍"; - default: - throw new Error(`Unknown reaction content: ${content}`); - } -}; +import { getReactionEmoji } from "../lib/reactions"; +import AddReactionButton from "./AddReactionButton"; type Props = { reactions: PullRequestReactions_reactions$key; @@ -48,6 +15,7 @@ const PullRequestReactions = ({ reactions }: Props) => { graphql` fragment PullRequestReactions_reactions on PullRequest { id + ...AddReactionButton_subject reactionGroups { content viewerHasReacted @@ -60,8 +28,6 @@ const PullRequestReactions = ({ reactions }: Props) => { reactions ); - const [showPicker, setShowPicker] = useState(false); - const [commitRemove, isRemoveInFlight] = useMutation(graphql` mutation PullRequestReactionsRemoveReactionMutation( $input: RemoveReactionInput! @@ -77,21 +43,6 @@ const PullRequestReactions = ({ reactions }: Props) => { } `); - const [commitAdd, isAddInFlight] = useMutation(graphql` - mutation PullRequestReactionsAddReactionMutation( - $input: AddReactionInput! - ) { - addReaction(input: $input) { - reaction { - content - reactable { - ...PullRequestReactions_reactions - } - } - } - } - `); - const handleRemoveReaction = (content: ReactionContent) => { commitRemove({ variables: { @@ -103,18 +54,6 @@ const PullRequestReactions = ({ reactions }: Props) => { }); }; - const handleAddReaction = (content: ReactionContent) => { - commitAdd({ - variables: { - input: { - subjectId: data.id, - content: content, - }, - }, - onCompleted: () => setShowPicker(false), - }); - }; - if (!data.reactionGroups) { return null; } @@ -147,37 +86,7 @@ const PullRequestReactions = ({ reactions }: Props) => { ))} -
- - - {showPicker && ( -
- {REACTION_TYPES.map((type) => ( - - ))} -
- )} -
+
); }; diff --git a/src/lib/reactions.ts b/src/lib/reactions.ts new file mode 100644 index 0000000..71fa11f --- /dev/null +++ b/src/lib/reactions.ts @@ -0,0 +1,36 @@ +import { ReactionContent } from "../components/__generated__/PullRequestReactions_reactions.graphql"; + +export const REACTION_TYPES: ReactionContent[] = [ + "THUMBS_UP", + "THUMBS_DOWN", + "LAUGH", + "HOORAY", + "CONFUSED", + "HEART", + "ROCKET", + "EYES", +]; + +export const getReactionEmoji = (content: ReactionContent): string => { + switch (content) { + case "CONFUSED": + return "😕"; + case "EYES": + return "👀"; + case "HEART": + return "❤️"; + case "HOORAY": + return "🎉"; + case "LAUGH": + return "😄"; + case "ROCKET": + return "🚀"; + case "THUMBS_DOWN": + return "👎"; + case "THUMBS_UP": + return "👍"; + default: + throw new Error(`Unknown reaction content: ${content}`); + } +}; + From e4ec14e01cf4c70d39b0f1deeff57c6673fd2faa Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 12:05:49 +0000 Subject: [PATCH 07/18] lean into abstract Reactable interface --- src/components/AddReactionButton.tsx | 19 ++--- src/components/PullRequest.tsx | 6 +- src/components/PullRequestReactions.tsx | 94 ------------------------- src/components/ReactableReactions.tsx | 39 ++++++++++ src/components/ReactionGroup.tsx | 79 +++++++++++++++++++++ src/lib/reactions.ts | 5 +- 6 files changed, 135 insertions(+), 107 deletions(-) delete mode 100644 src/components/PullRequestReactions.tsx create mode 100644 src/components/ReactableReactions.tsx create mode 100644 src/components/ReactionGroup.tsx diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx index ddc7ba1..165966d 100644 --- a/src/components/AddReactionButton.tsx +++ b/src/components/AddReactionButton.tsx @@ -1,21 +1,24 @@ import { useState } from "react"; import { graphql, useFragment, useMutation } from "react-relay"; -import { AddReactionButton_subject$key } from "./__generated__/AddReactionButton_subject.graphql"; -import { REACTION_TYPES, getReactionEmoji } from "../lib/reactions"; -import { ReactionContent } from "./__generated__/PullRequestReactions_reactions.graphql"; +import { AddReactionButton_reactable$key } from "./__generated__/AddReactionButton_reactable.graphql"; +import { + REACTION_TYPES, + getReactionEmoji, + ReactionContent, +} from "../lib/reactions"; type Props = { - subject: AddReactionButton_subject$key; + reactable: AddReactionButton_reactable$key; }; -const AddReactionButton = ({ subject }: Props) => { +const AddReactionButton = ({ reactable }: Props) => { const data = useFragment( graphql` - fragment AddReactionButton_subject on Reactable { + fragment AddReactionButton_reactable on Reactable { id } `, - subject + reactable ); const [showPicker, setShowPicker] = useState(false); @@ -26,7 +29,7 @@ const AddReactionButton = ({ subject }: Props) => { reaction { content reactable { - ...PullRequestReactions_reactions + ...ReactableReactions_reactable } } } diff --git a/src/components/PullRequest.tsx b/src/components/PullRequest.tsx index 517fd51..a5bb57e 100644 --- a/src/components/PullRequest.tsx +++ b/src/components/PullRequest.tsx @@ -1,6 +1,6 @@ import { graphql, useFragment } from "react-relay"; import { PullRequest_pr$key } from "./__generated__/PullRequest_pr.graphql"; -import PullRequestReactions from "./PullRequestReactions"; +import ReactableReactions from "./ReactableReactions"; const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => { const data = useFragment( @@ -23,7 +23,7 @@ const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => { additions deletions reviewDecision - ...PullRequestReactions_reactions + ...ReactableReactions_reactable } `, pr @@ -100,7 +100,7 @@ const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => {
- +
diff --git a/src/components/PullRequestReactions.tsx b/src/components/PullRequestReactions.tsx deleted file mode 100644 index 7d7ff35..0000000 --- a/src/components/PullRequestReactions.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { graphql, useFragment, useMutation } from "react-relay"; -import { - PullRequestReactions_reactions$key, - ReactionContent, -} from "./__generated__/PullRequestReactions_reactions.graphql"; -import { getReactionEmoji } from "../lib/reactions"; -import AddReactionButton from "./AddReactionButton"; - -type Props = { - reactions: PullRequestReactions_reactions$key; -}; - -const PullRequestReactions = ({ reactions }: Props) => { - const data = useFragment( - graphql` - fragment PullRequestReactions_reactions on PullRequest { - id - ...AddReactionButton_subject - reactionGroups { - content - viewerHasReacted - reactors { - totalCount - } - } - } - `, - reactions - ); - - const [commitRemove, isRemoveInFlight] = useMutation(graphql` - mutation PullRequestReactionsRemoveReactionMutation( - $input: RemoveReactionInput! - ) { - removeReaction(input: $input) { - reaction { - content - reactable { - ...PullRequestReactions_reactions - } - } - } - } - `); - - const handleRemoveReaction = (content: ReactionContent) => { - commitRemove({ - variables: { - input: { - subjectId: data.id, - content: content, - }, - }, - }); - }; - - if (!data.reactionGroups) { - return null; - } - - const activeReactions = data.reactionGroups.filter( - (group) => group.reactors.totalCount > 0 - ); - - return ( -
- {activeReactions.map((group) => ( - - ))} - - -
- ); -}; - -export default PullRequestReactions; diff --git a/src/components/ReactableReactions.tsx b/src/components/ReactableReactions.tsx new file mode 100644 index 0000000..56157f1 --- /dev/null +++ b/src/components/ReactableReactions.tsx @@ -0,0 +1,39 @@ +import { graphql, useFragment } from "react-relay"; +import AddReactionButton from "./AddReactionButton"; +import ReactionGroup from "./ReactionGroup"; +import { ReactableReactions_reactable$key } from "./__generated__/ReactableReactions_reactable.graphql"; + +type Props = { + reactable: ReactableReactions_reactable$key; +}; + +const ReactableReactions = ({ reactable }: Props) => { + const data = useFragment( + graphql` + fragment ReactableReactions_reactable on Reactable { + ...AddReactionButton_reactable + reactionGroups { + content + ...ReactionGroup_group + } + } + `, + reactable + ); + + if (!data.reactionGroups) { + return null; + } + + return ( +
+ {data.reactionGroups.map((group) => ( + + ))} + + +
+ ); +}; + +export default ReactableReactions; diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx new file mode 100644 index 0000000..9df93ab --- /dev/null +++ b/src/components/ReactionGroup.tsx @@ -0,0 +1,79 @@ +import { graphql, useFragment, useMutation } from "react-relay"; +import { + ReactionGroup_group$key, + ReactionContent, +} from "./__generated__/ReactionGroup_group.graphql"; +import { getReactionEmoji } from "../lib/reactions"; + +type Props = { + group: ReactionGroup_group$key; +}; + +const ReactionGroup = ({ group }: Props) => { + const data = useFragment( + graphql` + fragment ReactionGroup_group on ReactionGroup { + content + viewerHasReacted + reactors { + totalCount + } + subject { + id + } + } + `, + group + ); + + const [commitRemove, isRemoveInFlight] = useMutation(graphql` + mutation ReactionGroupRemoveReactionMutation($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } + `); + + const handleRemoveReaction = (content: ReactionContent) => { + commitRemove({ + variables: { + input: { + subjectId: data.subject.id, + content: content, + }, + }, + }); + }; + + if (data.reactors.totalCount === 0) { + return null; + } + + return ( + + ); +}; + +export default ReactionGroup; diff --git a/src/lib/reactions.ts b/src/lib/reactions.ts index 71fa11f..e4c3552 100644 --- a/src/lib/reactions.ts +++ b/src/lib/reactions.ts @@ -1,4 +1,4 @@ -import { ReactionContent } from "../components/__generated__/PullRequestReactions_reactions.graphql"; +import { ReactionContent } from "../components/__generated__/ReactableReactions_reactions.graphql"; export const REACTION_TYPES: ReactionContent[] = [ "THUMBS_UP", @@ -11,6 +11,8 @@ export const REACTION_TYPES: ReactionContent[] = [ "EYES", ]; +export type { ReactionContent }; + export const getReactionEmoji = (content: ReactionContent): string => { switch (content) { case "CONFUSED": @@ -33,4 +35,3 @@ export const getReactionEmoji = (content: ReactionContent): string => { throw new Error(`Unknown reaction content: ${content}`); } }; - From 109aafbb5eda582779b3af67f30c23d32526238f Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 12:06:47 +0000 Subject: [PATCH 08/18] remove Adam's name --- src/pages/HomePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 3c8da50..92b5230 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -12,7 +12,7 @@ export default function HomePage({ user, onLogout }: HomePageProps) {

- Adam's GitHub Pull Request Viewer + GitHub Pull Request Viewer

Sign in with GitHub to view your open pull requests From 1e46701dfb9d92653b91ea80774ff05d215d41d2 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 12:47:22 +0000 Subject: [PATCH 09/18] an attempt at optimistic updates but types don't work --- src/components/AddReactionButton.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx index 165966d..1d1b7cd 100644 --- a/src/components/AddReactionButton.tsx +++ b/src/components/AddReactionButton.tsx @@ -16,6 +16,10 @@ const AddReactionButton = ({ reactable }: Props) => { graphql` fragment AddReactionButton_reactable on Reactable { id + reactionGroups { + content + ...AddReactionButton_updatable + } } `, reactable @@ -44,6 +48,28 @@ const AddReactionButton = ({ reactable }: Props) => { content: content, }, }, + optimisticUpdater: (store) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + if (!reactionGroup) return; + + const { updatableData } = store.readUpdatableFragment( + graphql` + fragment AddReactionButton_updatable on ReactionGroup @updatable { + content + viewerHasReacted + reactors { + totalCount + } + } + `, + reactionGroup + ); + + updatableData.viewerHasReacted = true; + updatableData.reactors.totalCount++; + }, onCompleted: () => setShowPicker(false), }); }; From 0fb5b7a66e6a257d5f703865cd8fd475d2a8c5f0 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 13:06:43 +0000 Subject: [PATCH 10/18] failed attempt for updates in reactiongroups --- src/components/ReactionGroup.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index 9df93ab..cfab5ff 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -13,6 +13,7 @@ const ReactionGroup = ({ group }: Props) => { const data = useFragment( graphql` fragment ReactionGroup_group on ReactionGroup { + ...ReactionGroup_updatable content viewerHasReacted reactors { @@ -47,6 +48,21 @@ const ReactionGroup = ({ group }: Props) => { content: content, }, }, + optimisticUpdater: (store) => { + if (!data) return; + const { updatableData } = store.readUpdatableFragment( + graphql` + fragment ReactionGroup_updatable on ReactionGroup @updatable { + viewerHasReacted + reactors { + totalCount + } + } + `, + data + ); + updatableData.viewerHasReacted = false; + updatableData.reactors.totalCount--; }); }; From 690e3e0692945b03af852dfa178a1a76c8c19165 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 13:16:41 +0000 Subject: [PATCH 11/18] Fix formatting --- src/components/ReactionGroup.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index cfab5ff..ef5569d 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -63,6 +63,7 @@ const ReactionGroup = ({ group }: Props) => { ); updatableData.viewerHasReacted = false; updatableData.reactors.totalCount--; + }, }); }; From 90c60b4d9c31996e4f450d53466f5dc991fd151e Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 13:22:34 +0000 Subject: [PATCH 12/18] fix add reactiono button types --- src/components/AddReactionButton.tsx | 24 +++++++++++++----------- src/components/ReactionGroup.tsx | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx index 1d1b7cd..ffbdcd5 100644 --- a/src/components/AddReactionButton.tsx +++ b/src/components/AddReactionButton.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { graphql, useFragment, useMutation } from "react-relay"; import { AddReactionButton_reactable$key } from "./__generated__/AddReactionButton_reactable.graphql"; +import { AddReactionButton_updatable$key } from "./__generated__/AddReactionButton_updatable.graphql"; import { REACTION_TYPES, getReactionEmoji, @@ -54,18 +55,19 @@ const AddReactionButton = ({ reactable }: Props) => { ); if (!reactionGroup) return; - const { updatableData } = store.readUpdatableFragment( - graphql` - fragment AddReactionButton_updatable on ReactionGroup @updatable { - content - viewerHasReacted - reactors { - totalCount + const { updatableData } = + store.readUpdatableFragment( + graphql` + fragment AddReactionButton_updatable on ReactionGroup @updatable { + content + viewerHasReacted + reactors { + totalCount + } } - } - `, - reactionGroup - ); + `, + reactionGroup + ); updatableData.viewerHasReacted = true; updatableData.reactors.totalCount++; diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index ef5569d..830d3a3 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -13,7 +13,7 @@ const ReactionGroup = ({ group }: Props) => { const data = useFragment( graphql` fragment ReactionGroup_group on ReactionGroup { - ...ReactionGroup_updatable + # ...ReactionGroup_updatable content viewerHasReacted reactors { From 6b8d42800285647076a4c8a722e82cceced5fa23 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 13:39:44 +0000 Subject: [PATCH 13/18] Maybe use updatable query? --- src/components/ReactionGroup.tsx | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index 830d3a3..92cfe5c 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -4,6 +4,7 @@ import { ReactionContent, } from "./__generated__/ReactionGroup_group.graphql"; import { getReactionEmoji } from "../lib/reactions"; +import { ReactionGroup_updatableQuery$key } from "./__generated__/ReactionGroup_updatableQuery.graphql"; type Props = { group: ReactionGroup_group$key; @@ -50,17 +51,26 @@ const ReactionGroup = ({ group }: Props) => { }, optimisticUpdater: (store) => { if (!data) return; - const { updatableData } = store.readUpdatableFragment( - graphql` - fragment ReactionGroup_updatable on ReactionGroup @updatable { - viewerHasReacted - reactors { - totalCount + + const { updatableData } = + store.readUpdatableQuery( + graphql` + query ReactionGroup_updatableQuery($subjectId: ID!) @updatable { + node(id: $subjectId) { + ... on Reactable { + reactionGroups { + viewerHasReacted + reactors { + totalCount + } + } + } + } } - } - `, - data - ); + `, + { subjectId: data.subject.id } + ); + updatableData.viewerHasReacted = false; updatableData.reactors.totalCount--; }, From 24ce6e9f69e0a0cce563a3da8fcf2a8032f70755 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 20:34:12 +0000 Subject: [PATCH 14/18] Revert "Maybe use updatable query?" This reverts commit 6b8d42800285647076a4c8a722e82cceced5fa23. --- src/components/ReactionGroup.tsx | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index 92cfe5c..830d3a3 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -4,7 +4,6 @@ import { ReactionContent, } from "./__generated__/ReactionGroup_group.graphql"; import { getReactionEmoji } from "../lib/reactions"; -import { ReactionGroup_updatableQuery$key } from "./__generated__/ReactionGroup_updatableQuery.graphql"; type Props = { group: ReactionGroup_group$key; @@ -51,26 +50,17 @@ const ReactionGroup = ({ group }: Props) => { }, optimisticUpdater: (store) => { if (!data) return; - - const { updatableData } = - store.readUpdatableQuery( - graphql` - query ReactionGroup_updatableQuery($subjectId: ID!) @updatable { - node(id: $subjectId) { - ... on Reactable { - reactionGroups { - viewerHasReacted - reactors { - totalCount - } - } - } - } + const { updatableData } = store.readUpdatableFragment( + graphql` + fragment ReactionGroup_updatable on ReactionGroup @updatable { + viewerHasReacted + reactors { + totalCount } - `, - { subjectId: data.subject.id } - ); - + } + `, + data + ); updatableData.viewerHasReacted = false; updatableData.reactors.totalCount--; }, From 8d87924648973d7ce0f561a9ef77d889dbd82c8d Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 20:58:20 +0000 Subject: [PATCH 15/18] handle trying to add same reaction content twice --- src/components/AddReactionButton.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx index ffbdcd5..a5c34c8 100644 --- a/src/components/AddReactionButton.tsx +++ b/src/components/AddReactionButton.tsx @@ -19,6 +19,7 @@ const AddReactionButton = ({ reactable }: Props) => { id reactionGroups { content + viewerHasReacted ...AddReactionButton_updatable } } @@ -42,6 +43,14 @@ const AddReactionButton = ({ reactable }: Props) => { `); const handleAddReaction = (content: ReactionContent) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + + if (reactionGroup?.viewerHasReacted) { + return; + } + commitAdd({ variables: { input: { @@ -53,7 +62,10 @@ const AddReactionButton = ({ reactable }: Props) => { const reactionGroup = data.reactionGroups?.find( (group) => group?.content === content ); - if (!reactionGroup) return; + if (!reactionGroup || reactionGroup?.viewerHasReacted) { + setShowPicker(false); + return; + } const { updatableData } = store.readUpdatableFragment( From 856dd4647e4e1d148336c85ad5fddb0f3136cb03 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 21:26:11 +0000 Subject: [PATCH 16/18] just use old store apis to update --- src/components/ReactionGroup.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index 830d3a3..e86c795 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -49,20 +49,15 @@ const ReactionGroup = ({ group }: Props) => { }, }, optimisticUpdater: (store) => { - if (!data) return; - const { updatableData } = store.readUpdatableFragment( - graphql` - fragment ReactionGroup_updatable on ReactionGroup @updatable { - viewerHasReacted - reactors { - totalCount - } - } - `, - data + const subject = store.get(data.subject.id); + const groups = subject?.getLinkedRecords("reactionGroups"); + const group = groups?.find( + (g) => g.getValue("content") === data.content ); - updatableData.viewerHasReacted = false; - updatableData.reactors.totalCount--; + const reactors = group?.getLinkedRecord("reactors"); + const totalCount = Number(reactors?.getValue("totalCount")) ?? 0; + reactors?.setValue(totalCount - 1, "totalCount"); + group?.setValue(true, "viewerHasReacted"); }, }); }; From 19d1ed12e7da697d701853e3878eab3aa47e9784 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 21:36:05 +0000 Subject: [PATCH 17/18] Fix bug --- src/components/ReactionGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx index e86c795..0e8ebc0 100644 --- a/src/components/ReactionGroup.tsx +++ b/src/components/ReactionGroup.tsx @@ -57,7 +57,7 @@ const ReactionGroup = ({ group }: Props) => { const reactors = group?.getLinkedRecord("reactors"); const totalCount = Number(reactors?.getValue("totalCount")) ?? 0; reactors?.setValue(totalCount - 1, "totalCount"); - group?.setValue(true, "viewerHasReacted"); + group?.setValue(false, "viewerHasReacted"); }, }); }; From 11f20c9bd61ac1a916c990fccdbf83088f436871 Mon Sep 17 00:00:00 2001 From: Adam Fielding Date: Tue, 9 Dec 2025 21:36:11 +0000 Subject: [PATCH 18/18] Add script --- mutations.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 mutations.md diff --git a/mutations.md b/mutations.md new file mode 100644 index 0000000..9d86583 --- /dev/null +++ b/mutations.md @@ -0,0 +1,166 @@ +Note: I'm quite open to removing or deprioritising the optimistic updates here. It's a shame we don't have an example of `optimisticResponse`, and the others look a bit complicated. Not sure they really sell relay's abilities as much as we'd like + +This is also AI generated as I'm out of time, not sure this is that useful. Feel free to discard this if not suitable + +# Relay Workshop: Mutations + +In this section, we'll implement the ability to add and remove reactions from pull requests. We'll cover: + +1. Defining GraphQL mutations +2. Using the `useMutation` hook +3. Implementing **optimistic updates** for instant UI feedback +4. Using both standard Store updates and **Updatable Fragments** (maybe we omit one or both of these. I'd be nice to say that the order of preference for the APIS here is `optimisticResponse` -> `optimisticUpdater` with `@refetchable` -> `optimisticUpdater` with non-typesafe, old `store` apis) + +## 1. Adding a Reaction (`AddReactionButton.tsx`) + +We'll start by allowing users to add a reaction. Open `src/components/AddReactionButton.tsx`. + +### Define the Mutation + +First, we define the mutation to add a reaction. This mutation takes an `input` and returns the updated reaction object. + +```typescript +const [commitAdd, isAddInFlight] = useMutation(graphql` + mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } +`); +``` + +### Implement the Handler with Optimistic Update + +When the user selects an emoji, we want to update the UI immediately, before the server responds. We'll use Relay's **Updatable Fragments** feature for a clean optimistic update. + +Ensure your fragment definition includes the `@updatable` directive on the `ReactionGroup` (this might already be in a separate file or defined locally): + +```graphql +fragment AddReactionButton_updatable on ReactionGroup @updatable { + content + viewerHasReacted + reactors { + totalCount + } +} +``` + +Now, implement `handleAddReaction`: + +```typescript +const handleAddReaction = (content: ReactionContent) => { + // Check if we already reacted + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + + if (reactionGroup?.viewerHasReacted) { + return; + } + + commitAdd({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + // Optimistic Update using Updatable Fragments + optimisticUpdater: (store) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + + if (!reactionGroup || reactionGroup?.viewerHasReacted) { + setShowPicker(false); + return; + } + + // Read the updatable fragment + const { updatableData } = + store.readUpdatableFragment( + graphql` + fragment AddReactionButton_updatable on ReactionGroup @updatable { + content + viewerHasReacted + reactors { + totalCount + } + } + `, + reactionGroup + ); + + // Mutate the data directly + updatableData.viewerHasReacted = true; + updatableData.reactors.totalCount++; + }, + onCompleted: () => setShowPicker(false), + }); +}; +``` + +## 2. Removing a Reaction (`ReactionGroup.tsx`) + +Next, let's handle removing a reaction. Open `src/components/ReactionGroup.tsx`. + +### Define the Mutation + +```typescript +const [commitRemove, isRemoveInFlight] = useMutation(graphql` + mutation ReactionGroupRemoveReactionMutation($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } +`); +``` + +### Implement the Handler with Store API + +For this example, we'll use the imperative **Store API** for the optimistic update. This is the traditional way to handle complex updates in Relay. + +```typescript +const handleRemoveReaction = (content: ReactionContent) => { + commitRemove({ + variables: { + input: { + subjectId: data.subject.id, + content: content, + }, + }, + optimisticUpdater: (store) => { + // 1. Get the subject record + const subject = store.get(data.subject.id); + + // 2. Traverse to the reaction groups + const groups = subject?.getLinkedRecords("reactionGroups"); + const group = groups?.find((g) => g.getValue("content") === data.content); + + // 3. Update the values + const reactors = group?.getLinkedRecord("reactors"); + const totalCount = Number(reactors?.getValue("totalCount")) ?? 0; + + reactors?.setValue(totalCount - 1, "totalCount"); + group?.setValue(false, "viewerHasReacted"); // Set to false since we're removing + }, + }); +}; +``` + +## Summary + +- **`useMutation`**: The primary hook for triggering mutations. +- **Optimistic Updates**: Crucial for a snappy user experience. +- **Updatable Fragments**: A newer, more declarative API for local data updates (`readUpdatableFragment`). +- **Store API**: The imperative API for manual store manipulation (`store.get`, `setValue`, etc.).