Implement editorial levels

This commit is contained in:
IRBorisov 2024-05-27 20:42:34 +03:00
parent e0dcbd612c
commit 18beffb1d9
38 changed files with 864 additions and 324 deletions

View File

@ -5,7 +5,10 @@
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Веб-приложение для работы с концептуальными схемами" />
<meta
name="description"
content="Разработка концептуальных схем. Библиотека концептуальных схем и предметных моделей"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -1,105 +1,107 @@
// Search new icons at https://reactsvgicons.com/
// Note: save this file using Ctrl + K, Ctrl + Shift + S to disable autoformat
// ==== General actions =======
export { BiMenu as IconMenu } from 'react-icons/bi';
export { LuLogOut as IconLogout } from 'react-icons/lu';
export { FiSave as IconSave } from 'react-icons/fi';
export { BiCheck as IconAccept } from 'react-icons/bi';
export { BiX as IconRemove } from 'react-icons/bi';
export { BiTrash as IconDestroy } from 'react-icons/bi';
export { BiReset as IconReset } from 'react-icons/bi';
export { LiaEdit as IconEdit } from 'react-icons/lia';
export { FiEdit as IconEdit2 } from 'react-icons/fi';
export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } from 'react-icons/bi';
export { BiCog as IconSettings } from 'react-icons/bi';
export { BiShareAlt as IconShare } from 'react-icons/bi';
export { BiFilterAlt as IconFilter } from 'react-icons/bi';
export { BiDownArrowCircle as IconOpenList } from 'react-icons/bi';
export { LuAlertTriangle as IconAlert } from 'react-icons/lu';
export { BiMenu as IconMenu } from 'react-icons/bi';
export { LuLogOut as IconLogout } from 'react-icons/lu';
export { FiSave as IconSave } from 'react-icons/fi';
export { BiCheck as IconAccept } from 'react-icons/bi';
export { BiX as IconRemove } from 'react-icons/bi';
export { BiTrash as IconDestroy } from 'react-icons/bi';
export { BiReset as IconReset } from 'react-icons/bi';
export { LiaEdit as IconEdit } from 'react-icons/lia';
export { FiEdit as IconEdit2 } from 'react-icons/fi';
export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } from 'react-icons/bi';
export { BiCog as IconSettings } from 'react-icons/bi';
export { BiShareAlt as IconShare } from 'react-icons/bi';
export { BiFilterAlt as IconFilter } from 'react-icons/bi';
export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi';
export { LuAlertTriangle as IconAlert } from 'react-icons/lu';
// ===== UI elements =======
export { BiX as IconClose } from 'react-icons/bi';
export { LuChevronDown as IconDropArrow } from 'react-icons/lu';
export { LuChevronUp as IconDropArrowUp } from 'react-icons/lu';
export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } from 'react-icons/lu';
export { LuLightbulb as IconHelp } from 'react-icons/lu';
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
export { RiPushpinFill as IconPin } from 'react-icons/ri';
export { RiUnpinLine as IconUnpin } from 'react-icons/ri';
export { BiCaretDown as IconSortDesc } from 'react-icons/bi';
export { BiCaretUp as IconSortAsc } from 'react-icons/bi';
export { BiChevronLeft as IconPageLeft } from 'react-icons/bi';
export { BiChevronRight as IconPageRight } from 'react-icons/bi';
export { BiFirstPage as IconPageFirst } from 'react-icons/bi';
export { BiLastPage as IconPageLast } from 'react-icons/bi';
export { BiX as IconClose } from 'react-icons/bi';
export { LuChevronDown as IconDropArrow } from 'react-icons/lu';
export { LuChevronUp as IconDropArrowUp } from 'react-icons/lu';
export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } from 'react-icons/lu';
export { LuLightbulb as IconHelp } from 'react-icons/lu';
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
export { RiPushpinFill as IconPin } from 'react-icons/ri';
export { RiUnpinLine as IconUnpin } from 'react-icons/ri';
export { BiCaretDown as IconSortDesc } from 'react-icons/bi';
export { BiCaretUp as IconSortAsc } from 'react-icons/bi';
export { BiChevronLeft as IconPageLeft } from 'react-icons/bi';
export { BiChevronRight as IconPageRight } from 'react-icons/bi';
export { BiFirstPage as IconPageFirst } from 'react-icons/bi';
export { BiLastPage as IconPageLast } from 'react-icons/bi';
// ==== User status =======
export { LuUserCircle2 as IconUser } from 'react-icons/lu';
export { FaCircleUser as IconUser2 } from 'react-icons/fa6';
export { LuCrown as IconOwner } from 'react-icons/lu';
export { TbMeteor as IconAdmin } from 'react-icons/tb';
export { TbMeteorOff as IconAdminOff } from 'react-icons/tb';
export { LuGlasses as IconReader } from 'react-icons/lu';
export { LuUserCircle2 as IconUser } from 'react-icons/lu';
export { FaCircleUser as IconUser2 } from 'react-icons/fa6';
export { LuShovel as IconEditor } from 'react-icons/lu';
export { LuCrown as IconOwner } from 'react-icons/lu';
export { TbMeteor as IconAdmin } from 'react-icons/tb';
export { TbMeteorOff as IconAdminOff } from 'react-icons/tb';
export { LuGlasses as IconReader } from 'react-icons/lu';
// ===== Domain entities =======
export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { LuArchive as IconArchive } from 'react-icons/lu';
export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuImage as IconImage } from 'react-icons/lu';
export { TbColumns as IconList } from 'react-icons/tb';
export { TbColumnsOff as IconListOff } from 'react-icons/tb';
export { LuAtSign as IconTerm } from 'react-icons/lu';
export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb';
export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi';
export { RiTreeLine as IconTree } from 'react-icons/ri';
export { FaRegKeyboard as IconControls } from 'react-icons/fa6';
export { BiCheckShield as IconImmutable } from 'react-icons/bi';
export { RiOpenSourceLine as IconPublic } from 'react-icons/ri';
export { BiBug as IconStatusError } from 'react-icons/bi';
export { BiCheckCircle as IconStatusOK } from 'react-icons/bi';
export { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';
export { BiPauseCircle as IconStatusIncalculable } from 'react-icons/bi';
export { LuPower as IconKeepAliasOn } from 'react-icons/lu';
export { LuPowerOff as IconKeepAliasOff } from 'react-icons/lu';
export { LuFlag as IconKeepTermOn } from 'react-icons/lu';
export { LuFlagOff as IconKeepTermOff } from 'react-icons/lu';
export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { LuArchive as IconArchive } from 'react-icons/lu';
export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuImage as IconImage } from 'react-icons/lu';
export { TbColumns as IconList } from 'react-icons/tb';
export { TbColumnsOff as IconListOff } from 'react-icons/tb';
export { LuAtSign as IconTerm } from 'react-icons/lu';
export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb';
export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi';
export { RiTreeLine as IconTree } from 'react-icons/ri';
export { FaRegKeyboard as IconControls } from 'react-icons/fa6';
export { BiCheckShield as IconImmutable } from 'react-icons/bi';
export { RiOpenSourceLine as IconPublic } from 'react-icons/ri';
export { BiBug as IconStatusError } from 'react-icons/bi';
export { BiCheckCircle as IconStatusOK } from 'react-icons/bi';
export { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';
export { BiPauseCircle as IconStatusIncalculable } from 'react-icons/bi';
export { LuPower as IconKeepAliasOn } from 'react-icons/lu';
export { LuPowerOff as IconKeepAliasOff } from 'react-icons/lu';
export { LuFlag as IconKeepTermOn } from 'react-icons/lu';
export { LuFlagOff as IconKeepTermOff } from 'react-icons/lu';
// ===== Domain actions =====
export { BiUpvote as IconMoveUp } from 'react-icons/bi';
export { BiDownvote as IconMoveDown } from 'react-icons/bi';
export { BiRightArrow as IconMoveRight } from 'react-icons/bi';
export { BiLeftArrow as IconMoveLeft } from 'react-icons/bi';
export { FiBell as IconFollow } from 'react-icons/fi';
export { FiBellOff as IconFollowOff } from 'react-icons/fi';
export { FaSortAmountDownAlt as IconSortList } from 'react-icons/fa';
export { BiPlusCircle as IconNewItem } from 'react-icons/bi';
export { FaSquarePlus as IconNewItem2 } from 'react-icons/fa6';
export { BiDuplicate as IconClone } from 'react-icons/bi';
export { LuReplace as IconReplace } from 'react-icons/lu';
export { LuNetwork as IconGenerateStructure } from 'react-icons/lu';
export { LuBookCopy as IconInlineSynthesis } from 'react-icons/lu';
export { LuWand2 as IconGenerateNames } from 'react-icons/lu';
export { BiUpvote as IconMoveUp } from 'react-icons/bi';
export { BiDownvote as IconMoveDown } from 'react-icons/bi';
export { BiRightArrow as IconMoveRight } from 'react-icons/bi';
export { BiLeftArrow as IconMoveLeft } from 'react-icons/bi';
export { FiBell as IconFollow } from 'react-icons/fi';
export { FiBellOff as IconFollowOff } from 'react-icons/fi';
export { BiPlusCircle as IconNewItem } from 'react-icons/bi';
export { FaSquarePlus as IconNewItem2 } from 'react-icons/fa6';
export { BiDuplicate as IconClone } from 'react-icons/bi';
export { LuReplace as IconReplace } from 'react-icons/lu';
export { FaSortAmountDownAlt as IconSortList } from 'react-icons/fa';
export { LuNetwork as IconGenerateStructure } from 'react-icons/lu';
export { LuBookCopy as IconInlineSynthesis } from 'react-icons/lu';
export { LuWand2 as IconGenerateNames } from 'react-icons/lu';
// ======== Graph UI =======
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';
export { BiGitBranch as IconGraphInputs } from 'react-icons/bi';
export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi';
export { LuAtom as IconGraphCore } from 'react-icons/lu';
export { LuRotate3D as IconRotate3D } from 'react-icons/lu';
export { MdOutlineFitScreen as IconFitImage } from 'react-icons/md';
export { LuSparkles as IconClustering } from 'react-icons/lu';
export { LuSparkle as IconClusteringOff } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';
export { BiGitBranch as IconGraphInputs } from 'react-icons/bi';
export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi';
export { LuAtom as IconGraphCore } from 'react-icons/lu';
export { LuRotate3D as IconRotate3D } from 'react-icons/lu';
export { MdOutlineFitScreen as IconFitImage } from 'react-icons/md';
export { LuSparkles as IconClustering } from 'react-icons/lu';
export { LuSparkle as IconClusteringOff } from 'react-icons/lu';
// ===== Custom elements ======
interface IconSVGProps {

View File

@ -1,29 +0,0 @@
import { useIntl } from 'react-intl';
import { useUsers } from '@/context/UsersContext';
import { ILibraryItemEx } from '@/models/library';
import LabeledValue from '../ui/LabeledValue';
interface InfoLibraryItemProps {
item?: ILibraryItemEx;
}
function InfoLibraryItem({ item }: InfoLibraryItemProps) {
const { getUserLabel } = useUsers();
const intl = useIntl();
return (
<div className='flex flex-col gap-1'>
<LabeledValue label='Владелец' text={getUserLabel(item?.owner ?? null)} />
<LabeledValue label='Редакторы' text={item?.editors.length ?? 0} />
<LabeledValue label='Отслеживают' text={item?.subscribers.length ?? 0} />
<LabeledValue
label='Дата обновления'
text={item ? new Date(item?.time_update).toLocaleString(intl.locale) : ''}
/>
<LabeledValue label='Дата создания' text={item ? new Date(item?.time_create).toLocaleString(intl.locale) : ''} />
</div>
);
}
export default InfoLibraryItem;

View File

@ -0,0 +1,25 @@
import clsx from 'clsx';
import { useUsers } from '@/context/UsersContext';
import { UserID } from '@/models/user';
import { CProps } from '../props';
interface InfoUsersProps extends CProps.Styling {
items: UserID[];
prefix: string;
}
function InfoUsers({ items, className, prefix, ...restProps }: InfoUsersProps) {
const { getUserLabel } = useUsers();
return (
<div className={clsx('flex flex-col dense', className)} {...restProps}>
{items.map((user, index) => (
<div key={`${prefix}${index}`}>{getUserLabel(user)}</div>
))}
</div>
);
}
export default InfoUsers;

View File

@ -15,9 +15,17 @@ interface SelectConstituentaProps extends CProps.Styling {
items?: IConstituenta[];
value?: IConstituenta;
onSelectValue: (newValue?: IConstituenta) => void;
placeholder?: string;
}
function SelectConstituenta({ className, items, value, onSelectValue, ...restProps }: SelectConstituentaProps) {
function SelectConstituenta({
className,
items,
value,
onSelectValue,
placeholder = 'Выберите конституенту',
...restProps
}: SelectConstituentaProps) {
const options = useMemo(() => {
return (
items?.map(cst => ({
@ -39,10 +47,11 @@ function SelectConstituenta({ className, items, value, onSelectValue, ...restPro
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={{ value: value?.id, label: value ? `${value.alias}: ${describeConstituentaTerm(value)}` : '' }}
value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : undefined}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
placeholder={placeholder}
{...restProps}
/>
);

View File

@ -5,14 +5,17 @@ import { Grammeme } from '@/models/language';
import { getCompatibleGrams } from '@/models/languageAPI';
import { compareGrammemeOptions, IGrammemeOption, SelectorGrammemes } from '@/utils/selectors';
interface SelectGrammemeProps extends Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'> {
import { CProps } from '../props';
interface SelectMultiGrammemeProps
extends Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'>,
CProps.Styling {
value: IGrammemeOption[];
setValue: React.Dispatch<React.SetStateAction<IGrammemeOption[]>>;
className?: string;
placeholder?: string;
}
function SelectGrammeme({ value, setValue, ...restProps }: SelectGrammemeProps) {
function SelectMultiGrammeme({ value, setValue, ...restProps }: SelectMultiGrammemeProps) {
const [options, setOptions] = useState<IGrammemeOption[]>([]);
useEffect(() => {
@ -32,4 +35,4 @@ function SelectGrammeme({ value, setValue, ...restProps }: SelectGrammemeProps)
);
}
export default SelectGrammeme;
export default SelectMultiGrammeme;

View File

@ -0,0 +1,62 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { useUsers } from '@/context/UsersContext';
import { IUserInfo, UserID } from '@/models/user';
import { matchUser } from '@/models/userAPI';
import { CProps } from '../props';
import SelectSingle from '../ui/SelectSingle';
interface SelectUserProps extends CProps.Styling {
items?: IUserInfo[];
value?: UserID;
placeholder?: string;
onSelectValue: (newValue: UserID) => void;
}
function SelectUser({
className,
items,
value,
onSelectValue,
placeholder = 'Выберите пользователя',
...restProps
}: SelectUserProps) {
const { getUserLabel } = useUsers();
const options = useMemo(() => {
return (
items?.map(user => ({
value: user.id,
label: getUserLabel(user.id)
})) ?? []
);
}, [items, getUserLabel]);
const filter = useCallback(
(option: { value: UserID | undefined; label: string }, inputValue: string) => {
const user = items?.find(item => item.id === option.value);
return !user ? false : matchUser(user, inputValue);
},
[items]
);
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value, label: getUserLabel(value) } : undefined}
onChange={data => {
if (data !== null && data.value !== undefined) onSelectValue(data.value);
}}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
placeholder={placeholder}
{...restProps}
/>
);
}
export default SelectUser;

View File

@ -3,7 +3,7 @@
import clsx from 'clsx';
import { useMemo } from 'react';
import { IVersionInfo } from '@/models/library';
import { IVersionInfo, VersionID } from '@/models/library';
import { labelVersion } from '@/utils/labels';
import { CProps } from '../props';
@ -12,8 +12,8 @@ import SelectSingle from '../ui/SelectSingle';
interface SelectVersionProps extends CProps.Styling {
id?: string;
items?: IVersionInfo[];
value?: number;
onSelectValue: (newValue?: number) => void;
value?: VersionID;
onSelectValue: (newValue?: VersionID) => void;
}
function SelectVersion({ id, className, items, value, onSelectValue, ...restProps }: SelectVersionProps) {

View File

@ -1,17 +1,24 @@
import clsx from 'clsx';
interface DividerProps {
import { CProps } from '@/components/props';
interface DividerProps extends CProps.Styling {
vertical?: boolean;
margins?: string;
}
function Divider({ vertical, margins = 'mx-2' }: DividerProps) {
function Divider({ vertical, margins = 'mx-2', className, ...restProps }: DividerProps) {
return (
<div
className={clsx(margins, {
'border-x': vertical,
'border-y': !vertical
})}
className={clsx(
margins, //prettier: split-lines
className,
{
'border-x': vertical,
'border-y': !vertical
}
)}
{...restProps}
/>
);
}

View File

@ -1,13 +1,17 @@
interface LabeledValueProps {
import clsx from 'clsx';
import { CProps } from '../props';
interface LabeledValueProps extends CProps.Styling {
id?: string;
label: string;
text: string | number;
title?: string;
}
function LabeledValue({ id, label, text, title }: LabeledValueProps) {
function LabeledValue({ id, label, text, title, className, ...restProps }: LabeledValueProps) {
return (
<div className='flex justify-between gap-3'>
<div className={clsx('flex justify-between gap-3', className)} {...restProps}>
<span title={title}>{label}</span>
<span id={id}>{text}</span>
</div>

View File

@ -2,11 +2,11 @@
import { createContext, useContext, useState } from 'react';
import { UserAccessMode } from '@/models/miscellaneous';
import { UserLevel } from '@/models/user';
interface IAccessModeContext {
mode: UserAccessMode;
setMode: React.Dispatch<React.SetStateAction<UserAccessMode>>;
accessLevel: UserLevel;
setAccessLevel: React.Dispatch<React.SetStateAction<UserLevel>>;
}
const AccessContext = createContext<IAccessModeContext | null>(null);
@ -23,7 +23,7 @@ interface AccessModeStateProps {
}
export const AccessModeState = ({ children }: AccessModeStateProps) => {
const [mode, setMode] = useState<UserAccessMode>(UserAccessMode.READER);
const [accessLevel, setAccessLevel] = useState<UserLevel>(UserLevel.READER);
return <AccessContext.Provider value={{ mode, setMode }}>{children}</AccessContext.Provider>;
return <AccessContext.Provider value={{ accessLevel, setAccessLevel }}>{children}</AccessContext.Provider>;
};

View File

@ -9,6 +9,7 @@ import {
getTRSFile,
patchConstituenta,
patchDeleteConstituenta,
patchEditorsSet as patchSetEditors,
patchInlineSynthesis,
patchLibraryItem,
patchMoveConstituenta,
@ -17,6 +18,7 @@ import {
patchResetAliases,
patchRestoreOrder,
patchRestoreVersion,
patchSetOwner,
patchSubstituteConstituents,
patchUploadTRS,
patchVersion,
@ -26,7 +28,7 @@ import {
} from '@/app/backendAPI';
import { type ErrorData } from '@/components/info/InfoError';
import useRSFormDetails from '@/hooks/useRSFormDetails';
import { ILibraryItem, IVersionData } from '@/models/library';
import { ILibraryItem, IVersionData, VersionID } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library';
import {
ConstituentaID,
@ -43,6 +45,7 @@ import {
IRSFormUploadData,
ITargetCst
} from '@/models/rsform';
import { UserID } from '@/models/user';
import { useAuth } from './AuthContext';
import { useLibrary } from './LibraryContext';
@ -62,11 +65,14 @@ interface IRSFormContext {
isSubscribed: boolean;
update: (data: ILibraryUpdateData, callback?: DataCallback<ILibraryItem>) => void;
subscribe: (callback?: () => void) => void;
unsubscribe: (callback?: () => void) => void;
download: (callback: DataCallback<Blob>) => void;
upload: (data: IRSFormUploadData, callback: () => void) => void;
subscribe: (callback?: () => void) => void;
unsubscribe: (callback?: () => void) => void;
setOwner: (newOwner: UserID, callback?: () => void) => void;
setEditors: (newEditors: UserID[], callback?: () => void) => void;
resetAliases: (callback: () => void) => void;
restoreOrder: (callback: () => void) => void;
produceStructure: (data: ITargetCst, callback?: DataCallback<ConstituentaID[]>) => void;
@ -79,9 +85,9 @@ interface IRSFormContext {
cstDelete: (data: IConstituentaList, callback?: () => void) => void;
cstMoveTo: (data: ICstMovetoData, callback?: () => void) => void;
versionCreate: (data: IVersionData, callback?: (version: number) => void) => void;
versionUpdate: (target: number, data: IVersionData, callback?: () => void) => void;
versionDelete: (target: number, callback?: () => void) => void;
versionCreate: (data: IVersionData, callback?: (version: VersionID) => void) => void;
versionUpdate: (target: VersionID, data: IVersionData, callback?: () => void) => void;
versionDelete: (target: VersionID, callback?: () => void) => void;
versionRestore: (target: string, callback?: () => void) => void;
}
@ -228,6 +234,50 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
[schemaID, setError, schema, user]
);
const setOwner = useCallback(
(newOwner: UserID, callback?: () => void) => {
if (!schema) {
return;
}
setError(undefined);
patchSetOwner(schemaID, {
data: {
user: newOwner
},
showError: true,
setLoading: setProcessing,
onError: setError,
onSuccess: () => {
schema.owner = newOwner;
if (callback) callback();
}
});
},
[schemaID, setError, schema]
);
const setEditors = useCallback(
(newEditors: UserID[], callback?: () => void) => {
if (!schema) {
return;
}
setError(undefined);
patchSetEditors(schemaID, {
data: {
users: newEditors
},
showError: true,
setLoading: setProcessing,
onError: setError,
onSuccess: () => {
schema.editors = newEditors;
if (callback) callback();
}
});
},
[schemaID, setError, schema]
);
const resetAliases = useCallback(
(callback?: () => void) => {
if (!schema || !user) {
@ -522,8 +572,12 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
resetAliases,
produceStructure,
inlineSynthesis,
subscribe,
unsubscribe,
setOwner,
setEditors,
cstUpdate,
cstCreate,
cstRename,

View File

@ -1,10 +1,10 @@
import clsx from 'clsx';
import { IRSForm } from '@/models/rsform';
import { ConstituentaID, IRSForm } from '@/models/rsform';
import { labelConstituenta } from '@/utils/labels';
interface ConstituentsListProps {
list: number[];
list: ConstituentaID[];
schema: IRSForm;
prefix: string;
title?: string;

View File

@ -5,20 +5,23 @@ import { useMemo, useState } from 'react';
import Checkbox from '@/components/ui/Checkbox';
import Modal, { ModalProps } from '@/components/ui/Modal';
import { IRSForm } from '@/models/rsform';
import { ConstituentaID, IRSForm } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
import ConstituentsList from './ConstituentsList';
interface DlgDeleteCstProps extends Pick<ModalProps, 'hideWindow'> {
selected: number[];
onDelete: (items: number[]) => void;
selected: ConstituentaID[];
onDelete: (items: ConstituentaID[]) => void;
schema: IRSForm;
}
function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstProps) {
const [expandOut, setExpandOut] = useState(false);
const expansion: number[] = useMemo(() => schema.graph.expandAllOutputs(selected), [selected, schema.graph]);
const expansion: ConstituentaID[] = useMemo(
() => schema.graph.expandAllOutputs(selected), // prettier: split-lines
[selected, schema.graph]
);
function handleSubmit() {
hideWindow();

View File

@ -0,0 +1,71 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { IconRemove } from '@/components/Icons';
import SelectUser from '@/components/select/SelectUser';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal';
import { useUsers } from '@/context/UsersContext';
import { UserID } from '@/models/user';
import UsersTable from './UsersTable';
interface DlgEditEditorsProps {
editors: UserID[];
setEditors: (newValue: UserID[]) => void;
hideWindow: () => void;
}
function DlgEditEditors({ hideWindow, editors, setEditors }: DlgEditEditorsProps) {
const [selected, setSelected] = useState<UserID[]>(editors);
const { users } = useUsers();
const filtered = useMemo(() => users.filter(user => !selected.includes(user.id)), [users, selected]);
function handleSubmit() {
setEditors(selected);
}
const onDeleteEditor = useCallback((target: UserID) => setSelected(prev => prev.filter(id => id !== target)), []);
const onAddEditor = useCallback((target: UserID) => setSelected(prev => [...prev, target]), []);
const usersTable = useMemo(
() => <UsersTable items={users.filter(user => selected.includes(user.id))} onDelete={onDeleteEditor} />,
[users, selected, onDeleteEditor]
);
return (
<Modal
canSubmit
header='Список редакторов'
submitText='Сохранить список'
hideWindow={hideWindow}
className='flex flex-col w-[35rem] px-6 gap-3 pb-6'
onSubmit={handleSubmit}
>
<div className={clsx('flex self-center items-center', 'text-sm font-semibold')}>
<span>Всего редакторов [{selected.length}]</span>
<MiniButton
noHover
title='Очистить список'
className='py-0'
icon={<IconRemove size='1.5rem' className='icon-red' />}
disabled={selected.length === 0}
onClick={() => setSelected([])}
/>
</div>
{usersTable}
<div className='flex items-center gap-3'>
<Label text='Добавить' />
<SelectUser items={filtered} value={undefined} onSelectValue={onAddEditor} className='w-[25rem]' />
</div>
</Modal>
);
}
export default DlgEditEditors;

View File

@ -0,0 +1,64 @@
'use client';
import { useMemo } from 'react';
import { IconRemove } from '@/components/Icons';
import DataTable, { createColumnHelper } from '@/components/ui/DataTable';
import MiniButton from '@/components/ui/MiniButton';
import { IUserInfo, UserID } from '@/models/user';
interface UsersTableProps {
items: IUserInfo[];
onDelete: (target: UserID) => void;
}
const columnHelper = createColumnHelper<IUserInfo>();
function UsersTable({ items, onDelete }: UsersTableProps) {
const columns = useMemo(
() => [
columnHelper.accessor('first_name', {
id: 'first_name',
size: 400,
header: 'Имя'
}),
columnHelper.accessor('last_name', {
id: 'last_name',
size: 400,
header: 'Фамилия'
}),
columnHelper.display({
id: 'actions',
size: 50,
minSize: 50,
maxSize: 50,
cell: props => (
<div className='h-[1.25rem] w-[1.25rem]'>
<MiniButton
title='Удалить из списка'
noHover
noPadding
icon={<IconRemove size='1.25rem' className='icon-red' />}
onClick={() => onDelete(props.row.original.id)}
/>
</div>
)
})
],
[onDelete]
);
return (
<DataTable
dense
noFooter
headPosition='0'
className='mb-2 border cc-scroll-y'
rows={6}
data={items}
columns={columns}
/>
);
}
export default UsersTable;

View File

@ -0,0 +1 @@
export { default } from './DlgEditEditors';

View File

@ -3,7 +3,7 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import PickConstituenta from '@/components/select/PickConstituenta';
import SelectGrammeme from '@/components/select/SelectGrammeme';
import SelectMultiGrammeme from '@/components/select/SelectMultiGrammeme';
import Label from '@/components/ui/Label';
import TextInput from '@/components/ui/TextInput';
import AnimateFade from '@/components/wrap/AnimateFade';
@ -99,7 +99,7 @@ function EntityTab({ initial, schema, setIsValid, setReference }: EntityTabProps
<div className='flex items-center gap-4'>
<Label text='Словоформа' />
<SelectGrammeme
<SelectMultiGrammeme
id='dlg_reference_grammemes'
placeholder='Выберите граммемы'
className='flex-grow'

View File

@ -8,15 +8,15 @@ import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useRSForm } from '@/context/RSFormContext';
import { IVersionData, IVersionInfo } from '@/models/library';
import { IVersionData, IVersionInfo, VersionID } from '@/models/library';
import VersionsTable from './VersionsTable';
interface DlgEditVersionsProps {
hideWindow: () => void;
versions: IVersionInfo[];
onDelete: (versionID: number) => void;
onUpdate: (versionID: number, data: IVersionData) => void;
onDelete: (versionID: VersionID) => void;
onUpdate: (versionID: VersionID, data: IVersionData) => void;
}
function DlgEditVersions({ hideWindow, versions, onDelete, onUpdate }: DlgEditVersionsProps) {

View File

@ -8,14 +8,14 @@ import { IconRemove } from '@/components/Icons';
import DataTable, { createColumnHelper, IConditionalStyle } from '@/components/ui/DataTable';
import MiniButton from '@/components/ui/MiniButton';
import { useConceptOptions } from '@/context/OptionsContext';
import { IVersionInfo } from '@/models/library';
import { IVersionInfo, VersionID } from '@/models/library';
interface VersionsTableProps {
processing: boolean;
items: IVersionInfo[];
selected?: number;
onDelete: (versionID: number) => void;
onSelect: (versionID: number) => void;
selected?: VersionID;
onDelete: (versionID: VersionID) => void;
onSelect: (versionID: VersionID) => void;
}
const columnHelper = createColumnHelper<IVersionInfo>();

View File

@ -5,7 +5,7 @@ import { useLayoutEffect, useState } from 'react';
import { IconAccept, IconMoveDown, IconMoveLeft, IconMoveRight, IconRemove } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import SelectGrammeme from '@/components/select/SelectGrammeme';
import SelectMultiGrammeme from '@/components/select/SelectMultiGrammeme';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal';
@ -170,7 +170,7 @@ function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps)
onClick={handleInflect}
/>
</div>
<SelectGrammeme
<SelectMultiGrammeme
placeholder='Выберите граммемы'
className='w-[15rem]'
value={inputGrams}

View File

@ -2,9 +2,9 @@
import { useCallback, useState } from 'react';
import { DataCallback, postGenerateLexeme, postInflectText, postParseText } from '@/app/backendAPI';
import { ErrorData } from '@/components/info/InfoError';
import { ILexemeData, ITextRequest, ITextResult, IWordFormPlain } from '@/models/language';
import { DataCallback, postGenerateLexeme, postInflectText, postParseText } from '@/app/backendAPI';
function useConceptText() {
const [loading, setLoading] = useState(false);

View File

@ -2,6 +2,8 @@
* Module: Models for LibraryItem.
*/
import { UserID } from './user';
/**
* Represents type of library items.
*/
@ -10,21 +12,26 @@ export enum LibraryItemType {
OPERATIONS_SCHEMA = 'oss'
}
/**
* Represents {@link LibraryItem} identifier type.
*/
export type LibraryItemID = number;
/**
* Represents {@link Version} identifier type.
*/
export type VersionID = number;
/**
* Represents library item version information.
*/
export interface IVersionInfo {
id: number;
id: VersionID;
version: string;
description: string;
time_create: string;
}
/**
* Represents {@link LibraryItem} identifier type.
*/
export type LibraryItemID = number;
/**
* Represents user data, intended to create or update version metadata in persistent storage.
*/
@ -43,16 +50,16 @@ export interface ILibraryItem {
is_canonical: boolean;
time_create: string;
time_update: string;
owner: number | null;
owner: UserID | null;
}
/**
* Represents library item extended data.
*/
export interface ILibraryItemEx extends ILibraryItem {
subscribers: number[];
editors: number[];
version?: number;
subscribers: UserID[];
editors: UserID[];
version?: VersionID;
versions: IVersionInfo[];
}

View File

@ -2,15 +2,6 @@
* Module: Miscellaneous frontend model types. Future targets for refactoring aimed at extracting modules.
*/
/**
* Represents user access mode.
*/
export enum UserAccessMode {
READER = 0,
OWNER,
ADMIN
}
/**
* Represents graph dependency mode.
*/

View File

@ -2,12 +2,17 @@
* Module: Models for Users.
*/
/**
* Represents {@link User} identifier type.
*/
export type UserID = number;
/**
* Represents user detailed information.
* Some information should only be accessible to authorized users
*/
export interface IUser {
id: number;
id: UserID;
username: string;
is_staff: boolean;
email: string;
@ -19,7 +24,7 @@ export interface IUser {
* Represents CurrentUser information.
*/
export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> {
subscriptions: number[];
subscriptions: UserID[];
}
/**
@ -82,12 +87,22 @@ export interface IUserUpdatePassword {
* Represents target {@link User}.
*/
export interface ITargetUser {
user: number;
user: UserID;
}
/**
* Represents target multiple {@link User}.
*/
export interface ITargetUsers {
users: number[];
users: UserID[];
}
/**
* Represents user access mode.
*/
export enum UserLevel {
READER = 0,
EDITOR,
OWNER,
ADMIN
}

View File

@ -0,0 +1,19 @@
/**
* Module: API for formal representation for Users.
*/
import { TextMatcher } from '@/utils/utils';
import { IUserInfo } from './user';
/**
* Checks if a given target {@link IConstituenta} matches the specified query using the provided matching mode.
*
* @param target - The target object to be matched.
* @param query - The query string used for matching.
* @param mode - The matching mode to determine which properties to include in the matching process.
*/
export function matchUser(target: IUserInfo, query: string): boolean {
const matcher = new TextMatcher(query);
return matcher.test(target.last_name) || matcher.test(target.first_name);
}

View File

@ -4,41 +4,61 @@ import {
IconClone,
IconDestroy,
IconDownload,
IconEditor,
IconFollow,
IconImmutable,
IconList,
IconNewItem,
IconOwner,
IconPublic,
IconSave,
IconUpload
IconSave
} from '../../../components/Icons';
import LinkTopic from '../../../components/ui/LinkTopic';
function HelpRSFormCard() {
// prettier-ignore
return (
<div className='dense'>
<h1>Карточка схемы</h1>
<p>Карточка содержит общую информацию и статистику</p>
<p>Карточка позволяет управлять атрибутами схемы и <LinkTopic text='версиями' topic={HelpTopic.VERSIONS}/></p>
<div className='dense'>
<h1>Карточка схемы</h1>
<h2>Управление</h2>
<li><IconSave className='inline-icon'/> сохранить изменения: Ctrl + S</li>
<li><IconOwner className='inline-icon'/> Владелец обладает правом редактирования</li>
<li><IconPublic className='inline-icon'/> Общедоступные схемы доступны для всех</li>
<li><IconImmutable className='inline-icon'/> Неизменные схемы редактируют только администраторы</li>
<li><IconClone className='inline-icon icon-green'/> Клонировать создать копию схемы</li>
<li><IconFollow className='inline-icon'/> Отслеживание схема в персональном списке</li>
<li><IconDownload className='inline-icon'/> Загрузить/Выгрузить взаимодействие с Экстеор</li>
<li><IconDestroy className='inline-icon icon-red'/> Удалить полностью удаляет схему из базы Портала</li>
<p>Карточка содержит общую информацию и статистику</p>
<p>
Карточка позволяет управлять атрибутами схемы и <LinkTopic text='версиями' topic={HelpTopic.VERSIONS} />
</p>
<p>
Карточка позволяет назначать <IconEditor className='inline-icon' /> Редакторов
</p>
<p>
Карточка позволяет изменить <IconOwner className='inline-icon icon-green' /> Владельца
</p>
<h2>Версионирование</h2>
<li><IconNewItem className='inline-icon icon-green'/> Создать версию можно только из актуальной схемы</li>
<li><IconUpload className='inline-icon icon-red'/> Загрузить версию в актуальную схему</li>
<li><IconList className='inline-icon'/> Редактировать атрибуты версий</li>
</div>);
<h2>Управление</h2>
<li>
<IconSave className='inline-icon' /> сохранить изменения: Ctrl + S
</li>
<li>
<IconEditor className='inline-icon' /> Редактор обладает правом редактирования
</li>
<li>
<IconOwner className='inline-icon' /> Владелец обладает полным доступом к схеме
</li>
<li>
<IconPublic className='inline-icon' /> Общедоступные схемы видны всем посетителям
</li>
<li>
<IconImmutable className='inline-icon' /> Неизменные схемы редактируют только администраторы
</li>
<li>
<IconClone className='inline-icon icon-green' /> Клонировать создать копию схемы
</li>
<li>
<IconFollow className='inline-icon' /> Отслеживание схема в персональном списке
</li>
<li>
<IconDownload className='inline-icon' /> Загрузить/Выгрузить взаимодействие с Экстеор
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> Удалить полностью удаляет схему из базы Портала
</li>
</div>
);
}
export default HelpRSFormCard;

View File

@ -6,6 +6,7 @@ import {
IconDestroy,
IconDownload,
IconEdit2,
IconEditor,
IconMenu,
IconOwner,
IconReader,
@ -75,17 +76,22 @@ function HelpRSFormMenu() {
<IconArchive size='1.25rem' className='inline-icon' /> просмотр архивной версии. Переход к актуальной версии
</li>
<li>
<IconReader size='1.25rem' className='inline-icon' /> режим "только чтение"
<IconReader size='1.25rem' className='inline-icon' /> режим Читатель
</li>
<li>
<IconOwner size='1.25rem' className='inline-icon' /> режим "редактор"
<IconEditor size='1.25rem' className='inline-icon' /> режим Редактор
</li>
<li>
<IconAdmin size='1.25rem' className='inline-icon' /> режим "администратор"
<IconOwner size='1.25rem' className='inline-icon' /> режим Владелец
</li>
<li>
<IconAdmin size='1.25rem' className='inline-icon' /> режим Администратор
</li>
</div>
</div>
<p>Нижестоящие в списке режимы работы включают все права и доступные функции вышестоящих</p>
<p>
<IconEdit2 size='1.25rem' className='inline-icon icon-green' /> операции над концептуальной схемой описаны в{' '}
<LinkTopic text='разделе Экспликация' topic={HelpTopic.RSL_OPERATIONS} />.

View File

@ -24,56 +24,90 @@ import {
function HelpTermGraph() {
const { colors } = useConceptOptions();
// prettier-ignore
return (
<div className='flex flex-col'>
<div className='flex'>
<div className='dense w-[14rem]'>
<h1>Настройка графа</h1>
<li>Цвет покраска узлов</li>
<li>Граф расположение</li>
<li>Размер размер узлов</li>
<li><IconText className='inline-icon'/> Отображение текста</li>
<li><IconClustering className='inline-icon'/> Скрыть порожденные</li>
<li><IconRotate3D className='inline-icon'/> Вращение 3D</li>
<div className='flex flex-col'>
<div className='flex'>
<div className='dense w-[14rem]'>
<h1>Настройка графа</h1>
<li>Цвет покраска узлов</li>
<li>Граф расположение</li>
<li>Размер размер узлов</li>
<li>
<IconText className='inline-icon' /> Отображение текста
</li>
<li>
<IconClustering className='inline-icon' /> Скрыть порожденные
</li>
<li>
<IconRotate3D className='inline-icon' /> Вращение 3D
</li>
</div>
<Divider vertical margins='mx-3 mt-3' />
<div className='dense w-[21rem]'>
<h1>Изменение узлов</h1>
<li>Клик на конституенту выделение</li>
<li>
Ctrl + клик выбор <span style={{ color: colors.fgPurple }}>фокус-конституенты</span>
</li>
<li>
<IconReset className='inline-icon' /> Esc сбросить выделение
</li>
<li>
<IconEdit className='inline-icon' /> Двойной клик редактирование
</li>
<li>
<IconDestroy className='inline-icon' /> Delete удалить выбранные
</li>
<li>
<IconNewItem className='inline-icon' /> Новая со ссылками на выделенные
</li>
</div>
</div>
<Divider vertical margins='mx-3 mt-3' />
<div className='dense w-[21rem]'>
<h1>Изменение узлов</h1>
<li>Клик на конституенту выделение</li>
<li>Ctrl + клик выбор <span style={{ color: colors.fgPurple }}>фокус-конституенты</span></li>
<li><IconReset className='inline-icon'/> Esc сбросить выделение</li>
<li><IconEdit className='inline-icon'/> Двойной клик редактирование</li>
<li><IconDestroy className='inline-icon'/> Delete удалить выбранные</li>
<li><IconNewItem className='inline-icon'/> Новая со ссылками на выделенные</li>
<Divider margins='my-3' />
<div className='flex mb-3'>
<div className='dense w-[14rem]'>
<h1>Общие</h1>
<li>
<IconFilter className='inline-icon' /> Открыть настройки
</li>
<li>
<IconFitImage className='inline-icon' /> Вписать граф в экран
</li>
<li>
<IconImage className='inline-icon' /> Сохранить в формат PNG
</li>
</div>
<Divider vertical margins='mx-3' />
<div className='dense w-[21rem]'>
<h1>Выделение</h1>
<li>
<IconGraphCollapse className='inline-icon' /> все влияющие
</li>
<li>
<IconGraphExpand className='inline-icon' /> все зависимые
</li>
<li>
<IconGraphMaximize className='inline-icon' /> зависимые только от выделенных
</li>
<li>
<IconGraphInputs className='inline-icon' /> входящие напрямую
</li>
<li>
<IconGraphOutputs className='inline-icon' /> исходящие напрямую
</li>
<li>
<IconGraphCore className='inline-icon' /> выделить <LinkTopic text='Ядро' topic={HelpTopic.CC_SYSTEM} />
</li>
</div>
</div>
</div>
<Divider margins='my-3' />
<div className='flex mb-3'>
<div className='dense w-[14rem]'>
<h1>Общие</h1>
<li><IconFilter className='inline-icon'/> Открыть настройки</li>
<li><IconFitImage className='inline-icon'/> Вписать граф в экран</li>
<li><IconImage className='inline-icon'/> Сохранить в формат PNG</li>
</div>
<Divider vertical margins='mx-3' />
<div className='dense w-[21rem]'>
<h1>Выделение</h1>
<li><IconGraphCollapse className='inline-icon'/> все влияющие</li>
<li><IconGraphExpand className='inline-icon'/> все зависимые</li>
<li><IconGraphMaximize className='inline-icon'/> зависимые только от выделенных</li>
<li><IconGraphInputs className='inline-icon'/> входящие напрямую</li>
<li><IconGraphOutputs className='inline-icon'/> исходящие напрямую</li>
<li><IconGraphCore className='inline-icon'/> выделить <LinkTopic text='Ядро' topic={HelpTopic.CC_SYSTEM} /></li>
</div>
</div>
</div>);
);
}
export default HelpTermGraph;

View File

@ -1,19 +1,30 @@
import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
import { IconEditor, IconList, IconNewItem, IconShare, IconUpload } from '@/components/Icons';
function HelpVersions() {
return (
<div className='text-justify'>
<div className=''>
<h1>Версионирование схем</h1>
<p>
Версионирование позволяет сохранить текущее состояние схемы под определенным именем (версией) и использовать
ссылку на него для совместной работы. После создания версии ее содержание изменить нельзя
Версионирование доступно <IconEditor size='1rem' className='inline-icon' /> Редакторам.
</p>
<li>Владелец обладает правом редактирования названий и создания новых версий</li>
<p>Версионирование сохраняет текущее состояние схемы под определенным именем (версией) с доступом по ссылке.</p>
<p>После создания версии ее содержание изменить нельзя.</p>
<h2>Действия</h2>
<li>
Управление версиями происходит в <LinkTopic text='Карточке схемы' topic={HelpTopic.UI_RS_CARD} />
<IconShare size='1.25rem' className='inline-icon' /> Поделиться включает версию в ссылку
</li>
<li>
<IconUpload size='1.25rem' className='inline-icon icon-red' /> Загрузить версию в актуальную схему
</li>
<li>
<IconNewItem size='1.25rem' className='inline-icon icon-green' /> Создать версию можно только из актуальной
схемы
</li>
<li>
<IconList size='1.25rem' className='inline-icon' /> Редактировать атрибуты версий
</li>
<li>Функция Поделиться включает версию в ссылку</li>
</div>
);
}

View File

@ -0,0 +1,102 @@
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import { IconEdit } from '@/components/Icons';
import InfoUsers from '@/components/info/InfoUsers';
import SelectUser from '@/components/select/SelectUser';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import Tooltip from '@/components/ui/Tooltip';
import { useAccessMode } from '@/context/AccessModeContext';
import { useUsers } from '@/context/UsersContext';
import useDropdown from '@/hooks/useDropdown';
import { ILibraryItemEx } from '@/models/library';
import { UserID, UserLevel } from '@/models/user';
import { prefixes } from '@/utils/constants';
import LabeledValue from '../../../components/ui/LabeledValue';
import { useRSEdit } from '../RSEditContext';
interface EditorLibraryItemProps {
item?: ILibraryItemEx;
isModified?: boolean;
}
function EditorLibraryItem({ item, isModified }: EditorLibraryItemProps) {
const { getUserLabel, users } = useUsers();
const controller = useRSEdit();
const { accessLevel } = useAccessMode();
const intl = useIntl();
const ownerSelector = useDropdown();
const onSelectUser = useCallback(
(newValue: UserID) => {
console.log(newValue);
ownerSelector.hide();
if (newValue === item?.owner) {
return;
}
controller.setOwner(newValue);
},
[controller, item?.owner, ownerSelector]
);
return (
<div className='flex flex-col'>
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[6rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title='Изменить владельца'
noHover
onClick={() => ownerSelector.toggle()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
/>
{ownerSelector.isOpen ? (
<SelectUser
className='w-[20rem] sm:w-[22.5rem] text-sm'
items={users}
value={item?.owner ?? undefined}
onSelectValue={onSelectUser}
/>
) : null}
</div>
</Overlay>
) : null}
<LabeledValue className='sm:mb-1' label='Владелец' text={getUserLabel(item?.owner ?? null)} />
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[6rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title='Изменить редакторов'
noHover
onClick={() => controller.promptEditors()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
/>
</div>
</Overlay>
) : null}
<LabeledValue id='editor_stats' className='sm:mb-1' label='Редакторы' text={item?.editors.length ?? 0} />
<Tooltip anchorSelect='#editor_stats' layer='z-modal-tooltip'>
<InfoUsers items={item?.editors ?? []} prefix={prefixes.user_editors} />
</Tooltip>
<LabeledValue id='sub_stats' className='sm:mb-1' label='Отслеживают' text={item?.subscribers.length ?? 0} />
<Tooltip anchorSelect='#sub_stats' layer='z-modal-tooltip'>
<InfoUsers items={item?.subscribers ?? []} prefix={prefixes.user_subs} />
</Tooltip>
<LabeledValue
className='sm:mb-1'
label='Дата обновления'
text={item ? new Date(item?.time_update).toLocaleString(intl.locale) : ''}
/>
<LabeledValue label='Дата создания' text={item ? new Date(item?.time_create).toLocaleString(intl.locale) : ''} />
</div>
);
}
export default EditorLibraryItem;

View File

@ -2,7 +2,6 @@
import clsx from 'clsx';
import InfoLibraryItem from '@/components/info/InfoLibraryItem';
import Divider from '@/components/ui/Divider';
import FlexColumn from '@/components/ui/FlexColumn';
import AnimateFade from '@/components/wrap/AnimateFade';
@ -10,6 +9,7 @@ import { useAuth } from '@/context/AuthContext';
import { useRSForm } from '@/context/RSFormContext';
import { globals } from '@/utils/constants';
import EditorLibraryItem from './EditorLibraryItem';
import FormRSForm from './FormRSForm';
import RSFormStats from './RSFormStats';
import RSFormToolbar from './RSFormToolbar';
@ -56,7 +56,7 @@ function EditorRSForm({ isModified, onDestroy, setIsModified }: EditorRSFormProp
<Divider margins='my-1' />
<InfoLibraryItem item={schema} />
<EditorLibraryItem item={schema} isModified={isModified} />
</FlexColumn>
<RSFormStats stats={schema?.stats} />

View File

@ -11,7 +11,9 @@ function RSFormStats({ stats }: RSFormStatsProps) {
return null;
}
return (
<div className='flex flex-col gap-1 px-4 sm:mt-8 sm:w-[16rem]'>
<div className='flex flex-col sm:gap-1 px-4 sm:mt-8 sm:w-[16rem]'>
<Divider margins='my-2' className='sm:hidden' />
<LabeledValue id='count_all' label='Всего конституент ' text={stats.count_all} />
<LabeledValue id='count_errors' label='Некорректных' text={stats.count_errors} />
{stats.count_property !== 0 ? (

View File

@ -6,7 +6,9 @@ import { IconDestroy, IconDownload, IconFollow, IconFollowOff, IconSave, IconSha
import BadgeHelp from '@/components/info/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import { useAccessMode } from '@/context/AccessModeContext';
import { HelpTopic } from '@/models/miscellaneous';
import { UserLevel } from '@/models/user';
import { prepareTooltip } from '@/utils/labels';
import { useRSEdit } from '../RSEditContext';
@ -22,6 +24,7 @@ interface RSFormToolbarProps {
function RSFormToolbar({ modified, anonymous, subscribed, onSubmit, onDestroy }: RSFormToolbarProps) {
const controller = useRSEdit();
const { accessLevel } = useAccessMode();
const canSave = useMemo(() => modified && !controller.isProcessing, [modified, controller.isProcessing]);
return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='cc-icons'>
@ -61,7 +64,7 @@ function RSFormToolbar({ modified, anonymous, subscribed, onSubmit, onDestroy }:
<MiniButton
title='Удалить схему'
icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={!controller.isContentEditable || controller.isProcessing}
disabled={!controller.isContentEditable || controller.isProcessing || accessLevel < UserLevel.OWNER}
onClick={onDestroy}
/>
) : null}

View File

@ -21,14 +21,14 @@ import DlgConstituentaTemplate from '@/dialogs/DlgConstituentaTemplate';
import DlgCreateCst from '@/dialogs/DlgCreateCst';
import DlgCreateVersion from '@/dialogs/DlgCreateVersion';
import DlgDeleteCst from '@/dialogs/DlgDeleteCst';
import DlgEditEditors from '@/dialogs/DlgEditEditors';
import DlgEditVersions from '@/dialogs/DlgEditVersions';
import DlgEditWordForms from '@/dialogs/DlgEditWordForms';
import DlgInlineSynthesis from '@/dialogs/DlgInlineSynthesis';
import DlgRenameCst from '@/dialogs/DlgRenameCst';
import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import { IVersionData } from '@/models/library';
import { UserAccessMode } from '@/models/miscellaneous';
import { IVersionData, VersionID } from '@/models/library';
import {
ConstituentaID,
CstType,
@ -45,6 +45,7 @@ import {
TermForm
} from '@/models/rsform';
import { generateAlias } from '@/models/rsformAPI';
import { UserID, UserLevel } from '@/models/user';
import { EXTEOR_TRS_FILE } from '@/utils/constants';
import { promptUnsaved } from '@/utils/utils';
@ -58,13 +59,17 @@ interface IRSEditContext {
canProduceStructure: boolean;
nothingSelected: boolean;
setOwner: (newOwner: UserID) => void;
promptEditors: () => void;
toggleSubscribe: () => void;
setSelected: React.Dispatch<React.SetStateAction<ConstituentaID[]>>;
select: (target: ConstituentaID) => void;
deselect: (target: ConstituentaID) => void;
toggleSelect: (target: ConstituentaID) => void;
deselectAll: () => void;
viewVersion: (version?: number, newTab?: boolean) => void;
viewVersion: (version?: VersionID, newTab?: boolean) => void;
createVersion: () => void;
restoreVersion: () => void;
editVersions: () => void;
@ -81,7 +86,6 @@ interface IRSEditContext {
promptClone: () => void;
promptUpload: () => void;
share: () => void;
toggleSubscribe: () => void;
download: () => void;
reindex: () => void;
@ -123,20 +127,17 @@ export const RSEditState = ({
const router = useConceptNavigation();
const { user } = useAuth();
const { adminMode } = useConceptOptions();
const { mode, setMode } = useAccessMode();
const { accessLevel, setAccessLevel } = useAccessMode();
const model = useRSForm();
const isMutable = useMemo(() => {
return (
mode !== UserAccessMode.READER && ((model.isOwned || (mode === UserAccessMode.ADMIN && user?.is_staff)) ?? false)
);
}, [user?.is_staff, mode, model.isOwned]);
const isMutable = useMemo(() => accessLevel > UserLevel.READER, [accessLevel]);
const isContentEditable = useMemo(() => isMutable && !model.isArchive, [isMutable, model.isArchive]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]);
const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false);
const [showDeleteCst, setShowDeleteCst] = useState(false);
const [showEditEditors, setShowEditEditors] = useState(false);
const [showEditTerm, setShowEditTerm] = useState(false);
const [showSubstitute, setShowSubstitute] = useState(false);
const [showCreateVersion, setShowCreateVersion] = useState(false);
@ -154,20 +155,27 @@ export const RSEditState = ({
useLayoutEffect(
() =>
setMode(prev => {
if (user?.is_staff && (prev === UserAccessMode.ADMIN || adminMode)) {
return UserAccessMode.ADMIN;
setAccessLevel(prev => {
if (
prev === UserLevel.EDITOR &&
(model.isOwned || user?.is_staff || (user && model.schema?.editors.includes(user.id)))
) {
return UserLevel.EDITOR;
} else if (user?.is_staff && (prev === UserLevel.ADMIN || adminMode)) {
return UserLevel.ADMIN;
} else if (model.isOwned) {
return UserAccessMode.OWNER;
return UserLevel.OWNER;
} else if (user?.id && model.schema?.editors.includes(user?.id)) {
return UserLevel.EDITOR;
} else {
return UserAccessMode.READER;
return UserLevel.READER;
}
}),
[model.schema, setMode, model.isOwned, user, adminMode]
[model.schema, setAccessLevel, model.isOwned, user, adminMode]
);
const viewVersion = useCallback(
(version?: number, newTab?: boolean) => router.push(urls.schema(model.schemaID, version), newTab),
(version?: VersionID, newTab?: boolean) => router.push(urls.schema(model.schemaID, version), newTab),
[router, model]
);
@ -270,7 +278,7 @@ export const RSEditState = ({
);
const handleDeleteVersion = useCallback(
(versionID: number) => {
(versionID: VersionID) => {
if (!model.schema) {
return;
}
@ -285,7 +293,7 @@ export const RSEditState = ({
);
const handleUpdateVersion = useCallback(
(versionID: number, data: IVersionData) => {
(versionID: VersionID, data: IVersionData) => {
if (!model.schema) {
return;
}
@ -473,6 +481,10 @@ export const RSEditState = ({
setShowClone(true);
}, [isModified]);
const promptEditors = useCallback(() => {
setShowEditEditors(true);
}, []);
const download = useCallback(() => {
if (isModified && !promptUnsaved()) {
return;
@ -504,6 +516,20 @@ export const RSEditState = ({
}
}, [model]);
const setOwner = useCallback(
(newOwner: UserID) => {
model.setOwner(newOwner, () => toast.success('Владелец обновлен'));
},
[model]
);
const setEditors = useCallback(
(newEditors: UserID[]) => {
model.setEditors(newEditors, () => toast.success('Редакторы обновлены'));
},
[model]
);
return (
<RSEditContext.Provider
value={{
@ -515,6 +541,10 @@ export const RSEditState = ({
canProduceStructure,
nothingSelected,
toggleSubscribe,
setOwner,
promptEditors,
setSelected: setSelected,
select: (target: ConstituentaID) => setSelected(prev => [...prev, target]),
deselect: (target: ConstituentaID) => setSelected(prev => prev.filter(id => id !== target)),
@ -540,7 +570,6 @@ export const RSEditState = ({
promptUpload: () => setShowUpload(true),
download,
share,
toggleSubscribe,
reindex,
reorder,
@ -619,6 +648,13 @@ export const RSEditState = ({
onUpdate={handleUpdateVersion}
/>
) : null}
{showEditEditors ? (
<DlgEditEditors
hideWindow={() => setShowEditEditors(false)}
editors={model.schema.editors}
setEditors={setEditors}
/>
) : null}
{showInlineSynthesis ? (
<DlgInlineSynthesis
receiver={model.schema}

View File

@ -9,6 +9,7 @@ import {
IconDestroy,
IconDownload,
IconEdit2,
IconEditor,
IconGenerateNames,
IconGenerateStructure,
IconInlineSynthesis,
@ -31,7 +32,7 @@ import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useRSForm } from '@/context/RSFormContext';
import useDropdown from '@/hooks/useDropdown';
import { UserAccessMode } from '@/models/miscellaneous';
import { UserLevel } from '@/models/user';
import { describeAccessMode, labelAccessMode } from '@/utils/labels';
import { useRSEdit } from './RSEditContext';
@ -46,7 +47,7 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
const { user } = useAuth();
const model = useRSForm();
const { mode, setMode } = useAccessMode();
const { accessLevel, setAccessLevel } = useAccessMode();
const schemaMenu = useDropdown();
const editMenu = useDropdown();
@ -107,9 +108,9 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
controller.inlineSynthesis();
}
function handleChangeMode(newMode: UserAccessMode) {
function handleChangeMode(newMode: UserLevel) {
accessMenu.hide();
setMode(newMode);
setAccessLevel(newMode);
}
function handleCreateNew() {
@ -165,7 +166,7 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
<DropdownButton
text='Удалить схему'
icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={controller.isProcessing}
disabled={controller.isProcessing || accessLevel < UserLevel.OWNER}
onClick={handleDelete}
/>
) : null}
@ -266,14 +267,16 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
noBorder
noOutline
tabIndex={-1}
title={`Режим ${labelAccessMode(mode)}`}
title={`Режим ${labelAccessMode(accessLevel)}`}
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
icon={
mode === UserAccessMode.ADMIN ? (
accessLevel === UserLevel.ADMIN ? (
<IconAdmin size='1.25rem' className='icon-primary' />
) : mode === UserAccessMode.OWNER ? (
) : accessLevel === UserLevel.OWNER ? (
<IconOwner size='1.25rem' className='icon-primary' />
) : accessLevel === UserLevel.EDITOR ? (
<IconEditor size='1.25rem' className='icon-primary' />
) : (
<IconReader size='1.25rem' className='icon-primary' />
)
@ -282,24 +285,31 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
/>
<Dropdown isOpen={accessMenu.isOpen}>
<DropdownButton
text={labelAccessMode(UserAccessMode.READER)}
title={describeAccessMode(UserAccessMode.READER)}
text={labelAccessMode(UserLevel.READER)}
title={describeAccessMode(UserLevel.READER)}
icon={<IconReader size='1rem' className='icon-primary' />}
onClick={() => handleChangeMode(UserAccessMode.READER)}
onClick={() => handleChangeMode(UserLevel.READER)}
/>
<DropdownButton
text={labelAccessMode(UserAccessMode.OWNER)}
title={describeAccessMode(UserAccessMode.OWNER)}
text={labelAccessMode(UserLevel.EDITOR)}
title={describeAccessMode(UserLevel.EDITOR)}
icon={<IconEditor size='1rem' className='icon-primary' />}
disabled={!model.isOwned && !model.schema?.editors.includes(user.id)}
onClick={() => handleChangeMode(UserLevel.EDITOR)}
/>
<DropdownButton
text={labelAccessMode(UserLevel.OWNER)}
title={describeAccessMode(UserLevel.OWNER)}
icon={<IconOwner size='1rem' className='icon-primary' />}
disabled={!model.isOwned}
onClick={() => handleChangeMode(UserAccessMode.OWNER)}
onClick={() => handleChangeMode(UserLevel.OWNER)}
/>
<DropdownButton
text={labelAccessMode(UserAccessMode.ADMIN)}
title={describeAccessMode(UserAccessMode.ADMIN)}
text={labelAccessMode(UserLevel.ADMIN)}
title={describeAccessMode(UserLevel.ADMIN)}
icon={<IconAdmin size='1rem' className='icon-primary' />}
disabled={!user?.is_staff}
onClick={() => handleChangeMode(UserAccessMode.ADMIN)}
onClick={() => handleChangeMode(UserLevel.ADMIN)}
/>
</Dropdown>
</div>

View File

@ -141,6 +141,8 @@ export const prefixes = {
topic_list: 'topic_list_',
topic_item: 'topic_item_',
library_list: 'library_list_',
user_subs: 'user_subs_',
user_editors: 'user_editors_',
wordform_list: 'wordform_list_',
rsedit_btn: 'rsedit_btn_',
dlg_cst_substitutes_list: 'dlg_cst_substitutes_list_'

View File

@ -12,8 +12,7 @@ import {
GraphColoring,
GraphSizing,
HelpTopic,
LibraryFilterStrategy,
UserAccessMode
LibraryFilterStrategy
} from '@/models/miscellaneous';
import { CstClass, CstType, ExpressionStatus, IConstituenta, IRSForm } from '@/models/rsform';
import {
@ -24,6 +23,7 @@ import {
RSErrorType,
TokenID
} from '@/models/rslang';
import { UserLevel } from '@/models/user';
/**
* Generates description for {@link IConstituenta}.
@ -773,29 +773,32 @@ export function describeRSError(error: IRSErrorDescription): string {
}
/**
* Retrieves label for {@link UserAccessMode}.
* Retrieves label for {@link UserLevel}.
*/
export function labelAccessMode(mode: UserAccessMode): string {
export function labelAccessMode(mode: UserLevel): string {
// prettier-ignore
switch (mode) {
case UserAccessMode.READER: return 'Читатель';
case UserAccessMode.OWNER: return 'Владелец';
case UserAccessMode.ADMIN: return 'Администратор';
case UserLevel.READER: return 'Читатель';
case UserLevel.EDITOR: return 'Редактор';
case UserLevel.OWNER: return 'Владелец';
case UserLevel.ADMIN: return 'Администратор';
}
}
/**
* Retrieves description for {@link UserAccessMode}.
* Retrieves description for {@link UserLevel}.
*/
export function describeAccessMode(mode: UserAccessMode): string {
export function describeAccessMode(mode: UserLevel): string {
// prettier-ignore
switch (mode) {
case UserAccessMode.READER:
case UserLevel.READER:
return 'Режим запрещает редактирование';
case UserAccessMode.OWNER:
return 'Режим редактирования владельцем';
case UserAccessMode.ADMIN:
return 'Режим редактирования администратором';
case UserLevel.EDITOR:
return 'Режим редактирования';
case UserLevel.OWNER:
return 'Режим владельца';
case UserLevel.ADMIN:
return 'Режим администратора';
}
}