F: Add search feature to multiple cst selector
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled

This commit is contained in:
Ivan 2024-08-25 13:49:28 +03:00
parent e7678c979a
commit 8f1fbcde3d
9 changed files with 139 additions and 67 deletions

View File

@ -1,10 +1,18 @@
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library'; import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { CstMatchMode, DependencyMode } from '@/models/miscellaneous'; import { CstMatchMode, DependencyMode } from '@/models/miscellaneous';
import { ExpressionStatus } from '@/models/rsform'; import { CstType, ExpressionStatus } from '@/models/rsform';
import { import {
IconAlias, IconAlias,
IconBusiness, IconBusiness,
IconCstAxiom,
IconCstBaseSet,
IconCstConstSet,
IconCstFunction,
IconCstPredicate,
IconCstStructured,
IconCstTerm,
IconCstTheorem,
IconFilter, IconFilter,
IconFormula, IconFormula,
IconGraphCollapse, IconGraphCollapse,
@ -132,3 +140,24 @@ export function StatusIcon({ value, size = '1.25rem', className }: DomIconProps<
return <IconStatusError size={size} className={className} />; return <IconStatusError size={size} className={className} />;
} }
} }
export function CstTypeIcon({ value, size = '1.25rem', className }: DomIconProps<CstType>) {
switch (value) {
case CstType.BASE:
return <IconCstBaseSet size={size} className={className ?? 'clr-text-green'} />;
case CstType.CONSTANT:
return <IconCstConstSet size={size} className={className ?? 'clr-text-green'} />;
case CstType.STRUCTURED:
return <IconCstStructured size={size} className={className ?? 'clr-text-green'} />;
case CstType.TERM:
return <IconCstTerm size={size} className={className ?? 'clr-text-primary'} />;
case CstType.AXIOM:
return <IconCstAxiom size={size} className={className ?? 'clr-text-red'} />;
case CstType.FUNCTION:
return <IconCstFunction size={size} className={className ?? 'clr-text-primary'} />;
case CstType.PREDICATE:
return <IconCstPredicate size={size} className={className ?? 'clr-text-red'} />;
case CstType.THEOREM:
return <IconCstTheorem size={size} className={className ?? 'clr-text-red'} />;
}
}

View File

@ -5,12 +5,14 @@ import { useLayoutEffect, useMemo, useState } from 'react';
import DataTable, { createColumnHelper, RowSelectionState } from '@/components/ui/DataTable'; import DataTable, { createColumnHelper, RowSelectionState } from '@/components/ui/DataTable';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { CstMatchMode } from '@/models/miscellaneous';
import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform'; import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform';
import { isBasicConcept } from '@/models/rsformAPI'; import { isBasicConcept, matchConstituenta } from '@/models/rsformAPI';
import { describeConstituenta } from '@/utils/labels'; import { describeConstituenta } from '@/utils/labels';
import BadgeConstituenta from '../info/BadgeConstituenta'; import BadgeConstituenta from '../info/BadgeConstituenta';
import NoData from '../ui/NoData'; import NoData from '../ui/NoData';
import SearchBar from '../ui/SearchBar';
import ToolbarGraphSelection from './ToolbarGraphSelection'; import ToolbarGraphSelection from './ToolbarGraphSelection';
interface PickMultiConstituentaProps { interface PickMultiConstituentaProps {
@ -28,18 +30,30 @@ const columnHelper = createColumnHelper<IConstituenta>();
function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelected }: PickMultiConstituentaProps) { function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelected }: PickMultiConstituentaProps) {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [filtered, setFiltered] = useState<IConstituenta[]>(schema?.items ?? []);
const [filterText, setFilterText] = useState('');
useLayoutEffect(() => { useLayoutEffect(() => {
if (!schema || selected.length === 0) { if (filtered.length === 0) {
setRowSelection({}); setRowSelection({});
} else { return;
const newRowSelection: RowSelectionState = {};
schema.items.forEach((cst, index) => {
newRowSelection[String(index)] = selected.includes(cst.id);
});
setRowSelection(newRowSelection);
} }
}, [selected, schema]); const newRowSelection: RowSelectionState = {};
filtered.forEach((cst, index) => {
newRowSelection[String(index)] = selected.includes(cst.id);
});
setRowSelection(newRowSelection);
}, [filtered, setRowSelection, selected]);
useLayoutEffect(() => {
if (!schema || schema.items.length === 0) {
setFiltered([]);
} else if (filterText) {
setFiltered(schema.items.filter(cst => matchConstituenta(cst, filterText, CstMatchMode.ALL)));
} else {
setFiltered(schema.items);
}
}, [filterText, schema?.items, schema]);
function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) { function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) {
if (!schema) { if (!schema) {
@ -47,12 +61,12 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
} else { } else {
const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater; const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newSelection: ConstituentaID[] = []; const newSelection: ConstituentaID[] = [];
schema.items.forEach((cst, index) => { filtered.forEach((cst, index) => {
if (newRowSelection[String(index)] === true) { if (newRowSelection[String(index)] === true) {
newSelection.push(cst.id); newSelection.push(cst.id);
} }
}); });
setSelected(newSelection); setSelected(prev => [...prev.filter(cst_id => !filtered.find(cst => cst.id === cst_id)), ...newSelection]);
} }
} }
@ -75,10 +89,17 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
return ( return (
<div> <div>
<div className='flex items-end gap-3 mb-3'> <div className='flex justify-between items-center gap-3 clr-input px-3 border-x border-t rounded-t-md'>
<span className='w-[24ch] select-none whitespace-nowrap'> <div className='w-[24ch] select-none whitespace-nowrap'>
Выбраны {selected.length} из {schema?.items.length ?? 0} Выбраны {selected.length} из {schema?.items.length ?? 0}
</span> </div>
<SearchBar
id='dlg_constituents_search'
noBorder
className='min-w-[6rem] pr-2 flex-grow'
value={filterText}
onChange={setFilterText}
/>
{schema ? ( {schema ? (
<ToolbarGraphSelection <ToolbarGraphSelection
graph={schema.graph} graph={schema.graph}
@ -86,7 +107,7 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited} isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited}
setSelected={setSelected} setSelected={setSelected}
emptySelection={selected.length === 0} emptySelection={selected.length === 0}
className='w-full ml-8' className='w-fit'
/> />
) : null} ) : null}
</div> </div>
@ -97,7 +118,7 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
rows={rows} rows={rows}
contentHeight='1.3rem' contentHeight='1.3rem'
className={clsx('cc-scroll-y', 'border', 'text-sm', 'select-none')} className={clsx('cc-scroll-y', 'border', 'text-sm', 'select-none')}
data={schema?.items ?? []} data={filtered}
columns={columns} columns={columns}
headPosition='0rem' headPosition='0rem'
enableRowSelection enableRowSelection

