Skip to content

Commit

Permalink
New GUI - List component (#3787)
Browse files Browse the repository at this point in the history
* GUI Components: add List component (#3780)

* GUI Components: List component styling, add ListHeader (#3780)
  • Loading branch information
AleksandrGorodetskii authored Nov 27, 2024
1 parent a550313 commit 4e33c6f
Show file tree
Hide file tree
Showing 8 changed files with 366 additions and 4 deletions.
65 changes: 65 additions & 0 deletions portals-ui/packages/components/lib/components/list/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Key, ReactNode } from 'react';
import React, { useState } from 'react';
import type { VirtualListState } from '@epam/uui-core';
import classNames from 'classnames';
import { VirtualList } from '@epam/uui';
import type { ListProps } from './types';
import ListHeader from './list-header';

const MIN_VISIBLE_COUNT = 20;

export default function List<Item>(props: ListProps<Item>): ReactNode {
const {
header,
footer,
data,
renderItem,
virtualized = false,
style,
className,
itemKey,
} = props;
const [listState, setListState] = useState<VirtualListState>({
topIndex: 0,
visibleCount: MIN_VISIBLE_COUNT,
});
const visibleData = data.slice(
listState.topIndex,
(listState.topIndex ?? 0) + (listState.visibleCount ?? MIN_VISIBLE_COUNT),
);
const rows = visibleData.map((item, index) => (
<React.Fragment key={index}>{renderItem(item, index)}</React.Fragment>
));
const listComponent = virtualized ? (
<VirtualList
cx="max-h-full"
rows={rows}
value={listState}
onValueChange={setListState}
rowsCount={data.length}
/>
) : (
<div className="overflow-y-auto">
{data.map((item, index) => {
let key: Key = `key_${index}`;
if (itemKey && typeof itemKey !== 'symbol') {
key = typeof itemKey === 'function' ? itemKey(item, index) : itemKey;
}
return (
<React.Fragment key={key}>{renderItem(item, index)}</React.Fragment>
);
})}
</div>
);
return (
<div
style={style}
className={classNames('overflow-hidden flex flex-col', className)}>
{header ?? null}
{listComponent}
{footer ?? null}
</div>
);
}

export { ListHeader };
37 changes: 37 additions & 0 deletions portals-ui/packages/components/lib/components/list/list-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import classNames from 'classnames';
import { SearchInput } from '@epam/uui';
import type { ListHeaderProps } from './types';

const ListHeader = (props: ListHeaderProps) => {
const {
className,
style,
title,
controls,
search,
onSearch,
searchPlaceholder,
} = props;
return (
<div className={classNames(className, 'divide-y')} style={style}>
<b
className="flex no-wrap px-6 py-4"
style={{ color: 'var(--uui-text-secondary)' }}>
{title} {controls ? <div className="ml-auto">{controls}</div> : null}
</b>
{onSearch ? (
<div className="px-6 py-2">
<SearchInput
value={search}
onValueChange={onSearch}
placeholder={searchPlaceholder ?? 'Search'}
debounceDelay={300}
size="30"
/>
</div>
) : null}
</div>
);
};

export default ListHeader;
20 changes: 20 additions & 0 deletions portals-ui/packages/components/lib/components/list/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import type { CommonProps } from '../common.types';

export type ListProps<Item> = CommonProps & {
data: Item[];
renderItem: (item: Item, index: number) => ReactNode | string;
header?: ReactNode;
footer?: ReactNode;
virtualized?: boolean;
itemKey?: keyof Item | ((item: Item, index: number) => string | number);
};

export type ListHeaderProps = CommonProps & {
title: string | ReactNode;
logo?: string;
search?: string;
searchPlaceholder?: string;
onSearch?: (search: string) => void;
controls?: ReactNode;
};
3 changes: 2 additions & 1 deletion portals-ui/packages/components/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import DummyComponent from './components/dummy-component';
import List, { ListHeader } from './components/list';
import '@epam/uui-components/styles.css';
import '@epam/uui/styles.css';
import './style.css';

export { DummyComponent };
export { DummyComponent, List, ListHeader };
export * from './components/common.types';
114 changes: 111 additions & 3 deletions portals-ui/sites/ngs-portal/src/pages/home/home.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,117 @@
import { DummyComponent } from '@cloud-pipeline/components';
import { useEffect, useMemo, useState } from 'react';
import type { Project } from '@cloud-pipeline/core';
import { Button, LinkButton } from '@epam/uui';
import { List, ListHeader } from '@cloud-pipeline/components';
import { useProjectsState } from '../../state/projects/hooks';
import { loadProjects } from '../../state/projects/load-projects';
import HighlightedText from '../../shared/highlight-text';
import './style.css';

export const Home = () => {
const { projects } = useProjectsState();
const [projectSearch, setProjectSearch] = useState('');
const [pipelinesSearch, setPipelinesSearch] = useState('');
useEffect(() => {
loadProjects()
.then(() => {})
.catch(() => {});
}, []);
const filteredProjects = useMemo(() => {
if (!projects) {
return [];
}
return projectSearch
? projects.filter((project) =>
project.name.toLowerCase().includes(projectSearch.toLowerCase()),
)
: projects;
}, [projectSearch, projects]);
if (!projects) {
return null;
}
return (
<div>
<DummyComponent />
<div className="flex h-full gap-5 overflow-hidden flex-nowrap justify-around p-2">
<List
className="list-container"
header={
<ListHeader
className="list-header-container"
title="Projects"
controls={
<Button caption="Add project" size="24" onClick={() => null} />
}
search={projectSearch}
onSearch={setProjectSearch}
/>
}
footer={
<div className="list-footer-container">
<LinkButton
caption="View all projects"
link={{ pathname: '/projects' }}
/>
</div>
}
data={filteredProjects}
itemKey={(item: Project) => item.id}
virtualized
renderItem={(item: Project) => (
<div className="p-2" style={{ height: 100 }}>
<HighlightedText search={projectSearch}>
{item.name}
</HighlightedText>
</div>
)}
style={{ flex: 1 }}
/>
<List
className="list-container"
header={
<ListHeader
className="list-header-container"
title="Pipelines"
search={pipelinesSearch}
onSearch={setPipelinesSearch}
/>
}
footer={
<div className="list-footer-container">
<LinkButton
caption="View all pipelines"
link={{ pathname: '/pipelines' }}
/>
</div>
}
data={projects}
virtualized
itemKey={(item: Project) => item.id}
renderItem={(item: Project) => (
<div className="p-2" style={{ height: 100 }}>
{item.name}
</div>
)}
style={{ flex: 1 }}
/>
<List
className="list-container"
header={
<ListHeader className="list-header-container" title="Run History" />
}
footer={
<div className="list-footer-container">
<LinkButton caption="View all runs" link={{ pathname: '/runs' }} />
</div>
}
data={projects}
virtualized
itemKey={(item: Project) => item.id}
renderItem={(item: Project) => (
<div className="p-2" style={{ height: 300 }}>
{item.name}
</div>
)}
style={{ flex: 1 }}
/>
</div>
);
};
16 changes: 16 additions & 0 deletions portals-ui/sites/ngs-portal/src/pages/home/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@tailwind components;

.list-container {
box-shadow: var(--uui-shadow-level-1);
border-radius: var(--uui-border-radius);
}

.list-footer-container,
.list-header-container {
box-shadow: var(--uui-shadow-level-1);
}

.list-footer-container {
box-shadow: var(--uui-shadow-level-1);
@apply h-12 flex justify-center items-center;
}
110 changes: 110 additions & 0 deletions portals-ui/sites/ngs-portal/src/shared/highlight-text/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useMemo } from 'react';
import type { CSSProperties } from 'react';
import classNames from 'classnames';
import type { CommonProps } from '@cloud-pipeline/components';
import './style.css';

export type HighlightedTextProps = CommonProps & {
search?: string | undefined;
children: string;
highlightClassName?: string;
highlightStyle?: CSSProperties;
};

type HighlightPart = {
part: string;
highlight: boolean;
};

const escapeRegExpCharacters = [
'.',
'-',
'+',
'*',
'?',
'^',
'$',
'(',
')',
'[',
']',
'{',
'}',
];

function escapeRegExp(
string: string,
characters = escapeRegExpCharacters,
): string {
let result = string;
characters.forEach((character) => {
result = result.replace(
new RegExp('\\' + character, 'g'),
`\\${character}`,
);
});
return result;
}

export default function HighlightedText(props: HighlightedTextProps) {
const {
children: text,
search = '',
className,
style,
highlightClassName,
highlightStyle,
} = props;
const parts = useMemo<HighlightPart[]>(() => {
if (!search?.length) {
return [
{
part: text,
highlight: false,
},
];
}
const regExp = new RegExp(`${escapeRegExp(search)}`, 'ig');
let e = regExp.exec(text);
let idx = 0;
const result: HighlightPart[] = [];
while (e) {
result.push({
part: text.slice(idx, e.index),
highlight: false,
});
result.push({
part: e[0],
highlight: true,
});
idx = e.index + e[0].length;
e = regExp.exec(text);
}
if (idx < text.length) {
result.push({
part: text.slice(idx),
highlight: false,
});
}
return result;
}, [text, search]);
return (
<span className={classNames(className, 'highlighted-text')} style={style}>
{parts.map((part, idx) => (
<span
key={`part-${idx}`}
className={classNames(
'm-0',
'highlighted-text-part',
highlightClassName,
{
highlighted: part.highlight,
},
)}
style={part.highlight ? highlightStyle : undefined}>
{part.part}
</span>
))}
</span>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@tailwind components;

.highlighted-text .highlighted-text-part.highlighted {
@apply bg-amber-200 dark:bg-amber-700/75;
}

0 comments on commit 4e33c6f

Please sign in to comment.