【实战】十一、看板页面及任务组页面开发(四) —— React17+React Hook+TS4 最佳实践,仿 Jira 企业级项目(二十六)

文章目录


学习内容来源:React + React Hook + TS 最佳实践-慕课网


相对原教程,我在学习开始时(2023.03)采用的是当前最新版本:

版本
react & react-dom ^18.2.0
react-router & react-router-dom ^6.11.2
antd ^4.24.8
@commitlint/cli & @commitlint/config-conventional ^17.4.4
eslint-config-prettier ^8.6.0
husky ^8.0.3
lint-staged ^13.1.2
prettier 2.8.4
json-server 0.17.2
craco-less ^2.0.0
@craco/craco ^7.1.0
qs ^6.11.0
dayjs ^1.11.7
react-helmet ^6.1.0
@types/react-helmet ^6.1.6
react-query ^6.1.0
@welldone-software/why-did-you-render ^7.0.1
@emotion/react & @emotion/styled ^11.10.6

具体配置、操作和内容会有差异,"坑"也会有所不同。。。


一、项目起航:项目初始化与配置

二、React 与 Hook 应用:实现项目列表

三、TS 应用:JS神助攻 - 强类型

四、JWT、用户认证与异步请求


五、CSS 其实很简单 - 用 CSS-in-JS 添加样式


六、用户体验优化 - 加载中和错误状态处理



七、Hook,路由,与 URL 状态管理



八、用户选择器与项目编辑功能


九、深入React 状态管理与Redux机制





十、用 react-query 获取数据,管理缓存


十一、看板页面及任务组页面开发

1~3

4~6

7&8

9.拖拽实现

接下来的内容将会是整个课程最难的部分,相关知识也不是很常用

  • 安装拖拽功能库 react-beautiful-dnd 及相关类型文件库
bash 复制代码
npm i react-beautiful-dnd ## --force
npm i @types/react-beautiful-dnd -D ## --force 

接下来在原功能库的基础上自行二次封装

新建 src\components\grag-and-drop.tsx

js 复制代码
import React, { ReactNode } from "react"
import { Draggable, DraggableProps, Droppable, DroppableProps, DroppableProvided, DroppableProvidedProps } from "react-beautiful-dnd"

// 删除原有 "函数类型" children,使用 ReactNode 类型的 children
type DropProps = Omit<DroppableProps, 'children'> & { children: ReactNode }

export const Drop = ({ children, ...props }: DropProps) => {
  return (
    <Droppable {...props}>
      {(provided) => {
        if (React.isValidElement(children)) {
          return React.cloneElement(children, {
            ...provided.droppableProps,
            ref: provided.innerRef,
            provided,
          });
        }
        return <div />;
      }}
    </Droppable>
  );
};

type DropChildProps =
  Partial<{ provided: DroppableProvided } & DroppableProvidedProps> &
    React.HTMLAttributes<HTMLDivElement>

export const DropChild = React.forwardRef<HTMLDivElement, DropChildProps>(
  ({children, ...props}, ref) => <div ref={ref} {...props} >
    {children}
    {props.provided?.placeholder}
  </div>
);

type DragProps = Omit<DraggableProps, 'children'> & { children: ReactNode }
export const Drag = ({children, ...props}: DragProps) => {
  return <Draggable {...props}>
    {
      (provided => {
        if(React.isValidElement(children)) {
          return React.cloneElement(children, {
            ...provided.draggableProps,
            ...provided.dragHandleProps,
            ref: provided.innerRef
          })
        }
        return <div/>
      })
    }
  </Draggable>
}

forwardRef 是用作转发的,经过包裹后的组件可以传入 ref 属性

这步报错:ref: provided.innerRef

bash 复制代码
Argument of type '{ ref: (element: HTMLElement | null) => void; 'data-rbd-drag-handle-draggable-id'?: string | undefined; 'data-rbd-drag-handle-context-id'?: string | undefined; 'aria-describedby'?: string | undefined; ... 7 more ...; onTransitionEnd?: React.TransitionEventHandler<...> | undefined; }' is not assignable to parameter of type 'Partial<unknown> & Attributes'.
      Object literal may only specify known properties, and 'ref' does not exist in type 'Partial<unknown> & Attributes'.

接下来使用这个组件

编辑 src\screens\ViewBoard\index.tsx

js 复制代码
...
import { DragDropContext } from "react-beautiful-dnd";
import { Drag, Drop, DropChild } from "components/grag-and-drop";

