diff --git a/examples/app-crm/index.html b/examples/app-crm/index.html index 5bc5c4f86c54..44ac107097b0 100644 --- a/examples/app-crm/index.html +++ b/examples/app-crm/index.html @@ -1,13 +1,13 @@ - + - - - - - refine crm app - - -
- - + + + + + refine crm app + + +
+ + diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 7bf1569f8374..60627405cb9e 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -3,16 +3,23 @@ "version": "0.0.1", "private": true, "dependencies": { + "@ant-design/icons": "5.0.1", + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@refinedev/antd": "^5.34.0", - "@refinedev/core": "^4.34.0", "@refinedev/cli": "^2.7.6", - "@refinedev/react-router-v6": "^4.5.0", + "@refinedev/core": "^4.34.0", "@refinedev/nestjs-query-graphql": "^0.0.1", + "@refinedev/react-router-v6": "^4.5.0", + "@tanstack/react-query-devtools": "^4.32.6", "antd": "^5.0.5", + "classnames": "^2.3.2", "react": "^18.0.0", "react-dom": "^18.0.0", "react-router-dom": "^6.8.1", "graphql-ws": "^5.9.1", + "@uiw/react-md-editor": "^3.19.5", "recharts": "^2.7.3", "dayjs": "^1.10.7" }, @@ -35,9 +42,9 @@ "autoprefixer": "^10.4.1", "cypress": "^12.11.0", "eslint": "^8.24.0", - "prettier": "^2.7.1", "postcss": "^8.1.4", "postcss-nesting": "^12.0.1", + "prettier": "^2.7.1", "typescript": "^4.7.4", "vite": "^4.3.1", "jest": "^29.3.1", diff --git a/examples/app-crm/public/refine_favicon.png b/examples/app-crm/public/refine_favicon.png new file mode 100644 index 000000000000..26170d97f56a Binary files /dev/null and b/examples/app-crm/public/refine_favicon.png differ diff --git a/examples/app-crm/src/App.tsx b/examples/app-crm/src/App.tsx index e01ca459477a..a5bff513205a 100644 --- a/examples/app-crm/src/App.tsx +++ b/examples/app-crm/src/App.tsx @@ -1,8 +1,6 @@ import { Refine, Authenticated } from "@refinedev/core"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { notificationProvider, ErrorComponent } from "@refinedev/antd"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import relativeTime from "dayjs/plugin/relativeTime"; import routerProvider, { NavigateToResource, @@ -27,7 +25,13 @@ import { UpdatePasswordPage } from "./routes/update-password"; import { DashboardPage } from "./routes/dashboard/index"; import { CalendarPageWrapper } from "./routes/calendar/wrapper"; -import { KanbanPage } from "./routes/scrumboard/kanban"; +import { + KanbanPage, + KanbanCreatePage, + KanbanEditPage, + KanbanCreateStage, + KanbanEditStage, +} from "./routes/scrumboard/kanban"; import { SalesPage } from "./routes/scrumboard/sales"; import { CompaniesPage } from "./routes/companies"; import { CompanyShowPage } from "./routes/companies/show"; @@ -41,10 +45,10 @@ import { CalendarShowPage } from "./routes/calendar/show"; import { CalendarEditPage } from "./routes/calendar/edit"; import { CalendarCreatePage } from "./routes/calendar/create"; -import "./styles/index.css"; +import "./utilities/init-dayjs"; -dayjs.extend(utc); -dayjs.extend(relativeTime); +import "./styles/antd.css"; +import "./styles/index.css"; const App: React.FC = () => { return ( @@ -99,9 +103,34 @@ const App: React.FC = () => { /> }> - } /> + + + + } + > + } + /> + } + /> + } + /> + } + /> + } /> + } /> { + diff --git a/examples/app-crm/src/components/deal-kanban-card/index.tsx b/examples/app-crm/src/components/deal-kanban-card/index.tsx new file mode 100644 index 000000000000..a7eaead3a486 --- /dev/null +++ b/examples/app-crm/src/components/deal-kanban-card/index.tsx @@ -0,0 +1,273 @@ +import { useDelete, useNavigation } from "@refinedev/core"; +import { FC, memo, useMemo, useState } from "react"; +import { + Avatar, + Button, + Card, + ConfigProvider, + Dropdown, + MenuProps, + Tooltip, +} from "antd"; +import { MoreOutlined, EyeOutlined, DeleteOutlined } from "@ant-design/icons"; +import dayjs from "dayjs"; +import { Text } from "../text"; +import { getRandomColorFromString } from "../../utilities"; +import { User } from "../../interfaces/graphql"; + +type Props = { + id: string; + title: string; + price: string; + date: string; + user: { + name: string; + avatarUrl?: User["avatarUrl"]; + }; + company: { + name: string; + avatar?: string; + }; + variant?: "default" | "won" | "lost"; +}; + +export const DealKanbanCard: FC = ({ + id, + company, + date, + price, + title, + user, + variant = "default", +}) => { + const [color] = useState(() => { + return { + user: getRandomColorFromString(user.name), + company: getRandomColorFromString(company.name), + }; + }); + + const { push } = useNavigation(); + const { mutate } = useDelete(); + + const dropdownItems = useMemo(() => { + const dropdownItems: MenuProps["items"] = [ + { + label: "View card", + key: "1", + icon: , + onClick: () => { + push(`${id}`); + }, + }, + { + danger: true, + label: "Delete card", + key: "2", + icon: , + onClick: () => { + mutate({ + resource: "deals", + id, + }); + }, + }, + ]; + + return dropdownItems; + }, []); + + const variantColors = useMemo(() => { + const colors = { + Card: { + colorBgContainer: "white", + colorBorderSecondary: "#F0F0F0", + }, + Typography: { + colorText: "rgba(0, 0, 0, 0.85)", + colorTextDescription: "rgba(0, 0, 0, 0.65)", + }, + }; + + if (variant === "won") { + colors.Card.colorBgContainer = "#F6FFED"; + colors.Card.colorBorderSecondary = "#B7EB8F"; + colors.Typography.colorText = "#135200"; + colors.Typography.colorTextDescription = "#135200"; + } + + if (variant === "lost") { + colors.Card.colorBgContainer = "#FFF1F0"; + colors.Card.colorBorderSecondary = "#FFA39E"; + colors.Typography.colorText = "#820014"; + colors.Typography.colorTextDescription = "#820014"; + } + + return colors; + }, [variant]); + + return ( + + +
+ + + {user?.name[0]} + + + + + {dayjs(date).fromNow()} + + +
+ {price} + , + ]} + > + + {company?.name[0]} + + } + title={ +
+ + {company.name} + + { + e.stopPropagation(); + }, + onClick: (e) => { + e.domEvent.stopPropagation(); + }, + }} + placement="bottom" + arrow={{ pointAtCenter: true }} + > +
+ } + description={ + + {title} + + } + /> +
+
+ ); +}; + +export const DealKanbanCardMemo = memo(DealKanbanCard, (prev, next) => { + return ( + prev.id === next.id && + prev.title === next.title && + prev.price === next.price && + prev.date === next.date && + prev.user.name === next.user.name && + prev.company.name === next.company.name && + prev.variant === next.variant + ); +}); diff --git a/examples/app-crm/src/components/deal-kanban-won-lost-drop/index.module.css b/examples/app-crm/src/components/deal-kanban-won-lost-drop/index.module.css new file mode 100644 index 000000000000..cbeb14b02f4e --- /dev/null +++ b/examples/app-crm/src/components/deal-kanban-won-lost-drop/index.module.css @@ -0,0 +1,34 @@ +.container { + position: fixed; + bottom: 0; + left: 0; + height: 120px; + width: 100vw; + z-index: 999; + background-color: #f6ffed; + display: flex; + gap: 16px; + padding: 8px; +} + +.dropArea { + height: 100%; + width: 100%; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + font-weight: 700; + + &.lost { + color: #a8071a; + border: 2px dashed #ff7875; + background: #ffccc7; + } + + &.won { + color: #237804; + border: 2px dashed #95de64; + background: #d9f7be; + } +} diff --git a/examples/app-crm/src/components/deal-kanban-won-lost-drop/index.tsx b/examples/app-crm/src/components/deal-kanban-won-lost-drop/index.tsx new file mode 100644 index 000000000000..054b6def49f2 --- /dev/null +++ b/examples/app-crm/src/components/deal-kanban-won-lost-drop/index.tsx @@ -0,0 +1,60 @@ +import { FC, useState } from "react"; +import { useDndMonitor, useDroppable } from "@dnd-kit/core"; +import cn from "classnames"; +import { Text } from "../../components"; + +import styles from "./index.module.css"; + +export const DealKanbanWonLostDrop: FC = () => { + const [show, setShow] = useState(false); + + useDndMonitor({ + onDragStart: () => setShow(true), + onDragEnd: () => setShow(false), + }); + + if (!show) { + return null; + } + + return ( +
+ + +
+ ); +}; + +const WonArea = () => { + const { setNodeRef } = useDroppable({ id: "won" }); + + return ( +
+ + WON 🎉 + +
+ ); +}; + +const LostArea = () => { + const { setNodeRef } = useDroppable({ id: "lost" }); + + return ( +
+ + LOST 🙁 + +
+ ); +}; diff --git a/examples/app-crm/src/components/fullscreen-loading/index.tsx b/examples/app-crm/src/components/fullscreen-loading/index.tsx new file mode 100644 index 000000000000..f841a10bf63a --- /dev/null +++ b/examples/app-crm/src/components/fullscreen-loading/index.tsx @@ -0,0 +1,16 @@ +import { Spin } from "antd"; + +export const FullScreenLoading = () => { + return ( + + ); +}; diff --git a/examples/app-crm/src/components/icon/TextIcon.tsx b/examples/app-crm/src/components/icon/TextIcon.tsx new file mode 100644 index 000000000000..c3dee18f451f --- /dev/null +++ b/examples/app-crm/src/components/icon/TextIcon.tsx @@ -0,0 +1,32 @@ +import Icon from "@ant-design/icons"; +import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; + +export const TextIconSvg = () => ( + + + + + +); + +export const TextIcon = (props: Partial) => ( + +); diff --git a/examples/app-crm/src/components/icon/index.ts b/examples/app-crm/src/components/icon/index.ts new file mode 100644 index 000000000000..5d0922a94f76 --- /dev/null +++ b/examples/app-crm/src/components/icon/index.ts @@ -0,0 +1 @@ +export * from "./TextIcon"; diff --git a/examples/app-crm/src/components/index.ts b/examples/app-crm/src/components/index.ts new file mode 100644 index 000000000000..4fe890033599 --- /dev/null +++ b/examples/app-crm/src/components/index.ts @@ -0,0 +1,4 @@ +export * from "./project-kanban-card"; +export * from "./text"; +export * from "./fullscreen-loading"; +export * from "./deal-kanban-card"; diff --git a/examples/app-crm/src/components/kanban/accordion-header-skeleton.tsx b/examples/app-crm/src/components/kanban/accordion-header-skeleton.tsx new file mode 100644 index 000000000000..1c37d2ad31ec --- /dev/null +++ b/examples/app-crm/src/components/kanban/accordion-header-skeleton.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "antd"; + +export const AccordionHeaderSkeleton = () => { + return ( +
+ + +
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/accordion-header.tsx b/examples/app-crm/src/components/kanban/accordion-header.tsx new file mode 100644 index 000000000000..5d11bb8ea96b --- /dev/null +++ b/examples/app-crm/src/components/kanban/accordion-header.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren, ReactNode } from "react"; +import { Space } from "antd"; + +import { Text } from "../../components/text"; +import { AccordionHeaderSkeleton } from "./accordion-header-skeleton"; + +type Props = PropsWithChildren<{ + icon: ReactNode; + isActive: boolean; + fallback: string | ReactNode; + isLoading?: boolean; +}>; + +export const AccordionHeader = ({ + icon, + isActive, + fallback, + isLoading = false, + children, +}: Props) => { + if (isLoading) { + return ; + } + + return ( + + {icon} + {isActive ? {children} : fallback} + + ); +}; diff --git a/examples/app-crm/src/components/kanban/add-card-button.tsx b/examples/app-crm/src/components/kanban/add-card-button.tsx new file mode 100644 index 000000000000..6c0fc6af429d --- /dev/null +++ b/examples/app-crm/src/components/kanban/add-card-button.tsx @@ -0,0 +1,31 @@ +import { FC, PropsWithChildren } from "react"; +import { PlusSquareOutlined } from "@ant-design/icons"; +import { Button } from "antd"; +import { Text } from "../text"; + +interface Props { + onClick: () => void; +} + +export const KanbanAddCardButton: FC> = ({ + children, + onClick, +}) => { + return ( + + ); +}; diff --git a/examples/app-crm/src/components/kanban/add-stage-button.tsx b/examples/app-crm/src/components/kanban/add-stage-button.tsx new file mode 100644 index 000000000000..312179335dc4 --- /dev/null +++ b/examples/app-crm/src/components/kanban/add-stage-button.tsx @@ -0,0 +1,32 @@ +import { FC, PropsWithChildren } from "react"; +import { PlusSquareOutlined } from "@ant-design/icons"; +import { Button } from "antd"; +import { Text } from "../text"; + +interface Props { + onClick: () => void; +} + +export const KanbanAddStageButton: FC> = ({ + children, + onClick, +}) => { + return ( + + ); +}; diff --git a/examples/app-crm/src/components/kanban/board/index.module.css b/examples/app-crm/src/components/kanban/board/index.module.css new file mode 100644 index 000000000000..15db8fa69015 --- /dev/null +++ b/examples/app-crm/src/components/kanban/board/index.module.css @@ -0,0 +1,7 @@ +.container { + height: 100%; + width: 100%; + overflow: scroll; + display: flex; + gap: 32px; +} diff --git a/examples/app-crm/src/components/kanban/board/index.tsx b/examples/app-crm/src/components/kanban/board/index.tsx new file mode 100644 index 000000000000..fe4d38623f29 --- /dev/null +++ b/examples/app-crm/src/components/kanban/board/index.tsx @@ -0,0 +1,23 @@ +import { FC, PropsWithChildren } from "react"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; + +import styles from "./index.module.css"; + +type Props = { + onDragEnd: (event: DragEndEvent) => void; +}; + +export const KanbanBoard: FC> = ({ + onDragEnd, + children, +}) => { + const handleDragEnd = (event: DragEndEvent) => { + onDragEnd(event); + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/checklist-form.tsx b/examples/app-crm/src/components/kanban/checklist-form.tsx new file mode 100644 index 000000000000..ec7bdcf3c2c5 --- /dev/null +++ b/examples/app-crm/src/components/kanban/checklist-form.tsx @@ -0,0 +1,112 @@ +import { HttpError, useInvalidate } from "@refinedev/core"; +import { useForm } from "@refinedev/antd"; +import { Button, Form } from "antd"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; + +import { CheckListInput } from "./checklist-input"; +import { ChecklistHeader } from "./checklist-header"; +import { AccordionHeaderSkeleton } from "./accordion-header-skeleton"; +import { Task } from "../../interfaces/graphql"; + +type Props = { + initialValues: { + checklist?: Task["checklist"]; + }; + isLoading?: boolean; +}; + +export const CheckListForm = ({ initialValues, isLoading }: Props) => { + const invalidate = useInvalidate(); + const { formProps } = useForm({ + queryOptions: { + enabled: false, + }, + redirect: false, + autoSave: { + enabled: true, + onFinish: (values) => { + return { + ...values, + checklist: values.checklist?.filter(Boolean), + }; + }, + }, + successNotification: false, + onMutationSuccess: () => { + invalidate({ invalidates: ["list"], resource: "tasks" }); + }, + }); + + if (isLoading) { + return ; + } + + return ( +
+ +
+
+ + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( +
+ + + +
+ ))} + + + + + )} +
+
+
+
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/checklist-header.tsx b/examples/app-crm/src/components/kanban/checklist-header.tsx new file mode 100644 index 000000000000..45c46454e7c0 --- /dev/null +++ b/examples/app-crm/src/components/kanban/checklist-header.tsx @@ -0,0 +1,24 @@ +import { Space } from "antd"; +import { CheckSquareOutlined } from "@ant-design/icons"; + +import { Text } from "../../components/text"; +import { Task } from "../../interfaces/graphql"; + +type Props = { + checklist?: Task["checklist"]; +}; + +export const ChecklistHeader = ({ checklist = [] }: Props) => { + const completed = checklist.filter((item) => item?.checked).length; + const total = checklist.length; + + return ( + + + Checklist + + {completed}/{total} + + + ); +}; diff --git a/examples/app-crm/src/components/kanban/checklist-input.tsx b/examples/app-crm/src/components/kanban/checklist-input.tsx new file mode 100644 index 000000000000..e73434bf63f1 --- /dev/null +++ b/examples/app-crm/src/components/kanban/checklist-input.tsx @@ -0,0 +1,57 @@ +import { Checkbox, Input } from "antd"; + +import { CheckListItem } from "../../interfaces/graphql"; + +type Props = { + value?: CheckListItem; + onChange?: (value: CheckListItem) => void; +}; + +export const CheckListInput = ({ + value = { + title: "", + checked: false, + }, + onChange, +}: Props) => { + const triggerChange = (changedValue: { + title?: string; + checked?: boolean; + }) => { + onChange?.({ ...value, ...changedValue }); + }; + + const onTitleChange = (e: React.ChangeEvent) => { + const newTitle = e.target.value; + + triggerChange({ title: newTitle }); + }; + + return ( +
+ { + const newChecked = e.target.checked; + + triggerChange({ checked: newChecked }); + }} + /> + +
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/column/index.module.css b/examples/app-crm/src/components/kanban/column/index.module.css new file mode 100644 index 000000000000..fbc74785dad7 --- /dev/null +++ b/examples/app-crm/src/components/kanban/column/index.module.css @@ -0,0 +1,76 @@ +.container { + height: 100%; + display: flex; + flex-direction: column; + border-radius: 1rem; + padding-top: 16px; + + &.default { + min-width: 258px; + } + + &.solid { + min-width: 288px; + padding-left: 16px; + padding-right: 16px; + background-color: #fff; + } +} + +.header { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.15); +} + +.titleContainer { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.title { + width: 168px; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 10px; + flex: 0.5; +} + +.count { + font-variant-numeric: tabular-nums; + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + justify-content: center; + align-items: center; + background-color: white; + border-radius: 4px; +} + +.actionContainer { + display: flex; + gap: 12px; +} + +.childrenWrapper { + margin-top: 12px; + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + border: 2px dashed transparent; +} + +.isOver { + border-radius: 4px; + border-color: rgba(0, 0, 0, 0.25); + background: rgba(255, 255, 255, 0.3); +} diff --git a/examples/app-crm/src/components/kanban/column/index.tsx b/examples/app-crm/src/components/kanban/column/index.tsx new file mode 100644 index 000000000000..308df3c9d7af --- /dev/null +++ b/examples/app-crm/src/components/kanban/column/index.tsx @@ -0,0 +1,120 @@ +import { FC, PropsWithChildren, ReactNode, memo } from "react"; +import { UseDroppableArguments, useDroppable } from "@dnd-kit/core"; +import { Button, Dropdown, MenuProps } from "antd"; +import { PlusOutlined, MoreOutlined } from "@ant-design/icons"; +import cn from "classnames"; +import { Text } from "../../text"; + +import styles from "./index.module.css"; + +type Props = { + id: string; + title: string; + description?: ReactNode; + count: number; + data?: UseDroppableArguments["data"]; + variant?: "default" | "solid"; + contextMenuItems?: MenuProps["items"]; + onAddClick?: (args: { id: string }) => void; +}; + +export const KanbanColumn: FC> = ({ + children, + id, + title, + description, + count, + data, + variant = "default", + contextMenuItems, + onAddClick, +}) => { + const { isOver, setNodeRef } = useDroppable({ + id, + data, + }); + + const onAddClickHandler = () => { + onAddClick?.({ id }); + }; + + return ( +
+
+
+
+ + {title} + + {!!count && ( +
+ {count} +
+ )} +
+
+ {contextMenuItems && ( + { + e.stopPropagation(); + }, + onClick: (e) => { + e.domEvent.stopPropagation(); + }, + }} + placement="bottom" + arrow={{ pointAtCenter: true }} + > +
+
+ {description} +
+
+ {children} +
+
+ ); +}; + +export const KanbanColumnMemo = memo(KanbanColumn, (prev, next) => { + return ( + prev.id === next.id && + prev.title === next.title && + prev.description === next.description && + prev.count === next.count && + prev.variant === next.variant + ); +}); diff --git a/examples/app-crm/src/components/kanban/comment-form.tsx b/examples/app-crm/src/components/kanban/comment-form.tsx new file mode 100644 index 000000000000..9c2318ff3477 --- /dev/null +++ b/examples/app-crm/src/components/kanban/comment-form.tsx @@ -0,0 +1,90 @@ +import { + BaseKey, + HttpError, + useGetIdentity, + useInvalidate, + useParsed, +} from "@refinedev/core"; +import { useForm } from "@refinedev/antd"; +import { Avatar, Form, Input } from "antd"; + +import { TaskComment, User } from "../../interfaces/graphql"; + +type FormValues = TaskComment & { + taskId: BaseKey; +}; + +export const CommentForm = () => { + const invalidate = useInvalidate(); + const { id: taskId } = useParsed(); + + const { data: me } = useGetIdentity(); + + const { formProps, onFinish, form } = useForm< + TaskComment, + HttpError, + FormValues + >({ + action: "create", + resource: "taskComments", + queryOptions: { + enabled: false, + }, + meta: { + operation: "taskComment", + }, + redirect: false, + mutationMode: "optimistic", + onMutationSuccess: () => { + invalidate({ + invalidates: ["list", "detail"], + resource: "tasks", + id: taskId, + }); + }, + }); + + const handleOnFinish = async (values: TaskComment) => { + if (!taskId) { + return; + } + + try { + await onFinish({ + ...values, + taskId, + }); + + form.resetFields(); + } catch (error) {} + }; + + return ( +
+ + {me?.name.charAt(0)} + +
+ + + +
+
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/comment-list.tsx b/examples/app-crm/src/components/kanban/comment-list.tsx new file mode 100644 index 000000000000..89e72f9e975e --- /dev/null +++ b/examples/app-crm/src/components/kanban/comment-list.tsx @@ -0,0 +1,177 @@ +import { + HttpError, + useGetIdentity, + useList, + useParsed, + useInvalidate, +} from "@refinedev/core"; +import { DeleteButton, useForm } from "@refinedev/antd"; +import { Avatar, Form, Space, Typography, Input, Button } from "antd"; +import dayjs from "dayjs"; + +import { Text } from "../../components/text"; +import { TaskComment, User } from "../../interfaces/graphql"; + +const CommentListItem = ({ item }: { item: TaskComment }) => { + const invalidate = useInvalidate(); + const { formProps, setId, id, saveButtonProps } = useForm< + TaskComment, + HttpError, + TaskComment + >({ + resource: "taskComment", + action: "edit", + queryOptions: { + enabled: false, + }, + onMutationSuccess: () => { + setId(undefined); + invalidate({ + invalidates: ["list"], + resource: "taskComments", + }); + }, + }); + const { data: me } = useGetIdentity(); + + const isMe = me?.id === item.createdBy.id; + + return ( +
+ + {item.createdBy.name.charAt(0)} + +
+
+ + {item.createdBy.name} + + + {dayjs(item.createdAt).format("MMMM D, YYYY - h:ma")} + +
+ + {id ? ( +
+ + + +
+ ) : ( + + {item.comment} + + )} + + {isMe && !id && ( + + setId(item.id)} + > + Edit + + { + invalidate({ + invalidates: ["list"], + resource: "taskComments", + }); + }} + style={{ + padding: 0, + fontSize: "12px", + color: "inherit", + }} + /> + + )} + + {id && ( + + + + + )} +
+
+ ); +}; + +export const CommentList = () => { + const { id: taskId } = useParsed(); + + const { data } = useList({ + resource: "taskComments", + filters: [{ field: "task.id", operator: "eq", value: taskId }], + sorters: [{ field: "createdAt", order: "desc" }], + pagination: { + mode: "off", + }, + meta: { + fields: [ + "id", + "comment", + "createdAt", + { createdBy: ["id", "name", "avatarUrl"] }, + ], + }, + }); + + return ( + + {data?.data?.map((item) => ( + + ))} + + ); +}; diff --git a/examples/app-crm/src/components/kanban/description-form.tsx b/examples/app-crm/src/components/kanban/description-form.tsx new file mode 100644 index 000000000000..172e8f9d2cac --- /dev/null +++ b/examples/app-crm/src/components/kanban/description-form.tsx @@ -0,0 +1,53 @@ +import { HttpError } from "@refinedev/core"; +import { useForm } from "@refinedev/antd"; +import { Button, Form, Space } from "antd"; +import MDEditor from "@uiw/react-md-editor"; + +import { Task } from "../../interfaces/graphql"; + +type Props = { + initialValues: { + description?: Task["description"]; + }; + cancelForm: () => void; +}; + +export const DescriptionForm = ({ initialValues, cancelForm }: Props) => { + const { formProps, saveButtonProps } = useForm({ + queryOptions: { + enabled: false, + }, + redirect: false, + }); + + return ( + <> +
+ + + +
+
+ + + + +
+ + ); +}; diff --git a/examples/app-crm/src/components/kanban/description-header.tsx b/examples/app-crm/src/components/kanban/description-header.tsx new file mode 100644 index 000000000000..c8506d1302ee --- /dev/null +++ b/examples/app-crm/src/components/kanban/description-header.tsx @@ -0,0 +1,20 @@ +import { Typography } from "antd"; +import { MarkdownField } from "@refinedev/antd"; + +import { Task } from "../../interfaces/graphql"; + +type Props = { + description?: Task["description"]; +}; + +export const DescriptionHeader = ({ description }: Props) => { + if (description) { + return ( + + + + ); + } + + return Add task description; +}; diff --git a/examples/app-crm/src/components/kanban/duedate-form.tsx b/examples/app-crm/src/components/kanban/duedate-form.tsx new file mode 100644 index 000000000000..b89e2cf8c334 --- /dev/null +++ b/examples/app-crm/src/components/kanban/duedate-form.tsx @@ -0,0 +1,60 @@ +import { HttpError } from "@refinedev/core"; +import { useForm } from "@refinedev/antd"; +import { Button, DatePicker, Form, Space } from "antd"; +import dayjs from "dayjs"; + +import { Task } from "../../interfaces/graphql"; + +type Props = { + initialValues: { + dueDate?: Task["dueDate"]; + }; + cancelForm: () => void; +}; + +export const DueDateForm = ({ initialValues, cancelForm }: Props) => { + const { formProps, saveButtonProps } = useForm({ + queryOptions: { + enabled: false, + }, + redirect: false, + }); + + return ( +
+
+ { + if (!value) return { value: undefined }; + return { value: dayjs(value) }; + }} + > + + +
+ + + + +
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/duedate-header.tsx b/examples/app-crm/src/components/kanban/duedate-header.tsx new file mode 100644 index 000000000000..581c26487d50 --- /dev/null +++ b/examples/app-crm/src/components/kanban/duedate-header.tsx @@ -0,0 +1,38 @@ +import { Space, Tag, Typography } from "antd"; +import dayjs from "dayjs"; + +import { Text } from "../../components/text"; +import { getDateColor } from "../../utilities/date"; +import { Task } from "../../interfaces/graphql"; + +type Props = { + dueData?: Task["dueDate"]; +}; + +export const DueDateHeader = ({ dueData }: Props) => { + if (dueData) { + const color = getDateColor({ + date: dueData, + defaultColor: "processing", + }); + const getTagText = () => { + switch (color) { + case "error": + return "Overdue"; + case "warning": + return "Due soon"; + default: + return "Processing"; + } + }; + + return ( + + {getTagText()} + {dayjs(dueData).format("MMMM D, YYYY - h:ma")} + + ); + } + + return Add due date; +}; diff --git a/examples/app-crm/src/components/kanban/index.ts b/examples/app-crm/src/components/kanban/index.ts new file mode 100644 index 000000000000..a0580e90425e --- /dev/null +++ b/examples/app-crm/src/components/kanban/index.ts @@ -0,0 +1,5 @@ +export * from "./column"; +export * from "./board"; +export * from "./item"; +export * from "./add-stage-button"; +export * from "./add-card-button"; diff --git a/examples/app-crm/src/components/kanban/item/index.tsx b/examples/app-crm/src/components/kanban/item/index.tsx new file mode 100644 index 000000000000..3df74ddb4d1b --- /dev/null +++ b/examples/app-crm/src/components/kanban/item/index.tsx @@ -0,0 +1,61 @@ +import { UseDraggableArguments, useDraggable } from "@dnd-kit/core"; +import { FC, PropsWithChildren } from "react"; +import { CSS } from "@dnd-kit/utilities"; + +interface Props { + id: string; + data?: UseDraggableArguments["data"]; +} + +export const KanbanItem: FC> = ({ + children, + id, + data, +}) => { + const { attributes, listeners, transform, setNodeRef, isDragging, active } = + useDraggable({ + id, + data, + }); + return ( +
+
+ {children} +
+ {isDragging && ( +
+ )} +
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/modal-footer.tsx b/examples/app-crm/src/components/kanban/modal-footer.tsx new file mode 100644 index 000000000000..155e0dda9d0e --- /dev/null +++ b/examples/app-crm/src/components/kanban/modal-footer.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from "react-router-dom"; +import { useGetToPath } from "@refinedev/core"; +import { DeleteButton } from "@refinedev/antd"; + +export const ModalFooter = () => { + const navigate = useNavigate(); + const getToPath = useGetToPath(); + + return ( + { + navigate( + getToPath({ + action: "list", + }) ?? "", + { + replace: true, + }, + ); + }} + > + Delete card + + ); +}; diff --git a/examples/app-crm/src/components/kanban/stage-form.tsx b/examples/app-crm/src/components/kanban/stage-form.tsx new file mode 100644 index 000000000000..b6ab5cb9a1ca --- /dev/null +++ b/examples/app-crm/src/components/kanban/stage-form.tsx @@ -0,0 +1,92 @@ +import { BaseKey, HttpError, useInvalidate } from "@refinedev/core"; +import { useForm, useSelect } from "@refinedev/antd"; +import { Checkbox, Form, Select, Space } from "antd"; +import { FlagOutlined } from "@ant-design/icons"; + +import { Task, TaskStage } from "../../interfaces/graphql"; +import { AccordionHeaderSkeleton } from "./accordion-header-skeleton"; + +type Props = { + initialValues: { + completed: Task["completed"]; + stage: Task["stage"]; + }; + isLoading?: boolean; +}; + +type FormValues = Task & { + stage?: TaskStage; + stageId?: BaseKey; +}; + +export const StageForm = ({ initialValues, isLoading }: Props) => { + const invalidate = useInvalidate(); + const { formProps } = useForm({ + queryOptions: { + enabled: true, + }, + autoSave: { + enabled: true, + debounce: 0, + onFinish: (values) => { + return { + ...values, + stage: undefined, + stageId: values.stage?.id, + }; + }, + }, + onMutationSuccess: () => { + invalidate({ invalidates: ["list"], resource: "tasks" }); + }, + }); + + const { selectProps } = useSelect({ + resource: "taskStages", + meta: { + fields: ["title", "id"], + }, + }); + + if (isLoading) { + return ; + } + + return ( +
+
+ + + + + + + + + + +
+ ); +}; diff --git a/examples/app-crm/src/components/kanban/users-header.tsx b/examples/app-crm/src/components/kanban/users-header.tsx new file mode 100644 index 000000000000..f7cf12fb5dfe --- /dev/null +++ b/examples/app-crm/src/components/kanban/users-header.tsx @@ -0,0 +1,22 @@ +import { Space, Typography } from "antd"; + +import { UserTag } from "../user-tag"; +import { Task } from "../../interfaces/graphql"; + +type Props = { + users?: Task["users"]; +}; + +export const UsersHeader = ({ users = [] }: Props) => { + if (users.length > 0) { + return ( + + {users.map((user) => ( + + ))} + + ); + } + + return Assign to users; +}; diff --git a/examples/app-crm/src/components/project-kanban-card/index.tsx b/examples/app-crm/src/components/project-kanban-card/index.tsx new file mode 100644 index 000000000000..85d3c4b5dd85 --- /dev/null +++ b/examples/app-crm/src/components/project-kanban-card/index.tsx @@ -0,0 +1,316 @@ +import { useDelete, useNavigation } from "@refinedev/core"; +import { memo, useMemo } from "react"; +import dayjs from "dayjs"; +import { + Avatar, + Button, + Card, + ConfigProvider, + Dropdown, + Space, + Tag, + Tooltip, + theme, +} from "antd"; +import type { MenuProps } from "antd"; +import { + MoreOutlined, + MessageOutlined, + ClockCircleOutlined, + CheckSquareOutlined, + EyeOutlined, + DeleteOutlined, +} from "@ant-design/icons"; +import { TextIcon } from "../icon"; +import { Text } from "../text"; +import { + getDateColor, + getNameInitials, + getRandomColorFromString, +} from "../../utilities"; +import { User } from "../../interfaces/graphql"; + +type ProjectCardProps = { + id: string; + title: string; + comments: { + totalCount: number; + }; + dueDate?: string; + users?: { + id: string; + name: string; + avatarUrl?: User["avatarUrl"]; + }[]; + checkList?: { + title: string; + checked: boolean; + }[]; +}; + +export const ProjectCard = ({ + id, + title, + checkList, + comments, + dueDate, + users, +}: ProjectCardProps) => { + const { token } = theme.useToken(); + const { edit } = useNavigation(); + const { mutate } = useDelete(); + + const dropdownItems = useMemo(() => { + const dropdownItems: MenuProps["items"] = [ + { + label: "View card", + key: "1", + icon: , + onClick: () => { + edit("tasks", id, "replace"); + }, + }, + { + danger: true, + label: "Delete card", + key: "2", + icon: , + onClick: () => { + mutate({ + resource: "tasks", + id, + meta: { + operation: "task", + }, + }); + }, + }, + ]; + + return dropdownItems; + }, []); + + const dueDateOptions = useMemo(() => { + if (!dueDate) return null; + + const date = dayjs(dueDate); + + return { + color: getDateColor({ date: dueDate }) as string, + text: date.format("MMM D"), + }; + }, [dueDate]); + + const checkListCompletionCountOptions = useMemo(() => { + const hasCheckList = checkList && checkList.length > 0; + if (!hasCheckList) { + return null; + } + + const total = checkList.length; + const checked = checkList?.filter((item) => item.checked).length; + + const defaulOptions = { + color: "default", + text: `${checked}/${total}`, + allCompleted: false, + }; + + if (checked === total) { + defaulOptions.color = "success"; + defaulOptions.allCompleted = true; + return defaulOptions; + } + + return defaulOptions; + }, [checkList]); + + return ( + + {title}} + extra={ + { + e.stopPropagation(); + }, + onClick: (e) => { + e.domEvent.stopPropagation(); + }, + }} + placement="bottom" + arrow={{ pointAtCenter: true }} + > +