Profile
🍔 핵심 내용
🥑 프로필 화면을 만들어 보자.
1. useParams 훅을 배워보자.
Params 는 url 주소의 파라미터 값을 말한다.
2. 프로필 링크 걸어주기
헤더 부분, 포토 부분에 있는 아바타사진과 유저네임 부분에 링크를 걸어주자.
3. Profile 컴포넌트를 만들어 주자.
해당 컴포넌트에서 useParams를 통해 파라미터 값을 가져와보자
🍔 코드 리뷰
🥑 Photo.js
...
<Link to={`/users/${user.username}`}>
<Avatar lg url={user.avatar} />
</Link>
<Link to={`/users/${user.username}`}>
<Username>{user.username}</Username>
</Link>
...
🥑 Comment.js
...
<Link to={`/users/${author}`}>
<FatText>{author}</FatText>
</Link>
...
Link 걸어주었다.
🥑 App.js
...
<Route path={`/users/:userName`}>
<Profile />
</Route>
...
Public 하게 Route를 하나 만들어 주었고, 이때 :userName 부분을 통해 Parameter 역할을 한다. 만약에 : 가 없으면 그냥 userName 문자 그대로 주소를 쳐야 해당 컴포넌트 화면으로 들어가진다.
🥑 Profile.js
import { useParams } from "react-router-dom";
function Profile() {
const { username } = useParams();
return "Profile";
}
export default Profile;
useParams() 훅을 통해서 파라미터 값을 가지고 올 수 있다. 해당 값을 콘솔로그로 찍어보면
{ userName : 파라미터값(kimmad) } 이렇게 나온다.
🍔 핵심 내용
🥑 Profile 화면에 seeProfile Query를 불러오고, data를 불러올 때 Fragment로 가져오는 방법도 배워보자.
여러 곳에 중복된 data들을 가지고 올 수 있다. 이 때 노가다로 하나씩 가지고 와도 되는데, 자주 가져오는 data에 대해서는 Fragment로 별도 파일로 묶어줘서 가지고 오자.
🍔 코드 리뷰
🥑 Fragments.js
import { gql } from "@apollo/client";
export const PHOTO_FRAGMENT = gql`
fragment PhotoFragment on Photo {
id
file
likes
commentNumber
isLiked
}
`;
export const COMMENT_FRAGMENT = gql`
fragment CommentFragment on Comment {
id
user {
username
avatar
}
payload
isMine
createdAt
}
`;
fragment 아무 이름(변수 명) on 백엔드 항목(백엔드와 이름 같아야함) 으로 Photo와 Comment에서 자주 가지고 오는 data를 Fragment화 해주었다.
🥑 Home.js
import { gql, useQuery } from "@apollo/client";
import Photo from "../components/feed/Photo";
import PageTitle from "../components/PageTitle";
import { COMMENT_FRAGMENT, PHOTO_FRAGMENT } from "../fragments";
const FEED_QUERY = gql`
query seeFeed {
seeFeed {
...PhotoFragment
user {
userName
avatar
}
caption
comments {
...CommentFragment
}
createdAt
isMine
}
}
${PHOTO_FRAGMENT}
${COMMENT_FRAGMENT}
`;
function Home() {
const { data } = useQuery(FEED_QUERY);
return (
<div>
<PageTitle title="Home" />
{data?.seeFeed?.map((photo) => (
<Photo key={photo.id} {...photo} />
))}
</div>
);
}
export default Home;
위에서 만든 Fragment를 활용해주는 방법이다.
🥑 Home.js
import { gql, useQuery } from "@apollo/client";
import { useParams } from "react-router-dom";
import { PHOTO_FRAGMENT } from "../fragments";
const SEE_PROFILE_QUERY = gql`
query seeProfile($userName: String!) {
seeProfile(userName: $userName) {
firstName
lastName
userName
bio
avatar
photos {
...PhotoFragment
}
totalFollowings
totalFollowers
isMe
isFollowing
}
}
${PHOTO_FRAGMENT}
`;
function Profile() {
const { userName } = useParams();
const { data } = useQuery(SEE_PROFILE_QUERY, {
variables: {
userName,
},
});
console.log(data);
return "Profile";
}
export default Profile;
Profile 화면에서 seeProfile 쿼리 데이터를 받아 왔다. 콘솔로그 값을 확인해보면, 아래와 같이 data 값을 가지고 온 걸 알 수 있다.
여기서 문제는, 위 코딩 내용에 id 값을 가지고 오지 않았기 떄문에 아래와 같이 cache 데이터에 User 값이 별도로 저장 되고 있지 않다. id 값을 받아오면 해당 문제는 해결 된다.
🍔 핵심 내용
🥑 keyField의 이해
apollo cache에서는 기본 키 값을 id로 되어 있다. 해당 id 대신 다른 값을 넣을 수도 있다.
(위 CACHE 화면처럼 Photo: id 값 , User: id 값 이렇게 되어 있다) id 값 대신 userName을 고유 값으로 넣어보자.
먼저, USER가 ROOT_QUERY에서 string 형식이 아닌 ref로 따로 CACHE로 저장 되어야 하는 이유는, 저장된 CACHE user값을 통해 해당 값들을 업데이트 해줄 수 있기 때문이다. (totalFollowing이나 isFollowing 같은 값들)
🍔 코드 리뷰
🥑 apollo.js
...
export const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: (obj) => `User:${obj.username}`,
},
},
}),
});
cache 부분에 User값의 keyFields를 변경 가능. userName으로 해보자. 아래와 같이 된다.
위 apollo cache의 주요 개념을 다시 정리 하자면
1. ROOT_QUERY는 해당 화면에서 QUERY한 값들이 보인다. 만약 다른 화면에서 추가 query가 생기면 ROOT_QUERY에 추가 된다. 만약 이때 가지고 오는 결과 값이 id나 고유 키 값이 있으면 별도로 CACHE에 저장 된다. (왼쪽 화면)
2. CACHE에 저장된 것도 해당 화면에서 요청한 결과값만 가지고 오고, 동일한 User: kimmad 여도, 다른 화면에서 해당 kimmad의 결과값이 추가 되면 해당 결과값이 더 추가 된다. 예를 들면 A 화면에서 a,b 결과값을 요청하고 B 화면에서 b,c를 요청하면 최종 kimmad CACHE값은 a,b,c로 되어 있다.
🍔 핵심 내용
🥑 follow / unfollow 기능 만들기
프로필 화면에서 follow / unfollow 기능을 만들어 보자.
🍔 코드 리뷰
🥑 App.js
...
<Layout>
<Profile />
</Layout>
...
Profile 컴포넌트를 넣는다. (레이아웃 컴포넌트 안에)
🥑 Header.js
...
<Link to={routes.home}>
<FontAwesomeIcon icon={faHome} size="lg" />
</Link>
...
헤더부분 Home에 링크를 만들어 준다.
🥑 Profile.js
import { gql, useQuery } from "@apollo/client";
import { faHeart, faComment } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import { FatText } from "../components/shared";
import { PHOTO_FRAGMENT } from "../fragments";
const SEE_PROFILE_QUERY = gql`
query seeProfile($username: String!) {
seeProfile(username: $username) {
firstName
lastName
username
bio
avatar
photos {
...PhotoFragment
}
totalFollowing
totalFollowers
isMe
isFollowing
}
}
${PHOTO_FRAGMENT}
`;
const Header = styled.div`
display: flex;
`;
const Avatar = styled.img`
margin-left: 50px;
height: 160px;
width: 160px;
border-radius: 50%;
margin-right: 150px;
background-color: #2c2c2c;
`;
const Column = styled.div``;
const Username = styled.h3`
font-size: 28px;
font-weight: 400;
`;
const Row = styled.div`
margin-bottom: 20px;
font-size: 16px;
`;
const List = styled.ul`
display: flex;
`;
const Item = styled.li`
margin-right: 20px;
`;
const Value = styled(FatText)`
font-size: 18px;
`;
const Name = styled(FatText)`
font-size: 20px;
`;
const Grid = styled.div`
display: grid;
grid-auto-rows: 290px;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
margin-top: 50px;
`;
const Photo = styled.div`
background-image: url(${(props) => props.bg});
background-size: cover;
position: relative;
`;
const Icons = styled.div`
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
color: white;
opacity: 0;
&:hover {
opacity: 1;
}
`;
const Icon = styled.span`
font-size: 18px;
display: flex;
align-items: center;
margin: 0px 5px;
svg {
font-size: 14px;
margin-right: 5px;
}
`;
function Profile() {
const { username } = useParams();
const { data } = useQuery(SEE_PROFILE_QUERY, {
variables: {
username,
},
});
return (
<div>
<Header>
<Avatar src={data?.seeProfile?.avatar} />
<Column>
<Row>
<Username>{data?.seeProfile?.username}</Username>
</Row>
<Row>
<List>
<Item>
<span>
<Value>{data?.seeProfile?.totalFollowers}</Value> followers
</span>
</Item>
<Item>
<span>
<Value>{data?.seeProfile?.totalFollowing}</Value> following
</span>
</Item>
</List>
</Row>
<Row>
<Name>
{data?.seeProfile?.firstName}
{" "}
{data?.seeProfile?.lastName}
</Name>
</Row>
<Row>{data?.seeProfile?.bio}</Row>
</Column>
</Header>
<Grid>
{data?.seeProfile?.photos.map((photo) => (
<Photo bg={photo.file}>
<Icons>
<Icon>
<FontAwesomeIcon icon={faHeart} />
{photo.likes}
</Icon>
<Icon>
<FontAwesomeIcon icon={faComment} />
{photo.commentNumber}
</Icon>
</Icons>
</Photo>
))}
</Grid>
</div>
);
}
export default Profile;
Profile 화면에 seeProfile query data를 불러 왔고, css로 꾸며주었다.
🍔 핵심 내용
🥑 follow / unfollow 기능 만들기 2
프로필 화면에서 follow / unfollow 기능을 만들어 보자.
🍔 코드 리뷰
🥑 index.html
<title>React App</title>
// 아래 내용으로 변경
<title>Instaclone</title>
기본 title 명칭
🥑 Profile.js
import { gql, useQuery } from "@apollo/client";
import { faHeart, faComment } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import Button from "../components/auth/Button";
import PageTitle from "../components/PageTitle";
import { FatText } from "../components/shared";
import { PHOTO_FRAGMENT } from "../fragments";
const FOLLOW_USER_MUTATION = gql`
mutation followUser($username: String!) {
followUser(username: $username) {
ok
}
}
`;
const UNFOLLOW_USER_MUTATION = gql`
mutation unfollowUser($username: String!) {
unfollowUser(username: $username) {
ok
}
}
`;
const SEE_PROFILE_QUERY = gql`
query seeProfile($username: String!) {
seeProfile(username: $username) {
firstName
lastName
username
bio
avatar
photos {
...PhotoFragment
}
totalFollowing
totalFollowers
isMe
isFollowing
}
}
${PHOTO_FRAGMENT}
`;
const Header = styled.div`
display: flex;
`;
const Avatar = styled.img`
margin-left: 50px;
height: 160px;
width: 160px;
border-radius: 50%;
margin-right: 150px;
background-color: #2c2c2c;
`;
const Column = styled.div``;
const Username = styled.h3`
font-size: 28px;
font-weight: 400;
`;
const Row = styled.div`
margin-bottom: 20px;
font-size: 16px;
display: flex;
align-items: center;
`;
const List = styled.ul`
display: flex;
`;
const Item = styled.li`
margin-right: 20px;
`;
const Value = styled(FatText)`
font-size: 18px;
`;
const Name = styled(FatText)`
font-size: 20px;
`;
const Grid = styled.div`
display: grid;
grid-auto-rows: 290px;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
margin-top: 50px;
`;
const Photo = styled.div`
background-image: url(${(props) => props.bg});
background-size: cover;
position: relative;
`;
const Icons = styled.div`
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
color: white;
opacity: 0;
&:hover {
opacity: 1;
}
`;
const Icon = styled.span`
font-size: 18px;
display: flex;
align-items: center;
margin: 0px 5px;
svg {
font-size: 14px;
margin-right: 5px;
}
`;
const ProfileBtn = styled(Button).attrs({
as: "span",
})`
margin-left: 10px;
margin-top: 0px;
`;
function Profile() {
const { username } = useParams();
const { data, loading } = useQuery(SEE_PROFILE_QUERY, {
variables: {
username,
},
});
const getButton = (seeProfile) => {
const { isMe, isFollowing } = seeProfile;
if (isMe) {
return <ProfileBtn>Edit Profile</ProfileBtn>;
}
if (isFollowing) {
return <ProfileBtn>Unfollow</ProfileBtn>;
} else {
return <ProfileBtn>Follow</ProfileBtn>;
}
};
return (
<div>
<PageTitle
title={
loading ? "Loading..." : `${data?.seeProfile?.username}'s Profile`
}
/>
<Header>
<Avatar src={data?.seeProfile?.avatar} />
<Column>
<Row>
<Username>{data?.seeProfile?.username}</Username>
{data?.seeProfile ? getButton(data.seeProfile) : null}
</Row>
<Row>
<List>
<Item>
<span>
<Value>{data?.seeProfile?.totalFollowers}</Value> followers
</span>
</Item>
<Item>
<span>
<Value>{data?.seeProfile?.totalFollowing}</Value> following
</span>
</Item>
</List>
</Row>
<Row>
<Name>
{data?.seeProfile?.firstName}
{" "}
{data?.seeProfile?.lastName}
</Name>
</Row>
<Row>{data?.seeProfile?.bio}</Row>
</Column>
</Header>
<Grid>
{data?.seeProfile?.photos.map((photo) => (
<Photo key={photo.id} bg={photo.file}>
<Icons>
<Icon>
<FontAwesomeIcon icon={faHeart} />
{photo.likes}
</Icon>
<Icon>
<FontAwesomeIcon icon={faComment} />
{photo.commentNumber}
</Icon>
</Icons>
</Photo>
))}
</Grid>
</div>
);
}
export default Profile;
핵심 내용
1. follow / unfollow mutation 추가
2. 이전에 만든 Button은 input 타입이라 children은 받을 수 없었으나, attrs({as:"span"}) 을 통해서 span으로 쓸 수 있음.
3. 로딩 시 title 제목 변경
4. 삼항 연산자 내용이 길어지면, 별도 함수로 만들어 줌
🍔 핵심 내용
🥑 follow / unfollow 기능 만들기 3 - 마지막
다른 사람 follow, unfollow cache 실시간 업데이트 및 실시간으로 내 following수 연동
🍔 코드 리뷰
🥑 profile.js
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
import { faHeart, faComment } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import Button from "../components/auth/Button";
import PageTitle from "../components/PageTitle";
import { FatText } from "../components/shared";
import { PHOTO_FRAGMENT } from "../fragments";
import useUser from "../hooks/useUser";
const FOLLOW_USER_MUTATION = gql`
mutation followUser($userName: String!) {
followUser(userName: $userName) {
ok
}
}
`;
const UNFOLLOW_USER_MUTATION = gql`
mutation unfollowUser($userName: String!) {
unfollowUser(userName: $userName) {
ok
}
}
`;
const SEE_PROFILE_QUERY = gql`
query seeProfile($userName: String!) {
seeProfile(userName: $userName) {
firstName
lastName
userName
bio
avatar
photos {
...PhotoFragment
}
totalFollowings
totalFollowers
isMe
isFollowing
}
}
${PHOTO_FRAGMENT}
`;
const Header = styled.div`
display: flex;
`;
const Avatar = styled.img`
margin-left: 50px;
height: 160px;
width: 160px;
border-radius: 50%;
margin-right: 150px;
background-color: #2c2c2c;
`;
const Column = styled.div``;
const Username = styled.h3`
font-size: 28px;
font-weight: 400;
`;
const Row = styled.div`
margin-bottom: 20px;
font-size: 16px;
display: flex;
align-items: center;
`;
const List = styled.ul`
display: flex;
`;
const Item = styled.li`
margin-right: 20px;
`;
const Value = styled(FatText)`
font-size: 18px;
`;
const Name = styled(FatText)`
font-size: 20px;
`;
const Grid = styled.div`
display: grid;
grid-auto-rows: 290px;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
margin-top: 50px;
`;
const Photo = styled.div`
background-image: url(${(props) => props.bg});
background-size: cover;
position: relative;
`;
const Icons = styled.div`
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
color: white;
opacity: 0;
&:hover {
opacity: 1;
}
`;
const Icon = styled.span`
font-size: 18px;
display: flex;
align-items: center;
margin: 0px 5px;
svg {
font-size: 14px;
margin-right: 5px;
}
`;
const ProfileBtn = styled(Button).attrs({
as: "span",
})`
margin-left: 10px;
margin-top: 0px;
cursor: pointer;
`;
function Profile() {
const { userName } = useParams();
const { data: userData } = useUser();
const client = useApolloClient();
const { data, loading } = useQuery(SEE_PROFILE_QUERY, {
variables: {
userName,
},
});
const unfollowUserUpdate = (cache, result) => {
const {
data: {
unfollowUser: { ok },
},
} = result;
if (!ok) {
return;
}
cache.modify({
id: `User:${userName}`,
fields: {
isFollowing(prev) {
return false;
},
totalFollowers(prev) {
return prev - 1;
},
},
});
const { me } = userData;
cache.modify({
id: `User:${me.userName}`,
fields: {
totalFollowings(prev) {
return prev - 1;
},
},
});
};
const [unfollowUser] = useMutation(UNFOLLOW_USER_MUTATION, {
variables: {
userName,
},
update: unfollowUserUpdate,
});
const followUserCompleted = (data) => {
const {
followUser: { ok },
} = data;
if (!ok) {
return;
}
const { cache } = client;
cache.modify({
id: `User:${userName}`,
fields: {
isFollowing(prev) {
return true;
},
totalFollowers(prev) {
return prev + 1;
},
},
});
const { me } = userData;
cache.modify({
id: `User:${me.userName}`,
fields: {
totalFollowings(prev) {
return prev + 1;
},
},
});
};
const [followUser] = useMutation(FOLLOW_USER_MUTATION, {
variables: {
userName,
},
onCompleted: followUserCompleted,
});
const getButton = (seeProfile) => {
const { isMe, isFollowing } = seeProfile;
if (isMe) {
return <ProfileBtn>Edit Profile</ProfileBtn>;
}
if (isFollowing) {
return <ProfileBtn onClick={unfollowUser}>Unfollow</ProfileBtn>;
} else {
return <ProfileBtn onClick={followUser}>Follow</ProfileBtn>;
}
};
return (
<div>
<PageTitle
title={
loading ? "Loading..." : `${data?.seeProfile?.userName}'s Profile`
}
/>
<Header>
<Avatar src={data?.seeProfile?.avatar} />
<Column>
<Row>
<Username>{data?.seeProfile?.userName}</Username>
{data?.seeProfile ? getButton(data.seeProfile) : null}
</Row>
<Row>
<List>
<Item>
<span>
<Value>{data?.seeProfile?.totalFollowers}</Value> followers
</span>
</Item>
<Item>
<span>
<Value>{data?.seeProfile?.totalFollowings}</Value> following
</span>
</Item>
</List>
</Row>
<Row>
<Name>
{data?.seeProfile?.firstName}
{" "}
{data?.seeProfile?.lastName}
</Name>
</Row>
<Row>{data?.seeProfile?.bio}</Row>
</Column>
</Header>
<Grid>
{data?.seeProfile?.photos.map((photo) => (
<Photo key={photo.id} bg={photo.file}>
<Icons>
<Icon>
<FontAwesomeIcon icon={faHeart} />
{photo.likes}
</Icon>
<Icon>
<FontAwesomeIcon icon={faComment} />
{photo.commentNumber}
</Icon>
</Icons>
</Photo>
))}
</Grid>
</div>
);
}
export default Profile;
이전 photo update 하는 것과 유사하다. 특이점은 cache 데이터를 받아 오기 위해서는 2가지 방법이 있는데,
첫번째는 useMutation에서 update를 통해 받아 올 수 있고 (기존 photo에서 like 하던 방법, cache와 result(data)를 받아 올 수 있음)
두번 째는, onCompleted 에서는 cache를 사용 할 수 없는데(data만 받아옴), 여기서 글로벌하게 cache를 사용하려면 useApolloClient()를 사용하면 된다.