export const ViewBoard = () => {
  useDocumentTitle("看板列表");

  const { data: currentProject } = useProjectInUrl();
  const { data: viewboards, isLoading: viewBoardIsLoading } = useViewboards(
    useViewBoardSearchParams()
  );
  const { isLoading: taskIsLoading } = useTasks(useTasksSearchParams());
  const isLoading = taskIsLoading || viewBoardIsLoading;

  return (
    <DragDropContext onDragEnd={() => {}}>
      <ViewContainer>
        <h1>{currentProject?.name}看板</h1>
        <SearchPanel />
        {isLoading ? (
          <Spin />
        ) : (
          <Drop type='COLUMN' direction='horizontal' droppableId="viewboard">
            <ColumnsContainer>
              {viewboards?.map((vbd, index) => (
                <Drag key={vbd.id} draggableId={'viewboard' + vbd.id} index={index}>
                  <ViewboardColumn viewboard={vbd} key={vbd.id} />
                </Drag>
              ))}
              <CreateViewBoard />
            </ColumnsContainer>
          </Drop>
        )}
        <TaskModal />
      </ViewContainer>
    </DragDropContext>
  );
};

export const ColumnsContainer = styled(DropChild)`
  display: flex;
  overflow-x: scroll;
  flex: 1;
`;

编辑 src\screens\ViewBoard\components\ViewboardCloumn.tsx 使组件可以透传 props 以及通过 forwardRef 转发 传入 ref:

js 复制代码
...
export const ViewboardColumn = React.forwardRef<HTMLDivElement, { viewboard: Viewboard }>(({ viewboard, ...props }, ref) => {
  const { data: allTasks } = useTasks(useTasksSearchParams());
  const tasks = allTasks?.filter((task) => task.kanbanId === viewboard.id);
  return (
    <Container {...props} ref={ref}>
      <Row>
        <h3>{viewboard.name}</h3>
        <More viewboard={viewboard} key={viewboard.id}/>
      </Row>
      <TasksContainer>
        {tasks?.map((task) => (
          <TaskCard key={task.id} task={task} />
        ))}
        <CreateTask kanbanId={viewboard.id} />
      </TasksContainer>
    </Container>
  );
});

10.拖拽持久化

拖拽的时候 看板之间的间隔应该是不变的

编辑 src\screens\ViewBoard\index.tsx(调整组件层级并显式使用 DropChild):

js 复制代码
...
export const ViewBoard = () => {
  ...
  return (
    <DragDropContext onDragEnd={() => {}}>
      <ViewContainer>
        <h1>{currentProject?.name}看板</h1>
        <SearchPanel />
        {isLoading ? (
          <Spin />
        ) : (
          <ColumnsContainer>
            <Drop type="COLUMN" direction="horizontal" droppableId="viewboard">
              <DropChild style={{display: 'flex'}}>
                {viewboards?.map((vbd, index) => (
                  <Drag
                    key={vbd.id}
                    draggableId={"viewboard" + vbd.id}
                    index={index}
                  >
                    <ViewboardColumn viewboard={vbd} key={vbd.id} />
                  </Drag>
                ))}
              </DropChild>
            </Drop>
            <CreateViewBoard />
          </ColumnsContainer>
        )}
        <TaskModal />
      </ViewContainer>
    </DragDropContext>
  );
};

export const ColumnsContainer = styled.div`
  display: flex;
  overflow-x: scroll;
  flex: 1;
`;

接下来做 任务拖拽排序

编辑 src\screens\ViewBoard\components\ViewboardCloumn.tsx:

js 复制代码
...
import { Drag, Drop, DropChild } from "components/grag-and-drop";
...
export const ViewboardColumn = React.forwardRef<
  HTMLDivElement,
  { viewboard: Viewboard }
>(({ viewboard, ...props }, ref) => {
  const { data: allTasks } = useTasks(useTasksSearchParams());
  const tasks = allTasks?.filter((task) => task.kanbanId === viewboard.id);
  return (
    <Container {...props} ref={ref}>
      <Row>
        <h3>{viewboard.name}</h3>
        <More viewboard={viewboard} key={viewboard.id} />
      </Row>
      <TasksContainer>
        <Drop type="Row" direction="vertical" droppableId={'task' + viewboard.id}>
          <DropChild>
            {tasks?.map((task, taskIndex) => (
              <Drag
                key={task.id}
                draggableId={"task" + task.id}
                index={taskIndex}>
                <TaskCard key={task.id} task={task} />
              </Drag>
            ))}
          </DropChild>
        </Drop>
        <CreateTask kanbanId={viewboard.id} />
      </TasksContainer>
    </Container>
  );
});

拖拽功能好了,接下来将拖拽结果持久化到数据库中

编辑 src\utils\use-optimistic-options.ts(看板和任务排序 获取URL参数,为后续乐观更新做准备):

js 复制代码
...
export const useReorderViewboardConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => old ? [old, ...target] : []);

