[MM-63685] Virtualize drafts in the drafts list (#30563)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
282f0769e5
Коммит
e9c54bf88e
@@ -6,7 +6,7 @@ import {Page, expect} from '@playwright/test';
|
|||||||
export default class DraftPage {
|
export default class DraftPage {
|
||||||
readonly page: Page;
|
readonly page: Page;
|
||||||
|
|
||||||
readonly badgeCountOnScheduledTab;
|
readonly tab;
|
||||||
readonly confirmbutton;
|
readonly confirmbutton;
|
||||||
readonly datePattern;
|
readonly datePattern;
|
||||||
readonly deleteIcon;
|
readonly deleteIcon;
|
||||||
@@ -27,8 +27,7 @@ export default class DraftPage {
|
|||||||
|
|
||||||
this.datePattern =
|
this.datePattern =
|
||||||
/(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/;
|
/(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/;
|
||||||
this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-0 div.drafts_tab_title span.MuiBadge-badge');
|
this.tab = page.getByRole('tab', {name: 'Drafts'});
|
||||||
// this.scheduledDraftPageInfo = page.locator('span:has-text("Send on")');
|
|
||||||
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
|
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
|
||||||
this.scheduledDraftPanel = (messageContent: string) =>
|
this.scheduledDraftPanel = (messageContent: string) =>
|
||||||
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
|
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
|
||||||
@@ -51,8 +50,10 @@ export default class DraftPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async assertBadgeCountOnTab(badgeCount: string) {
|
async assertBadgeCountOnTab(badgeCount: string) {
|
||||||
await this.badgeCountOnScheduledTab.isVisible();
|
await this.tab.isVisible();
|
||||||
await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount);
|
const badge = this.tab.locator('span.MuiBadge-badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveText(badgeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
async assertDraftBody(draftMessage: string) {
|
async assertDraftBody(draftMessage: string) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {Page, expect} from '@playwright/test';
|
|||||||
export default class ScheduledDraftPage {
|
export default class ScheduledDraftPage {
|
||||||
readonly page: Page;
|
readonly page: Page;
|
||||||
|
|
||||||
readonly badgeCountOnScheduledTab;
|
readonly tab;
|
||||||
readonly confirmbutton;
|
readonly confirmbutton;
|
||||||
readonly copyIcon;
|
readonly copyIcon;
|
||||||
readonly copyIconToolTip;
|
readonly copyIconToolTip;
|
||||||
@@ -31,7 +31,7 @@ export default class ScheduledDraftPage {
|
|||||||
this.datePattern =
|
this.datePattern =
|
||||||
/(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/;
|
/(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/;
|
||||||
this.scheduledDraftBody = page.locator('div.post__body');
|
this.scheduledDraftBody = page.locator('div.post__body');
|
||||||
this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-1 div.drafts_tab_title span.MuiBadge-badge');
|
this.tab = page.getByRole('tab', {name: 'Scheduled'});
|
||||||
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
|
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
|
||||||
this.scheduledDraftPanel = (messageContent: string) =>
|
this.scheduledDraftPanel = (messageContent: string) =>
|
||||||
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
|
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
|
||||||
@@ -56,8 +56,10 @@ export default class ScheduledDraftPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async assertBadgeCountOnTab(badgeCount: string) {
|
async assertBadgeCountOnTab(badgeCount: string) {
|
||||||
await this.badgeCountOnScheduledTab.isVisible();
|
await this.tab.isVisible();
|
||||||
await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount);
|
const badge = this.tab.locator('span.MuiBadge-badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveText(badgeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
async assertscheduledDraftBody(draftMessage: string) {
|
async assertscheduledDraftBody(draftMessage: string) {
|
||||||
|
|||||||
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 68 KiB После Ширина: | Высота: | Размер: 67 KiB |
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 107 KiB После Ширина: | Высота: | Размер: 106 KiB |
Двоичный файл не отображается.
|
До Ширина: | Высота: | Размер: 86 KiB После Ширина: | Высота: | Размер: 66 KiB |
@@ -74,9 +74,9 @@
|
|||||||
"react-router-dom": "5.3.4",
|
"react-router-dom": "5.3.4",
|
||||||
"react-select": "5.9.0",
|
"react-select": "5.9.0",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
"react-virtualized-auto-sizer": "1.0.7",
|
"react-virtualized-auto-sizer": "1.0.25",
|
||||||
"react-window": "1.8.8",
|
"react-window": "1.8.11",
|
||||||
"react-window-infinite-loader": "1.0.8",
|
"react-window-infinite-loader": "1.0.10",
|
||||||
"rebound": "0.1.0",
|
"rebound": "0.1.0",
|
||||||
"redux": "4.2.0",
|
"redux": "4.2.0",
|
||||||
"redux-batched-actions": "0.5.0",
|
"redux-batched-actions": "0.5.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {AutoSizerProps} from 'react-virtualized-auto-sizer';
|
import type {Props as AutoSizerProps} from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import type {GlobalState} from 'types/store';
|
|||||||
|
|
||||||
import CustomStatusModal from './custom_status_modal';
|
import CustomStatusModal from './custom_status_modal';
|
||||||
|
|
||||||
jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100}));
|
jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100, scaledHeight: 100, scaledWidth: 100}));
|
||||||
jest.mock('images/img_trans.gif', () => 'img_trans.gif');
|
jest.mock('images/img_trans.gif', () => 'img_trans.gif');
|
||||||
|
|
||||||
describe('CustomStatusModal', () => {
|
describe('CustomStatusModal', () => {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`components/drafts/drafts should match snapshot 1`] = `
|
|
||||||
<ContextProvider
|
|
||||||
value={
|
|
||||||
Object {
|
|
||||||
"store": Object {
|
|
||||||
"clearActions": [Function],
|
|
||||||
"dispatch": [Function],
|
|
||||||
"getActions": [Function],
|
|
||||||
"getState": [Function],
|
|
||||||
"replaceReducer": [Function],
|
|
||||||
"subscribe": [Function],
|
|
||||||
},
|
|
||||||
"subscription": Subscription {
|
|
||||||
"handleChangeWrapper": [Function],
|
|
||||||
"listeners": Object {
|
|
||||||
"notify": [Function],
|
|
||||||
},
|
|
||||||
"onStateChange": [Function],
|
|
||||||
"parentSub": undefined,
|
|
||||||
"store": Object {
|
|
||||||
"clearActions": [Function],
|
|
||||||
"dispatch": [Function],
|
|
||||||
"getActions": [Function],
|
|
||||||
"getState": [Function],
|
|
||||||
"replaceReducer": [Function],
|
|
||||||
"subscribe": [Function],
|
|
||||||
},
|
|
||||||
"unsubscribe": null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Memo(Drafts)
|
|
||||||
displayName="display_name"
|
|
||||||
draftRemotes={Object {}}
|
|
||||||
drafts={Array []}
|
|
||||||
status={Object {}}
|
|
||||||
user={Object {}}
|
|
||||||
/>
|
|
||||||
</ContextProvider>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`components/drafts/drafts should match snapshot for local drafts disabled 1`] = `
|
|
||||||
<ContextProvider
|
|
||||||
value={
|
|
||||||
Object {
|
|
||||||
"store": Object {
|
|
||||||
"clearActions": [Function],
|
|
||||||
"dispatch": [Function],
|
|
||||||
"getActions": [Function],
|
|
||||||
"getState": [Function],
|
|
||||||
"replaceReducer": [Function],
|
|
||||||
"subscribe": [Function],
|
|
||||||
},
|
|
||||||
"subscription": Subscription {
|
|
||||||
"handleChangeWrapper": [Function],
|
|
||||||
"listeners": Object {
|
|
||||||
"notify": [Function],
|
|
||||||
},
|
|
||||||
"onStateChange": [Function],
|
|
||||||
"parentSub": undefined,
|
|
||||||
"store": Object {
|
|
||||||
"clearActions": [Function],
|
|
||||||
"dispatch": [Function],
|
|
||||||
"getActions": [Function],
|
|
||||||
"getState": [Function],
|
|
||||||
"replaceReducer": [Function],
|
|
||||||
"subscribe": [Function],
|
|
||||||
},
|
|
||||||
"unsubscribe": null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Memo(Drafts)
|
|
||||||
displayName="display_name"
|
|
||||||
draftRemotes={Object {}}
|
|
||||||
drafts={Array []}
|
|
||||||
status={Object {}}
|
|
||||||
user={Object {}}
|
|
||||||
/>
|
|
||||||
</ContextProvider>
|
|
||||||
`;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import React from 'react';
|
|
||||||
import {useIntl} from 'react-intl';
|
|
||||||
|
|
||||||
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
|
||||||
|
|
||||||
import type {Draft} from 'selectors/drafts';
|
|
||||||
|
|
||||||
import DraftRow from 'components/drafts/draft_row';
|
|
||||||
import DraftsIllustration from 'components/drafts/drafts_illustration';
|
|
||||||
import NoResultsIndicator from 'components/no_results_indicator';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
drafts: Draft[];
|
|
||||||
user: UserProfile;
|
|
||||||
displayName: string;
|
|
||||||
draftRemotes: Record<string, boolean>;
|
|
||||||
status: UserStatus['status'];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DraftList({drafts, user, displayName, draftRemotes, status, className}: Props) {
|
|
||||||
const {formatMessage} = useIntl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('DraftList Drafts__main', className)}>
|
|
||||||
{drafts.map((d) => (
|
|
||||||
<DraftRow
|
|
||||||
key={d.key}
|
|
||||||
displayName={displayName}
|
|
||||||
item={d.value}
|
|
||||||
isRemote={draftRemotes?.[d.key]}
|
|
||||||
user={user}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{drafts.length === 0 && (
|
|
||||||
<NoResultsIndicator
|
|
||||||
expanded={true}
|
|
||||||
iconGraphic={DraftsIllustration}
|
|
||||||
title={formatMessage({
|
|
||||||
id: 'drafts.empty.title',
|
|
||||||
defaultMessage: 'No drafts at the moment',
|
|
||||||
})}
|
|
||||||
subtitle={formatMessage({
|
|
||||||
id: 'drafts.empty.subtitle',
|
|
||||||
defaultMessage: 'Any messages you’ve started will show here.',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
|
||||||
|
import NoResultsIndicator from 'components/no_results_indicator/no_results_indicator';
|
||||||
|
|
||||||
|
import EmptyDraftListIllustration from './empty_draft_list_illustration';
|
||||||
|
|
||||||
|
export default function EmptyDraftList() {
|
||||||
|
const {formatMessage} = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='DraftList Drafts__main'>
|
||||||
|
<NoResultsIndicator
|
||||||
|
expanded={true}
|
||||||
|
iconGraphic={EmptyDraftListIllustration}
|
||||||
|
title={formatMessage({
|
||||||
|
id: 'drafts.empty.title',
|
||||||
|
defaultMessage: 'No drafts at the moment',
|
||||||
|
})}
|
||||||
|
subtitle={formatMessage({
|
||||||
|
id: 'drafts.empty.subtitle',
|
||||||
|
defaultMessage: 'Any messages you’ve started will show here.',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
webapp/channels/src/components/drafts/draft_list/index.test.tsx
Обычный файл
107
webapp/channels/src/components/drafts/draft_list/index.test.tsx
Обычный файл
@@ -0,0 +1,107 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type {Props as AutoSizerProps} from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
|
import type {Draft} from 'selectors/drafts';
|
||||||
|
|
||||||
|
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||||
|
import {TestHelper} from 'utils/test_helper';
|
||||||
|
|
||||||
|
import type {PostDraft} from 'types/store/draft';
|
||||||
|
|
||||||
|
import DraftList from './index';
|
||||||
|
|
||||||
|
jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100, scaledHeight: 100, scaledWidth: 100}));
|
||||||
|
|
||||||
|
jest.mock('components/drafts/draft_row', () => {
|
||||||
|
return function MockDraftRow(props: {item: PostDraft}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid='draft-row'
|
||||||
|
className='draft-row-mock'
|
||||||
|
>
|
||||||
|
{props.item.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('components/drafts/draft_list', () => {
|
||||||
|
const currentUser = TestHelper.getUserMock({id: 'user1'});
|
||||||
|
|
||||||
|
const mockDrafts: Draft[] = [
|
||||||
|
{
|
||||||
|
id: 'channel1',
|
||||||
|
type: 'channel',
|
||||||
|
key: 'draft_channel1',
|
||||||
|
value: TestHelper.getPostDraftMock({
|
||||||
|
message: 'What you seek is seeking you',
|
||||||
|
}),
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'channel2',
|
||||||
|
type: 'channel',
|
||||||
|
key: 'draft_channel2',
|
||||||
|
value: TestHelper.getPostDraftMock({
|
||||||
|
message: 'Where there is ruin, there is hope for a treasure.',
|
||||||
|
}),
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
views: {
|
||||||
|
drafts: {
|
||||||
|
remotes: {
|
||||||
|
draft_channel1: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should render empty draft list when no drafts are provided', () => {
|
||||||
|
renderWithContext(
|
||||||
|
<DraftList
|
||||||
|
drafts={[]}
|
||||||
|
currentUser={currentUser}
|
||||||
|
userDisplayName='User One'
|
||||||
|
userStatus='online'
|
||||||
|
/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('No drafts at the moment')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle undefined drafts', () => {
|
||||||
|
renderWithContext(
|
||||||
|
<DraftList
|
||||||
|
drafts={[]}
|
||||||
|
currentUser={currentUser}
|
||||||
|
userDisplayName='User One'
|
||||||
|
userStatus='online'
|
||||||
|
/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('No drafts at the moment')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render virtualized draft list when drafts are provided', () => {
|
||||||
|
renderWithContext(
|
||||||
|
<DraftList
|
||||||
|
drafts={mockDrafts}
|
||||||
|
currentUser={currentUser}
|
||||||
|
userDisplayName='User One'
|
||||||
|
userStatus='online'
|
||||||
|
/>,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('What you seek is seeking you')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Where there is ruin, there is hope for a treasure.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
webapp/channels/src/components/drafts/draft_list/index.tsx
Обычный файл
37
webapp/channels/src/components/drafts/draft_list/index.tsx
Обычный файл
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import {getDraftRemotes, type Draft} from 'selectors/drafts';
|
||||||
|
|
||||||
|
import EmptyDraftList from './empty_draft_list';
|
||||||
|
import VirtualizedDraftList from './virtualized_draft_list';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
drafts: Draft[];
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userDisplayName: string;
|
||||||
|
userStatus: UserStatus['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DraftList(props: Props) {
|
||||||
|
const draftRemotes = useSelector(getDraftRemotes);
|
||||||
|
|
||||||
|
if (props.drafts.length === 0) {
|
||||||
|
return <EmptyDraftList/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualizedDraftList
|
||||||
|
drafts={props.drafts}
|
||||||
|
currentUser={props.currentUser}
|
||||||
|
userDisplayName={props.userDisplayName}
|
||||||
|
userStatus={props.userStatus}
|
||||||
|
draftRemotes={draftRemotes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import type {CSSProperties} from 'react';
|
||||||
|
import React, {useMemo, useRef, useCallback, useEffect, memo} from 'react';
|
||||||
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
import {VariableSizeList} from 'react-window';
|
||||||
|
|
||||||
|
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import type {Draft} from 'selectors/drafts';
|
||||||
|
|
||||||
|
import DraftRow from 'components/drafts/draft_row';
|
||||||
|
|
||||||
|
const OVERSCAN_ROW_COUNT = 6; // no. of rows
|
||||||
|
const ROW_HEIGHT_CHANGE_TOLERANCE = 4; // in px
|
||||||
|
|
||||||
|
const RESIZE_DEBOUNCE_TIME = 120; // in ms
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
drafts: Draft[];
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userDisplayName: string;
|
||||||
|
userStatus: UserStatus['status'];
|
||||||
|
draftRemotes: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualizedDraftList(props: Props) {
|
||||||
|
const listRef = useRef<VariableSizeList>(null);
|
||||||
|
const itemHeightCacheMap = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Function to set row height after measurement, we also reset the list after setting the height
|
||||||
|
const setRowHeight = useCallback((index: number, draftKey: string, size: number) => {
|
||||||
|
const currentItemHeight = itemHeightCacheMap.current.get(draftKey);
|
||||||
|
|
||||||
|
// If current height is not cached or if there is a significant difference, update the cache
|
||||||
|
// This prevents layout thrashing when the height variations are small
|
||||||
|
if (!currentItemHeight || (Math.abs(currentItemHeight - size) > ROW_HEIGHT_CHANGE_TOLERANCE)) {
|
||||||
|
itemHeightCacheMap.current.set(draftKey, size);
|
||||||
|
|
||||||
|
// Reset the list UI in response to the row height changing
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.resetAfterIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getItemSize = useCallback((index: number) => {
|
||||||
|
const draftKey = index < props.drafts.length ? props.drafts[index].key : '';
|
||||||
|
return draftKey ? (itemHeightCacheMap.current.get(draftKey) || 0) : 0;
|
||||||
|
}, [props.drafts]);
|
||||||
|
|
||||||
|
const itemData = useMemo(() => ({
|
||||||
|
drafts: props.drafts,
|
||||||
|
userDisplayName: props.userDisplayName,
|
||||||
|
draftRemotes: props.draftRemotes,
|
||||||
|
currentUser: props.currentUser,
|
||||||
|
userStatus: props.userStatus,
|
||||||
|
setRowHeight,
|
||||||
|
}), [props.drafts, props.userDisplayName, props.draftRemotes, props.currentUser, props.userStatus, setRowHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='DraftList Drafts__main'>
|
||||||
|
<AutoSizer>
|
||||||
|
{({height, width}) => (
|
||||||
|
<VariableSizeList
|
||||||
|
ref={listRef}
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
itemCount={props.drafts.length}
|
||||||
|
itemSize={getItemSize}
|
||||||
|
itemData={itemData}
|
||||||
|
overscanCount={OVERSCAN_ROW_COUNT}
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</VariableSizeList>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
index: number;
|
||||||
|
style: CSSProperties;
|
||||||
|
data: {
|
||||||
|
drafts: Draft[];
|
||||||
|
userDisplayName: string;
|
||||||
|
draftRemotes: Record<string, boolean>;
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userStatus: string;
|
||||||
|
setRowHeight: (index: number, draftKey: string, size: number) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row component for dynamic height measurement
|
||||||
|
// This component is rendered for each visible draft item by react-window's virtualization
|
||||||
|
function RowComponent({index, style, data: {drafts, userDisplayName, draftRemotes, currentUser, userStatus, setRowHeight}}: RowProps) {
|
||||||
|
const draft = drafts[index];
|
||||||
|
|
||||||
|
// Reference to the DOM element we'll measure
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// These refs store the current values for use in callbacks
|
||||||
|
// This prevents stale closures in the ResizeObserver callback
|
||||||
|
// and ensures event handlers always have the latest values
|
||||||
|
// we also update these refs whenever their source values change
|
||||||
|
const indexRef = useRef(index);
|
||||||
|
const draftKeyRef = useRef(draft.key);
|
||||||
|
const setRowHeightRef = useRef(setRowHeight);
|
||||||
|
useEffect(() => {
|
||||||
|
indexRef.current = index;
|
||||||
|
draftKeyRef.current = draft.key;
|
||||||
|
setRowHeightRef.current = setRowHeight;
|
||||||
|
}, [index, draft.key, setRowHeight]);
|
||||||
|
|
||||||
|
// This effect performs the initial height measurement on first render
|
||||||
|
// or whenever the draft content changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rowRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to measure after the browser has painted
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
if (!rowRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = rowRef.current.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
// Inform the virtualized list about this row's height
|
||||||
|
setRowHeight(index, draft.key, height);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [draft, setRowHeight, index]);
|
||||||
|
|
||||||
|
// This effect sets up a ResizeObserver to track height changes on the row element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rowRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag to track whether we're still observing (prevents updates after unmount)
|
||||||
|
let isObservingResize = true;
|
||||||
|
|
||||||
|
// Create a debounced function to update height measurements
|
||||||
|
// This prevents excessive updates when height changes rapidly
|
||||||
|
const debouncedUpdateHeight = debounce((height: number) => {
|
||||||
|
if (!isObservingResize || !rowRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the parent list about this row's height
|
||||||
|
setRowHeightRef.current(
|
||||||
|
indexRef.current,
|
||||||
|
draftKeyRef.current,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
}, RESIZE_DEBOUNCE_TIME);
|
||||||
|
|
||||||
|
// This ResizeObserver API notifies us when the element's size changes
|
||||||
|
const resizeObserver = new ResizeObserver((resizeEntries) => {
|
||||||
|
if (!isObservingResize || !rowRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since we're observing a single row, we can safely assume that the first entry is the one we want
|
||||||
|
if (resizeEntries.length === 1 && resizeEntries[0].target === rowRef.current) {
|
||||||
|
const height = resizeEntries[0].borderBoxSize[0].blockSize;
|
||||||
|
debouncedUpdateHeight(height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing size changes on the row element
|
||||||
|
resizeObserver.observe(rowRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isObservingResize = false;
|
||||||
|
debouncedUpdateHeight.cancel();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style}> {/* To avoid interference with virtualized list styles we are not using this div for height measurement */}
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
className={classNames('virtualizedVariableListRowWrapper', {
|
||||||
|
firstRow: index === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<DraftRow
|
||||||
|
key={draft.key}
|
||||||
|
item={draft.value}
|
||||||
|
displayName={userDisplayName}
|
||||||
|
user={currentUser}
|
||||||
|
status={userStatus}
|
||||||
|
isRemote={draftRemotes?.[draft.key]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = memo(RowComponent);
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
.Panel {
|
|
||||||
.post--editing__wrapper {
|
|
||||||
margin: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,8 +50,6 @@ import PanelBody from './panel/panel_body';
|
|||||||
import Header from './panel/panel_header';
|
import Header from './panel/panel_header';
|
||||||
import {getErrorStringFromCode} from './utils';
|
import {getErrorStringFromCode} from './utils';
|
||||||
|
|
||||||
import './draft_row.scss';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: UserProfile;
|
user: UserProfile;
|
||||||
status: UserStatus['status'];
|
status: UserStatus['status'];
|
||||||
@@ -59,6 +57,7 @@ type Props = {
|
|||||||
item: PostDraft | ScheduledPost;
|
item: PostDraft | ScheduledPost;
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
scrollIntoView?: boolean;
|
scrollIntoView?: boolean;
|
||||||
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockLastBlurAt = {current: 0};
|
const mockLastBlurAt = {current: 0};
|
||||||
@@ -70,6 +69,7 @@ function DraftRow({
|
|||||||
displayName,
|
displayName,
|
||||||
isRemote,
|
isRemote,
|
||||||
scrollIntoView,
|
scrollIntoView,
|
||||||
|
containerClassName,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
@@ -366,6 +366,7 @@ function DraftRow({
|
|||||||
hasError={Boolean(postError)}
|
hasError={Boolean(postError)}
|
||||||
innerRef={scrollIntoView ? alertRef : undefined}
|
innerRef={scrollIntoView ? alertRef : undefined}
|
||||||
isHighlighted={scrollIntoView}
|
isHighlighted={scrollIntoView}
|
||||||
|
className={containerClassName}
|
||||||
>
|
>
|
||||||
{({hover}) => (
|
{({hover}) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {shallow} from 'enzyme';
|
|
||||||
import React from 'react';
|
|
||||||
import {Provider} from 'react-redux';
|
|
||||||
|
|
||||||
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
|
||||||
|
|
||||||
import type {Draft} from 'selectors/drafts';
|
|
||||||
|
|
||||||
import mockStore from 'tests/test_store';
|
|
||||||
|
|
||||||
import Drafts from './drafts';
|
|
||||||
|
|
||||||
describe('components/drafts/drafts', () => {
|
|
||||||
const baseProps = {
|
|
||||||
drafts: [] as Draft[],
|
|
||||||
user: {} as UserProfile,
|
|
||||||
displayName: 'display_name',
|
|
||||||
status: {} as UserStatus['status'],
|
|
||||||
draftRemotes: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should match snapshot', () => {
|
|
||||||
const store = mockStore();
|
|
||||||
|
|
||||||
const wrapper = shallow(
|
|
||||||
<Provider store={store}>
|
|
||||||
<Drafts
|
|
||||||
{...baseProps}
|
|
||||||
/>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should match snapshot for local drafts disabled', () => {
|
|
||||||
const store = mockStore();
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
...baseProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = shallow(
|
|
||||||
<Provider store={store}>
|
|
||||||
<Drafts
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {Badge} from '@mui/base';
|
|
||||||
import React, {memo, useCallback, useEffect, useMemo} from 'react';
|
|
||||||
import {FormattedMessage} from 'react-intl';
|
|
||||||
import {useDispatch, useSelector} from 'react-redux';
|
|
||||||
import {type match, useHistory, useRouteMatch} from 'react-router-dom';
|
|
||||||
|
|
||||||
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
|
||||||
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
|
||||||
|
|
||||||
import {
|
|
||||||
isScheduledPostsEnabled,
|
|
||||||
makeGetScheduledPostsByTeam,
|
|
||||||
} from 'mattermost-redux/selectors/entities/scheduled_posts';
|
|
||||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
|
||||||
|
|
||||||
import {selectLhsItem} from 'actions/views/lhs';
|
|
||||||
import {suppressRHS, unsuppressRHS} from 'actions/views/rhs';
|
|
||||||
import type {Draft} from 'selectors/drafts';
|
|
||||||
|
|
||||||
import DraftList from 'components/drafts/draft_list/draft_list';
|
|
||||||
import ScheduledPostList from 'components/drafts/scheduled_post_list/scheduled_post_list';
|
|
||||||
import Tab from 'components/tabs/tab';
|
|
||||||
import Tabs from 'components/tabs/tabs';
|
|
||||||
import Header from 'components/widgets/header';
|
|
||||||
|
|
||||||
import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants';
|
|
||||||
|
|
||||||
import type {GlobalState} from 'types/store';
|
|
||||||
import {LhsItemType, LhsPage} from 'types/store/lhs';
|
|
||||||
|
|
||||||
import './drafts.scss';
|
|
||||||
|
|
||||||
const EMPTY_LIST: ScheduledPost[] = [];
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
drafts: Draft[];
|
|
||||||
user: UserProfile;
|
|
||||||
displayName: string;
|
|
||||||
status: UserStatus['status'];
|
|
||||||
draftRemotes: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Drafts({
|
|
||||||
displayName,
|
|
||||||
drafts,
|
|
||||||
draftRemotes,
|
|
||||||
status,
|
|
||||||
user,
|
|
||||||
}: Props) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
const match: match<{team: string}> = useRouteMatch();
|
|
||||||
const isDraftsTab = useRouteMatch('/:team/drafts');
|
|
||||||
|
|
||||||
const isScheduledPostsTab = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX);
|
|
||||||
|
|
||||||
const currentTeamId = useSelector(getCurrentTeamId);
|
|
||||||
const getScheduledPostsByTeam = useMemo(() => makeGetScheduledPostsByTeam(), []);
|
|
||||||
const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true));
|
|
||||||
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Drafts));
|
|
||||||
dispatch(suppressRHS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dispatch(unsuppressRHS);
|
|
||||||
};
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleSwitchTabs = useCallback((key) => {
|
|
||||||
if (key === 0 && isScheduledPostsTab) {
|
|
||||||
history.push(`/${match.params.team}/drafts`);
|
|
||||||
} else if (key === 1 && isDraftsTab) {
|
|
||||||
history.push(`/${match.params.team}/scheduled_posts`);
|
|
||||||
}
|
|
||||||
}, [history, isDraftsTab, isScheduledPostsTab, match]);
|
|
||||||
|
|
||||||
const scheduledPostsTabHeading = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<div className='drafts_tab_title'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='schedule_post.tab.heading'
|
|
||||||
defaultMessage='Scheduled'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{
|
|
||||||
scheduledPosts?.length > 0 &&
|
|
||||||
<Badge
|
|
||||||
className='badge'
|
|
||||||
badgeContent={scheduledPosts.length}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [scheduledPosts?.length]);
|
|
||||||
|
|
||||||
const draftTabHeading = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<div className='drafts_tab_title'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='drafts.heading'
|
|
||||||
defaultMessage='Drafts'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{
|
|
||||||
drafts.length > 0 &&
|
|
||||||
<Badge
|
|
||||||
className='badge'
|
|
||||||
badgeContent={drafts.length}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [drafts?.length]);
|
|
||||||
|
|
||||||
const heading = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<FormattedMessage
|
|
||||||
id='drafts.heading'
|
|
||||||
defaultMessage='Drafts'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const subtitle = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<FormattedMessage
|
|
||||||
id={'drafts.subtitle'}
|
|
||||||
defaultMessage={'Any messages you\'ve started will show here'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const activeTab = isDraftsTab ? 0 : 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id='app-content'
|
|
||||||
className='Drafts app__content'
|
|
||||||
>
|
|
||||||
<Header
|
|
||||||
level={2}
|
|
||||||
className='Drafts__header'
|
|
||||||
heading={heading}
|
|
||||||
subtitle={subtitle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{
|
|
||||||
isScheduledPostEnabled &&
|
|
||||||
<Tabs
|
|
||||||
id='draft_tabs'
|
|
||||||
activeKey={activeTab}
|
|
||||||
mountOnEnter={true}
|
|
||||||
unmountOnExit={false}
|
|
||||||
onSelect={handleSwitchTabs}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
eventKey={0}
|
|
||||||
title={draftTabHeading}
|
|
||||||
unmountOnExit={false}
|
|
||||||
tabClassName='drafts_tab'
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<DraftList
|
|
||||||
drafts={drafts}
|
|
||||||
user={user}
|
|
||||||
displayName={displayName}
|
|
||||||
draftRemotes={draftRemotes}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab
|
|
||||||
eventKey={1}
|
|
||||||
title={scheduledPostsTabHeading}
|
|
||||||
unmountOnExit={false}
|
|
||||||
tabClassName='drafts_tab'
|
|
||||||
>
|
|
||||||
<ScheduledPostList
|
|
||||||
scheduledPosts={scheduledPosts || EMPTY_LIST}
|
|
||||||
user={user}
|
|
||||||
displayName={displayName}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!isScheduledPostEnabled &&
|
|
||||||
<DraftList
|
|
||||||
drafts={drafts}
|
|
||||||
user={user}
|
|
||||||
displayName={displayName}
|
|
||||||
draftRemotes={draftRemotes}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(Drafts);
|
|
||||||
@@ -18,9 +18,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: calc(100% - 56px);
|
||||||
flex-flow: column nowrap;
|
flex-flow: column nowrap;
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -72,6 +71,7 @@
|
|||||||
|
|
||||||
.DraftList.Drafts__main {
|
.DraftList.Drafts__main {
|
||||||
top: 0;
|
top: 0;
|
||||||
|
height: calc(100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,14 @@
|
|||||||
top: -2px;
|
top: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.virtualizedVariableListRowWrapper {
|
||||||
|
padding: 0 24px 16px 24px;
|
||||||
|
|
||||||
|
&.firstRow {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
grid-template-rows: 0 1fr;
|
grid-template-rows: 0 1fr;
|
||||||
|
|
||||||
@@ -92,4 +100,11 @@
|
|||||||
border-top: var(--border-default);
|
border-top: var(--border-default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Panel {
|
||||||
|
.post--editing__wrapper {
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
import Header from 'components/widgets/header';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DraftsAndSchedulePostsPageHeader(props: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id='app-content'
|
||||||
|
className='Drafts app__content'
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
level={2}
|
||||||
|
className='Drafts__header'
|
||||||
|
heading={
|
||||||
|
<FormattedMessage
|
||||||
|
id='drafts.heading'
|
||||||
|
defaultMessage='Drafts'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<FormattedMessage
|
||||||
|
id='drafts.subtitle'
|
||||||
|
defaultMessage="Any messages you've started will show here"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {Badge} from '@mui/base';
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
import {useHistory, useLocation} from 'react-router-dom';
|
||||||
|
|
||||||
|
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||||
|
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import {makeGetScheduledPostsByTeam} from 'mattermost-redux/selectors/entities/scheduled_posts';
|
||||||
|
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||||
|
|
||||||
|
import {type Draft} from 'selectors/drafts';
|
||||||
|
|
||||||
|
import DraftList from 'components/drafts/draft_list';
|
||||||
|
import ScheduledPostList from 'components/drafts/scheduled_post_list';
|
||||||
|
import Tab from 'components/tabs/tab';
|
||||||
|
import Tabs from 'components/tabs/tabs';
|
||||||
|
|
||||||
|
import {DRAFT_URL_SUFFIX, SCHEDULED_POST_URL_SUFFIX} from 'utils/constants';
|
||||||
|
|
||||||
|
import type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
|
const EMPTY_SCHEDULED_POSTS: ScheduledPost[] = [];
|
||||||
|
|
||||||
|
const TAB_KEYS = {
|
||||||
|
DRAFTS: 'drafts',
|
||||||
|
SCHEDULED_POSTS: 'scheduled_posts',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
drafts: Draft[];
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userDisplayName: string;
|
||||||
|
userStatus: UserStatus['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DraftsAndSchedulePostsTabs(props: Props) {
|
||||||
|
const history = useHistory();
|
||||||
|
const location = useLocation();
|
||||||
|
const isDraftsTab = location.pathname.includes(DRAFT_URL_SUFFIX);
|
||||||
|
const isScheduledPostsTab = location.pathname.includes(SCHEDULED_POST_URL_SUFFIX);
|
||||||
|
|
||||||
|
const currentTeam = useSelector(getCurrentTeam);
|
||||||
|
const currentTeamName = currentTeam?.name ?? '';
|
||||||
|
const currentTeamId = currentTeam?.id ?? '';
|
||||||
|
|
||||||
|
const getScheduledPostsByTeam = useMemo(() => makeGetScheduledPostsByTeam(), []);
|
||||||
|
const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true));
|
||||||
|
|
||||||
|
const handleSwitchTabs = useCallback((key) => {
|
||||||
|
if (key === TAB_KEYS.DRAFTS) {
|
||||||
|
history.push(`/${currentTeamName}/drafts`);
|
||||||
|
} else if (key === TAB_KEYS.SCHEDULED_POSTS) {
|
||||||
|
history.push(`/${currentTeamName}/scheduled_posts`);
|
||||||
|
}
|
||||||
|
}, [history, currentTeamName]);
|
||||||
|
|
||||||
|
const scheduledPostsTabHeading = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<div className='drafts_tab_title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='schedule_post.tab.heading'
|
||||||
|
defaultMessage='Scheduled'
|
||||||
|
/>
|
||||||
|
{scheduledPosts?.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
className='badge'
|
||||||
|
badgeContent={scheduledPosts.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [scheduledPosts?.length]);
|
||||||
|
|
||||||
|
const draftTabHeading = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<div className='drafts_tab_title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='drafts.heading'
|
||||||
|
defaultMessage='Drafts'
|
||||||
|
/>
|
||||||
|
{props.drafts.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
className='badge'
|
||||||
|
badgeContent={props.drafts.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [props.drafts.length]);
|
||||||
|
|
||||||
|
const activeTab = useMemo(() => {
|
||||||
|
if (isDraftsTab) {
|
||||||
|
return TAB_KEYS.DRAFTS;
|
||||||
|
} else if (isScheduledPostsTab) {
|
||||||
|
return TAB_KEYS.SCHEDULED_POSTS;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [isDraftsTab, isScheduledPostsTab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
id='draft_tabs'
|
||||||
|
activeKey={activeTab}
|
||||||
|
mountOnEnter={true}
|
||||||
|
unmountOnExit={true}
|
||||||
|
onSelect={handleSwitchTabs}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
eventKey={TAB_KEYS.DRAFTS}
|
||||||
|
title={draftTabHeading}
|
||||||
|
unmountOnExit={true}
|
||||||
|
tabClassName='drafts_tab'
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<DraftList
|
||||||
|
drafts={props.drafts}
|
||||||
|
currentUser={props.currentUser}
|
||||||
|
userDisplayName={props.userDisplayName}
|
||||||
|
userStatus={props.userStatus}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey={TAB_KEYS.SCHEDULED_POSTS}
|
||||||
|
title={scheduledPostsTabHeading}
|
||||||
|
unmountOnExit={true}
|
||||||
|
tabClassName='drafts_tab'
|
||||||
|
>
|
||||||
|
<ScheduledPostList
|
||||||
|
scheduledPosts={scheduledPosts || EMPTY_SCHEDULED_POSTS}
|
||||||
|
currentUser={props.currentUser}
|
||||||
|
userDisplayName={props.userDisplayName}
|
||||||
|
userStatus={props.userStatus}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {connect} from 'react-redux';
|
|
||||||
|
|
||||||
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
|
|
||||||
import {getCurrentUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
|
|
||||||
import {displayUsername} from 'mattermost-redux/utils/user_utils';
|
|
||||||
|
|
||||||
import {makeGetDrafts} from 'selectors/drafts';
|
|
||||||
|
|
||||||
import type {GlobalState} from 'types/store';
|
|
||||||
|
|
||||||
import Drafts from './drafts';
|
|
||||||
|
|
||||||
function makeMapStateToProps() {
|
|
||||||
const getDrafts = makeGetDrafts();
|
|
||||||
return (state: GlobalState) => {
|
|
||||||
const user = getCurrentUser(state);
|
|
||||||
const status = getStatusForUserId(state, user.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayName: displayUsername(user, getTeammateNameDisplaySetting(state)),
|
|
||||||
drafts: getDrafts(state),
|
|
||||||
draftRemotes: state.views.drafts.remotes,
|
|
||||||
status,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(makeMapStateToProps)(Drafts);
|
|
||||||
81
webapp/channels/src/components/drafts/index.tsx
Обычный файл
81
webapp/channels/src/components/drafts/index.tsx
Обычный файл
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {memo, useEffect, useMemo} from 'react';
|
||||||
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
|
||||||
|
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
|
||||||
|
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
|
||||||
|
import {getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
|
||||||
|
import {displayUsername} from 'mattermost-redux/utils/user_utils';
|
||||||
|
|
||||||
|
import {selectLhsItem} from 'actions/views/lhs';
|
||||||
|
import {suppressRHS, unsuppressRHS} from 'actions/views/rhs';
|
||||||
|
import type {Draft} from 'selectors/drafts';
|
||||||
|
import {makeGetDrafts} from 'selectors/drafts';
|
||||||
|
|
||||||
|
import DraftList from 'components/drafts/draft_list';
|
||||||
|
|
||||||
|
import type {GlobalState} from 'types/store';
|
||||||
|
import {LhsItemType, LhsPage} from 'types/store/lhs';
|
||||||
|
|
||||||
|
import DraftsAndSchedulePostsPageHeader from './drafts_and_schedule_posts_page_header';
|
||||||
|
import DraftsAndSchedulePostsTabs from './drafts_and_schedule_posts_tabs';
|
||||||
|
|
||||||
|
import './drafts_and_schedule_posts.scss';
|
||||||
|
|
||||||
|
const EMPTY_DRAFTS: Draft[] = [];
|
||||||
|
|
||||||
|
function Drafts() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const scheduledPostsEnabled = useSelector(isScheduledPostsEnabled);
|
||||||
|
|
||||||
|
// We would need to get drafts here early since its needed by the draft list component
|
||||||
|
const getDrafts = useMemo(() => makeGetDrafts(), []);
|
||||||
|
const drafts = useSelector(getDrafts);
|
||||||
|
|
||||||
|
const currentUser = useSelector(getCurrentUser);
|
||||||
|
const userStatus = useSelector((state: GlobalState) => getStatusForUserId(state, currentUser.id));
|
||||||
|
|
||||||
|
const teammateNameDisplaySetting = useSelector(getTeammateNameDisplaySetting);
|
||||||
|
const userDisplayName = useMemo(() => displayUsername(currentUser, teammateNameDisplaySetting), [currentUser, teammateNameDisplaySetting]);
|
||||||
|
|
||||||
|
// When Drafts component mounts, select Drafts in the LHS
|
||||||
|
// and suppress the RHS and restore RHS when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Drafts));
|
||||||
|
dispatch(suppressRHS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dispatch(unsuppressRHS);
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
if (scheduledPostsEnabled) {
|
||||||
|
return (
|
||||||
|
<DraftsAndSchedulePostsPageHeader>
|
||||||
|
<DraftsAndSchedulePostsTabs
|
||||||
|
drafts={drafts || EMPTY_DRAFTS}
|
||||||
|
currentUser={currentUser}
|
||||||
|
userDisplayName={userDisplayName}
|
||||||
|
userStatus={userStatus}
|
||||||
|
/>
|
||||||
|
</DraftsAndSchedulePostsPageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraftsAndSchedulePostsPageHeader>
|
||||||
|
<DraftList
|
||||||
|
drafts={drafts || EMPTY_DRAFTS}
|
||||||
|
currentUser={currentUser}
|
||||||
|
userDisplayName={userDisplayName}
|
||||||
|
userStatus={userStatus}
|
||||||
|
/>
|
||||||
|
</DraftsAndSchedulePostsPageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Drafts);
|
||||||
@@ -14,6 +14,8 @@ type Props = {
|
|||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
innerRef?: React.Ref<HTMLElement>;
|
innerRef?: React.Ref<HTMLElement>;
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
|
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
|
||||||
@@ -24,6 +26,8 @@ function Panel({
|
|||||||
hasError,
|
hasError,
|
||||||
innerRef,
|
innerRef,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
|
|
||||||
@@ -49,7 +53,9 @@ function Panel({
|
|||||||
draftError: hasError,
|
draftError: hasError,
|
||||||
highlighted: isHighlighted,
|
highlighted: isHighlighted,
|
||||||
},
|
},
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
|
||||||
|
import NoResultsIndicator from 'components/no_results_indicator';
|
||||||
|
|
||||||
|
import NoScheduledPostsIllustration from './empty_scheduled_post_list_illustration';
|
||||||
|
|
||||||
|
export default function EmptyScheduledPostList() {
|
||||||
|
const {formatMessage} = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoResultsIndicator
|
||||||
|
expanded={true}
|
||||||
|
iconGraphic={NoScheduledPostsIllustration}
|
||||||
|
title={formatMessage({
|
||||||
|
id: 'Schedule_post.empty_state.title',
|
||||||
|
defaultMessage: 'No scheduled drafts at the moment',
|
||||||
|
})}
|
||||||
|
subtitle={formatMessage({
|
||||||
|
id: 'Schedule_post.empty_state.subtitle',
|
||||||
|
defaultMessage:
|
||||||
|
'Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||||
|
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import {fetchMissingChannels} from 'mattermost-redux/actions/channels';
|
||||||
|
import {hasScheduledPostError} from 'mattermost-redux/selectors/entities/scheduled_posts';
|
||||||
|
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||||
|
|
||||||
|
import type {GlobalState} from 'types/store';
|
||||||
|
|
||||||
|
import EmptyScheduledPostList from './empty_scheduled_post_list';
|
||||||
|
import NonVirtualizedScheduledPostList from './non_virtualized_scheduled_post_list';
|
||||||
|
import ScheduledPostError from './scheduled_post_error';
|
||||||
|
|
||||||
|
import './scheduled_post_list.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scheduledPosts: ScheduledPost[];
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userDisplayName: string;
|
||||||
|
userStatus: UserStatus['status'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScheduledPostList(props: Props) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const currentTeamId = useSelector(getCurrentTeamId);
|
||||||
|
|
||||||
|
const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, currentTeamId));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.scheduledPosts.length > 0) {
|
||||||
|
dispatch(fetchMissingChannels(props.scheduledPosts.map((post) => post.channel_id)));
|
||||||
|
}
|
||||||
|
}, [dispatch, props.scheduledPosts]);
|
||||||
|
|
||||||
|
if (props.scheduledPosts.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyScheduledPostList/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='ScheduledPostList nonVirtualizedScheduledPostList'>
|
||||||
|
{scheduledPostsHasError && (<ScheduledPostError/>)}
|
||||||
|
|
||||||
|
<NonVirtualizedScheduledPostList
|
||||||
|
scheduledPosts={props.scheduledPosts}
|
||||||
|
currentUser={props.currentUser}
|
||||||
|
userDisplayName={props.userDisplayName}
|
||||||
|
userStatus={props.userStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, {useRef} from 'react';
|
||||||
|
|
||||||
|
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||||
|
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import DraftRow from 'components/drafts/draft_row';
|
||||||
|
|
||||||
|
import {useQuery} from 'utils/http_utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scheduledPosts: ScheduledPost[];
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userDisplayName: string;
|
||||||
|
userStatus: UserStatus['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NonVirtualizedScheduledPostList(props: Props) {
|
||||||
|
const query = useQuery();
|
||||||
|
const targetId = query.get('target_id');
|
||||||
|
const targetScheduledPostId = useRef<string>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
props.scheduledPosts.map((scheduledPost, index) => {
|
||||||
|
// find the first scheduled posst with the target and no error
|
||||||
|
const isInTargetChannelOrThread = scheduledPost.channel_id === targetId || scheduledPost.root_id === targetId;
|
||||||
|
const hasError = Boolean(scheduledPost.error_code);
|
||||||
|
const scrollIntoView = !targetScheduledPostId.current && (isInTargetChannelOrThread && !hasError);
|
||||||
|
if (scrollIntoView) {
|
||||||
|
// if found, save the scheduled post's ID
|
||||||
|
targetScheduledPostId.current = scheduledPost.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraftRow
|
||||||
|
key={scheduledPost.id}
|
||||||
|
item={scheduledPost}
|
||||||
|
displayName={props.userDisplayName}
|
||||||
|
status={props.userStatus}
|
||||||
|
user={props.currentUser}
|
||||||
|
scrollIntoView={targetScheduledPostId.current === scheduledPost.id} // scroll into view if this is the target scheduled post
|
||||||
|
containerClassName={classNames('nonVirtualizedScheduledPostRow', {firstRow: index === 0})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
import AlertBanner from 'components/alert_banner';
|
||||||
|
|
||||||
|
export default function ScheduledPostError() {
|
||||||
|
return (
|
||||||
|
<AlertBanner
|
||||||
|
mode='danger'
|
||||||
|
className='scheduledPostListErrorIndicator'
|
||||||
|
message={
|
||||||
|
<FormattedMessage
|
||||||
|
id='scheduled_post.panel.error_indicator.message'
|
||||||
|
defaultMessage='One of your scheduled drafts cannot be sent.'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,17 +2,26 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
.ScheduledPostList {
|
.ScheduledPostList {
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 24px;
|
|
||||||
overflow-y: auto;
|
&.nonVirtualizedScheduledPostList {
|
||||||
|
position: absolute;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nonVirtualizedScheduledPostRow {
|
||||||
|
margin: 0 24px 16px 24px;
|
||||||
|
|
||||||
|
&.firstRow {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.scheduledPostListErrorIndicator {
|
.scheduledPostListErrorIndicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin: 24px 24px 16px 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding-block: 10px;
|
padding-block: 10px;
|
||||||
}
|
}
|
||||||
@@ -35,6 +44,4 @@
|
|||||||
.Panel.highlighted {
|
.Panel.highlighted {
|
||||||
animation: borderColorAnimation 10s;
|
animation: borderColorAnimation 10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React, {useEffect, useRef} from 'react';
|
|
||||||
import {FormattedMessage, useIntl} from 'react-intl';
|
|
||||||
import {useDispatch, useSelector} from 'react-redux';
|
|
||||||
|
|
||||||
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
|
||||||
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
|
||||||
|
|
||||||
import {fetchMissingChannels} from 'mattermost-redux/actions/channels';
|
|
||||||
import {hasScheduledPostError} from 'mattermost-redux/selectors/entities/scheduled_posts';
|
|
||||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
|
||||||
|
|
||||||
import AlertBanner from 'components/alert_banner';
|
|
||||||
import NoScheduledPostsIllustration from 'components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration';
|
|
||||||
import NoResultsIndicator from 'components/no_results_indicator';
|
|
||||||
|
|
||||||
import {useQuery} from 'utils/http_utils';
|
|
||||||
|
|
||||||
import type {GlobalState} from 'types/store';
|
|
||||||
|
|
||||||
import DraftRow from '../draft_row';
|
|
||||||
|
|
||||||
import './style.scss';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
scheduledPosts: ScheduledPost[];
|
|
||||||
user: UserProfile;
|
|
||||||
displayName: string;
|
|
||||||
status: UserStatus['status'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScheduledPostList({
|
|
||||||
scheduledPosts,
|
|
||||||
user,
|
|
||||||
displayName,
|
|
||||||
status,
|
|
||||||
}: Props) {
|
|
||||||
const {formatMessage} = useIntl();
|
|
||||||
|
|
||||||
const currentTeamId = useSelector(getCurrentTeamId);
|
|
||||||
const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, currentTeamId));
|
|
||||||
|
|
||||||
const query = useQuery();
|
|
||||||
const targetId = query.get('target_id');
|
|
||||||
const targetScheduledPostId = useRef<string>();
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchMissingChannels(scheduledPosts.map((post) => post.channel_id)));
|
|
||||||
}, [scheduledPosts]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='ScheduledPostList'>
|
|
||||||
{
|
|
||||||
scheduledPostsHasError &&
|
|
||||||
<AlertBanner
|
|
||||||
mode='danger'
|
|
||||||
className='scheduledPostListErrorIndicator'
|
|
||||||
message={
|
|
||||||
<FormattedMessage
|
|
||||||
id='scheduled_post.panel.error_indicator.message'
|
|
||||||
defaultMessage='One of your scheduled drafts cannot be sent.'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
scheduledPosts.map((scheduledPost) => {
|
|
||||||
// find the first scheduled posst with the target and no error
|
|
||||||
const isInTargetChannelOrThread = scheduledPost.channel_id === targetId || scheduledPost.root_id === targetId;
|
|
||||||
const hasError = Boolean(scheduledPost.error_code);
|
|
||||||
const scrollIntoView = !targetScheduledPostId.current && (isInTargetChannelOrThread && !hasError);
|
|
||||||
if (scrollIntoView) {
|
|
||||||
// if found, save the scheduled post's ID
|
|
||||||
targetScheduledPostId.current = scheduledPost.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DraftRow
|
|
||||||
key={scheduledPost.id}
|
|
||||||
item={scheduledPost}
|
|
||||||
displayName={displayName}
|
|
||||||
status={status}
|
|
||||||
user={user}
|
|
||||||
scrollIntoView={targetScheduledPostId.current === scheduledPost.id} // scroll into view if this is the target scheduled post
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
scheduledPosts.length === 0 && (
|
|
||||||
<NoResultsIndicator
|
|
||||||
expanded={true}
|
|
||||||
iconGraphic={NoScheduledPostsIllustration}
|
|
||||||
title={formatMessage({
|
|
||||||
id: 'Schedule_post.empty_state.title',
|
|
||||||
defaultMessage: 'No scheduled drafts at the moment',
|
|
||||||
})}
|
|
||||||
subtitle={formatMessage({
|
|
||||||
id: 'Schedule_post.empty_state.subtitle',
|
|
||||||
defaultMessage: 'Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import type {CSSProperties} from 'react';
|
||||||
|
import React, {useMemo, useRef, useCallback, useEffect} from 'react';
|
||||||
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
import {VariableSizeList} from 'react-window';
|
||||||
|
|
||||||
|
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||||
|
import type {UserProfile, UserStatus} from '@mattermost/types/users';
|
||||||
|
|
||||||
|
import DraftRow from 'components/drafts/draft_row';
|
||||||
|
|
||||||
|
import {useQuery} from 'utils/http_utils';
|
||||||
|
|
||||||
|
const TARGET_ID_QUERY_PARAM = 'target_id';
|
||||||
|
const OVERSCAN_ROW_COUNT = 10; // no. of rows
|
||||||
|
const ROW_HEIGHT_CHANGE_TOLERANCE = 2; // in px
|
||||||
|
|
||||||
|
const FRAME_RATE = 60; // in Hz
|
||||||
|
const RESIZE_DEBOUNCE_TIME = Math.round(1000 / FRAME_RATE); // in ms
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scheduledPosts: ScheduledPost[];
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userDisplayName: string;
|
||||||
|
userStatus: UserStatus['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScheduledPostList(props: Props) {
|
||||||
|
const query = useQuery();
|
||||||
|
const scheduledPostTargetId = query.get(TARGET_ID_QUERY_PARAM);
|
||||||
|
const targetScheduledPostId = useRef<string>();
|
||||||
|
const listRef = useRef<VariableSizeList>(null);
|
||||||
|
const itemHeightCacheMap = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Function to set row height after measurement, we also reset the list after setting the height
|
||||||
|
const setRowHeight = useCallback((index: number, postId: string, size: number) => {
|
||||||
|
const currentItemHeight = itemHeightCacheMap.current.get(postId);
|
||||||
|
|
||||||
|
// If current height is not cached or if there is a significant difference, update the cache
|
||||||
|
// This prevents layout thrashing when the height variations are small
|
||||||
|
if (!currentItemHeight || Math.abs(currentItemHeight - size) > ROW_HEIGHT_CHANGE_TOLERANCE) {
|
||||||
|
itemHeightCacheMap.current.set(postId, size);
|
||||||
|
|
||||||
|
// Reset the list UI in response to the row height changing
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.resetAfterIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getItemSize = useCallback((index: number) => {
|
||||||
|
const postId = index < props.scheduledPosts.length ? props.scheduledPosts[index].id : '';
|
||||||
|
return postId ? (itemHeightCacheMap.current.get(postId) || 0) : 0;
|
||||||
|
}, [props.scheduledPosts]);
|
||||||
|
|
||||||
|
// Update cached sizes when list items change
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemHeightCacheMap.current.size > 0) {
|
||||||
|
const updatedItemHeightCacheMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// Only keep height entries for items that still exist
|
||||||
|
for (const post of props.scheduledPosts) {
|
||||||
|
const height = itemHeightCacheMap.current.get(post.id);
|
||||||
|
if (height) {
|
||||||
|
updatedItemHeightCacheMap.set(post.id, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the old height cache with the new one
|
||||||
|
itemHeightCacheMap.current = updatedItemHeightCacheMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset list UI in response to list items changing
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.resetAfterIndex(0);
|
||||||
|
}
|
||||||
|
}, [props.scheduledPosts]);
|
||||||
|
|
||||||
|
// Find the target post index for scrolling
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduledPostTargetId || !listRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the target post index
|
||||||
|
const targetIndex = props.scheduledPosts.findIndex((post) => {
|
||||||
|
const isInTargetChannelOrThread = post.channel_id === scheduledPostTargetId || post.root_id === scheduledPostTargetId;
|
||||||
|
const hasError = Boolean(post.error_code);
|
||||||
|
return isInTargetChannelOrThread && !hasError && !targetScheduledPostId.current;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetIndex !== -1) {
|
||||||
|
targetScheduledPostId.current = props.scheduledPosts[targetIndex].id;
|
||||||
|
listRef.current.scrollToItem(targetIndex, 'center');
|
||||||
|
}
|
||||||
|
}, [props.scheduledPosts, scheduledPostTargetId]);
|
||||||
|
|
||||||
|
const itemData = useMemo(() => ({
|
||||||
|
scheduledPosts: props.scheduledPosts,
|
||||||
|
userDisplayName: props.userDisplayName,
|
||||||
|
currentUser: props.currentUser,
|
||||||
|
userStatus: props.userStatus,
|
||||||
|
setRowHeight,
|
||||||
|
targetScheduledPostId: targetScheduledPostId.current,
|
||||||
|
scheduledPostTargetId,
|
||||||
|
}), [props.scheduledPosts, props.userDisplayName, props.currentUser, props.userStatus, setRowHeight, scheduledPostTargetId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutoSizer>
|
||||||
|
{({height, width}) => (
|
||||||
|
<VariableSizeList
|
||||||
|
ref={listRef}
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
itemCount={props.scheduledPosts.length}
|
||||||
|
itemSize={getItemSize}
|
||||||
|
itemData={itemData}
|
||||||
|
overscanCount={OVERSCAN_ROW_COUNT}
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</VariableSizeList>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
index: number;
|
||||||
|
style: CSSProperties;
|
||||||
|
data: {
|
||||||
|
scheduledPosts: ScheduledPost[];
|
||||||
|
userDisplayName: string;
|
||||||
|
currentUser: UserProfile;
|
||||||
|
userStatus: string;
|
||||||
|
setRowHeight: (index: number, postId: string, size: number) => void;
|
||||||
|
targetScheduledPostId?: string;
|
||||||
|
scheduledPostTargetId?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row component for dynamic height measurement
|
||||||
|
function Row({index, style, data: {scheduledPosts, userDisplayName, currentUser, userStatus, setRowHeight, targetScheduledPostId, scheduledPostTargetId}}: RowProps) {
|
||||||
|
const scheduledPost = scheduledPosts[index];
|
||||||
|
|
||||||
|
// Reference to the DOM element we'll measure
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Cache the last measured height to avoid unnecessary updates
|
||||||
|
const lastMeasuredHeightRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// These refs store the current values for use in callbacks
|
||||||
|
const indexRef = useRef(index);
|
||||||
|
const postIdRef = useRef(scheduledPost.id);
|
||||||
|
const setRowHeightRef = useRef(setRowHeight);
|
||||||
|
useEffect(() => {
|
||||||
|
indexRef.current = index;
|
||||||
|
postIdRef.current = scheduledPost.id;
|
||||||
|
setRowHeightRef.current = setRowHeight;
|
||||||
|
}, [index, scheduledPost.id, setRowHeight]);
|
||||||
|
|
||||||
|
// Calculate if this row should scroll into view
|
||||||
|
const isInTargetChannelOrThread = scheduledPost.channel_id === scheduledPostTargetId || scheduledPost.root_id === scheduledPostTargetId;
|
||||||
|
const hasError = Boolean(scheduledPost.error_code);
|
||||||
|
const scrollIntoView = targetScheduledPostId === scheduledPost.id || (isInTargetChannelOrThread && !hasError && !targetScheduledPostId);
|
||||||
|
|
||||||
|
// This effect performs the initial height measurement on first render
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rowRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to measure after the browser has painted
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
if (!rowRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the rendered height and enforce minimum height
|
||||||
|
const height = Math.max(rowRef.current.getBoundingClientRect().height);
|
||||||
|
lastMeasuredHeightRef.current = height;
|
||||||
|
|
||||||
|
// Inform the virtualized list about this row's height
|
||||||
|
setRowHeight(index, scheduledPost.id, height);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [scheduledPost, setRowHeight, index, scheduledPost.id]);
|
||||||
|
|
||||||
|
// This effect sets up a ResizeObserver to track height changes on the row element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rowRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag to track whether we're still observing (prevents updates after unmount)
|
||||||
|
let isObservingResize = true;
|
||||||
|
|
||||||
|
// Create a debounced function to update height measurements
|
||||||
|
const debouncedUpdateHeight = debounce((height: number) => {
|
||||||
|
// Skip if component was unmounted or ref is gone
|
||||||
|
if (!isObservingResize || !rowRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if the height hasn't changed significantly
|
||||||
|
if (lastMeasuredHeightRef.current !== null && Math.abs((lastMeasuredHeightRef.current - height)) <= ROW_HEIGHT_CHANGE_TOLERANCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update our cached height
|
||||||
|
lastMeasuredHeightRef.current = height;
|
||||||
|
|
||||||
|
// Notify the parent list about the new height
|
||||||
|
setRowHeightRef.current(
|
||||||
|
indexRef.current,
|
||||||
|
postIdRef.current,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
}, RESIZE_DEBOUNCE_TIME);
|
||||||
|
|
||||||
|
// This ResizeObserver API notifies us when the element's size changes
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
if (!isObservingResize || !rowRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all resize entries (typically just one)
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Double-check that the entry is for our element
|
||||||
|
if (entry.target === rowRef.current) {
|
||||||
|
// Get the new content height and enforce minimum
|
||||||
|
const height = entry.borderBoxSize[0].blockSize;
|
||||||
|
|
||||||
|
// Update height with debouncing to prevent layout thrashing
|
||||||
|
debouncedUpdateHeight(height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing size changes on the row element
|
||||||
|
resizeObserver.observe(rowRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isObservingResize = false;
|
||||||
|
debouncedUpdateHeight.cancel();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
className={classNames('virtualizedVariableListRowWrapper', {
|
||||||
|
firstRow: index === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<DraftRow
|
||||||
|
key={scheduledPost.id}
|
||||||
|
item={scheduledPost}
|
||||||
|
displayName={userDisplayName}
|
||||||
|
user={currentUser}
|
||||||
|
status={userStatus}
|
||||||
|
scrollIntoView={scrollIntoView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -142,20 +142,6 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = `
|
|||||||
<div
|
<div
|
||||||
style="overflow: visible; height: 0px; width: 0px;"
|
style="overflow: visible; height: 0px; width: 0px;"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
class="resize-triggers"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="expand-trigger"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="width: 1px; height: 1px;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="contract-trigger"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`components/threading/global_threads/thread_list/virtualized_thread_list should match snapshot 1`] = `
|
exports[`components/threading/global_threads/thread_list/virtualized_thread_list should match snapshot 1`] = `
|
||||||
<AutoSizer
|
<AutoSizer>
|
||||||
disableHeight={false}
|
|
||||||
disableWidth={false}
|
|
||||||
onResize={[Function]}
|
|
||||||
style={Object {}}
|
|
||||||
>
|
|
||||||
<Component />
|
<Component />
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ class ThreadViewerVirtualized extends PureComponent<Props, State> {
|
|||||||
role='application'
|
role='application'
|
||||||
aria-label={Utils.localizeMessage({id: 'accessibility.sections.rhsContent', defaultMessage: 'message details complimentary region'})}
|
aria-label={Utils.localizeMessage({id: 'accessibility.sections.rhsContent', defaultMessage: 'message details complimentary region'})}
|
||||||
className='post-right__content a11y__region'
|
className='post-right__content a11y__region'
|
||||||
style={{height: '100%'}}
|
style={{height: '100%', position: 'relative'}}
|
||||||
data-a11y-sort-order='3'
|
data-a11y-sort-order='3'
|
||||||
data-a11y-focus-child={true}
|
data-a11y-focus-child={true}
|
||||||
data-a11y-order-reversed={true}
|
data-a11y-order-reversed={true}
|
||||||
|
|||||||
@@ -141,3 +141,7 @@ export function makeGetDraft() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDraftRemotes(state: GlobalState) {
|
||||||
|
return state.views.drafts.remotes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2249,6 +2249,7 @@ export const PageLoadContext = {
|
|||||||
RECONNECT: 'reconnect',
|
RECONNECT: 'reconnect',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const DRAFT_URL_SUFFIX = 'drafts';
|
||||||
export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts';
|
export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts';
|
||||||
|
|
||||||
export const scheduledPosts = {
|
export const scheduledPosts = {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useMemo} from 'react';
|
||||||
import {useLocation} from 'react-router-dom';
|
import {useLocation} from 'react-router-dom';
|
||||||
|
|
||||||
export function useQuery() {
|
export function useQuery() {
|
||||||
return new URLSearchParams(useLocation().search);
|
const {search} = useLocation();
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
return new URLSearchParams(search);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import {createMemoryHistory} from 'history';
|
import {createMemoryHistory} from 'history';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {AutoSizerProps} from 'react-virtualized-auto-sizer';
|
import type {Props as AutoSizerProps} from 'react-virtualized-auto-sizer';
|
||||||
|
|
||||||
import {Permissions} from 'mattermost-redux/constants';
|
import {Permissions} from 'mattermost-redux/constants';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import {TestHelper} from 'utils/test_helper';
|
|||||||
|
|
||||||
import {identifyElementRegion} from './element_identification';
|
import {identifyElementRegion} from './element_identification';
|
||||||
|
|
||||||
jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100}));
|
jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100, scaledHeight: 100, scaledWidth: 100}));
|
||||||
|
|
||||||
describe('identifyElementRegion', () => {
|
describe('identifyElementRegion', () => {
|
||||||
test('should be able to identify various elements in the app', async () => {
|
test('should be able to identify various elements in the app', async () => {
|
||||||
|
|||||||
93
webapp/package-lock.json
сгенерированный
93
webapp/package-lock.json
сгенерированный
@@ -128,9 +128,9 @@
|
|||||||
"react-router-dom": "5.3.4",
|
"react-router-dom": "5.3.4",
|
||||||
"react-select": "5.9.0",
|
"react-select": "5.9.0",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
"react-virtualized-auto-sizer": "1.0.7",
|
"react-virtualized-auto-sizer": "1.0.25",
|
||||||
"react-window": "1.8.8",
|
"react-window": "1.8.11",
|
||||||
"react-window-infinite-loader": "1.0.8",
|
"react-window-infinite-loader": "1.0.10",
|
||||||
"rebound": "0.1.0",
|
"rebound": "0.1.0",
|
||||||
"redux": "4.2.0",
|
"redux": "4.2.0",
|
||||||
"redux-batched-actions": "0.5.0",
|
"redux-batched-actions": "0.5.0",
|
||||||
@@ -515,6 +515,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"channels/node_modules/react-virtualized-auto-sizer": {
|
||||||
|
"version": "1.0.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.25.tgz",
|
||||||
|
"integrity": "sha512-YHsksEGDfsHbHuaBVDYwJmcktblcHGafz4ZVuYPQYuSHMUGjpwmUCrAOcvMSGMwwk1eFWj1M/1GwYpNPuyhaBg==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels/node_modules/react-window": {
|
||||||
|
"version": "1.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||||
|
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.0.0",
|
||||||
|
"memoize-one": ">=3.1.1 <6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels/node_modules/react-window-infinite-loader": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-NO/csdHlxjWqA2RJZfzQgagAjGHspbO2ik9GtWZb0BY1Nnapq0auG8ErI+OhGCzpjYJsCYerqUlK6hkq9dfAAA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels/node_modules/react-window/node_modules/memoize-one": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
|
},
|
||||||
"channels/node_modules/strip-ansi": {
|
"channels/node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
@@ -23459,51 +23501,6 @@
|
|||||||
"tslib": "*"
|
"tslib": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-virtualized-auto-sizer": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc",
|
|
||||||
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-window": {
|
|
||||||
"version": "1.8.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz",
|
|
||||||
"integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.0.0",
|
|
||||||
"memoize-one": ">=3.1.1 <6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
|
|
||||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-window-infinite-loader": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-907ZLAiZZfBHuZyiY0V7uiSL4P/rI6UQyCF9wES1cDWTeyNLgGLaxu+BZkcUW3R5tSCQcbCcWBl0jVIpYzrKGQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0",
|
|
||||||
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-window/node_modules/memoize-one": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
|
||||||
},
|
|
||||||
"node_modules/reactcss": {
|
"node_modules/reactcss": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user