F: Add search feature to multiple cst selector

This commit is contained in:
Ivan 2024-08-25 13:45:32 +03:00
parent f6b52adacd
commit 584ce59f2d
9 changed files with 139 additions and 67 deletions

View File

@ -1,10 +1,18 @@
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { CstMatchMode, DependencyMode } from '@/models/miscellaneous';
import { ExpressionStatus } from '@/models/rsform';
import { CstType, ExpressionStatus } from '@/models/rsform';
import {
IconAlias,
IconBusiness,
IconCstAxiom,
IconCstBaseSet,
IconCstConstSet,
IconCstFunction,
IconCstPredicate,
IconCstStructured,
IconCstTerm,
IconCstTheorem,
IconFilter,
IconFormula,
IconGraphCollapse,
@ -132,3 +140,24 @@ export function StatusIcon({ value, size = '1.25rem', className }: DomIconProps<
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 { useConceptOptions } from '@/context/ConceptOptionsContext';
import { CstMatchMode } from '@/models/miscellaneous';
import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform';
import { isBasicConcept } from '@/models/rsformAPI';
import { isBasicConcept, matchConstituenta } from '@/models/rsformAPI';
import { describeConstituenta } from '@/utils/labels';
import BadgeConstituenta from '../info/BadgeConstituenta';
import NoData from '../ui/NoData';
import SearchBar from '../ui/SearchBar';
import ToolbarGraphSelection from './ToolbarGraphSelection';
interface PickMultiConstituentaProps {
@ -28,18 +30,30 @@ const columnHelper = createColumnHelper<IConstituenta>();
function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelected }: PickMultiConstituentaProps) {
const { colors } = useConceptOptions();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [filtered, setFiltered] = useState<IConstituenta[]>(schema?.items ?? []);
const [filterText, setFilterText] = useState('');
useLayoutEffect(() => {
if (!schema || selected.length === 0) {
if (filtered.length === 0) {
setRowSelection({});
} else {
const newRowSelection: RowSelectionState = {};
schema.items.forEach((cst, index) => {
newRowSelection[String(index)] = selected.includes(cst.id);
});
setRowSelection(newRowSelection);
return;
}
}, [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>) {
if (!schema) {
@ -47,12 +61,12 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
} else {
const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newSelection: ConstituentaID[] = [];
schema.items.forEach((cst, index) => {
filtered.forEach((cst, index) => {
if (newRowSelection[String(index)] === true) {
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 (
<div>
<div className='flex items-end gap-3 mb-3'>
<span className='w-[24ch] select-none whitespace-nowrap'>
<div className='flex justify-between items-center gap-3 clr-input px-3 border-x border-t rounded-t-md'>
<div className='w-[24ch] select-none whitespace-nowrap'>
Выбраны {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 ? (
<ToolbarGraphSelection
graph={schema.graph}
@ -86,7 +107,7 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited}
setSelected={setSelected}
emptySelection={selected.length === 0}
className='w-full ml-8'
className='w-fit'
/>
) : null}
</div>
@ -97,7 +118,7 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
rows={rows}
contentHeight='1.3rem'
className={clsx('cc-scroll-y', 'border', 'text-sm', 'select-none')}
data={schema?.items ?? []}
data={filtered}
columns={columns}
headPosition='0rem'
enableRowSelection

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { CstTypeIcon } from '@/components/DomainIcons';
import {
IconClone,
IconDestroy,
@ -16,7 +17,6 @@ import Overlay from '@/components/ui/Overlay';
import useDropdown from '@/hooks/useDropdown';
import { HelpTopic } from '@/models/miscellaneous';
import { CstType } from '@/models/rsform';
import { getCstTypePrefix } from '@/models/rsformAPI';
import { prefixes } from '@/utils/constants';
import { getCstTypeShortcut, labelCstType, prepareTooltip } from '@/utils/labels';
@ -27,7 +27,10 @@ function ToolbarRSList() {
const insertMenu = useDropdown();
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 ? (
<MiniSelectorOSS
items={controller.schema.oss}
@ -52,18 +55,6 @@ function ToolbarRSList() {
disabled={controller.isProcessing || controller.nothingSelected}
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}>
<MiniButton
title='Добавить пустую конституенту'
@ -72,17 +63,30 @@ function ToolbarRSList() {
disabled={controller.isProcessing}
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 => (
<DropdownButton
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)}
titleHtml={getCstTypeShortcut(typeStr as CstType)}
/>
))}
</Dropdown>
</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
titleHtml={prepareTooltip('Удалить выбранные', 'Delete')}
icon={<IconDestroy size='1.25rem' className='icon-red' />}

View File

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