export const useReorderTaskConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => old || []);

编辑 src\utils\viewboard.ts(新增看板排序接口的 Custom Hook, SortPropsuseReorderTask 共用):

js 复制代码
...
export interface SortProps {
  // 要重新排序的 item
  fromId: number;
  // 目标 item
  referenceId: number;
  // 放在目标 Item 的前还是后
  type: 'before' | 'after';
  fromKanbanId?: number;
  toKanbanId?: number;
}

export const useReorderViewboard = () => {
  const client = useHttp();
  return useMutation((params: SortProps) => {
    return client("kanbans/reorder", {
      data: params,
      method: "POST",
    });
  }, useReorderViewboardConfig(queryKey));
};

编辑 src\utils\task.ts(新增看板排序接口的 Custom Hook):

js 复制代码
...
export const useReorderTask = (queryKey: QueryKey) => {
  const client = useHttp();
  return useMutation((params: SortProps) => {
    return client("tasks/reorder", {
      data: params,
      method: "POST",
    });
  }, useReorderTaskConfig(queryKey));
};

编辑 src\screens\ViewBoard\index.tsx(完善之前预留的 onDragEnd):

js 复制代码
...
import { useReorderViewboard, useViewboards } from "utils/viewboard";
import {
  useProjectInUrl,
  useTasksQueryKey,
  useTasksSearchParams,
  useViewBoardQueryKey,
  useViewBoardSearchParams,
} from "./utils";
...
import { useReorderTask, useTasks } from "utils/task";
...
import { useCallback } from "react";

export const ViewBoard = () => {
  ...

  const onDragEnd = useDragEnd();
  return (
    <DragDropContext onDragEnd={onDragEnd}>
      ...
    </DragDropContext>
  );
};


export const useDragEnd = () => {
  const { data: viewboards } = useViewboards(useViewBoardSearchParams());
  const { mutate: reorderViewBoard } = useReorderViewboard(useViewBoardQueryKey());
  const { mutate: reorderTask } = useReorderTask(useTasksQueryKey());
  const { data: allTasks = [] } = useTasks(useTasksSearchParams());
  return useCallback(
    ({ source, destination, type }: DropResult) => {
      if (!destination) {
        return;
      }
      // 看板排序
      if (type === "COLUMN") {
        const fromId = viewboards?.[source.index].id;
        const toId = viewboards?.[destination.index].id;
        if (!fromId || !toId || fromId === toId) {
          return;
        }
        const type = destination.index > source.index ? "after" : "before";
        reorderViewBoard({ fromId, referenceId: toId, type });
      }
      if (type === "ROW") {
        const fromKanbanId = +source.droppableId;
        const toKanbanId = +destination.droppableId;
        if (fromKanbanId === toKanbanId) {
          return;
        }
        const fromTask = allTasks.filter((task) => task.kanbanId === fromKanbanId)[source.index];
        const toTask = allTasks.filter((task) => task.kanbanId === toKanbanId)[destination.index];
        if (fromTask?.id === toTask?.id) {
          return;
        }
        reorderTask({
          fromId: fromTask?.id,
          referenceId: toTask?.id,
          fromKanbanId,
          toKanbanId,
          type:
            fromKanbanId === toKanbanId && destination.index > source.index
              ? "after"
              : "before",
        });
      }
    },
    [viewboards, reorderViewBoard, allTasks, reorderTask]
  );
};
...

至此,拖拽持久化完成,查看效果验证


部分引用笔记还在草稿阶段,敬请期待。。。

相关推荐
景天科技苑4 分钟前
【vue3+vite】新一代vue脚手架工具vite,助力前端开发更快捷更高效
前端·javascript·vue.js·vite·vue项目·脚手架工具
小行星12515 分钟前
前端预览pdf文件流
前端·javascript·vue.js
小行星12522 分钟前
前端把dom页面转为pdf文件下载和弹窗预览
前端·javascript·vue.js·pdf
疯狂的沙粒24 分钟前
如何在 React 项目中应用 TypeScript?应该注意那些点?结合实际项目示例及代码进行讲解!
react.js·typescript
Lysun00131 分钟前
[less] Operation on an invalid type
前端·vue·less·sass·scss
J总裁的小芒果1 小时前
Vue3 el-table 默认选中 传入的数组
前端·javascript·elementui·typescript
Lei_zhen961 小时前
记录一次electron-builder报错ENOENT: no such file or directory, rename xxxx的问题
前端·javascript·electron
咖喱鱼蛋1 小时前
Electron一些概念理解
前端·javascript·electron
yqcoder1 小时前
Vue3 + Vite + Electron + TS 项目构建
前端·javascript·vue.js
鑫宝Code1 小时前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架