Skip to content

Commit

Permalink
feat(chat): chat new component
Browse files Browse the repository at this point in the history
  • Loading branch information
HaixingOoO committed Oct 11, 2024
1 parent d6933f1 commit 36cc3c9
Show file tree
Hide file tree
Showing 21 changed files with 1,097 additions and 3 deletions.
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"lint:tsc": "tsc -p ./tsconfig.dev.json ",
"generate:usage": "node script/generate-usage/index.js",
"generate:coverage-badge": "npm run test:coverage && node script/generate-coverage.js",
"generate:jsx-demo":"npx babel src/**/_example --extensions '.tsx' --config-file ./babel.config.demo.js --relative --out-dir ../_example-js --out-file-extension=.jsx",
"generate:jsx-demo": "npx babel src/**/_example --extensions '.tsx' --config-file ./babel.config.demo.js --relative --out-dir ../_example-js --out-file-extension=.jsx",
"format:jsx-demo": "npx eslint src/**/_example-js/*.jsx --fix && npx prettier --write src/**/_example-js/*.jsx",
"test": "vitest run && npm run test:snap",
"test:ui": "vitest --ui",
Expand All @@ -61,7 +61,7 @@
"build:tsc-esm": "tsc --emitDeclarationOnly -d -p ./tsconfig.build.json --outDir esm/",
"build:tsc-cjs": "tsc --emitDeclarationOnly -d -p ./tsconfig.build.json --outDir cjs/",
"build:tsc-lib": "tsc --emitDeclarationOnly -d -p ./tsconfig.build.json --outDir lib/",
"build:jsx-demo":"npm run generate:jsx-demo && npm run format:jsx-demo",
"build:jsx-demo": "npm run generate:jsx-demo && npm run format:jsx-demo",
"changelog": "node script/generate-changelog.js",
"init:component": "node script/init-component",
"robot": "publish-cli robot-msg",
Expand Down Expand Up @@ -207,9 +207,13 @@
"@types/tinycolor2": "^1.4.3",
"@types/validator": "^13.1.3",
"classnames": "~2.5.1",
"clipboard": "^2.0.11",
"dayjs": "1.11.10",
"highlight.js": "^11.10.0",
"hoist-non-react-statics": "~3.3.2",
"lodash": "~4.17.15",
"marked": "^14.1.2",
"marked-highlight": "^2.1.4",
"mitt": "^3.0.0",
"raf": "~3.4.1",
"react-is": "^18.2.0",
Expand Down
8 changes: 8 additions & 0 deletions site/site.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,14 @@ export const docs = [
component: () => import('tdesign-react/popup/popup.md'),
componentEn: () => import('tdesign-react/popup/popup.en-US.md'),
},
{
title: 'Chat',
titleEn: 'Chat',
name: 'chat',
path: '/react/components/chat',
component: () => import('tdesign-react/chat/chat.md'),
componentEn: () => import('tdesign-react/chat/chat.en-US.md'),
},
],
},
];
Expand Down
155 changes: 155 additions & 0 deletions src/chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import classNames from 'classnames';
import isArray from 'lodash/isArray';
import { Icon } from 'tdesign-icons-react';
import useConfig from '../hooks/useConfig';
import ChatItem from './components/ChatItem';
import type { TdChatItemProps } from './components/ChatItem';
import Popconfirm from '../popconfirm';
import Divider from '../divider';
import parseTNode from '../_util/parseTNode';

const handleScrollToBottom = (target: HTMLDivElement, behavior?: 'auto' | 'smooth') => {
const currentScrollHeight = target.scrollHeight;
const currentClientHeight = target.clientHeight;

const innerBehavior = behavior ?? 'auto';
if (innerBehavior === 'auto') {
// eslint-disable-next-line no-param-reassign
target.scrollTop = currentScrollHeight - currentClientHeight;
} else {
const startScrollTop = target.scrollTop;
const endScrollTop = currentScrollHeight - currentClientHeight;
const duration = 300;
const step = (endScrollTop - startScrollTop) / duration;
let startTime: number | undefined;
// 平滑地修改scrollTop值
const animateScroll = (time: number) => {
if (!startTime) {
startTime = time;
}
const elapsed = time - startTime;
const top = Math.min(endScrollTop, startScrollTop + elapsed * step);
// eslint-disable-next-line no-param-reassign
target.scrollTop = top;
if (top < endScrollTop) {
requestAnimationFrame(animateScroll);
}
};

requestAnimationFrame(animateScroll);
}
};

