superdesk-ui-framework 3.0.14 → 3.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +5 -0
- package/app-typescript/components/ResizablePanels.tsx +98 -0
- package/app-typescript/components/WithPagination.tsx +231 -0
- package/app-typescript/components/with-pagination.spec.tsx +146 -0
- package/app-typescript/index.ts +2 -0
- package/dist/examples.bundle.js +2900 -1032
- package/dist/react/Index.tsx +11 -0
- package/dist/react/ResizablePanels.tsx +49 -0
- package/dist/react/WithPaginationDocs.tsx +57 -0
- package/dist/superdesk-ui.bundle.js +2498 -795
- package/dist/vendor.bundle.js +20 -20
- package/examples/pages/react/Index.tsx +11 -0
- package/examples/pages/react/ResizablePanels.tsx +49 -0
- package/examples/pages/react/WithPaginationDocs.tsx +57 -0
- package/mocha-setup.ts +5 -0
- package/package.json +15 -2
- package/react/components/ResizablePanels.d.ts +34 -0
- package/react/components/ResizablePanels.js +101 -0
- package/react/components/WithPagination.d.ts +26 -0
- package/react/components/WithPagination.js +191 -0
- package/react/index.d.ts +2 -0
- package/react/index.js +7 -2
- package/tsconfig.json +1 -1
- package/spec/scenarios.js +0 -13
package/.mocharc.json
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
import * as React from 'react';
|
2
|
+
import {ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle} from '@superdesk/react-resizable-panels';
|
3
|
+
|
4
|
+
interface IPanelSize {
|
5
|
+
min?: number; // percent
|
6
|
+
max?: number; // percent
|
7
|
+
default?: number; // percent
|
8
|
+
}
|
9
|
+
|
10
|
+
interface IProps {
|
11
|
+
/**
|
12
|
+
* component will set primary dimension(width when horizontal, height when vertical) to 100%
|
13
|
+
* parent component has to support this
|
14
|
+
*/
|
15
|
+
direction: 'horizontal' | 'vertical';
|
16
|
+
|
17
|
+
primarySize?: IPanelSize;
|
18
|
+
secondarySize?: IPanelSize;
|
19
|
+
|
20
|
+
/**
|
21
|
+
* Only 2 items are supported to keep API surface minimal so it's easy to switch to another library if needed.
|
22
|
+
*/
|
23
|
+
children: [React.ReactNode, React.ReactNode];
|
24
|
+
}
|
25
|
+
|
26
|
+
|
27
|
+
/**
|
28
|
+
Features:
|
29
|
+
* No absolute positioning is used
|
30
|
+
* Component height is fully dynamic and adjusts according to children inside panes
|
31
|
+
* Library supports an arbitrary number of panes. We are not using it to keep API minimal.
|
32
|
+
* Drawback: only works with percent units. Can be made to work with pixels
|
33
|
+
* by creating a wrapper that measures available space and converts to percent.
|
34
|
+
*/
|
35
|
+
export class ResizablePanels extends React.PureComponent<IProps> {
|
36
|
+
private primaryPanelRef: ImperativePanelHandle | null;
|
37
|
+
private secondaryPanelRef: ImperativePanelHandle | null;
|
38
|
+
|
39
|
+
constructor(props: IProps) {
|
40
|
+
super(props);
|
41
|
+
|
42
|
+
this.primaryPanelRef = null;
|
43
|
+
this.secondaryPanelRef = null;
|
44
|
+
}
|
45
|
+
|
46
|
+
render(): React.ReactNode {
|
47
|
+
const {direction, primarySize, secondarySize, children} = this.props;
|
48
|
+
const separatorDimensions: React.CSSProperties = direction === 'horizontal'
|
49
|
+
? {width: 3, height: '100%'}
|
50
|
+
: {height: 3, width: '100%'};
|
51
|
+
|
52
|
+
// Sometimes second panel is conditional. Checking here is more convenient.
|
53
|
+
if (children.some((child) => child === false || child == null)) {
|
54
|
+
return children;
|
55
|
+
}
|
56
|
+
|
57
|
+
return (
|
58
|
+
<PanelGroup direction={direction}>
|
59
|
+
<Panel
|
60
|
+
id="primary"
|
61
|
+
minSize={primarySize?.min}
|
62
|
+
maxSize={primarySize?.max}
|
63
|
+
defaultSize={primarySize?.default}
|
64
|
+
ref={(panelRef) => {
|
65
|
+
this.primaryPanelRef = panelRef;
|
66
|
+
}}
|
67
|
+
>
|
68
|
+
{children[0]}
|
69
|
+
</Panel>
|
70
|
+
|
71
|
+
<PanelResizeHandle>
|
72
|
+
<div
|
73
|
+
style={{background: 'var(--color-text-lighter)', ...separatorDimensions}}
|
74
|
+
onDoubleClick={() => {
|
75
|
+
if (primarySize?.default != null) {
|
76
|
+
this.primaryPanelRef?.resize(primarySize.default);
|
77
|
+
} else if (secondarySize?.default != null) {
|
78
|
+
this.secondaryPanelRef?.resize(secondarySize.default);
|
79
|
+
}
|
80
|
+
}}
|
81
|
+
/>
|
82
|
+
</PanelResizeHandle>
|
83
|
+
|
84
|
+
<Panel
|
85
|
+
id="secondary"
|
86
|
+
minSize={secondarySize?.min}
|
87
|
+
maxSize={secondarySize?.max}
|
88
|
+
defaultSize={secondarySize?.default}
|
89
|
+
ref={(panelRef) => {
|
90
|
+
this.secondaryPanelRef = panelRef;
|
91
|
+
}}
|
92
|
+
>
|
93
|
+
{children[1]}
|
94
|
+
</Panel>
|
95
|
+
</PanelGroup>
|
96
|
+
);
|
97
|
+
}
|
98
|
+
}
|
@@ -0,0 +1,231 @@
|
|
1
|
+
import * as React from 'react';
|
2
|
+
import {Icon} from '../components/Icon';
|
3
|
+
|
4
|
+
interface IProps<T> {
|
5
|
+
getItems(pageNo: number, signal: AbortSignal): Promise<{items: Array<T>, itemCount: number}>;
|
6
|
+
children: (items: Array<T>) => JSX.Element;
|
7
|
+
pageSize?: number;
|
8
|
+
}
|
9
|
+
|
10
|
+
interface IState<T> {
|
11
|
+
currentPage: number;
|
12
|
+
items: Array<T> | null;
|
13
|
+
}
|
14
|
+
|
15
|
+
export function getPagination(currentPage: number, totalPages: number): Array<number | 'dots'> {
|
16
|
+
if (currentPage <= 0 || totalPages <= 0 || currentPage > totalPages) {
|
17
|
+
return [];
|
18
|
+
}
|
19
|
+
|
20
|
+
let basePages: ReturnType<typeof getPagination> = [
|
21
|
+
currentPage - 2,
|
22
|
+
currentPage - 1,
|
23
|
+
currentPage,
|
24
|
+
currentPage + 1,
|
25
|
+
currentPage + 2,
|
26
|
+
].filter((page) => page >= 1 && page <= totalPages);
|
27
|
+
|
28
|
+
if (!basePages.includes(1)) { // include first and maybe dots
|
29
|
+
const firstInCurrentList = basePages[0];
|
30
|
+
|
31
|
+
if (firstInCurrentList !== 1) {
|
32
|
+
basePages = [
|
33
|
+
'dots',
|
34
|
+
...basePages,
|
35
|
+
];
|
36
|
+
}
|
37
|
+
|
38
|
+
basePages = [
|
39
|
+
1,
|
40
|
+
...basePages,
|
41
|
+
];
|
42
|
+
}
|
43
|
+
|
44
|
+
if (!basePages.includes(totalPages)) { // include last and maybe dots
|
45
|
+
const lastInCurrentList = basePages[basePages.length - 1];
|
46
|
+
|
47
|
+
if (lastInCurrentList !== totalPages - 1) { // add dots if we're skipping some numbers
|
48
|
+
basePages = basePages.concat('dots');
|
49
|
+
}
|
50
|
+
|
51
|
+
basePages = [
|
52
|
+
...basePages,
|
53
|
+
totalPages,
|
54
|
+
];
|
55
|
+
}
|
56
|
+
|
57
|
+
return basePages;
|
58
|
+
}
|
59
|
+
|
60
|
+
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
|
61
|
+
if (element == null) {
|
62
|
+
return null;
|
63
|
+
}
|
64
|
+
|
65
|
+
let parentElement: HTMLElement | null = element;
|
66
|
+
|
67
|
+
const overflowY = window.getComputedStyle(parentElement).overflowY;
|
68
|
+
const hasScrollbar = overflowY === 'auto' || overflowY === 'scroll';
|
69
|
+
|
70
|
+
while (parentElement !== null && !hasScrollbar) {
|
71
|
+
parentElement = parentElement.parentElement ?? null;
|
72
|
+
}
|
73
|
+
|
74
|
+
return parentElement;
|
75
|
+
}
|
76
|
+
|
77
|
+
export class WithPagination<T> extends React.PureComponent<IProps<T>, IState<T>> {
|
78
|
+
private pageCount: number;
|
79
|
+
private abortController: AbortController;
|
80
|
+
private ref: HTMLDivElement | null;
|
81
|
+
private inProgress: boolean;
|
82
|
+
|
83
|
+
constructor(props: IProps<T>) {
|
84
|
+
super(props);
|
85
|
+
|
86
|
+
this.state = {
|
87
|
+
currentPage: 1,
|
88
|
+
items: null,
|
89
|
+
};
|
90
|
+
|
91
|
+
this.switchPage = this.switchPage.bind(this);
|
92
|
+
this.getPageSize = this.getPageSize.bind(this);
|
93
|
+
|
94
|
+
this.pageCount = 0;
|
95
|
+
this.abortController = new window.AbortController(); // window. needed for unit tests
|
96
|
+
this.ref = null;
|
97
|
+
this.inProgress = false;
|
98
|
+
}
|
99
|
+
|
100
|
+
getPageSize() {
|
101
|
+
return this.props.pageSize ?? 20;
|
102
|
+
}
|
103
|
+
|
104
|
+
switchPage(page: number) {
|
105
|
+
if (this.inProgress) {
|
106
|
+
this.abortController.abort();
|
107
|
+
}
|
108
|
+
|
109
|
+
this.inProgress = true;
|
110
|
+
this.props.getItems(page, this.abortController.signal).then((res) => {
|
111
|
+
this.inProgress = false;
|
112
|
+
this.setState({items: res.items, currentPage: page}, () => {
|
113
|
+
const scrollableEl = getScrollParent(this.ref);
|
114
|
+
const diff = scrollableEl != null && this.ref?.scrollHeight != null
|
115
|
+
? scrollableEl.offsetHeight - this.ref.scrollHeight
|
116
|
+
: null;
|
117
|
+
|
118
|
+
if (scrollableEl != null) {
|
119
|
+
scrollableEl.scrollTop = diff != null ? diff : 0;
|
120
|
+
}
|
121
|
+
});
|
122
|
+
});
|
123
|
+
}
|
124
|
+
|
125
|
+
componentDidMount(): void {
|
126
|
+
this.props.getItems(1, this.abortController.signal).then((res) => {
|
127
|
+
this.pageCount = Math.ceil(res.itemCount / this.getPageSize());
|
128
|
+
this.setState({items: res.items});
|
129
|
+
});
|
130
|
+
}
|
131
|
+
|
132
|
+
render() {
|
133
|
+
if (this.state.items == null) {
|
134
|
+
return null;
|
135
|
+
}
|
136
|
+
|
137
|
+
const pageElements = getPagination(this.state.currentPage, this.pageCount).map((el, i) => {
|
138
|
+
if (el === 'dots') {
|
139
|
+
return (
|
140
|
+
<span data-test-id="more-pages" className='sd-pagination__item sd-pagination__item--more'>...</span>
|
141
|
+
);
|
142
|
+
} else {
|
143
|
+
return (
|
144
|
+
<button
|
145
|
+
data-test-id={`page-button-${i}`}
|
146
|
+
className={
|
147
|
+
this.state.currentPage === el
|
148
|
+
? 'sd-pagination__item sd-pagination__item--active'
|
149
|
+
: 'sd-pagination__item'
|
150
|
+
}
|
151
|
+
onClick={() => this.switchPage(el)}
|
152
|
+
>
|
153
|
+
{el}
|
154
|
+
</button>
|
155
|
+
);
|
156
|
+
}
|
157
|
+
});
|
158
|
+
|
159
|
+
pageElements.unshift(
|
160
|
+
<>
|
161
|
+
<button
|
162
|
+
data-test-id="btn-1"
|
163
|
+
className='sd-pagination__item sd-pagination__item--start'
|
164
|
+
disabled={this.state.currentPage === 1}
|
165
|
+
onClick={() => this.switchPage(1)}
|
166
|
+
>
|
167
|
+
<Icon name='backward-thin' />
|
168
|
+
</button>
|
169
|
+
<button
|
170
|
+
data-test-id="btn-2"
|
171
|
+
className='sd-pagination__item sd-pagination__item--start'
|
172
|
+
disabled={this.state.currentPage <= 1}
|
173
|
+
onClick={() => this.switchPage(this.state.currentPage - 1)}
|
174
|
+
>
|
175
|
+
<Icon name='chevron-left-thin' />
|
176
|
+
</button>
|
177
|
+
</>,
|
178
|
+
);
|
179
|
+
|
180
|
+
pageElements.push(
|
181
|
+
<>
|
182
|
+
<button
|
183
|
+
data-test-id="btn-3"
|
184
|
+
className='sd-pagination__item sd-pagination__item--forward'
|
185
|
+
onClick={() => this.switchPage(this.state.currentPage + 1)}
|
186
|
+
disabled={this.state.currentPage === this.pageCount}
|
187
|
+
>
|
188
|
+
<Icon name='chevron-right-thin' />
|
189
|
+
</button>
|
190
|
+
<button
|
191
|
+
data-test-id="btn-4"
|
192
|
+
className='sd-pagination__item sd-pagination__item--end'
|
193
|
+
onClick={() => this.switchPage(this.pageCount)}
|
194
|
+
disabled={this.state.currentPage === this.pageCount}
|
195
|
+
>
|
196
|
+
<Icon name='forward-thin' />
|
197
|
+
</button>
|
198
|
+
</>,
|
199
|
+
);
|
200
|
+
|
201
|
+
const StyledPagination: React.ComponentType = () => (
|
202
|
+
<div
|
203
|
+
style={{
|
204
|
+
display: 'flex',
|
205
|
+
flexDirection: 'row',
|
206
|
+
justifyContent: 'center',
|
207
|
+
}}
|
208
|
+
>
|
209
|
+
{pageElements}
|
210
|
+
</div>
|
211
|
+
);
|
212
|
+
|
213
|
+
return (
|
214
|
+
<div
|
215
|
+
style={{
|
216
|
+
height: '100%',
|
217
|
+
width: '100%',
|
218
|
+
display: 'flex',
|
219
|
+
flexDirection: 'column',
|
220
|
+
}}
|
221
|
+
ref={(element) => {
|
222
|
+
this.ref = element;
|
223
|
+
}}
|
224
|
+
>
|
225
|
+
<StyledPagination />
|
226
|
+
{this.props.children(this.state.items)}
|
227
|
+
<StyledPagination />
|
228
|
+
</div>
|
229
|
+
);
|
230
|
+
}
|
231
|
+
}
|
@@ -0,0 +1,146 @@
|
|
1
|
+
import {describe, it} from 'mocha';
|
2
|
+
import * as assert from 'assert';
|
3
|
+
import {mount} from 'enzyme';
|
4
|
+
import * as React from 'react';
|
5
|
+
import {getPagination, WithPagination} from './WithPagination';
|
6
|
+
import {range} from 'lodash';
|
7
|
+
|
8
|
+
interface IPost {
|
9
|
+
title: string;
|
10
|
+
}
|
11
|
+
|
12
|
+
// Simulate fetching delay
|
13
|
+
const TIMEOUT = 1000;
|
14
|
+
|
15
|
+
export class Paginated extends React.PureComponent {
|
16
|
+
getItems(): Promise<{items: Array<IPost>, itemCount: number}> {
|
17
|
+
return new Promise((resolve) => {
|
18
|
+
setTimeout(() => {
|
19
|
+
return resolve({items: range(1, 500).map((x) => ({title: `title ${x}`})), itemCount: 500});
|
20
|
+
}, TIMEOUT);
|
21
|
+
});
|
22
|
+
}
|
23
|
+
|
24
|
+
render() {
|
25
|
+
return (
|
26
|
+
<WithPagination
|
27
|
+
getItems={() => this.getItems()}
|
28
|
+
>
|
29
|
+
{
|
30
|
+
(items) => <div>{JSON.stringify(items)}</div>
|
31
|
+
}
|
32
|
+
</WithPagination>
|
33
|
+
);
|
34
|
+
}
|
35
|
+
}
|
36
|
+
|
37
|
+
describe('getPagination', () => {
|
38
|
+
it('returns empty array when we have 0 pages', () => {
|
39
|
+
assert.strictEqual(getPagination(1, 0).length, 0);
|
40
|
+
});
|
41
|
+
|
42
|
+
it('returns empty array when we try to access page <= 0', () => {
|
43
|
+
assert.strictEqual(getPagination(-10, 100).length, 0);
|
44
|
+
});
|
45
|
+
|
46
|
+
it('returns dots when on page 1 having 5 total pages', () => {
|
47
|
+
assert.notStrictEqual(getPagination(1, 5), [1, 2, 3, 'dots', 5]);
|
48
|
+
});
|
49
|
+
|
50
|
+
it('returns dots when on the last page having 5 total pages', () => {
|
51
|
+
assert.notStrictEqual(getPagination(5, 5), [1, 'dots', 3, 4, 5]);
|
52
|
+
});
|
53
|
+
|
54
|
+
it('returns dots twice when on the middle page', () => {
|
55
|
+
assert.notStrictEqual(getPagination(5, 10), [1, 'dots', 3, 4, 5, 6, 7, 'dots', 10]);
|
56
|
+
});
|
57
|
+
|
58
|
+
it('returns page 1 and last page when on the middle page', () => {
|
59
|
+
assert.notStrictEqual(getPagination(5, 10), [1, 'dots', 3, 4, 5, 6, 7, 'dots', 10]);
|
60
|
+
});
|
61
|
+
|
62
|
+
it('contains no pages out of reach', () => {
|
63
|
+
const res = getPagination(101, 100);
|
64
|
+
assert.strictEqual(res.length, 0);
|
65
|
+
});
|
66
|
+
});
|
67
|
+
|
68
|
+
describe('with-pagination', () => {
|
69
|
+
it('contains page-button-1 button when first rendered', (done) => {
|
70
|
+
const wrapper = mount(<Paginated />);
|
71
|
+
|
72
|
+
setTimeout(() => {
|
73
|
+
assert.strictEqual(
|
74
|
+
wrapper.update().find('[data-test-id="page-button-1"]').length,
|
75
|
+
2,
|
76
|
+
);
|
77
|
+
done();
|
78
|
+
}, TIMEOUT + 100);
|
79
|
+
});
|
80
|
+
|
81
|
+
it('contains page-button-4 after clicking forward', (done) => {
|
82
|
+
const wrapper = mount(<Paginated />);
|
83
|
+
|
84
|
+
setTimeout(() => {
|
85
|
+
wrapper.update();
|
86
|
+
wrapper.find('[data-test-id="btn-3"]').at(0).simulate('click');
|
87
|
+
|
88
|
+
assert.strictEqual(
|
89
|
+
wrapper.find('[data-test-id="page-button-4"]').length,
|
90
|
+
2,
|
91
|
+
);
|
92
|
+
done();
|
93
|
+
}, TIMEOUT + 100);
|
94
|
+
});
|
95
|
+
|
96
|
+
it('previous-next buttons works', (done) => {
|
97
|
+
const wrapper = mount(<Paginated />);
|
98
|
+
|
99
|
+
setTimeout(() => {
|
100
|
+
wrapper.update();
|
101
|
+
wrapper.find('[data-test-id="btn-4"]').at(0).simulate('click');
|
102
|
+
wrapper.find('[data-test-id="btn-2"]').at(0).simulate('click');
|
103
|
+
|
104
|
+
assert.strictEqual(
|
105
|
+
wrapper.find('[data-test-id="button4"]').length,
|
106
|
+
2,
|
107
|
+
);
|
108
|
+
done();
|
109
|
+
}, TIMEOUT + 100);
|
110
|
+
});
|
111
|
+
|
112
|
+
it('returns span when at last page', (done) => {
|
113
|
+
const wrapper = mount(<Paginated />);
|
114
|
+
|
115
|
+
setTimeout(() => {
|
116
|
+
wrapper.update();
|
117
|
+
wrapper.find('[data-test-id="btn-4"]').at(0).simulate('click');
|
118
|
+
|
119
|
+
assert.strictEqual(
|
120
|
+
wrapper.find('[data-test-id="more-pages"]').length,
|
121
|
+
2,
|
122
|
+
);
|
123
|
+
done();
|
124
|
+
}, TIMEOUT + 100);
|
125
|
+
});
|
126
|
+
|
127
|
+
it.only('scrolls to the top of the pagination container', (done) => {
|
128
|
+
const wrapper = mount(
|
129
|
+
<div style={{height: 1200, overflowY: 'auto'}}>
|
130
|
+
<div style={{height: 400}} />
|
131
|
+
<Paginated />
|
132
|
+
</div>,
|
133
|
+
);
|
134
|
+
|
135
|
+
setTimeout(() => {
|
136
|
+
wrapper.update();
|
137
|
+
wrapper.find('[data-test-id="btn-4"]').at(1).simulate('click');
|
138
|
+
|
139
|
+
assert.strictEqual(
|
140
|
+
wrapper.getDOMNode().scrollTop,
|
141
|
+
0,
|
142
|
+
);
|
143
|
+
done();
|
144
|
+
}, TIMEOUT + 100);
|
145
|
+
});
|
146
|
+
});
|
package/app-typescript/index.ts
CHANGED
@@ -6,6 +6,7 @@ export { Button } from './components/Button';
|
|
6
6
|
export { Input } from './components/Input';
|
7
7
|
export { Select, Option } from './components/Select';
|
8
8
|
export { SelectWithTemplate } from './components/SelectWithTemplate';
|
9
|
+
export { WithPagination } from './components/WithPagination';
|
9
10
|
export { Popover } from './components/Popover';
|
10
11
|
export { Label } from './components/Label';
|
11
12
|
export { Badge } from './components/Badge';
|
@@ -90,6 +91,7 @@ export { TreeSelect } from './components/TreeSelect';
|
|
90
91
|
export { TableList, TableListItem } from './components/Lists/TableList';
|
91
92
|
export { ContentListItem } from './components/Lists/ContentList';
|
92
93
|
export { MultiSelect } from './components/MultiSelect';
|
94
|
+
export { ResizablePanels } from './components/ResizablePanels';
|
93
95
|
|
94
96
|
// declare non-typescript exports to prevent errors
|
95
97
|
export declare const ToggleBoxNext: any;
|