본문 바로가기
개발/React

리액트, Node JS - 스프레드시트 공동 작업하기 Project Settings (Real-Time Collaboration with Handsontable Spreadsheet)

by 피로물든딸기 2024. 4. 13.
반응형

리액트 전체 링크

Node JS 전체 링크

 

참고

- 로그인 + 채팅방 UI 만들기

- Hansontable Customizing with GitHub

- https://forum.handsontable.com/t/is-there-a-feature-for-simultaneous-editing/7510

 

스프레드시트 공동 작업하기 Project Settings
Handsontable에 Socket.IO 적용하기
변경된 셀만 데이터 전송해서 최적화하기
행 / 열 이동 및 크기 변경 연동하기
다른 클라이언트가 선택한 셀 표시하기

 

다음과 같이 동시에 편집할 수 있는 스프레드 시트를 만들어보자.

 

그리고 다른 클라이언트가 작업하고 있는 셀의 정보도 알 수 있도록 하자.

 

주의 : handsontable은 공동 작업 기능을 제공하지 않는다. (링크 참고)

따라서 socket을 이용하여 다른 클라이언트의 셀을 변경하면 해당 클라이언트의 셀 편집기가 자동으로 닫히게 된다.


로그인 UI 구현

 

먼저 로그인 UI를 만들어보자.

로그인 + 채팅방 UI 만들기에서 navigate 되는 URL만 변경하였다.

import React from "react";

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import { useNavigate } from "react-router-dom";

const ChatLogin = () => {
  const navigate = useNavigate();
  const [id, setID] = React.useState("");

  const handleChange = (event) => {
    setID(event.target.value);
  };

  const login = () => {
    if (id === "") return;
    console.log(id);
    navigate("/socket-table", { state: { loginID: id } });
  };

  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        height: "100vh",
      }}
    >
      <Box sx={{ display: "flex", alignItems: "center" }}>
        <FormControl sx={{ marginRight: 1, width: 300 }}>
          <InputLabel>ID</InputLabel>
          <Select
            labelId="demo-simple-select-label"
            value={id}
            label="ID"
            onChange={handleChange}
          >
            <MenuItem value="Lilly">Lilly</MenuItem>
            <MenuItem value="Joe">Joe</MenuItem>
            <MenuItem value="Emily">Emily</MenuItem>
            <MenuItem value="Akane">Akane</MenuItem>
            <MenuItem value="Eliot">Eliot</MenuItem>
            <MenuItem value="Zoe">Zoe</MenuItem>
          </Select>
        </FormControl>
        <Button variant="outlined" onClick={login}>
          Login
        </Button>
      </Box>
    </div>
  );
};

export default ChatLogin;

 

/socket-table로 이동하기 때문에 리액트 라우터를 설정한다.

<Route path="/socket-table" element={<SocketTable />} />

 

/socket-table은 아래 컴포넌트로 이동하게 된다. (SocketTable.js)

import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";

import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";

import { Avatar, Conversation, MessageSeparator } from "@chatscope/chat-ui-kit-react";

const AVATAR_MAP = {
  Lilly: "https://chatscope.io/storybook/react/assets/lilly-aj6lnGPk.svg",
  Joe: "https://chatscope.io/storybook/react/assets/joe-v8Vy3KOS.svg",
  Emily: "https://chatscope.io/storybook/react/assets/emily-xzL8sDL2.svg",
  Akane: "https://chatscope.io/storybook/react/assets/akane-MXhWvx63.svg",
  Eliot: "https://chatscope.io/storybook/react/assets/eliot-JNkqSAth.svg",
  Zoe: "https://chatscope.io/storybook/react/assets/zoe-E7ZdmXF0.svg",
};

const SocketTable = () => {
  const location = useLocation();
  const [loginID, setLoginID] = useState("");

  const init = () => {
     setLoginID(location.state.loginID);
  };

  useEffect(init, []);

  return (
    <div>
      <Conversation
        info="I'm fine, thank you, and you?"
        lastSenderName={loginID}
        name={loginID}
      >
        <Avatar name={loginID} src={AVATAR_MAP[loginID]} status="available" />
      </Conversation>
      <MessageSeparator style={{ marginTop: 5, marginBottom: 5 }} />
    </div>
  );
};

export default SocketTable;

 

실행 화면은 다음과 같다.


Handsontable 추가하기

 

Hansontable Customizing with GitHub의 최종 코드에서 현재 작업에 대해 불필요한 부분을 제거하였다.

 

SimpleHandsOnTable.js

/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useState } from "react";

import Handsontable from "handsontable";
import "handsontable/dist/handsontable.full.min.css";
import Box from "@mui/material/Box";

import styled from "styled-components";

const DisplayCellStyle = styled.div`
  span {
    background-color: grey;
    position: relative;
    padding: 0.4rem 0.85rem;
    border: 1px solid transparent;
    border-radius: 0.35rem;
  }
`;

