Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement filtering by browser in new ui #593

Merged
merged 2 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {isString} from 'lodash';
import React, {ReactNode} from 'react';

const valueToIcon = {
google: 'chrome',
chrome: 'chrome',
firefox: 'firefox',
safari: 'safari',
edge: 'edge',
yandex: 'yandex',
yabro: 'yandex',
ie: 'internet-explorer',
explorer: 'internet-explorer',
opera: 'opera',
phone: 'mobile',
mobile: 'mobile',
tablet: 'tablet',
ipad: 'tablet'
} as const;

export function BrowserIcon({name: browser}: {name: string}): ReactNode {
const getIcon = (iconName: string): ReactNode => <i className={`fa fa-${iconName}`} aria-hidden="true" />;

if (!isString(browser)) {
return getIcon('browser');
}

const lowerValue = browser.toLowerCase();

for (const pattern in valueToIcon) {
if (lowerValue.includes(pattern)) {
shadowusr marked this conversation as resolved.
Show resolved Hide resolved
return getIcon(valueToIcon[pattern as keyof typeof valueToIcon]);
}
}

return getIcon('browser');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
:global(.g-popup) {
z-index: 9999;
}

.browserlist__filter {
padding: 4px;
}

.action-button {
width: 60px;
margin-left: auto;
}

.browserlist__popup {
--g-color-text-info: var(--g-color-private-color-500-solid);
}

.browserlist__popup :global(.g-select-list__option) {
gap: 8px;
flex-direction: row-reverse;
}
180 changes: 180 additions & 0 deletions lib/static/new-ui/features/suites/components/BrowsersSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import {Globe} from '@gravity-ui/icons';
import {Button, Flex, Select, SelectRenderControlProps, SelectRenderOption} from '@gravity-ui/uikit';
import React, {useState, useEffect, ReactNode} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';

import * as actions from '@/static/modules/actions';
import {BrowserIcon} from '@/static/new-ui/features/suites/components/BrowsersSelect/BrowserIcon';
import {State} from '@/static/new-ui/types/store';
import {BrowserItem} from '@/types';
import styles from './index.module.css';

// In the onUpdate callback we only have access to array of selected strings. That's why we need to serialize
// id/version in string. Encoding to avoid errors if id/version contains delimiter.
// The other approach would be to use mapping, but in practice it makes things even more complex.
const DELIMITER = '/';

const serializeBrowserData = (id: string, version: string): string =>
`${encodeURIComponent(id)}${DELIMITER}${encodeURIComponent(version)}`;

const deserializeBrowserData = (data: string): {id: string; version: string} => {
const [idEncoded, versionEncoded] = data.split(DELIMITER);
return {id: decodeURIComponent(idEncoded), version: decodeURIComponent(versionEncoded)};
};

interface BrowsersSelectProps {
browsers: BrowserItem[];
filteredBrowsers: BrowserItem[];
actions: typeof actions;
}

function BrowsersSelectInternal({browsers, filteredBrowsers, actions}: BrowsersSelectProps): ReactNode {
const [selectedBrowsers, setSelectedBrowsers] = useState<BrowserItem[]>([]);

useEffect(() => {
setSelectedBrowsers(filteredBrowsers);
}, [browsers, filteredBrowsers]);

const renderFilter = (): React.JSX.Element => {
return (
<div className={styles['browserlist__filter']}>
<Button onClick={(): void => setSelectedBrowsers(browsers)} width='max'>
Select All
</Button>
</div>
);
};

const onUpdate = (values: string[]): void => {
const selectedItems: BrowserItem[] = [];

values.forEach(encodedBrowserData => {
const {id, version} = deserializeBrowserData(encodedBrowserData);
const existingBrowser = selectedItems.find(browser => browser.id === id);

if (existingBrowser) {
shadowusr marked this conversation as resolved.
Show resolved Hide resolved
if (!existingBrowser.versions.includes(version)) {
existingBrowser.versions.push(version);
}
} else {
selectedItems.push({id, versions: [version]});
}
});

setSelectedBrowsers(selectedItems);
};

const renderOptions = (): React.JSX.Element | React.JSX.Element[] => {
const browsersWithMultipleVersions = browsers.filter(browser => browser.versions.length > 1);
const browsersWithSingleVersion = browsers.filter(browser => browser.versions.length === 1);

const getOptionProps = (browser: BrowserItem, version: string): {value: string; content: string; data: Record<string, unknown>} => ({
value: serializeBrowserData(browser.id, version),
content: browser.id,
data: {id: browser.id, version}
});

if (browsersWithMultipleVersions.length === 0) {
// If there are no browsers with multiple versions, we want to render a simple plain list
return browsers.map(browser => <Select.Option
key={browser.id}
{...getOptionProps(browser, browser.versions[0])}
/>);
} else {
// Otherwise render browser version groups and place all browsers with single version into "Other" group
return (
<>
{browsersWithMultipleVersions.map(browser => (
<Select.OptionGroup key={browser.id} label={browser.id}>
{browser.versions.map(version => (
<Select.Option
key={version}
{...getOptionProps(browser, version)}
/>
))}
</Select.OptionGroup>
))}
<Select.OptionGroup label="Other">
{browsersWithSingleVersion.map(browser => (
shadowusr marked this conversation as resolved.
Show resolved Hide resolved
<Select.Option
key={browser.id}
{...getOptionProps(browser, browser.versions[0])}
/>
))}
</Select.OptionGroup>
</>
);
}
};

const renderControl = ({onClick, onKeyDown, ref}: SelectRenderControlProps): React.JSX.Element => {
return <Button ref={ref} onClick={onClick} extraProps={{onKeyDown}} view={'outlined'} style={{width: 28}}>
<Globe/>
</Button>;
};

const selected = selectedBrowsers.flatMap(browser => browser.versions.map(version => serializeBrowserData(browser.id, version)));

const onClose = (): void => {
actions.selectBrowsers(selectedBrowsers.filter(browser => browser.versions.length > 0));
};

const onFocus = (): void => {
if (selected.length === 0) {
setSelectedBrowsers(browsers);
}
};

const renderOption: SelectRenderOption<{id: string; version: string}> = (option) => {
const isTheOnlySelected = selected.includes(option.value) && selected.length === 1;
const selectOnly = (e: React.MouseEvent<HTMLElement>): void => {
e.preventDefault();
e.stopPropagation();
setSelectedBrowsers([{id: option.data?.id as string, versions: [option.data?.version as string]}]);
};
const selectExcept = (e: React.MouseEvent<HTMLElement>): void => {
e.preventDefault();
e.stopPropagation();
setSelectedBrowsers(browsers.map(browser => ({
id: browser.id,
versions: browser.versions.filter(version => browser.id !== option.data?.id || version !== option.data?.version)
})));
};
return (
<Flex alignItems="center" width={'100%'} gap={2}>
<Flex height={16} width={16} alignItems={'center'} justifyContent={'center'}><BrowserIcon name={option.data?.id as string} /></Flex>
<div className="browser-name">{option.content}</div>
<Button size="s" onClick={isTheOnlySelected ? selectExcept : selectOnly} className={styles.actionButton}>{isTheOnlySelected ? 'Except' : 'Only'}</Button>
</Flex>
);
};

return (
<Select
disablePortal
value={selected}
multiple={true}
hasCounter
filterable
renderFilter={renderFilter}
renderOption={renderOption}
renderControl={renderControl}
popupClassName={styles['browserlist__popup']}
className='browserlist'
onUpdate={onUpdate}
onFocus={onFocus}
onClose={onClose}
>
{renderOptions()}
</Select>
);
}

export const BrowsersSelect = connect(
(state: State) => ({
filteredBrowsers: state.view.filteredBrowsers,
browsers: state.browsers
}),
(dispatch) => ({actions: bindActionCreators(actions, dispatch)})
)(BrowsersSelectInternal);
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import {SplitViewLayout} from '@/static/new-ui/components/SplitViewLayout';
import {TestNameFilter} from '@/static/new-ui/features/suites/components/TestNameFilter';
import {SuitesTreeView} from '@/static/new-ui/features/suites/components/SuitesTreeView';
import {TestStatusFilter} from '@/static/new-ui/features/suites/components/TestStatusFilter';
import {BrowsersSelect} from '@/static/new-ui/features/suites/components/BrowsersSelect';

function SuitesPageInternal(): ReactNode {
return <SplitViewLayout>
<div>
<Flex direction={'column'} spacing={{p: '2'}} style={{height: '100vh'}}>
<h2 className="text-display-1">Suites</h2>
<Flex>
<Flex gap={2}>
<TestNameFilter/>
<BrowsersSelect/>
</Flex>
<Flex spacing={{mt: 2}}>
<TestStatusFilter/>
Expand Down
10 changes: 6 additions & 4 deletions lib/static/new-ui/types/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {TestStatus, ViewMode} from '@/constants';
import {ImageFile} from '@/types';
import {BrowserItem, ImageFile} from '@/types';

export interface SuiteEntityNode {
name: string;
Expand Down Expand Up @@ -65,7 +65,8 @@ export interface State {
app: {
isInitialized: boolean;
currentSuiteId: string | null;
}
};
browsers: BrowserItem[];
tree: {
browsers: {
allIds: string[];
Expand All @@ -83,9 +84,10 @@ export interface State {
byId: Record<string, SuiteEntity>;
stateById: Record<string, SuiteState>;
};
}
};
view: {
testNameFilter: string;
viewMode: ViewMode;
}
filteredBrowsers: BrowserItem[];
};
}
7 changes: 1 addition & 6 deletions lib/tests-tree-builder/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {BaseTestsTreeBuilder, BaseTestsTreeBuilderOptions, Tree} from './base';
import {BrowserVersions, DB_COLUMN_INDEXES, TestStatus} from '../constants';
import {ReporterTestResult} from '../adapters/test-result';
import {SqliteTestResultAdapter} from '../adapters/test-result/sqlite';
import {RawSuitesRow} from '../types';
import {BrowserItem, RawSuitesRow} from '../types';

interface Stats {
total: number;
Expand All @@ -27,11 +27,6 @@ export interface SkipItem {
comment?: string;
}

interface BrowserItem {
id: string;
versions: string[];
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface StaticTestsTreeBuilderOptions extends BaseTestsTreeBuilderOptions {}

Expand Down
5 changes: 5 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,8 @@ export type RawSuitesRow = [
export type LabeledSuitesRow = {
[K in (typeof SUITES_TABLE_COLUMNS)[number]['name']]: string;
};

export interface BrowserItem {
id: string;
versions: string[];
}
Loading