Skip to content

Commit

Permalink
GUI Components: List component styling, add ListHeader (#3780)
Browse files Browse the repository at this point in the history
  • Loading branch information
AleksandrGorodetskii committed Nov 26, 2024
1 parent e813d2a commit 550018e
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 25 deletions.
22 changes: 8 additions & 14 deletions portals-ui/packages/components/lib/components/list/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,23 @@
import type { Key, ReactNode } from 'react';
import React, { useState } from 'react';
import type { VirtualListState } from '@epam/uui-core';
import type { CommonProps } from '../..';
import { VirtualList } from '@epam/uui';
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 type ListProps<Item> = CommonProps & {
data: Item[];
renderItem: (item: Item, index: number) => ReactNode | string;
header?: ReactNode;
footer?: ReactNode;
virtualized?: boolean;
fieldKey?: string;
};

export default function List<Item>(props: ListProps<Item>): ReactNode {
const {
header,
footer,
data,
renderItem,
fieldKey,
virtualized = false,
style,
className,
itemKey,
} = props;
const [listState, setListState] = useState<VirtualListState>({
topIndex: 0,
Expand All @@ -50,8 +42,8 @@ export default function List<Item>(props: ListProps<Item>): ReactNode {
<div className="overflow-y-auto">
{data.map((item, index) => {
let key: Key = `key_${index}`;
if (item && typeof item === 'object' && fieldKey) {
key = (item[fieldKey as keyof typeof item] as string | number) ?? key;
if (itemKey && typeof itemKey !== 'symbol') {
key = typeof itemKey === 'function' ? itemKey(item, index) : itemKey;
}
return (
<React.Fragment key={key}>{renderItem(item, index)}</React.Fragment>
Expand All @@ -69,3 +61,5 @@ export default function List<Item>(props: ListProps<Item>): ReactNode {
</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;
};
4 changes: 2 additions & 2 deletions portals-ui/packages/components/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import DummyComponent from './components/dummy-component';
import List from './components/list';
import List, { ListHeader } from './components/list';
import '@epam/uui-components/styles.css';
import '@epam/uui/styles.css';
import './style.css';

export { DummyComponent, List };
export { DummyComponent, List, ListHeader };
export * from './components/common.types';
76 changes: 67 additions & 9 deletions portals-ui/sites/ngs-portal/src/pages/home/home.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,90 @@
import { useEffect } from 'react';
import { List } 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 type { Project } from '@cloud-pipeline/core';
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 className="flex h-full gap-5 overflow-hidden flex-nowrap justify-around p-2">
<List
className="list-container"
header={<div className="p-2 list-header-container">Projects</div>}
footer={<div className="p-2">List footer</div>}
data={projects}
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 }}>
{item.name}
<HighlightedText search={projectSearch}>
{item.name}
</HighlightedText>
</div>
)}
style={{ flex: 1 }}
/>
<List
className="list-container"
header={<div className="p-2">List header</div>}
footer={<div className="p-2">List footer</div>}
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}
Expand All @@ -45,8 +94,17 @@ export const Home = () => {
/>
<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}
Expand Down
8 changes: 8 additions & 0 deletions portals-ui/sites/ngs-portal/src/pages/home/style.css
Original file line number Diff line number Diff line change
@@ -1,8 +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 550018e

Please sign in to comment.