const Chat = forwardRef<any, any>((props, ref) => {
const {
data,
layout,
clearHistory = true,
reverse,
// isStreamLoad,
textLoading,
footer,
children,
onClear,
onScroll,
} = props;
const { classPrefix } = useConfig();
const chatBoxRef = useRef<HTMLDivElement | null>(null);

const COMPONENT_NAME = `${classPrefix}-chat`;

const classes = useMemo(
() =>
classNames([
COMPONENT_NAME,
{
[`${COMPONENT_NAME}--normal`]: layout === 'both',
},
]),
[COMPONENT_NAME, layout],
);

const listClasses = useMemo(
() =>
classNames([
`${COMPONENT_NAME}__list`,
{
[`${COMPONENT_NAME}__list--reverse`]: reverse,
},
]),
[COMPONENT_NAME, reverse],
);

const clearConfirm = (context: { e: React.MouseEvent<HTMLElement, MouseEvent> }) => {
onClear?.(context);
};

const renderBody = () => {
/**
* 1. 两种方式获取要渲染的 list
* a. props 传 data
* b. slots t-chat-item
* a 优先级更高
*/
if (isArray(data) && data.length > 0) {
const isLoading = (index: number) => (reverse ? index === 0 : index === data.length - 1) && textLoading;
return data.map((item: TdChatItemProps, index: number) => (
<ChatItem
key={index}
avatar={item.avatar}
name={item.name}
role={item.role}
datetime={item.datetime}
content={item.content}
textLoading={isLoading(index)}
itemIndex={index}
/>
));
}
return children;
};

const defaultClearHistory = (
<Popconfirm content="确定要清空所有的消息吗?" onConfirm={clearConfirm}>
<Divider className="clear-btn">
<Icon name="clear" size="14px" />
<span className="clear-btn-text">清空历史记录</span>
</Divider>
</Popconfirm>
);

const renderClearHistory = clearHistory && parseTNode(clearHistory, null, defaultClearHistory);

// 滚动到底部
// BackBottomParams
const scrollToBottom = (data: any) => {
if (!chatBoxRef.current) return;
const { behavior = 'auto' } = data;
handleScrollToBottom(chatBoxRef.current, behavior);
};

const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
onScroll?.({ e });
};

useImperativeHandle(ref, () => ({
scrollToBottom,
}));

return (
<div className={classes}>
<div className={listClasses} ref={chatBoxRef} onScroll={handleScroll}>
{reverse && <div className="place-holder"></div>}
{reverse && renderClearHistory}
{renderBody()}
{!reverse && renderClearHistory}
</div>
{!!footer && <div className={`${COMPONENT_NAME}__footer`}>{parseTNode(footer)}</div>}
</div>
);
});

Chat.displayName = 'Chat';

export default Chat;
195 changes: 195 additions & 0 deletions src/chat/__tests__/time-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import MockDate from 'mockdate';
import { fireEvent, render, waitFor, vi, mockDelay } from '@test/utils';
import React, { useState } from 'react';
import TimePicker from '../index';

// 固定时间,当使用 new Date() 时,返回固定时间,防止“当前时间”的副作用影响,导致 snapshot 变更,mockdate 插件见 https://github.com/boblauer/MockDate
MockDate.set('2022-08-27');

