[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 {
|
||||
readonly page: Page;
|
||||
|
||||
readonly badgeCountOnScheduledTab;
|
||||
readonly tab;
|
||||
readonly confirmbutton;
|
||||
readonly datePattern;
|
||||
readonly deleteIcon;
|
||||
@@ -27,8 +27,7 @@ export default class DraftPage {
|
||||
|
||||
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/;
|
||||
this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-0 div.drafts_tab_title span.MuiBadge-badge');
|
||||
// this.scheduledDraftPageInfo = page.locator('span:has-text("Send on")');
|
||||
this.tab = page.getByRole('tab', {name: 'Drafts'});
|
||||
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
|
||||
this.scheduledDraftPanel = (messageContent: string) =>
|
||||
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
|
||||
@@ -51,8 +50,10 @@ export default class DraftPage {
|
||||
}
|
||||
|
||||
async assertBadgeCountOnTab(badgeCount: string) {
|
||||
await this.badgeCountOnScheduledTab.isVisible();
|
||||
await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount);
|
||||
await this.tab.isVisible();
|
||||
const badge = this.tab.locator('span.MuiBadge-badge');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText(badgeCount);
|
||||
}
|
||||
|
||||
async assertDraftBody(draftMessage: string) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {Page, expect} from '@playwright/test';
|
||||
export default class ScheduledDraftPage {
|
||||
readonly page: Page;
|
||||
|
||||
readonly badgeCountOnScheduledTab;
|
||||
readonly tab;
|
||||
readonly confirmbutton;
|
||||
readonly copyIcon;
|
||||
readonly copyIconToolTip;
|
||||
@@ -31,7 +31,7 @@ export default class ScheduledDraftPage {
|
||||
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/;
|
||||
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.scheduledDraftPanel = (messageContent: string) =>
|
||||
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
|
||||
@@ -56,8 +56,10 @@ export default class ScheduledDraftPage {
|
||||
}
|
||||
|
||||
async assertBadgeCountOnTab(badgeCount: string) {
|
||||
await this.badgeCountOnScheduledTab.isVisible();
|
||||
await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount);
|
||||
await this.tab.isVisible();
|
||||
const badge = this.tab.locator('span.MuiBadge-badge');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText(badgeCount);
|
||||
}
|
||||
|
||||
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-select": "5.9.0",
|
||||
"react-transition-group": "4.4.5",
|
||||
"react-virtualized-auto-sizer": "1.0.7",
|
||||
"react-window": "1.8.8",
|
||||
"react-window-infinite-loader": "1.0.8",
|
||||
"react-virtualized-auto-sizer": "1.0.25",
|
||||
"react-window": "1.8.11",
|
||||
"react-window-infinite-loader": "1.0.10",
|
||||
"rebound": "0.1.0",
|
||||
"redux": "4.2.0",
|
||||
"redux-batched-actions": "0.5.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
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';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {GlobalState} from 'types/store';
|
||||
|
||||
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');
|
||||
|
||||
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 {getErrorStringFromCode} from './utils';
|
||||
|
||||
import './draft_row.scss';
|
||||
|
||||
type Props = {
|
||||
user: UserProfile;
|
||||
status: UserStatus['status'];
|
||||
@@ -59,6 +57,7 @@ type Props = {
|
||||
item: PostDraft | ScheduledPost;
|
||||
isRemote?: boolean;
|
||||
scrollIntoView?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const mockLastBlurAt = {current: 0};
|
||||
@@ -70,6 +69,7 @@ function DraftRow({
|
||||
displayName,
|
||||
isRemote,
|
||||
scrollIntoView,
|
||||
containerClassName,
|
||||
}: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -366,6 +366,7 @@ function DraftRow({
|
||||
hasError={Boolean(postError)}
|
||||
innerRef={scrollIntoView ? alertRef : undefined}
|
||||
isHighlighted={scrollIntoView}
|
||||
className={containerClassName}
|
||||
>
|
||||
{({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;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: calc(100% - 56px);
|
||||
flex-flow: column nowrap;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
@@ -72,6 +71,7 @@
|
||||
|
||||
.DraftList.Drafts__main {
|
||||
top: 0;
|
||||
height: calc(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,14 @@
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.virtualizedVariableListRowWrapper {
|
||||
padding: 0 24px 16px 24px;
|
||||
|
||||
&.firstRow {
|
||||
padding-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
grid-template-rows: 0 1fr;
|
||||
|
||||
@@ -92,4 +100,11 @@
|
||||
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;
|
||||
innerRef?: React.Ref<HTMLElement>;
|
||||
isHighlighted?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
|
||||
@@ -24,6 +26,8 @@ function Panel({
|
||||
hasError,
|
||||
innerRef,
|
||||
isHighlighted,
|
||||
style,
|
||||
className,
|
||||
}: Props) {
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
@@ -49,7 +53,9 @@ function Panel({
|
||||
draftError: hasError,
|
||||
highlighted: isHighlighted,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
onMouseOver={handleMouseOver}
|
||||
onClick={handleOnClick}
|
||||
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.
|
||||
|
||||
.ScheduledPostList {
|
||||
position: absolute;
|
||||
width: 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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
margin: 24px 24px 16px 24px;
|
||||
font-weight: bold;
|
||||
padding-block: 10px;
|
||||
}
|
||||
@@ -35,6 +44,4 @@
|
||||
.Panel.highlighted {
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/threading/global_threads/thread_list/virtualized_thread_list should match snapshot 1`] = `
|
||||
<AutoSizer
|
||||
disableHeight={false}
|
||||
disableWidth={false}
|
||||
onResize={[Function]}
|
||||
style={Object {}}
|
||||
>
|
||||
<AutoSizer>
|
||||
<Component />
|
||||
</AutoSizer>
|
||||
`;
|
||||
|
||||
@@ -431,7 +431,7 @@ class ThreadViewerVirtualized extends PureComponent<Props, State> {
|
||||
role='application'
|
||||
aria-label={Utils.localizeMessage({id: 'accessibility.sections.rhsContent', defaultMessage: 'message details complimentary region'})}
|
||||
className='post-right__content a11y__region'
|
||||
style={{height: '100%'}}
|
||||
style={{height: '100%', position: 'relative'}}
|
||||
data-a11y-sort-order='3'
|
||||
data-a11y-focus-child={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',
|
||||
} as const;
|
||||
|
||||
export const DRAFT_URL_SUFFIX = 'drafts';
|
||||
export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts';
|
||||
|
||||
export const scheduledPosts = {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useLocation} from 'react-router-dom';
|
||||
|
||||
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 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';
|
||||
|
||||
@@ -14,7 +14,7 @@ import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
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', () => {
|
||||
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-select": "5.9.0",
|
||||
"react-transition-group": "4.4.5",
|
||||
"react-virtualized-auto-sizer": "1.0.7",
|
||||
"react-window": "1.8.8",
|
||||
"react-window-infinite-loader": "1.0.8",
|
||||
"react-virtualized-auto-sizer": "1.0.25",
|
||||
"react-window": "1.8.11",
|
||||
"react-window-infinite-loader": "1.0.10",
|
||||
"rebound": "0.1.0",
|
||||
"redux": "4.2.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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
@@ -23459,51 +23501,6 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
|
||||
|
||||
Ссылка в новой задаче
Block a user