diff --git a/rsconcept/frontend/index.html b/rsconcept/frontend/index.html index b2846a33..5c54ce31 100644 --- a/rsconcept/frontend/index.html +++ b/rsconcept/frontend/index.html @@ -5,7 +5,10 @@ - + diff --git a/rsconcept/frontend/src/components/Icons.tsx b/rsconcept/frontend/src/components/Icons.tsx index ccccbf21..5e93ab73 100644 --- a/rsconcept/frontend/src/components/Icons.tsx +++ b/rsconcept/frontend/src/components/Icons.tsx @@ -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 { diff --git a/rsconcept/frontend/src/components/info/InfoLibraryItem.tsx b/rsconcept/frontend/src/components/info/InfoLibraryItem.tsx deleted file mode 100644 index 4beee062..00000000 --- a/rsconcept/frontend/src/components/info/InfoLibraryItem.tsx +++ /dev/null @@ -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 ( -
- - - - - -
- ); -} - -export default InfoLibraryItem; diff --git a/rsconcept/frontend/src/components/info/InfoUsers.tsx b/rsconcept/frontend/src/components/info/InfoUsers.tsx new file mode 100644 index 00000000..eeea4a84 --- /dev/null +++ b/rsconcept/frontend/src/components/info/InfoUsers.tsx @@ -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 ( +
+ {items.map((user, index) => ( +
{getUserLabel(user)}
+ ))} +
+ ); +} + +export default InfoUsers; diff --git a/rsconcept/frontend/src/components/select/SelectConstituenta.tsx b/rsconcept/frontend/src/components/select/SelectConstituenta.tsx index 36d1b7c0..a4bdc8cb 100644 --- a/rsconcept/frontend/src/components/select/SelectConstituenta.tsx +++ b/rsconcept/frontend/src/components/select/SelectConstituenta.tsx @@ -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 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} /> ); diff --git a/rsconcept/frontend/src/components/select/SelectGrammeme.tsx b/rsconcept/frontend/src/components/select/SelectMultiGrammeme.tsx similarity index 77% rename from rsconcept/frontend/src/components/select/SelectGrammeme.tsx rename to rsconcept/frontend/src/components/select/SelectMultiGrammeme.tsx index b40295f6..fe8f89ca 100644 --- a/rsconcept/frontend/src/components/select/SelectGrammeme.tsx +++ b/rsconcept/frontend/src/components/select/SelectMultiGrammeme.tsx @@ -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, 'value' | 'onChange'> { +import { CProps } from '../props'; + +interface SelectMultiGrammemeProps + extends Omit, 'value' | 'onChange'>, + CProps.Styling { value: IGrammemeOption[]; setValue: React.Dispatch>; - className?: string; placeholder?: string; } -function SelectGrammeme({ value, setValue, ...restProps }: SelectGrammemeProps) { +function SelectMultiGrammeme({ value, setValue, ...restProps }: SelectMultiGrammemeProps) { const [options, setOptions] = useState([]); useEffect(() => { @@ -32,4 +35,4 @@ function SelectGrammeme({ value, setValue, ...restProps }: SelectGrammemeProps) ); } -export default SelectGrammeme; +export default SelectMultiGrammeme; diff --git a/rsconcept/frontend/src/components/select/SelectUser.tsx b/rsconcept/frontend/src/components/select/SelectUser.tsx new file mode 100644 index 00000000..3cd0c7d2 --- /dev/null +++ b/rsconcept/frontend/src/components/select/SelectUser.tsx @@ -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 ( + { + 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; diff --git a/rsconcept/frontend/src/components/select/SelectVersion.tsx b/rsconcept/frontend/src/components/select/SelectVersion.tsx index e14881be..e4cb349e 100644 --- a/rsconcept/frontend/src/components/select/SelectVersion.tsx +++ b/rsconcept/frontend/src/components/select/SelectVersion.tsx @@ -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) { diff --git a/rsconcept/frontend/src/components/ui/Divider.tsx b/rsconcept/frontend/src/components/ui/Divider.tsx index f6f786e2..1030cb9e 100644 --- a/rsconcept/frontend/src/components/ui/Divider.tsx +++ b/rsconcept/frontend/src/components/ui/Divider.tsx @@ -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 (
); } diff --git a/rsconcept/frontend/src/components/ui/LabeledValue.tsx b/rsconcept/frontend/src/components/ui/LabeledValue.tsx index e0322fe6..d267cdc2 100644 --- a/rsconcept/frontend/src/components/ui/LabeledValue.tsx +++ b/rsconcept/frontend/src/components/ui/LabeledValue.tsx @@ -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 ( -
+
{label} {text}
diff --git a/rsconcept/frontend/src/context/AccessModeContext.tsx b/rsconcept/frontend/src/context/AccessModeContext.tsx index 96d70bd2..531fe682 100644 --- a/rsconcept/frontend/src/context/AccessModeContext.tsx +++ b/rsconcept/frontend/src/context/AccessModeContext.tsx @@ -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>; + accessLevel: UserLevel; + setAccessLevel: React.Dispatch>; } const AccessContext = createContext(null); @@ -23,7 +23,7 @@ interface AccessModeStateProps { } export const AccessModeState = ({ children }: AccessModeStateProps) => { - const [mode, setMode] = useState(UserAccessMode.READER); + const [accessLevel, setAccessLevel] = useState(UserLevel.READER); - return {children}; + return {children}; }; diff --git a/rsconcept/frontend/src/context/RSFormContext.tsx b/rsconcept/frontend/src/context/RSFormContext.tsx index 98b5e440..d872a615 100644 --- a/rsconcept/frontend/src/context/RSFormContext.tsx +++ b/rsconcept/frontend/src/context/RSFormContext.tsx @@ -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) => void; - subscribe: (callback?: () => void) => void; - unsubscribe: (callback?: () => void) => void; download: (callback: DataCallback) => 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) => 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, diff --git a/rsconcept/frontend/src/dialogs/DlgDeleteCst/ConstituentsList.tsx b/rsconcept/frontend/src/dialogs/DlgDeleteCst/ConstituentsList.tsx index 6bb3575e..fb2bddfc 100644 --- a/rsconcept/frontend/src/dialogs/DlgDeleteCst/ConstituentsList.tsx +++ b/rsconcept/frontend/src/dialogs/DlgDeleteCst/ConstituentsList.tsx @@ -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; diff --git a/rsconcept/frontend/src/dialogs/DlgDeleteCst/DlgDeleteCst.tsx b/rsconcept/frontend/src/dialogs/DlgDeleteCst/DlgDeleteCst.tsx index e8d9c327..7cae9043 100644 --- a/rsconcept/frontend/src/dialogs/DlgDeleteCst/DlgDeleteCst.tsx +++ b/rsconcept/frontend/src/dialogs/DlgDeleteCst/DlgDeleteCst.tsx @@ -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 { - 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(); diff --git a/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx b/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx new file mode 100644 index 00000000..60028634 --- /dev/null +++ b/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx @@ -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(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( + () => selected.includes(user.id))} onDelete={onDeleteEditor} />, + [users, selected, onDeleteEditor] + ); + + return ( + +
+ Всего редакторов [{selected.length}] + } + disabled={selected.length === 0} + onClick={() => setSelected([])} + /> +
+ + {usersTable} + +
+
+
+ ); +} + +export default DlgEditEditors; diff --git a/rsconcept/frontend/src/dialogs/DlgEditEditors/UsersTable.tsx b/rsconcept/frontend/src/dialogs/DlgEditEditors/UsersTable.tsx new file mode 100644 index 00000000..0cfc0524 --- /dev/null +++ b/rsconcept/frontend/src/dialogs/DlgEditEditors/UsersTable.tsx @@ -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(); + +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 => ( +
+ } + onClick={() => onDelete(props.row.original.id)} + /> +
+ ) + }) + ], + [onDelete] + ); + + return ( + + ); +} + +export default UsersTable; diff --git a/rsconcept/frontend/src/dialogs/DlgEditEditors/index.tsx b/rsconcept/frontend/src/dialogs/DlgEditEditors/index.tsx new file mode 100644 index 00000000..4afa4b72 --- /dev/null +++ b/rsconcept/frontend/src/dialogs/DlgEditEditors/index.tsx @@ -0,0 +1 @@ +export { default } from './DlgEditEditors'; diff --git a/rsconcept/frontend/src/dialogs/DlgEditReference/EntityTab.tsx b/rsconcept/frontend/src/dialogs/DlgEditReference/EntityTab.tsx index 6e66e193..5258aee9 100644 --- a/rsconcept/frontend/src/dialogs/DlgEditReference/EntityTab.tsx +++ b/rsconcept/frontend/src/dialogs/DlgEditReference/EntityTab.tsx @@ -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
- { - 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 } diff --git a/rsconcept/frontend/src/models/userAPI.ts b/rsconcept/frontend/src/models/userAPI.ts new file mode 100644 index 00000000..f0c2aaba --- /dev/null +++ b/rsconcept/frontend/src/models/userAPI.ts @@ -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); +} diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx index 9ec07153..b47c5c4f 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormCard.tsx @@ -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 ( -
-

Карточка схемы

- -

Карточка содержит общую информацию и статистику

-

Карточка позволяет управлять атрибутами схемы и

+
+

Карточка схемы

-

Управление

-
  • сохранить изменения: Ctrl + S
  • -
  • Владелец обладает правом редактирования
  • -
  • Общедоступные схемы доступны для всех
  • -
  • Неизменные схемы редактируют только администраторы
  • -
  • Клонировать – создать копию схемы
  • -
  • Отслеживание – схема в персональном списке
  • -
  • Загрузить/Выгрузить – взаимодействие с Экстеор
  • -
  • Удалить – полностью удаляет схему из базы Портала
  • +

    Карточка содержит общую информацию и статистику

    +

    + Карточка позволяет управлять атрибутами схемы и +

    +

    + Карточка позволяет назначать Редакторов +

    +

    + Карточка позволяет изменить Владельца +

    -

    Версионирование

    -
  • Создать версию можно только из актуальной схемы
  • -
  • Загрузить версию в актуальную схему
  • -
  • Редактировать атрибуты версий
  • -
    ); +

    Управление

    +
  • + сохранить изменения: Ctrl + S +
  • +
  • + Редактор обладает правом редактирования +
  • +
  • + Владелец обладает полным доступом к схеме +
  • +
  • + Общедоступные схемы видны всем посетителям +
  • +
  • + Неизменные схемы редактируют только администраторы +
  • +
  • + Клонировать – создать копию схемы +
  • +
  • + Отслеживание – схема в персональном списке +
  • +
  • + Загрузить/Выгрузить – взаимодействие с Экстеор +
  • +
  • + Удалить – полностью удаляет схему из базы Портала +
  • +
    + ); } export default HelpRSFormCard; diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx index f490ff5d..5ab61365 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpRSFormMenu.tsx @@ -6,6 +6,7 @@ import { IconDestroy, IconDownload, IconEdit2, + IconEditor, IconMenu, IconOwner, IconReader, @@ -75,17 +76,22 @@ function HelpRSFormMenu() { просмотр архивной версии. Переход к актуальной версии
  • - режим "только чтение" + режим Читатель
  • - режим "редактор" + режим Редактор
  • - режим "администратор" + режим Владелец +
  • +
  • + режим Администратор
  • +

    Нижестоящие в списке режимы работы включают все права и доступные функции вышестоящих

    +

    операции над концептуальной схемой описаны в{' '} . diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpTermGraph.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpTermGraph.tsx index d3f4e217..07631625 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpTermGraph.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpTermGraph.tsx @@ -24,56 +24,90 @@ import { function HelpTermGraph() { const { colors } = useConceptOptions(); - // prettier-ignore return ( -

    -
    -
    -

    Настройка графа

    -
  • Цвет – покраска узлов
  • -
  • Граф – расположение
  • -
  • Размер – размер узлов
  • -
  • Отображение текста
  • -
  • Скрыть порожденные
  • -
  • Вращение 3D
  • +
    +
    +
    +

    Настройка графа

    +
  • Цвет – покраска узлов
  • +
  • Граф – расположение
  • +
  • Размер – размер узлов
  • +
  • + Отображение текста +
  • +
  • + Скрыть порожденные +
  • +
  • + Вращение 3D +
  • +
    + + + +
    +

    Изменение узлов

    +
  • Клик на конституенту – выделение
  • +
  • + Ctrl + клик – выбор фокус-конституенты +
  • +
  • + Esc – сбросить выделение +
  • +
  • + Двойной клик – редактирование +
  • +
  • + Delete – удалить выбранные +
  • +
  • + Новая со ссылками на выделенные +
  • +
    - - -
    -

    Изменение узлов

    -
  • Клик на конституенту – выделение
  • -
  • Ctrl + клик – выбор фокус-конституенты
  • -
  • Esc – сбросить выделение
  • -
  • Двойной клик – редактирование
  • -
  • Delete – удалить выбранные
  • -
  • Новая со ссылками на выделенные
  • + + +
    +
    +

    Общие

    +
  • + Открыть настройки +
  • +
  • + Вписать граф в экран +
  • +
  • + Сохранить в формат PNG +
  • +
    + + + +
    +

    Выделение

    +
  • + все влияющие +
  • +
  • + все зависимые +
  • +
  • + зависимые только от выделенных +
  • +
  • + входящие напрямую +
  • +
  • + исходящие напрямую +
  • +
  • + выделить +
  • +
    - - - -
    -
    -

    Общие

    -
  • Открыть настройки
  • -
  • Вписать граф в экран
  • -
  • Сохранить в формат PNG
  • -
    - - - -
    -

    Выделение

    -
  • все влияющие
  • -
  • все зависимые
  • -
  • зависимые только от выделенных
  • -
  • входящие напрямую
  • -
  • исходящие напрямую
  • -
  • выделить
  • -
    -
    -
    ); + ); } export default HelpTermGraph; diff --git a/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx b/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx index 41fca240..da99607a 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/items/HelpVersions.tsx @@ -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 ( -
    +

    Версионирование схем

    - Версионирование позволяет сохранить текущее состояние схемы под определенным именем (версией) и использовать - ссылку на него для совместной работы. После создания версии ее содержание изменить нельзя + Версионирование доступно Редакторам.

    -
  • Владелец обладает правом редактирования названий и создания новых версий
  • +

    Версионирование сохраняет текущее состояние схемы под определенным именем (версией) с доступом по ссылке.

    +

    После создания версии ее содержание изменить нельзя.

    + +

    Действия

  • - Управление версиями происходит в + Поделиться включает версию в ссылку +
  • +
  • + Загрузить версию в актуальную схему +
  • +
  • + Создать версию можно только из актуальной + схемы +
  • + +
  • + Редактировать атрибуты версий
  • -
  • Функция Поделиться включает версию в ссылку
  • ); } diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorLibraryItem.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorLibraryItem.tsx new file mode 100644 index 00000000..9785eede --- /dev/null +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorLibraryItem.tsx @@ -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 ( +
    + {accessLevel >= UserLevel.OWNER ? ( + +
    + ownerSelector.toggle()} + icon={} + disabled={isModified || controller.isProcessing} + /> + {ownerSelector.isOpen ? ( + + ) : null} +
    +
    + ) : null} + + + {accessLevel >= UserLevel.OWNER ? ( + +
    + controller.promptEditors()} + icon={} + disabled={isModified || controller.isProcessing} + /> +
    +
    + ) : null} + + + + + + + + + + + + +
    + ); +} + +export default EditorLibraryItem; diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorRSForm.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorRSForm.tsx index 65220651..6e7aaef9 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorRSForm.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/EditorRSForm.tsx @@ -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 - + diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormStats.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormStats.tsx index 838f0a72..fd761bd1 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormStats.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormStats.tsx @@ -11,7 +11,9 @@ function RSFormStats({ stats }: RSFormStatsProps) { return null; } return ( -
    +
    + + {stats.count_property !== 0 ? ( diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx index 1a55ee95..795b786b 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx @@ -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 ( @@ -61,7 +64,7 @@ function RSFormToolbar({ modified, anonymous, subscribed, onSubmit, onDestroy }: } - disabled={!controller.isContentEditable || controller.isProcessing} + disabled={!controller.isContentEditable || controller.isProcessing || accessLevel < UserLevel.OWNER} onClick={onDestroy} /> ) : null} diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx index 888f8064..c0e9653b 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx @@ -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>; 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 ( 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 ? ( + setShowEditEditors(false)} + editors={model.schema.editors} + setEditors={setEditors} + /> + ) : null} {showInlineSynthesis ? ( } - 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 ? ( - ) : mode === UserAccessMode.OWNER ? ( + ) : accessLevel === UserLevel.OWNER ? ( + ) : accessLevel === UserLevel.EDITOR ? ( + ) : ( ) @@ -282,24 +285,31 @@ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) { /> } - onClick={() => handleChangeMode(UserAccessMode.READER)} + onClick={() => handleChangeMode(UserLevel.READER)} /> } + disabled={!model.isOwned && !model.schema?.editors.includes(user.id)} + onClick={() => handleChangeMode(UserLevel.EDITOR)} + /> + } disabled={!model.isOwned} - onClick={() => handleChangeMode(UserAccessMode.OWNER)} + onClick={() => handleChangeMode(UserLevel.OWNER)} /> } disabled={!user?.is_staff} - onClick={() => handleChangeMode(UserAccessMode.ADMIN)} + onClick={() => handleChangeMode(UserLevel.ADMIN)} />
    diff --git a/rsconcept/frontend/src/utils/constants.ts b/rsconcept/frontend/src/utils/constants.ts index 4756aa39..03502ba8 100644 --- a/rsconcept/frontend/src/utils/constants.ts +++ b/rsconcept/frontend/src/utils/constants.ts @@ -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_' diff --git a/rsconcept/frontend/src/utils/labels.ts b/rsconcept/frontend/src/utils/labels.ts index 4f6704fe..34f3dcbc 100644 --- a/rsconcept/frontend/src/utils/labels.ts +++ b/rsconcept/frontend/src/utils/labels.ts @@ -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 'Режим администратора'; } }