// TODO
describe('Timepicker 组件测试', () => {
test('props.disabled works fine', () => {
// disabled default value is
const wrapper1 = render(<TimePicker></TimePicker>);
const container1 = wrapper1.container.querySelector('.t-time-picker');
expect(container1.querySelector('.t-is-disabled')).toBeFalsy();
// disabled = true
const wrapper2 = render(<TimePicker disabled={true}></TimePicker>);
const container2 = wrapper2.container.querySelector('.t-time-picker .t-input');
expect(container2).toHaveClass('t-is-disabled');
// disabled = false
const wrapper3 = render(<TimePicker disabled={false}></TimePicker>);
const container3 = wrapper3.container.querySelector('.t-time-picker');
expect(container3.querySelector('.t-is-disabled')).toBeFalsy();
});

test('trigger panel works fine', async () => {
const { container } = render(<TimePicker></TimePicker>);
expect(container.querySelectorAll('input').length).toBe(1);
fireEvent.click(document.querySelector('input'));
await waitFor(() => {
expect(document.querySelector('.t-time-picker__panel')).not.toBeNull();
expect(document.querySelector('.t-time-picker__panel')).toHaveStyle({
display: 'block',
});
expect(document.querySelectorAll('.t-time-picker__panel-body-scroll').length).toBe(3);
});
});

test('props.defaultValue works fine', async () => {
const { container } = render(<TimePicker defaultValue="00:10:20"></TimePicker>);
expect(container.querySelectorAll('input').length).toBe(1);
expect(container.querySelectorAll('input').item(0)).toHaveValue('00:10:20');
fireEvent.click(document.querySelector('input'));
await waitFor(() => {
const scrollPanels = document.querySelectorAll('.t-time-picker__panel-body-scroll');
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00');
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10');
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20');
});
});

test('props.defaultValue for TimePicker works fine', async () => {
const { container } = render(<TimePicker defaultValue="00:10:20"></TimePicker>);
expect(container.querySelectorAll('input').length).toBe(1);
expect(container.querySelectorAll('input').item(0)).toHaveValue('00:10:20');
fireEvent.click(document.querySelector('input'));
await waitFor(() => {
const scrollPanels = document.querySelectorAll('.t-time-picker__panel-body-scroll');
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00');
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10');
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20');
});
});

test('props.defaultValue for TimeRangePicker works fine', async () => {
const { container } = render(<TimePicker.TimeRangePicker defaultValue={['00:00:00', '00:10:20']} />);
const inputs = container.querySelectorAll('input');
expect(inputs.length).toBe(2);
expect(inputs.item(0)).toHaveValue('00:00:00');
expect(inputs.item(1)).toHaveValue('00:10:20');
fireEvent.click(inputs.item(1));
await waitFor(() => {
const scrollPanels = document.querySelectorAll('.t-time-picker__panel-body-scroll');
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00');
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10');
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20');
});
});

test('props.value for TimePickerPanel works fine', () => {
const onChange = vi.fn();
const { container } = render(<TimePicker.TimePickerPanel value={'00:10:20'} onChange={onChange} />);
const scrollPanels = container.querySelectorAll('.t-time-picker__panel-body-scroll');
expect(scrollPanels.item(0).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('00');
expect(scrollPanels.item(1).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('10');
expect(scrollPanels.item(2).querySelectorAll('.t-is-current').item(0)).toHaveTextContent('20');
});

test('props.allowInput works fine', async () => {
const handleBlur = vi.fn();
const handleFocus = vi.fn();
const { container } = render(<TimePicker onBlur={handleBlur} onFocus={handleFocus} allowInput />);
const InputDom = container.querySelector('.t-input__inner');
fireEvent.click(InputDom);
expect(handleFocus).toBeCalledTimes(1);
// input blur is not equal to TimePicker.blur
fireEvent.mouseDown(document);
expect(handleBlur).toBeCalledTimes(1);
});

test('props.onInput&onBlur&onForus works fine', async () => {
const handleBlur = vi.fn();
const handleInput = vi.fn();
const handleFocus = vi.fn();
const { container } = render(
<TimePicker.TimeRangePicker allowInput onInput={handleInput} onBlur={handleBlur} onFocus={handleFocus} />,
);
const inputs = container.querySelectorAll('input');
fireEvent.focus(inputs[0]);
expect(handleFocus).toBeCalledTimes(1);
fireEvent.change(inputs[0], { target: { value: '00:10:20' } });
expect(handleInput).toBeCalledTimes(1);
fireEvent.blur(inputs[0]);
expect(handleBlur).toBeCalledTimes(1);
});

test('click to pick', async () => {
const handleChange = vi.fn();
const handleOpen = vi.fn();
const handlePick = vi.fn();
render(
<TimePicker defaultValue="00:00:00" onChange={handleChange} onOpen={handleOpen} onPick={handlePick}></TimePicker>,
);
fireEvent.click(document.querySelector('input'));
expect(handleOpen).toBeCalledTimes(1);
await waitFor(async () => {
const confirmBtn = document.querySelectorAll('.t-time-picker__panel button').item(0);
expect(confirmBtn).toBeInTheDocument();
const panelItem1 = document.querySelectorAll('.t-time-picker__panel-body-scroll').item(0);
fireEvent.click(panelItem1.querySelectorAll('.t-time-picker__panel-body-scroll-item').item(1));

expect(handlePick).toHaveBeenCalled();
fireEvent.click(confirmBtn);
// expect(container.querySelectorAll('input').item(0)).toHaveValue('01:00:00');
// expect(handleChange).toHaveBeenCalled(1);
});
});

test('TimePicker presets test', async () => {
const date = '20:00:00';
const { container, getByText } = render(
<TimePicker
value={date}
presets={{
此刻: '11:00:00',
}}
clearable
/>,
);

fireEvent.click(document.querySelector('.t-input'));
await mockDelay();
fireEvent.click(getByText('此刻'));
fireEvent.click(getByText('确定'));

const inputs = container.querySelectorAll('input');
expect(inputs.item(0).value).toBe(date);
});

test('TimeRangePick presets test', async () => {
const date = ['11:00:00', '12:00:00'];
const defaultValue = ['00:00:01', '00:00:02'];

const RangePicker = () => {
const [value, setValue] = useState(defaultValue);
const onChange = (value) => {
setValue(value);
};

return (
<TimePicker.TimeRangePicker
value={value}
onChange={onChange}
allow-input
presets={{
此刻: ['11:00:00', '12:00:00'],
}}
format="HH:mm:ss"
clearable
/>
);
};
const { getByText } = render(<RangePicker />);
fireEvent.click(document.querySelectorAll('input').item(0));
await mockDelay();
fireEvent.click(getByText('此刻'));
fireEvent.click(document.querySelectorAll('input').item(1));
await mockDelay();
fireEvent.click(getByText('此刻'));
fireEvent.click(getByText('确定'));
const inputs = document.querySelectorAll('input');
expect(inputs.item(0).value).toBe(date[0]);
expect(inputs.item(1).value).toBe(date[1]);
});
});
Loading

0 comments on commit 36cc3c9

Please sign in to comment.