Scrum Toolkit 작성 #2 - 클라이언트용 React, TypeScript 및 Websocket 설정
80160 단어 webdevshowdevjavascriptreact
애플리케이션은 글로벌 앱 스토어에 Redux를 사용하고 있습니다. react-router를 통해 경로를 일치시키고 카드 드래그 앤 드롭에 react-dnd를 사용합니다. 따라서 index.tsx에서 모든 것을 함께 설정합니다.
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
</Provider>
</BrowserRouter>
</React.StrictMode>,
);
redux의 앱 스토어는 카드, 사용자, 투표 및 보드의 네 가지 주요 엔터티로 구성됩니다. 보드는 중심점이며 사용자는 보드마다 있습니다. 각 보드는 여러 사용자와 카드를 처리할 수 있습니다. 카드는 한 명의 사용자만 작성할 수 있으며 동일하거나 다른 사용자로부터 여러 표를 받을 수 있습니다.
// cards state
export type CardsState = Array<RawCard>;
// config state
export type ConfigState = {
localUser: RawUser;
board: {
boardId: string;
stage: number;
timerTo: number;
maxVotes: number;
mode: string;
};
users: Array<RawUser>;
socket: Socket | null;
};
원시 엔티티는 다음과 같습니다.
export type RawVote = {
id: string;
userId: string;
};
export type RawUser = {
id: string;
nickname: string;
avatar: number;
isReady: boolean;
selectedPlanningCard: number;
};
export type RawCard = {
id: string;
stackedOn: string;
content: string;
userId: string;
column: number;
votes: RawVote[];
createdAt: number;
};
API와의 통신은 socket.io로 합니다. "Socket Manager"를 사용하여 연결, 핸들러 등록 및 소켓 핸들러 관리를 위한 사용자 지정 후크를 작성했습니다.
export type SocketHook = {
connect: (nickname: string, avatar: number, boardId: string) => void;
socket: Socket<IncomingEvents, OutgoingEvents> | null;
};
export function useSocket(): SocketHook {
const socket = useAppSelector((state) => state.config.socket);
const dispatch = useAppDispatch();
const navigate = useNavigate();
function connect(nickname: string, avatar: number, boardId: string) {
if (socket?.connected) {
socket.disconnect();
}
const newSocket: Socket<IncomingEvents, OutgoingEvents> = io('http://localhost:3001', { transports: ['websocket', 'polling'] });
newSocket.on('connect', () => {
newSocket.emit('Join', {
nickname,
boardId,
avatar,
});
dispatch({
type: actions.config.SetNickname,
payload: {
nickname,
},
});
dispatch({
type: actions.config.SetBoardId,
payload: {
boardId,
},
});
});
registerUsersHandlers(newSocket, dispatch, navigate);
registerBoardsHandlers(newSocket, dispatch);
registerCardsHandlers(newSocket, dispatch);
dispatch({
type: actions.config.SetSocket,
payload: {
socket: newSocket,
},
});
}
return { connect, socket };
}
모든 핸들러는 특정 이벤트에 대한 리스너를 등록하는 소켓을 허용합니다. 이 접근 방식 덕분에 여러 이벤트를 쉽게 관리할 수 있습니다. 클라이언트는 들어오는 이벤트를 감속기에 전달하여 이벤트에 응답합니다.
import { Socket } from 'socket.io-client';
import { IncomingEvents, OutgoingEvents } from './events';
import { RootDispatch } from '../utils/store';
import actions from '../actions';
function registerCardsHandlers(
socket: Socket<IncomingEvents, OutgoingEvents>,
dispatch: RootDispatch,
) {
socket.on('CardState', (data) => {
dispatch({
type: actions.cards.SetOneCard,
payload: {
card: data.card,
},
});
});
// ...
}
export default registerCardsHandlers;
보드는 보드에 대한 모든 공통 논리를 보유하는 간단한 컨테이너 구성 요소입니다. 보드 모드에 따라 Retro 또는 Planning 보기를 열 수 있습니다.
초기 로드 시 앱은 후크를 사용하여 로컬 저장소에서 닉네임과 아바타를 가져오려고 합니다. 실패하면 사용자의 닉네임을 생성하고 임의의 아바타를 선택합니다. 두 정보는 나중에 변경할 수 있습니다.
function Board() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isNavbarOpen, setIsNavbarOpen] = useState(false);
const socketController = useSocket();
const [nickname, setNickname] = useLocalStorage<string>(
'nickname',
`Guest${Math.floor(Math.random() * 10000)}`,
);
const [avatar, setAvatar] = useLocalStorage<number>(
'avatar',
Math.floor(Math.random() * 89),
);
useEffect(() => {
if (!socketController.socket?.connected) {
if (!id) navigate('/');
socketController.connect(nickname, avatar, id || '');
}
return () => {
socketController.socket?.disconnect();
};
}, []);
const localUser = useAppSelector((state) => state.config.localUser);
const board = useAppSelector((state) => state.config.board);
const [isUserModalOpen, setIsUserModalOpen] = useState(false);
const [userModalNickname, setUserModalNickname] = useState('');
const [userModalAvatar, setUserModalAvatar] = useState(0);
const handleUserModalOpen = () => {
setUserModalNickname(localUser.nickname);
setUserModalAvatar(localUser.avatar);
setIsUserModalOpen(true);
};
const handleUserModalSave = () => {
if (!userModalNickname) return;
socketController.socket?.emit('ChangeUserData', {
nickname: userModalNickname,
avatar: userModalAvatar,
});
setNickname(userModalNickname);
setAvatar(userModalAvatar);
setIsUserModalOpen(false);
};
return (
<div>
<Sidebar
isOpen={isNavbarOpen}
onSidebarToggleClick={() => setIsNavbarOpen(!isNavbarOpen)}
onChangeUserData={handleUserModalOpen}
/>
{board.mode === 'retro' && <Retro />}
{(board.mode === 'planning_hidden' ||
board.mode === 'planning_revealed') && <Planning />}
<UserModal
isOpen={isUserModalOpen}
avatar={userModalAvatar}
nickname={userModalNickname}
onSave={handleUserModalSave}
onChangeAvatar={setUserModalAvatar}
onChangeNickname={setUserModalNickname}
onClose={() => setIsUserModalOpen(false)}
/>
</div>
);
}
export default Board;
레트로 보기는 다른 유형의 카드를 세 개의 열로 표시합니다. 첫 번째 단계에서는 자신의 카드만 볼 수 있고, 두 번째 단계에서는 모든 카드를 볼 수 있지만 자신의 투표만 볼 수 있으며, 세 번째 단계에서는 모든 카드, 모든 투표 및 세 번째 열을 볼 수 있습니다. 이 접근 방식은 작업을 작성하거나 투표하는 동안 사용자가 서로를 추측하거나 제안하는 것을 방지합니다.
카드는 쌓일 수 있으므로 렌더링할 때 다른 카드에 의존하는 모든 카드를 필터링해야 합니다(스택의 중간 또는 맨 아래에 있음). 다음은 카드 상태, CRUD 작업, 업보팅, 다운보팅, 스태킹, 언스태킹 등을 조작하는 모든 핸들러입니다.
const getCardsStack = (firstCardId: string, allCards: Array<RawCard>) => {
const cardsStack: Array<RawCard> = [];
let cardOnTopOfStack = allCards.find((card) => card.id === firstCardId);
while (cardOnTopOfStack && cardOnTopOfStack.stackedOn !== '') {
cardOnTopOfStack = allCards.find(
// eslint-disable-next-line no-loop-func
(card) => card.id === cardOnTopOfStack?.stackedOn,
);
if (cardOnTopOfStack) cardsStack.push(cardOnTopOfStack);
}
return cardsStack;
};
const getVotes = (
card: RawCard,
allCards: Array<RawCard>,
boardStage: number,
localUserId: string,
) => {
let votesCount = card.votes.length;
if (boardStage === 1) {
votesCount = card.votes.filter(
(vote) => vote.userId === localUserId,
).length;
}
if (card.stackedOn) {
const stack = getCardsStack(card.id, allCards);
if (boardStage === 1) {
for (let i = 0; i < stack.length; i++) {
const item = stack[i];
votesCount += item.votes.filter(
(vote) => vote.userId === localUserId,
).length;
}
} else {
for (let i = 0; i < stack.length; i++) {
votesCount += stack[i].votes.length;
}
}
}
return votesCount;
};
// ...
const cards = useAppSelector((state) => state.cards);
const board = useAppSelector((state) => state.config.board);
const localUser = useAppSelector((state) => state.config.localUser);
const socketController = useSocket();
const handleCardGroup = (cardId: string, stackedOn: string) => {
socketController.socket?.emit('GroupCards', { cardId, stackedOn });
};
// ...
{(!isMobile || selectedColumn === 0) && (
<List
id={0}
type="positive"
columnWidth={columnWidth}
selectedColumn={selectedColumn}
onChangeColumn={setSelectedColumn}
>
{cards
.filter(
(card) =>
card.column === 0 &&
!cards.some(
(nestedCard) => nestedCard.stackedOn === card.id,
),
)
.filter(
(card) => board.stage !== 0 || card.userId === localUser.id,
)
.sort((a, b) => {
if (board.stage !== 2) {
return b.createdAt - a.createdAt;
}
return b.votes.length - a.votes.length;
})
.map((card) => {
const votesCount = getVotes(
card,
cards,
board.stage,
localUser.id,
);
return (
<Card
key={card.id}
id={card.id}
content={card.content}
onDecreaseVote={() => handleDownvote(card.id)}
votesCount={votesCount}
onDelete={() => handleCardDelete(card.id)}
onEdit={() => handleCardEdit(card.id, card.content)}
onGroup={handleCardGroup}
onUngroup={handleCardUngroup}
onIncreaseVote={() => handleUpvote(card.id)}
stack={!!card.stackedOn}
displayVotes={board.stage !== 0}
color="success"
createdAt={card.createdAt}
/>
);
})}
</List>
)}
// ...
심판으로 각 카드 레지스터 드래그 앤 드롭. 불투명도와 테두리를 약간 변경하여 드래그 중이거나 끝났음을 나타냅니다. 쌓인 카드는 데크에 불규칙하게 뒤섞인 실제 카드처럼 보이도록 배치됩니다.
카드에 대한 명성은 콘텐츠에서 항상 "명성"이라는 단어를 찾아 수행됩니다. 표시되면 배경이 애니메이션 밈 GIF로 변경됩니다. 이를 통해 보드는 의식 중에 더욱 매력적이고 흥미롭게 보입니다.
// ..
const [{ isDragging }, drag] = useDrag(() => ({
type: 'card',
item: {
id,
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
const [{ isOver }, drop] = useDrop(() => ({
accept: 'card',
drop: (item: { id: string }) => {
onGroup(item.id, id);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
const isKudos = content.toLowerCase().indexOf('kudos') > -1;
const kudosHash = createdAt % 32;
const kudosImage = `/kudos/q${kudosHash}.gif`;
const kudosStyles = isKudos
? {
backgroundImage: `url(${kudosImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: {};
const cardColor =
color === 'success' && !isKudos ? 'text-black' : 'text-white';
// ...
export default Card;
계획 보기에는 피보나치 수열에 관한 카드 세트가 표시되며, 다음 카드는 이전 두 카드의 합입니다. 두 개의 추가 카드는 "추산 방법을 모릅니다"와 "예식이 너무 깁니다"를 의미합니다. 카드를 선택하면 자동으로 사용자 상태가 준비됨으로 변경되어 다른 사람들이 귀하가 카드를 선택하고 공개할 준비가 되었음을 알립니다.
상단에 공개한 후 번호가 매겨진 카드의 평균과 PS4용 "Knowledge is Power"게임에서 영감을 받은 작은 프롬프트를 볼 수 있습니다. 선택한 카드는 약간의 애니메이션으로 표시되므로 무엇을 선택했는지 알 수 있고 보드를 더욱 역동적으로 만들 수 있습니다.
function Planning() {
const socketController = useSocket();
const localUser = useAppSelector((state) => state.config.localUser);
const board = useAppSelector((state) => state.config.board);
const users = useAppSelector((state) => state.config.users);
const handleSetSelectPlanningCard = (selectedPlanningCard: number) => {
socketController.socket?.emit('SetSelectedPlanningCard', {
selectedPlanningCard,
});
};
// ...
const cardsMap: Array<{
number: number | undefined;
icon: 'not sure' | 'break pls' | undefined;
}> = [
{ number: 0, icon: undefined },
{ number: 1, icon: undefined },
// ...
{ number: undefined, icon: 'not sure' },
{ number: undefined, icon: 'break pls' },
];
const userVotes = users.filter((user) => user.selectedPlanningCard !== 0);
const userVotesWithNumbers = userVotes.filter(
(user) =>
user.selectedPlanningCard !== 11 && user.selectedPlanningCard !== 12,
);
const sum = userVotesWithNumbers.reduce(
(acc, user) => acc + (cardsMap[user.selectedPlanningCard].number || 0),
0,
);
const average = Number((sum / (userVotesWithNumbers.length || 1)).toFixed(1));
const comments = [
'The voting is over.',
'How did our players vote?',
// ...
'Time to check the valuation!',
];
return (
<ShiftedContent>
<div className="vh-100 w-100 bg-planning overflow-y-auto">
<div className="container d-flex align-items-center">
<div className="row m-0 w-100">
<div className="mt-5 col-12 col-lg-8 offset-lg-2 ">
{board.mode === 'planning_hidden' && (
<div className="d-flex flex-row flex-wrap justify-content-center">
{cardsMap
.filter((card) => card.number !== 0)
.map((card, index) => (
<PlanningCard
key={card.number}
number={card.number}
icon={card.icon}
selected={localUser.selectedPlanningCard === index + 1}
onClick={() => handleSetSelectPlanningCard(index + 1)}
/>
))}
</div>
)}
{board.mode === 'planning_revealed' && (
<div>
<div className="small text-white text-center">
{
comments[
(userVotesWithNumbers.length + sum + users.length) %
comments.length
]
}
</div>
<h1 className="text-white text-center">{average}</h1>
<div className="d-flex flex-row flex-wrap justify-content-center">
{userVotes.map((user) => (
<PlanningCard
key={user.nickname}
number={cardsMap[user.selectedPlanningCard].number}
icon={cardsMap[user.selectedPlanningCard].icon}
voter={user.nickname}
/>
))}
</div>
</div>
)}
</div>
<div className="my-3 col-12 d-flex align-items-center justify-content-center">
<button
onClick={handleResetPlanning}
type="button"
className="btn btn-primary"
disabled={board.mode === 'planning_hidden'}
>
Reset
</button>
<button
onClick={handleRevealPlanning}
type="button"
className="ms-3 btn btn-success"
disabled={board.mode === 'planning_revealed'}
>
Reveal
</button>
</div>
</div>
</div>
</div>
</ShiftedContent>
);
}
export default Planning;
클라이언트의 마지막 부분은 사이드바입니다. 미래의 타임스탬프로 타이머를 설정하고 준비 상태를 전환하고 사용자 모달을 열고 다른 참가자를 볼 수 있습니다. 사이드바는 넓고 열리거나 좁고 닫힐 수 있습니다.
// ...
const users = useAppSelector((state) => state.config.users);
const board = useAppSelector((state) => state.config.board);
const localUser = useAppSelector((state) => state.config.localUser);
const socketController = useSocket();
const handleNextStage = () => {
if (board.stage < 2) {
socketController.socket?.emit('SetStage', {
stage: board.stage + 1,
});
}
};
const handlePreviousStage = () => {
if (board.stage > 0) {
socketController.socket?.emit('SetStage', {
stage: board.stage - 1,
});
}
};
const handleToggleReady = () => {
socketController.socket?.emit('ToggleReady');
};
const handleChangeMaxVotes = (maxVotes: number) => {
socketController.socket?.emit('SetMaxVotes', {
maxVotes,
});
};
const handleSetTimer = (duration: number) => {
socketController.socket?.emit('SetTimer', {
duration,
});
};
const handleSetBoardMode = () => {
socketController.socket?.emit('SetBoardMode', {
mode: board.mode === 'retro' ? 'planning_hidden' : 'retro',
});
};
const timerTo = useAppSelector((state) => state.config.board.timerTo);
const [timer, setTimer] = useState('');
const getDiffFormat = (diff: number) =>
dayjs(dayjs(diff).diff(dayjs())).format('m:ss');
useEffect(() => {
setTimer(getDiffFormat(board.timerTo));
const intervalHandler = setInterval(() => {
setTimer(getDiffFormat(board.timerTo));
}, 500);
return () => {
clearInterval(intervalHandler);
};
}, [timerTo]);
const ref = useRef(null);
useOnClickOutside(ref, onSidebarToggleClick);
// ...
그것은 도구의 클라이언트 측면에 관한 거의 모든 것입니다. 다음 부분은 TypeScript 및 TypeORM을 사용하여 Node.js에서 WebSocket을 설정하는 방법입니다. 안녕히 계세요:)
Reference
이 문제에 관하여(Scrum Toolkit 작성 #2 - 클라이언트용 React, TypeScript 및 Websocket 설정), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://dev.to/meatboy/writing-scrum-toolkit-2-react-typescript-websocket-setup-for-client-3320텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)