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.). diff --git a/src/components/AddReactionButton.tsx b/src/components/AddReactionButton.tsx new file mode 100644 index 0000000..a5c34c8 --- /dev/null +++ b/src/components/AddReactionButton.tsx @@ -0,0 +1,126 @@ +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, + ReactionContent, +} from "../lib/reactions"; + +type Props = { + reactable: AddReactionButton_reactable$key; +}; + +const AddReactionButton = ({ reactable }: Props) => { + const data = useFragment( + graphql` + fragment AddReactionButton_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + ...AddReactionButton_updatable + } + } + `, + reactable + ); + + const [showPicker, setShowPicker] = useState(false); + + const [commitAdd, isAddInFlight] = useMutation(graphql` + mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } + `); + + const handleAddReaction = (content: ReactionContent) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + + if (reactionGroup?.viewerHasReacted) { + return; + } + + commitAdd({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + optimisticUpdater: (store) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + if (!reactionGroup || reactionGroup?.viewerHasReacted) { + setShowPicker(false); + 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), + }); + }; + + return ( +
+ + + {showPicker && ( +
+ {REACTION_TYPES.map((type) => ( + + ))} +
+ )} +
+ ); +}; + +export default AddReactionButton; diff --git a/src/components/PullRequest.tsx b/src/components/PullRequest.tsx index f9e7ae1..a5bb57e 100644 --- a/src/components/PullRequest.tsx +++ b/src/components/PullRequest.tsx @@ -1,5 +1,6 @@ import { graphql, useFragment } from "react-relay"; import { PullRequest_pr$key } from "./__generated__/PullRequest_pr.graphql"; +import ReactableReactions from "./ReactableReactions"; const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => { const data = useFragment( @@ -22,6 +23,7 @@ const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => { additions deletions reviewDecision + ...ReactableReactions_reactable } `, pr @@ -98,6 +100,8 @@ const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => { + +
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..0e8ebc0 --- /dev/null +++ b/src/components/ReactionGroup.tsx @@ -0,0 +1,91 @@ +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 { + # ...ReactionGroup_updatable + 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, + }, + }, + optimisticUpdater: (store) => { + const subject = store.get(data.subject.id); + const groups = subject?.getLinkedRecords("reactionGroups"); + const group = groups?.find( + (g) => g.getValue("content") === data.content + ); + const reactors = group?.getLinkedRecord("reactors"); + const totalCount = Number(reactors?.getValue("totalCount")) ?? 0; + reactors?.setValue(totalCount - 1, "totalCount"); + group?.setValue(false, "viewerHasReacted"); + }, + }); + }; + + if (data.reactors.totalCount === 0) { + return null; + } + + return ( + + ); +}; + +export default ReactionGroup; diff --git a/src/lib/reactions.ts b/src/lib/reactions.ts new file mode 100644 index 0000000..e4c3552 --- /dev/null +++ b/src/lib/reactions.ts @@ -0,0 +1,37 @@ +import { ReactionContent } from "../components/__generated__/ReactableReactions_reactions.graphql"; + +export const REACTION_TYPES: ReactionContent[] = [ + "THUMBS_UP", + "THUMBS_DOWN", + "LAUGH", + "HOORAY", + "CONFUSED", + "HEART", + "ROCKET", + "EYES", +]; + +export type { ReactionContent }; + +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}`); + } +}; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 488776f..92b5230 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; @@ -36,9 +36,7 @@ export default function HomePage({ user, onLogout }: HomePageProps) {

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

-

- {user.email} -

+

{user.email}