Skip to content

Commit

Permalink
Merge pull request #298 from boostcampwm2023/feature/project-link
Browse files Browse the repository at this point in the history
feat: 프로젝트 외부 링크 추가, 삭제 기능 구현
  • Loading branch information
surinkwon authored Jun 23, 2024
2 parents 207ff28 + f9115a4 commit 27bf786
Show file tree
Hide file tree
Showing 10 changed files with 286 additions and 44 deletions.
2 changes: 1 addition & 1 deletion frontend/src/assets/icons/trash-can.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 12 additions & 7 deletions frontend/src/components/landing/link/LandingLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,25 @@ import useLandingLinkSocket from "../../../hooks/common/landing/useLandingLinkSo

const LandingLink = () => {
const { socket }: { socket: Socket } = useOutletContext();
const { link } = useLandingLinkSocket(socket);
const { linkList, emitLinkCreateEvent, emitLinkDeleteEvent } =
useLandingLinkSocket(socket);

const { open, close } = useModal(true);
const handleCreateLinkClick = () => {
open(<LandingLinkModal close={close} />);
open(<LandingLinkModal {...{ close, emitLinkCreateEvent }} />);
};

return (
<div className="w-full shadow-box rounded-lg flex flex-col pt-6 pl-6 pr-3 bg-gradient-to-tr to-middle-green-linear-from from-middle-green">
<div className="flex flex-col w-full pt-6 pl-6 pr-3 rounded-lg shadow-box bg-gradient-to-tr to-middle-green-linear-from from-middle-green">
<LandingTitleUI title={"외부 링크"} handleClick={handleCreateLinkClick} />
<div className="flex flex-col gap-3 pr-6 py-6 overflow-y-scroll scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-dark-green scrollbar-track-transparent">
{link.map((linkData: LandingLinkDTO) => {
return <LandingLinkBlock {...linkData} key={linkData.id} />;
})}
<div className="flex flex-col gap-3 py-6 pr-6 overflow-y-scroll scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-dark-green scrollbar-track-transparent">
{linkList.map((linkData: LandingLinkDTO) => (
<LandingLinkBlock
{...linkData}
{...{ emitLinkDeleteEvent }}
key={linkData.id}
/>
))}
</div>
</div>
);
Expand Down
50 changes: 36 additions & 14 deletions frontend/src/components/landing/link/LandingLinkBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
import { LINK_LOGO_URL } from "../../../constants/landing";
import { LandingLinkDTO } from "../../../types/DTO/landingDTO";
import getLinkType from "../../../utils/getLinkType";
import ProfileImage from "../../common/ProfileImage";
import TrashCan from "../../../assets/icons/trash-can.svg?react";

interface LandingLinkBlockProps extends LandingLinkDTO {
emitLinkDeleteEvent: ({ id }: { id: number }) => void;
}

const LandingLinkBlock = ({
id,
description,
url,
emitLinkDeleteEvent,
}: LandingLinkBlockProps) => {
const linkLogoUrl = `${new URL(url).origin}/favicon.ico`;

const handleDeleteClick = () => {
emitLinkDeleteEvent({ id });
};

const LandingLinkBlock = ({ description, url }: LandingLinkDTO) => {
const linkLogoUrl = LINK_LOGO_URL[getLinkType(url)];
return (
<a
className="w-full flex justify-start items-center gap-4 p-3 bg-white rounded-lg shadow-box hover:bg-light-gray"
href={url}
target="_blank"
>
<ProfileImage imageUrl={linkLogoUrl} pxSize={40} />
<p className="text-dark-green text-xs font-bold truncate">
{description}
</p>
</a>
<div className="flex items-center justify-between w-full p-3 bg-white rounded-lg group shadow-box hover:bg-light-gray">
<a
className="flex items-center justify-start w-full gap-4"
href={url}
target="_blank"
>
<ProfileImage imageUrl={linkLogoUrl} pxSize={40} />
<p className="text-xs font-bold truncate text-dark-green">
{description}
</p>
</a>
<button
className="invisible p-1 rounded-md group-hover:visible hover:bg-white"
type="button"
onClick={handleDeleteClick}
>
<TrashCan width={24} fill="#E33535" />
</button>
</div>
);
};

Expand Down
145 changes: 133 additions & 12 deletions frontend/src/components/landing/link/LandingLinkModal.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,155 @@
import { MouseEventHandler } from "react";
import { ChangeEvent, MouseEventHandler, useState } from "react";
import isValidURL from "../../../utils/isValidURL";
import addSchemeToURL from "../../../utils/addSchemeToURL";

interface LandingLinkModalProps {
close: () => void;
emitLinkCreateEvent: (content: { url: string; description: string }) => void;
}

interface ValidDataState {
urlValid: boolean | null;
urlValidText: string;
descriptionValid: boolean | null;
descriptionValidText: string;
}

const LandingLinkModal = ({
close,
emitLinkCreateEvent,
}: LandingLinkModalProps) => {
const [linkData, setLinkData] = useState({
url: "",
description: "",
});
const [validData, setValidData] = useState<ValidDataState>({
urlValid: null,
urlValidText: "",
descriptionValid: null,
descriptionValidText: "",
});

const checkUrlValidation = (url: string): [boolean, string] => {
if (url.trim().length === 0) {
return [false, "링크 주소를 입력해주세요."];
}

if (decodeURI(addSchemeToURL(url)).length > 255) {
return [false, "링크 주소의 길이가 너무 깁니다."];
}

if (!isValidURL(decodeURI(addSchemeToURL(url)))) {
return [false, "유효하지 않은 링크 주소입니다."];
}

return [true, ""];
};

const checkDescriptionValidation = (
description: string
): [boolean, string] => {
if (description.length > 255) {
return [false, "링크 이름의 길이가 너무 깁니다."];
}

if (description.trim().length === 0) {
return [false, "링크 이름을 입력해주세요."];
}

return [true, ""];
};

const handleUrlChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const { value } = target;
const [urlValid, urlValidText] = checkUrlValidation(value);

setLinkData({ ...linkData, url: decodeURI(value) });
setValidData({ ...validData, urlValid, urlValidText });
};

const handleDescriptionChange = ({
target,
}: ChangeEvent<HTMLInputElement>) => {
const { value } = target;
const [descriptionValid, descriptionValidText] =
checkDescriptionValidation(value);

setLinkData({ ...linkData, description: value });
setValidData({ ...validData, descriptionValid, descriptionValidText });
};

const handleConfirmClick = () => {
const { url, description } = linkData;
const urlWithScheme = decodeURI(addSchemeToURL(url));
const [urlValid, urlValidText] = checkUrlValidation(urlWithScheme);
const [descriptionValid, descriptionValidText] =
checkDescriptionValidation(description);

if (!urlValid || !descriptionValid) {
setValidData({
urlValid,
urlValidText,
descriptionValid,
descriptionValidText,
});
return;
}

emitLinkCreateEvent({ ...linkData, url: urlWithScheme });
close();
};

const LandingLinkModal = ({ close }: { close: () => void }) => {
const handleCloseClick: MouseEventHandler<
HTMLButtonElement | HTMLDivElement
> = ({ target, currentTarget }: React.MouseEvent) => {
if (target !== currentTarget) return;
if (target !== currentTarget) {
return;
}
close();
};

return (
<div
className="top-0 left-0 absolute w-screen h-screen flex justify-center items-center bg-black bg-opacity-30 z-50"
className="fixed top-0 left-0 z-50 flex items-center justify-center w-full h-full bg-black bg-opacity-30"
onClick={handleCloseClick}
>
<div className="w-[40.625rem] bg-white flex flex-col p-9 gap-9 rounded-lg shadow-box">
<p className="text-m font-bold">| 링크 추가하기</p>
<div className="flex gap-8 items-center">
<p className="font-bold text-m">| 링크 추가하기</p>
<div className="flex gap-8">
<p className="text-xs shrink-0">링크 주소</p>
<input className="rounded-lg w-full border border-text-gray text-xs py-1 px-2" />
<div className="w-full">
<input
className="w-full px-2 py-1 text-xs border rounded-lg border-text-gray"
value={linkData.url}
onChange={handleUrlChange}
/>
<p className={`text-xxxs text-error-red h-[1.125rem]`}>
{validData.urlValidText}
</p>
</div>
</div>
<div className="flex gap-8 items-center">
<div className="flex gap-8">
<p className="text-xs shrink-0">링크 이름</p>
<input className="rounded-lg w-full border border-text-gray text-xs py-1 px-2" />
<div className="w-full">
<input
className="w-full px-2 py-1 text-xs border rounded-lg border-text-gray"
value={linkData.description}
onChange={handleDescriptionChange}
/>
<p className="text-xxxs text-error-red h-[1.125rem]">
{validData.descriptionValidText}
</p>
</div>
</div>
<div className="flex gap-3 justify-end">
<button className="rounded-lg bg-middle-green text-white text-xs px-3 py-1 hover:bg-dark-green">
<div className="flex justify-end gap-3">
<button
className="px-3 py-1 text-xs text-white rounded-lg bg-middle-green hover:bg-dark-green"
onClick={handleConfirmClick}
>
완료
</button>
<button
className="rounded-lg border border-light-gray text-xs px-3 py-1 hover:bg-light-gray"
className="px-3 py-1 text-xs border rounded-lg border-light-gray hover:bg-light-gray"
onClick={handleCloseClick}
>
취소
Expand Down
46 changes: 38 additions & 8 deletions frontend/src/hooks/common/landing/useLandingLinkSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,50 @@ import { LandingDTO, LandingLinkDTO } from "../../../types/DTO/landingDTO";
import {
LandingSocketData,
LandingSocketDomain,
LandingSocketLinkAction,
} from "../../../types/common/landing";

const useLandingLinkSocket = (socket: Socket) => {
const [link, setLink] = useState<LandingLinkDTO[]>([]);
const [linkList, setLinkList] = useState<LandingLinkDTO[]>([]);
const handleInitEvent = (content: LandingDTO) => {
const { link } = content as LandingDTO;
setLink(link);
const { linkList } = content as LandingDTO;
setLinkList(linkList);
};

const handleOnLanding = ({ domain, content }: LandingSocketData) => {
if (domain !== LandingSocketDomain.INIT) {
return;
const handleLinkEvent = (
action: LandingSocketLinkAction,
content: LandingLinkDTO
) => {
switch (action) {
case LandingSocketLinkAction.CREATE:
setLinkList([...linkList, content]);
break;
case LandingSocketLinkAction.DELETE:
setLinkList(linkList.filter(({ id }) => id !== content.id));
break;
}
handleInitEvent(content);
};

const handleOnLanding = ({ domain, action, content }: LandingSocketData) => {
switch (domain) {
case LandingSocketDomain.INIT:
handleInitEvent(content);
break;
case LandingSocketDomain.LINK:
handleLinkEvent(action, content);
break;
}
};

const emitLinkCreateEvent = (content: {
url: string;
description: string;
}) => {
socket.emit("link", { action: "create", content });
};

const emitLinkDeleteEvent = (content: { id: number }) => {
socket.emit("link", { action: "delete", content });
};

useEffect(() => {
Expand All @@ -28,7 +58,7 @@ const useLandingLinkSocket = (socket: Socket) => {
};
}, []);

return { link };
return { linkList, emitLinkCreateEvent, emitLinkDeleteEvent };
};

export default useLandingLinkSocket;
18 changes: 18 additions & 0 deletions frontend/src/test/isValidURL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import isValidURL from "../utils/isValidURL";

describe("URL 유효성 검사 함수", () => {
it("스킴이 http, https가 아닐 때", () => {
expect(isValidURL("ftp://example.com/file.txt")).toBe(false);
});

it("도메인 이름이 형식에 맞지 않을 때", () => {
expect(isValidURL("http://example!.com")).toBe(false);
expect(isValidURL("http://exa mple.com")).toBe(false);
expect(isValidURL("http://exam*ple.com")).toBe(false);
expect(isValidURL("http://.com")).toBe(false);
});

it("쿼리 스트링 포함", () => {
expect(isValidURL("https://www.google.com/search?q=openai")).toBe(true);
});
});
2 changes: 1 addition & 1 deletion frontend/src/types/DTO/landingDTO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ export interface LandingDTO {
myInfo: LandingMemberDTO;
sprint: LandingSprintDTO | null;
memoList: LandingMemoDTO[];
link: LandingLinkDTO[];
linkList: LandingLinkDTO[];
inviteLinkId: string;
}
Loading

0 comments on commit 27bf786

Please sign in to comment.