const SimpleHandsOnTable = ({ data, setTable, customOptions }) => {
  const [displayCellInfo, setDisplaySetInfo] = useState("");
  const [selectedCell, setSelectedCell] = useState([0, 0]);

  const cellSelected = () => {
    let selectedLast = myTable.getSelectedLast();

    if (selectedLast[0] < 0 || selectedLast[1] < 0) return;

    let value = myTable.getValue() || "";
    setDisplaySetInfo(value);

    setSelectedCell([selectedLast[0], selectedLast[1]]);
  };

  const options = {
    data,
    colHeaders: true,
    rowHeaders: true,
    wordWrap: false /* 줄 바꿈 off */,
    manualColumnResize: true,
    manualRowResize: true,
    manualColumnMove: true,
    manualRowMove: true,
    allowInsertColumn: true,
    allowInsertRow: true,
    allowRemoveColumn: true,
    allowRemoveRow: true,
    autoWrapCol: true /* 마지막 셀 아래에서 다음 셀 위로 이동 */,
    autoWrapRow: true /* 마지막 셀 옆에서 다음 셀 처음으로 이동 */,
    dragToScroll: true /* 표를 클릭 후 드래그를 할 때, 같이 스크롤 되는지 여부 */,
    persistentState: false /* 열 정렬 상태, 열 위치 및 열 크기를 로컬 스토리지에 저장 */,
    outsideClickDeselects: false /* 셀 외부 클릭 시, 셀 선택 해제 */,
    readOnly: false /* true : 모든 셀을 readOnly로 설정*/,
    enterBeginsEditing: true /* true : 엔터 클릭 시 편집 모드, false : 다음 셀로 이동 */,
    copyable: true /* 복사 가능 여부 */,
    copyPaste: true /* 복사, 붙여넣기 가능 여부 */,
    undo: true /* false : ctrl + z 비활성화 */,
    trimWhitespace: false /* 자동 trim() 실행 후 셀에 저장 */,
    contextMenu: true /* 마우스 왼쪽 버튼 클릭 시 컨텍스트 메뉴 */,
    comments: true /* 주석, 메모 기능 context menu에 추가 */,
    manualColumnFreeze: true /* freezeColumn context menu에 추가 */,

    className: "htMiddle htCenter" /* Cell Alignment */,
    width: 1000,
    height: 1000,
    startCols: 5 /* data가 없는 경우 기본 설정 */,
    startRows: 3 /* data가 없는 경우 기본 설정 */,
    afterSelection: cellSelected,
    colWidths: 60 /* 특정 위치 너비 변경 : [60, 120, 60, 60, 60, 60, 60] */,
    rowHeights: 25,

    licenseKey: "non-commercial-and-evaluation",
  };


  let myTable;
  const makeTable = () => {
    const container = document.getElementById("hot-app");
    container.innerHTML = "";

    console.log(options);
    myTable = new Handsontable(container, {
      ...options,
      ...customOptions,
    });

    myTable.render();
    if(setTable) setTable(myTable);
  };

  useEffect(() => {
    makeTable();
  }, [data]);

  return (
    <div>
      <Box sx={{ m: 2 }}>
        <DisplayCellStyle>
          <span>{displayCellInfo}</span>
        </DisplayCellStyle>
        <div id="hot-app" style={{ marginTop: "13px" }}></div>
      </Box>
    </div>
  );
};

export default SimpleHandsOnTable;

 

SimpleHandsOnTablepropsdata, setTable, customOptions를 받는다.

 

- data : 2D table

- setTable : 부모 컴포넌트에서 table에 접근하기 위한 useState의 setter

- customOptions : 부모 컴포넌트에서 기본 옵션을 변경하기 위한 정보

const SimpleHandsOnTable = ({ data, setTable, customOptions }) => {

 

이제 SocketTableSimpleHandsOnTable 컴포넌트를 추가한다.

 

SocketTable.js

import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";

import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";

import { Avatar, Conversation, MessageSeparator } from "@chatscope/chat-ui-kit-react";

import SimpleHandsOnTable from "./SimpleHandsOnTable";

const AVATAR_MAP = {
  Lilly: "https://chatscope.io/storybook/react/assets/lilly-aj6lnGPk.svg",
  Joe: "https://chatscope.io/storybook/react/assets/joe-v8Vy3KOS.svg",
  Emily: "https://chatscope.io/storybook/react/assets/emily-xzL8sDL2.svg",
  Akane: "https://chatscope.io/storybook/react/assets/akane-MXhWvx63.svg",
  Eliot: "https://chatscope.io/storybook/react/assets/eliot-JNkqSAth.svg",
  Zoe: "https://chatscope.io/storybook/react/assets/zoe-E7ZdmXF0.svg",
};

const defaultData = [
  ["", "Tesla", "Nissan", "Toyota", "Honda", "Mazda", "Ford"],
  ["2017", 10, 11, 12, 13, 15, 16],
  ["2018", 10, 11, 12, 13, 15, 16],
  ["2019", 10, 11, 12, 13, 15, 16],
  ["2020", 10, 11, 12, 13, 15, 16],
  ["2021", 10, 11, 12, 13, 15, 16],
];

const customOptions = {};

const SocketTable = () => {
  const location = useLocation();
  const [loginID, setLoginID] = useState("");

  const init = () => {
     setLoginID(location.state.loginID);
  };

  useEffect(init, []);

  return (
    <div>
      <Conversation
        info="I'm fine, thank you, and you?"
        lastSenderName={loginID}
        name={loginID}
      >
        <Avatar name={loginID} src={AVATAR_MAP[loginID]} status="available" />
      </Conversation>
      <MessageSeparator style={{ marginTop: 5, marginBottom: 5 }} />
      <SimpleHandsOnTable data={defaultData} customOptions={customOptions} />
    </div>
  );
};

export default SocketTable;

반응형

댓글