View File

@ -96,6 +96,7 @@ function PickSchema({
<div className='border divide-y'> <div className='border divide-y'>
<SearchBar <SearchBar
id={id ? `${id}__search` : undefined} id={id ? `${id}__search` : undefined}
className='clr-input'
noBorder noBorder
value={filterText} value={filterText}
onChange={newValue => setFilterText(newValue)} onChange={newValue => setFilterText(newValue)}

View File

@ -18,7 +18,7 @@ function Dropdown({ isOpen, stretchLeft, stretchTop, className, children, ...res
<motion.div <motion.div
tabIndex={-1} tabIndex={-1}
className={clsx( className={clsx(
'z-modalTooltip', 'z-topmost',
'absolute mt-3', 'absolute mt-3',
'flex flex-col', 'flex flex-col',
'border rounded-md shadow-lg', 'border rounded-md shadow-lg',

View File

@ -27,7 +27,7 @@ function SearchBar({ id, value, noIcon, onChange, noBorder, placeholder = 'По
noOutline noOutline
placeholder={placeholder} placeholder={placeholder}
type='search' type='search'
className={clsx('w-full outline-none', !noIcon && 'pl-10')} className={clsx('w-full outline-none bg-transparent', !noIcon && 'pl-10')}
noBorder={noBorder} noBorder={noBorder}
value={value} value={value}
onChange={event => (onChange ? onChange(event.target.value) : undefined)} onChange={event => (onChange ? onChange(event.target.value) : undefined)}

View File

@ -29,7 +29,7 @@ function Tooltip({
} }
return createPortal( return createPortal(
<TooltipImpl <TooltipImpl
delayShow={1000} delayShow={750}
delayHide={100} delayHide={100}
opacity={1} opacity={1}
className={clsx( className={clsx(

View File

@ -1,18 +1,19 @@
'use client'; 'use client';
import clsx from 'clsx';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { IconCSV } from '@/components/Icons'; import { IconCSV } from '@/components/Icons';
import SelectedCounter from '@/components/info/SelectedCounter';
import { type RowSelectionState } from '@/components/ui/DataTable'; import { type RowSelectionState } from '@/components/ui/DataTable';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import SearchBar from '@/components/ui/SearchBar';
import AnimateFade from '@/components/wrap/AnimateFade'; import AnimateFade from '@/components/wrap/AnimateFade';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { ConstituentaID, CstType } from '@/models/rsform'; import { CstMatchMode } from '@/models/miscellaneous';
import { ConstituentaID, CstType, IConstituenta } from '@/models/rsform';
import { matchConstituenta } from '@/models/rsformAPI';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
import { convertToCSV } from '@/utils/utils'; import { convertToCSV } from '@/utils/utils';
@ -29,30 +30,43 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const controller = useRSEdit(); const controller = useRSEdit();
const [filtered, setFiltered] = useState<IConstituenta[]>(controller.schema?.items ?? []);
const [filterText, setFilterText] = useState('');
useLayoutEffect(() => { useLayoutEffect(() => {
if (!controller.schema || controller.selected.length === 0) { if (filtered.length === 0) {
setRowSelection({}); setRowSelection({});
} else { return;
const newRowSelection: RowSelectionState = {};
controller.schema.items.forEach((cst, index) => {
newRowSelection[String(index)] = controller.selected.includes(cst.id);
});
setRowSelection(newRowSelection);
} }
}, [controller.selected, controller.schema]); const newRowSelection: RowSelectionState = {};
filtered.forEach((cst, index) => {
newRowSelection[String(index)] = controller.selected.includes(cst.id);
});
setRowSelection(newRowSelection);
}, [filtered, setRowSelection, controller.selected]);
useLayoutEffect(() => {
if (!controller.schema || controller.schema.items.length === 0) {
setFiltered([]);
} else if (filterText) {
setFiltered(controller.schema.items.filter(cst => matchConstituenta(cst, filterText, CstMatchMode.ALL)));
} else {
setFiltered(controller.schema.items);
}
}, [filterText, controller.schema?.items, controller.schema]);
const handleDownloadCSV = useCallback(() => { const handleDownloadCSV = useCallback(() => {
if (!controller.schema || controller.schema.items.length === 0) { if (!controller.schema || filtered.length === 0) {
toast.error(information.noDataToExport); toast.error(information.noDataToExport);
return; return;
} }
const blob = convertToCSV(controller.schema.items); const blob = convertToCSV(filtered);
try { try {
fileDownload(blob, `${controller.schema.alias}.csv`, 'text/csv;charset=utf-8;'); fileDownload(blob, `${controller.schema.alias}.csv`, 'text/csv;charset=utf-8;');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}, [controller]); }, [filtered, controller]);
function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) { function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) {
if (!controller.schema) { if (!controller.schema) {
@ -60,12 +74,15 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
} else { } else {
const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater; const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newSelection: ConstituentaID[] = []; const newSelection: ConstituentaID[] = [];
controller.schema.items.forEach((cst, index) => { filtered.forEach((cst, index) => {
if (newRowSelection[String(index)] === true) { if (newRowSelection[String(index)] === true) {
newSelection.push(cst.id); newSelection.push(cst.id);
} }
}); });
controller.setSelected(newSelection); controller.setSelected(prev => [
...prev.filter(cst_id => !filtered.find(cst => cst.id === cst_id)),
...newSelection
]);
} }
} }
@ -127,21 +144,21 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
{controller.isContentEditable ? <ToolbarRSList /> : null} {controller.isContentEditable ? <ToolbarRSList /> : null}
<AnimateFade tabIndex={-1} onKeyDown={handleKeyDown}> <AnimateFade tabIndex={-1} onKeyDown={handleKeyDown}>
{controller.isContentEditable ? ( {controller.isContentEditable ? (
<SelectedCounter <div className='flex items-center border-b'>
totalCount={controller.schema?.stats?.count_all ?? 0} <div className='px-2'>
selectedCount={controller.selected.length} Выбор {controller.selected.length} из {controller.schema?.stats?.count_all ?? 0}
position='top-[0.3rem] left-2' </div>
/> <SearchBar
id='constituents_search'
noBorder
className='w-[8rem]'
value={filterText}
onChange={setFilterText}
/>
</div>
) : null} ) : null}
<div <Overlay position='top-[0.25rem] right-[1rem]' layer='z-navigation'>
className={clsx('border-b', {
'pt-[2.3rem]': controller.isContentEditable,
'relative top-[-1px]': !controller.isContentEditable
})}
/>
<Overlay position='top-[0.25rem] right-[1rem]' layer='z-tooltip'>
<MiniButton <MiniButton
title='Выгрузить в формате CSV' title='Выгрузить в формате CSV'
icon={<IconCSV size='1.25rem' className='icon-green' />} icon={<IconCSV size='1.25rem' className='icon-green' />}
@ -150,7 +167,7 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
</Overlay> </Overlay>
<TableRSList <TableRSList
items={controller.schema?.items} items={filtered}
maxHeight={tableHeight} maxHeight={tableHeight}
enableSelection={controller.isContentEditable} enableSelection={controller.isContentEditable}
selected={rowSelection} selected={rowSelection}

View File

@ -1,3 +1,4 @@
import { CstTypeIcon } from '@/components/DomainIcons';
import { import {
IconClone, IconClone,
IconDestroy, IconDestroy,
@ -16,7 +17,6 @@ import Overlay from '@/components/ui/Overlay';
import useDropdown from '@/hooks/useDropdown'; import useDropdown from '@/hooks/useDropdown';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
import { CstType } from '@/models/rsform'; import { CstType } from '@/models/rsform';
import { getCstTypePrefix } from '@/models/rsformAPI';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { getCstTypeShortcut, labelCstType, prepareTooltip } from '@/utils/labels'; import { getCstTypeShortcut, labelCstType, prepareTooltip } from '@/utils/labels';
@ -27,7 +27,10 @@ function ToolbarRSList() {
const insertMenu = useDropdown(); const insertMenu = useDropdown();
return ( return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='items-start cc-icons'> <Overlay
position='top-1 right-4 translate-x-0 md:right-1/2 md:translate-x-1/2'
className='cc-icons items-start outline-none transition-all duration-500'
>
{controller.schema && controller.schema?.oss.length > 0 ? ( {controller.schema && controller.schema?.oss.length > 0 ? (
<MiniSelectorOSS <MiniSelectorOSS
items={controller.schema.oss} items={controller.schema.oss}
@ -52,18 +55,6 @@ function ToolbarRSList() {
disabled={controller.isProcessing || controller.nothingSelected} disabled={controller.isProcessing || controller.nothingSelected}
onClick={controller.moveDown} onClick={controller.moveDown}
/> />
<MiniButton
titleHtml={prepareTooltip('Клонировать конституенту', 'Alt + V')}
icon={<IconClone size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing || controller.selected.length !== 1}
onClick={controller.cloneCst}
/>
<MiniButton
titleHtml={prepareTooltip('Добавить новую конституенту...', 'Alt + `')}
icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={() => controller.createCst(undefined, false)}
/>
<div ref={insertMenu.ref}> <div ref={insertMenu.ref}>
<MiniButton <MiniButton
title='Добавить пустую конституенту' title='Добавить пустую конституенту'
@ -72,17 +63,30 @@ function ToolbarRSList() {
disabled={controller.isProcessing} disabled={controller.isProcessing}
onClick={insertMenu.toggle} onClick={insertMenu.toggle}
/> />
<Dropdown isOpen={insertMenu.isOpen}> <Dropdown isOpen={insertMenu.isOpen} className='-translate-x-1/2 md:translate-x-0'>
{Object.values(CstType).map(typeStr => ( {Object.values(CstType).map(typeStr => (
<DropdownButton <DropdownButton
key={`${prefixes.csttype_list}${typeStr}`} key={`${prefixes.csttype_list}${typeStr}`}
text={`${getCstTypePrefix(typeStr as CstType)}1 — ${labelCstType(typeStr as CstType)}`} text={labelCstType(typeStr as CstType)}
icon={<CstTypeIcon value={typeStr as CstType} size='1.25rem' />}
onClick={() => controller.createCst(typeStr as CstType, true)} onClick={() => controller.createCst(typeStr as CstType, true)}
titleHtml={getCstTypeShortcut(typeStr as CstType)} titleHtml={getCstTypeShortcut(typeStr as CstType)}
/> />
))} ))}
</Dropdown> </Dropdown>
</div> </div>
<MiniButton
titleHtml={prepareTooltip('Добавить новую конституенту...', 'Alt + `')}
icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={() => controller.createCst(undefined, false)}
/>
<MiniButton
titleHtml={prepareTooltip('Клонировать конституенту', 'Alt + V')}
icon={<IconClone size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing || controller.selected.length !== 1}
onClick={controller.cloneCst}
/>
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранные', 'Delete')} titleHtml={prepareTooltip('Удалить выбранные', 'Delete')}
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}

View File

@ -267,7 +267,7 @@ function RSTabs() {
<TabLabel label='Граф термов' /> <TabLabel label='Граф термов' />
</TabList> </TabList>
<AnimateFade className='overflow-y-auto' style={{ maxHeight: panelHeight }}> <AnimateFade className='overflow-y-auto overflow-x-hidden' style={{ maxHeight: panelHeight }}>
{cardPanel} {cardPanel}
{listPanel} {listPanel}
{editorPanel} {editorPanel}