simple add remove follower

This commit is contained in:
Stepan Usatiuk
2023-12-17 12:01:21 +01:00
parent 871c6950c9
commit d752d20d92
22 changed files with 246 additions and 34 deletions

View File

@@ -8,6 +8,7 @@
"name": "yclient", "name": "yclient",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"jwt-decode": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
@@ -4725,6 +4726,14 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@@ -10,6 +10,7 @@
}, },
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
"dependencies": { "dependencies": {
"jwt-decode": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",

View File

@@ -10,7 +10,12 @@ import { deleteToken, getToken } from "./api/utils";
import { Login } from "./Login"; import { Login } from "./Login";
import { Signup } from "./Signup"; import { Signup } from "./Signup";
import { Home } from "./Home"; import { Home } from "./Home";
import { loginAction, profileSelfAction, signupAction } from "./actions"; import {
loginAction,
profileSelfAction,
signupAction,
userListAction,
} from "./actions";
import { homeLoader, profileLoader, userListLoader } from "./loaders"; import { homeLoader, profileLoader, userListLoader } from "./loaders";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import { Feed } from "./Feed"; import { Feed } from "./Feed";
@@ -39,7 +44,12 @@ const router = createBrowserRouter([
children: [ children: [
{ path: "feed", element: <Feed /> }, { path: "feed", element: <Feed /> },
{ path: "messages", element: <Messages /> }, { path: "messages", element: <Messages /> },
{ path: "users", element: <UserList />, loader: userListLoader }, {
path: "users",
element: <UserList />,
loader: userListLoader,
action: userListAction,
},
{ {
path: "profile", path: "profile",
loader: profileLoader, loader: profileLoader,

View File

@@ -3,6 +3,7 @@ import { homeLoader, LoaderToType } from "./loaders";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import "./Home.scss"; import "./Home.scss";
import { HomeContextType } from "./HomeContext";
export function Home() { export function Home() {
const loaderData = useLoaderData() as LoaderToType<typeof homeLoader>; const loaderData = useLoaderData() as LoaderToType<typeof homeLoader>;
@@ -42,7 +43,9 @@ export function Home() {
</div> </div>
</div> </div>
<div id="HomeContent"> <div id="HomeContent">
<Outlet /> <Outlet
context={{ user: loaderData } satisfies HomeContextType}
/>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,10 @@
import { TPersonTo } from "./api/dto";
import { useOutletContext } from "react-router-dom";
export type HomeContextType = {
user: TPersonTo;
};
export function useHomeContext() {
return useOutletContext<HomeContextType>();
}

View File

@@ -3,6 +3,7 @@ import { Form, Link, useLoaderData } from "react-router-dom";
import { LoaderToType, profileLoader } from "./loaders"; import { LoaderToType, profileLoader } from "./loaders";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import { Post } from "./Post"; import { Post } from "./Post";
import { useHomeContext } from "./HomeContext";
export interface IProfileProps { export interface IProfileProps {
self: boolean; self: boolean;
@@ -11,6 +12,8 @@ export interface IProfileProps {
export function Profile({ self }: IProfileProps) { export function Profile({ self }: IProfileProps) {
const loaderData = useLoaderData() as LoaderToType<typeof profileLoader>; const loaderData = useLoaderData() as LoaderToType<typeof profileLoader>;
const homeContext = useHomeContext();
if (!loaderData || isError(loaderData)) { if (!loaderData || isError(loaderData)) {
return <div>Error</div>; return <div>Error</div>;
} }
@@ -22,8 +25,8 @@ export function Profile({ self }: IProfileProps) {
return ( return (
<div className={"profileView"}> <div className={"profileView"}>
<div className={"profileInfo"}> <div className={"profileInfo"}>
<span className={"fullName"}>{loaderData.user.fullName}</span> <span className={"fullName"}>{homeContext.user.fullName}</span>
<span className={"username"}>{loaderData.user.fullName}</span> <span className={"username"}>{homeContext.user.fullName}</span>
</div> </div>
{self && ( {self && (
<div className={"newPost"}> <div className={"newPost"}>

View File

@@ -1,12 +1,18 @@
import "./ProfileCard.scss"; import "./ProfileCard.scss";
import { Link } from "react-router-dom"; import { Form, Link } from "react-router-dom";
export function ProfileCard({ export function ProfileCard({
username, username,
fullName, fullName,
uuid,
actions,
alreadyFollowing,
}: { }: {
username: string; username: string;
fullName: string; fullName: string;
uuid: string;
actions: boolean;
alreadyFollowing: boolean;
}) { }) {
return ( return (
<div className={"profileCard"}> <div className={"profileCard"}>
@@ -16,6 +22,30 @@ export function ProfileCard({
<Link to={`/home/profile/${username}`} className={"username"}> <Link to={`/home/profile/${username}`} className={"username"}>
{username} {username}
</Link> </Link>
{actions &&
(alreadyFollowing ? (
<Form method={"put"}>
<input hidden={true} value={uuid} name={"uuid"} />
<button
type={"submit"}
name={"intent"}
value={"unfollow"}
>
unfollow
</button>
</Form>
) : (
<Form method={"put"}>
<input hidden={true} value={uuid} name={"uuid"} />
<button
type={"submit"}
name={"intent"}
value={"follow"}
>
follow
</button>
</Form>
))}
</div> </div>
); );
} }

View File

@@ -2,22 +2,33 @@ import { useLoaderData } from "react-router-dom";
import { LoaderToType, userListLoader } from "./loaders"; import { LoaderToType, userListLoader } from "./loaders";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import { ProfileCard } from "./ProfileCard"; import { ProfileCard } from "./ProfileCard";
import { useHomeContext } from "./HomeContext";
export function UserList() { export function UserList() {
const loaderData = useLoaderData() as LoaderToType<typeof userListLoader>; const loaderData = useLoaderData() as LoaderToType<typeof userListLoader>;
const homeContext = useHomeContext();
if (!loaderData || isError(loaderData)) { if (!loaderData) {
return <div>Error</div>; return <div>Error</div>;
} }
const { people, following } = loaderData;
if (isError(following) || isError(people)) {
return <div>Error</div>;
}
return ( return (
<div> <div>
{loaderData.map((u) => { {people.map((u) => {
return ( return (
<ProfileCard <ProfileCard
username={u.username} username={u.username}
fullName={u.fullName} fullName={u.fullName}
uuid={u.uuid}
key={u.uuid} key={u.uuid}
actions={homeContext.user.uuid != u.uuid}
alreadyFollowing={following.some(
(f) => f.uuid == u.uuid,
)}
/> />
); );
})} })}

View File

@@ -1,4 +1,4 @@
import { signup } from "./api/Person"; import { addFollower, removeFollower, signup } from "./api/Person";
import { ActionFunctionArgs, redirect } from "react-router-dom"; import { ActionFunctionArgs, redirect } from "react-router-dom";
import { login } from "./api/Token"; import { login } from "./api/Token";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
@@ -61,3 +61,14 @@ export async function profileSelfAction({ request }: ActionFunctionArgs) {
); );
} }
} }
export async function userListAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent")!.toString();
console.log(intent);
if (intent == "follow") {
return await addFollower(formData.get("uuid")!.toString());
} else if (intent == "unfollow") {
return await removeFollower(formData.get("uuid")!.toString());
}
}

View File

@@ -1,7 +1,9 @@
import { fetchJSON, fetchJSONAuth } from "./utils"; import { fetchJSON, fetchJSONAuth } from "./utils";
import { import {
NoContentToResp,
PersonToArrResp, PersonToArrResp,
PersonToResp, PersonToResp,
TNoContentToResp,
TPersonToArrResp, TPersonToArrResp,
TPersonToResp, TPersonToResp,
} from "./dto"; } from "./dto";
@@ -30,6 +32,10 @@ export async function getAllPerson(): Promise<TPersonToArrResp> {
return fetchJSONAuth("/person", "GET", PersonToArrResp); return fetchJSONAuth("/person", "GET", PersonToArrResp);
} }
export async function getFollowing(): Promise<TPersonToArrResp> {
return fetchJSONAuth("/person/following", "GET", PersonToArrResp);
}
export async function getPersonByUsername( export async function getPersonByUsername(
username: string, username: string,
): Promise<TPersonToResp> { ): Promise<TPersonToResp> {
@@ -39,3 +45,15 @@ export async function getPersonByUsername(
PersonToResp, PersonToResp,
); );
} }
export async function addFollower(uuid: string): Promise<TNoContentToResp> {
return fetchJSONAuth("/person/following/" + uuid, "PUT", NoContentToResp);
}
export async function removeFollower(uuid: string): Promise<TNoContentToResp> {
return fetchJSONAuth(
"/person/following/" + uuid,
"DELETE",
NoContentToResp,
);
}

View File

@@ -17,6 +17,22 @@ export async function deletePost(id: number): Promise<TNoContentToResp> {
return fetchJSONAuth(`/post/${id.toString()}`, "DELETE", NoContentToResp); return fetchJSONAuth(`/post/${id.toString()}`, "DELETE", NoContentToResp);
} }
export async function getPosts(author: string): Promise<TPostToArrResp> { export async function getPostsByAuthorUuid(
return fetchJSONAuth(`/post?author=${author}`, "GET", PostToArrResp); author: string,
): Promise<TPostToArrResp> {
return fetchJSONAuth(
`/post/by-author-uuid?author=${author}`,
"GET",
PostToArrResp,
);
}
export async function getPostsByAuthorUsername(
author: string,
): Promise<TPostToArrResp> {
return fetchJSONAuth(
`/post/by-author-username?author=${author}`,
"GET",
PostToArrResp,
);
} }

View File

@@ -1,5 +1,8 @@
// import { apiRoot } from "~src/env"; // import { apiRoot } from "~src/env";
import { jwtDecode } from "jwt-decode";
import { isError, TErrorTo } from "./dto";
const apiRoot: string = "http://localhost:8080"; const apiRoot: string = "http://localhost:8080";
let token: string | null; let token: string | null;
@@ -16,6 +19,12 @@ export function getToken(): string | null {
return token; return token;
} }
export function getTokenUserUuid(): string | null {
const token = getToken();
if (!token) return null;
return jwtDecode(token).sub ?? null;
}
export function deleteToken(): void { export function deleteToken(): void {
token = null; token = null;
localStorage.removeItem("jwt_token"); localStorage.removeItem("jwt_token");

View File

@@ -1,8 +1,13 @@
import { getAllPerson, getPersonByUsername, getSelf } from "./api/Person"; import {
import { deleteToken, getToken } from "./api/utils"; getAllPerson,
getFollowing,
getPersonByUsername,
getSelf,
} from "./api/Person";
import { deleteToken, getToken, getTokenUserUuid } from "./api/utils";
import { redirect } from "react-router-dom"; import { redirect } from "react-router-dom";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import { getPosts } from "./api/Post"; import { getPostsByAuthorUsername, getPostsByAuthorUuid } from "./api/Post";
export type LoaderToType<T extends (...args: any) => any> = export type LoaderToType<T extends (...args: any) => any> =
| Exclude<Awaited<ReturnType<T>>, Response> | Exclude<Awaited<ReturnType<T>>, Response>
@@ -25,7 +30,7 @@ export async function homeLoader() {
} }
export async function userListLoader() { export async function userListLoader() {
return await getAllPerson(); return { people: await getAllPerson(), following: await getFollowing() };
} }
export async function profileLoader({ export async function profileLoader({
@@ -33,27 +38,31 @@ export async function profileLoader({
}: { }: {
params: { username?: string }; params: { username?: string };
}) { }) {
const self = await getCheckUserSelf(); const selfUuid = getTokenUserUuid();
if (!self || self instanceof Response || isError(self)) { if (!selfUuid) return redirect("/");
return self; if (selfUuid == params.username) {
}
if (self.username == params.username) {
return redirect("/home/profile"); return redirect("/home/profile");
} }
const user = params.username const posts = params.username
? await getPersonByUsername(params.username) ? await getPostsByAuthorUsername(params.username)
: self; : await getPostsByAuthorUuid(selfUuid);
if (!user || user instanceof Response || isError(user)) {
return user;
}
const posts = await getPosts(user.uuid); const retUser = params.username
? await getPersonByUsername(params.username)
: null;
if (
(params.username && !retUser) ||
retUser instanceof Response ||
isError(retUser)
) {
return retUser;
}
if (isError(posts)) { if (isError(posts)) {
return { user, posts: null }; return { user: retUser, posts: null };
} }
return { user, posts }; return { user: retUser, posts };
} }

View File

@@ -7,6 +7,7 @@ import com.usatiuk.tjv.y.server.entity.Person;
import com.usatiuk.tjv.y.server.service.PersonService; import com.usatiuk.tjv.y.server.service.PersonService;
import com.usatiuk.tjv.y.server.service.exceptions.UserAlreadyExistsException; import com.usatiuk.tjv.y.server.service.exceptions.UserAlreadyExistsException;
import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException; import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -79,4 +80,16 @@ public class PersonController {
return personService.getFollowing(principal.getName()).stream().map(PersonMapper::makeDto); return personService.getFollowing(principal.getName()).stream().map(PersonMapper::makeDto);
} }
@PutMapping(path = "/following/{uuid}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void addFollowing(Principal principal, @PathVariable String uuid) throws UserNotFoundException {
personService.addFollower(principal.getName(), uuid);
}
@DeleteMapping(path = "/following/{uuid}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteFollowing(Principal principal, @PathVariable String uuid) throws UserNotFoundException {
personService.removeFollower(principal.getName(), uuid);
}
} }

View File

@@ -36,8 +36,16 @@ public class PostController {
return PostMapper.makeDto(postService.create(post)); return PostMapper.makeDto(postService.create(post));
} }
@GetMapping @GetMapping(path = "/by-author-uuid")
public Stream<PostTo> readAllByAuthor(@RequestParam Optional<String> author) { public Stream<PostTo> readAllByAuthorUuid(@RequestParam Optional<String> author) {
if (author.isPresent())
return postService.readByAuthorId(author.get()).stream().map(PostMapper::makeDto);
else
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
@GetMapping(path = "/by-author-username")
public Stream<PostTo> readAllByAuthorUsername(@RequestParam Optional<String> author) {
if (author.isPresent()) if (author.isPresent())
return postService.readByAuthorId(author.get()).stream().map(PostMapper::makeDto); return postService.readByAuthorId(author.get()).stream().map(PostMapper::makeDto);
else else

View File

@@ -12,6 +12,8 @@ import java.util.Collection;
public interface PostRepository extends PagingAndSortingRepository<Post, Long>, CrudRepository<Post, Long> { public interface PostRepository extends PagingAndSortingRepository<Post, Long>, CrudRepository<Post, Long> {
Collection<Post> findByAuthorUuid(String authorUuid); Collection<Post> findByAuthorUuid(String authorUuid);
Collection<Post> findByAuthorUsername(String authorUsername);
@Query(value = "SELECT p FROM Post p " + @Query(value = "SELECT p FROM Post p " +
"WHERE EXISTS " + "WHERE EXISTS " +
"(SELECT u FROM Person u LEFT JOIN u.following f where u.uuid = :personUuid and f.uuid = p.author.uuid)") "(SELECT u FROM Person u LEFT JOIN u.following f where u.uuid = :personUuid and f.uuid = p.author.uuid)")

View File

@@ -17,4 +17,8 @@ public interface PersonService extends CrudService<Person, String> {
Collection<Person> getFollowers(String uuid) throws UserNotFoundException; Collection<Person> getFollowers(String uuid) throws UserNotFoundException;
Collection<Person> getFollowing(String uuid) throws UserNotFoundException; Collection<Person> getFollowing(String uuid) throws UserNotFoundException;
void addFollower(String follower, String followee) throws UserNotFoundException;
void removeFollower(String follower, String followee) throws UserNotFoundException;
} }

View File

@@ -4,6 +4,7 @@ import com.usatiuk.tjv.y.server.entity.Person;
import com.usatiuk.tjv.y.server.repository.PersonRepository; import com.usatiuk.tjv.y.server.repository.PersonRepository;
import com.usatiuk.tjv.y.server.service.exceptions.UserAlreadyExistsException; import com.usatiuk.tjv.y.server.service.exceptions.UserAlreadyExistsException;
import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException; import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException;
import jakarta.persistence.EntityManager;
import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.CrudRepository;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -15,11 +16,13 @@ import java.util.Optional;
public class PersonServiceImpl extends CrudServiceImpl<Person, String> implements PersonService { public class PersonServiceImpl extends CrudServiceImpl<Person, String> implements PersonService {
private final PersonRepository personRepository; private final PersonRepository personRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final EntityManager entityManager;
public PersonServiceImpl(PersonRepository personRepository, public PersonServiceImpl(PersonRepository personRepository,
PasswordEncoder passwordEncoder) { PasswordEncoder passwordEncoder, EntityManager entityManager) {
this.personRepository = personRepository; this.personRepository = personRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.entityManager = entityManager;
} }
@Override @Override
@@ -59,4 +62,18 @@ public class PersonServiceImpl extends CrudServiceImpl<Person, String> implement
public Collection<Person> getFollowing(String uuid) throws UserNotFoundException { public Collection<Person> getFollowing(String uuid) throws UserNotFoundException {
return personRepository.findById(uuid).orElseThrow(UserNotFoundException::new).getFollowing(); return personRepository.findById(uuid).orElseThrow(UserNotFoundException::new).getFollowing();
} }
@Override
public void addFollower(String follower, String followee) throws UserNotFoundException {
var person = personRepository.findById(follower).orElseThrow(UserNotFoundException::new);
person.getFollowing().add(entityManager.getReference(Person.class, followee));
personRepository.save(person);
}
@Override
public void removeFollower(String follower, String followee) throws UserNotFoundException {
var person = personRepository.findById(follower).orElseThrow(UserNotFoundException::new);
person.getFollowing().remove(entityManager.getReference(Person.class, followee));
personRepository.save(person);
}
} }

View File

@@ -7,5 +7,7 @@ import java.util.Collection;
public interface PostService extends CrudService<Post, Long> { public interface PostService extends CrudService<Post, Long> {
Collection<Post> readByAuthorId(String authorUuid); Collection<Post> readByAuthorId(String authorUuid);
Collection<Post> readByAuthorUsername(String authorUsername);
Collection<Post> readByPersonFollowees(String personUuid); Collection<Post> readByPersonFollowees(String personUuid);
} }

View File

@@ -25,6 +25,11 @@ public class PostServiceImpl extends CrudServiceImpl<Post, Long> implements Post
return postRepository.findByAuthorUuid(authorId); return postRepository.findByAuthorUuid(authorId);
} }
@Override
public Collection<Post> readByAuthorUsername(String authorUsername) {
return postRepository.findByAuthorUsername(authorUsername);
}
@Override @Override
public Collection<Post> readByPersonFollowees(String personUuid) { public Collection<Post> readByPersonFollowees(String personUuid) {
return postRepository.findByPersonFollowees(personUuid); return postRepository.findByPersonFollowees(personUuid);

View File

@@ -119,4 +119,25 @@ public class PersonControllerTest extends DemoDataDbTest {
Assertions.assertIterableEquals(Arrays.asList(personToResponse), List.of(PersonMapper.makeDto(person2), PersonMapper.makeDto(person1))); Assertions.assertIterableEquals(Arrays.asList(personToResponse), List.of(PersonMapper.makeDto(person2), PersonMapper.makeDto(person1)));
} }
@Test
void shouldAddFollowing() {
var response = restTemplate.exchange(addr + "/person/following/" + person3.getUuid(),
HttpMethod.PUT, new HttpEntity<>(createAuthHeaders(person1Auth)), Object.class);
Assertions.assertNotNull(response);
Assertions.assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
var response2 = restTemplate.exchange(addr + "/person/following",
HttpMethod.GET, new HttpEntity<>(createAuthHeaders(person1Auth)), PersonTo[].class);
Assertions.assertNotNull(response2);
Assertions.assertEquals(HttpStatus.OK, response2.getStatusCode());
var personToResponse = response2.getBody();
Assertions.assertNotNull(personToResponse);
Assertions.assertEquals(1, personToResponse.length);
Assertions.assertIterableEquals(Arrays.asList(personToResponse), List.of(PersonMapper.makeDto(person3)));
}
} }

View File

@@ -60,7 +60,7 @@ public class PostControllerTest extends DemoDataDbTest {
@Test @Test
void shouldGetByAuthor() { void shouldGetByAuthor() {
var response = restTemplate.exchange(addr + "/post?author=" + person1.getUuid(), HttpMethod.GET, var response = restTemplate.exchange(addr + "/post/by-author-uuid?author=" + person1.getUuid(), HttpMethod.GET,
HttpEntity.EMPTY, PostTo[].class); HttpEntity.EMPTY, PostTo[].class);
Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); Assertions.assertEquals(HttpStatus.OK, response.getStatusCode());